Fan out research across parallel sub-agents
Someone asks: “Should we migrate our billing off Stripe?” That is not one question. It is five — fee structure, migration cost, feature parity, what our own code depends on, what the competitors did — and each one is a research thread with its own sources, its own dead ends, and its own several thousand tokens of raw material.
One agent answering all five in sequence is slow (five times the latency) and worse (by thread four its context is packed with thread one’s discarded sources). A coordinator with a team is faster and cleaner: it splits the question, dispatches the threads concurrently, and each worker returns one paragraph instead of a pile of pages.
The shape
Section titled “The shape”coordinator one model, one plan, one synthesis├── market-analyst ─┐├── cost-analyst │ all four dispatched in ONE turn├── code-auditor │ → they run concurrently└── competitor-scout ─┘The coordinator’s turn emits four task tool calls at once. Parallel tool calls in a single turn
already run concurrently in the shipped client loop — and task is just a tool.
Concurrency is free: there is no parallel() API to learn, no executor to configure. The
model asking for four things in one turn is the fan-out.
Each child gets a fresh transcript, spends its own window, and returns exactly one tool message to the parent. The coordinator’s context grows by four paragraphs, not by four research sessions.
1. Designing the team — does is the routing signal
Section titled “1. Designing the team — does is the routing signal”This is where fan-out quality is actually decided, and it is prose, not configuration.
The task tool’s description is composed from the caller’s team — sorted by name, each entry
built from that agent’s does (the same progressive-disclosure pattern as the skill tool).
That composed description is the entire basis on which the coordinator picks a
worker and writes its prompt. A team of four agents all described as “researches things” will get
random routing and overlapping work.
Weak does |
Strong does |
|---|---|
| “researches the market” | “Sizes a market and names the incumbents. Give it a product category and a geography.” |
| “looks at costs” | “Estimates one-time and recurring cost for a stated migration. Needs current volumes to be useful.” |
| “checks the code” | “Answers what OUR codebase depends on for a named vendor. Read-only; cites file:line.” |
Three rules that hold up in production:
- State the input shape. “Give it a product category and a geography” makes the coordinator write a better prompt, which is most of the win.
- State the boundary. “Read-only”, “does not have web access”, “US only” — the coordinator routes around limits it can see and hallucinates around limits it cannot.
- Make the names disjoint. Overlapping remits produce duplicated tokens, and duplicated tokens are the whole cost of fan-out.
Scoping backs the prose up structurally: uses is the worker’s toolkit view, and the registry is
the transitive closure of the entry agent’s team graph — an agent you did not wire in is not
reachable, and a task call naming an out-of-team agent is refused with the team’s names listed.
2. Bounding the fan-out — maxConcurrent
Section titled “2. Bounding the fan-out — maxConcurrent”An unbounded fan-out is a rate-limit incident. Two gates bound it, at different scopes:
| Gate | Where | What it bounds |
|---|---|---|
budget.maxConcurrent |
per parent agent | Running children under that parent. Admission is atomic with wake; over the cap, wakes queue FIFO and a completed sibling transfers its slot. Default 8. |
maxConcurrentTurns |
runtime-wide (RuntimeOptions) |
Concurrent LLM HTTP calls across the entire tree. Wraps only the HTTP call — never a whole run, which would deadlock a parent against its own children — and releases on acquirer death. Default 8. |
budget.maxChildren |
per parent agent | How many children may be spawned at all (a total, not a rate). |
Use maxConcurrent to shape the work (how wide is a sensible fan-out for this question) and
maxConcurrentTurns to respect your provider (how many requests may this process have in flight).
They compose: eight workers with maxConcurrentTurns: 3 still run eight threads, but only three
are ever talking to the model at once.
3. Seeing the true cost — usage roll-up
Section titled “3. Seeing the true cost — usage roll-up”Fan-out spends money in places you did not directly call. A child’s tokens, turns, and tool calls
roll up into its parent’s usage, so the root result’s totalTokens is the ledger for the
whole tree — coordinator plus every worker plus anything they delegated to.
That single number is what you meter, alert on, and compare against the single-agent baseline. For
a per-handle breakdown, the runtime’s read-only list() returns one row per handle with its id,
state, and rolled-up tokens:
const r = await coordinator.run(question, { llm })
console.log(r.totalTokens) // the WHOLE tree — this is your billfor (const h of r.runtime.list()) { // per-handle breakdown, tree order console.log(h.id, h.state, h.tokens) // root/coordinator.1 idle 184203 // root/coordinator.1/cost-analyst.2 idle 41120}4. A failed worker must not kill the run
Section titled “4. A failed worker must not kill the run”The most common real failure in a fan-out is one worker dying — a flaky source, a bad prompt, a timeout. If that propagated as an exception, one bad thread would discard four good ones.
It does not. A failed child Run crosses the handle boundary as a uniform isError tool result,
never an exception. The coordinator’s model sees the failure in its transcript
alongside the successes and decides what to do: re-prompt the worker, route the question to a
different one, or write the report with a stated gap. Mechanical retry and backoff already
ran at the level where the failure happened; what reaches the parent is the considered outcome.
Only the root throws to the host.
That is why the coordinator’s soul should say what to do with a failure. Prose is the policy:
If a worker returns an error or an empty result, do not abandon the report.Retry it ONCE with a narrower prompt. If it fails again, route the question tothe closest other worker, and if nothing covers it, state the gap explicitly inthe final report under "Not covered". Never present a gap as a finding.The same rule covers budget stops: a worker that hits its ceiling returns
status: "incomplete" with partial work preserved — a partial answer the coordinator can still
use, not a crash.
5. When a worker needs a human — durable resume
Section titled “5. When a worker needs a human — durable resume”A worker can hit something only a person can clear: a paywalled source needing login, an
approval, a clarifying question. It suspends, and the suspension escalates one hop at a time
to the nearest waitFor interpreter — worker → coordinator → root. Give the coordinator a
waitFor and the pause never leaves the process. Give it none and the root run returns
status: "pending" carrying the Request, with data.path naming the suspended handle.
Parked levels burn zero tokens. A tree can sit suspended for days at no cost.
On resume, the Answer routes to the deepest suspended handle, which continues from its
checkpoint (turns and usage grow, they never reset). The cascade re-runs each parent — and this
is the part that matters for a fan-out — a re-invoked task reattaches to the existing child by
task key (agent + prompt): settled ⇒ its recorded result, suspended ⇒ its pending, running ⇒
await. The other three workers are not re-run. Reattachment, not transcript inspection, is the
required idempotency mechanism in every port.
const r = await coordinator.run(question, { llm })if (r.status === "pending") { const req = r.pending! // { id, kind, prompt, url?, data: { path } } await db.save(jobId, req) // plain serializable data await slack.post(channel, req.prompt) // ask the human, out of band // ... hours later, in the same process holding the runtime: await r.runtime.resume({ id: req.id, ok: true, data: { credential } })}The full orchestrator
Section titled “The full orchestrator”Four workers, disjoint remits, a bounded fan-out, and a coordinator whose soul tells it to dispatch in parallel and how to handle a failure.
import { createToolkit, agents } from "toolnexus"
const llm = { baseUrl: "https://openrouter.ai/api/v1", style: "openai" as const, model: "anthropic/claude-sonnet-4" }
// Workers get a read-only view: fetch + search, never write or shell.const research = await createToolkit({ builtins: { tools: { bash: false, write: false, edit: false, apply_patch: false } },})const tools = research.tools()
const worker = (name: string, does: string, soul: string) => agents.agent(name, { does, soul, uses: { tools }, budget: { maxTurns: 15, maxTokens: 80_000 } })
const team = [ worker("cost-analyst", "Estimates one-time and recurring cost of a stated migration. Needs current volumes to be useful.", "Produce a cost range with the assumptions named. Say 'unknown' rather than inventing a number."), worker("competitor-scout", "Finds what comparable companies did about a named decision, and what happened after.", "Three concrete examples with sources. No generic industry commentary."), worker("code-auditor", "Answers what OUR codebase depends on for a named vendor. Read-only; cites file:line.", "Search the repo. Cite file:line for every dependency you report. Never guess."), worker("risk-officer", "Argues the strongest case AGAINST a proposed change. Adversarial by design.", "Your job is to find what breaks. Be specific and concrete, never rhetorical."),]
const coordinator = agents.agent("coordinator", { does: "Answers a strategic question by delegating research and synthesizing the results", soul: [ "Split the question into INDEPENDENT parts, one per worker, and dispatch them ALL in a", "SINGLE turn so they run in parallel. Give each worker a self-contained prompt — they", "cannot see this conversation.", "", "If a worker errors or returns nothing: retry ONCE with a narrower prompt, then route to", "the closest other worker. If nothing covers it, state the gap under 'Not covered'.", "Never present a gap as a finding.", "", "Finish with a recommendation, the evidence for it, and what would change your mind.", ].join("\n"), team, budget: { maxTurns: 25, maxTokens: 600_000, maxConcurrent: 4, maxChildren: 8, maxDepth: 2 },})
const r = await coordinator.run( "Should we migrate billing off Stripe? We do ~40k charges/month across US and EU.", { llm, maxConcurrentTurns: 3 }, // provider-friendly: 4 workers, 3 requests in flight)
console.log(r.status, r.totalTokens) // whole-tree ledgerconsole.log(r.text) // the brieffor (const h of r.runtime.list()) console.log(h.id, h.state, h.tokens)from toolnexus import create_toolkitfrom toolnexus.agents import agent, Budget
llm = {"base_url": "https://openrouter.ai/api/v1", "style": "openai", "model": "anthropic/claude-sonnet-4"}
research = await create_toolkit( builtins={"tools": {"bash": False, "write": False, "edit": False, "apply_patch": False}})tools = research.tools()
def worker(name, does, soul): return agent(name, does=does, soul=soul, uses={"tools": tools}, budget=Budget(max_turns=15, max_tokens=80_000))
team = [ worker("cost-analyst", "Estimates one-time and recurring cost of a stated migration. Needs current volumes to be useful.", "Produce a cost range with the assumptions named. Say 'unknown' rather than inventing a number."), worker("competitor-scout", "Finds what comparable companies did about a named decision, and what happened after.", "Three concrete examples with sources. No generic industry commentary."), worker("code-auditor", "Answers what OUR codebase depends on for a named vendor. Read-only; cites file:line.", "Search the repo. Cite file:line for every dependency you report. Never guess."), worker("risk-officer", "Argues the strongest case AGAINST a proposed change. Adversarial by design.", "Your job is to find what breaks. Be specific and concrete, never rhetorical."),]
coordinator = agent( "coordinator", does="Answers a strategic question by delegating research and synthesizing the results", soul=( "Split the question into INDEPENDENT parts, one per worker, and dispatch them ALL in a\n" "SINGLE turn so they run in parallel. Give each worker a self-contained prompt — they\n" "cannot see this conversation.\n\n" "If a worker errors or returns nothing: retry ONCE with a narrower prompt, then route to\n" "the closest other worker. If nothing covers it, state the gap under 'Not covered'.\n" "Never present a gap as a finding.\n\n" "Finish with a recommendation, the evidence for it, and what would change your mind." ), team=team, budget=Budget(max_turns=25, max_tokens=600_000, max_concurrent=4, max_children=8, max_depth=2),)
r = await coordinator.run( "Should we migrate billing off Stripe? We do ~40k charges/month across US and EU.", llm=llm, max_concurrent_turns=3, # 4 workers, 3 requests in flight)
print(r.status, r.total_tokens) # whole-tree ledgerprint(r.text)llm := &agents.LLMOptions{ BaseURL: "https://openrouter.ai/api/v1", Style: tn.StyleOpenAI, Model: "anthropic/claude-sonnet-4",}
research, _ := tn.CreateToolkit(ctx, tn.Options{Builtins: tn.BuiltinsConfig{ Tools: map[string]bool{"bash": false, "write": false, "edit": false, "apply_patch": false}}})tools := research.Tools()
worker := func(name, does, soul string) *agents.Agent { return agents.New(name, agents.Spec{ Does: does, Soul: soul, Tools: tools, Budget: &agents.Budget{MaxTurns: 15, MaxTokens: 80_000}, })}
team := []*agents.Agent{ worker("cost-analyst", "Estimates one-time and recurring cost of a stated migration. Needs current volumes to be useful.", "Produce a cost range with the assumptions named. Say 'unknown' rather than inventing a number."), worker("competitor-scout", "Finds what comparable companies did about a named decision, and what happened after.", "Three concrete examples with sources. No generic industry commentary."), worker("code-auditor", "Answers what OUR codebase depends on for a named vendor. Read-only; cites file:line.", "Search the repo. Cite file:line for every dependency you report. Never guess."), worker("risk-officer", "Argues the strongest case AGAINST a proposed change. Adversarial by design.", "Your job is to find what breaks. Be specific and concrete, never rhetorical."),}
coordinator := agents.New("coordinator", agents.Spec{ Does: "Answers a strategic question by delegating research and synthesizing the results", Soul: `Split the question into INDEPENDENT parts, one per worker, and dispatch them ALL in aSINGLE turn so they run in parallel. Give each worker a self-contained prompt — theycannot see this conversation.
If a worker errors or returns nothing: retry ONCE with a narrower prompt, then route tothe closest other worker. If nothing covers it, state the gap under "Not covered".Never present a gap as a finding.
Finish with a recommendation, the evidence for it, and what would change your mind.`, Team: team, Budget: &agents.Budget{MaxTurns: 25, MaxTokens: 600_000, MaxConcurrent: 4, MaxChildren: 8, MaxDepth: 2},})
r, rt := coordinator.Run( agents.Options{LLM: llm, MaxConcurrentTurns: 3}, // 4 workers, 3 requests in flight "Should we migrate billing off Stripe? We do ~40k charges/month across US and EU.")
fmt.Println(r.Status, r.TotalTokens) // whole-tree ledgerfor _, h := range rt.List() { fmt.Println(h.ID, h.State, h.Tokens) // per-handle breakdown}RuntimeOptions rt = new RuntimeOptions() .baseUrl("https://openrouter.ai/api/v1").style("openai") .defaultModel("anthropic/claude-sonnet-4") .maxConcurrentTurns(3); // 4 workers, 3 requests in flight
Toolkit research = Toolkit.create(new Toolkit.Options().builtins(Map.of("tools", Map.of("bash", false, "write", false, "edit", false, "apply_patch", false))));Tool[] tools = research.tools().toArray(new Tool[0]);
BiFunction<String, String[], Agents.Agent> worker = (name, ds) -> Agents.agent(name, new Agents.AgentSpec() .does(ds[0]).soul(ds[1]).tools(tools) .budget(new Budget().maxTurns(15).maxTokens(80_000)));
Agents.Agent costAnalyst = worker.apply("cost-analyst", new String[]{ "Estimates one-time and recurring cost of a stated migration. Needs current volumes to be useful.", "Produce a cost range with the assumptions named. Say 'unknown' rather than inventing a number."});Agents.Agent competitorScout = worker.apply("competitor-scout", new String[]{ "Finds what comparable companies did about a named decision, and what happened after.", "Three concrete examples with sources. No generic industry commentary."});Agents.Agent codeAuditor = worker.apply("code-auditor", new String[]{ "Answers what OUR codebase depends on for a named vendor. Read-only; cites file:line.", "Search the repo. Cite file:line for every dependency you report. Never guess."});Agents.Agent riskOfficer = worker.apply("risk-officer", new String[]{ "Argues the strongest case AGAINST a proposed change. Adversarial by design.", "Your job is to find what breaks. Be specific and concrete, never rhetorical."});
Agents.Agent coordinator = Agents.agent("coordinator", new Agents.AgentSpec() .does("Answers a strategic question by delegating research and synthesizing the results") .soul(""" Split the question into INDEPENDENT parts, one per worker, and dispatch them ALL in a SINGLE turn so they run in parallel. Give each worker a self-contained prompt — they cannot see this conversation.
If a worker errors or returns nothing: retry ONCE with a narrower prompt, then route to the closest other worker. If nothing covers it, state the gap under "Not covered". Never present a gap as a finding.
Finish with a recommendation, the evidence for it, and what would change your mind.""") .team(costAnalyst, competitorScout, codeAuditor, riskOfficer) .budget(new Budget().maxTurns(25).maxTokens(600_000) .maxConcurrent(4).maxChildren(8).maxDepth(2)));
TaskResult r = coordinator.run(rt, "Should we migrate billing off Stripe? We do ~40k charges/month across US and EU.");
System.out.println(r.status() + " " + r.totalTokens()); // whole-tree ledgervar rt = new RuntimeOptions{ BaseUrl = "https://openrouter.ai/api/v1", Style = "openai", Model = "anthropic/claude-sonnet-4", MaxConcurrentTurns = 3, // 4 workers, 3 requests in flight};
await using var research = await Toolkit.CreateAsync(new Toolkit.Options{ Builtins = new Dictionary<string, object?> { ["tools"] = new Dictionary<string, object?> { ["bash"] = false, ["write"] = false, ["edit"] = false, ["apply_patch"] = false } },});var tools = research.Tools();
Agent Worker(string name, string does, string soul) => new(name, new AgentSpec{ Does = does, Soul = soul, Uses = tools, Budget = new Budget { MaxTurns = 15, MaxTokens = 80_000 },});
var team = new List<Agent>{ Worker("cost-analyst", "Estimates one-time and recurring cost of a stated migration. Needs current volumes to be useful.", "Produce a cost range with the assumptions named. Say 'unknown' rather than inventing a number."), Worker("competitor-scout", "Finds what comparable companies did about a named decision, and what happened after.", "Three concrete examples with sources. No generic industry commentary."), Worker("code-auditor", "Answers what OUR codebase depends on for a named vendor. Read-only; cites file:line.", "Search the repo. Cite file:line for every dependency you report. Never guess."), Worker("risk-officer", "Argues the strongest case AGAINST a proposed change. Adversarial by design.", "Your job is to find what breaks. Be specific and concrete, never rhetorical."),};
var coordinator = new Agent("coordinator", new AgentSpec{ Does = "Answers a strategic question by delegating research and synthesizing the results", Soul = """ Split the question into INDEPENDENT parts, one per worker, and dispatch them ALL in a SINGLE turn so they run in parallel. Give each worker a self-contained prompt — they cannot see this conversation.
If a worker errors or returns nothing: retry ONCE with a narrower prompt, then route to the closest other worker. If nothing covers it, state the gap under "Not covered". Never present a gap as a finding.
Finish with a recommendation, the evidence for it, and what would change your mind. """, Team = team, Budget = new Budget { MaxTurns = 25, MaxTokens = 600_000, MaxConcurrent = 4, MaxChildren = 8, MaxDepth = 2 },});
var r = await coordinator.RunAsync(rt, "Should we migrate billing off Stripe? We do ~40k charges/month across US and EU.");
Console.WriteLine($"{r.Status} {r.TotalTokens}"); // whole-tree ledgeralias Toolnexus.Agents
llm = %{base_url: "https://openrouter.ai/api/v1", style: "openai", model: "anthropic/claude-sonnet-4"}
{:ok, research} = Toolnexus.create_toolkit( builtins: %{tools: %{"bash" => false, "write" => false, "edit" => false, "apply_patch" => false}})
tools = Toolnexus.Toolkit.tools(research)
worker = fn name, does, soul -> Agents.agent(name, does: does, soul: soul, uses: %{tools: tools}, budget: %{max_turns: 15, max_tokens: 80_000})end
team = [ worker.("cost-analyst", "Estimates one-time and recurring cost of a stated migration. Needs current volumes to be useful.", "Produce a cost range with the assumptions named. Say 'unknown' rather than inventing a number."), worker.("competitor-scout", "Finds what comparable companies did about a named decision, and what happened after.", "Three concrete examples with sources. No generic industry commentary."), worker.("code-auditor", "Answers what OUR codebase depends on for a named vendor. Read-only; cites file:line.", "Search the repo. Cite file:line for every dependency you report. Never guess."), worker.("risk-officer", "Argues the strongest case AGAINST a proposed change. Adversarial by design.", "Your job is to find what breaks. Be specific and concrete, never rhetorical.")]
coordinator = Agents.agent("coordinator", does: "Answers a strategic question by delegating research and synthesizing the results", soul: """ Split the question into INDEPENDENT parts, one per worker, and dispatch them ALL in a SINGLE turn so they run in parallel. Give each worker a self-contained prompt — they cannot see this conversation.
If a worker errors or returns nothing: retry ONCE with a narrower prompt, then route to the closest other worker. If nothing covers it, state the gap under "Not covered". Never present a gap as a finding.
Finish with a recommendation, the evidence for it, and what would change your mind. """, team: team, budget: %{max_turns: 25, max_tokens: 600_000, max_concurrent: 4, max_children: 8, max_depth: 2} )
r = Agents.run(coordinator, [llm: llm, max_concurrent_turns: 3], "Should we migrate billing off Stripe? We do ~40k charges/month across US and EU.")
IO.puts("#{r.status} #{r.total_tokens}") # whole-tree ledgerWhen fan-out beats one big agent — and when it does not
Section titled “When fan-out beats one big agent — and when it does not”Fan-out is not free. Be honest about the trade before you reach for it.
It wins when:
| Condition | Why |
|---|---|
| The parts are independent | Nothing serializes; wall-clock collapses toward the slowest single worker. |
| Each part generates a lot of raw material | This is the real win. Five workers reading forty sources each return five paragraphs, not two hundred pages. The coordinator’s context stays small and its synthesis stays sharp. |
| The parts need different tool scopes | The code auditor gets the repo, the market scout gets the web, and neither can reach the other’s surface. One agent with the union of both is a larger attack surface. |
| You want an adversarial step | A risk-officer on a fresh transcript genuinely argues the other side. The same model that just wrote the recommendation will not. |
| You want cheap parts | Per-agent model lets a summarizer run on a small model while the coordinator runs on a large one. |
It loses when:
| Condition | Why |
|---|---|
| The parts are dependent | Worker 2 needs worker 1’s answer, so nothing runs in parallel and you paid the overhead anyway. |
| The task is small | Every task call costs a spawn, a fresh system prompt, and a synthesis turn. Below roughly a few thousand tokens of real research per part, one agent is cheaper and faster. |
| The parts overlap | Two workers researching the same thing bill you twice for one answer. This is a does-design failure. |
| You need the full transcript | The parent gets the child’s final text and metadata — nothing else. If you need the child’s intermediate reasoning, delegation is the wrong tool. |
| Total tokens are the binding constraint | Fan-out trades tokens for latency and quality. Each worker pays its own system prompt and its own framing of the question. Expect a fan-out to cost more tokens than one sequential agent — you are buying speed and context hygiene, not savings. |
See also
Section titled “See also”- Sub-agents & teams — the full delegation surface, the six host verbs, and the backpressure gates.
- Build a Claude-Code-style coding agent — the depth-first counterpart: one agent acting on a working tree.
- Suspension & the human loop — the primitive escalation and durable resume ride on.
- Persona agents — long-lived agents, memory, and compaction.
- SPEC.md §7D (state machine, gates, budgets, reattachment) and §8 (parallel tool calls, retries).