Claude Cookbook

Orchestrator pattern: big models for planning, small models for execution

Two-model team economics via the multiagent config — a frontier orchestrator plans and synthesizes while cheap search workers do the token-heavy web reading in parallel threads, compared against a rigor-matched solo-frontier control with real bills. Covers per-thread usage.list_cost metering, cost attribution, and a session budget as the fan-out guardrail.

Brad Abrams
Brad Abrams
@bradabrams
Published on July 2, 2026
Was this page helpful?

Orchestrator pattern: big models for planning, small models for execution

Introduction

Most agent workloads have two very different jobs inside them: a small amount of planning and judgment, and a large amount of mechanical reading and doing. Web research is the extreme case, and it's the example this notebook uses: verifying twenty facts against their authoritative sources means pulling hundreds of thousands of tokens of web pages through a model, and at frontier rates that reading bill dominates.

The orchestrator pattern splits the two workloads. A frontier model plans the research and synthesizes the answer, but it never touches a raw web page — cheap workers do all the reading in their own parallel context windows and report back distilled findings. This notebook measures the split honestly: it runs the realistic alternative — one frontier agent with the same tools, held to the same verification standard — on the same question, and compares real bills and real wall-clock. On the authors' runs, made before this notebook moved to the multiagent_20261001 type, both arms read about the same amount, with 84-98% of the team's input tokens billed at the worker rate.

By the end of this cookbook, you'll be able to:

  • Configure a two-model team with the multiagent block: a frontier orchestrator and cheap search workers
  • Follow a delegation live through the session event stream (thread_created, thread_message_sent, thread_message_received)
  • Run a rigor-matched solo-frontier control and compare real bills
  • Meter each thread with the typed per-thread cumulative usage

The same economics apply to any workload where a cheap model can do the token-heavy leg: document review, log analysis, codebase sweeps.

Architecture of the orchestrator pattern: the user's question goes to a frontier-model orchestrator with no tools of its own; it sends one brief per park to parallel small-model search workers and gets distilled findings back; only the workers touch the open web, via web search and page fetch; the orchestrator synthesizes the final answer.

Prerequisites

Before following this guide, ensure you have:

Required Knowledge:

Required Tools:

If you haven't seen the multiagent block before, the multiagent orchestration documentation introduces it. This notebook uses the simplest possible team — one worker type — because the point here is the cost structure, not the team design.

Setup

%%capture
%pip install -qU "anthropic>=1.13.0" python-dotenv
import os
import time
import anthropic
from dotenv import load_dotenv
load_dotenv()
BETAS = ["managed-agents-2026-04-01"]
# The frontier model plans and synthesizes; the cheap model reads the web.
# COOKBOOK_COORDINATOR_MODEL is the older name for the same setting, kept for setups that use it.
ORCHESTRATOR_MODEL = os.environ.get("COOKBOOK_ORCHESTRATOR_MODEL") or os.environ.get(
"COOKBOOK_COORDINATOR_MODEL", "claude-fable-5"
)
WORKER_MODEL = os.environ.get("COOKBOOK_WORKER_MODEL", "claude-sonnet-5")
client = anthropic.Anthropic()

1. The team: cheap readers, expensive thinker

Two agent definitions make the whole team.

The worker is an ordinary agent: a model, a toolset scoped down to web_search + web_fetch, and a system prompt. The docs call it a subagent, and the orchestrator can delegate to it because you list it in subagents.predefined_agents. Each worker instance researches one focused sub-question in its own session thread, so the giant web pages it reads never enter anyone else's context.

The orchestrator has no tools of its own — only a multiagent block listing the worker. That block is what lets it delegate: the server automatically gives it create_agent, send_to_agent, and list_agents, and workers get send_to_parent the same way. You never define any of those tools.

The block also turns off two things that the multiagent_20261001 type enables by default: inline agents, which the orchestrator would define itself, and workflow runs, in which the server executes a program that the orchestrator writes to run many agents and combine what they return. With either left on, the orchestrator could hand work to agents other than the worker you listed, and the setup would no longer guarantee the split this notebook measures. Setting subagents.inline_agents and workflows to {"type": "disabled"} keeps every delegated task on the worker you listed.

