●─▶●─▶●─▶● relay docs
Concepts

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.

FieldTypeMeaning
dependsOnstring[]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:

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.

FieldTypeRequiredMeaning
promptFilestringyesPath to the prompt file, relative to the flow directory.
outputsee belowyesWhere the response goes — handoff, artifact, or both.
contextFromstring[]Handoff ids whose JSON is exposed to the prompt's Handlebars context.
modelstringModel alias (e.g. sonnet, haiku). Provider-dependent.
systemPromptstringSystem prompt for this invocation.
toolsstring[]Tool names the invocation may use.
agentssee core READMEEphemeral subagent definitions, inline or sourced from a handoff.
maxRetriesnumberRetry count on transient errors. Defaults to provider policy.
maxBudgetUsdnumberPer-step cap. Exceeding it raises BudgetExceededError.
timeoutMsnumberWall-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.

FieldTypeRequiredMeaning
runstring | string[]yesThe command to run. Use an array for argv-style invocation; a string is passed through your shell.
envRecord<string, string | { from }>Explicit env map. Values can be string literals or { from: 'input.x' } / { from: 'handoff.id.path' } references.
cwdstringWorking directory for the child process.
output{ artifact?: string }If set, stdout is captured to the named artifact.
onExitRecord<string, 'abort' | 'continue' | <stepId>>Map of exit codes to actions. Use 'default' as the catch-all key.
maxRetriesnumberRetry count on non-zero exit.
timeoutMsnumberWall-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").

FieldTypeRequiredMeaning
runstring | string[]yesThe command whose exit code drives routing.
onExitRecord<string, 'abort' | 'continue' | <stepId>>yesRequired and non-empty. Map exit-code strings (or 'default') to actions.
envsame as scriptExplicit env map.
cwdstringWorking directory.
timeoutMsnumberWall-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.

FieldTypeRequiredMeaning
branchesstring[]yesIds of the steps to run in parallel. Must be unique.
onAllCompletestringStep 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.

FieldTypeRequiredMeaning
bodyRecord<string, Step>yesThe sub-graph to repeat. Non-empty. No nested loops, no parallel.
until{ from: handoffId, when: { ... } }yesStop when the body's handoff equals the pattern (shallow equality).
maxIterationsnumberyesPositive integer. Hard cap.
startstringId 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.

FieldTypeRequiredMeaning
questionsQuestion[] | { from: stepId }yesLiteral list, or a dynamic source that names a step whose handoff produces the questions at dispatch time.
namestringOptional 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.

FieldTypeRequiredMeaning
messagestringFinal message rendered in the success banner.
exitCode0–255Process 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.