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

version: '0.1.0'
name: ship-feature
agents:
  planner: { cli: claude, model: claude-opus-4-6 }
  implementer: { cli: codex, model: gpt-5.6-codex }
  reviewer: { cli: claude, model: claude-sonnet-4-6 }

steps:
  - id: plan
    type: agent
    agent: planner
    instruction: 'Plan the implementation for: add OAuth2 support'

  - id: implement
    type: agent
    agent: implementer
    dependsOn: [plan]
    instruction: 'Implement the plan from the previous step.'

  - id: review
    type: agent
    agent: reviewer
    dependsOn: [implement]
    instruction: 'Review the diff for correctness and security.'
    verification:
      type: output_contains
      value: 'APPROVED'

Each step's agent: selector resolves to that named { cli, model } pair at compile time, from an explicit declaration. flows check also flags a named agent nobody ever selects, and one that a step overrides without using, so a stale declaration doesn't quietly rot in the spec.

The named-agent map (agents: plus a step's agent: selector, reused across steps by name) is YAML/JSON authoring only today. In TypeScript, f.agent(name, options) takes { task, workspace?, cli?, model? } (flows#310) — so a call can set its own cli/model directly, but the name argument still just labels the call in the journal; there's no map to select a declared pair from, or to reuse across multiple calls, the way YAML's agent: selector does (flows#300).

Asking a human, then handing off

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('canceled');

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

f.human parks the run on a durable wait — nothing sits there blocking a thread, and the wait survives a restart exactly like a crash mid-step does. f.dispatch hands the plan to a named child flow and returns its typed result, so one large flow decomposes into several smaller ones instead of a script that tries to do everything.

Next