Two things to know about how the orchestrator relates to the workers you list. First, the predefined_agents list is snapshotted when the orchestrator is created or updated — if you change the worker's definition, update or recreate the orchestrator. Second, and less obvious: the orchestrator can't see its workers' system prompts. Its create_agent tool takes an agent name and a task string. Everything the orchestrator believes about how its workers behave comes from its own system prompt, so keep that prompt's description of the workers and the workers' actual prompts in agreement — nothing on the server enforces it. (With several worker types, name them explicitly in the orchestrator's prompt.)

worker = client.beta.agents.create(
name="search-worker",
# Tells Anthropic which cookbook this agent came from. Safe to remove.
metadata={"anthropic_cookbook": "claude-cookbooks/cma-plan-big-execute-small"},
model=WORKER_MODEL,
# Everything off except the two web tools: the worker's job is
# reading, and scoping keeps the cheap model from wandering into
# bash or the filesystem. It's also the security boundary: workers
# read arbitrary (untrusted) web pages, so a worker that can only
# search, fetch, and report back is the blast radius you want for
# that input, and the orchestrator reading the reports is given
# no tools of its own.
tools=[
{
"type": "agent_toolset_20260401",
"default_config": {"enabled": False},
"configs": [
{"name": "web_search", "enabled": True},
{"name": "web_fetch", "enabled": True},
],
}
],
system=(
"You are a search worker researching one focused sub-question for "
"an orchestrator. Use web_search and web_fetch to find the answer. "
"Be thorough: try multiple query phrasings, follow promising "
"links, and cross-check facts across sources. Report back with "
"the specific answer you found and the evidence (URLs, quotes) "
"that supports it. If you could not find a definitive answer, say "
"exactly what you did find and what remains uncertain. Always "
"finish by calling send_to_parent with your report."
),
betas=BETAS,
)
orchestrator = client.beta.agents.create(
name="search-orchestrator",
# Tells Anthropic which cookbook this agent came from. Safe to remove.
metadata={"anthropic_cookbook": "claude-cookbooks/cma-plan-big-execute-small"},
model=ORCHESTRATOR_MODEL,
multiagent={
"type": "multiagent_20261001",
"subagents": {
"type": "enabled",
# Delegate only to the worker listed here. Inline agents,
# which the orchestrator would define itself, are on by
# default and would run on the orchestrator's model.
"inline_agents": {"type": "disabled"},
"predefined_agents": [{"type": "agent", "id": worker.id}],
},
# Workflow runs are also on by default with multiagent_20261001.
# Off here, so every delegated task goes to the worker.
"workflows": {"type": "disabled"},
},
system=(
"You are coordinating a team of search workers to answer a hard "
"web-research question. Your workers have web_search and "
"web_fetch; you do not. Break the question into focused "
"sub-questions and delegate each to a worker via create_agent. "
"Run several workers in parallel on independent sub-questions, "
"and do not draw any conclusion until every worker has "
"reported. When a worker reports, decide whether its "
"findings answer the sub-question or whether to send a follow-up "
"with send_to_agent. If a worker returns an infrastructure error "
"(rate limit, timeout) instead of findings, re-assign the same "
"sub-question to a fresh worker. Once every worker has reported "
"and you have enough evidence, synthesize the workers' findings "
"into a single final answer to the original question."
),
betas=BETAS,
)
print(f"worker {worker.id}")
print(f"orchestrator {orchestrator.id}")

2. Run a research question

Create an environment and a session for the orchestrator, send the question as a user.message, and stream. The session-level stream is the orchestrator's primary thread — a condensed view of the whole run. Worker threads show up in it as delegation traffic: session.thread_created when a worker is spawned, agent.thread_message_sent when the orchestrator hands one a sub-question, and agent.thread_message_received when the findings come back.

