// runAgent.ts:15
import { query } from '../../query.js'The entire "magic" of sub-agents lives in this import. It imports the main loop's query() function and calls it recursively:
// runAgent.ts core logic (heavily simplified)
export async function* runAgent({
agentDefinition,
promptMessages,
toolUseContext,
canUseTool,
availableTools,
...
}): AsyncGenerator<Message, void> {
// Create context for the sub-agent
const childContext = createSubagentContext(toolUseContext)
const childTools = filterToolsForAgent(agentDefinition, availableTools)
// Recursively call the main loop
for await (const event of query({
messages: promptMessages,
tools: childTools,
toolUseContext: childContext,
canUseTool,
})) {
yield event // Stream the sub-agent's output
}
}This means sub-agents automatically inherit all capabilities of the main loop:
- Four-layer context compression
- Seven types of error recovery
- Streaming tool execution
- Permission checks
- Model fallback
No separate infrastructure needs to be implemented for sub-agents.
| Feature | Recursion (Claude Code) | Orchestration (CrewAI/AutoGen) |
|---|---|---|
| Infrastructure code | Written once, automatically inherited | Independently implemented at each layer |
| New feature propagation | Automatic (just add it to query) | Manual (update each layer) |
| Maintenance cost | O(1) | O(n), n = number of agent layers |
| Nesting depth | Bounded by token budget | Bounded by architecture |
| Debugging complexity | Just look at one function | Requires understanding multiple framework concepts |
A sub-agent's context is derived from its parent's, but with selective isolation:
// utils/forkedAgent.ts (simplified)
function createSubagentContext(parentContext: ToolUseContext): ToolUseContext {
return {
// Isolated
abortController: new AbortController(), // Independent cancellation
fileStateCache: parentContext.fileStateCache.clone(), // Cloned file cache
agentId: generateId(), // Independent ID
// Shared
setAppState: parentContext.setAppState, // Global state routes to root
getAppState: parentContext.getAppState, // Global state
// Restricted
options: {
tools: filteredTools, // Possibly a subset of tools
...parentContext.options,
},
}
}If a sub-agent's abort were shared with the parent:
- Sub-agent cancelled → parent also cancelled → entire session terminated
This is not the desired behavior. A sub-agent might be terminated due to timeout or user cancellation, but the parent should be able to continue working (for example, telling the user "the sub-agent timed out, let me try a different approach").
The file cache is a performance optimization that avoids reading the same file repeatedly. The reasons for cloning:
- Parent → Child: The sub-agent inherits the parent's already-read file cache, avoiding redundant I/O
- Child → Parent: Files newly read by the sub-agent do not pollute the parent's cache (the sub-agent may be working in a different worktree)
- Child → Child: Multiple sub-agents do not interfere with each other
// Set a size limit when cloning
const childCache = parentCache.clone()
childCache.maxSize = READ_FILE_STATE_CACHE_SIZE // Prevent memory leaksAppState holds global state, most importantly permission updates.
Scenario: a user clicks "Always Allow for Read" in a sub-agent's dialog. If AppState were not shared, this decision would only apply to the current sub-agent — the parent and other sub-agents would continue to show dialogs.
By routing to the root store, permission updates take immediate global effect across all agents.
Each agent type is described by a definition file:
type AgentDefinition = {
name: string // "Explore", "Plan", "general-purpose"
prompt: string // the agent's system prompt
tools: string[] // list of available tools (or ['*'])
model?: string // model override
permissionMode?: string // permission mode override
mcpServers?: McpServer[] // MCP servers exclusive to this agent
}Claude Code ships with several built-in agent types:
| Agent | Tool Restrictions | Typical Use |
|---|---|---|
general-purpose |
All tools | Complex multi-step tasks |
Explore |
Read-only tools (no Edit/Write) | Code search and exploration |
Plan |
Read-only tools (no Edit/Write) | Designing implementation plans |
filterToolsForAgent() filters available tools according to the agent definition:
// agentToolUtils.ts:70-116
function filterToolsForAgent(
agentDefinition: AgentDefinition,
allTools: Tools,
): Tools {
// 1. MCP tools (mcp__*) are always allowed
// 2. ALL_AGENT_DISALLOWED_TOOLS are disabled for all agents
// 3. CUSTOM_AGENT_DISALLOWED_TOOLS are disabled for non-built-in agents
// 4. Async agents have additional allowed-tool list restrictions
}The design of always allowing MCP tools is interesting — it means tools provided by custom MCP servers are available to all agent types, unaffected by tool filtering.
When run_in_background: true:
Parent agent: [process tool 1] [process tool 2] [continue conversation...]
↑
Sub-agent: [running independently...] ──done notification──→ ┘
The parent agent does not wait for the sub-agent to finish. The sub-agent runs independently in the background and notifies the parent via a notification mechanism when complete.
// agentToolUtils.ts:508-686
async function runAsyncAgentLifecycle({
agentGenerator,
taskId,
progressTracker,
...
}) {
try {
// 1. Drive the query generator
for await (const message of agentGenerator) {
// Update progress
updateAsyncAgentProgress(taskId, message)
}
// 2. Successful completion
transitionTaskState(taskId, 'completed')
enqueueNotification(taskId, 'completed')
} catch (error) {
// 3. Failure handling
transitionTaskState(taskId, 'failed')
enqueueNotification(taskId, 'failed')
}
}Background agents have a tricky problem: they need permission confirmations, but UI focus is on the parent agent.
The solution depends on configuration:
if (isAsync) {
// Default: skip permission dialogs, use shouldAvoidPermissionPrompts
shouldAvoidPermissionPrompts: true
// But if canShowPermissionPrompts is explicitly configured:
if (canShowPermissionPrompts) {
// Dialogs are allowed, but wait for automated checks to finish first
awaitAutomatedChecksBeforeDialog: true
}
}awaitAutomatedChecksBeforeDialog: true ensures all automated checks (such as classifiers) run before any dialog is shown — reducing unnecessary interruptions.
Git worktrees let you check out multiple branches from the same repository simultaneously:
git worktree add /tmp/my-experiment feature-branch
# Now /tmp/my-experiment is a complete copy of the repo
# working on feature-branchWhen isolation: "worktree":
1. Create a temporary worktree (new branch)
2. Sub-agent works inside the worktree directory
3. All file operations happen in the worktree (main repo is unaffected)
4. After the sub-agent finishes:
├─ Has changes → return the worktree path and branch name
└─ No changes → automatically clean up the worktree
User: "Try replacing Memcached with Redis and see how the performance looks"
Claude Code:
├─ Main branch: untouched
└─ Worktree (sub-agent):
├─ Replace Memcached → Redis
├─ Run benchmark
├─ Report results
└─ If user is happy → merge branch
If not happy → discard worktree
This solves a real pain point: letting agents experiment boldly without fear of breaking things.
Worktree creation and cleanup are managed by EnterWorktreeTool and ExitWorktreeTool:
- On creation:
git worktree add+ set working directory - On cleanup: check for uncommitted changes
- Has changes: keep the worktree, return path information
- No changes:
git worktree removeautomatic cleanup
An agent definition can declare the MCP servers it requires:
# agent definition
name: database-explorer
mcpServers:
- name: postgres
command: npx
args: ["@modelcontextprotocol/server-postgres"]// runAgent.ts:95-218
async function initializeAgentMcpServers(
agentDefinition: AgentDefinition,
parentClients: McpClients,
): Promise<{ clients: McpClients; cleanup: () => Promise<void> }> {
// 1. Inherit the parent agent's MCP connections
const mergedClients = { ...parentClients }
// 2. Start MCP servers exclusive to this agent
for (const server of agentDefinition.mcpServers) {
mergedClients[server.name] = await startMcpServer(server)
}
// 3. Return a cleanup function (only cleans up newly created ones, does not close inherited ones)
return {
clients: mergedClients,
cleanup: async () => {
for (const server of agentDefinition.mcpServers) {
await mergedClients[server.name].close()
}
}
}
}Only newly created servers are cleaned up — inherited MCP connections are managed by the parent agent; sub-agents should not close them.
The parent agent can pass a portion of its conversation history to a sub-agent:
// runAgent.ts:369-378
if (forkContextMessages) {
// Filter out incomplete tool calls
const filtered = filterIncompleteToolCalls(forkContextMessages)
promptMessages = [...filtered, ...promptMessages]
}Filtering incomplete tool calls is critical — if the parent's history contains a tool_use with no corresponding tool_result (e.g., a tool is still executing), passing it to the sub-agent will cause an API 400 error.
forkContextMessages also has a cache optimization benefit: if sub-agents use the same context prefix, prompt caching can exploit those shared prefixes.
When auto mode is enabled, a sub-agent's output goes through a classification check before being returned to the user:
// agentToolUtils.ts:404-460
async function classifyHandoffIfNeeded(
agentOutput: Message[],
autoModeEnabled: boolean,
) {
if (!autoModeEnabled) return
const decision = await classifyYoloAction(agentOutput)
if (decision === 'block') {
// Prevent the sub-agent's output from being shown directly to the user
// Human confirmation is required
}
}This is an additional safety net — even if the sub-agent's permission checks passed, its final output is still reviewed by the classifier.
Claude Code's sub-agent system demonstrates a counterintuitive design principle:
The most powerful abstraction is not creating new concepts, but reusing existing ones.
- No new "Crew" abstraction — sub-agents are just recursive
query()calls - No new communication protocol — sub-agents pass messages via
yield - No new state management — isolation is achieved through context cloning
- No new permission model — sub-agents inherit the parent's permissions
The result is a sub-agent system with zero additional concepts — understand query() and you understand sub-agents.