●─▶●─▶●─▶● relay docs
Patterns

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.

patterns on this page

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:

Body isolation. A body step cannot 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.

Why one prompt, not 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:

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.

Why not just trust the schema? A Zod schema validates the JSON the model returned — not the side effects on disk. If the model says "I wrote 200 files" but only created a stub, the schema is happy. The verify script closes that gap.

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:

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.