markdown.engineering
Lesson 05

The Agent System

⏱ ~35 min ⚡ Intermediate 📂 AgentTool · SendMessageTool · spawnMultiAgent

🗺 What is the Agent System?

Claude Code's agent system is the machinery that lets one Claude instance delegate work to other Claude instances — each a separate LLM turn with its own tool pool, system prompt, model, and optional filesystem isolation. The parent calls AgentTool (wire name: Agent), which spawns a child. That child can itself spawn further children, producing a multi-level hierarchy at runtime.

The legacy name is Task. The source keeps both names registered via aliases: [LEGACY_AGENT_TOOL_NAME] for backward compatibility with existing permission rules, hooks, and resumed sessions. All new code uses Agent.

The diagram below shows the full runtime hierarchy. The main loop sits at the top. It has access to AgentTool, which branches into three agent types and two execution modes.

flowchart TD ML["🧠 Main Loop
parent REPL / SDK"] AT["🛠 AgentTool
tool: Agent / Task"] ML -->|"calls"| AT AT --> BI["🔷 Built-In Agents
source: 'built-in'"] AT --> CA["📝 Custom Agents
source: userSettings / projectSettings / policySettings"] AT --> PA["🧩 Plugin Agents
source: 'plugin'"] BI --> GP["general-purpose
tools: ['*']"] BI --> EX["Explore
read-only · haiku/inherit"] BI --> PL["Plan
read-only · inherit"] BI --> VF["verification
background: true"] CA --> MD["Markdown .md
.claude/agents/"] CA --> JS["JSON frontmatter
settings.json agents{}"] AT --> SY["🔄 Sync Lifecycle
blocks parent turn"] AT --> AS["⚡ Async Lifecycle
fire-and-forget · notified on complete"] AT --> FK["🔀 Fork Path
inherits parent context byte-exact"] AT --> TM["👥 Teammate / Swarm
tmux pane or in-process"] SY --> WT["🌲 Worktree Isolation
git worktree · separate branch"] AS --> WT style ML fill:#1a1816,stroke:#7d9ab8,color:#b8b0a4 style AT fill:#1a1816,stroke:#7d9ab8,color:#b8b0a4 style BI fill:#171513,stroke:#7d9ab8,color:#7d9ab8 style CA fill:#1d211b,stroke:#6e9468,color:#6e9468 style PA fill:#1f1b24,stroke:#8e82ad,color:#8e82ad style GP fill:#141211,stroke:#3a3632,color:#5c564f style EX fill:#141211,stroke:#3a3632,color:#5c564f style PL fill:#141211,stroke:#3a3632,color:#5c564f style VF fill:#241816,stroke:#c47a50,color:#c47a50 style MD fill:#141211,stroke:#3a3632,color:#5c564f style JS fill:#141211,stroke:#3a3632,color:#5c564f style SY fill:#141211,stroke:#3a3632,color:#5c564f style AS fill:#231f16,stroke:#b8965e,color:#b8965e style FK fill:#1f1b24,stroke:#8e82ad,color:#8e82ad style TM fill:#141211,stroke:#3a3632,color:#5c564f style WT fill:#141211,stroke:#3a3632,color:#5c564f

📦 Three Agent Types

Every agent in the system is one of three concrete TypeScript types, all satisfying AgentDefinition (a discriminated union on source).

Built-In

BuiltInAgentDefinition

Ships with Claude Code. Dynamic system prompts via getSystemPrompt({toolUseContext}). Cannot be overridden by user files — but managed (policy) agents can shadow them by agentType name.

Custom

CustomAgentDefinition

User/project/policy-settings agents. Loaded from .claude/agents/*.md or JSON blobs in settings.json. System prompt stored in a closure over the markdown body.

Plugin

PluginAgentDefinition

Bundled with a plugin (--plugin-dir). Behaves like Custom but source === 'plugin'. Treated as admin-trusted for MCP server policy — can load frontmatter MCP even when strictPluginOnlyCustomization is set.

Show: AgentDefinition union & type guards (loadAgentsDir.ts)
tools/AgentTool/loadAgentsDir.ts TypeScript
// Built-in agents — dynamic prompts only, no static systemPrompt field
export type BuiltInAgentDefinition = BaseAgentDefinition & {
  source: 'built-in'
  baseDir: 'built-in'
  getSystemPrompt: (params: { toolUseContext: Pick<ToolUseContext, 'options'> }) => string
}

// Custom agents from user/project/policy settings
export type CustomAgentDefinition = BaseAgentDefinition & {
  getSystemPrompt: () => string
  source: SettingSource
  filename?: string
  baseDir?: string
}

// Plugin agents — like Custom but source is 'plugin'
export type PluginAgentDefinition = BaseAgentDefinition & {
  getSystemPrompt: () => string
  source: 'plugin'
  plugin: string
}

export type AgentDefinition =
  | BuiltInAgentDefinition
  | CustomAgentDefinition
  | PluginAgentDefinition

// Type guards
export function isBuiltInAgent(agent: AgentDefinition): agent is BuiltInAgentDefinition {
  return agent.source === 'built-in'
}
export function isCustomAgent(agent: AgentDefinition): agent is CustomAgentDefinition {
  return agent.source !== 'built-in' && agent.source !== 'plugin'
}
export function isPluginAgent(agent: AgentDefinition): agent is PluginAgentDefinition {
  return agent.source === 'plugin'
}

Priority / Override Order

When two agents share the same agentType string, a priority map decides which wins. The order from getActiveAgentsFromList() is:

built-in → plugin → userSettings → projectSettings → flagSettings → policySettings  — later groups overwrite earlier ones in the map, so policySettings (managed agents) have the highest effective priority.

🔷 Built-In Agents Deep Dive

Agent agentType Model Tools Mode
General Purpose general-purpose default subagent ['*'] — all tools sync / async
Explore Explore haiku (external) / inherit (ant) read-only; disallows Edit, Write, FileEdit, Agent sync
Plan Plan inherit same disallowedTools as Explore sync
Verification verification inherit no Edit/Write; ephemeral /tmp scripts allowed background: true (always async)
Fork fork inherit ['*'] with useExactTools (cache-identical) experimental gate
StatuslineSetup statusline-setup default limited shell scope sync
Show: Explore agent system prompt (excerpt)
tools/AgentTool/built-in/exploreAgent.ts TypeScript
export const EXPLORE_AGENT: BuiltInAgentDefinition = {
  agentType: 'Explore',
  // Ants get inherit; external users get haiku for speed
  model: process.env.USER_TYPE === 'ant' ? 'inherit' : 'haiku',
  disallowedTools: [
    AGENT_TOOL_NAME,          // no spawning sub-agents
    EXIT_PLAN_MODE_TOOL_NAME,
    FILE_EDIT_TOOL_NAME,
    FILE_WRITE_TOOL_NAME,
    NOTEBOOK_EDIT_TOOL_NAME,
  ],
  // Saves ~5-15 Gtok/week — Explore doesn't need commit/PR/lint rules
  omitClaudeMd: true,
  source: 'built-in',
  baseDir: 'built-in',
  getSystemPrompt: () => getExploreSystemPrompt(),
}
Show: Custom agent markdown format (.claude/agents/my-agent.md)
.claude/agents/my-agent.md Markdown + YAML frontmatter
---
name: my-agent
description: A focused TypeScript refactoring specialist.
model: sonnet
tools:
  - Read
  - Edit
  - Bash
  - Grep
  - Glob
permissionMode: acceptEdits
maxTurns: 50
memory: project
isolation: worktree
---

You are a TypeScript refactoring specialist. Your job is to improve
type safety and reduce any-casts in the provided code.

Rules:
- Only touch files you are explicitly asked about
- Run tsc --noEmit before and after to confirm zero new errors
- Commit changes with a clear message before reporting

Sync vs Async Lifecycle

When AgentTool's call() runs, it computes a single boolean: shouldRunAsync. Everything downstream branches on that flag.

tools/AgentTool/AgentTool.tsx TypeScript
const shouldRunAsync = (
  run_in_background === true   // explicit caller request
  || selectedAgent.background === true   // agent def forces background (e.g. verification)
  || isCoordinator                           // coordinator mode: always async
  || forceAsync                              // fork experiment: all spawns async
  || assistantForceAsync                     // KAIROS assistant mode
  || (proactiveModule?.isProactiveActive() ?? false)
) && !isBackgroundTasksDisabled

Synchronous Path

Step 1
Build system prompt + prompt messages
Normal path: agent's getSystemPrompt() + enhanceSystemPromptWithEnvDetails(). Fork path: parent's already-rendered bytes (byte-exact for prompt cache).
Step 2
Optional: create git worktree
If isolation === 'worktree', createAgentWorktree(slug) is called before runAgent(). Slug is agent-{earlyAgentId.slice(0,8)}.
Step 3
await runAgent(params)
Parent turn is blocked. AgentTool returns a status: 'completed' result with the agent's final text.
Step 4
Cleanup worktree (if no changes)
hasWorktreeChanges() diffed against the pre-spawn HEAD commit. If no changes, the worktree branch is deleted immediately.

Asynchronous Path

Step 1
registerAsyncAgent() — task registered in AppState
Returns an agentBackgroundTask with its own AbortControllernot linked to the parent's controller. Background agents survive ESC.
Step 2
Return status: 'async_launched' immediately
Caller receives agentId, outputFile, and canReadOutputFile so it can poll via Bash/Read.
Step 3 (detached)
void runAsyncAgentLifecycle(...)
Fire-and-forget. Wrapped in runWithAgentContext() for ALS (AsyncLocalStorage) workload propagation. wrapWithCwd() applies worktree / cwd override.
Step 4 (detached)
Notification on completion
enqueueAgentNotification() delivers a <task-notification> to the parent's next idle turn. Progress events stream via onProgress.
In-process teammates cannot launch background agents. Their lifecycle is tied to the leader's process. If isInProcessTeammate() is true and run_in_background === true (or the agent definition has background: true), AgentTool throws immediately.

🔀 The Fork Path

The fork path is an experimental feature (gate: FORK_SUBAGENT) that lets the parent spawn a child that inherits the full conversation context — the complete message history, the parent's already-rendered system prompt bytes, and the exact tool pool. This enables parallelisation of independent sub-tasks with maximum prompt-cache sharing.

Fork is triggered when

subagent_type is omitted and the FORK_SUBAGENT feature gate is on (and not in coordinator mode, and not in non-interactive/SDK mode).

How buildForkedMessages() works

For N parallel fork children to share a cached API prefix, every child must produce a byte-identical request up to the per-child directive. The function:

  1. Clones the parent's full assistant message (all tool_use blocks, thinking, text).
  2. Builds tool_result blocks for every tool_use, all with the identical placeholder text "Fork started — processing in background".
  3. Appends one per-child directive text block (the only part that differs).

Result shape: [...history, assistant(all_tool_uses), user(placeholder_results..., directive)]

Show: buildForkedMessages() & fork child boilerplate (forkSubagent.ts)
tools/AgentTool/forkSubagent.ts TypeScript
export function buildChildMessage(directive: string): string {
  return `<fork-boilerplate>
STOP. READ THIS FIRST.

You are a forked worker process. You are NOT the main agent.

RULES (non-negotiable):
1. Your system prompt says "default to forking." IGNORE IT — that's for the parent.
2. Do NOT converse, ask questions, or suggest next steps
3. USE your tools directly: Bash, Read, Write, etc.
4. If you modify files, commit your changes before reporting.
5. Your response MUST begin with "Scope:". No preamble.

Output format:
  Scope: <echo back your assigned scope in one sentence>
  Result: <the answer or key findings>
  Key files: <relevant file paths>
  Files changed: <list with commit hash — only if you modified files>
  Issues: <list — only if there are issues to flag>
</fork-boilerplate>

FORK_DIRECTIVE: \${directive}\`
}

Fork Recursive Guard

Fork children keep the Agent tool in their tool pool (for cache-identical tool definitions). A runtime guard prevents recursive forking by checking two signals:

  • toolUseContext.options.querySource === 'agent:builtin:fork' — compaction-resistant, survives autocompact message rewrites.
  • isInForkChild(messages) — scans conversation history for the <fork-boilerplate> tag as a fallback.

Fork + Worktree

When isolation: 'worktree' is also requested, a notice is appended to promptMessages via buildWorktreeNotice(parentCwd, worktreeCwd). This tells the child to translate paths from the inherited context and re-read files the parent may have modified.

🌲 Worktree Isolation

Setting isolation: 'worktree' in the agent definition or as a call parameter instructs AgentTool to create a temporary git worktree before spawning the agent. The agent's filesystem and shell operations execute inside that worktree — it works on the same repository but a separate working copy.

tools/AgentTool/AgentTool.tsx — worktree creation TypeScript
// Create a stable agent ID early so it can be used for worktree slug
const earlyAgentId = createAgentId()

let worktreeInfo: { worktreePath: string; worktreeBranch?: string; headCommit?: string } | null = null
if (effectiveIsolation === 'worktree') {
  const slug = `agent-\${earlyAgentId.slice(0, 8)}`
  worktreeInfo = await createAgentWorktree(slug)
}

// After agent completes — cleanup if no changes
const cleanupWorktreeIfNeeded = async () => {
  if (!worktreeInfo) return {}
  const { worktreePath, worktreeBranch, headCommit } = worktreeInfo
  worktreeInfo = null  // idempotent guard
  if (headCommit) {
    const changed = await hasWorktreeChanges(worktreePath, headCommit)
    if (!changed) {
      await removeAgentWorktree(worktreePath, worktreeBranch, gitRoot)
      return {}
    }
  }
  // Changes detected — keep the worktree branch
  return { worktreePath, worktreeBranch }
}
Cleanup is smart: if the agent made no git-tracked changes (determined by diffing against headCommit), the worktree is deleted automatically. If it did make changes, the branch is kept for the parent to inspect or merge.

Exception: hook-based worktrees are always kept because VCS change detection is not possible when Git is managed via hooks.

📬 SendMessageTool & Swarm Protocol

Once agents are running as teammates (tmux panes or in-process), they need to communicate. SendMessageTool (wire name: SendMessage) is the inter-agent messaging primitive. It is only enabled when isAgentSwarmsEnabled() returns true.

Message Routing Logic

tomessage typeResult
"teammate-name" string Written to teammate's mailbox file. If agent is stopped, auto-resumed from transcript.
"*" string Broadcast to all team members (excluding sender).
any name shutdown_request Sends a structured shutdown request. Recipient can approve or reject.
"team-lead" shutdown_response Approve: triggers gracefulShutdown(0). Reject: continues working.
any name plan_approval_response Only the team-lead can approve/reject plans. Propagates permissionMode.
"uds:<path>" string Unix domain socket — cross-session send to a local peer.
"bridge:<session-id>" string only Remote Control: posts to another Claude instance via Anthropic servers. Requires explicit user consent.
Show: SpawnTeammate in spawnMultiAgent.ts (excerpt)
tools/shared/spawnMultiAgent.ts TypeScript
// Build the full spawn command for a new pane
const teammateArgs = [
  `--agent-id \${quote([teammateId])}`,
  `--agent-name \${quote([sanitizedName])}`,
  `--team-name \${quote([teamName])}`,
  `--agent-color \${quote([teammateColor])}`,
  `--parent-session-id \${quote([getSessionId()])}`,
  plan_mode_required ? '--plan-mode-required' : '',
  agent_type ? `--agent-type \${quote([agent_type])}` : '',
].filter(Boolean).join(' ')

// Propagate permission mode, model, settings, plugin dirs
const inheritedFlags = buildInheritedCliFlags({ planModeRequired, permissionMode })

const spawnCommand = `cd \${quote([workingDir])} && env \${envStr} \${quote([binaryPath])} \${teammateArgs}\${flagsStr}`

// Send to the tmux pane (or swarm socket when outside tmux)
await sendCommandToPane(paneId, spawnCommand, !insideTmux)

📋 Agent Frontmatter Field Reference

Expand full frontmatter schema
FieldTypeEffect
namestring (required)Unique identifier — used as subagent_type value
descriptionstring (required)Shown to the parent LLM as "when to use" guidance
modelsonnet|opus|haiku|inherit|<id>inherit → use parent's model at runtime
toolsstring[]Allow-list. ['*'] = all tools. Omit = inherit default pool.
disallowedToolsstring[]Subtract from pool — applied after tools allow-list
permissionModedefault|acceptEdits|bypassPermissions|auto|plan|bubbleOverrides parent mode for this agent's tool calls
maxTurnspositive intHard cap on agentic turns before the agent stops
backgroundbooleanAlways run async regardless of run_in_background param
isolationworktree (| remote ant-only)Git worktree isolation per spawn
memoryuser|project|localPersistent memory across sessions in the chosen scope
mcpServersstring[] | object[]Additive MCP servers — connected on agent start, cleaned up on finish
hooksHooksSettingsSession-scoped hooks registered only while the agent runs
skillsstring[]Skill slash commands to preload into the agent's context
initialPromptstringPrepended to the first user turn (slash commands work)
effortlow|normal|high | intControls thinking depth (extended thinking budget)
requiredMcpServersstring[]Agent hidden if these MCP servers aren't authenticated & have tools

Key Takeaways

  • Every agent is one of three TypeScript types distinguished by source: built-in, custom (userSettings / projectSettings / policySettings), or plugin. Policy settings win in priority disputes.
  • The shouldRunAsync boolean is the single branch point. Six conditions can force async: explicit run_in_background, agent definition's background: true, coordinator mode, fork experiment, KAIROS mode, or proactive mode.
  • Async agents get their own AbortController not linked to the parent's — they survive ESC and are killed only via explicit chat:killAgents.
  • The fork path achieves maximum prompt-cache sharing by using identical placeholder text for every tool_result block — only the final directive text differs per child.
  • Worktree isolation is smart-cleaned: if the agent made no git-tracked changes, the branch is deleted. If it did, it is kept for the parent to review.
  • omitClaudeMd: true on Explore and Plan saves ~5-15 Gtok/week at Anthropic's usage scale — a meaningful optimization enabled by read-only specialization.
  • The verification agent is the only built-in with background: true hardcoded — it always runs async and always ends with a VERDICT: PASS/FAIL/PARTIAL line.
  • SendMessageTool supports four message types (string, shutdown_request, shutdown_response, plan_approval_response) and three addressing schemes: teammate name, broadcast (*), UDS socket, and bridge session.

🧪 Quiz

Q1. Which agent source has the highest effective priority when two agents share the same agentType?
A built-in
B plugin
C projectSettings
D policySettings (managed agents)
Correct. getActiveAgentsFromList() processes groups in order: built-in → plugin → userSettings → projectSettings → flagSettings → policySettings. Each group overwrites earlier entries in the map, so policySettings wins.
Q2. What is the purpose of the identical placeholder text "Fork started — processing in background" used in all fork children?
A It tells the child to skip tool execution
B It makes all children produce byte-identical API request prefixes for prompt cache sharing
C It is displayed to the user as a status message
D It prevents the child from spawning sub-agents
Correct. Every tool_result block in the fork prefix uses the same placeholder so that the API request prefix is byte-identical across all N children. Only the final directive text block differs per child — maximizing cache hits.
Q3. An in-process teammate calls AgentTool with run_in_background: true. What happens?
A The agent runs async, linked to the parent's AbortController
B AgentTool throws an error immediately
C The agent runs sync as a fallback
D The request is queued for when the teammate's process separates
Correct. In-process teammates cannot spawn background agents because their lifecycle is tied to the leader's process. AgentTool throws: "In-process teammates cannot spawn background agents."
Q4. What does the worktree cleanup logic do if the agent made no git-tracked changes?
A It merges the worktree branch into main
B It deletes the worktree and branch immediately
C It keeps the worktree for parent inspection
D It stashes the changes and closes the worktree
Correct. hasWorktreeChanges() diffs against the pre-spawn headCommit. If no changes, removeAgentWorktree(path, branch, gitRoot) is called to clean up. If changes exist, the branch is kept.
Q5. Which built-in agent type always has background: true hardcoded in its definition?
A Explore
B Plan
C verification
D general-purpose
Correct. The VERIFICATION_AGENT definition sets background: true. It always runs async and always ends with a VERDICT: PASS/FAIL/PARTIAL line. Explore and Plan are sync by default; general-purpose is sync unless the caller opts in.
0 / 5 answered