●─▶●─▶●─▶● relay docs
Build

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:

VariableResolves 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:

FieldRole
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 flowshello-world, multi-perspective-review, git-log-summary, and more, runnable end-to-end.