Pipelines & Trajectories
A pipeline is an entrypoint — a single definePipeline whose runner composes your steps in code. There's no separate graph to declare and no run order to maintain: you call the units you need, and the shape of the run is just the shape of the code that produced it.
import { definePipeline } from '@stepbook/runner'
import parse from './steps/parse.step'
import analyze from './steps/analyze.step'
export default definePipeline({
name: 'demo',
runner: async (input) => {
const parsed = await parse(input)
return analyze(parsed)
},
})stepbook run executes this runner. A pipeline composes units in a line; an agent composes them in a loop. Either way the wiring is ordinary code — variables, conditionals, loops — not a dependency table stepbook topologically sorts for you.
Composing units
A step (defineStep) is a callable unit. There are two ways to invoke one from the entrypoint:
Callable (recommended). Import the step and call it — fully typed on both input and output:
tsimport parse from './steps/parse.step' const parsed = await parse(input)By name.
ctx.run('parse', input)invokes the unit registered under that name. Untyped, but handy when the unit is chosen dynamically.
Both record the call the same way. Composition is just the runner deciding what to call and with what — pass one unit's output straight into the next for a line, or drive ctx.run inside a for loop for an agent.
The trajectory
A run is a tree. The entrypoint is the root; every unit call is a first-class node carrying its own input, output, checks, telemetry, and cache/replay state. Nested calls (a unit that itself calls another unit) nest in the tree.
Each node is keyed by the unit it ran. A unit that runs once among its siblings keeps its bare name (parse); a unit that runs repeatedly — the turns of an agent loop — is numbered per occurrence (decide#0, decide#1, …). Running the demo pipeline:
$ stepbook run inputs/hello.json
loaded pipeline · demo (entrypoint)
input: inputs/hello.json (120 B)
run 8f3c1a2 · 2026-07-01T12:00:00.000Z
• parse (0ms)
• analyze (0ms)
10 checks pass · 0 failAn agent that loops over a decide unit shows one node per turn instead:
• decide#0 (412ms)
• decide#1 (388ms)
• decide#2 (cached)The trajectory is ground truth — it records exactly what ran, in the order it ran, with real inputs and outputs. Assertions, telemetry, and the cache all operate per node, so a five-turn loop is five inspectable nodes, not one opaque step.
The static preview
Before a pipeline has ever run, stepbook can still show you its shape. It scans the entrypoint source and picks out the units it composes: ctx.run('name', …) calls and calls to identifiers imported from *.step modules. Straight-line and branching composition resolves cleanly; a call sitting inside a loop is marked dynamic, since its repeat count is only known at run time.
This preview fills the tree before the first run — but it's only a preview. The runtime trajectory always wins: if the code takes a branch the scanner couldn't predict, or loops more times than expected, the recorded run is what you inspect and assert over.
Caching & re-runs
Each node is cached independently, keyed on its unit's input plus its own source file. A unit whose input and source are unchanged replays for free; editing a unit's file invalidates that node and re-runs it. Because the key is per node, changing one unit never forces the others to re-execute. See The Cache for the full semantics, including --no-cache and --from <unit> for forcing fresh work.
Lifecycle hooks
An entrypoint can define hooks that fire around an entire run:
export default definePipeline({
name: 'my-pipeline',
runner: async (input) => {
/* … */
},
beforeAll: async ({ stateDir }) => {
// runs once before the runner — load env, warm a connection
},
afterAll: async ({ stateDir }) => {
// runs once after the runner (success or failure) — teardown
},
})beforeAll runs before the runner starts; afterAll runs after it finishes, whether the run succeeded, threw, or was aborted.
Error handling
If a unit throws, the error is recorded on its node, the throw propagates up through the runner (so anything the runner would have done next simply doesn't run), and the partial trajectory is still persisted. You can inspect exactly which node broke — and with what input — in the dashboard or with stepbook q.