Real-world patterns
The step types are LEGO bricks; the patterns are the things you build with
them. Every snippet below comes from a flow that ships work in production
— sprint planners, sprint runners, project bootstrappers — not toy
examples. Use them as starting points the next time you reach for
step.loop or step.parallel and want to know what
shape works.
- Iterate until a verifier passes — loop with 4-step body
- Fan-out via dynamic agents inside a loop — the wave-loop idiom
- Let the model decide what to ask — dynamic questions from a handoff
- Static fan-out / fan-in — many reviewers, one aggregator
- Verify after every write — the script-checks-handoff idiom
- Carrying inputs into shell steps — script env passthrough
- Approval gates between phases — single-confirm asks
Iterate until a verifier passes
The shape: a four-step loop body where the last step is a
judge. The body keeps running until the judge says it's done — or until
maxIterations stops it. Useful any time the model needs more
than one pass to converge.
This is the compose-plan loop from the planning
flow. It turns a feature spec into a single sprint. The first three body
steps produce tasks, group them into waves, and fold the waves into a
sprint plan. The fourth step is the verifier — it reads the proposed plan,
walks the requirements, and emits { verdict: 'pass' | 'fail', ... }.
If verdict is 'fail', the loop runs again with
the verifier's notes feeding back into the first step.
'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'],
tools: ['Read', 'Glob'],
model: 'opus',
output: { handoff: 'coverage_report', schema: CoverageReportSchema },
}),
},
}), What carries the pattern:
start— names the first body step. Required when the body'sdependsOngraph has more than one root.until.from— the handoff id of the verifier, identifying which JSON Relay checks at the end of each iteration.until.when— a shallow-equality pattern. The loop stops when every field in this object matches the verifier's handoff.maxIterations— the hard ceiling. Even if the model never says pass, Relay walks out.
contextFrom handoffs from outside the loop
directly. If you need an upstream value inside the body, pass it via
env: { X: { from: 'input.x' } } on a script step, or
re-read it from disk inside a prompt.
Fan-out via dynamic agents inside a loop
The shape: one prompt step inside a loop body fans out
work to many ephemeral subagents via Claude's Task tool. The
list of subagents is itself a handoff written by an earlier step, so the
set of workers is decided by the model rather than hard-coded.
This is the wave-loop from sprint-implementation.
Each iteration runs one wave of a sprint: a deterministic script flips
every task in the next wave to in_progress, then a single
wave prompt step dispatches a Task per task in that wave
using the right builder persona, commits, smoke-tests, and updates state.
The loop exits when every wave is done.
'wave-loop': step.loop({
dependsOn: ['derive-builders'],
maxIterations: 20,
until: { from: 'wave_outcome', when: { all_waves_done: true } },
body: {
'mark-tasks-in-progress': step.script({
run: ['bash', '-c', '"$RELAY_FLOW_DIR/scripts/mark-tasks-in-progress.sh"'],
env: { SPRINT_ID: { from: 'input.sprintId', required: true } },
onFail: 'abort',
}),
wave: step.prompt({
promptFile: 'prompts/02_wave.md',
dependsOn: ['mark-tasks-in-progress'],
tools: ['Read', 'Write', 'Edit', 'Bash', 'Glob', 'Grep', 'Task'],
model: 'opus',
agents: { from: 'handoff.builder_agents', required: true },
output: { handoff: 'wave_outcome', schema: WaveOutcomeSchema },
}),
'wave-commit': step.script({
run: ['bash', '-c', '"$RELAY_FLOW_DIR/scripts/wave-commit.sh"'],
dependsOn: ['wave'],
onFail: 'abort',
}),
'wave-smoke': step.script({
run: ['bash', '-c', '"$RELAY_FLOW_DIR/scripts/wave-smoke.sh"'],
dependsOn: ['wave-commit'],
env: { SPRINT_ID: { from: 'input.sprintId', required: true } },
onFail: 'abort',
}),
'mark-tasks-done': step.script({
run: ['bash', '-c', '"$RELAY_FLOW_DIR/scripts/mark-tasks-done.sh"'],
dependsOn: ['wave-smoke'],
env: { SPRINT_ID: { from: 'input.sprintId', required: true } },
onFail: 'abort',
}),
},
}), The load-bearing line:
agents: { from: 'handoff.builder_agents', required: true },
An earlier step (derive-builders) wrote
builder_agents — an array of AgentDefinition
objects. Each entry has a name, a systemPrompt,
a tool list, and a model. When the wave step runs, those agents are
materialised as Claude Code subagents and exposed to the Task tool. The
main wave invocation then fans out one Task per sprint task, picking the
right persona for each.
step.parallel?
Parallel-inside-loop is forbidden in v1 — and even if it weren't, the set
of workers per wave isn't known until the wave starts. Pushing fan-out
inside the model lets the dispatch be data-driven; the orchestrator still
gets exactly one checkpoint per wave.
Let the model decide what to ask
The shape: a prompt step writes a Question[]
handoff; an ask step consumes it via { from: <handoffId> };
a downstream prompt step reads the answers via contextFrom.
The flow author never hand-writes the questions.
From the discovery flow:
'clarify-questions': step.prompt({
promptFile: 'prompts/01_clarify_questions.md',
dependsOn: ['branch'],
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'],
tools: ['Read', 'Glob', 'Grep'],
model: 'opus',
output: { handoff: 'feature_list', schema: FeatureListSchema },
}),
Claude reads docs/APPLICATION.md, identifies the blocking
gaps, and emits a list of questions shaped like [{ id, kind, label, required }].
The ask step renders them on the CLI. The user types answers. The
decompose step picks them up under the handoff id ask-clarify
(the step's own id, because answers are stored under the ask step that
collected them).
Static fan-out / fan-in
The shape: N independent prompt steps run concurrently
via step.parallel; an aggregator step waits for all of them
and produces the final artifact. Use this when the workers are known at
flow-author time and don't need to coordinate.
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' },
}), The four things to get right:
- The branch steps are independent — no
dependsOnbetween them. - The
parallelstep lists them inbranches. onAllCompletenames the aggregator.- The aggregator's
dependsOnlists every branch, and itscontextFromlists every handoff it reads.
Use static fan-out when N is small and known. When N is dynamic, use the dynamic-agents pattern above instead.
Verify after every write
The shape: every prompt step that writes a file is followed by a deterministic script step that asserts the file exists and is non-trivially populated. This catches model regressions before they propagate.
From sdlc-init:
architecture: step.prompt({
promptFile: 'prompts/03_architecture.md',
dependsOn: ['verify-brainstorm'],
contextFrom: ['brief', 'intel'],
tools: ['Read', 'Write'],
model: 'opus',
output: { handoff: 'architecture', schema: ArchitectureSchema },
}),
'verify-architecture': step.script({
run: ['bash', '-c', '"$RELAY_FLOW_DIR/scripts/assert-handoff-files.sh"'],
dependsOn: ['architecture'],
env: {
HANDOFF_NAME: 'architecture',
PATHS_JQ: '[.architecture_path]',
MIN_BYTES: '2048',
},
onFail: 'abort',
}),
A shared script reads the handoff JSON, extracts a list of file paths via
a jq expression, and fails the run with onFail: 'abort'
if any file is missing or smaller than MIN_BYTES. The script
is generic; the per-step env map specialises it.
Carrying inputs into shell steps
The shape: map input.<path> and
handoff.<id>.<path> values into the child
process's environment without ever interpolating them into the command
string. Safer than building a shell line by concatenation.
pr: step.script({
run: ['bash', '-c', '"$RELAY_FLOW_DIR/scripts/open-pr.sh"'],
dependsOn: ['report'],
env: {
SPRINT_ID: { from: 'input.sprintId', required: true },
REPO: { from: 'input.repo', required: true },
DRY_RUN: { from: 'input.dryRun' },
},
}), Rules:
{ from: 'input.<path>' }reads a dot-path from the flow'sinputobject.{ from: 'handoff.<id>.<path>' }reads a path from a prior step's handoff.required: truemakes the resolver fail the step if the path is missing or empty.- String values like
HANDOFF_NAME: 'architecture'are passed through as literals.
Approval gates between phases
The shape: a single-question confirm ask
between two prompt steps. The user reviews what the previous step wrote
on disk, then types y or n to either let the run continue or abort.
'approve-arch': step.ask({
dependsOn: ['verify-architecture'],
questions: [
{
id: 'approved',
kind: 'confirm',
label: 'Approve docs/ARCHITECTURE.md? Reject to abort the run and revise the prompt or brief.',
default: true,
},
],
}),
'tech-stack': step.prompt({
promptFile: 'prompts/04_tech_stack.md',
dependsOn: ['approve-arch'],
contextFrom: ['architecture', 'brief'],
...
}),
The approve-arch step pauses the run between writing the
architecture doc and starting the tech-stack step. Because the run is
fully checkpointed, the user can close the terminal entirely and come
back hours later — relay resume <runId> picks up at the
pause. Reject the gate and the run aborts cleanly with the partial
artifacts intact on disk.
These patterns are composable. The planning flow chains the
dynamic-ask pattern → an approval gate → the iterate-until-passes loop, in
one file. The sprint-implementation flow stacks two loops
back-to-back (a wave-loop, then a review-fix-loop). Pick a pattern, paste
it into flow.ts, and adjust the prompt files. The shape
survives.