Orchestration: When One Agent Is Enough
Multi-agent is a cost and complexity decision, not a default. Make it with numbers.
What you will be able to do
- Say, with a cost table in hand, whether a job needs one agent or several
- Build a fixed fan-out / fan-in: one-tool specialists in parallel on a small model, one tool-less synthesiser on a large one
- Design the contract between specialists and synthesiser the same way you designed the tool envelope
- Recognise the harness behaviour that quietly truncates big tool results, and fix it at the tool, not the harness
lesson-85. New: dayflow/orchestrate.py, fixtures/inbox_large.json (120 messages), dayflow brief --engine orch. Changed: read_inbox is paged. Diff: git diff lesson-84 lesson-85 -- dayflow/tools.py dayflow/prompts.py.Start from the question, not the architecture
Lesson 84's single agent reads three sources and writes the brief in one context. For a five-message inbox that is the right design, and this lesson will not talk you out of it. The question is what happens when one source grows. A 120-message inbox is about 66 KB of JSON. Put it in one context and every turn after the read pays to carry it; ask one model to read 120 emails and write a four-section brief and it will do both a little worse.
Orchestration is the name for splitting that. It has three honest benefits and three honest costs, and you should be able to recite both lists before you reach for it:
| It buys you | It costs you |
|---|---|
| Context isolation. The synthesiser never sees an email body. Its context is three short reports whatever the inbox size. | More system prompts. Four contexts, four prompts, four subprocess startups. |
| Routing. Each job goes to the smallest model that does it well. Reading and classifying is a Haiku job; writing the brief is a Sonnet job. | Wall time. Parallel specialists help, but the slowest one plus the synthesiser is still longer than one agent on a small input. |
| Narrow prompts. "Classify every message" is easier to write, test and evaluate (lesson 89) than "do everything". | A new failure mode. A specialist can return the wrong shape, and the synthesiser has to cope. |
Dayflow keeps both engines so you can run them on the same fixtures and read the numbers. The rest of this lesson is those numbers and what produced them.
Fixed fan-out, in code you can read
The SDK offers subagents the model spawns itself (ClaudeAgentOptions(agents={...})plus an Agent tool). Dayflow does not use that here, deliberately. When the model decides whether to delegate, your cost and latency become whatever it felt like today. Dayflow's fan-out is a tuple, so it is the same every run and it is testable without a model:
@dataclass(frozen=True)
class Specialist:
name: str
system: str
tool: str # exactly one registry tool
request: str # "Today is {today}. ..." - every new context needs the date (lesson 84)
max_turns: int = 3
SPECIALISTS = (
Specialist("inbox", prompts.INBOX_TRIAGE, "read_inbox", "Today is {today}. Triage the inbox.", max_turns=10),
Specialist("calendar", prompts.CALENDAR_READER, "read_calendar", "Today is {today}. Read the calendar."),
Specialist("tasks", prompts.TASKS_READER, "read_tasks", "Today is {today}. Read the tasks."),
)
# fan out: three sessions at once, each with ONE tool and a cheap model
legs = await asyncio.gather(*[run_specialist(s, today=today, ...) for s in SPECIALISTS])
reports = {leg.name: leg.text for leg in legs}
# fan in: no tools, no MCP, just the three reports
options = ClaudeAgentOptions(system_prompt=prompts.SYNTHESISER, tools=[], strict_mcp_config=True,
setting_sources=[], max_turns=1, model=model)
parts, result = await collect(query(prompt=prompts.synth_request(today, reports), options=options))Two details carry the lesson 84 discipline forward. Each specialist getsbuild_server(("read",), names=[spec.tool]): not the read tools, the tool. And every request states the date. The first orchestrated run skipped that and the calendar specialist reported "no events today" while the inbox specialist marked future deadlines as past. Three new contexts, three fresh chances to make the lesson 84 mistake.
The report shape is a contract
Lesson 82 made the tool envelope a fixed shape so the model could branch on ok. The specialist report is the same idea one level up. Each specialist prompt ends with "Output EXACTLY this, nothing else" and a template:
NEEDS_REPLY: - <from> | <subject> | <one-line gist> | <one-line suggested reply> DEADLINES: - <YYYY-MM-DD> | <what> | <source message subject> SKIPPED: <count> newsletters/promos, <count> automated Never invent messages. If read_inbox returns ok:false, output: INBOX_UNAVAILABLE: <error code>.
The *_UNAVAILABLE line is the part people forget. A specialist that runs out of turns or budget is a missing source, not a dead brief. run_specialist catches the failure and returns a leg whose text is INBOX_UNAVAILABLE: ...; the synthesiser's prompt already says what to do with that. Lesson 81's rule, again: a failing component is information the next stage needs, not an exception that kills the run.
max_turns and max_budget_usd do not come back as a ResultMessage withis_error; the stream raises ResultError with subtype andterminal_reason on it. Dayflow's shared collect() normalises that to one exception type so callers have one thing to catch. Lesson 84's is_error branches were dead code until this lesson found out. Read your SDK's exception docstrings.The harness has opinions about big tool results
The first large-inbox run asked read_inbox for 200 messages in one call. The hook recorded a 67 KB result. The specialist's report began: "The inbox report saw just the first 2KB of about 66KB, so other messages needing a reply may be missing." It was telling the truth. The session transcript shows what the model actually received:
<persisted-output>
Output too large (65.8KB). Full output saved to:
/Users/.../.claude/projects/.../tool-results/toolu_01J5fJWmagfZ2kSmZoeNJjJY.json
Preview (first 2KB):
[ { "type": "text", "text": "{\"ok\": true, \"data\": [{\"id\": \"m26\", ...Claude Code persists large tool results to disk and hands the model a preview, expecting it toRead the file. That is a sensible default for a coding agent. Dayflow's specialists havetools=[], so there is no Read, and that is correct: giving a triage agent the filesystem to work around a size limit is exactly the kind of trade lesson 88 is about.
So the fix goes in the tool. read_inbox now takes offset as well as limit(capped at 50) and returns {messages, total, next_offset}; the prompts say to keep calling until next_offset is null. Every page is about 14 KB. Pagination was already the right API in lesson 82 terms; the large inbox made it urgent. Note what did not change: handlers still return through dispatch(), and the single agent pages too, which is what makes the comparison below fair.
The PostToolUse hook received all 67 KB. If you are debugging "the model ignored my data", compare the hook payload with the session transcript under
~/.claude/projects. When they differ, the harness is in the middle.The numbers
Same fixtures, same date, Sonnet 5.5 for the single agent and the synthesiser, Haiku 5.5 for specialists.
# small inbox (5 messages)
sdk 5 turns ยท 4 tool calls ยท 6 in (+5000 cached) / 1150 out ยท $0.0213 ยท ~10s
orch leg model turns in cached out $ s
inbox claude-haiku-5-5 2 4 1992 2037 0.0013 11.0
calendar claude-haiku-5-5 2 4 1687 662 0.0006 5.6
tasks claude-haiku-5-5 2 4 1581 239 0.0004 3.3
synthesiser claude-sonnet-5-5 1 2 0 1049 0.0139 9.4
total 14 5260 3987 0.0162 20.3
# large inbox (120 messages), DAYFLOW_INBOX=large
sdk 8 turns ยท 7 tool calls ยท 10 in (+39923 cached) / 1954 out ยท $0.0835 ยท 19.5s
โณ read_inbox limit 25 offset 0 ยท 25/25 ยท 50/50 ยท 50/100 (it paged, as told)
"Needs a reply": 7 of the 9 human messages
orch inbox claude-haiku-5-5 6 12 52160 8279 0.0075 33.7
calendar claude-haiku-5-5 2 4 2146 971 0.0007 6.6
tasks claude-haiku-5-5 2 4 2040 270 0.0003 3.5
synthesiser claude-sonnet-5-5 1 2 0 865 0.0136 8.0
total 22 56346 10385 0.0221 41.6
"Needs a reply": 9 of 9- Small inbox: one agent wins. Orchestration is 25% cheaper and twice as slow. Twenty seconds for a morning brief that used to take ten is a worse product. Keep
--engine sdkas the default. - Large inbox: orchestration wins, and not for the reason the slides say. Four times cheaper, and the saving is almost entirely routing: the inbox specialist burned 52K cached tokens paging through the backlog, but at Haiku prices that is $0.0075. The synthesiser's isolated context saved tokens too, but it is the model choice that moved the bill.
- Thoroughness is the quiet win. The single agent, doing everything, missed two human messages (an HR deadline notice and the school form). The specialist with one job and a "classify every message" prompt found all nine. That is the argument for narrow prompts, and lesson 89 turns it into a number you can track.
- Wall time doubled either way. Six sequential Haiku turns to page the inbox is the long pole. If latency matters more than cost, split the inbox specialist by page range and run those in parallel too. That is one more
Specialistentry, not a redesign.
The inbox specialist re-reads every previous page on every turn, because that is what a conversation is. Caching makes it cheap, not free. If the backlog were 1,200 messages this design would fall over, and the right move would be a specialist per page range writing to the store, with the synthesiser reading the table. The store from lesson 83 is already the place for "facts that must outlast a context".
Run the comparison yourself, then break it
Budget about twenty-five cents for the whole set. The large-inbox orchestrated run is the expensive one at two cents.
- Run `pytest`. 36 pass. Read tests/test_orchestrate.py: the fan-out is a tuple, so most of the orchestration is tested without a model. Note test_specialist_failure_becomes_unavailable_report.
- Run `dayflow brief --engine sdk` and `dayflow brief --engine orch -v` on the default inbox. Write down cost and wall time for each. Decide which you would ship for a five-message inbox and say why in one sentence.
- Run both again with `DAYFLOW_INBOX=large`. Compare the "Needs a reply" sections line by line against fixtures/inbox_large.json (grep for the six NEEDS_REPLY senders in gen_inbox_large.py). Which engine missed what?
- In dayflow/orchestrate.py set the inbox specialist back to `max_turns=3` and run the large orchestrated brief. Read the INBOX_UNAVAILABLE line in the brief. Then read the cost table: the failed leg cost $0.0000 and the brief still shipped. Restore it.
- Set `DAYFLOW_SPECIALIST_MODEL=claude-sonnet-5-5` and rerun the large orchestrated brief. The cost table now tells you exactly how much of the saving was routing versus isolation. Write the two numbers down.
- Temporarily change `min(50, limit)` in dayflow/tools.py to `min(200, limit)` and set INBOX_TRIAGE to call with limit 200 once. Run the large orchestrated brief with -v. Find the persisted-output preview in the newest file under ~/.claude/projects/*/ and read what the model actually saw. Revert both.
- TypeScript: `cd ts && DAYFLOW_INBOX=large npm run brief -- --engine orch -v`. The cost table should land within a few hundred tokens of the Python one. Diff ts/src/orchestrate.ts against dayflow/orchestrate.py: the SPECIALISTS tables must be identical.
What this lesson deliberately does not do
- Use SDK-managed subagents.
agents={...}is the right tool when the set of sub-tasks is not known until runtime. Dayflow's three sources are known at import time. Lesson 92 revisits this when Dayflow is itself a tool another agent calls. - Let specialists write. Every specialist is read-only by construction (
build_server(("read",), names=[...])). The write tool stays with the single agent until lesson 86 puts a human in front of it. - Give specialists
Readto work around persisted output. The tool got smaller instead. Lesson 88 explains what a filesystem in a triage agent's hands costs. - Parallelise inbox paging. One specialist, sequential pages, 34 seconds. It is the obvious next optimisation and it is left for you; point 4 under The numbers says where to cut.
When one source is big enough that carrying it costs more than four prompts, or when one job is narrow enough that a small model does it better. Measure; do not assume.
SPECIALISTS is a tuple: name, system prompt, exactly one tool, dated request, max_turns. asyncio.gather runs them; the synthesiser has tools=[].
"Output EXACTLY" plus a template plus a *_UNAVAILABLE line. The synthesiser is told what each means. Same idea as the tool envelope, one level up.
Specialists on Haiku 5.5, synthesiser on Sonnet 5.5. On the 120-message inbox this is where the 4x saving came from.
Claude Code saves large tool results to disk and shows a 2 KB preview. With tools=[] the model cannot read the rest. Fix the tool: paginate.
limit โค 50, offset, returns {messages, total, next_offset}. Prompts say: keep calling until next_offset is null.
In this SDK version max_turns / max_budget_usd raise instead of returning an is_error result. collect() normalises; specialists turn it into an *_UNAVAILABLE report.
Every new context is a new chance to forget what day it is. Each specialist request starts with "Today is ...".