Build a flow

Write your own steps, verification, and permissions — TypeScript or YAML, same journal underneath.

Start with the job in one sentence: what runs, in what order, and what proves each step did its job. If you can't state that proof yet, figure it out first, before wiring the step up.

Use the skill

Don't paste this page into your agent's context — install the writing-relayflows skill and let it author flows directly:

# with prpm
npx prpm install @agent-relay/writing-relayflows

# with skills.sh
npx skills add https://github.com/agentworkforce/skills --skill writing-relayflows

The rest of this page is what the skill encodes — worth reading so you can tell whether what it wrote is right.

Two ways to author the same thing

TypeScript calls the primitives imperatively, as ordinary code. YAML describes the same fixed set of steps and their dependencies as data, so flows check or a CI gate can read and validate it without running anything. Both compile down to the same journal; every sample on these pages is shown in TypeScript, with the YAML form behind the language switch.

import { flow } from '@relayflows/surface';

export default flow('hello-agent', async (f) => {
  const greeting = await f.run('printf hello');
  const edit = await f.agent('edit', {
    task: 'Produce the hello artifact.',
    cli: 'claude',
    model: 'claude-sonnet-4-6',
  });
  const finish = await f.run('printf done');
  f.done('success');
});

Reach for YAML when you want the whole flow readable at a glance and checkable in CI. Reach for TypeScript when a step's next move depends on what a previous one returned: an f.human approval, ordinary if/for logic, an f.dispatch to a child flow.

