Author a flow
This walkthrough goes through the full loop a new flow author touches: scaffold, edit, run, fail, fix, resume, extend. By the end you have a working two-step flow that passes a typed handoff from the first step to the second.
1. Scaffold with relay new
relay new my-flow --template blank Expected output:
●─▶●─▶●─▶● relay new my-flow (blank template)
✓ wrote ./my-flow/package.json
✓ wrote ./my-flow/flow.ts
✓ wrote ./my-flow/prompts/01_first.md
✓ wrote ./my-flow/README.md
✓ wrote ./my-flow/tsconfig.json
✓ installed dev dependencies
try it:
cd my-flow && relay run .
The --template blank flag skips the interactive generator and
writes files directly. Without it, relay new uses the
generator skill (if installed in Claude Code) for a conversational path.
The directory you now have:
my-flow/
├── package.json
├── flow.ts
├── prompts/
│ └── 01_first.md
├── README.md
└── tsconfig.json flow.ts is the entry point. It default-exports a
Flow object built by defineFlow(). The blank
template gives you one prompt step named first.
Before running, build the flow so the CLI can load dist/flow.js:
cd my-flow && npm run build 2. Edit prompts/01_first.md
Open my-flow/prompts/01_first.md. The blank template places this content there:
You are writing about {{input.subject}}. Produce a short paragraph describing it.
Return ONLY the paragraph text. No preamble, no headings, no commentary. Prompt files are Handlebars templates. Two variable namespaces are available:
| Variable | Resolves to |
|---|---|
{{input.<field>}} | Any field from the flow's input schema. |
{{<handoffId>.<field>}} | The JSON value a prior step wrote as a handoff, when the step declares contextFrom: ['<handoffId>']. |
3. Run it
relay run . --subject "the water cycle"
The . tells the CLI to load the flow from the current
directory. The --subject flag satisfies the
subject: z.string() input the blank template declared.
●─▶●─▶●─▶● relay
flow my-flow v0.1.0
input the water cycle
run a1b2c3 · 2026-04-23 09:14Z
bill subscription (max) · no api charges
est $0.00 · 1 steps · ~1 min
press ctrl-c any time — state is saved after every step.
───────────────────────────────────────────────────────
⠋ first sonnet turn 1
When the step completes, the success banner appears with the run id and the artifact path. Open the handoff to inspect it:
cat .relay/runs/a1b2c3/handoffs/result.json
4. Introduce a deliberate failure
Add a Zod schema to the step so its output is validated. Open flow.ts and add a schema to output:
import { defineFlow, step, z } from '@ganderbite/relay-core';
export default defineFlow({
name: 'my-flow',
version: '0.1.0',
input: z.object({ subject: z.string() }),
steps: {
first: step.prompt({
promptFile: 'prompts/01_first.md',
output: {
handoff: 'result',
schema: z.object({ summary: z.string(), wordCount: z.number() }),
},
}),
},
});
The prompt still asks for a plain paragraph — which will not satisfy { summary: string; wordCount: number }. Rebuild and run:
npm run build
relay run . --subject "photosynthesis"
●─▶●─▶●─▶● my-flow · b2c3d4 ✕
✕ first exit 1 0.2s
step 'first' raised HandoffSchemaError
handoff 'result' missing required field: summary, wordCount
0 of 1 steps succeeded · $0.000 spent · state saved
to resume after fixing:
relay resume b2c3d4
to restart from scratch:
relay run . --fresh
The run id b2c3d4 and the step id first are both
in the banner. The checkpoint up to (but not including) the failed step is
saved on disk.
5. Diagnose and fix
A HandoffSchemaError means the step's prompt returned output
the schema rejected. The banner names the failing paths.
For more detail, read the structured log:
relay logs b2c3d4 --step first
Two paths forward: fix the prompt to return the schema's shape, or relax the schema. For this tutorial, fix the prompt. Replace prompts/01_first.md with:
You are writing about {{input.subject}}.
Return ONLY a JSON object with this exact shape:
{
"summary": "<one sentence describing the subject>",
"wordCount": <number of words in the summary sentence>
}
No prose, no backticks, no preamble.
Then rebuild:
npm run build
6. Resume from the checkpoint
relay resume b2c3d4
●─▶●─▶●─▶● relay resume b2c3d4
flow my-flow v0.1.0
picking up from: first
⠋ first running
spent so far: $0.000
The resume rule.
Steps with status ✓ in the pre-resume banner are cached —
they will not re-execute and their cost is not re-incurred. Only failed or
pending steps run again. Resuming a five-step flow where step 3 failed
costs roughly what step 3 through step 5 cost originally.
7. Add a second step with a handoff
Now add a second step that consumes the result handoff:
import { defineFlow, step, z } from '@ganderbite/relay-core';
export default defineFlow({
name: 'my-flow',
version: '0.1.0',
input: z.object({ subject: z.string() }),
steps: {
first: step.prompt({
promptFile: 'prompts/01_first.md',
output: {
handoff: 'result',
schema: z.object({ summary: z.string(), wordCount: z.number() }),
},
}),
second: step.prompt({
promptFile: 'prompts/02_second.md',
dependsOn: ['first'],
contextFrom: ['result'],
output: { artifact: 'report.md' },
}),
},
});
Three fields wire the handoff together:
Field Role output.handoff: 'result'The first step names the JSON it writes to disk. dependsOn: ['first']The second step waits until first succeeds. contextFrom: ['result']The second step's prompt sees result as a Handlebars variable.
Create prompts/02_second.md:
The prior step analysed {{input.subject}} and produced this summary:
"{{result.summary}}" ({{result.wordCount}} words)
Write a short markdown document (two paragraphs, no headings) that expands on the summary. Focus on why the subject matters.
Return the markdown document as plain text. No code fences, no commentary.
Build and run:
npm run build
relay run . --subject "the carbon cycle"
●─▶●─▶●─▶● my-flow · c3d4e5 ✓
✓ first sonnet 3.1s $0.000
✓ second sonnet 5.4s $0.000
all 2 steps succeeded in 8.5s
cost $0.000 (estimated api equivalent; billed to subscription)
output .relay/runs/c3d4e5/report.md
The artifact report.md contains the markdown the second step wrote, with the summary from the first step substituted via the handoff.
Reference: the flow package layout
Every flow — generated, installed from the catalog, or hand-written — has the same directory shape:
<flow-name>/
├── package.json # name, version, peer-dep on @ganderbite/relay-core
├── flow.ts # defineFlow() entry point — default export
├── prompts/
│ ├── 01_<step>.md # one file per prompt step
│ └── ...
├── schemas/ # optional: shared Zod schemas
├── templates/ # optional: output templates (HTML, markdown)
├── examples/ # optional: sample outputs for the README
├── README.md # user-facing docs
└── tsconfig.json # extends @ganderbite/relay-core/tsconfig
Required: package.json, flow.ts, README.md. Everything else is optional.
package.json
{
"name": "@ganderbite/flow-my-flow",
"version": "0.1.0",
"description": "Short description shown in the catalog.",
"type": "module",
"main": "./dist/flow.js",
"files": ["dist", "prompts", "README.md"],
"scripts": {
"build": "tsc",
"test": "relay test ."
},
"peerDependencies": {
"@ganderbite/relay-core": "^1.0.0"
},
"relay": {
"displayName": "My Flow",
"tags": ["example"],
"estimatedCostUsd": { "min": 0.05, "max": 0.20 },
"estimatedDurationMin": { "min": 1, "max": 5 },
"audience": ["dev"]
}
}
The relay metadata block drives relay list, relay search, and the pre-run banner. All five keys are required when publishing to the catalog.
Where to go from here
- Step types — the other six step kinds and when to reach for them.
- CLI commands — every
relay verb and what it prints. - Example flows —
hello-world, multi-perspective-review, git-log-summary, and more, runnable end-to-end.