Agentic AI

Agentic AI

Claude Code Pattern 7: Multi-Agent Coordination

Ken Huang's avatar
Ken Huang
Apr 08, 2026
∙ Paid

Introduction

In the previous chapter, we examined context management at scale and saw how the harness handles long-running conversations through token counting, auto-compact, reactive compact, context collapse, snip, and micro-compact. We learned how these strategies work together to ensure that conversations can continue indefinitely without hitting context limits. But context management is only one aspect of scaling AI agents. As tasks grow more complex, a single agent is often not enough. Different tasks require different expertise, and some tasks benefit from parallel execution. The harness supports multi-agent coordination through the AgentTool, which allows one agent to spawn and delegate to other agents.

Multi-agent coordination is essential for tackling complex problems that exceed the capabilities of a single agent. A coding agent might spawn a testing agent to validate changes. A research agent might spawn multiple analysis agents to examine different aspects of a topic in parallel. A planning agent might spawn specialized execution agents for different phases of a project. The harness manages the full lifecycle of spawned agents — allocating resources, tracking progress, handling errors, and cleaning up on completion.

This chapter examines the multi-agent coordination system in detail, showing how the harness spawns and manages agents, routes between different execution modes, isolates agents through worktrees, inherits system prompts for efficiency, coordinates teams of agents, and enables inter-agent communication. We will see how the AgentTool serves as the central orchestrator, how fork subagents inherit the parent’s context, how async agents run in the background, how worktrees provide filesystem isolation, and how named agents enable message routing.

The Agent Tool as Multi-Agent Orchestrator

The AgentTool is the primary mechanism for spawning and coordinating multiple agents. It supports several execution modes that serve different use cases. Synchronous subagents wait for completion before continuing, blocking the parent agent until the child finishes. Asynchronous agents run in the background, allowing the parent to continue working while receiving notifications on completion. Fork subagents inherit the parent’s context and tools, enabling them to pick up where the parent left off. Teammates are named agents in a team with message routing, enabling ongoing collaboration. Remote agents execute in a separate CCR environment for isolation.

The AgentTool is defined in the codebase as:

// src/tools/AgentTool/AgentTool.tsx

const AgentTool = buildTool({

async call({

prompt,

subagent_type,

description,

model: modelParam,

run_in_background,

name,

team_name,

mode: spawnMode,

isolation,

cwd

}: AgentToolInput, toolUseContext, canUseTool, assistantMessage, onProgress?) {

// ... agent spawning logic

}

})

The call method receives the prompt for the subagent, an optional subagent type for specialized agents, a description for display, an optional model override, a flag for background execution, an optional name for message routing, an optional team name for team coordination, a spawn mode, an isolation level, and an optional working directory. The tool use context provides access to the current state, the canUseTool function enables recursive tool invocation, the assistant message provides context about the parent’s request, and the onProgress callback enables real-time progress reporting.

Agent Selection and Routing

The harness routes agent spawning requests to different paths based on the subagent_type parameter and feature flags. When subagent_type is explicitly set, that agent type is used. When subagent_type is omitted and the fork subagent feature is enabled, the fork path is used with an undefined effective type. When subagent_type is omitted and the fork feature is disabled, the default general-purpose agent is used.

The routing logic is:

// Fork subagent experiment routing:

// - subagent_type set: use it (explicit wins)

// - subagent_type omitted, gate on: fork path (undefined)

// - subagent_type omitted, gate off: default general-purpose

const effectiveType = subagent_type ?? (isForkSubagentEnabled() ? undefined : GENERAL_PURPOSE_AGENT.agentType)

const isForkPath = effectiveType === undefined

let selectedAgent: AgentDefinition

