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 Result with ok: false, and the caller - or an explicit failFast - decides whether to stop.

  • A subagent that crashed after 12k tokens cost 12k tokens: its Usage is filled in even when ok is false.

  • Cancellation propagates. The AbortSignal reaches 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;

  • orchestrate validates the whole plan before spawning anything, and fails if it exceeds maxTasks.

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