Instrument an agent
Wrapping a client gets you routing. Declaring structure is what stops the optimizer from inventing a shape your program never had.
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.' }],
}),
);
}
});
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:
- Edges between calls that never invoked one another.
- 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.
loopIndexmarks a genuine iteration. A map over ten items is ten iterations of one step, not a cycle.isRetrymarks 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:
| Kind | For |
|---|---|
classification | Pick a label from a known set |
extraction | Pull structured fields out of supplied text |
summarization | Condense supplied text |
formatting | Reshape without adding judgment |
translation | Convert between languages or notations |
question_answering | Answer from supplied context |
generation | Open-ended prose a person will read |
planning | Decide the next action |
reasoning | Multi-hop inference where the chain is the work |
code | Write or modify code |
evaluation | Grade 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:
- 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
traceparentinto that span, a distributed trace carries through without the SDK touching a header. - 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
- Report outcomes — the next rung, and
the one that shrinks
exploration_cost. - Framework adapters — if LangChain or LlamaIndex owns your client.