The question is a coverage task — twenty facts (10 parks x 2 attributes), each of which must be verified against a specific authoritative source. Coverage questions are where the pattern shines, because the reading is mandatory: nobody gets to answer from memory, so the only question is what rate the reading bills at and whether it happens in parallel. (Discovery questions — find one answer hiding in a big search space, in the style of benchmarks like BrowseComp — reward a frontier model's search intuition more, and the gap narrows.)

Fan-out is data-dependent: the orchestrator decides how many workers to spawn, so nothing in the prompt fixes its ceiling. The session below carries a budget, an enforced cap on the whole team's list cost across every thread. The $10 cap is generous enough that a normal run never touches it, but if a bad question ever sent the orchestrator spawning workers without end, the session would pause at the cap instead of running up the bill. The pause mechanics, and raising the cap afterward, are in CMA_cap_session_spend.ipynb.

env = client.beta.environments.create(
name="research-fanout",
config={"type": "anthropic_cloud", "networking": {"type": "unrestricted"}},
)
session = client.beta.sessions.create(
agent=orchestrator.id,
environment_id=env.id,
# guardrail on data-dependent fan-out: cap the whole team's list cost
budget={"type": "limit", "max_list_cost": {"currency": "USD", "amount": "1000"}},
betas=BETAS,
)
QUESTION = (
"For each of the ten largest national parks in the contiguous United "
"States by area, find: the current standard private-vehicle entrance "
"fee, and whether the park currently requires a timed-entry or "
"day-use reservation for peak season. Each fact must be verified "
"against that park's official nps.gov pages (fees page and alerts/"
"reservations page) - not from third-party summaries. Give park, fee, "
"reservation requirement, and the nps.gov URLs you used."
)
t_start = time.monotonic()
client.beta.sessions.events.send(
session.id,
betas=BETAS,
events=[{"type": "user.message", "content": [{"type": "text", "text": QUESTION}]}],
)
def text_of(content):
return "".join(b.text for b in content or [] if b.type == "text")
def clip(s, n=160):
return s[:n] + ("..." if len(s) > n else "")
final_answer = ""
with client.beta.sessions.events.stream(session.id, betas=BETAS) as stream:
for ev in stream:
match ev.type:
case "agent.message":
if text := text_of(ev.content).strip():
final_answer = text
print(f"[orchestrator] {clip(text, 200)}")
case "session.thread_created":
print(f"[spawn] {ev.agent_name} ({ev.session_thread_id})")
case "agent.thread_message_sent":
print(f"[delegate -> {ev.to_agent_name}] {clip(text_of(ev.content))}")
case "agent.thread_message_received":
print(f"[report <- {ev.from_agent_name}] {clip(text_of(ev.content))}")
case "session.status_idle":
break
print(f"\n[team finished in {time.monotonic() - t_start:.0f}s]")
print("=" * 70)
print(final_answer)

The shape to notice: every [delegate ->] line is a small message, and every [report <-] line is a distilled summary. The megabytes of search results and fetched pages that produced those reports never crossed the orchestrator's context. That separation is the entire cost story. To price it fairly, the next section runs the realistic alternative, then we meter both.

3. Run the control: one frontier agent, same verification standard

What would this cost without the pattern? The realistic alternative is a single frontier agent with the same two web tools. One subtlety makes this comparison fair or worthless: the solo agent must be held to the same verification standard. Left to its own judgment, a frontier model is economical — it reads a single source per fact and comes in cheap, but that's a lower-rigor product, not the same work at a different price. So the solo prompt below demands what the team already does: every fact verified from two independent fetches, conflicts re-checked and flagged.

Same question, quiet stream.

solo = client.beta.agents.create(
name="solo-researcher",
# Tells Anthropic which cookbook this agent came from. Safe to remove.
metadata={"anthropic_cookbook": "claude-cookbooks/cma-plan-big-execute-small"},
model=ORCHESTRATOR_MODEL,
tools=[
{
"type": "agent_toolset_20260401",
"default_config": {"enabled": False},
"configs": [
{"name": "web_search", "enabled": True},
{"name": "web_fetch", "enabled": True},
],
}
],
system=(
"You research hard web questions with audit-grade rigor. Use "
"web_search and web_fetch. For EVERY fact you report, verify it "
"from at least two independent fetches (the authoritative page "
"plus one corroborating source), and re-fetch when two sources "
"disagree. Never carry a fact forward on one source or from "
"memory. In your answer, give each fact with both source URLs, "
"and explicitly flag any fact where sources conflicted. Before "
"finishing, audit your own answer: list each claim and check it "
"has two cited sources."
),
betas=BETAS,
)
t_solo = time.monotonic()
solo_session = client.beta.sessions.create(agent=solo.id, environment_id=env.id, betas=BETAS)
client.beta.sessions.events.send(
solo_session.id,
betas=BETAS,
events=[{"type": "user.message", "content": [{"type": "text", "text": QUESTION}]}],
)
solo_answer = ""
with client.beta.sessions.events.stream(solo_session.id, betas=BETAS) as stream:
for ev in stream:
match ev.type:
case "agent.message":
if text := text_of(ev.content).strip():
solo_answer = text
case "session.status_idle":
break
print(f"[solo finished in {time.monotonic() - t_solo:.0f}s]")
print(clip(solo_answer, 300))

4. Meter and price both runs

Cost attribution is built into the API. Every session thread carries a cumulative usage, and both the session and each thread report usage.list_cost: the token cost at public list rates, computed server-side. List the threads, take the primary thread (parent_thread_id is None) as the orchestrator, and the child threads are the workers — the solo session simply has no child threads. The session's own list_cost is the total, so there is no rate table to keep in sync for the headline numbers.

(If you ever need per-request detail instead, it's in each thread's own event feed — the session-level feed only carries the primary thread's span.model_request_end events.)

One number the API can't hand you is a counterfactual: what this exact team workload would have billed at all-frontier rates. That takes a rate table applied to the token counts, so the code keeps the input and output rates from the pricing page for that one what-if — 5-minute cache writes bill at 1.25x the input rate, 1-hour writes at 2x, cache reads at 0.1x — and uses list_cost for everything real.

# $ / MTok input and output from the pricing page, used only for the
# all-frontier counterfactual below; the real bills come from the API's
# server-side list_cost.
PRICES = {
"claude-fable-5": {"input": 10.0, "output": 50.0},
"claude-sonnet-5": {"input": 2.0, "output": 10.0},
}
def total_input(u):
cache = u.cache_creation # None on threads with no cache activity
return (
u.input_tokens
+ u.cache_read_input_tokens
+ (cache.ephemeral_5m_input_tokens if cache else 0)
+ (cache.ephemeral_1h_input_tokens if cache else 0)
)
def counterfactual_cost(u, model):
"""Re-price a thread's tokens at another model's rates (the what-if)."""
p = PRICES[model]
cache = u.cache_creation
return (
u.input_tokens * p["input"]
+ (cache.ephemeral_5m_input_tokens if cache else 0) * p["input"] * 1.25
+ (cache.ephemeral_1h_input_tokens if cache else 0) * p["input"] * 2.0
+ u.cache_read_input_tokens * p["input"] * 0.1
+ u.output_tokens * p["output"]
) / 1e6
def dollars(list_cost):
return float(list_cost.amount) / 100 # amount is an integer string in cents
def report(session_id):
session_usage = client.beta.sessions.retrieve(session_id, betas=BETAS).usage
threads = list(client.beta.sessions.threads.list(session_id, betas=BETAS))
primary = next(t for t in threads if t.parent_thread_id is None)
workers = [t for t in threads if t.parent_thread_id is not None]
workers_in = sum(total_input(t.usage) for t in workers)
print(
f" primary thread ({primary.agent.model.id}): "
f"{total_input(primary.usage):>9,} in / {primary.usage.output_tokens:>6,} out"
f" -> ${dollars(primary.usage.list_cost):.2f}"
)
if workers:
print(
f" {len(workers)} worker(s): {workers_in:>9,} in / "
f"{sum(t.usage.output_tokens for t in workers):>6,} out"
f" -> ${sum(dollars(t.usage.list_cost) for t in workers):.2f}"
)
print(
f" workers' share of input: {workers_in / (workers_in + total_input(primary.usage)):.0%}"
)
total = dollars(session_usage.list_cost)
print(f" total cost (session usage.list_cost): ${total:.2f}")
return total, threads
print("split team (fable orchestrator + sonnet workers):")
split_cost, split_threads = report(session.id)
print("\nsolo frontier agent:")
solo_cost, _ = report(solo_session.id)
# The counterfactual that isolates the rate split: this run's team
# workload with every token billed at the frontier rate.
frontier_team_cost = sum(counterfactual_cost(t.usage, ORCHESTRATOR_MODEL) for t in split_threads)
print(f"\nsolo / split cost ratio on this pair of runs: {solo_cost / split_cost:.1f}x")
print(f"the split team's workload at all-frontier rates: ${frontier_team_cost:.2f}")

The results described below come from the authors' runs, made before this notebook moved to the multiagent_20261001 type. The two runs did nearly the same reading — that's the point of matching the verification standard. What differs is the rate the reading billed at and the shape of the work: the team's twenty lookups ran as parallel worker threads at the cheap rate, while the solo agent ground through them serially in one frontier-priced context. On the authors' runs, 84-98% of the team's input tokens billed at the worker rate. Token volumes vary run to run, so treat any single printed ratio as one sample; the structure is the stable part.

Four honest caveats, all observed while building this notebook:

  • Hold the comparison to matched rigor. A solo frontier agent left to its own judgment reads far less (one source per fact) and comes in cheaper than the team — but that's a different, lower-rigor product. Compare the two arms only with the verification standard held fixed.
  • Delegation has a floor cost. Each worker thread pays a fixed setup overhead. Splitting the same work into more, narrower briefs raised our bill instead of lowering it — brief granularity has an optimum.
  • The verification standard only covers what you put in it. On one of the authors' runs, both arms verified all twenty facts against nps.gov — and both built their list of parks from model memory, which put Kings Canyon in the #10 slot that actually belongs to Great Smoky Mountains (Kings Canyon is #12 by area). The facts were audited; the question decomposition wasn't. If the premise matters, spend one more delegation making a worker verify it.
  • The orchestrator only knows what you tell it. Nothing on the server shows it the workers' prompts (see section 1), so the economics also depend on you describing the workers' behavior accurately in the orchestrator's prompt.

When does the split not pay? On narrow questions there's too little reading to arbitrage. If the orchestrator answers from its own knowledge (no delegation), you paid a frontier round-trip for nothing — watch for runs with no [spawn] lines. And if the task needs frontier judgment on the raw material itself (subtle document analysis rather than fact-finding), a cheap reader may summarize away exactly what mattered.

Recap

In this guide you built the cheapest useful shape of a multi-agent team and measured what it buys:

  • Configured a two-model team — a frontier orchestrator with no tools of its own, only a multiagent block listing the worker, and a cheap worker scoped to the web tools
  • Followed the delegation live through thread_created / thread_message_sent / thread_message_received on the session stream
  • Ran a rigor-matched solo-frontier control and compared real bills and real wall-clock, not estimates
  • Metered each thread with the typed cumulative usage every session thread carries (session.usage is the whole-team total)

To take this further:

  1. Add specialist worker types with scoped toolsets — CMA_coordinate_specialist_team.ipynb shows a three-role team and why per-role scoping matters
  2. Put the per-thread metering into your production telemetry: the thread-level usage is how you attribute spend per delegation, not just per session
  3. Try the same split on your own token-heavy workload — document review and log triage have the same read-heavy, coverage-shaped profile as web research

Reference: multiagent orchestration documentation.

Was this page helpful?