if (isForkPath) {

// Recursive fork guard

if (toolUseContext.options.querySource === `agent:builtin:${FORK_AGENT.agentType}` ||

isInForkChild(toolUseContext.messages)) {

throw new Error(’Fork is not available inside a forked worker.’)

}

selectedAgent = FORK_AGENT

} else {

const agents = filterDeniedAgents(

allowedAgentTypes ? allAgents.filter(a => allowedAgentTypes.includes(a.agentType)) : allAgents,

appState.toolPermissionContext,

AGENT_TOOL_NAME

)

const found = agents.find(agent => agent.agentType === effectiveType)

if (!found) {

const agentExistsButDenied = allAgents.find(agent => agent.agentType === effectiveType)

if (agentExistsButDenied) {

const denyRule = getDenyRuleForAgent(appState.toolPermissionContext, AGENT_TOOL_NAME, effectiveType)

throw new Error(`Agent type ‘${effectiveType}’ has been denied by permission rule ‘${denyRule?.source}’.`)

}

throw new Error(`Agent type ‘${effectiveType}’ not found.`)

}

selectedAgent = found

}

The recursive fork guard prevents infinite nesting — if the current agent is already a fork agent or if the conversation is inside a fork child, the fork path is blocked. This prevents a fork agent from spawning another fork agent, which would create an infinite chain.

MCP Server Requirements

Agents can declare required MCP servers that must be available before the agent can be spawned:

const requiredMcpServers = selectedAgent.requiredMcpServers

if (requiredMcpServers?.length) {

const hasPendingRequiredServers = appState.mcp.clients.some(

c => c.type === ‘pending’ && requiredMcpServers.some(pattern =>

c.name.toLowerCase().includes(pattern.toLowerCase())

)

)

if (hasPendingRequiredServers) {

const MAX_WAIT_MS = 30_000

const POLL_INTERVAL_MS = 500

const deadline = Date.now() + MAX_WAIT_MS

while (Date.now() < deadline) {

await sleep(POLL_INTERVAL_MS)

currentAppState = toolUseContext.getAppState()

const hasFailedRequiredServer = currentAppState.mcp.clients.some(

c => c.type === ‘failed’ && requiredMcpServers.some(...)

)

if (hasFailedRequiredServer) break

const stillPending = currentAppState.mcp.clients.some(...)

if (!stillPending) break

}

}

const serversWithTools: string[] = []

for (const tool of currentAppState.mcp.tools) {

if (tool.name?.startsWith(’mcp__’)) {

const parts = tool.name.split(’__’)

const serverName = parts[1]

if (serverName && !serversWithTools.includes(serverName)) {

serversWithTools.push(serverName)

}

}

}

if (!hasRequiredMcpServers(selectedAgent, serversWithTools)) {

const missing = requiredMcpServers.filter(pattern =>

!serversWithTools.some(server => server.toLowerCase().includes(pattern.toLowerCase()))

)

throw new Error(`Agent ‘${selectedAgent.agentType}’ requires MCP servers matching: ${missing.join(’, ‘)}`)

}

}

This logic waits up to 30 seconds for pending MCP servers to connect, polling every 500 milliseconds. If a required server fails to connect, the wait terminates early with an error. After waiting, the system checks whether the required servers have tools available and throws a descriptive error if any are missing.

Async vs. Sync Execution

The decision to run an agent asynchronously or synchronously depends on multiple factors:

const forceAsync = isForkSubagentEnabled()

const assistantForceAsync = feature(’KAIROS’) ? appState.kairosEnabled : false

const shouldRunAsync = (

run_in_background === true ||

selectedAgent.background === true ||

isCoordinator ||

forceAsync ||

assistantForceAsync ||

(proactiveModule?.isProactiveActive() ?? false)

) && !isBackgroundTasksDisabled

An agent runs asynchronously if any of these conditions are true: the user explicitly requested background execution, the agent definition marks it as a background agent, the system is in coordinator mode, the fork subagent feature forces async, the assistant feature forces async, or the proactive agent module is active. The final check ensures background tasks are not globally disabled.

Async Execution Path

When an agent runs asynchronously, it is registered as a background task and the parent receives immediate control:

