Step types
A flow is a directed acyclic graph of steps. Every step is one of seven
kinds. Each kind has a different role: prompt calls
Claude, script runs shell, branch picks the next step from an
exit code, and so on. This page documents every kind with the fields that
drive it and a runnable example.
Fields every step shares
Steps live as keyed entries on the steps record passed to
defineFlow(). The record key becomes the step's id.
Every step kind supports dependsOn; most also accept
onFail.
| Field | Type | Meaning |
|---|---|---|
dependsOn | string[] | Ids of steps that must succeed before this one runs. Cycles fail at defineFlow() time. |
onFail | 'abort' | 'continue' | <stepId> | What to do when the step fails. abort stops the run. continue marks the step failed and runs downstream anyway. A step id jumps to that step. |
Two more concepts apply across kinds:
- Handoffs. A JSON value one step writes and a later step reads. Identified by a string id, validated by a Zod schema if you supply one. Stored at
.relay/runs/<runId>/handoffs/<id>.json. - Artifacts. A file on disk written by a step — markdown, HTML, JSON, whatever. Stored at
.relay/runs/<runId>/<name>. Artifacts are the user-visible output.
prompt — invoke Claude
step.prompt kind: prompt
Send a templated prompt to Claude and capture the response as a handoff, an artifact, or both.
step.prompt is the workhorse. It reads a Handlebars-templated
markdown file, substitutes input fields and prior handoffs, and dispatches
a Claude invocation through the configured provider. The contained
subprocess receives an explicit env allowlist so credentials never leak in.
| Field | Type | Required | Meaning |
|---|---|---|---|
promptFile | string | yes | Path to the prompt file, relative to the flow directory. |
output | see below | yes | Where the response goes — handoff, artifact, or both. |
contextFrom | string[] | — | Handoff ids whose JSON is exposed to the prompt's Handlebars context. |
model | string | — | Model alias (e.g. sonnet, haiku). Provider-dependent. |
systemPrompt | string | — | System prompt for this invocation. |
tools | string[] | — | Tool names the invocation may use. |
agents | see core README | — | Ephemeral subagent definitions, inline or sourced from a handoff. |
maxRetries | number | — | Retry count on transient errors. Defaults to provider policy. |
maxBudgetUsd | number | — | Per-step cap. Exceeding it raises BudgetExceededError. |
timeoutMs | number | — | Wall-clock cap. Defaults to 600 000 ms (10 min). |
output shapes
// (a) Just a handoff — JSON the next step reads.
output: { handoff: 'inventory' }
// (b) Just an artifact — a file written to .relay/runs/<id>/.
output: { artifact: 'report.html' }
// (c) Both at once.
output: { handoff: 'summary', artifact: 'summary.md' }
// (d) Handoff with a Zod schema. Validated before the next step starts.
output: {
handoff: 'inventory',
schema: z.object({ files: z.number(), entities: z.array(z.string()) }),
} Example
import { defineFlow, step, z } from '@ganderbite/relay-core';
export default defineFlow({
name: 'hello',
version: '0.1.0',
input: z.object({ topic: z.string() }),
steps: {
write: step.prompt({
promptFile: 'prompts/01_write.md',
output: { handoff: 'draft' },
}),
polish: step.prompt({
promptFile: 'prompts/02_polish.md',
dependsOn: ['write'],
contextFrom: ['draft'],
output: { artifact: 'final.md' },
}),
},
}); And the prompt file prompts/02_polish.md can reference both the input and the handoff:
The topic is {{input.topic}}.
The draft so far:
{{draft.text}}
Polish it into a tight one-paragraph summary. script — run a shell command
step.script kind: script
Run a shell command or script under your shell, optionally producing an artifact.
step.script spawns a child process. It is your hook for the
parts of a flow that aren't model calls — running tests, querying a
database, hitting a webhook, cleaning up scratch files. Unlike
prompt, the child process inherits process.env in
full. If a script touches the network, pass an explicit env map.
| Field | Type | Required | Meaning |
|---|---|---|---|
run | string | string[] | yes | The command to run. Use an array for argv-style invocation; a string is passed through your shell. |
env | Record<string, string | { from }> | — | Explicit env map. Values can be string literals or { from: 'input.x' } / { from: 'handoff.id.path' } references. |
cwd | string | — | Working directory for the child process. |
output | { artifact?: string } | — | If set, stdout is captured to the named artifact. |
onExit | Record<string, 'abort' | 'continue' | <stepId>> | — | Map of exit codes to actions. Use 'default' as the catch-all key. |
maxRetries | number | — | Retry count on non-zero exit. |
timeoutMs | number | — | Wall-clock cap. Process is killed on timeout. |
Example
import { defineFlow, step, z } from '@ganderbite/relay-core';
export default defineFlow({
name: 'ship',
version: '0.1.0',
input: z.object({ webhook: z.string() }),
steps: {
summarize: step.prompt({
promptFile: 'prompts/01_summarize.md',
output: { handoff: 'summary', artifact: 'summary.json' },
}),
notify: step.script({
dependsOn: ['summarize'],
run: 'curl -sS -X POST "$WEBHOOK_URL" --data-binary @summary.json',
env: {
WEBHOOK_URL: { from: 'input.webhook' },
},
onExit: { '0': 'continue', default: 'abort' },
timeoutMs: 30_000,
}),
},
});
Env containment. script and branch forward process.env
intact unless you pass an explicit env map. prompt
does not — it uses an allowlist. If a script touches an external service,
pass env explicitly so credentials are scoped to that step.
branch — route on an exit code
step.branch kind: branch
Run a shell command and use its exit code to pick which step runs next.
A branch step is a script step whose entire purpose is the
onExit map. It produces no artifact and no handoff; the
side-effect is changing where execution goes. Use it for guards
("only continue if the lockfile is clean") and conditional
dispatch ("if there are no changes, jump to publish; if there are,
go to review").
Field Type Required Meaning runstring | string[]yes The command whose exit code drives routing. onExitRecord<string, 'abort' | 'continue' | <stepId>>yes Required and non-empty. Map exit-code strings (or 'default') to actions. envsame as script — Explicit env map. cwdstring— Working directory. timeoutMsnumber— Wall-clock cap.
Example
import { defineFlow, step, z } from '@ganderbite/relay-core';
export default defineFlow({
name: 'release',
version: '0.1.0',
input: z.object({}),
steps: {
hasChanges: step.branch({
run: 'git diff --quiet HEAD',
onExit: {
'0': 'publish', // no changes — go straight to publish
'1': 'review', // changes pending — review first
default: 'abort',
},
}),
review: step.prompt({
promptFile: 'prompts/review.md',
dependsOn: ['hasChanges'],
output: { handoff: 'review' },
}),
publish: step.script({
dependsOn: ['hasChanges'],
run: 'npm publish',
}),
},
});
parallel — fan-out / fan-in
step.parallel kind: parallel
Fan out to a set of named branches and converge on a single follow-up step.
A parallel step lists the ids of the branches to run
concurrently and (optionally) a single step that runs after they all
complete. The branches themselves are normal steps elsewhere in the flow —
parallel just declares the fan-out boundary.
Field Type Required Meaning branchesstring[]yes Ids of the steps to run in parallel. Must be unique. onAllCompletestring— Step id to run after every branch succeeds. onFail'abort' | <stepId>— continue is not legal here — any branch failure either aborts or routes to a recovery step.
No retry, no timeout, no contextFrom.
A parallel step is a dispatch boundary, not a worker. Retry, timeout, and
context injection belong on the branch steps themselves.
Example: three reviewers, one aggregator
import { defineFlow, step, z } from '@ganderbite/relay-core';
export default defineFlow({
name: 'multi-perspective-review',
version: '0.1.0',
input: z.object({ filePath: z.string() }),
steps: {
reviewSecurity: step.prompt({
promptFile: 'prompts/security-review.md',
output: { handoff: 'security' },
}),
reviewPerformance: step.prompt({
promptFile: 'prompts/performance-review.md',
output: { handoff: 'performance' },
}),
reviewReadability: step.prompt({
promptFile: 'prompts/readability-review.md',
output: { handoff: 'readability' },
}),
fanOut: step.parallel({
branches: ['reviewSecurity', 'reviewPerformance', 'reviewReadability'],
onAllComplete: 'aggregate',
}),
aggregate: step.prompt({
promptFile: 'prompts/aggregate-reviews.md',
dependsOn: ['reviewSecurity', 'reviewPerformance', 'reviewReadability'],
contextFrom: ['security', 'performance', 'readability'],
output: { artifact: 'report.md' },
}),
},
});
loop — iterate until done
step.loop kind: loop
Run a sub-graph of steps repeatedly until a named handoff matches a condition or an iteration cap is hit.
A loop step holds its own body — a small map of steps that
Relay re-executes each iteration. After each iteration, Relay inspects the
handoff named in until.from and stops when its fields match
the until.when pattern. The maxIterations field
is a hard cap — Relay will not loop forever, even if the model never
decides it's done.
Field Type Required Meaning bodyRecord<string, Step>yes The sub-graph to repeat. Non-empty. No nested loops, no parallel. until{ from: handoffId, when: { ... } }yes Stop when the body's handoff equals the pattern (shallow equality). maxIterationsnumberyes Positive integer. Hard cap. startstring— Id of the body step to start each iteration on, if the body has multiple roots.
Example: compose a sprint plan until coverage verifies
This is the compose-plan loop from the real planning
flow. Four prompt steps run in sequence — break the feature into tasks,
bucket tasks into waves, fold waves into a sprint, verify coverage —
then the verifier emits a verdict. If it's pass,
the loop exits. Otherwise the next iteration starts again at
compose-tasks with the verifier's notes in context.
'compose-plan': step.loop({
dependsOn: ['approve-arch'],
maxIterations: 3,
start: 'compose-tasks',
until: { from: 'coverage_report', when: { verdict: 'pass' } },
body: {
'compose-tasks': step.prompt({
promptFile: 'prompts/04_compose_tasks.md',
tools: ['Read', 'Glob', 'Grep'],
model: 'opus',
output: { handoff: 'tasks', schema: TasksSchema },
}),
'compose-waves': step.prompt({
promptFile: 'prompts/05_compose_waves.md',
dependsOn: ['compose-tasks'],
contextFrom: ['tasks'],
model: 'opus',
output: { handoff: 'waves', schema: WavesSchema },
}),
'compose-sprints': step.prompt({
promptFile: 'prompts/06_compose_sprints.md',
dependsOn: ['compose-waves'],
contextFrom: ['tasks', 'waves'],
model: 'opus',
output: { handoff: 'sprints', schema: SprintsSchema },
}),
'verify-coverage': step.prompt({
promptFile: 'prompts/07_verify.md',
dependsOn: ['compose-sprints'],
contextFrom: ['sprints'],
model: 'opus',
output: { handoff: 'coverage_report', schema: CoverageReportSchema },
}),
},
}),
Notice three things: start names the body entry point so
Relay knows where each iteration begins, until.from matches
the verifier's handoff id, and maxIterations: 3 is a hard
ceiling — three rounds at most. See the
Patterns page for the
fuller version with prompts and schemas.
ask — pause and collect user input
step.ask kind: ask
Pause the run, prompt the user for one or more answers, write the responses as a handoff.
step.ask turns a flow into an interactive one. When the
flow reaches the step, Relay pauses, presents the questions on the CLI (or
in a host UI), and writes the collected answers as a handoff. A paused run
is fully checkpointed — the user can close the terminal and resume with
relay resume <runId> later.
Field Type Required Meaning questionsQuestion[] | { from: stepId }yes Literal list, or a dynamic source that names a step whose handoff produces the questions at dispatch time. namestring— Optional label for the prompt block. output{ schema: ZodType }— Optional Zod schema. If set, the collected answers are validated before becoming a handoff.
Supported question kinds: text, multiline, select, multiselect, confirm, number.
Two ways to source questions
A static questions array fixes the prompts at flow-author
time. The dynamic { from: <stepId> } form lets a
prior step decide what to ask — useful when the questions themselves
depend on what Claude found earlier.
Static: an approval gate
'approve-arch': step.ask({
dependsOn: ['architecture'],
questions: [
{
id: 'approved',
kind: 'confirm',
label: 'Approve docs/ARCHITECTURE.md? Reject to abort the run.',
default: true,
},
],
}),
A single confirm question is the canonical "are you sure"
gate. The flow pauses; the user types y/n; the run resumes.
Dynamic: questions a prior step wrote
The discovery flow asks Claude to read the application idea
and generate clarifying questions of its own. Those questions become the
handoff that the next ask step reads:
'clarify-questions': step.prompt({
promptFile: 'prompts/01_clarify_questions.md',
tools: ['Read', 'Glob'],
model: 'opus',
output: { handoff: 'clarify_questions', schema: ClarifyQuestionsSchema },
}),
'ask-clarify': step.ask({
dependsOn: ['clarify-questions'],
questions: { from: 'clarify_questions' },
}),
decompose: step.prompt({
promptFile: 'prompts/02_decompose.md',
dependsOn: ['ask-clarify'],
contextFrom: ['ask-clarify'],
...
}),
The prompt step produces a Question[] that the schema
validates; the ask step renders it; the decompose step picks up the
user's answers via contextFrom: ['ask-clarify']. The
flow author never wrote a single question.
terminal — end the flow
step.terminal kind: terminal
Explicitly end the flow with a message and an exit code, regardless of remaining steps.
Use a terminal step at the end of a branch arm that should
short-circuit the rest of the flow — e.g. a "nothing to do" path
after a guard step. Reaching a terminal step writes a final-state
checkpoint and exits with the configured code.
Field Type Required Meaning messagestring— Final message rendered in the success banner. exitCode0–255— Process exit code. Defaults to 0.
Example: short-circuit when there's nothing to do
import { defineFlow, step, z } from '@ganderbite/relay-core';
export default defineFlow({
name: 'maybe-publish',
version: '0.1.0',
input: z.object({}),
steps: {
hasChanges: step.branch({
run: 'git diff --quiet HEAD',
onExit: {
'0': 'noop',
'1': 'publish',
default: 'abort',
},
}),
noop: step.terminal({
dependsOn: ['hasChanges'],
message: 'nothing to publish',
exitCode: 0,
}),
publish: step.script({
dependsOn: ['hasChanges'],
run: 'npm publish',
}),
},
});
Want to put this together into a working flow? Read
Author a flow — a seven-step walkthrough that
scaffolds, fails, fixes, and resumes a real run.