Studio Notebook

Claude Code Atlas

Prompt Override Priority And Final Assembly

Learn how default prompts, custom prompts, coordinator prompts, and agent prompts are merged into the final system prompt.

Why this matters

Claude Code can get prompt text from several places, and the order matters. Sometimes a caller wants to replace the default prompt entirely. Sometimes the runtime should use a coordinator prompt or an agent-specific prompt. If none of those apply, the normal default prompt still needs to be assembled and then optionally extended with extra text at the end.

Big picture first

This page follows the real priority rules in buildEffectiveSystemPrompt(...), then shows how fetchSystemPromptParts(...) decides which inputs to fetch, and finally shows the QueryEngine.ts assembly path that turns the selected pieces into the branded SystemPrompt array.

Final system prompt container

Claude Code does not hand one string to the model. It hands a branded array of prompt sections.

Field Meaning First Pass
defaultSystemPrompt The array returned by getSystemPrompt(...) before override logic is applied. Treat this as the default prompt candidate.
customSystemPrompt A caller-provided replacement prompt. If this exists, the default builder path is skipped in fetchSystemPromptParts(...).
appendSystemPrompt Extra prompt text appended at the end of the chosen prompt. This is the simplest override to understand.
SystemPrompt The branded readonly string array created by asSystemPrompt([...]). This is the object that later API calls consume.

The short version is simple: overrideSystemPrompt wins first, coordinator mode can replace the normal prompt next, agent prompts can win after that, and the customSystemPrompt !== undefined path only exists for callers that passed their own system prompt. If none of those special cases apply, Claude Code uses the default prompt and then appends anything extra at the end.

The priority rules

/**
 * Builds the effective system prompt array based on priority:
 * 0. Override system prompt (if set, e.g., via loop mode - REPLACES all other prompts)
 * 1. Coordinator system prompt (if coordinator mode is active)
 * 2. Agent system prompt (if mainThreadAgentDefinition is set)
 *    - In proactive mode: agent prompt is APPENDED to default (agent adds domain
 *      instructions on top of the autonomous agent prompt, like teammates do)
 *    - Otherwise: agent prompt REPLACES default
 * 3. Custom system prompt (if specified via --system-prompt)
 * 4. Default system prompt (the standard Claude Code prompt)
 *
 * Plus appendSystemPrompt is always added at the end if specified (except when override is set).
 */
export function buildEffectiveSystemPrompt({
  mainThreadAgentDefinition,
  toolUseContext,
  customSystemPrompt,
  defaultSystemPrompt,
  appendSystemPrompt,
  overrideSystemPrompt,
}: {
  mainThreadAgentDefinition: AgentDefinition | undefined
  toolUseContext: Pick<ToolUseContext, 'options'>
  customSystemPrompt: string | undefined
  defaultSystemPrompt: string[]
  appendSystemPrompt: string | undefined
  overrideSystemPrompt?: string | null
}): SystemPrompt {
  if (overrideSystemPrompt) {
    return asSystemPrompt([overrideSystemPrompt])
  }
  if (
    feature('COORDINATOR_MODE') &&
    isEnvTruthy(process.env.CLAUDE_CODE_COORDINATOR_MODE) &&
    !mainThreadAgentDefinition
  ) {
    const { getCoordinatorSystemPrompt } =
      require('../coordinator/coordinatorMode.js') as typeof import('../coordinator/coordinatorMode.js')
    return asSystemPrompt([
      getCoordinatorSystemPrompt(),
      ...(appendSystemPrompt ? [appendSystemPrompt] : []),
    ])
  }

  const agentSystemPrompt = mainThreadAgentDefinition
    ? isBuiltInAgent(mainThreadAgentDefinition)
      ? mainThreadAgentDefinition.getSystemPrompt({
          toolUseContext: { options: toolUseContext.options },
        })
      : mainThreadAgentDefinition.getSystemPrompt()
    : undefined

That branch order is the key teaching point. The mainThreadAgentDefinition path can replace the default prompt, while overrideSystemPrompt short-circuits the whole function before anything else gets a chance to run.

The fetch helper

export async function fetchSystemPromptParts({
  tools,
  mainLoopModel,
  additionalWorkingDirectories,
  mcpClients,
  customSystemPrompt,
}: {
  tools: Tools
  mainLoopModel: string
  additionalWorkingDirectories: string[]
  mcpClients: MCPServerConnection[]
  customSystemPrompt: string | undefined
}): Promise<{
  defaultSystemPrompt: string[]
  userContext: { [k: string]: string }
  systemContext: { [k: string]: string }
}> {
  const [defaultSystemPrompt, userContext, systemContext] = await Promise.all([
    customSystemPrompt !== undefined
      ? Promise.resolve([])
      : getSystemPrompt(
          tools,
          mainLoopModel,
          additionalWorkingDirectories,
          mcpClients,
        ),
    getUserContext(),
    customSystemPrompt !== undefined ? Promise.resolve({}) : getSystemContext(),
  ])
  return { defaultSystemPrompt, userContext, systemContext }
}

fetchSystemPromptParts(...) is deliberately conservative. When customSystemPrompt !== undefined, it skips the default prompt builder and the system-context fetch because the caller already chose a replacement prompt. That keeps the cache-key prefix aligned with the real runtime behavior.

The QueryEngine assembly path

    const customPrompt =
      typeof customSystemPrompt === 'string' ? customSystemPrompt : undefined
    const {
      defaultSystemPrompt,
      userContext: baseUserContext,
      systemContext,
    } = await fetchSystemPromptParts({
      tools,
      mainLoopModel: initialMainLoopModel,
      additionalWorkingDirectories: Array.from(
        initialAppState.toolPermissionContext.additionalWorkingDirectories.keys(),
      ),
      mcpClients,
      customSystemPrompt: customPrompt,
    })
    const userContext = {
      ...baseUserContext,
      ...getCoordinatorUserContext(
        mcpClients,
        isScratchpadEnabled() ? getScratchpadDir() : undefined,
      ),
    }

    const memoryMechanicsPrompt =
      customPrompt !== undefined && hasAutoMemPathOverride()
        ? await loadMemoryPrompt()
        : null

    const systemPrompt = asSystemPrompt([
      ...(customPrompt !== undefined ? [customPrompt] : defaultSystemPrompt),
      ...(memoryMechanicsPrompt ? [memoryMechanicsPrompt] : []),
      ...(appendSystemPrompt ? [appendSystemPrompt] : []),
    ])

This is the last step in the chain. QueryEngine.ts only injects memoryMechanicsPrompt when a custom prompt is present and hasAutoMemPathOverride() is true, which keeps memory mechanics tied to the explicit auto-memory override path instead of every prompt build.

Takeaways

  • Prompt precedence is explicit: override, coordinator, agent, custom, then default.
  • fetchSystemPromptParts(...) skips work when a custom prompt already replaces the default path.
  • The final SystemPrompt is a branded array built by asSystemPrompt([...]), not a free-form string.