Skip to content

08 · Multi-Agent Systems

One agent, one context window, one job — that's the limit of module 7's patterns. Some tasks genuinely benefit from splitting work across several agents with different roles: an orchestrator that delegates, specialized subagents that each focus on one kind of work, and a shared workspace or messages they use to coordinate.

Orchestrator / subagent architecture

The orchestrator never does the work itself — it decides which subagent should handle a piece of the task, delegates via a tool call, and assembles the results:

from dotenv import load_dotenv
import anthropic

load_dotenv()
client = anthropic.Anthropic()
MODEL = "claude-sonnet-5"

DELEGATE_TOOLS = [
    {
        "name": "delegate_to_researcher",
        "description": "Send a research question to the researcher subagent, "
                       "which can search and read documents but cannot write code.",
        "input_schema": {"type": "object", "properties": {"question": {"type": "string"}}, "required": ["question"]},
    },
    {
        "name": "delegate_to_coder",
        "description": "Send a coding task to the coder subagent, which can write "
                       "and run code but has no research/browsing ability.",
        "input_schema": {"type": "object", "properties": {"task": {"type": "string"}}, "required": ["task"]},
    },
]

def researcher_subagent(question: str) -> str:
    resp = client.messages.create(
        model=MODEL, max_tokens=600,
        system="You are a research specialist. Answer using only verifiable facts; "
               "say clearly when you are uncertain.",
        messages=[{"role": "user", "content": question}],
    )
    return resp.content[0].text

def coder_subagent(task: str) -> str:
    resp = client.messages.create(
        model=MODEL, max_tokens=800,
        system="You are a coding specialist. Write correct, tested code with brief "
               "comments. Do not explain at length — show the code.",
        messages=[{"role": "user", "content": task}],
    )
    return resp.content[0].text

def orchestrate(task: str) -> str:
    messages = [{"role": "user", "content": task}]
    while True:
        resp = client.messages.create(
            model=MODEL, max_tokens=1000, tools=DELEGATE_TOOLS, messages=messages,
        )
        if resp.stop_reason != "tool_use":
            return resp.content[0].text

        messages.append({"role": "assistant", "content": resp.content})
        results = []
        for block in resp.content:
            if block.name == "delegate_to_researcher":
                output = researcher_subagent(block.input["question"])
            elif block.name == "delegate_to_coder":
                output = coder_subagent(block.input["task"])
            else:
                output = "Error: unknown subagent"
            results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
        messages.append({"role": "user", "content": results})

print(orchestrate(
    "Find out the time complexity of Python's Timsort, then write a Python "
    "function that documents that complexity in its docstring."
))

Each subagent is just a messages.create call with its own narrow system prompt — "delegation" is the orchestrator treating another model call as a tool, structurally identical to Level 1's tool calling, just with the tool implementation being an LLM instead of a Python function.

Why specialize subagents at all

A narrow system prompt and a restricted toolset make a subagent more reliable at its one job than a single generalist prompt trying to do everything — the same reasoning that motivates prompt chaining in module 1, applied to agents instead of single calls. It also isolates context: the coder subagent's window fills with code and errors, the researcher's fills with search results and citations, and neither pollutes the other's context with irrelevant history — useful once either role's transcript would otherwise grow large enough to crowd out the orchestrator's own context.

Shared workspace

When subagents need to build on each other's output beyond what fits in a single delegation message, use a shared, external workspace (a directory, a database, a shared object) rather than routing every intermediate result back through the orchestrator's context:

import json
from pathlib import Path

WORKSPACE = Path("./agent_workspace")
WORKSPACE.mkdir(exist_ok=True)

def write_artifact(name: str, content: str) -> str:
    (WORKSPACE / name).write_text(content)
    return f"Wrote {name} ({len(content)} chars)"

def read_artifact(name: str) -> str:
    return (WORKSPACE / name).read_text()

Give each subagent write_artifact/read_artifact tools scoped to this directory; the orchestrator's job becomes coordinating which subagent reads/writes which file and in what order, rather than physically carrying every byte of intermediate output through its own context.

Inter-agent messaging

For subagents that need to negotiate rather than just execute a single delegated task (e.g. a "critic" subagent reviewing a "writer" subagent's draft, back and forth), model the exchange as an explicit message log both sides read and append to — the same message-list structure used for conversation state in Level 1, just with two model-driven participants instead of one model and one human:

def writer_critic_loop(brief: str, max_rounds: int = 3) -> str:
    log = [{"role": "writer", "content": brief}]
    draft = ""
    for _ in range(max_rounds):
        draft = client.messages.create(
            model=MODEL, max_tokens=600,
            messages=[{"role": "user", "content": f"Brief: {brief}\n\nPrior feedback:\n" +
                       "\n".join(f"{m['role']}: {m['content']}" for m in log[1:])}],
        ).content[0].text
        log.append({"role": "writer", "content": draft})

        critique = client.messages.create(
            model=MODEL, max_tokens=300,
            messages=[{"role": "user", "content": f"Critique this draft against the brief. "
                       f"Reply 'APPROVED' if it's ready.\n\nBrief: {brief}\n\nDraft:\n{draft}"}],
        ).content[0].text
        log.append({"role": "critic", "content": critique})
        if "APPROVED" in critique:
            break
    return draft

This is deliberately just module 1's self-critique loop with the critic role framed as a separate participant — multi-agent systems are, at bottom, compositions of the same single-model-call primitives, arranged so each call sees a narrower, purpose-built context.

How It Actually Works

There is no mechanism here beyond what earlier modules already established: every "agent" is a messages.create call with its own system prompt and its own message list, and "multi-agent" simply means your orchestrating code makes several such calls, in some cases treating one model's output as another model call's input. No information passes between agents except through the text (or artifacts) your code explicitly routes between them — there is no shared memory, shared attention, or awareness between separate API calls.

This is precisely why context isolation is the real payoff of the architecture: since each subagent call only ever sees what your code puts in its messages list, giving the coder subagent a clean, code-focused context (rather than the orchestrator's full delegation history) is not an optimization detail — it's the entire reason to use a subagent at all instead of just appending "now act like a coder" to a single, ever-growing conversation. A single bloated context degrades quality gradually as irrelevant history crowds out attention on the immediately relevant part of the input; separate agent calls each get a full, undiluted context budget for their own narrow task.

Cheat sheet

Concept Key fact
Orchestrator Delegates via tool calls; never does the work itself
Subagent A messages.create call with a narrow system prompt, invoked as a tool
Specialization payoff Narrower prompt + isolated context = higher reliability
Shared workspace Files/DB rows for artifacts too large to pass through messages
Inter-agent messaging An explicit message log both sides read/append to
No hidden coordination Agents only share what your code explicitly routes between them

Exercise

Build an orchestrator with two subagents: a "planner" (drafts a 3-step outline for a blog post) and a "writer" (expands one outline step into 2-3 paragraphs). Have the orchestrator delegate the outline first, then delegate each step to the writer in turn, assembling the final post. Add a third "editor" subagent that reviews the assembled post and returns specific line-edit suggestions, and print each subagent's isolated context window (i.e., exactly what messages you sent it) to confirm none of them saw the others' full transcripts.