markdown.engineering
Lesson 39

System Prompt Construction

How Claude Code assembles the massive system prompt — static sections, dynamic sections, CLAUDE.md injection, cache boundaries, and mode overrides.

01 Overview

Before the model ever processes your first message, Claude Code compiles a system prompt that can span thousands of tokens. This isn't a single static string baked into the binary — it's assembled fresh each session from a pipeline of composable sections, some cached across turns, others recomputed on every request, and all of them shaped by the active tools, user settings, CLAUDE.md files, MCP servers, and feature flags present at runtime.

Source files covered
constants/prompts.tsutils/systemPrompt.tsconstants/systemPromptSections.tsutils/claudemd.tsmemdir/memdir.ts

The architecture separates concerns cleanly: prompts.ts owns the content of every section; systemPrompt.ts owns the priority logic that decides which prompt even runs; and systemPromptSections.ts owns the caching registry that prevents redundant recomputation between turns.

Layer 1

Priority resolver

systemPrompt.ts — override → coordinator → agent → custom → default

Layer 2

Content factory

prompts.ts — static + dynamic sections assembled per session

Layer 3

Section registry

systemPromptSections.ts — memoized vs. volatile section cache

Layer 4

CLAUDE.md loader

claudemd.ts — multi-scope file discovery, @include, frontmatter

Layer 5

Memory system

memdir/memdir.ts — MEMORY.md + auto-memory injection

Layer 6

Cache boundary

SYSTEM_PROMPT_DYNAMIC_BOUNDARY — global-scope prefix split

02 Priority Resolver — Which Prompt Even Runs?

buildEffectiveSystemPrompt() in utils/systemPrompt.ts is the first gate. It implements a strict priority waterfall. Once a higher-priority source is found, lower layers are skipped entirely.

flowchart TD A["buildEffectiveSystemPrompt()"] --> B{overrideSystemPrompt?} B -->|"Yes (loop mode)"| OV["Return [overrideSystemPrompt]\nAll other sources ignored"] B -->|"No"| C{COORDINATOR_MODE\nenv + feature flag?} C -->|"Yes"| CO["Return [getCoordinatorSystemPrompt()\n+ appendSystemPrompt]"] C -->|"No"| D{mainThreadAgentDefinition\nset?} D -->|"Yes + PROACTIVE active"| PA["Return [defaultSystemPrompt...\n+ Custom Agent Instructions\n+ appendSystemPrompt]"] D -->|"Yes, normal"| AG["Return [agentSystemPrompt\n+ appendSystemPrompt]"] D -->|"No"| E{customSystemPrompt\nset via --system-prompt?} E -->|"Yes"| CS["Return [customSystemPrompt\n+ appendSystemPrompt]"] E -->|"No"| DF["Return [defaultSystemPrompt...\n+ appendSystemPrompt]"] style OV fill:#2a1a1a,stroke:#c47a50,color:#b8b0a4 style DF fill:#1a3a1a,stroke:#6e9468,color:#b8b0a4 style CO fill:#1a2a3a,stroke:#7d9ab8,color:#b8b0a4 style PA fill:#2a1a3a,stroke:#8e82ad,color:#b8b0a4
Key insight
appendSystemPrompt is the only thing that always appends — it is added to every branch except when overrideSystemPrompt is active. This is how --append-system-prompt works regardless of what mode you're in.

In proactive / KAIROS mode the agent's instructions are appended to the default prompt rather than replacing it. This mirrors the "teammate" pattern: the proactive default is already a lean autonomous-agent identity, and domain agents add on top.

// utils/systemPrompt.ts — proactive mode branch
if (agentSystemPrompt && (feature('PROACTIVE') || feature('KAIROS')) && isProactiveActive()) {
  return asSystemPrompt([
    ...defaultSystemPrompt,
    `\n# Custom Agent Instructions\n${agentSystemPrompt}`,
    ...(appendSystemPrompt ? [appendSystemPrompt] : []),
  ])
}
03 Content Factory — getSystemPrompt()

