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?

Everyday Analogy

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.

Don't Over-Split

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

Technical Definition

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:

Triage → Specialist — Route, Then Answer
"I want a refund for order AC-1042."customer message
Triage agent — reads intent, picks a specialist"this is a refund request"
↓ route to Refunds
Orders specialistnot chosen
Refunds specialisthandles it
"I've started your refund for AC-1042 — 5-7 business days."final answer
What Just Happened?

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."));
What Just Happened?

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);
Verbosity Watch — ADK's Runtime

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

Expected output (refund routed to the Refunds specialist)
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
✅ Checkpoint: Send an order question instead ("Where's AC-1042?") and it should route to Orders. If your triage agent answers directly without delegating, its instructions aren't firm enough about routing — or, in the raw version, the specialist tool descriptions overlap. Tighten the descriptions; routing quality lives there (just like M03).

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:

Who Produces the Final Answer?
  • 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_agent tells 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

ConceptAnthropicGoogle GeminiOpenAI
FrameworkClaude Agent SDKADK (Agent Dev Kit)Agents SDK
Packageclaude-agent-sdkgoogle-adkopenai-agents
Agent classAgentDefinitionAgent / LlmAgentAgent
Delegation fieldagents={…} (+ "Agent" tool)sub_agents=[…]handoffs=[…]
Routing signalsubagent descriptionsub-agent descriptionhandoff_description
How you run itquery(prompt, options)Runner + session + event loopRunner.run(agent, msg)
Final answer byparent (subagent summarized)the sub-agent (transfer)the specialist (transfer)
Runtime weightlightheaviest (stateful runner)light
Why the Differences?

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?

AMulti-agent is always faster
BIt removes the need for tools
CSpecialists get focused prompts, only the tools they need, and their own permissions (e.g. only Refunds moves money)
DIt's required by all three SDKs
Correct! Splitting shrinks each prompt, scopes tools/permissions, and makes pieces independently testable. But it adds latency and routing risk — don't split until a single agent genuinely gets unwieldy.

Q2: In "agent-as-tool", where does control go after the specialist runs?

AThe specialist talks to the user directly and control never returns
BBack to the orchestrator, which relays the result — it stays in charge
CThe conversation ends
DTo a random other agent
Correct! Agent-as-tool = the specialist's output returns as a tool_result and the orchestrator composes the reply. A true handoff (OpenAI/ADK) transfers control so the specialist answers directly.

Q3: The Anthropic raw multi-agent pattern is really just…

AA brand-new API you haven't seen
BStructured output from M02
CRAG from M05
DM03's tool loop, where the "tools" are specialist model calls
Correct! Anthropic's base API has no handoff primitive — but you don't need one. A specialist is a system prompt + a model call, wrapped as a tool, driven by the M03 orchestration loop.

Q4: Which delegation field belongs to which framework?

AOpenAI handoffs, Google ADK sub_agents, Claude Agent SDK agents
BThey all use handoffs
COpenAI sub_agents, Google handoffs
DNone have a delegation field
Correct! Same idea, three names: OpenAI handoffs=[…], 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?

AOpenAI Agents SDK
BClaude Agent SDK
CGoogle ADK — session service + created session + event loop
DThey're all identical in setup
Correct! ADK is an application runtime (sessions, events, streaming, deploy), so even one message needs a session service and event loop. The extra lines are wiring, not difficulty — same triage → specialists shape.

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 SDK handoffs, Google ADK sub_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.