Instrument an agent

Wrapping a client gets you routing. Declaring structure is what stops the optimizer from inventing a shape your program never had.

instrument-an-agent.ts
import OpenAI from 'openai';
import { Shimmy } from '@rfa-labs/shimmy';

const shimmy = new Shimmy();
const openai = shimmy.wrap(new OpenAI());

interface Plan {
  topics: string[];
}

async function makePlan(): Promise<Plan> {
  const res = await shimmy.step('plan', { kind: 'planning' }, () =>
    openai.chat.completions.create({
      model: 'auto',
      messages: [{ role: 'user', content: 'List three topics to research.' }],
    }),
  );
  const raw = res.choices[0]?.message.content ?? '{"topics":[]}';
  return { topics: JSON.parse(raw).topics };
}

await shimmy.run('research', async () => {
  const plan = await makePlan();

  // The case a proxy cannot see. These three run concurrently, so the edge
  // receives them interleaved and — inferring from arrival order — records a
  // chain through them. Worse, they share a step shape, so the repeat also
  // reads as a loop that does not exist.
  //
  // Opened inside the same scope, they are siblings: one parent, three children,
  // no order implied between them.
  await Promise.all(
    plan.topics.map((topic) =>
      shimmy.step('research-topic', { kind: 'question_answering' }, () =>
        openai.chat.completions.create({
          model: 'auto',
          messages: [{ role: 'user', content: `Research: ${topic}` }],
        }),
      ),
    ),
  );

  // A loop index distinguishes a genuine refinement loop from a step that
  // merely recurs — the distinction the server cannot draw from call sequence.
  for (let i = 0; i < 3; i++) {
    await shimmy.step('refine', { kind: 'generation', loopIndex: i }, () =>
      openai.chat.completions.create({
        model: 'auto',
        messages: [{ role: 'user', content: 'Refine the draft.' }],
      }),
    );
  }
});
Compiled in CI against the real SDK.

The parallel case

The Promise.all above is the whole reason this guide exists.

Three steps opened inside one scope are siblings: they share a parent and imply no order between them. Run concurrently, they reach the edge interleaved, in whatever order they finished — and inference from arrival order produces two wrong things:

  1. Edges between calls that never invoked one another.
  2. A cycle, when the siblings share a step shape. Mapping one summarizer across ten documents is the ordinary case, and it reads as a loop.

Declared, the graph shows one parent with N children, which is what your code actually does.

Rust needs an explicit parent

This is the one place the SDKs genuinely differ, and it is stated rather than hidden.

tokio::spawn starts a task with empty locals — precisely the fan-out case that matters most. So capture the parent before spawning and hand it over:

let parent = shimmy.current();

tokio::spawn(async move {
    let _step = shimmy.step("worker").parent(parent).open();
    // …
});

One extra argument, for a run tree that stays correct across the task boundary. An SDK that appeared to propagate and did not would produce exactly the phantom-chain graphs this exists to prevent.

Loops and retries

A step that recurs is not necessarily a loop, and the edge cannot tell the difference from call sequence alone.

  • loopIndex marks a genuine iteration. A map over ten items is ten iterations of one step, not a cycle.
  • isRetry marks a re-attempt. At the edge a retry is indistinguishable from a fresh call, so it inflates step counts and injects a false back-edge — and reported honestly, it is also free evidence that the previous attempt was inadequate.

Declaring what a step is for

kind replaces the server’s keyword scan with fact:

KindFor
classificationPick a label from a known set
extractionPull structured fields out of supplied text
summarizationCondense supplied text
formattingReshape without adding judgment
translationConvert between languages or notations
question_answeringAnswer from supplied context
generationOpen-ended prose a person will read
planningDecide the next action
reasoningMulti-hop inference where the chain is the work
codeWrite or modify code
evaluationGrade or verify another output

The other per-step option is mode: "discover" puts this step into the discovery search even when the request’s model names one — the pin then acts as the search’s cost ceiling — and "off" excludes it even when the agent has discovery enabled.

Run ids resolve themselves

You do not pass one. Opening a run resolves its id in order:

  1. An active OpenTelemetry span’s trace id, when your application has one. Your run and your existing traces become the same unit of work — and because your web framework already parsed the inbound traceparent into that span, a distributed trace carries through without the SDK touching a header.
  2. A fresh UUID otherwise, which is correct for a standalone script.

When to override it

One case: a request is not one run. A nightly job that processes 10,000 tickets is 10,000 runs, and only your code knows where each begins.

await shimmy.run('nightly-reindex', fn, { id: `job-${jobId}` });

A declared id is also how a conversation spanning several requests reads as one run — pass the conversation id, and it survives restarts, replicas and deploys.

Next