getSystemPrompt() in constants/prompts.ts is the main content factory. It builds the defaultSystemPrompt array that feeds into the priority resolver. The function has a fast escape hatch: if CLAUDE_CODE_SIMPLE=1 it returns a three-line stub and exits immediately.

For normal sessions it gathers four pieces of runtime state in parallel, then assembles two halves — a static half and a dynamic half — separated by a boundary marker that controls prompt-cache scoping.

// constants/prompts.ts — abbreviated assembly
export async function getSystemPrompt(tools, model, additionalDirs, mcpClients) {
  const [skillToolCommands, outputStyleConfig, envInfo] = await Promise.all([
    getSkillToolCommands(cwd),
    getOutputStyleConfig(),
    computeSimpleEnvInfo(model, additionalDirs),
  ])

  return [
    // ── Static (globally cacheable) ──
    getSimpleIntroSection(outputStyleConfig),
    getSimpleSystemSection(),
    getSimpleDoingTasksSection(),      // code-style, YAGNI, security rules
    getActionsSection(),               // reversibility / blast-radius guidance
    getUsingYourToolsSection(enabledTools),
    getSimpleToneAndStyleSection(),
    getOutputEfficiencySection(),
    // ── Boundary marker ──
    SYSTEM_PROMPT_DYNAMIC_BOUNDARY,
    // ── Dynamic (session-specific, registry-managed) ──
    ...resolvedDynamicSections,        // session_guidance, memory, env_info_simple, ...
  ].filter(s => s !== null)
}

Static sections — what they contain

Section function What it instills Scope
getSimpleIntroSection() Identity ("interactive agent for software engineering tasks"), URL-generation guard, CYBER_RISK_INSTRUCTION static
getSimpleSystemSection() Markdown rendering, permission-mode tool approval, system-reminder tags, prompt-injection warning, hooks guidance, auto-compression notice static
getSimpleDoingTasksSection() Task scope ("software engineering tasks"), YAGNI/no-gold-plating rules, no speculative abstractions, security hygiene, code-style bullets (ant-only extras) static
getActionsSection() Reversibility and blast-radius guidance — when to pause and confirm, risky action taxonomy (destructive, hard-to-reverse, visible-to-others) static
getUsingYourToolsSection() Prefer dedicated tools over Bash, REPL-mode path, parallel tool calls, task-tracking tool static
getSimpleToneAndStyleSection() No emojis, file:line references, GitHub issue format, no colon before tool calls static
getOutputEfficiencySection() Conciseness / prose quality rules — ant build gets a rich prose guide, external gets brief "go straight to the point" static
ANT-only branches
Several sections — comment-writing norms, false-claims mitigation, assertiveness counterweights, numeric length anchors — are gated on process.env.USER_TYPE === 'ant'. These are compiled out of the external binary via Bun dead-code elimination. Every // @[MODEL LAUNCH] comment marks instructions that need updating at each new model release.
04 Dynamic Sections — The Registry

Everything after the boundary marker is managed through the section registry defined in constants/systemPromptSections.ts. Each section is either memoized (computed once per session, cached until /clear or /compact) or volatile (recomputed every turn).

// systemPromptSections.ts
export function systemPromptSection(name, compute): SystemPromptSection {
  return { name, compute, cacheBreak: false }   // memoized
}

export function DANGEROUS_uncachedSystemPromptSection(name, compute, reason) {
  return { name, compute, cacheBreak: true }    // recomputes every turn
}

resolveSystemPromptSections() iterates the registry, returns cached values for non-cacheBreak sections, and calls compute() for volatile or uncached ones. Cache entries are keyed by the section's name string and stored in bootstrap/state.ts so the React render cycle can't lose them.

