Skip to content

Steps & Step Types

A step is the atomic unit of a stepbook pipeline: a typed async function with an optional output schema and assertions. Each step is the default export of a *.step.ts file. defineStep returns a callable unit — you compose it from your entrypoint by calling it, and each call becomes a first-class node in the run's trajectory.

Anatomy of a step

ts
import { z } from 'zod'
import { defineStep } from '@stepbook/runner'

export type ParseInput = { text: string }
export type ParseOutput = { wordCount: number; charCount: number; words: string[] }

export default defineStep<ParseInput, ParseOutput, { lowercase?: boolean }>({
  name: 'parse',
  type: 'parse',
  schema: z.object({
    wordCount: z.number().int().min(0),
    charCount: z.number().int().min(0),
    words: z.array(z.string()),
  }),
  config: { lowercase: false },
  runner: async (input, ctx) => {
    const text = ctx.config.lowercase ? input.text.toLowerCase() : input.text
    const words = text.trim().split(/\s+/).filter(Boolean)
    return { wordCount: words.length, charCount: text.length, words }
  },
  assertions: (o) => [
    { name: 'wordCount > 0', pass: o.wordCount > 0, reason: `count=${o.wordCount}` },
  ],
})

There is no deps field — a step doesn't declare what feeds it. Its input is whatever the caller passes when it invokes the unit. Wiring lives in the entrypoint, in code (see Pipelines & Trajectories).

The generics defineStep<I, O, C> carry types through to the runner signature and the assertion callback:

  • I — the step's input type (what the caller passes).
  • O — the step's output type, validated by schema if you supply one.
  • C — the step's config type, read from ctx.config.

You can omit the generics and let them be inferred, but naming them gives you full type-checking inside the runner.

Name inference

name is optional. When you omit it, stepbook infers it from the filename: it drops the .step.<ext> suffix and any leading numeric ordering prefix, so 03-parse.step.ts becomes the unit parse. Setting name explicitly (as above) is just a way to pin it independent of the filename.

Fields

FieldRequiredDescription
runneryesasync (input, ctx) => output. The work the step does.
namenoUnit key (cache namespace, JSON output key). Inferred from the filename when omitted.
typenoOne of input | parse | llm | derive | eval. Defaults to derive. Drives caching defaults.
schemanoA Zod schema. Output is validated before assertions run.
inputSchemanoA Zod schema for the step's input. Validates a case's input before running it.
confignoDefault config object passed as ctx.config. Dashboard controls bind here.
assertionsno(output) => Check[]. Post-execution checks. See Assertions.
casesnoNamed fixed inputs for running the step in isolation. See Cases below.
deterministicnoOverride the cache-write decision. Defaults by type (see below).
outlierThresholdnoPer-step stability outlier threshold (default 0.6). See Drift & Stability.

Step types

type drives the dashboard's per-step coloring and signals intent. The five types:

TypeMeaning
inputIngests or normalizes raw input.
parseDeterministic structural transform (tokenize, extract fields).
llmCalls a language model (or any non-deterministic service).
deriveComputes something from an upstream output.
evalScores or judges an output (often the last unit called).

type is optional and defaults to derive. Every step is cached and replayed the same way, whatever its type. The only behavior type affects is one default: whether a fresh run records its output for replay (the deterministic flag). llm defaults to off — the same prompt can differ run to run — and every other type defaults to on. Most pipelines never set it; see The Cache for the details.

The runner function

ts
runner: async (input, ctx) => {
  // input       — whatever the caller passed (typed as I)
  // ctx.config  — the step's config object (typed as C)
  // ctx.signal  — an AbortSignal, honored at unit boundaries
  // ctx.report  — record telemetry (cost, tokens, latency, model)
  // ctx.cached  — record/replay a sub-computation (e.g. one loop turn)
  // ctx.run     — invoke another unit as a recorded sub-call
  return output
}

The full run context is { config, signal, report, cached, run }.

ctx.report — telemetry

For LLM steps especially, call ctx.report to record per-run telemetry. The dashboard's Telemetry and Stability panels read this.

ts
ctx.report({
  model: 'claude-haiku-4-5',
  latencyMs: 412,
  cost: 0.0009,
  tokens: { in: 320, out: 88 },
})

All fields are optional. RunMeta accepts:

ts
type RunMeta = {
  cost?: number
  latencyMs?: number
  tokens?: { in: number; out: number }
  model?: string
  modelHash?: string
  cacheHit?: boolean // set by the runner, not you
}

If you don't report a latencyMs, stepbook falls back to wall-clock time for the step.

ctx.run — invoke another unit

ctx.run('unit', input) invokes another step as a recorded sub-call, keyed on that unit's own input and source file. It's the by-name counterpart to calling an imported callable step directly. Nested calls nest in the trajectory. This is how an agent composes units inside a loop:

ts
runner: async (input, ctx) => {
  const transcript = []
  for (let n = 0; n < maxTurns; n++) {
    const decision = await ctx.run('decide', { task: input.task, transcript })
    if (decision.action === 'final_answer') break
    // …act on the decision, append to the transcript…
  }
}

Each decide call appears as its own node (decide#0, decide#1, …). See Pipelines & Trajectories for the composition model.

ctx.cached — record/replay a sub-computation

A step that runs its own loop — a hand-written agent that decides, acts, and repeats — can wrap each iteration's model call in ctx.cached so the loop becomes replayable without splitting it into separate units:

ts
runner: async (input, ctx) => {
  const turns = []
  for (let n = 0; n < maxTurns; n++) {
    const decision = await ctx.cached(`turn-${n}`, { task, transcript }, () =>
      decideOnce({ task, transcript, signal: ctx.signal }),
    )
    if (decision.action === 'final_answer') break
    // ...act on the decision, append to the transcript...
  }
  return { turns, outcome } // the trajectory IS the step output — assert over it
}

After the first run, stepbook run replays the loop's recorded turns for free — the loop's code re-executes deterministically, the model isn't called, and reported cost is ~0. Use --no-cache (or --from <thisStep>) when you want fresh model calls. See the cache concept for the full semantics.

ctx.signal — cancellation

ctx.signal is an AbortSignal. Long-running steps (especially network calls) should pass it through so a cancelled run stops promptly:

ts
runner: async (input, ctx) => {
  const res = await fetch(url, { signal: ctx.signal })
  return res.json()
}

Aborts are honored at unit boundaries — an in-flight unit runs to completion before the run checks the signal, unless your runner forwards the signal to whatever it's awaiting.

Cases

A case is a named, fixed input for running a step in isolation — a "story" for a unit. Cases let you exercise one step against known inputs without running the whole pipeline. Author them either as named exports in the step file, or as cases/<step>/<name>.json files:

ts
// in parse.step.ts — a named export is a case
export const Empty = { input: { text: '' } }
json
// cases/parse/hello.json
{ "input": { "text": "hello world" } }

When you supply inputSchema, a case's input is validated against it before the step runs — so drift in a JSON fixture is caught up front.

Sharing assertions across steps

If you want to reuse an assertion function, the assertions helper keeps the output type inferred outside of defineStep:

ts
import { assertions } from '@stepbook/runner'

export const nonEmpty = assertions<{ words: string[] }>((o) => [
  { name: 'non-empty', pass: o.words.length > 0 },
])

See the Step-Author API Reference for every exported helper.

Released under the MIT License.