M06 — Multi-Agent Systems
Acme is now one very capable agent — maybe too capable, juggling orders, refunds, products, and policy in a single overloaded prompt. Real support desks split the work: a triage rep routes each customer to the right specialist. In this module you split Acme into a triage agent that hands off to Orders and Refunds specialists — the raw way, then with each SDK's dedicated agent framework.
Learning Objectives
- Decide when to split one agent into several (and when not to)
- Distinguish the two delegation styles: agent-as-tool (control returns) vs handoff (control transfers)
- Build a triage → specialists system the framework-free way, on the M03 loop you already know
- Build the same system with each provider's agent framework: Claude Agent SDK subagents, the OpenAI Agents SDK (
handoffs), and Google ADK (sub_agents) - Compare how much the frameworks save — and how their handoff semantics quietly differ
Why Split One Agent?
BEFORE: A tiny shop has one employee who does everything — sales, returns, tech support, shipping. It works while the shop is small.
PAIN: As the shop grows, that one employee needs a 40-page manual covering every policy, a keyring with every key, and the judgment to switch hats mid-sentence. They get slower, make more mistakes, and you can't give "the returns person" different permissions than "the sales person" — it's all one overloaded human.
MAPPING: One agent with a dozen tools and a sprawling system prompt is that overloaded employee. Splitting into a triage agent plus focused specialists gives each a short prompt, only the tools it needs, and its own guardrails. The triage agent just routes; the Refunds specialist only knows refunds. Smaller, sharper, safer — the same reason companies have departments.
Multi-agent isn't automatically better. Each handoff adds latency, cost, and a place for routing to go wrong. Split when a single agent's prompt is getting unwieldy, when specialists need different tools or permissions (e.g. only Refunds can move money), or when you want independent, testable pieces. If one well-described agent with a handful of tools does the job — as in M03 — keep it. Reach for multiple agents when the org chart genuinely has departments, not before.
Two Handoff Styles
Agent-as-tool (control returns). The orchestrator calls a specialist the way it calls any tool: the specialist runs, returns a result, and control comes back to the orchestrator, which composes the final answer. The orchestrator stays in charge start to finish. This is just M03's tool loop where a "tool" happens to be another agent.
Handoff (control transfers). The orchestrator hands the conversation off to the specialist, which then talks to the user directly and produces the final answer. Control does not return. This is what the OpenAI Agents SDK's handoffs and Google ADK's sub_agents do. (Claude's Agent SDK subagents are a third flavor — more on that below.) Neither style is "right"; agent-as-tool keeps a single voice, handoff lets a specialist fully own the reply.
Watch a refund request route through triage to the Refunds specialist — the Orders specialist stays dark because it wasn't chosen:
The triage agent didn't answer the refund question itself — it recognized the intent and routed. The Refunds specialist, with its own focused prompt and (in a real build) its own process_refund permission, produced the answer. The Orders specialist never ran. That's the whole value: each agent does one thing well, and routing decides who's up.
The Raw Pattern — Agent-as-Tool (works in any SDK)
Before any framework: you can already build multi-agent with what you know. A specialist is just another tool whose implementation is a second model call. This is M03's orchestration loop, with the "tools" being route_to_orders / route_to_refunds. Shown in Anthropic (which has no first-party handoff primitive, so this is the Claude raw way) — the identical pattern works with Gemini's or OpenAI's loop from M03.
# multiagent_raw.py — specialists are TOOLS. This is the M03 loop, unchanged.
import anthropic
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5"
def specialist(system: str, msg: str) -> str: # a specialist = its own Claude call
r = client.messages.create(model=MODEL, max_tokens=512,
system=system, messages=[{"role": "user", "content": msg}])
return "".join(b.text for b in r.content if b.type == "text")
DISPATCH = {
"route_to_orders": lambda customer_message: specialist("You are the ORDERS specialist.", customer_message),
"route_to_refunds": lambda customer_message: specialist("You are the REFUNDS specialist. Be polite.", customer_message),
}
TOOLS = [
{"name": "route_to_orders", "description": "Order status & tracking questions.",
"input_schema": {"type": "object", "properties": {"customer_message": {"type": "string"}}, "required": ["customer_message"]}},
{"name": "route_to_refunds", "description": "Refund & return requests.",
"input_schema": {"type": "object", "properties": {"customer_message": {"type": "string"}}, "required": ["customer_message"]}},
]
SYSTEM = "You are a triage agent. Route each message to exactly one specialist tool, then relay its answer."
def run(user_msg: str) -> str: # ← identical to M03's orchestration loop
messages = [{"role": "user", "content": user_msg}]
for _ in range(6):
r = client.messages.create(model=MODEL, max_tokens=1024, system=SYSTEM, tools=TOOLS, messages=messages)
if r.stop_reason != "tool_use":
return "".join(b.text for b in r.content if b.type == "text")
messages.append({"role": "assistant", "content": r.content})
results = [{"type": "tool_result", "tool_use_id": b.id, "content": DISPATCH[b.name](**b.input)}
for b in r.content if b.type == "tool_use"]
messages.append({"role": "user", "content": results})
print(run("I want a refund for order AC-1042."))
// multiagent_raw.mjs — specialists are TOOLS. This is the M03 loop, unchanged.
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
const MODEL = "claude-sonnet-5";
async function specialist(system, msg) { // a specialist = its own Claude call
const r = await client.messages.create({ model: MODEL, max_tokens: 512, system,
messages: [{ role: "user", content: msg }] });
return r.content.filter(b => b.type === "text").map(b => b.text).join("");
}
const DISPATCH = {
route_to_orders: (a) => specialist("You are the ORDERS specialist.", a.customer_message),
route_to_refunds: (a) => specialist("You are the REFUNDS specialist. Be polite.", a.customer_message),
};
const TOOLS = [
{ name: "route_to_orders", description: "Order status & tracking questions.",
input_schema: { type: "object", properties: { customer_message: { type: "string" } }, required: ["customer_message"] } },
{ name: "route_to_refunds", description: "Refund & return requests.",
input_schema: { type: "object", properties: { customer_message: { type: "string" } }, required: ["customer_message"] } },
];
const SYSTEM = "You are a triage agent. Route each message to exactly one specialist tool, then relay its answer.";
async function run(userMsg) { // ← identical to M03's orchestration loop
const messages = [{ role: "user", content: userMsg }];
for (let i = 0; i < 6; i++) {
const r = await client.messages.create({ model: MODEL, max_tokens: 1024, system: SYSTEM, tools: TOOLS, messages });
if (r.stop_reason !== "tool_use")
return r.content.filter(b => b.type === "text").map(b => b.text).join("");
messages.push({ role: "assistant", content: r.content });
const results = [];
for (const b of r.content)
if (b.type === "tool_use")
results.push({ type: "tool_result", tool_use_id: b.id, content: await DISPATCH[b.name](b.input) });
messages.push({ role: "user", content: results });
}
}
console.log(await run("I want a refund for order AC-1042."));
No new SDK feature — a "specialist" is a system prompt plus a model call, wrapped as a tool. The triage agent routes by calling it. This is agent-as-tool: the specialist's answer comes back as a tool_result and the triage agent relays it. Portable across every provider (it's just M03), but you hand-roll the routing and there's no built-in tracing. That's the gap the frameworks fill.
The Frameworks — Handoffs as a First-Class Primitive
Each provider ships an agent framework that makes multi-agent a declared structure instead of a hand-rolled loop: you define specialists and list them as delegation targets. The framework runs the routing, the handoff, and the tracing. Here's the same triage → specialists system in all three. Note these are separate packages from the base SDKs (install lines included).
# multiagent_claude_sdk.py — pip install claude-agent-sdk
# The Claude Agent SDK adds SUBAGENTS: define them, Claude auto-delegates by description.
import asyncio
from claude_agent_sdk import query, ClaudeAgentOptions, AgentDefinition
async def main():
async for message in query(
prompt="I want a refund for order AC-1042.",
options=ClaudeAgentOptions(
allowed_tools=["Agent"], # "Agent" = the delegation tool; required
agents={
"orders": AgentDefinition(
description="Order status & tracking questions.", # routing is by description
prompt="You answer order-status questions.", model="sonnet"),
"refunds": AgentDefinition(
description="Refund & return requests.",
prompt="You process refunds and returns politely.", model="sonnet"),
},
),
):
if hasattr(message, "result"):
print(message.result)
asyncio.run(main())
// multiagent_claude_sdk.mjs — npm i @anthropic-ai/claude-agent-sdk
// Claude Agent SDK subagents: define them, Claude auto-delegates by description.
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const message of query({
prompt: "I want a refund for order AC-1042.",
options: {
allowedTools: ["Agent"], // "Agent" = the delegation tool; required
agents: {
orders: { description: "Order status & tracking questions.",
prompt: "You answer order-status questions.", model: "sonnet" },
refunds: { description: "Refund & return requests.",
prompt: "You process refunds and returns politely.", model: "sonnet" },
},
},
})) {
if ("result" in message) console.log(message.result);
}
# multiagent_adk.py — pip install google-adk
# ADK: a coordinator with sub_agents; the model emits transfer_to_agent to delegate.
import asyncio
from google.adk.agents import Agent # Agent == LlmAgent
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
orders = Agent(name="orders", model="gemini-2.5-flash",
description="Order status & tracking questions.",
instruction="Answer order-status questions.")
refunds = Agent(name="refunds", model="gemini-2.5-flash",
description="Refund & return requests.",
instruction="Process refunds and returns politely.")
triage = Agent(name="triage", model="gemini-2.5-flash",
instruction="Route the user to the right specialist.",
sub_agents=[orders, refunds]) # ← delegation targets
async def main():
svc = InMemorySessionService()
await svc.create_session(app_name="support", user_id="u1", session_id="s1")
runner = Runner(agent=triage, app_name="support", session_service=svc)
msg = types.Content(role="user", parts=[types.Part(text="I want a refund for order AC-1042.")])
for event in runner.run(user_id="u1", session_id="s1", new_message=msg):
if event.is_final_response():
print(event.content.parts[0].text)
asyncio.run(main())
// multiagent_adk.mjs — npm i @google/adk
// ADK: a coordinator with subAgents; the model transfers control to delegate.
import { LlmAgent, Runner, InMemorySessionService } from "@google/adk";
const orders = new LlmAgent({ name: "orders", model: "gemini-2.5-flash",
description: "Order status & tracking questions.",
instruction: "Answer order-status questions." });
const refunds = new LlmAgent({ name: "refunds", model: "gemini-2.5-flash",
description: "Refund & return requests.",
instruction: "Process refunds and returns politely." });
const triage = new LlmAgent({ name: "triage", model: "gemini-2.5-flash",
instruction: "Route the user to the right specialist.",
subAgents: [orders, refunds] }); // ← delegation targets
const svc = new InMemorySessionService();
const runner = new Runner({ agent: triage, appName: "support", sessionService: svc });
const session = await svc.createSession({ appName: "support", userId: "u1" });
for await (const event of runner.runAsync({
userId: "u1", sessionId: session.id,
newMessage: { role: "user", parts: [{ text: "I want a refund for order AC-1042." }] },
})) {
if (event.isFinalResponse()) console.log(event.content?.parts?.[0]?.text);
}
# multiagent_openai.py — pip install openai-agents (import package: agents)
# OpenAI Agents SDK: handoffs transfer control to the chosen specialist.
import asyncio
from agents import Agent, Runner
orders = Agent(name="Orders", model="gpt-5.5",
handoff_description="Order status & tracking questions.",
instructions="You answer order-status questions.")
refunds = Agent(name="Refunds", model="gpt-5.5",
handoff_description="Refund & return requests.",
instructions="You process refunds and returns politely.")
triage = Agent(name="Triage", model="gpt-5.5",
instructions="Route the customer to the right specialist.",
handoffs=[orders, refunds]) # ← delegation targets
async def main():
result = await Runner.run(triage, "I want a refund for order AC-1042.")
print(result.final_output) # the specialist's answer
print("handled by:", result.last_agent.name) # "Refunds"
asyncio.run(main())
// multiagent_openai.mjs — npm i @openai/agents
// OpenAI Agents SDK: handoffs transfer control to the chosen specialist.
import { Agent, run } from "@openai/agents";
const orders = new Agent({ name: "Orders", model: "gpt-5.5",
handoffDescription: "Order status & tracking questions.",
instructions: "You answer order-status questions." });
const refunds = new Agent({ name: "Refunds", model: "gpt-5.5",
handoffDescription: "Refund & return requests.",
instructions: "You process refunds and returns politely." });
const triage = new Agent({ name: "Triage", model: "gpt-5.5",
instructions: "Route the customer to the right specialist.",
handoffs: [orders, refunds] }); // ← delegation targets
const result = await run(triage, "I want a refund for order AC-1042.");
console.log(result.finalOutput); // the specialist's answer
console.log("handled by:", result.lastAgent?.name);
Notice the OpenAI and Claude framework snippets are ~15 lines, but Google ADK needs a session service, a created session, and an event loop even for one message. ADK is built for stateful, streamed, multi-turn deployments, so the runtime scaffolding is front-loaded — the terser InMemoryRunner(agent=triage) bundles the session service but you still create a session and iterate events. Don't mistake the extra lines for extra difficulty; it's the same triage → specialists shape, just with ADK's runner wiring. (The ADK JS SDK is newer — treat its camelCase names as the documented mirror of Python and smoke-test against your installed version.)
Run It
I've started a refund for order AC-1042. You'll see it back on your original payment method within 5-7 business days. Anything else I can help with? # OpenAI Agents SDK also prints: handled by: Refunds
Same Shape, Different Semantics
All four approaches route triage → specialist, but what happens to control differs in ways that matter for how the final answer is produced:
- Raw agent-as-tool (Anthropic): control returns to the triage agent, which relays/rewrites the specialist's result. One voice throughout — the orchestrator owns the reply.
- OpenAI handoff: control transfers. The specialist becomes the active agent and produces the final answer directly (
result.last_agenttells you who). - Google ADK sub_agents: control transfers via an LLM-emitted
transfer_to_agent; the delegated sub-agent takes over the turn. - Claude Agent SDK subagents: a third flavor — the subagent runs in an isolated fresh context and only its final message returns to the parent, which then composes the user-facing answer. Great for keeping the main context clean; different from a true handoff.
Three Ways, One Idea
| Concept | Anthropic | Google Gemini | OpenAI |
|---|---|---|---|
| Framework | Claude Agent SDK | ADK (Agent Dev Kit) | Agents SDK |
| Package | claude-agent-sdk | google-adk | openai-agents |
| Agent class | AgentDefinition | Agent / LlmAgent | Agent |
| Delegation field | agents={…} (+ "Agent" tool) | sub_agents=[…] | handoffs=[…] |
| Routing signal | subagent description | sub-agent description | handoff_description |
| How you run it | query(prompt, options) | Runner + session + event loop | Runner.run(agent, msg) |
| Final answer by | parent (subagent summarized) | the sub-agent (transfer) | the specialist (transfer) |
| Runtime weight | light | heaviest (stateful runner) | light |
These frameworks encode each company's agent philosophy. OpenAI's Agents SDK is minimal and handoff-centric — agents pass the baton. Google's ADK is a full application runtime (sessions, events, streaming, deployment) with delegation as one feature — hence the heavier setup. The Claude Agent SDK treats subagents as context-isolated workers reporting to a parent, reflecting Claude Code's roots. And Anthropic's base API has no multi-agent primitive at all, because the raw agent-as-tool loop already expresses it. Four philosophies, one org chart: triage routes, specialists specialize.
Knowledge Check
Q1: What's a good reason to split one agent into triage + specialists?
Q2: In "agent-as-tool", where does control go after the specialist runs?
Q3: The Anthropic raw multi-agent pattern is really just…
Q4: Which delegation field belongs to which framework?
handoffs, Google ADK sub_agents, Claude Agent SDK agentshandoffssub_agents, Google handoffshandoffs=[…], Google ADK sub_agents=[…], Claude Agent SDK agents={…} (plus the "Agent" tool). Routing is driven by each specialist's description.Q5: Which framework front-loads the most runtime wiring for a single message?
Module Summary
Key Takeaways
- Split into triage + specialists when one agent's prompt/tools get unwieldy or specialists need different permissions — not before.
- Two styles: agent-as-tool (control returns to the orchestrator) vs handoff (control transfers to the specialist).
- You can build multi-agent with no framework — it's M03's loop with specialists as tools. That's Anthropic's raw way, portable everywhere.
- Each SDK has an agent framework: Claude Agent SDK
agents, OpenAI Agents SDKhandoffs, Google ADKsub_agents— same org chart, three declarations. - Semantics differ: who produces the final answer, and how much runtime you set up (ADK is heaviest). Routing quality always lives in the descriptions.
Next: M07 — Production
Acme now has memory, retrieval, tools, and a team of specialists — but it's still demo code. The last module hardens it for the real world: streaming responses for a live UI, retries and typed error handling, cost control, and a look at how these same agent frameworks carry you to a deployable Acme Support. Time to ship.