3.2 System Prompt Construction
The system prompt is the most stable part of the context — it defines "who" the model is and "what it should do". Precisely because it's stable, it's also the best candidate for prompt caching. Claude Code's system prompt construction strikes a careful balance between stability and flexibility.
Attribution Header
A fingerprint-based identity marker used to track request origins.
CLI System Prompt Prefix
Varies by execution mode: interactive mode (REPL) and -p single-query mode have different prefix instructions.
System Prompt Priority
The system prompt construction has strict priority ordering, implemented by buildEffectiveSystemPrompt() (src/utils/systemPrompt.ts):
// Priority from highest to lowest:
// 0. overrideSystemPrompt — Complete override (e.g., loop mode)
// 1. coordinatorSystemPrompt — Coordinator mode (Feature-gated)
// 2. agentSystemPrompt — Agent-defined prompt
// - Proactive mode: Appended after the default prompt
// - Normal mode: Replaces the default prompt
// 3. customSystemPrompt — Specified via --system-prompt parameter
// 4. defaultSystemPrompt — Standard Claude Code prompt
// + appendSystemPrompt is always appended to the end (except in override mode)This priority chain ensures that different execution modes (interactive, Agent, coordinator, SDK) all receive the correct system prompt while preserving user customization capabilities.
Static/Dynamic Boundary Marker
There is a key design element in the system prompt — SYSTEM_PROMPT_DYNAMIC_BOUNDARY (src/constants/prompts.ts:114). This is a sentinel string __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ that divides the system prompt array into two halves:
- Before the boundary: Core instructions, tool descriptions, safety rules, etc. — content that is completely identical across all sessions for all users
- After the boundary: MCP tool instructions, output style, language preferences, etc. — content that varies by user/session
Why is this boundary needed? Because it directly affects the efficiency of prompt caching. The static part before the boundary can be cached with scope: 'global', shared across all users — this means millions of Claude Code users worldwide can share the same cached core system prompt. The dynamic part after the boundary can only use scope: 'org' or no caching. Without this boundary, the entire system prompt would only get org-level caching, wasting large amounts of cache storage on completely identical content.
Section-Level Caching
The various components of the system prompt implement section-level caching through systemPromptSections.ts. There are two types:
// Computed once, cached until /clear or /compact
systemPromptSection('toolInstructions', () => buildToolPrompt(...))
// Recomputed every turn, will break prompt cache
DANGEROUS_uncachedSystemPromptSection(
'mcp_instructions',
() => getMcpInstructionsSection(mcpClients),
'MCP servers connect/disconnect between turns' // Must provide a reason
)The DANGEROUS_ prefix is intentional code-level warning — it reminds developers: this section is recomputed every turn, and if the value changes it will break the prompt cache. Developers must provide a _reason parameter explaining why cache breaking is necessary. Most sections are stable (tool descriptions, safety rules), and only a few sections that depend on runtime state that can change between turns (e.g. MCP servers may connect/disconnect between turns) need to use the DANGEROUS_ variant.
clearSystemPromptSections() is called during /clear and /compact, simultaneously resetting the beta header latch (see 3.6 Layer 2), giving the next conversation a completely fresh state.
System Context (getSystemContext)
The getSystemContext() function from src/context.ts is memoize-cached (computed only once per session).
The complete implementation shows a carefully designed context collection process:
// src/context.ts — getGitStatus()
export const getGitStatus = memoize(async (): Promise<string | null> => {
const isGit = await getIsGit()
if (!isGit) return null
try {
// 5 git commands executed in parallel
const [branch, mainBranch, status, log, userName] = await Promise.all([
getBranch(),
getDefaultBranch(),
execFileNoThrow(gitExe(), ['--no-optional-locks', 'status', '--short'], ...)
.then(({ stdout }) => stdout.trim()),
execFileNoThrow(gitExe(), ['--no-optional-locks', 'log', '--oneline', '-n', '5'], ...)
.then(({ stdout }) => stdout.trim()),
execFileNoThrow(gitExe(), ['config', 'user.name'], ...)
.then(({ stdout }) => stdout.trim()),
])
// Status truncated to 2000 characters, preventing many uncommitted files from blowing up the context
const truncatedStatus = status.length > MAX_STATUS_CHARS
? status.substring(0, MAX_STATUS_CHARS) +
'\n... (truncated because it exceeds 2k characters...)'
: status
return [
// This disclaimer is crucial — it tells the model that git status is a snapshot from session start,
// preventing the model from "hallucinating" real-time git status updates in subsequent turns
`This is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.`,
`Current branch: ${branch}`,
`Main branch (you will usually use this for PRs): ${mainBranch}`,
...(userName ? [`Git user: ${userName}`] : []),
`Status:\n${truncatedStatus || '(clean)'}`,
`Recent commits:\n${log}`,
].join('\n\n')
} catch (error) {
logError(error)
return null
}
})Noteworthy design details:
Promise.allparallelism: 5 git commands execute simultaneously instead of waiting sequentially — this can save hundreds of milliseconds in large repositories--no-optional-locks: Avoids git commands acquiring locks that could conflict with other git operationsMAX_STATUS_CHARS = 2000: Limits status output length. Imagine a monorepo with 500 uncommitted files — without truncation, git status alone would consume a significant context budget- Disclaimer text: Explicitly tells the model this is a snapshot that won't update in real-time — an important measure to prevent model hallucination
getSystemContext() itself also has conditional skip logic:
export const getSystemContext = memoize(async () => {
// Skip in CCR (Cloud Code Remote) mode or when git-instructions are disabled
const gitStatus =
isEnvTruthy(process.env.CLAUDE_CODE_REMOTE) ||
!shouldIncludeGitInstructions()
? null
: await getGitStatus()
// Cache break injection (internal debugging feature, Feature-gated)
const injection = feature('BREAK_CACHE_COMMAND')
? getSystemPromptInjection()
: null
return {
...(gitStatus && { gitStatus }),
...(injection ? { cacheBreaker: `[CACHE_BREAKER: ${injection}]` } : {}),
}
})User Context (getUserContext)
export const getUserContext = memoize(async () => {
// Subtle semantics of --bare mode:
// - CLAUDE_CODE_DISABLE_CLAUDE_MDS: Hard disable, always effective
// - --bare: Skips auto-discovery (CWD traversal), but respects explicit --add-dir
// Original comment: "bare means skip what I didn't ask for, not ignore what I asked for"
const shouldDisableClaudeMd =
isEnvTruthy(process.env.CLAUDE_CODE_DISABLE_CLAUDE_MDS) ||
(isBareMode() && getAdditionalDirectoriesForClaudeMd().length === 0)
const claudeMd = shouldDisableClaudeMd
? null
: getClaudeMds(filterInjectedMemoryFiles(await getMemoryFiles()))
// Cached for yoloClassifier use, avoiding import cycle creation
setCachedClaudeMdContent(claudeMd || null)
return {
...(claudeMd && { claudeMd }),
currentDate: `Today's date is ${getLocalISODate()}.`,
}
})CLAUDE.md Discovery Mechanism
CLAUDE.md is Claude Code's project-level instruction file, similar to .editorconfig or .eslintrc, but targeting the AI Agent. Its discovery process is more complex than it appears.
Discovery order (getMemoryFiles()):
- Managed policy files: Instructions read from MDM (Mobile Device Management) policies (e.g.,
/etc/claude-code/CLAUDE.md) - User home directory: Global configuration under
~/.claude/CLAUDE.md - Project files: Traversing the directory tree upward from CWD, looking for instruction files at each level
- Local files:
CLAUDE.local.md(personal instructions not committed to git) - Explicitly added directories: Extra directories specified via the
--add-dirparameter
Filename patterns: Each directory is checked for CLAUDE.md, .claude/CLAUDE.md, and all .md files under the .claude/rules/ directory. This means you can split instructions for different domains into independent files (e.g., .claude/rules/testing.md, .claude/rules/style.md), and the system will automatically load them.
Priority ordering: Files are loaded in far-to-near order — files closer to CWD are loaded later, thus having higher priority. This follows the "proximity principle": global rules in the project root can be overridden by local rules in subdirectories. Since LLMs pay more attention to content at the end of the context (recency bias), later-loaded instructions carry more weight in the model's "attention".
@include directive (src/utils/claudemd.ts):
CLAUDE.md files can reference other files using @ syntax:
# Project Instructions
@./docs/coding-standards.md
@~/global-rules.md
@/etc/company-policy.md@path(no prefix) is equivalent to@./path, resolved as a relative path@~/pathresolves from the user home directory@/pathresolves as an absolute path- Only effective in leaf text nodes (
@inside code blocks will not be parsed) - Referenced files are inserted as independent entries before the referencing file
- Circular references are prevented by tracking processed files
- Only text file extensions are allowed (.md, .txt, etc.), preventing loading of binary files
Filtering: filterInjectedMemoryFiles(), when the feature flag (tengu_moth_copse) is on, drops memory entries of type AutoMem / TeamMem — these auto-memories are surfaced on demand via attachment messages instead of being injected into the CLAUDE.md context block
Cache invalidation: clearMemoryFileCaches() clears the cache without firing any hook (used on worktree switches, settings sync); resetGetMemoryFilesCache() is called on compaction / /clear so that the next getMemoryFiles() reloads and fires the InstructionsLoaded hook with the corresponding reason
Context Injection Order
// src/utils/api.ts
const fullSystemPrompt = asSystemPrompt(
appendSystemContext(systemPrompt, systemContext) // System context appended
)
// userContext is prepended before messages (prependUserContext)System context is appended after the system prompt, while user context is prepended before messages — this order affects the efficiency of prompt caching. The system prompt is the most stable part (unchanged across requests), and placing it at the front favors cache hits; while user context (CLAUDE.md, date) may change with the session, and placing it before messages won't break the system prompt's cache.