if (shouldRunAsync) {

const asyncAgentId = earlyAgentId

const agentBackgroundTask = registerAsyncAgent({

agentId: asyncAgentId,

description,

prompt,

selectedAgent,

setAppState: rootSetAppState,

toolUseId: toolUseContext.toolUseId

})

// Register name → agentId for SendMessage routing

if (name) {

rootSetAppState(prev => {

const next = new Map(prev.agentNameRegistry)

next.set(name, asAgentId(asyncAgentId))

return { ...prev, agentNameRegistry: next }

})

}

const asyncAgentContext: SubagentContext = {

agentId: asyncAgentId,

parentSessionId: getParentSessionId(),

agentType: ‘subagent’ as const,

subagentName: selectedAgent.agentType,

isBuiltIn: isBuiltInAgent(selectedAgent),

invokingRequestId: assistantMessage?.requestId as string | undefined,

invocationKind: ‘spawn’ as const,

invocationEmitted: false

}

void runWithAgentContext(asyncAgentContext, () => wrapWithCwd(() => runAsyncAgentLifecycle({...})))

const canReadOutputFile = toolUseContext.options.tools.some(

t => toolMatchesName(t, FILE_READ_TOOL_NAME) || toolMatchesName(t, BASH_TOOL_NAME)

)

return {

data: {

isAsync: true as const,

status: ‘async_launched’ as const,

agentId: agentBackgroundTask.agentId,

description: description,

prompt: prompt,

outputFile: getTaskOutputPath(agentBackgroundTask.agentId),

canReadOutputFile

}

}

}

The async agent is registered with a unique ID, added to the name registry if a name was provided, and launched in the background. The parent receives an immediate response containing the agent ID, the output file path, and a flag indicating whether the parent can read the output file. The parent can then continue working or use SendMessage to communicate with the async agent.

Sync Execution Path

When an agent runs synchronously, the parent blocks until the child completes:

else {

const syncAgentId = asAgentId(earlyAgentId)

const syncAgentContext: SubagentContext = {

agentId: syncAgentId,

parentSessionId: getParentSessionId(),

agentType: ‘subagent’ as const,

subagentName: selectedAgent.agentType,

isBuiltIn: isBuiltInAgent(selectedAgent),

invocationKind: ‘spawn’ as const,

invocationEmitted: false

}

return runWithAgentContext(syncAgentContext, () => wrapWithCwd(async () => {

const agentMessages: MessageType[] = []

const agentStartTime = Date.now()

const syncTracker = createProgressTracker()

const syncResolveActivity = createActivityDescriptionResolver(toolUseContext.options.tools)

// Yield initial progress

if (promptMessages.length > 0) {

const normalizedPromptMessages = normalizeMessages(promptMessages)

const normalizedFirstMessage = normalizedPromptMessages.find((m): m is NormalizedUserMessage => m.type === ‘user’)

if (normalizedFirstMessage && onProgress) {

onProgress({

toolUseID: `agent_${assistantMessage.message.id}`,

data: {

message: normalizedFirstMessage,

type: ‘agent_progress’,

prompt,

agentId: syncAgentId

}

})

}

}

// Register as foreground task

let foregroundTaskId: string | undefined

let backgroundPromise: Promise<{ type: ‘background’ }> | undefined

let cancelAutoBackground: (() => void) | undefined

if (!isBackgroundTasksDisabled) {

const registration = registerAgentForeground({

agentId: syncAgentId,

description,

prompt,

selectedAgent,

setAppState: rootSetAppState,

toolUseId: toolUseContext.toolUseId,

autoBackgroundMs: getAutoBackgroundMs() || undefined

})

foregroundTaskId = registration.taskId

backgroundPromise = registration.backgroundSignal.then(() => ({ type: ‘background’ as const }))

cancelAutoBackground = registration.cancelAutoBackground

}

// Get async iterator for the agent

const agentIterator = runAgent({...})[Symbol.asyncIterator]()

let syncAgentError: Error | undefined

let wasAborted = false

let wasBackgrounded = false

let worktreeResult = {}

try {

while (true) {

const elapsed = Date.now() - agentStartTime

// Show background hint after threshold

if (!isBackgroundTasksDisabled && !backgroundHintShown && elapsed >= PROGRESS_THRESHOLD_MS) {

backgroundHintShown = true

toolUseContext.setToolJSX({

jsx: <BackgroundHint />,

shouldHidePromptInput: false,

shouldContinueAnimation: true,

showSpinner: true

})

}

// Race between next message and background signal

const nextMessagePromise = agentIterator.next()

const raceResult = backgroundPromise

? await Promise.race([

nextMessagePromise.then(r => ({ type: ‘message’ as const, result: r })),

backgroundPromise

])

: { type: ‘message’ as const, result: await nextMessagePromise }

// Handle backgrounding

if (raceResult.type === ‘background’ && foregroundTaskId) {

// ... transition to background execution ...

}

// Process message

const { result } = raceResult

if (result.done) break

const message = result.value as MessageType

agentMessages.push(message)

// Update progress

updateProgressFromMessage(syncTracker, message, syncResolveActivity, toolUseContext.options.tools)

// Forward bash_progress events

if (message.type === ‘progress’ && onProgress) {

onProgress({ toolUseID: message.toolUseID as string, data: message.data })

}

}

} catch (error) {

if (error instanceof AbortError) {

wasAborted = true

throw error

}

syncAgentError = toError(error)

} finally {

// ... cleanup ...

}

const agentResult = finalizeAgentTool(agentMessages, syncAgentId, metadata)

return {

data: {

status: ‘completed’ as const,

prompt,

...agentResult,

...worktreeResult

}

}

}))

}

