Workflows¶
A workflow is a function from an input to a Result or a list of them. They
compose because they share that contract, and for no other reason. Agents and
flows are data; our code decides what runs next. A workflow is the TypeScript
for what a flow file cannot say.
type Result = {
agent: string;
output: string; // last assistant text - what feeds the next step
messages: AgentMessage[];
usage: Usage; // this turn only
ok: boolean;
error?: string;
};
Every combinator returns a WorkflowResult: that Result, read for the whole
workflow, plus steps, the trail that led to it. The reading is the
combinator’s own and is decided nowhere else. A chain is its last step and a
reduce its synthesis; a fan-out is its branches labelled one after the other,
failed if any of them failed; an orchestration is its synthesis when it has
one and its planner otherwise. What a workflow says beyond that - converged,
plan, answers - it says in fields of its own, because ok only ever means
that every turn ran. A caller or a tool call therefore reads any of them the
same way, and never has to rebuild one.
Every combinator is an exported function. No classes, no inheritance, no global registry.
The combinators, and two patterns that are not¶
There are eight combinators: chain, fanOut, loop, reduce, route,
orchestrate, interview and swarm. Two more sections cover patterns you
would look for here, and neither is a combinator: a reviewer until approval is
written as a flow, and a subagent with subagents is given a tool,
delegateTool.
chain - 1 to 1 to 1¶
The output of step n is the input of step n+1.
const result = await chain({ steps: [scout, reviewer], input: "…" });
result.steps; // the intermediate results
A failing step stops the chain: there is no input left for the next one.
fanOut - 1 to N¶
N subtasks in parallel, with bounded concurrency.
const { results, usage } = await fanOut({ agent: scout, tasks, concurrency: 2 });
usage.busyMs / usage.wallMs; // the parallelism actually achieved
Results come back in the order of tasks, not of completion. A failing
branch becomes a Result with ok: false in its slot and the others carry on,
unless failFast is set. As one Result, the fan-out’s output is every
branch’s under a heading naming its agent, a failed one marked with its error,
and its ok is false the moment one branch failed.
loop - 1 to 1, until a bar is reached¶
const review = await loop({
steps: [coder, reviewer],
input: "Implement the parser",
until: (step) => saysWord(step.output, "LGTM"),
maxIterations: 5, // defaults to 5
lifetime: "workflow",
});
review.converged; // did it reach the bar, or run out of iterations?
Reaching a cap is not success. ok says the last turn ran without a model
error; converged says the work reached the bar. A loop that burns through
maxIterations with every turn technically fine is ok: true, converged: false,
and collapsing the two would hide the only thing worth knowing.
reduce - N to 1¶
One agent turns a fan-out’s branches into a single answer.
const answer = await reduce({ agent: synthesiser, results, input: question });
answer.steps; // the branches, then the synthesis
Failed branches are shown, not dropped. A synthesis of six reports when two crashed, with nothing saying so, is a confident lie, and the caller can no longer tell a thin answer from thin evidence. Pass only the successes if that is what you want; filtering an array needs no option.
reduce returns the branches in steps followed by the synthesis, because the
cost of an N to 1 is the cost of everything that produced it.
route - a classifier picks the destination¶
const handled = await route({ router, destinations: [coder, scout], input: task });
handled.destination?.name; // who was picked, or undefined
orchestrate - an agent decides the split¶
const done = await orchestrate({
planner,
workers: [scout, reviewer],
input: "Explain how usage is measured, and whether it can be trusted",
reduceWith: synthesiser, // optional: one answer instead of N
maxTasks: 3, // defaults to 8
});
done.plan; // validated against the known agents, before anything spawns
done.answer; // present only when reduceWith was given
A reviewer until approval, and a delivery¶
A coder and a reviewer talking until the reviewer approves, and a delivery
(plan, a pair per subtask, the project’s check, an audit of the whole), are
written as a flow now: the pair and deliver loops of the shipped
build. The reviewer decides through the
verdict tool, what it raises is kept as obligations until it closes them,
and each subtask works in a copy of the repository. See Flows and
Deliver a change.
interview - the agent questions the user¶
const { brief, answers } = await interview({ agent: interviewer, input: request, ask });
See Deliver a change.
swarm - several members, one job, nobody dividing it¶
Every combinator above decides who does what. A swarm decides none of it: the members are told the same goal, handed a board to talk on and a set of claims to take work from, and what they divide between them is theirs.
const done = await swarm({
members: [{ agent: member, count: 3 }],
goal: "describe every file under src/reporters/",
claims: createClaims({ keys: files }),
rounds: 2,
});
done.posts; // the run's social history, in order
done.claims; // what was held at the end, and what the swarm released
done.converged // whether `until` fired. Reaching the round cap is not success
A round is one ask per live member. A member whose turn fails is asked again
the next round. After two failed turns in a row it drops out rather than costing
every remaining round, and keeps the turn that failed as its answer. lifetime defaults to "workflow" here and nowhere else: a member that
forgets the last round cannot build on what it saw. For the same reason
lifetime: "task" with more than one round is refused outright - a member’s id
is its name on the board, and a task-lifetime member gets a new one every round.
With no board, no claims and one round, a swarm is a fan-out. That is the control arm: the board’s worth is the difference between the two, measured, on the job you actually have.
Swarms is the page on it: what a member is, what running one says, and what a board does not do. Two things a run of it showed, both now built in. Members are handed what is new on the board rather than made to fetch it, because once something arbitrates they stop reading it altogether. And whatever a member still held is released when it goes, because claims left by a member that has closed are work nobody will do and nobody can take.
A subagent with subagents¶
An agent whose tools: names subagent can delegate in turn. The tool is built
by the caller and handed over like any other:
const answer = await run(explorer, question, {
cwd: repo,
customTools: (parentId) => [delegateTool({ agents, parentId, cwd: repo })],
});
agents is the roster it may name, and nothing else. It goes two levels deep by
default: the session, a child, a grandchild. At the bound the tool refuses and
says so, rather than being withheld and leaving a model calling something that
is not there.
customTools is written as a function here for one reason: parentId is the id
spawn is about to give this subagent, and passing it is what has the children
measured under it rather than beside it. See
Measurements. A list still works, and a tool that spawns
nothing has no use for the id. A workflow that adds a tool of its own beside
whatever its caller offered composes the two with offerBoth, whichever shape
either was written in: that is how swarm hands every member the board.
examples/14-delegation-tree.ts is one explorer, three scouts and one answer,
and it prints what the tree cost.
Writing one in Markdown¶
A task graph of agents, branches, loops and questions can be written as a
file rather than as code, and run by /run, /step and the subagent tool.
See Flows.
What they all accept¶
{ lifetime, signal, timeoutMs, openInHerdr, model, onEvent, bus, cwd, sessionDir, exportDir, spawn }
Same names, same defaults everywhere, plus whatever is specific to each one
(concurrency and failFast for fanOut, until and maxIterations for
loop, and so on).
spawn is an injectable parameter, never a hard import inside a combinator.
That is what lets every workflow be tested without a network.
model puts every subagent of the workflow on one model, whatever their
frontmatter says. It is an override, not a default, and that is the point: it
lets the same workflow run against different LLMs without editing an agent
file, and a pinned agent slipping through a sweep would make the comparison
measure a mixture.
await loop({ steps: [coder, reviewer], input, model: "anthropic/claude-sonnet-5" });
Writing one in TypeScript¶
A combinator is a function over a SubagentPool. The pool takes the options
above and plays the turns; the combinator only says who speaks, in what order,
and what is done with the answer.
export async function twice(options: WorkflowOptions & { agent: Agent; input: string }) {
const pool = new SubagentPool(options);
try {
const first = await pool.turn(options.agent, options.input);
if (!first.ok) return first;
return await pool.turn(options.agent, `Do it again, better:\n${first.output}`);
} finally {
await pool.closeAll();
}
}
turn is one question and one answer. It refuses without spawning when the
signal is already aborted, runs the turn with the workflow’s signal and
timeoutMs, and gives the subagent back whatever happened - which in "task"
lifetime closes it. The key decides who shares a memory in a persistent
lifetime: it defaults to the agent’s name, and a fan-out passes the branch
instead so two branches never meet.
hold is for a conversation: it hands back an id and an ask for several
turns in a row, and the subagent lives until closeAll whatever the lifetime.
interview holds its interviewer, swarm holds its members. A hold is refused
the way a turn is when the signal is already aborted: nothing is spawned, and
every ask of it answers aborted.
pool.trail is what the turns add up to: every turn played, refusals included,
in steps; their usage summed over the pool’s own clock in usage(); the first
that failed in broken(). A combinator reports from there rather than keeping a
list and a clock of its own:
} finally {
await pool.closeAll();
}
return { ...last, steps: pool.trail.steps, usage: pool.trail.usage() };
A workflow whose clock must start before its pool can exist, or that composes
other workflows and has no pool, opens a Trail itself and records on it: the
pool takes one as its second argument, and record hands back what it was
given so a step is recorded where it is made.
closeAll goes in a finally. Whoever opens, closes, cancellation included.
Deadlines¶
timeoutMs is a per-turn deadline, and it has no default.
One ask is one session.prompt(), and pi’s agent loop is a while (true) with
no step cap: it runs as long as the model keeps requesting tools. A weak model
that hallucinates a tool name, gets “unknown tool” back and asks again will loop
until something stops it. Observed in the wild: 79 calls to a non-existent tool,
around 500k input tokens, in a single turn.
await coder.ask(task, { timeoutMs: 120_000 });
await fanOut({ agent: scout, tasks, timeoutMs: 60_000 }); // per branch
There is no default because the library does not get to decide that a legitimate task took too long. Set one on anything unattended.
A turn cut by its deadline fails with "timed out after 2m", one called off by
the caller’s signal with "aborted", and one a person stopped with
"stopped". The bound reads the way a flow file writes a duration, and in
milliseconds when it is not whole seconds: timeoutMs: 1_500 fails with
"timed out after 1500ms". A flow’s turn, question and check that run past
their bound say the same words.
A signal that aborts with a TimeoutError as its reason, as
AbortSignal.timeout does, counts as a deadline too: that is how a flow bounds
its attempts, and why its journal and its usage.json both read a timeout.
loop’s maxIterations does default to 5, and that is not inconsistent: an
iteration is a discrete, expensive unit with a meaningful small default, whereas
any wall-clock default would be arbitrary. The two guards sit at different
levels, and “loop forever” must not be reachable by forgetting an argument.
Failures and cancellation¶
A failure does not crash a workflow. It becomes a
Resultwithok: false, and the caller - or an explicitfailFast- decides whether to stop.A subagent that crashed after 12k tokens cost 12k tokens: its
Usageis filled in even whenokis false.Cancellation propagates. The
AbortSignalreaches every turn and closes the sessions that were opened.
How a deciding agent is read¶
orchestrate and route put a model in charge of a decision, and both read its
answer with a parsed convention rather than a tool call or structured output:
the weak models this library is run against do not reliably emit tool calls, and
structured output is not uniformly available across providers.
The parsers are lenient about shape and strict about content:
an unrecognised agent name is dropped, never remapped onto a plausible neighbour;
an ambiguous routing answer resolves to nothing rather than to the first match;
orchestratevalidates the whole plan before spawning anything, and fails if it exceedsmaxTasks.
Leniency was decided by real runs, not by taste. Asked for a JSON array, a planner answered with bare objects and no brackets, and a green suite had said nothing about it. The parser now accepts an array, a lone object, several objects on their own lines, or a fenced block, and reduces them all to the same plan.
Independence cannot be enforced, only asked for. When work is genuinely
sequential, use chain.
Reference¶
workflows/options-WorkflowOptions, what every combinator takes.workflows/pool-SubagentPool, where a workflow’s turns are played.workflows/trail-Trail, what the turns add up to.workflows/concurrent-mapConcurrent.