---
title: Multi-agent orchestration
description: More agents is not better. Each one adds cost, latency and a lossy handoff. Split work only when it divides cleanly, give every worker a precise brief, and put a check where a mistake would be expensive.
author: Asem Abdo
course: Agentic Engineering
lesson: Multi-agent orchestration
url: https://openplanr.dev/learn/agentic-engineering/multi-agent
updated: 2026-10-17
related:
  - https://openplanr.dev/learn/agentic-engineering/loop-engineering.md
  - https://openplanr.dev/learn/agentic-engineering/evals-cost-security.md
---

# Multi-agent orchestration

By [Asem Abdo](https://www.linkedin.com/in/asemabdo/) · Updated 2026-10-17

*Systems of agents*

More agents is not better. Each one adds cost, latency and a lossy handoff. Split work only when it divides cleanly, give every worker a precise brief, and put a check where a mistake would be expensive.

## The patterns

Level 1 · The rules

Nearly every multi-agent system is one of these, or two combined.

| Pattern | How it works | Good for | Watch out for |
| --- | --- | --- | --- |
| **Prompt chaining** | Fixed sequence, each output feeds the next | Predictable multi-step tasks | Errors compound down the chain |
| **Routing** | A classifier sends work to a specialist or model tier | Mixed requests, cost control | Silent misroutes unless logged |
| **Orchestrator-worker** | A lead splits the task, workers execute, the lead merges | Research, large refactors, audits | Worker quality equals brief quality |
| **Fan-out / fan-in** | Parallel workers, results combined | Independent subtasks | Token cost multiplies |
| **Evaluator-optimizer** | One produces, another critiques, repeat | Specs, code quality, writing | Same model may agree with itself |
| **Verifier / skeptic** | A final agent re-checks against different evidence | Research, data accuracy | Useless if it sees the same evidence |
| **Human gate** | Pause for approval at set points | Merges, deploys, irreversible steps | Too many gates and people rubber-stamp |

## Fan-out with a budget

Level 1 · The rules

A research team of one data agent, four researchers and one skeptic can easily read hundreds of thousands of tokens. Know the math before you launch it.

```python check=run title="Estimating a fan-out before launching it"
workers = {
    "data": 60_000,
    "storms": 55_000,
    "smoke": 50_000,
    "rain_fog": 52_000,
    "trends": 58_000,
}
skeptic = 90_000
total = sum(workers.values()) + skeptic
price_per_million = 3.00
print(f"{len(workers)} workers + 1 skeptic = {total:,} tokens")
print(f"estimated input cost: ${total / 1_000_000 * price_per_million:.2f}")
```

```text title="Output (Python 3.14.8)"
5 workers + 1 skeptic = 365,000 tokens
estimated input cost: $1.09
```

```python check=err:BudgetExceededError title="A team without a shared cost cap"
class BudgetExceededError(Exception):
    pass

def run_team(tasks, cap_usd, cost_per_task=0.40):
    spent = 0.0
    for task in tasks:
        spent += cost_per_task
        if spent > cap_usd:
            raise BudgetExceededError(
                f"${spent:.2f} spent, cap ${cap_usd:.2f}, stopped at {task}"
            )
    return spent

run_team([f"research-{i}" for i in range(12)], cap_usd=3.00)
```

```text title="Traceback (Python 3.14.8)"
Traceback (most recent call last):
  File "main.py", line 14, in <module>
    run_team([f"research-{i}" for i in range(12)], cap_usd=3.00)
    ~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "main.py", line 9, in run_team
    raise BudgetExceededError(
        f"${spent:.2f} spent, cap ${cap_usd:.2f}, stopped at {task}"
    )
BudgetExceededError: $3.20 spent, cap $3.00, stopped at research-7
```

## Model tiering and depth limits

Level 2 · Why it works

Strong models plan and review; cheaper models execute. And agents that can start agents need a depth limit, or a confused planner recurses.

```python check=run title="Routing each task to the cheapest capable tier"
TIERS = {
    "plan": "strong",
    "review": "strong",
    "implement": "mid",
    "format": "small",
    "search": "small",
}
PRICE = {"strong": 15.0, "mid": 3.0, "small": 1.0}

tasks = ["plan", "search", "implement", "implement", "format", "review"]
tiered = sum(PRICE[TIERS[t]] for t in tasks)
all_strong = PRICE["strong"] * len(tasks)
print(
    f"tiered: {tiered:.0f} units, all strong: {all_strong:.0f} units, saving"
    f" {1 - tiered / all_strong:.0%}"
)
```

```text title="Output (Python 3.14.8)"
tiered: 38 units, all strong: 90 units, saving 58%
```

```python check=err:RecursionError title="A planner that delegates to itself"
def planner(task):
    subtask = "plan: " + task
    return planner(subtask)

planner("build login")
```

```text title="Traceback (Python 3.14.8)"
Traceback (most recent call last):
  File "main.py", line 5, in <module>
    planner("build login")
    ~~~~~~~^^^^^^^^^^^^^^^
  File "main.py", line 3, in planner
    return planner(subtask)
  File "main.py", line 3, in planner
    return planner(subtask)
  File "main.py", line 3, in planner
    return planner(subtask)
  [Previous line repeated 996 more times]
RecursionError: maximum recursion depth exceeded
```

```python check=run title="Depth limit: workers cannot spawn workers forever"
def delegate(task, depth=0, max_depth=2):
    if depth >= max_depth:
        return f"executed '{task}' at depth {depth}"
    return delegate("sub-" + task, depth + 1, max_depth)

print(delegate("build login"))
```

```text title="Output (Python 3.14.8)"
executed 'sub-sub-build login' at depth 2
```

## A verifier only helps with different evidence

Level 2 · Why it works

A skeptic that reads the same sources as the researcher mostly agrees with it. Give it a second source, a test suite, or a different model.

```python check=run title="Same evidence agrees, independent evidence catches the error"
claim = {"metric": "rainy days 2023", "value": 74}
same_source = {"rainy days 2023": 74}
second_source = {"rainy days 2023": 67}

def verify(claim, source, tolerance=3):
    actual = source[claim["metric"]]
    return (
        "confirmed"
        if abs(actual - claim["value"]) <= tolerance
        else f"disputed: source says {actual}"
    )

print("skeptic with same source:", verify(claim, same_source))
print("skeptic with NOAA-style second source:", verify(claim, second_source))
```

```text title="Output (Python 3.14.8)"
skeptic with same source: confirmed
skeptic with NOAA-style second source: disputed: source says 67
```

## Step through the mental models

Level 2 · Watch it

Four short programs, one idea each: a loop turn filling the context, a hook blocking an edit, a brief losing a constraint, and compaction dropping a rule. Predict before each step.

### One task, one loop

```python
context = ["system prompt", "user: fix the failing test"]
action = {"tool": "run_tests"}
context.append("tool: 3 failed in cart.test.ts")
action = {"tool": "edit", "path": "src/cart.ts"}
context.append("tool: edited src/cart.ts")
context.append("tool: 42 passed")
answer = "Fixed rounding in cart total"
```

1. Line 1: `context = ["system prompt", "user: fix the failing test"]`
   - The run starts with two things in context: the instructions and the task.
   - Context window: `system prompt` (instructions) = stable, cached; `user` (task) = fix the failing test, loaded
   - Harness state: steps 0 of 40 · $0.00
   - Predict: The model can't run anything. What does it produce next?
2. Line 2: `action = {"tool": "run_tests"}`
   - The model proposes a tool call. The harness executes it.
   - Context window: `system prompt` (instructions) = stable, cached; `user` (task) = fix the failing test, loaded
   - Harness state: steps 1 of 40 · harness runs <b>run_tests</b>
   - Predict: Where does the test output go?
3. Line 3: `context.append("tool: 3 failed in cart.test.ts")`
   - The result is appended. Next turn, the model sees the failures.
   - Context window: `system prompt` (instructions) = stable, cached; `user` (task) = fix the failing test, loaded; `tool result` (observation) = 3 failed, new
   - Harness state: steps 1 of 40 · $0.01
4. Line 4: `action = {"tool": "edit", "path": "src/cart.ts"}`
   - A second tool call, informed by the first result.
   - Context window: `system prompt` (instructions) = stable, cached; `user` (task) = fix the failing test, loaded; `tool result` (observation) = 3 failed, loaded
   - Harness state: steps 2 of 40 · harness runs <b>edit</b>
   - Predict: What should the agent do after editing, before claiming success?
5. Line 5: `context.append("tool: edited src/cart.ts")`
   - The context grows with every step. This is why long sessions rot.
   - Context window: `system prompt` (instructions) = stable, cached; `user` (task) = fix the failing test, loaded; `tool result` (observation) = 3 failed, loaded; `tool result` (observation) = edited cart.ts, new
   - Harness state: steps 2 of 40 · $0.02
6. Line 6: `context.append("tool: 42 passed")`
   - Verification: tests run again. A good harness makes this mandatory, for example with a Stop hook.
   - Context window: `system prompt` (instructions) = stable, cached; `user` (task) = fix the failing test, loaded; `tool results` (observations) = 3 entries, loaded; `tool result` (observation) = 42 passed, new
   - Harness state: steps 3 of 40 · $0.03
7. Line 7: `answer = "Fixed rounding in cart total"`
   - The stop condition is met. Three steps, well inside the budget.
   - Context window: `final answer` (text) = fixed rounding, new
   - Harness state: stopped: final answer · 3 steps · $0.03

Ran without output (Python 3.14.8).

### A hook blocks an edit

```python
call = {"tool": "Edit", "file_path": "prisma/migrations/002/migration.sql"}
exit_code = 2 if "/migrations/" in call["file_path"] else 0
result = "blocked: migrations are generated" if exit_code == 2 else "edited"
next_call = {"tool": "Bash", "command": "pnpm prisma migrate dev"}
```

1. Line 1: `call = {"tool": "Edit", "file_path": "prisma/migrations/002/migration.sql"}`
   - The model proposes editing a migration file by hand.
   - Context window: `call` (Edit) = migration.sql, proposed
   - Harness state: PreToolUse hooks queued
   - Predict: Does the edit run before or after the hook?
2. Line 2: `exit_code = 2 if "/migrations/" in call["file_path"] else 0`
   - The hook runs first and exits 2. Exit 1 would have let the edit through.
   - Context window: `call` (Edit) = migration.sql, proposed; `exit_code` (int) = 2, blocked
   - Harness state: hook: migration_guard → exit 2
   - Predict: What does the model see now?
3. Line 3: `result = "blocked: migrations are generated" if exit_code == 2 else "edited"`
   - The reason from stderr becomes the tool result. The model reads it like any other observation.
   - Context window: `result` (tool result) = blocked: migrations are generated, new
   - Harness state: edit never executed
   - Predict: What is the right next move for the model?
4. Line 4: `next_call = {"tool": "Bash", "command": "pnpm prisma migrate dev"}`
   - The model corrects course and generates the migration with the tool. Enforced, not requested.
   - Context window: `next_call` (Bash) = prisma migrate dev, proposed
   - Harness state: hook: no match → exit 0

Ran without output (Python 3.14.8).

### A brief drops a constraint

```python
parent = ["task: add index", "never edit migrations", "use pnpm"]
brief = ["task: add index", "use pnpm"]
subagent_context = list(brief)
lost = [x for x in parent if x not in brief]
```

1. Line 1: `parent = ["task: add index", "never edit migrations", "use pnpm"]`
   - The parent knows the task plus two rules, learned over a long session.
   - Context window: `parent` (main context) = 3 facts, loaded
   - Harness state: main agent, 120k tokens of history
   - Predict: What will the subagent know?
2. Line 2: `brief = ["task: add index", "use pnpm"]`
   - The parent writes a brief. It forgets one rule.
   - Context window: `parent` (main context) = 3 facts, loaded; `brief` (handoff) = 2 facts, handed off
   - Harness state: writing the brief
3. Line 3: `subagent_context = list(brief)`
   - Clean context is the point of a subagent, and also the risk.
   - Context window: `subagent` (clean context) = 2 facts, loaded
   - Harness state: subagent starts with 0 history
   - Predict: Which rule is now invisible?
4. Line 4: `lost = [x for x in parent if x not in brief]`
   - Brief failures cause most subagent failures. Validate briefs, and back critical rules with hooks.
   - Context window: `lost` (list) = never edit migrations, dropped
   - Harness state: fix: brief schema + hook as backstop

Ran without output (Python 3.14.8).

### Compaction drops a chat-only rule

```python
history = ["you: always use pnpm", "turn 2", "turn 3", "turn 4"]
summary = "Working on checkout; tests pass."
history = [summary]
claude_md = "Use pnpm, never npm."
context = [claude_md] + history
```

1. Line 1: `history = ["you: always use pnpm", "turn 2", "turn 3", "turn 4"]`
   - A rule given once in chat, early in a long session.
   - Context window: `rule` (chat message) = always use pnpm, loaded; `turns 2 to 4` (history) = long, loaded
   - Harness state: ctx 78%
   - Predict: What happens to it when the session is compacted?
2. Line 2: `summary = "Working on checkout; tests pass."`
   - The summary keeps the gist and drops detail.
   - Context window: `summary` (text) = checkout, tests pass, new
   - Harness state: compaction running
3. Line 3: `history = [summary]`
   - The rule is gone. The agent may run npm on the next step.
   - Context window: `summary` (text) = checkout, tests pass, loaded; `rule` (chat message) = gone, dropped
   - Harness state: ctx 12%
   - Predict: Where should the rule have lived?
4. Line 4: `claude_md = "Use pnpm, never npm."`
   - A rule in a file is reloaded every session and survives compaction.
   - Context window: `CLAUDE.md` (file) = use pnpm, cached
   - Harness state: loaded at every session start
5. Line 5: `context = [claude_md] + history`
   - Durable rules in files, critical ones in hooks, nothing important only in chat.
   - Context window: `CLAUDE.md` (file) = use pnpm, cached; `summary` (text) = checkout, tests pass, loaded
   - Harness state: ctx 13%

Ran without output (Python 3.14.8).

## Describe a pipeline in this vocabulary

Level 3 · Mastery

A common shape for spec-driven delivery, described with the words from this page. Practice explaining your own system the same way:

1. **Plan phase:** Orchestrator-worker: planning agents turn a request into a spec and tasks. Strong models.
1. **Human gate:** Review the spec before any code is written. The cheapest place to fix a mistake.
1. **Build phase:** Handoff with a written brief per task; cheaper models implement, tests act as sensors.
1. **Verify and gate:** An independent reviewer and the full test suite before merge.

> **Note:** **Say it:** "Strong models plan, cheaper ones execute, tests verify, and humans review at the points where a wrong decision is most expensive to undo."

## Predict the output

### What the subagent never hears

Level 2.

```python
parent = {"task", "never edit migrations", "use pnpm", "tests must pass"}
brief = {"task", "tests must pass"}
print(sorted(parent - brief))
```

1. ['never edit migrations', 'use pnpm']
2. ['tests must pass']
3. []
4. ['task', 'tests must pass']

**Answer:** option 1.

```text title="Output (Python 3.14.8)"
['never edit migrations', 'use pnpm']
```

**Why:** Set difference: what the parent knew minus what it wrote in the brief. Those two constraints are invisible to the subagent.

## Check your understanding

Levels 1 to 3.

**Q:** When should you not use multiple agents?

**A:** When the task needs tight shared context or does not split into independent parts. Each handoff loses information and adds cost.

**Q:** What makes a verifier agent useful?

**A:** Independent evidence: a second source, tests, or a different model. Re-reading the same evidence mostly produces agreement.

**Q:** What two limits does an agent team need?

**A:** A shared budget (tokens or dollars) and a delegation depth limit.
