Building an Agent
An agent is just a pipeline whose runner loops. There's no separate agent construct to learn — you write a definePipeline entrypoint, and instead of calling your units in a straight line you call them inside a while/for. A pipeline composes units in a line; an agent composes the same units in a loop. Same construct, same trajectory, same cache.
A ReAct loop
Say you have two units — decide (an llm step that picks the next move) and act (a derive step that runs a tool) — each in its own *.step.ts file. The agent is the entrypoint that drives them:
// pipeline.ts — the agent entrypoint
import { definePipeline } from '@stepbook/runner'
import decide from './steps/decide.step'
import act from './steps/act.step'
import type { Turn } from './steps/decide.step'
export default definePipeline({
name: 'react-agent',
runner: async (input: { task: string }) => {
const transcript: Turn[] = []
for (let n = 0; n < 6; n++) {
// Callable unit — fully typed on input and output.
const decision = await decide({ task: input.task, transcript })
if (decision.action === 'final_answer') {
return { answer: decision.answer, transcript }
}
const result = await act(decision)
transcript.push({ decision, result })
}
return { answer: null, transcript } // hit the turn budget
},
})That's the whole agent. The for loop is the control flow; decide and act are ordinary units you can run, snapshot, assert, and query on their own. The turn budget (n < 6) is just code — an infinite-loop guard you write, not a framework knob.
Invoking units
await decide(input) (the imported callable) is the recommended form — it's typed end to end. When you need to pick a unit dynamically, ctx.run('decide', input) invokes it by name instead. Both record the call identically; see Composing units.
Every turn is a trajectory node
A run is a tree. Because the loop calls decide and act repeatedly, each call is recorded as its own first-class node, auto-numbered per unit:
$ stepbook run inputs/task.json
loaded pipeline · react-agent (entrypoint)
run 4a91c07 · 2026-07-03T12:00:00.000Z
• decide#0 (412ms)
• act#0 (2ms)
• decide#1 (388ms)
• act#1 (2ms)
• decide#2 (355ms) final answer
6 checks pass · 0 failA unit that runs once keeps its bare name (parse); a unit that runs repeatedly — the turns of the loop — is numbered per occurrence (decide#0, decide#1, …). Each node carries that turn's real input, output, checks, and telemetry. A five-turn loop is five inspectable nodes, not one opaque "agent" blob — so when turn 3 goes wrong you can open exactly that node, with exactly the transcript it saw.
If decide itself calls another unit, that call nests under the turn it belongs to. The tree mirrors the code.
Turns replay for free
Each unit call is cached and replayed independently, keyed on its input plus its own source file (see The Cache). On a re-run, an unchanged turn is a cacheHit — its runner never executes, so the loop's code re-runs against frozen model responses at no cost:
$ stepbook run inputs/task.json
• decide#0 (cached)
• act#0 (cached)
• decide#1 (cached)
...Edit decide.step.ts and only the decide turns re-run; act stays cached. That's what makes an agent loop a real regression test: replay the recorded trajectory to check your control-flow edits without re-paying for the model. Force fresh model calls with stepbook run --no-cache, or re-run from a single unit with --from decide.
Recording an inline model call
Sometimes the thing you loop over isn't worth promoting to its own unit — a single model call inside the runner, say. For that, ctx.cached(subkey, input, fn) records one inline sub-computation through the same cache, without making it a separate *.step.ts:
runner: async (input, ctx) => {
const transcript: Turn[] = []
for (let n = 0; n < 6; n++) {
const decision = await ctx.cached(`turn-${n}`, { task: input.task, transcript }, () =>
decideOnce({ task: input.task, transcript, signal: ctx.signal }),
)
// …
}
}ctx.cached is the lower-level primitive; a callable unit is the same idea promoted to a first-class trajectory node with its own schema, assertions, and canvas views. Reach for a unit when you want the turn to be independently inspectable and asserted; reach for ctx.cached when it's a private detail of one runner.
Asserting over the loop
Because assertions live on each unit and run per call, the per-turn checks come for free — decide's assertions run on every decide#n. To assert over the whole trajectory (ordering, tool budgets, "looks a fact up before calculating"), make the entrypoint return the transcript and add a small eval unit that takes it as input:
// steps/trajectory-check.step.ts
export default defineStep({
type: 'eval',
runner: async (traj: { transcript: Turn[] }) => traj,
assertions: (o) => [
{
name: 'ends with a final answer',
pass: o.transcript.at(-1)?.decision.action === 'final_answer',
},
{ name: 'stays within the turn budget', pass: o.transcript.length <= 6 },
],
})Call it at the end of the loop (await trajectoryCheck({ transcript })) and its checks land on the run like any other node. See Assertions & Schemas.
When the loop already exists
So far the loop is the definePipeline runner — you authored it in Stepbook. But often the loop is already production code you don't want to rewrite as a pipeline. You don't have to. Invert its one volatile boundary — the model calls — behind a seam, and let Stepbook inject recording units there.
Keep the loop framework-agnostic (it never imports @stepbook/*). Its model calls become a plain record of functions it depends on, defaulted to the real ones:
// engine/agent.ts — no Stepbook here; this is your production loop
export type ModelSeams = {
decide: (input: DecideIn) => Promise<Decision>
// …the other volatile calls
}
export async function runAgent(input: Task, opts: { seams?: ModelSeams } = {}) {
const m = opts.seams ?? realSeams // production injects the real model calls
// …the loop calls m.decide(...) — pure everywhere else
}A defineStep callable already has the seam's exact shape — (input) => Promise<output> — so the Stepbook side just supplies recording units and delegates the loop to runAgent:
// pipeline.ts — the only place the engine and Stepbook meet (arrow points here → engine)
import { runAgent, type ModelSeams } from './engine/agent'
import decide from './steps/decide.step' // a recording unit that calls the *real* decide
const seams: ModelSeams = { decide /* … */ }
export default definePipeline({
name: 'agent',
runner: (input) => runAgent(input, { seams }),
})runEntrypoint opens the ambient run-context, so every seam call the engine makes records as a first-class node (decide#0, decide#1, …) — the same trajectory, replay, and per-turn assertions as the authored-in-Stepbook loop, but the engine stays yours. Two rules keep it honest: the recording unit delegates to the real call (so the eval can't drift from production), and you invert only the volatile calls — the deterministic parts of the loop stay concrete.
See the discovery-agent example for the full pattern (engine + injected seams + plain vitest control-flow tests from the same seam).
Next
- Pipelines & Trajectories — the run-as-a-tree model in full, including the static preview and error handling.
- The Cache — per-unit-call record/replay.
- The Dashboard — the trajectory as a clickable tree of turns.
- Examples:
react-agent(loop authored in Stepbook) anddiscovery-agent(existing loop observed via injected seams).