Multi-agent flows

Several named agents, different CLIs and models, one flow — plus durable human approval and handoff to child flows.

A flow isn't locked to one CLI or one model. Name as many agents as the task needs, give each one its own cli and model, and assign them to steps — the planning step can run on Opus, the edit can go to Codex, and the review can come back to Claude, all in one run, each step's identity and cost accounted separately.

Named agents

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

// Each call names its own cli and model; a plain object keeps them in one place.
const planner = { cli: 'claude', model: 'claude-opus-5' };
const implementer = { cli: 'codex', model: 'gpt-5.6-codex' };
const reviewer = { cli: 'claude', model: 'claude-sonnet-4-6' };

export default flow('ship-feature', async (f) => {
  const plan = await f.agent('plan', {
    ...planner,
    task: 'Plan the implementation for: add OAuth2 support',
  });

  await f.agent('implement', {
    ...implementer,
    task: `Implement this plan:\n${plan.summary}`,
  });

  await f.agent('review', {
    ...reviewer,
    task: 'Review the diff for correctness and security. End with APPROVED or BLOCKED.',
  }).gate({ type: 'regex_match', pattern: 'APPROVED' });

  f.done('success');
});

In TypeScript every f.agent call names its own cli and model (flows#310); the name argument labels the step in the journal. In YAML the agents: map declares each { cli, model } pair once and a step's agent: selector resolves to it at compile time, and flows check flags a named agent nobody selects, or one a step overrides without using, so a stale declaration doesn't quietly rot in the spec.

The reusable named-agent map is YAML/JSON authoring only today. TypeScript has no agents: header to select a declared pair from (flows#300); an ordinary object spread, as above, is the idiom until it does.

Asking a human, then handing off

Ctx declares both verbs, and this typechecks and passes flows check — but neither runs yet in 2.0.16. Treat the shape below as the intended design, not something to ship on today:

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

export default flow('ship-feature', async (f) => {
  const plan = await f.agent('planner', {
    task: 'Plan implementation for: add OAuth2 support',
  });

  const ok = await f.human(`Ship this?\n${plan.summary}`, { to: 'khaliq' });
  if (!ok) return f.done('declined');   // a decision not to act, not a kernel cancellation

  const pr = await f.dispatch('garden/implement', plan);
  f.done('success');
});

Once wired, f.human is meant to park the run on a durable wait — nothing sits there blocking a thread, and the wait would survive a restart exactly like a crash mid-step does. A "no" ends the run with declined, the verdict for choosing not to proceed; canceled is reserved for the kernel. f.dispatch is meant to hand the plan to a named child flow and return its typed result, so one large flow decomposes into several smaller ones instead of a script that tries to do everything.

Verified directly: flows run on a flow that reaches f.human fails with unsupported_verb: the initial authored executor does not lower f.human, and the same for f.dispatch. Both are being implemented on flows' feat/f-human branch and ship in the release after 2.0.17. Until then, do durable approval in YAML instead (recoveryMode: manual parks a step as needs_human with a diff for a person to resolve), or use the shipped human gate directly: f.done('needs_human') parks the run (exit 3) for a person to act on, then flows resume <run-id> continues it.

Next