The sync execution path is more complex because it must handle real-time progress updates, backgrounding transitions, and error recovery. The agent iterator yields messages as the child agent processes them. Each message is processed to update progress tracking and forward progress events to the parent. If the agent runs longer than the progress threshold, a background hint is shown to the user. If the user triggers auto-background, the sync agent transitions to async execution.

Worktree Isolation

Agents can run in isolated git worktrees to provide filesystem separation:

const effectiveIsolation = isolation ?? selectedAgent.isolation

if (effectiveIsolation === ‘worktree’) {

const slug = `agent-${earlyAgentId.slice(0, 8)}`

worktreeInfo = await createAgentWorktree(slug)

}

// Fork + worktree: inject notice for path translation

if (isForkPath && worktreeInfo) {

promptMessages.push(createUserMessage({

content: buildWorktreeNotice(getCwd(), worktreeInfo.worktreePath)

}))

}

// Cleanup after agent completes

const cleanupWorktreeIfNeeded = async (): Promise<{ worktreePath?: string; worktreeBranch?: string }> => {

if (!worktreeInfo) return {}

const { worktreePath, worktreeBranch, headCommit, gitRoot, hookBased } = worktreeInfo

worktreeInfo = null // Make idempotent

if (hookBased) {

logForDebugging(`Hook-based agent worktree kept at: ${worktreePath}`)

return { worktreePath }

}

if (headCommit) {

const changed = await hasWorktreeChanges(worktreePath, headCommit)

if (!changed) {

await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot)

void writeAgentMetadata(asAgentId(earlyAgentId), { agentType: selectedAgent.agentType, description })

.catch(_err => logForDebugging(`Failed to clear worktree metadata: ${_err}`))

return {}

}

}

logForDebugging(`Agent worktree has changes, keeping: ${worktreePath}`)

return { worktreePath, worktreeBranch }

}

The worktree is created with a unique slug derived from the agent ID. For fork agents, a notice is injected into the prompt to inform the agent about the path translation between the parent’s working directory and the worktree. After the agent completes, the cleanup function checks whether the worktree has changes. If it does not, the worktree is removed. If it has changes, the worktree is kept so the user can review the modifications. Hook-based worktrees are always kept for debugging.

System Prompt Inheritance (Fork Path)

Fork subagents inherit the parent’s system prompt for cache-identical API prefixes:

User's avatar

Continue reading this post for free, courtesy of Ken Huang.

Or purchase a paid subscription.
© 2026 ken · Privacy ∙ Terms ∙ Collection notice
Start your SubstackGet the app
Substack is the home for great culture