Skip to content

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.

ts
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.

ts
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 by schema if supplied.
  • C — config type, surfaced as ctx.config.

Registration fields (StepRegistration<I, O, C>):

FieldTypeNotes
runnerStepFn<I, O, C>Required. (input, ctx) => Promise<O>.
namestringOptional. Unit key; inferred from the filename when omitted.
typeStepTypeOptional. input | parse | llm | derive | eval (default derive).
schemaZodType<O>Validated before assertions.
inputSchemaZodType<I>Validates a case's input before it runs.
configCDefault config object (ctx.config).
assertionsAssertionFn<O>Post-execution checks.
deterministicbooleanOverride the cache-write default.
outlierThresholdnumberStability outlier threshold (default 0.6).
casesRecord<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).

ts
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):

FieldTypeNotes
namestringRequired. Pipeline name.
runnerStepFnEntrypoint. (input, ctx) => Promise<output>.
stepsRecord<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 callawait parse(input). Import the unit's callable; input and output are fully typed.
  • By nameawait ctx.run('parse', input). Looks the unit up by name; 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.

ts
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).

ts
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> — the satisfies constraint 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.
ts
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:

ts
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

ts
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.

ts
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:

ts
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.

  • subkey must 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-cache runs fn every time and records nothing; --from <thisStep> bypasses replay and re-records.
  • A throwing fn is never recorded (no poisoned replays).

See the cache concept for where entries live on disk.

Check — an assertion result

ts
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:

TypeUse
StepType'input' | 'parse' | 'llm' | 'derive' | 'eval'.
StepFn<I, O, C>Runner function signature.
RunCtx<C>The runner's second argument.
RunMetactx.report payload.
CheckA 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 / PipelineDefInputPipeline shapes.
PipelineHooksbeforeAll / 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.

Released under the MIT License.