Section name Content Cache behaviour
session_guidance Ask-user-question guidance, interactive shell tip, agent-tool guidance, skill invocation syntax, verification-agent contract memoized
memory CLAUDE.md hierarchy + MEMORY.md auto-memory (see §5) memoized
ant_model_override Internal ant build suffix injected from remote config memoized
env_info_simple CWD, git status, platform, shell, OS version, model name, knowledge cutoff, model family reference memoized
language Language preference from settings → "Always respond in X" memoized
output_style Named output style loaded from settings (name + prompt string) memoized
mcp_instructions Per-server instructions blocks from connected MCP servers volatile — MCP servers connect/disconnect between turns
scratchpad Per-session scratchpad directory path and usage rules memoized
frc Function-result-clearing notice (CACHED_MICROCOMPACT feature) memoized
token_budget "Keep working until you approach the target" instruction memoized
Why mcp_instructions is DANGEROUS_uncached
MCP servers can connect and disconnect mid-session. If the instructions section were memoized, a server that connects after the first turn would never get its instructions into context. Making it volatile ensures it's always current — at the cost of potentially busting the prompt cache on server connect/disconnect events.
05 The Cache Boundary — How Token Cost Is Kept Low

Claude's API supports prompt caching: identical prefixes are billed at a fraction of normal input-token cost. To exploit this, the system prompt must be split into a stable prefix (same across all users) and a volatile tail (per-session content).

// constants/prompts.ts
export const SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'

// WARNING: Do not remove or reorder this marker without updating cache logic in:
// - src/utils/api.ts (splitSysPromptPrefix)
// - src/services/api/claude.ts (buildSystemPromptBlocks)

Everything before the marker can use scope: 'global' in the API call — cacheable across all organisations. Everything after contains session-specific content (your CWD, git status, CLAUDE.md files, model ID) and must not be globally cached.

block-beta columns 1 block:static["Static Prefix (scope: global)"] A["Intro · System · Doing Tasks · Actions · Tools · Tone · Output Efficiency"] end BOUNDARY["━━━ __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ ━━━"] block:dynamic["Dynamic Tail (scope: session)"] B["Session Guidance · Memory/CLAUDE.md · Env Info · Language · Output Style · MCP Instructions · Scratchpad · FRC · Token Budget"] end style BOUNDARY fill:#c47a50,color:#fff,font-weight:bold style static fill:#22201d,stroke:#7d9ab8,color:#b8b0a4 style dynamic fill:#1a2a1a,stroke:#6e9468,color:#b8b0a4
Engineering constraint
Any runtime-conditional expression placed before the boundary multiplies the number of distinct cache prefix hashes by 2 per condition. Engineers explicitly moved session-specific flags (non-interactive mode, fork-subagent mode) to getSessionSpecificGuidanceSection — which lives after the boundary — for exactly this reason. Comments reference "PR #24490, #24171" for the same bug class.
06 CLAUDE.md Injection — How User Instructions Enter

The memory section is where every CLAUDE.md file the user has placed in their filesystem gets loaded. utils/claudemd.ts discovers and ranks files across four scopes, loading them in priority order (lower-priority first, higher-priority last — so the model attends more to the files it sees last).

// utils/claudemd.ts — load order comment
// 1. Managed memory (/etc/claude-code/CLAUDE.md)   — Global for all users on machine
// 2. User memory   (~/.claude/CLAUDE.md)            — Private global for all projects
// 3. Project memory (CLAUDE.md / .claude/CLAUDE.md  — Checked into codebase
//                   .claude/rules/*.md)
// 4. Local memory  (CLAUDE.local.md)                — Private project-specific
Discovery algorithm
Project and Local files are found by walking upward from the current directory to the filesystem root, checking each directory for CLAUDE.md, .claude/CLAUDE.md, and every .md file in .claude/rules/. Files closer to CWD appear later in the array — giving them higher effective priority.

The @include directive

CLAUDE.md files can reference other files with an @path directive placed in leaf text (not inside code blocks). Supported forms:

# CLAUDE.md
@shared-rules.md                    # relative path (same dir)
@./scripts/lint-conventions.md      # explicit relative
@~/company/global-standards.md      # home-relative
@/absolute/path/to/rules.md         # absolute

