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

f.human is the approval gate, and it ships (2.0.18+): the run parks on a durable wait, and the person's answer comes back as the boolean the body branches on. f.dispatch is declared and typechecks, but still fails closed at runtime — treat that half of the sample as the intended shape:

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');
});

f.human parks the run — nothing sits blocking a thread, and the wait survives a restart exactly like a crash mid-step does. Locally, flows run exits 3 and prints the flows answer <run-id> human-1 yes|no that records the decision; flows resume <run-id> continues the body from the gate, with every step before it memoized. On Cloud, a bare handle like 'khaliq' names the deploy's approver: the question is delivered to the Slack thread or GitHub issue/PR the run was triggered from, they reply yes or no there, and the run resumes on its own. A "no" ends the run with declined, the verdict for choosing not to proceed; canceled is reserved for the kernel. Human gates has the full contract and the to forms; Cloud covers delivery.

Verified directly: flows run on a flow that reaches f.dispatch fails with unsupported_verb: the initial authored executor does not lower f.dispatch. It 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. Until it lands, keep the implementation in the same body — an f.agent step after the gate — rather than a second flow.

Next