Step-Author API
Everything you need to write steps is exported from @stepbook/runner. This page documents the public step-author surface — the functions and types you use in *.step.ts and pipeline.ts files.
import { defineStep, definePipeline, step, assertions } from '@stepbook/runner'defineStep<I, O, C>(registration)
Define a unit — the default export of any *.step.ts file. Returns a callable: a StepRegistration with the runner attached as a function, so you can invoke it directly (await parse(input)) when composing the pipeline.
export default defineStep<ParseInput, ParseOutput, { lowercase?: boolean }>({
name: 'parse',
type: 'parse',
schema: ParseOutputSchema,
config: { lowercase: false },
deterministic: true,
runner: async (input, ctx) => {
/* … */
},
assertions: (o) => [
/* … */
],
})Generics:
I— input type, the argument the runner (and any case) receives.O— output type, validated byschemaif supplied.C— config type, surfaced asctx.config.
Registration fields (StepRegistration<I, O, C>):
| Field | Type | Notes |
|---|---|---|
runner | StepFn<I, O, C> | Required. (input, ctx) => Promise<O>. |
name | string | Optional. Unit key; inferred from the filename when omitted. |
type | StepType | Optional. input | parse | llm | derive | eval (default derive). |
schema | ZodType<O> | Validated before assertions. |
inputSchema | ZodType<I> | Validates a case's input before it runs. |
config | C | Default config object (ctx.config). |
assertions | AssertionFn<O> | Post-execution checks. |
deterministic | boolean | Override the cache-write default. |
outlierThreshold | number | Stability outlier threshold (default 0.6). |
cases | Record<string, CaseSpec> | Inline named cases (usually authored as named exports). |
The returned callable is brand-tagged so the glob loader can recognize it as a unit. Invoked inside a run, await unit(input) records the call as a first-class node in the current trajectory; invoked outside a run it just executes the runner.
No deps
Units don't declare dependencies. You wire them by calling them from the pipeline entrypoint (see below) — the composition is the code.
definePipeline(input)
The pipeline entrypoint. stepbook run executes its runner, which composes units by calling them; the tree of those calls is the run's trajectory. Lives in pipeline.ts (see Config).
import { definePipeline } from '@stepbook/runner'
import parse, { type ParseInput } from './steps/parse.step'
import analyze from './steps/analyze.step'
export default definePipeline({
name: 'demo',
runner: async (input: ParseInput) => {
const parsed = await parse(input) // typed call — records a trajectory node
return analyze(parsed)
},
})Input fields (PipelineDefInput):
| Field | Type | Notes |
|---|---|---|
name | string | Required. Pipeline name. |
runner | StepFn | Entrypoint. (input, ctx) => Promise<output>. |
steps | Record<string, StepDef> | Optional — glob discovery provides them. |
beforeAll | (ctx: { stateDir }) => … | Runs once before the run. |
afterAll | (ctx: { stateDir }) => … | Runs once after the run (success or fail). |
Compose units two ways inside the runner:
- Typed call —
await parse(input). Import the unit's callable; input and output are fully typed. - By name —
await ctx.run('parse', input). Looks the unit up byname; use when you don't have the import (e.g. a dynamic dispatch).
step<I, O, C>(fn)
Identity helper for a runner function — keeps I/O/C types when you define a runner outside defineStep. A no-op at runtime.
import { step } from '@stepbook/runner'
const run = step<ParseInput, ParseOutput>(async (input, ctx) => {
return { words: input.text.split(/\s+/) }
})assertions<O>(fn)
Identity helper for an assertion function, keeping the output type inferred when you define assertions outside defineStep (e.g. to share across steps).
import { assertions } from '@stepbook/runner'
export const nonEmpty = assertions<{ words: string[] }>((o) => [
{ name: 'non-empty', pass: o.words.length > 0 },
])Typed cases — StepMeta / CaseFor
Two type helpers give a unit's cases precise input types, mirroring Storybook's Meta / StoryObj pattern:
StepMeta<Fn>— thesatisfiesconstraint for a meta object built from a runner function.CaseFor<Meta>—{ input: Parameters<Meta['runner']>[0]; name?: string }, the shape of a case for that meta.
import type { StepMeta, CaseFor } from '@stepbook/runner'
const analyze = async (input: { words: string[] }) => ({ n: input.words.length })
const meta = { runner: analyze } satisfies StepMeta<typeof analyze>
// `input` is type-checked against the runner's first parameter:
export const Empty = { input: { words: [] } } satisfies CaseFor<typeof meta>The runner context — RunCtx<C>
The second argument to every runner:
type RunCtx<C = unknown> = {
config: C // the step's config object
signal: AbortSignal // honored at step boundaries
report: (m: RunMeta) => void // record telemetry
cached: <T>(subkey: string, input: unknown, fn: () => Promise<T> | T) => Promise<T>
run: <O = unknown>(unit: string, input: unknown) => Promise<O>
}RunMeta — telemetry payload
type RunMeta = {
cost?: number
latencyMs?: number
tokens?: { in: number; out: number }
model?: string
modelHash?: string
cacheHit?: boolean // set by the runner, not you
}Call ctx.report({...}) from inside a runner to populate the Telemetry and Stability panels. If you don't report latencyMs, stepbook falls back to wall-clock time.
ctx.run — invoke another unit
Call a unit by name as a recorded sub-call — the building block for pipelines and agents that compose units in code. Each call is recorded and replayed through the cache (keyed on the child's input and its own source file) and appears as a node in the parent's trajectory. Repeated calls to the same unit are auto-numbered <unit>#<index> (one node per agent turn); nested calls nest in the tree.
const parsed = await ctx.run('parse', input)Prefer the typed call (await parse(input)) when you can import the unit; ctx.run is for name-based dispatch.
ctx.cached — record/replay a sub-computation
A unit that runs its own loop (e.g. a hand-written agent: decide → act → repeat) can't cache as a whole — there's no single input to key on. ctx.cached lets each iteration record/replay through the same cache:
const decision = await ctx.cached(`turn-${n}`, { task, transcript }, () =>
decideOnce({ task, transcript, model, signal: ctx.signal }),
)The first run executes fn and records its result under this unit's namespace; later runs replay the recorded value without calling fn, so the loop's code re-executes deterministically against frozen sub-results — a free, repeatable loop regression test. The return value (the trajectory) is an ordinary output: give it a schema and assert over the whole sequence.
subkeymust be a unique, filename-safe label per logical unit (convention:turn-${n}).- Keyed on
(subkey, input, this step's config, this step's source file). Editing the step file re-records the whole trajectory next run. - Writes happen regardless of
deterministic— it's a deliberate recording primitive.--no-cacherunsfnevery time and records nothing;--from <thisStep>bypasses replay and re-records. - A throwing
fnis never recorded (no poisoned replays).
See the cache concept for where entries live on disk.
Check — an assertion result
type Check = {
name: string
pass: boolean
reason?: string // human-readable explanation
offending?: unknown[] // items that caused a failure, shown inline
}Return an array of these from assertions. See Assertions & Schemas.
Type exports
These types are exported from @stepbook/runner for annotating your own code:
| Type | Use |
|---|---|
StepType | 'input' | 'parse' | 'llm' | 'derive' | 'eval'. |
StepFn<I, O, C> | Runner function signature. |
RunCtx<C> | The runner's second argument. |
RunMeta | ctx.report payload. |
Check | A single assertion result. |
AssertionFn<O> | (output) => Check[] | Promise<Check[]>. |
CaseSpec | { input: unknown; name?: string }. |
StepMeta<Fn> / CaseFor<Meta> | Typed-case helpers. |
StepRegistration<I,O,C> | What defineStep accepts. |
PipelineDef / PipelineDefInput | Pipeline shapes. |
PipelineHooks | beforeAll / afterAll. |
Determinism helper
isDeterministic(def) is the single source of truth for whether a step's output is cached. It's exported mainly for tooling; you rarely call it directly. The rule is deterministic ?? (type !== 'llm') — see llm calls replay too.
Subpath imports
@stepbook/runner also exposes its subsystems as subpaths (e.g. @stepbook/runner/execute, @stepbook/runner/markdown). Those are primarily for building tools on top of stepbook (like the dashboard). For authoring steps, the root @stepbook/runner import is all you need.