Both edit steps above name their own cli and model directly, the same way in either language (flows#310). Neither is required in TypeScript: omit them and the step falls back to the flow's cli, then the nearest flows.json's project-wide default, the same resolution Introduction covers.

recoveryMode, permissions, and surfaces, the richer step fields covered further down, are YAML/JSON fields only today. TypeScript's f.agent takes { task, workspace?, cli?, model? }, but under --local-agent any workspace value is refused — not just an annotated one. Verified: workspace: 'repo: readonly' and a bare workspace: 'repo' both fail identically with unsupported_workspace_permission: The local agent worker accepts stream-only steps. Remove workspace or attach a worker that holds its revision pins. The local worker holds no revision pins at all — omit workspace entirely for a step you run with --local-agent; it's a real field only against a worker that supports it (Cloud's). budget is available in both: the TypeScript form is the flow header, flow('name', { budget: '$5/run' }, async (f) => …).

The context a flow body gets

interface Ctx {
  run(command: string, options?: { timeout?: string | number }): Step<string>;   // lease: default 30s, max 15m
  llm(strings: TemplateStringsArray, ...values: unknown[]): Step<string>;
  llm(prompt: string, options: { output: JsonSchema; cli?: string; model?: string }): Step<unknown>;
  agent(name: string, options: { task: string; workspace?: string; cli?: string; model?: string }): Step<{ summary: string; artifacts: string[] }>;
  human(question: string, options: { to: string }): Step<boolean>;
  dispatch<T>(flow: string, input: unknown): Promise<T>;
  done(reason: 'success' | 'step_failed' | 'needs_human' | 'declined'): void;
  slack: SlackHelper; github: GithubHelper; /* …every generated helper */
  memory: MemoryHelper;
  mcp: Record<string, Record<string, (args: unknown) => Step<unknown>>>;
}

This is the whole kernel-level vocabulary a step body speaks: run, llm, agent for work, human, dispatch, done for control.

human ships in 2.0.18 — the run parks on a durable kernel wait and the answer is journal evidence; Human gates below has the contract. dispatch is declared and typechecks but still fails closed at runtime — verified: a flow that reaches f.dispatch fails with unsupported_verb: the initial authored executor does not lower f.dispatch. It is meant to hand work to a named child flow and return its typed result; until it lands, keep one flow per file.

done takes one of four authored verdicts. success completes the run; step_failed says the flow's own checks did not pass (the adversarial review found problems, the tests went red) and exits 1; needs_human parks the run (exit 3); declined records a deliberate decision not to act on the input — a ticket that turned out not to be work — and exits 0 with a DECLINED diagnostic. canceled and budget_exceeded are in the FlowCompletionReason type but refused at runtime with unsupported_completion: they are kernel facts, recorded when the kernel cancels a run or exhausts its budget, not verdicts a body can declare.

f.run returns the command's output; a step's .summary on f.agent is the CLI's final text. artifacts is always empty in 2.0.16 — populating it from what the agent actually wrote lands in the next release (flows#449).

Human gates

f.human(question, { to }) asks a person a yes/no question and parks the run until they answer. Nothing blocks: the kernel records a durable wait.human, the process exits 3, and the answer — whenever it comes — is journaled before the body continues from that line. Shipped in 2.0.18.

import { flow } from '@relayflows/surface';

type Input = { topic: string; channel: string };

export default flow<Input>('content-pipeline', { budget: '$5/run' }, async (f, input) => {
  await f.agent('writer', {
    task: `Write a post about ${input.topic} to post.md.`,
  }).gate({ type: 'subprocess_gate', command: 'test -s post.md' });

  const post = await f.run('cat post.md');

  const approved = await f.human(`Publish this post?\n\n${post}`, { to: 'slack:#marketing' });
  if (!approved) return f.done('declined');

  await f.slack.post(input.channel, post);
  f.done('success');
});

Run it locally and the run parks at the question:

$ flows run --local-agent content-pipeline.flow.ts --input '{"topic":"the launch","channel":"#marketing"}'
PARKED [run_parked] Run "01M2…" is waiting for slack:#marketing to answer human-1: "Publish this post?\n\n…"
Answer with: flows answer 01M2… human-1 yes|no
Then continue with: flows resume --local-agent 01M2…
  • The wait is named human-N — the call's ordinal, counted with every other authored operation, so a resumed body finds the same wait. With --json the report carries it as humanWait { waitId, question, to }.
  • flows answer <run-id> <wait-id> yes|no [--note <text>] [--by <identity>] records the decision as { answer, note?, answeredBy }. answeredBy is --by, else your OS user; the kernel stamps at_ms from its own clock and journals attribution: client_asserted, because the daemon socket — not the kernel — authenticated whoever ran it. The kernel closes a wait once: a second answer, or an answer to a wait the run isn't asking, is refused as human_wait_unknown.
  • flows resume <run-id> re-runs the body. Every step before the gate is memoized under its admission key, so nothing upstream repeats, and f.human resolves from the journaled answer — lowered as a human-N deterministic step carrying the answer on stdout, the same evidence shape as every other step.
  • A "no" is a value, not a failure. The body decides what it means; f.done('declined') exits 0.
  • f.human returns a Step, so a postfix named gate attaches like anywhere else: f.human(…).gate({ type: 'subprocess_gate', command: '…' }) is honoured on the lowered human-N step.

to says who is asked. Locally it's recorded with the question and printed in the PARKED line; on Cloud it's the delivery address (see Human approval on Cloud):

toWho's askedWho may answer
'slack:#marketing'a post in that Slack channelanyone in the channel
'slack:@khaliq'a Slack DM, mentioning themthat person
'github:@khaliq'a comment on the triggering issue or PR, mentioning themthat login
'khaliq'the deploy's approver, on the Slack thread or issue/PR the run was triggered fromthat person

Anything else — slack: with no target, github:#eng, an unknown provider, a handle with spaces — is refused at the call as human_to_invalid, before an ordinal is consumed or anything is journaled, rather than parking the run on a question nobody will receive. That refusal is on flows' current main (flows#472) and lands in the release after 2.0.19; earlier releases record any string.

Not yet: a timeout on the question is recorded but not enforced — a parked run waits until someone answers. A local run delivers nothing; it prints the flows answer invocation and waits. Slack and GitHub delivery, and answering by reply, need a Cloud run.

Verification

Every step's exit code is checked automatically. On top of that:

  • exit_code — a deterministic step's process exit code (always checked; declaring it is only meaningful on deterministic steps).
  • output_contains — an opt-in string match against the step's output. Fast to write, good for a smoke check.
  • json_schema on an llm step — the model's reply must parse and validate against the schema before anything downstream sees it. A schema of {} or true accepts anything and is flagged vacuous_gate.
  • Named gatesreferences_input, regex_match, word_count_bounds, and subprocess_gate (run a shell command against the output; exit 0 passes). Each lowers to a deterministic gate step in the same journal, so a resume replays the recorded verdict instead of re-judging.

In TypeScript the same named gates attach postfix: f.agent('review', {…}).gate({ type: 'subprocess_gate', command: 'test -s review.md' }). A callback gate, .gate((r) => r.artifacts.includes('review.md')), is refused in 2.0.16 (unsupported_gate: a closure can't be journaled); the next release runs it after the step and journals the verdict (flows#449).

A step that fails its check is recorded as verification_failed, with exactly which check failed. The agent insisting it went fine doesn't override that.

Permissions and recovery

An agent step declares what it's allowed to touch — fileGlobs, accessPreset — and what happens if it crashes mid-edit. These are YAML step fields with no TypeScript equivalent today; a TypeScript body leaves recovery to the kernel's default (reset), and will reach a YAML step through f.dispatch once that verb ships (see the Note above — it doesn't execute yet in 2.0.16). Author the step in YAML now if you need permissions/recoveryMode before then:

import { flow } from '@relayflows/surface';

export default flow('hello-agent', async (f) => {
  await f.run('printf hello');

  // No recoveryMode/permissions here: a crashed attempt resets to the
  // pinned revision (the default), and the workspace is the daemon's cwd.
  await f.agent('edit', {
    task: 'Produce the hello artifact and print agent-ok when it exists.',
    cli: 'claude',
  }).gate({ type: 'regex_match', pattern: 'agent-ok' });

  f.done('success');
});
  • reset (the default) — the next attempt starts fresh from the pinned workspace revision, with no half-finished edit left behind.
  • inspect — the next attempt starts inside the dirty workspace, with the failed attempt's trajectory tail injected as context, and decides whether to continue or redo.
  • manual — parks the run as needs_human with a diff of the pinned revision against whatever's actually there.

maxIterations caps how many attempts a step gets before one of those three outcomes has to happen. A run-level budget caps the whole flow the same way, declared once and enforced by the kernel instead of tracked by hand. Write it as "$5/run" or "$20/day" (dollars, priced from a frozen per-model table), or as { tokens?, dollars?, wallclock? }{ dollars: 5, wallclock: "45m" } bounds both. Tokens and wallclock apply to every step; dollars apply to steps whose model has a frozen price. A Codex step, which picks its own model, is reported as budget_unmetered under a dollar budget and runs; it counts toward tokens and wallclock but cannot cross the dollar limit. Crossing a limit lets the running step finish and refuses the next one with budget_exceeded.

Next