Free playbooks in your inbox

Why You Shouldn't Use a Model to Merge Claude Code Subagent Results

A model asked to combine parallel agent findings is slow, gives different answers on different days, and can return a file path no searcher ever found. Four lines of shell do it deterministically and print a duplicate count that tells you how well you scoped the run.

From the youcanbuildthings catalog ▸ Build-tested

Hello builders,

A model asked to combine the findings from parallel agents will give you a different answer tomorrow and can return a file path no searcher ever found. The obvious way to merge results from parallel AI agents is to hand the whole pile to a model and ask it to combine them, and that is wrong for three reasons: it pays a full agent’s start-up cost to do a job sort -u does perfectly, it’s non-deterministic so the same inputs give us different answers on different days, and it can invent a result no searcher ever returned. The merge should be code, and in the graph below it is four lines of shell that cost nothing.

A five-stage pipeline. Stage 1 SCOPER runs at 10:09:32 and writes .rg/runs/scoper.json. Stage 2 SEARCHERS PARALLEL shows three nodes all starting at 10:09:39, named search_model_id_consistency_across_notebooks_, search_api_key_exposure_in_committed_files_ and search_notebook_top_to_bottom_execution_validation_, each writing its own JSON file. Stage 3 MERGE is a box reading sort -u, no model call, raw findings 24, after dedupe 23, duplicates cut 1. Stage 4 JUDGE runs at 10:10:02 with tools Read and disallowedTools Bash, and keeps 16 of 23. Stage 5 COST shows $0.1606006 for the whole run.

Five stages, and every edge on that picture ends at a file. 1. Scoper decides what the searchers are allowed to look at. 2. Searchers run in parallel, one per concern. 3. Merge deduplicates in plain code. 4. Judge rules on what survived. 5. Cost adds up the envelopes. Four of those steps are a model, exactly one of them isn’t, and the one that isn’t is the step almost every tutorial skips.

That run cost $0.1606006 on a 649-file repository, and the timestamps are the proof the fan-out was real: the scoper started at 10:09:32, all three searchers started at 10:09:39, and the judge started at 10:10:02.

The scoper

The first node does no searching. It decides the smallest slice each branch needs, and skipping it is what made my first graph cost five dollars.

Two runs, same shape of graph. Unscoped: two searchers, each told to search the repository. 592 seconds, $5.12. Each one explored 649 files from scratch, in its own context window, paying the full cost of understanding the repo before answering anything. Scoped: the same two searchers, each confined to one directory. 21 seconds, $0.35. Nearly fifteen times cheaper.

The mechanism isn’t subtle once we’ve seen it. A node has no shared memory with its siblings, so whatever the fan-out has in common gets paid for once per branch. If all five of our searchers have to work out what the codebase is before they can search it, we buy that understanding five times, and that turns out to be the most expensive part of the whole run.

Two rules I hold to after getting this wrong repeatedly. The first is that the scoper’s output has to be machine-readable: three directory names, or a JSON array of paths. “You should probably focus on the API layer” is useless here, because the next stage is a for loop and a loop cannot act on a suggestion. The second is to use a single scoper. If you find yourself fanning out in order to decide how to fan out, the diagnostic should have sent you to a single agent.

The node you author, and the merge that follows it

A node is a Markdown file with frontmatter in .claude/agents/, which means the topology is diffable like anything else in the repo. Here is the searcher, complete:

---
name: searcher
description: Searches one scope of the repo for one pattern. Never writes.
tools: Bash, Read
disallowedTools: Write, Edit
model: haiku
maxTurns: 8
---

You search one directory for one thing.
Use `grep` and `ls`. Do not read whole files.
Reply with file paths, one per line, and nothing else. At most 8.

disallowedTools: Write, Edit means that node is structurally unable to modify your repo. It has not merely been instructed not to, and that difference is the guarantee you want when five of them are running at once. One warning from the docs that will cost you ten minutes the first time: if no entry in the tools list resolves to a real tool, the subagent usually fails to launch, so a misspelled Bash gives you a node that never starts at all, which is a different problem from one that behaves badly.

The orchestrator calls each node as its own process and keeps the envelope:

node() {                              # node <name> <model> <prompt>
  local name="$1" ; local model="$2" ; local prompt="$3"
  date +"%H:%M:%S" > "$OUT/runs/$name.start"
  claude -p --output-format json --model "$model" "$prompt" > "$OUT/runs/$name.json" </dev/null
}

That </dev/null is not decoration. Without it every scripted node waits three seconds for a stdin nobody is going to send, and a five-node graph pays it five times.

