13. Multi-Agent Coordination
This is the deep dive on how Claude Code runs multiple agents in parallel, how they share state, and how the coordinator mode orchestrates workers. Everything below is what actually happens in code — not a marketing abstraction.
13.1 Parallel tool execution inside a single assistant message
When the model emits multiple tool_use blocks in one assistant message, the runtime decides how to dispatch them.
The collection step
query.ts:829-844 extracts tool_use blocks as they stream:
const msgToolUseBlocks = message.message.content.filter(
content => content.type === 'tool_use',
) as ToolUseBlock[]
if (msgToolUseBlocks.length > 0) {
toolUseBlocks.push(...msgToolUseBlocks)
needsFollowUp = true
}Two dispatch modes
- Streaming tool execution (
query.ts:561-568) — whenconfig.gates.streamingToolExecutionis on, each tool_use block is handed to aStreamingToolExecutorthe moment it arrives (line 842). The tool starts running while the rest of the assistant message is still streaming. - Batched serial execution (
query.ts:1382) — otherwiserunTools()is called after the stream ends.
13.2 StreamingToolExecutor — the concurrency engine
/workspaces/src/services/tools/StreamingToolExecutor.ts is the scheduler. It is small and worth reading in full.
Status machine
Each tool in its tools[] array has a status: 'queued' | 'executing' | 'completed' | 'yielded' (line 19).
The safety rule
// StreamingToolExecutor.ts:129-135
private canExecuteTool(isConcurrencySafe: boolean): boolean {
const executingTools = this.tools.filter(t => t.status === 'executing')
return (
executingTools.length === 0 ||
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
)
}Translated: safe tools may run alongside other safe tools. Any unsafe tool runs alone.
isConcurrencySafe is a per-tool predicate declared on the Tool interface (Tool.ts:402):
- Read, Grep, Glob, WebFetch → safe (concurrent).
- Bash, FileEdit, FileWrite → unsafe (serialized).
- Agent (the subagent spawner) → typically safe because it hands off to a separate task context.
Scheduling
processQueue() (lines 140-151) iterates 'queued' tools and starts each where canExecuteTool() allows. Non-concurrent tools block the rest of the queue — once one unsafe tool is executing, nothing else starts until it completes.
executeTool(tool) (lines 265-405) runs the tool via runToolUse() as an async generator. In finally it calls processQueue() so the next tool starts the instant the prior completes.
Error isolation
A child AbortController is created per tool so one tool's error doesn't cancel siblings — except for Bash. Bash errors trigger siblingAbortController.abort('sibling_error') (lines 359-362), which cancels other queued tools. Other tool errors don't.
Interrupt handling
When abortController.signal.reason === 'interrupt' (user pressed Escape or sent new message), the executor consults the tool's interruptBehavior() (Tool.ts:411-416):
'cancel'→ generate a synthetictool_resultwith "interrupted by user" text; mark status'completed'.'block'(default) → returnnull; don't abort. The user message queues.
Result yielding
getCompletedResults() (lines 412-440) yields in submission order — not completion order — so the transcript maintains the order of the original tool_use blocks. getRemainingResults() (lines 453-490) is the async drain the main loop uses at query.ts:1384-1408.
13.3 Parallel Agent invocations
When the model issues multiple AgentTool calls in one turn — which the AgentTool prompt actively encourages (prompt.ts:263-264: "Launch multiple agents concurrently whenever possible, to maximize performance; to do that, use a single message with multiple tool uses") — each goes through the same StreamingToolExecutor mechanism.
AgentTool.isConcurrencySafeis true, so parallel Agent spawns are allowed.- Each
AgentTool.calleither (a) awaits a foreground child viarunAgent()or (b) registers a background task and returns immediately. - For foreground agents,
runAgent()is itself an async generator running its ownquery()loop. Multiple such generators execute as independent async tasks.
13.4 Background agents
Set run_in_background: true on an AgentTool call, and the behavior shifts to a fire-and-forget task registered in the AppState's task registry.
Registration — tools/AgentTool/AgentTool.tsx:688-703
const agentBackgroundTask = registerAsyncAgent({
taskId: agentId,
agentId,
description,
prompt,
// Don't link to parent's abort controller -- background agents should
// survive when the parent is cancelled
abortController: createAbortController(),
...
})Note the comment: background agents get their own AbortController so a parent Escape doesn't kill them.
Task state — tasks/LocalAgentTask/LocalAgentTask.tsx:116-148
type LocalAgentTaskState = TaskStateBase & {
type: 'local_agent'
agentId: string
isBackgrounded: boolean
pendingMessages: string[] // SendMessage routing queue
progress?: AgentProgress // token count, tool use count, activities
}Notification when done — LocalAgentTask.tsx:197-200
export function enqueueAgentNotification({ taskId, description, status, result, usage }: ...) {
enqueuePendingNotification({
mode: 'task-notification',
agentId: taskId,
...
})
}The notification is enqueued into the same command queue the main loop drains, with mode 'task-notification' and priority 'later'. The parent sees it as a user-role message on its next iteration, typically arriving as <task-notification>…</task-notification> XML in coordinator mode.
13.5 Fork subagents — tools/AgentTool/forkSubagent.ts
Fork subagents are the cheapest subagent variant because they share the parent's prompt cache byte-for-byte.
Triggering
When the model calls AgentTool({..., }) without subagent_type and isForkSubagentEnabled() is true, the synthetic FORK_AGENT definition is used:
// forkSubagent.ts:60-71
export const FORK_AGENT = {
agentType: FORK_SUBAGENT_TYPE,
tools: ['*'],
maxTurns: 200,
model: 'inherit',
permissionMode: 'bubble', // permission prompts surface to the parent
getSystemPrompt: () => '', // overridden with parent's cached prompt
}Message cloning — buildForkedMessages() at forkSubagent.ts:107-169
The fork's initial message array contains:
- A clone of the parent's full assistant message including all tool_use blocks and thinking.
- A single user message with placeholder tool_results for every tool_use, each saying "Fork started — processing in background":
const toolResultBlocks = toolUseBlocks.map(block => ({
type: 'tool_result',
tool_use_id: block.id,
content: [{ type: 'text', text: 'Fork started — processing in background' }],
}))- A per-fork directive text block (the user's prompt for this child).
Byte-identical prefixes across fork children are the point: the tools block, the system prompt, and the cached messages line up exactly, so every fork reads from the same global prompt cache entry regardless of which fork launches.
Recursion guard — forkSubagent.ts:78-88
isInForkChild(messages) scans for a <FORK_BOILERPLATE_TAG> sentinel to detect "am I already a fork". If so, AgentTool refuses further forking — forks don't fork.
13.6 Teams and in-process teammates
A team is a set of in-process or tmux-based teammate agents with a coordinator. They share:
- Async coordination — each teammate is its own async task inside the same process (in-process mode) or its own tmux pane.
- Message routing —
SendMessageTooldelivers messages between them. - Shared team memory — team scope of the memory system (
memdir/teamMemPaths.ts:85→memory/team/).
Teammate identity — utils/teammate.ts:44-51
let dynamicTeamContext: {
agentId: string
agentName: string
teamName: string
parentSessionId?: string
planModeRequired: boolean
} | null = nullIn-process vs. tmux
spawnMultiAgent.ts:50+:
- In-process:
spawnInProcessTeammate()registers a newInProcessTeammateTaskStateand runs a teammate query loop inside the same Node process. Teammate identity is propagated viaAsyncLocalStorageso child code sees its own teammate context. - tmux: a tmux pane spawns a new
claudeprocess with--agent-id,--team-name, etc. Communication is via mailbox files on disk.
team_name / mode in AgentTool schema — AgentTool.tsx:91-102
The extended agent schema adds name, team_name, mode (permission mode), isolation (worktree | remote), cwd. These appear only when the parent is allowed to run teams (not an in-process teammate; not a coordinator-locked worker).
13.7 SendMessageTool — inter-agent messaging — tools/SendMessageTool/SendMessageTool.ts
Minimal schema:
to: string // teammate name, "*" for broadcast, "uds:<socket>", "bridge:<session-id>"
message: string | StructuredMessageStructuredMessage variants include shutdown_request, shutdown_response, plan_approval_response — opinionated control signals rather than free text.
Routing (SendMessageTool.ts:133-150)
- In-process teammate →
LocalAgentTask.queuePendingMessage()appends topendingMessages. The teammate drains on its next turn. - tmux teammate →
writeToMailbox()writes the message to a mailbox file. - Broadcast (
to: "*") → iterate over all active teammates and send. bridge:<session-id>→ send to a remote-control session via the bridge.uds:<socket-path>→ send to a local peer via unix domain socket.
13.8 Coordinator mode — /workspaces/src/coordinator/coordinatorMode.ts
This is the most structured multi-agent setup.
Gate
// coordinatorMode.ts:36-41
export function isCoordinatorMode(): boolean {
if (feature('COORDINATOR_MODE')) {
return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
}
return false
}Explicitly mutually exclusive with fork subagents (forkSubagent.ts:34: if (isCoordinatorMode()) return false). The coordinator already orchestrates — it shouldn't also silently fork.
Role definition — coordinatorMode.ts:111-369
The coordinator system prompt reframes Claude from "software engineer" to "orchestrator." Key themes:
- You are a coordinator. Your job is to direct workers to research, implement, and verify; synthesize results; communicate with the user.
- Parallelism is your superpower. "Workers are async. Launch independent workers concurrently whenever possible — don't serialize work that can run simultaneously." (lines 212-213).
- Task notifications. Worker completions arrive as
<task-notification>…</task-notification>XML blocks (lines 142-160):
<task-notification>
<task-id>{agentId}</task-id>
<status>completed|failed|killed</status>
<summary>{human-readable status summary}</summary>
<result>{agent's final text response}</result>
<usage>…</usage>
</task-notification>
Tool segregation
coordinatorMode.ts:29-34:
- Coordinator-only tools:
TEAM_CREATE,TEAM_DELETE,SEND_MESSAGE,SYNTHETIC_OUTPUT. - Worker-only tool pool:
ASYNC_AGENT_ALLOWED_TOOLS— a curated set excluding the coordinator-only tools.
This prevents workers from spawning their own teams or sending arbitrary control messages.
Additional context (QueryEngine.ts:111-118,302-308)
getCoordinatorUserContext(mcpClients, scratchpadDir) is mixed into userContext so the coordinator sees the active worker roster and scratchpad path.
13.9 Putting it all together — execution flow
When the coordinator emits [AgentTool(A), AgentTool(B), AgentTool(C)] in one assistant message:
- Streaming collection — each
tool_useblock is pushed totoolUseBlocksas it streams (query.ts:829). - Scheduler registration — StreamingToolExecutor adds all three with
isConcurrencySafe=true(query.ts:842). - Parallel dispatch —
canExecuteToolapproves all three; they start concurrently. - Per-agent execution — each
AgentTool.calleither spawns a background async task (ifrun_in_background) or awaits a foreground child viarunAgent(). - Foreground children run their own
query()loop insiderunAgent(), streaming messages into a sidechain transcript and into the teams panel. - Background children run outside the parent's AbortController; completion triggers
enqueueAgentNotificationwhich drops a'task-notification'into the command queue. - Result collection —
StreamingToolExecutor.getRemainingResults()yields results in submission order once each child's top-level tool result is ready. - Coordinator next turn — all three child results (as tool_result blocks or as future task-notification user messages) are in the conversation; the coordinator decides what to do next.
13.10 What to remember
- Every parallel path goes through
StreamingToolExecutoror its serial siblingrunTools. There is no separate "agent scheduler" — the agent runtime piggybacks on the tool runtime. - Fork subagents share cache; fresh subagents don't. Picking between them is about whether inherited context is helpful or harmful.
- Background agents get their own AbortController so parent Escape doesn't kill them.
- Teams communicate via
SendMessageToolfor deliberate signals; via mailbox files for tmux; via AsyncLocalStorage for ambient context in-process. - Coordinator mode is a role-redefinition, not a new runtime. It reuses the same loop, just with a different system prompt and restricted worker tool sets.