Coordinator mode is a first-class operating mode in Claude Code where Claude acts exclusively as a dispatcher — it never runs tools itself. Every side-effecting operation is delegated to worker agents that the coordinator spawns and directs. The coordinator's job is to think, plan, synthesize, and communicate; the workers' job is to execute.
CLAUDE_CODE_COORDINATOR_MODE=1 activates it. The function
isCoordinatorMode() reads this at runtime — no caching — so you can flip it in
a resumed session and the change takes effect immediately.
This separation is not cosmetic. The coordinator receives a different system prompt, a different tool set, and a different context block compared to a regular Claude Code session. Understanding how each piece is wired together is the subject of this lesson.
The coordinator speaks only to the user and directs workers. Workers speak only to the coordinator
(via task-notification XML messages). The coordinator never executes Bash, edits files,
or reads the filesystem directly.
Two exported functions control whether coordinator mode is on and keep it consistent across session resumes.
isCoordinatorMode() and matchSessionMode() — coordinatorMode.tsexport function isCoordinatorMode(): boolean {
if (feature('COORDINATOR_MODE')) {
return isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE)
}
return false
}
/**
* Checks if the current coordinator mode matches the session's stored mode.
* If mismatched, flips the environment variable so isCoordinatorMode()
* returns the correct value for the resumed session.
* Returns a warning message if the mode was switched, or undefined if not.
*/
export function matchSessionMode(
sessionMode: 'coordinator' | 'normal' | undefined,
): string | undefined {
if (!sessionMode) return undefined // old session, no tracking
const currentIsCoordinator = isCoordinatorMode()
const sessionIsCoordinator = sessionMode === 'coordinator'
if (currentIsCoordinator === sessionIsCoordinator) return undefined
// Flip the env var — isCoordinatorMode() reads it live, no caching
if (sessionIsCoordinator) {
process.env.CLAUDE_CODE_COORDINATOR_MODE = '1'
} else {
delete process.env.CLAUDE_CODE_COORDINATOR_MODE
}
logEvent('tengu_coordinator_mode_switched', { to: sessionMode })
return sessionIsCoordinator
? 'Entered coordinator mode to match resumed session.'
: 'Exited coordinator mode to match resumed session.'
}
Notice that matchSessionMode() mutates process.env directly instead of
returning a new mode value. Because isCoordinatorMode() has no internal cache — it reads
the env var on every call — writing to process.env.CLAUDE_CODE_COORDINATOR_MODE is
sufficient to flip the entire mode for the rest of the process lifetime.
COORDINATOR_MODE Bun bundle feature is enabled. In production builds this gate
controls which organizations or experiments see the mode. In development builds it is on by default.
This is the most important mechanical difference from a normal session. The coordinator's allowed
tool set is defined as a constant in constants/tools.ts:
/**
* Tools allowed in coordinator mode — only output and agent management
* tools for the coordinator itself.
*/
export const COORDINATOR_MODE_ALLOWED_TOOLS = new Set([
AGENT_TOOL_NAME, // 'Agent' — spawn a new worker
TASK_STOP_TOOL_NAME, // 'TaskStop' — kill a running worker
SEND_MESSAGE_TOOL_NAME, // 'SendMessage' — continue an existing worker
SYNTHETIC_OUTPUT_TOOL_NAME, // internal output tool
])
Four tools. That is the entire interface available to the coordinator. No Bash,
no Read, no Edit, no Glob, no Grep.
The coordinator cannot touch the filesystem, run commands, or call MCP tools directly.
It can only spawn workers, message them, and stop them.
Creates a fresh worker with its own context, tool access, and system prompt. Use subagent_type: "worker". Workers run asynchronously by default and notify the coordinator on completion.
Sends a follow-up message to a worker identified by its task_id. The worker retains full context from its prior run — use this for corrections and follow-ups rather than spawning fresh.
Terminates a worker mid-execution. Use when the user changes requirements after launch, or when an approach is clearly wrong. A stopped worker can still be continued via SendMessage.
Used internally to surface structured output to the coordinator's own turn. Not typically called explicitly in coordinator prompts — it is part of the multi-agent notification machinery.
Agent receive the full ASYNC_AGENT_ALLOWED_TOOLS
set (Bash, Read, Edit, Glob, Grep, WebSearch, Skill, Notebook, Worktree, and more) plus MCP tools
from connected servers.
getCoordinatorUserContext()
When coordinator mode is active, getCoordinatorUserContext() injects a context block
into the coordinator's conversation that describes what tools the workers have access to. This is
how the coordinator knows what to delegate and at what level of specificity.
getCoordinatorUserContext() — coordinatorMode.ts// Tools hidden from workers even though they are in ASYNC_AGENT_ALLOWED_TOOLS
const INTERNAL_WORKER_TOOLS = new Set([
TEAM_CREATE_TOOL_NAME, // 'TeamCreate'
TEAM_DELETE_TOOL_NAME, // 'TeamDelete'
SEND_MESSAGE_TOOL_NAME, // 'SendMessage'
SYNTHETIC_OUTPUT_TOOL_NAME,
])
export function getCoordinatorUserContext(
mcpClients: ReadonlyArray<{ name: string }>,
scratchpadDir?: string,
): { [k: string]: string } {
if (!isCoordinatorMode()) return {}
// Simple mode: workers only get Bash, Read, Edit
const workerTools = isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)
? [BASH_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_EDIT_TOOL_NAME].sort().join(', ')
: Array.from(ASYNC_AGENT_ALLOWED_TOOLS)
.filter(name => !INTERNAL_WORKER_TOOLS.has(name))
.sort()
.join(', ')
let content = `Workers spawned via the Agent tool have access to these tools: ${workerTools}`
if (mcpClients.length > 0) {
const serverNames = mcpClients.map(c => c.name).join(', ')
content += `\n\nWorkers also have access to MCP tools from connected MCP servers: ${serverNames}`
}
if (scratchpadDir && isScratchpadGateEnabled()) {
content += `\n\nScratchpad directory: ${scratchpadDir}\nWorkers can read and write here without permission prompts.`
}
return { workerToolsContext: content }
}
Three things happen in this function:
ASYNC_AGENT_ALLOWED_TOOLS, removing the INTERNAL_WORKER_TOOLS set
(TeamCreate, TeamDelete, SendMessage, SyntheticOutput) which are coordinator-only primitives.CLAUDE_CODE_SIMPLE=1) is a stripped-down variant.
Workers only get Bash, Read, and Edit — no web search, no notebooks, no worktree management.
This is the mode for SDK consumers who want a minimal, predictable agent surface.
The heart of coordinator mode is its system prompt — returned by getCoordinatorSystemPrompt().
It is a 300+ line document that defines the coordinator's role, its tool contracts, the
four-phase task workflow, and detailed prompt-engineering guidelines for writing worker instructions.
Below are the six major sections annotated.
You are a coordinator. Your job is to:
- Help the user achieve their goal
- Direct workers to research, implement and verify code changes
- Synthesize results and communicate with the user
- Answer questions directly when possible — don't delegate work that
you can handle without tools
Every message you send is to the user. Worker results and system
notifications are internal signals, not conversation partners —
never thank or acknowledge them. Summarize new information for
the user as it arrives.
The key constraint: the coordinator always addresses the user, not workers. Worker
task-notification messages are treated as internal signals, not conversation turns.
Worker results arrive as user-role messages containing a <task-notification>
XML envelope. This is how the coordinator knows a worker finished:
The <task-id> value is the agent ID — pass it to SendMessage
as to to continue that specific worker. <result> and
<usage> are optional.
Synthesis is the coordinator's most important phase. The system prompt explicitly forbids phrases like "based on your findings" — the coordinator must understand the research and translate it into precise specs before handing work back to workers.
The system prompt treats concurrency as a first-class design principle:
To launch workers in parallel, make multiple Agent tool calls in a single coordinator turn.
Workers run asynchronously; the coordinator does not block waiting for one before launching another.
Let me investigate from two angles.
Agent({ description: "Investigate auth bug", subagent_type: "worker",
prompt: "Find null pointer sources in src/auth/. Report file paths,
line numbers, types. Do not modify files." })
Agent({ description: "Research auth test coverage", subagent_type: "worker",
prompt: "Find all test files for src/auth/. Report gaps around
session expiry. Do not modify files." })
Investigating both in parallel — I will report back with findings.
| Situation | Action | Why |
|---|---|---|
| Research explored exactly the files that need editing | Continue | Worker already has files in context AND gets a clear plan |
| Research was broad but implementation is narrow | Spawn fresh | Avoid dragging exploration noise into focused implementation |
| Correcting a failure or extending recent work | Continue | Worker has the error context and knows what it just tried |
| Verifying code a different worker wrote | Spawn fresh | Verifier should see code with fresh eyes |
| First attempt used entirely wrong approach | Spawn fresh | Wrong-approach context pollutes the retry |
| Unrelated task | Spawn fresh | No useful context to reuse |
Workers cannot see the coordinator's conversation. Every prompt must be fully self-contained.
Both modes use Claude Code's same underlying infrastructure — sessions, tools, permissions, MCP clients. The difference is in the role the main instance plays.
| Dimension | Single-Agent | Coordinator |
|---|---|---|
| Role | Does the work itself | Dispatches and synthesizes; never executes |
| Tool count | Full tool set (Bash, Read, Edit, Glob, ...) | 4 tools only: Agent, SendMessage, TaskStop, SyntheticOutput |
| Filesystem access | Direct read/write | None — must delegate to workers |
| System prompt | Standard Claude Code prompt | getCoordinatorSystemPrompt() — task workflow, parallel dispatch, synthesis rules |
| Worker context | Not injected | getCoordinatorUserContext() injects worker tool list + MCP servers + scratchpad |
| Parallelism | Sequential tool calls | Multiple concurrent workers via parallel Agent calls |
| Session resume | Mode irrelevant | matchSessionMode() flips env var to restore correct mode for resumed sessions |
| Simple mode variant | N/A | CLAUDE_CODE_SIMPLE=1 limits workers to Bash + Read + Edit only |
| Scratchpad | N/A | Shared directory injected into worker context — permission-free cross-worker storage |
This annotated example traces a complete auth bug fix through all four phases. Each "You:" block
is a separate coordinator turn. "User:" blocks are task-notification messages
arriving between turns.
// User request
User: "There's a null pointer in the auth module. Can you fix it?"
// Turn 1 — coordinator launches research workers in parallel
You:
Let me investigate from two angles.
Agent({ description: "Investigate auth bug", subagent_type: "worker",
prompt: "Investigate src/auth/. Find where null pointer exceptions
could occur around session handling and token validation.
Report specific file paths, line numbers, and types.
Do not modify files." })
Agent({ description: "Research auth tests", subagent_type: "worker",
prompt: "Find all test files related to src/auth/. Report structure,
coverage, and gaps around session expiry. Do not modify files." })
Investigating from two angles — I will report back with findings.
// task-notification from Worker A
User:
<task-notification>
<task-id>agent-a1b</task-id>
<status>completed</status>
<summary>Agent "Investigate auth bug" completed</summary>
<result>Found null pointer in src/auth/validate.ts:42. The user field
on Session (types.ts:15) is undefined when sessions expire but the
token remains cached. validate() accesses user.id without null check.
</result>
</task-notification>
// Turn 2 — synthesize finding, continue same worker with specific spec
You:
Found the bug — null pointer in validate.ts:42. Still waiting on test coverage.
SendMessage({ to: "agent-a1b",
message: "Fix the null pointer in src/auth/validate.ts:42.
The user field on Session (src/auth/types.ts:15) is
undefined when sessions expire but the token is still
cached. Add a null check before user.id access — if
user is null, return 401 with 'Session expired'.
Run the auth test suite, commit, report the hash." })
Fix is in progress. Still waiting for test coverage findings.
// task-notification from Worker B
User:
<task-notification>
<task-id>agent-b2c</task-id>
<status>completed</status>
<result>Test file: src/auth/__tests__/validate.test.ts. Session expiry
paths are tested at lines 58 and 72 but only for the happy path —
no test for expired session + cached token.</result>
</task-notification>
// Turn 3 — spawn fresh verifier once Worker A reports back
// (Worker A's implementation-heavy context should not contaminate verification)
You:
Test coverage found: validate.test.ts lacks expired session + cached token cases.
I will have the verifier add those once the fix lands.
// After Worker A reports fix committed:
You:
Fix is committed. Launching verification.
Agent({ description: "Verify auth fix", subagent_type: "worker",
prompt: "Verify the null-check fix in src/auth/validate.ts:42.
Add test cases to validate.test.ts (lines 58 and 72 area)
covering expired session + cached token — expect 401
with 'Session expired'. Run full auth suite with the
feature enabled. Investigate any failures — do not dismiss
as unrelated. Report pass/fail and the updated test hash." })
Verification in progress.
When the tengu_scratch feature gate is active and a scratchpad directory is provided,
coordinator mode injects the scratchpad path into the worker context. Workers can read and write
there without permission prompts, making it a shared workspace for durable findings.
coordinatorMode.ts as a local isScratchpadGateEnabled() function
instead of importing from utils/permissions/filesystem.ts. The comment explains:
importing that file would create a circular dependency chain
(filesystem → permissions → ... → coordinatorMode). The actual scratchpad
path is injected via dependency injection from QueryEngine.ts, which sits higher
in the dependency graph.
The coordinator can instruct workers to write findings, partial results, or structured data to the scratchpad so that later workers (or the coordinator on subsequent turns) can read it without re-doing work. This is especially useful for long-running parallel research tasks where multiple workers need to share context.
CLAUDE_CODE_COORDINATOR_MODE=1. isCoordinatorMode() reads it live with no caching, so writing to process.env flips the mode immediately.getCoordinatorUserContext() injects a runtime-computed list of worker tools into the coordinator's context so it knows what to delegate. In simple mode (CLAUDE_CODE_SIMPLE=1), workers get only Bash, Read, and Edit.matchSessionMode() ensures resumed sessions restore the correct mode by mutating process.env and logging a tengu_coordinator_mode_switched analytics event.tengu_scratch gate is active — useful for durable cross-worker knowledge without file permission prompts.COORDINATOR_MODE_ALLOWED_TOOLS in constants/tools.ts defines exactly four tools. The coordinator has no filesystem or shell access — all execution is delegated to workers.
matchSessionMode() do when the resumed session was in coordinator mode but the process was started in normal mode?isCoordinatorMode() reads process.env with no caching, writing to it via matchSessionMode() is enough to flip the mode mid-process. The function also fires a tengu_coordinator_mode_switched analytics event.
coordinatorMode.ts duplicate the scratchpad gate check instead of importing from utils/permissions/filesystem.ts?QueryEngine.ts which lives higher in the dependency graph.
SendMessage. Option A is the anti-pattern ("based on your findings") — the coordinator must synthesize, not delegate understanding.
CLAUDE_CODE_SIMPLE=1 is set, what tools do workers have access to in coordinator mode?getCoordinatorUserContext(), when CLAUDE_CODE_SIMPLE is truthy, the worker tool list is hardcoded to [BASH_TOOL_NAME, FILE_READ_TOOL_NAME, FILE_EDIT_TOOL_NAME]. This is the stripped-down surface for SDK consumers who want predictable, minimal worker behavior.