CodeDocs Vault

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

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):

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):

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.

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:

const toolResultBlocks = toolUseBlocks.map(block => ({
  type: 'tool_result',
  tool_use_id: block.id,
  content: [{ type: 'text', text: 'Fork started — processing in background' }],
}))

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:

Teammate identity — utils/teammate.ts:44-51

let dynamicTeamContext: {
  agentId: string
  agentName: string
  teamName: string
  parentSessionId?: string
  planModeRequired: boolean
} | null = null

In-process vs. tmux

spawnMultiAgent.ts:50+:

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 | StructuredMessage

StructuredMessage variants include shutdown_request, shutdown_response, plan_approval_response — opinionated control signals rather than free text.

Routing (SendMessageTool.ts:133-150)

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:

<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:

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:

  1. Streaming collection — each tool_use block is pushed to toolUseBlocks as it streams (query.ts:829).
  2. Scheduler registration — StreamingToolExecutor adds all three with isConcurrencySafe=true (query.ts:842).
  3. Parallel dispatchcanExecuteTool approves all three; they start concurrently.
  4. Per-agent execution — each AgentTool.call either spawns a background async task (if run_in_background) or awaits a foreground child via runAgent().
  5. Foreground children run their own query() loop inside runAgent(), streaming messages into a sidechain transcript and into the teams panel.
  6. Background children run outside the parent's AbortController; completion triggers enqueueAgentNotification which drops a 'task-notification' into the command queue.
  7. Result collectionStreamingToolExecutor.getRemainingResults() yields results in submission order once each child's top-level tool result is ready.
  8. 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