Included files are inserted as separate entries before the including file. Circular references are prevented via a processed-path set. Non-existent targets are silently skipped. The loader restricts includes to a large allowlist of text file extensions — binary files (images, PDFs) are rejected to prevent accidental context bloat.

Frontmatter path filtering

A CLAUDE.md file can carry YAML frontmatter with a paths key. Only files under those glob patterns will receive the instructions. This lets you place language-specific or directory-specific rules in a single CLAUDE.md without polluting every project.

---
paths:
  - src/components/**
  - "*.tsx"
---
Always use named exports in React components.

The memory instruction wrapper

Every loaded memory file is wrapped in a header injected by claudemd.ts:

const MEMORY_INSTRUCTION_PROMPT =
  'Codebase and user instructions are shown below. Be sure to adhere to these ' +
  'instructions. IMPORTANT: These instructions OVERRIDE any default behavior and ' +
  'you MUST follow them exactly as written.'

This is the preamble you see in your own <system-reminder> context. Individual file contents are separated by clearly labeled blocks identifying each scope (user memory, project memory, local memory) so the model can reason about origin.

MEMORY.md auto-memory

memdir/memdir.ts handles a separate auto-memory system layered on top. When enabled, a per-session MEMORY.md file is also injected. The file is truncated to 200 lines or 25,000 bytes, whichever fires first, with a warning appended if truncated. This prevents an ever-growing memory file from bloating every request.

// memdir/memdir.ts
export const MAX_ENTRYPOINT_LINES = 200
export const MAX_ENTRYPOINT_BYTES = 25_000
07 Environment Info — computeSimpleEnvInfo()

The env_info_simple section is assembled by computeSimpleEnvInfo(). It gives the model a snapshot of the runtime context it's operating in.

// Produced output (representative)
# Environment
You have been invoked in the following environment:
 - Primary working directory: /Users/alice/myproject
 - Is a git repository: true
 - Platform: darwin
 - Shell: zsh
 - OS Version: Darwin 25.3.0
 - You are powered by the model named Claude Sonnet 4.6. The exact model ID is claude-sonnet-4-6.
 - Assistant knowledge cutoff is August 2025.
 - The most recent Claude model family is Claude 4.5/4.6 ...
 - Claude Code is available as a CLI in the terminal, desktop app ...
 - Fast mode for Claude Code uses the same Claude Opus 4.6 model with faster output ...

Shell detection parses process.env.SHELL to extract the name. On Windows, it appends a note to prefer Unix shell syntax (/dev/null not NUL, forward slashes in paths). Knowledge cutoff dates are hardcoded per model canonical name via getKnowledgeCutoff() — every model launch requires a // @[MODEL LAUNCH] update.

Undercover mode
When Anthropic engineers run internal builds with isUndercover() active, all model name references are stripped from the environment section. This prevents internal model IDs from leaking into public commits, PRs, or screenshots.
08 MCP Server Instructions

When MCP servers are connected, getMcpInstructions() iterates the ConnectedMCPServer objects and assembles a block from any that expose an instructions field.

// constants/prompts.ts
function getMcpInstructions(mcpClients) {
  const withInstructions = mcpClients
    .filter(c => c.type === 'connected')
    .filter(c => c.instructions)

  const blocks = withInstructions.map(c => `## ${c.name}\n${c.instructions}`).join('\n\n')

  return `# MCP Server Instructions\n\n...\n\n${blocks}`
}

This is what injects text like "IMPORTANT: Before using any chrome browser tools, you MUST first load them using ToolSearch" into the system prompt — it comes verbatim from the MCP server's instructions field, not from Claude Code's own code.

There is also an experimental mcpInstructionsDelta path: when that feature is enabled, instructions are delivered as persisted attachment objects rather than being re-injected into the system prompt every turn. This avoids the prompt-cache bust that happens when a late-connecting MCP server forces a new system prompt hash.

09 Subagent Enhancement — enhanceSystemPromptWithEnvDetails()

Subagents launched by AgentTool do not go through getSystemPrompt(). They start with whatever system prompt the caller passes — typically DEFAULT_AGENT_PROMPT. enhanceSystemPromptWithEnvDetails() then appends environment context and agent-specific notes.

// The agent default prompt — what every subagent starts with
export const DEFAULT_AGENT_PROMPT = `You are an agent for Claude Code, Anthropic's official CLI for Claude. Given the user's message, you should use the tools available to complete the task. Complete the task fully—don't gold-plate, but don't leave it half-done. When you complete the task, respond with a concise report covering what was done and any key findings — the caller will relay this to the user, so it only needs the essentials.`

// Notes appended by enhanceSystemPromptWithEnvDetails()
`Notes:
- Agent threads always have their cwd reset between bash calls — use absolute file paths.
- In your final response, share file paths (always absolute, never relative) ...
- For clear communication the assistant MUST avoid using emojis.
- Do not use a colon before tool calls.`

computeEnvInfo() (the fuller version, not computeSimpleEnvInfo) is used here — it adds uname -sr output via getUnameSR() and wraps the env block in <env> XML tags rather than the simpler bullet-list format used in the main session.

10 Simple Mode & Other Escape Hatches

CLAUDE_CODE_SIMPLE=1

Setting this environment variable activates a three-line system prompt — no instructions, no CLAUDE.md, no tool guidance, just CWD and date. Useful for benchmarking raw model capability without Claude Code's prompt overhead.

if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) {
  return [`You are Claude Code, Anthropic's official CLI for Claude.\n\nCWD: ${getCwd()}\nDate: ${getSessionStartDate()}`]
}

Proactive / KAIROS mode

When feature('PROACTIVE') or feature('KAIROS') is enabled and proactive mode is active, getSystemPrompt() returns a short autonomous-agent identity prompt instead of the full interactive-session prompt. It includes memory, env info, MCP instructions, and the proactive section — but none of the interactive coding-task guidance.

return [
  `\nYou are an autonomous agent. Use the available tools to do useful work.\n\n${CYBER_RISK_INSTRUCTION}`,
  getSystemRemindersSection(),
  await loadMemoryPrompt(),
  envInfo,
  getLanguageSection(settings.language),
  getMcpInstructionsSection(mcpClients),
  getScratchpadInstructions(),
  getProactiveSection(),
].filter(s => s !== null)

Key Takeaways

  • buildEffectiveSystemPrompt() implements a strict priority waterfall: override > coordinator > agent > custom > default, with appendSystemPrompt always appending.
  • The prompt is split into a static half (globally cache-scoped, same for all users) and a dynamic tail (session-specific). The boundary marker __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ is the seam.
  • Dynamic sections are managed by a named registry. Most are memoized for the session; only mcp_instructions is volatile because servers connect mid-session.
  • CLAUDE.md files are loaded in four scopes (managed → user → project → local), with files closer to CWD taking higher priority by appearing last.
  • The @include directive lets CLAUDE.md files compose other files. Frontmatter paths gates instructions to specific file patterns.
  • Subagents bypass getSystemPrompt() entirely, starting from DEFAULT_AGENT_PROMPT then enhanced with env details.
  • Multiple escape hatches exist: CLAUDE_CODE_SIMPLE=1 for a stub prompt, proactive mode for an autonomous-agent identity, undercover mode to strip all model name references.
  • Every // @[MODEL LAUNCH] comment marks code that needs human attention at each new Claude model release.

Knowledge Check

1. What happens when overrideSystemPrompt is set in buildEffectiveSystemPrompt()?
2. Why is mcp_instructions marked as DANGEROUS_uncachedSystemPromptSection while other dynamic sections are memoized?
3. In CLAUDE.md loading priority, which file has the highest effective priority?
4. What is the purpose of __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__?
5. How does proactive / KAIROS mode change the system prompt?
0/5