Then the dedupe itself, which is four lines:

cat "$OUT"/runs/search_*.json | jq -r '.result' \
  | tr -d ' ' \
  | grep -E '^[A-Za-z0-9_./-]+\.[a-z]+$' | sort > "$OUT/raw.txt"
sort -u "$OUT/raw.txt" > "$OUT/deduped.txt"

That grep -E line is doing real work. Agents wrap answers in prose no matter how firmly we ask them not to, so we get “Here are the files I found:” followed by paths followed by “Let me know if you’d like me to look deeper.” The regex is a shape filter. It keeps the lines that look like file paths and drops everything else, so the thing you depend on is the shape of the output and not a promise the node made about how it would answer.

On the run in the picture that produced 24 raw findings, 23 after dedupe, 1 duplicate cut, for free, deterministically, from a step that cannot invent a path.

And the duplicate count is worth reading as a diagnostic of your own scoping. On an earlier run where I gave each searcher a genuinely disjoint scope, the dedupe removed zero, which is not a failed step: it means the scopes did not overlap. Read the number this way:

  • Zero duplicates cut. The branches covered separate ground. Adding another branch will probably find genuinely new things.
  • A few cut, as in the run above (1 of 24). About right. Some overlap is unavoidable and cheap.
  • A third or more cut. The branches are stepping on each other, and the next branch will mostly re-find what you already have while paying a full agent’s floor to do it.

So I do not fix a number of branches up front. I add branches while the duplicate rate stays low and stop once it starts climbing. That is measurable on every run, it moves with the task, and the dedupe step already prints the number you need.

Then the judge rules on what is left, and its frontmatter is the interesting part: tools: Read with disallowedTools: Bash, so it has no way to go looking for anything. It can only rule on what it was handed, which is what makes it a judge and not a fourth searcher. Mine kept 16 of 23 candidates.

One warning about that node, because I shipped this bug and it cost me a run. The judge was told to keep the paths matching “the brief” and was never told what the brief was. Sometimes it guessed and returned a plausible list. Sometimes it came back with I don’t see a brief in your message. Could you clarify what you’re looking for?, which my merge step happily treated as a finding, because all a merge step can see is a line of text. A node only knows what you put in its prompt, so the scoper’s output has to be passed forward explicitly. There is no shared conversation for it to travel through, which is the same thing that makes the state file necessary in the first place.

How wide can we go

Two documented limits, and I went and checked both of them today instead of trusting my notes.

Twenty subagents can run at once. The twenty-first fails, and the error tells Claude not to retry:

By default, when 20 subagents are running in a session, spawning another with the Agent tool fails with Concurrent subagent limit reached, and the error tells Claude not to retry. Spawning succeeds again when the running count drops below the limit.

Nesting goes three layers deep by default, and the behaviour at the ceiling is worth knowing before designing around it:

By default, a subagent can spawn subagents of its own, up to three layers below the main conversation. At the depth limit, Claude Code withholds the Agent tool from every subagent except a fork, so a subagent at the limit does its delegated work itself and returns one summary.

Both are configurable, through CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS and CLAUDE_CODE_MAX_SUBAGENT_SPAWN_DEPTH. There is no cap on the total number over a session. (Claude Code documentation, Create custom subagents)

Check your own version before trusting any of that, with claude --version. This surface has moved repeatedly: nesting was five layers deep and unchangeable for a stretch of releases, then defaulted to one for two releases, then settled at three. If we read a confident answer about it online, check its date and then check our own build.

Now the failure that will actually bite, and it is not a model problem. My first version of the fan-out loop was the obvious for d in $DIRS. The scoper returned three concerns, as asked, and each one was a phrase. The shell split on whitespace, and my three-node fan-out became ten searchers, each hunting a single disconnected word like file or risk. The run cost $0.306, roughly double the correct version, and produced 73 findings of which 21 were duplicates.

Nothing errored and nothing warned me, and the output looked more impressive than the correct run did. That is how most of the expensive mistakes in this kind of system show up. There is no crash, the run just costs more and comes back with a worse answer. The fix is while IFS= read -r d ... done <<< "$DIRS", which gives you one node per line, not one per word.

So build the four-line merge before building anything clever, then look at what it prints. If duplicates cut is greater than zero, run sort raw.txt | uniq -d and read the paths two of our agents both found. If it comes back zero, the scopes were disjoint, which is good news and worth knowing before you widen the fan-out any further.

Now go build something this weekend!

John Cook

Why trust this? Every youcanbuildthings guide is pulled from a build-tested book: code that ran in production before it was written down.