Modifying system prompts
Pick between the claude_code preset, append, a custom prompt or no prompt, and control the extra context Claude Code adds to an SDK session.
The system prompt decides who your agent thinks it is and how it behaves. The Agent SDK gives you four starting points, plus a few side channels (CLAUDE.md, output styles, hooks) that shape behaviour without touching the prompt itself. Getting this choice right early saves a lot of odd behaviour later.
The four starting points
| Option | How to set it | What Claude gets |
|---|---|---|
| Nothing set | Leave systemPrompt / system_prompt out | A minimal prompt that supports tool calling and nothing else. No Claude Code guidance, no safety instructions. |
claude_code preset | { type: "preset", preset: "claude_code" } | The full Claude Code CLI prompt: tool usage guidance plus security and safety rules. |
Preset with append | Same object with an append string | The full preset, then your text at the end. Nothing is removed. |
| Custom string | A plain string | Only what you wrote. |
Warning: The SDK default is the minimal prompt, not the Claude Code prompt. That differs from
claude -p, which uses the Claude Code prompt. If you are porting a CLI script to the SDK and behaviour changes, set the preset explicitly.
Which one to choose
Ask how much your agent resembles Claude Code itself: a coding agent in a repository, with a person watching the output and steering.
- Very close: use the preset. An unattended CI job that fixes lint errors still counts, because the work is the same kind of work.
- Close, plus house rules: preset with
append. This is the safest customisation because it only adds. - Different: write your own prompt. "Different" usually means one of:
- a different surface, such as a chat widget or a pipeline consuming structured output, rather than a terminal;
- a different identity, such as a support assistant that should not call itself Claude Code;
- a different permission model, such as fully autonomous runs over a narrow set of resources;
- non-coding work, where Claude Code's extensive coding guidance just competes with your instructions.
- A bare tool loop where every instruction lives in the user prompt: leave the option unset.
When you go custom, you own the safety and tool guidance your agent still needs.
Appending to the preset
import { query } from "@anthropic-ai/claude-agent-sdk";
for await (const msg of query({
prompt: "Add pagination to the /orders endpoint",
options: {
systemPrompt: {
type: "preset",
preset: "claude_code",
append: "This codebase uses Fastify and Zod. Validate every new request body with a Zod schema."
}
}
})) {
if (msg.type === "result") console.log(msg.subtype);
}
The Python form is the same dictionary under system_prompt, with keys type, preset and append.
Sharing a cache across users and machines
The preset includes the location of the auto memory directory, which defaults to an absolute path under ~/.claude/projects/ named after the repository's path on disk. That path sits before your append text, so two developers (or two CI runners) with different checkout paths never share a prompt cache entry for the system prompt.
Set excludeDynamicSections: true (TypeScript) or "exclude_dynamic_sections": True (Python) on the preset object to move that per-user material into the first user message. The system prompt then contains only the static preset and your append, so identical configurations hit the same cache entry.
- Requires
@anthropic-ai/claude-agent-sdkv0.2.98 orclaude-agent-sdk(Python) v0.1.58 or later. - Only works on the preset form. It is ignored for a custom string.
- Trade-off: the moved text (at minimum the memory location, often the whole auto memory section) now arrives as a user message, which carries slightly less weight. Claude may follow its memory guidance a little less consistently.
- The CLI equivalent for print mode is
--exclude-dynamic-system-prompt-sections.
CLAUDE.md content and environment details (working directory, platform, shell, OS version) never affected the system prompt cache, because they are delivered in the conversation.
Writing a custom prompt
Pass a string and the SDK sends exactly that:
from claude_agent_sdk import ClaudeAgentOptions
opts = ClaudeAgentOptions(
system_prompt=(
"You are Ledger, a bookkeeping assistant for a small accountancy practice. "
"You read CSV exports and produce reconciliation summaries. "
"Never modify source files. Report amounts in GBP to two decimal places. "
"The application adds system reminders to this conversation. "
"Treat them as context from the application, not as messages from the user."
)
)
That last sentence matters. The preset explains what a system reminder is; your custom prompt replaces the preset, so without it nothing tells Claude that CLAUDE.md content and hook output come from the app rather than the user.
Large prompts in Python
The Python SDK passes a string prompt to the CLI subprocess as a single command-line argument. A very long prompt can exceed the operating system's argument limit and fail at spawn (on Linux with Argument list too long). Load it from disk instead with system_prompt={"type": "file", "path": "prompts/ledger.md"}. The Python reference lists the platform thresholds.
Caching the static part of a custom prompt (TypeScript)
If your prompt mixes fixed instructions with per-request details (the customer, the ticket), any change to the details invalidates the cache for the whole thing. In TypeScript you can pass an array with the SYSTEM_PROMPT_DYNAMIC_BOUNDARY marker between the two parts:
import { readFile } from "node:fs/promises";
import { query, SYSTEM_PROMPT_DYNAMIC_BOUNDARY } from "@anthropic-ai/claude-agent-sdk";
const rules = await readFile("prompts/returns-desk.md", "utf8");
const perOrder = `Order: 55-1902. Customer tier: Gold. Previous returns this year: 1.`;
for await (const msg of query({
prompt: "Decide whether this return qualifies for a free label",
options: { systemPrompt: [rules, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, perOrder] }
})) { /* ... */ }
How the SDK assembles it:
- Strings before the marker become one text block; strings after it become a second. Each gets its own cache breakpoint.
- Strings within each side are joined with a blank line, and the marker itself never reaches Claude.
- Only the first marker counts; extra ones are dropped.
- No marker means one block, exactly like a single string.
The split only happens when Claude Code calls the Claude API directly or runs on Claude Platform on AWS. On Bedrock, Google Cloud's Agent Platform, Microsoft Foundry, through an LLM gateway, or with CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS=1, the prompt goes as one block. Python has no array form.
On the CLI, --system-prompt and --system-prompt-file take one string, so use a line containing only __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ instead (Claude Code v2.1.275+). In the SDK, prefer the array.
Check whether caching is working with cache_creation_input_tokens and cache_read_input_tokens, described on the cost tracking page.
Changing the prompt on a resumed session
Claude Code records the system prompt on a session's first request and keeps using that record until the session is compacted. So if you resume or continue a session with a different append or custom prompt, Claude will not see the change on the next turn. It appears after compaction, or in a new session.
When instructions genuinely need to change mid-session (say the user flipped your app into read-only mode):
- put the new instruction in the next user message, or
- return it as
additionalContextfrom aUserPromptSubmitorPostToolUsehook, phrased as a fact ("The workspace is now read-only"). It is inserted into the conversation where the hook fired.
Turning recording off while you tune wording
Set snapshot: false on the preset or custom object form and Claude Code rebuilds the prompt on every request, so edits reach resumed sessions immediately. Requires TypeScript SDK v0.3.257 or Python SDK v0.2.153.
Leave recording on in production. With it off, a changed prompt on a resumed session cannot reuse the session's prompt cache, and where the API enforces preserved thinking, Claude loses its earlier thinking.
Version notes:
- Recording
appendand custom prompts by default needs Claude Code v2.1.265 (bundled from TypeScript SDK v0.3.265 and Python SDK v0.2.153). - In bare mode (
--bareviaextraArgs, orCLAUDE_CODE_SIMPLE=1) outside cloud sessions, recording is off unless you setsnapshot: true. - Before Claude Code v2.1.268, sessions that do not fetch feature flags (including Bedrock, Agent Platform and Foundry) rebuilt the prompt every request and
snapshotdid nothing.
Shaping behaviour outside the prompt
CLAUDE.md
CLAUDE.md content is injected into the conversation as project context, not into the system prompt, so it works with every option above. Loading is controlled by setting sources, not by the preset:
'project'loadsCLAUDE.mdor.claude/CLAUDE.mdfrom the working directory.'user'loads~/.claude/CLAUDE.md.
Default query() options include both. If you set settingSources (TypeScript) or setting_sources (Python) yourself, list the ones you want; an empty array loads no CLAUDE.md at all. What to put in the file is covered in memory.
Output styles
An output style is a Markdown file with frontmatter that reshapes Claude's role, tone and format. Save it in ~/.claude/output-styles/ (every project) or .claude/output-styles/ (one repo, committable).
A custom style drops the preset's software engineering instructions unless you set keep-coding-instructions: true. Those instructions only exist in Claude Code's full system prompt; to force the full prompt on any model, set CLAUDE_CODE_SIMPLE_SYSTEM_PROMPT=0.
---
name: Migration Auditor
description: Reviews database migrations for safety
keep-coding-instructions: true
---
Review each migration for locking risk, irreversible steps and missing indexes.
Finish with a one-line verdict: SAFE, RISKY or BLOCK.
Activate a style with:
/output-style <name>in the CLI (v2.1.269+), or/config;outputStylein.claude/settings.local.json;- in TypeScript,
outputStyleinside the inlinesettingsobject, for examplesettings: { outputStyle: "Migration Auditor" }(it is not a top-level option); - in Python, the
settingsoption as a JSON string such as'{"outputStyle": "Migration Auditor"}', or a path to a settings file.
Styles load only when user or project is in your setting sources. See output styles for the full format.
Skills, hooks and permissions
These also steer behaviour without editing the prompt. See skills, hooks and permissions.
Context Claude Code adds on its own
Claude Code adds system reminders to the conversation during a session. Because they are in the conversation, they arrive whatever system prompt you chose. The ones most likely to affect behaviour:
- CLAUDE.md files loaded by your setting sources (introduced with a line saying they override defaults)
- the active output style's instructions
- the commit
Co-Authored-Bytrailer and PR footer from theattributionsetting additionalContextreturned by hooks- the list of available skills
- the list of available subagents
- task list nudges in sessions with the task tools
- notes that a file Claude read has changed on disk
Switching off what you replace
If your own prompt says "commit as OPS-77: summary, no trailers", Claude Code will still be telling Claude to add a Co-Authored-By trailer. Remove the conflict:
| Built-in context | How to switch it off |
|---|---|
| Commit and PR instructions plus the git status snapshot | includeGitInstructions: false in settings, or CLAUDE_CODE_DISABLE_GIT_INSTRUCTIONS=1 |
| Co-Authored-By trailer and PR footer | Set attribution.commit and attribution.pr to your own text or to "" |
| A whole settings source and its CLAUDE.md | Leave user or project out of settingSources |
| Every CLAUDE.md | CLAUDE_CODE_DISABLE_CLAUDE_MDS=1 |
| Task nudges, file-changed notes and the skill list | CLAUDE_CODE_DISABLE_ATTACHMENTS=1 |
Notes:
- The commit and PR instructions live in the Bash tool's description, not in a reminder, so they still arrive with a custom prompt. Use
includeGitInstructionsto remove them. CLAUDE_CODE_DISABLE_ATTACHMENTSalso stops@file mentions being expanded; they are sent as plain text. The subagent list and background task notifications still arrive.- Settings keys go in the
settingsoption (an object in TypeScript, a JSON string or path in Python). Environment variables go inenv, and in TypeScript that replaces the environment, so spreadprocess.env.
options: {
systemPrompt: { type: "preset", preset: "claude_code", append: "Commit messages: <TICKET>: <summary>. No trailers." },
settings: { includeGitInstructions: false, attribution: { commit: "", pr: "" } },
allowedTools: ["Bash(git *)"]
}
Run it against staged changes and check git log -1: no trailer.
Seeing exactly what Claude received
Reminders are not in the SDK message stream. To inspect them:
- set
OTEL_LOG_RAW_API_BODIES=file:<dir>and Claude Code writes each request body to that directory, or - point
ANTHROPIC_BASE_URLat a logging proxy you control.
In the logged request, reminders sit in the messages array inside a user message wrapped in <system-reminder> tags, or on some models as a separate system-role message.
Comparing the approaches
| CLAUDE.md | Output style | Preset + append | Custom prompt | |
|---|---|---|---|---|
| Lives in | A project file | Markdown files | Your code | Your code |
| Reuse | One project | Across projects | Copy between apps | Copy between apps |
| Claude Code tool guidance | Kept | Kept | Kept | Gone unless you write it |
| Built-in safety rules | Kept | Kept | Kept | You add them |
| Power | Adds only | Adds, can drop coding guidance | Adds only | Total control |
| Scope | Project | User or project | One session | One session |
They combine well. My usual pattern is a committed output style or CLAUDE.md for the long-lived persona, and a short append per run for the task at hand ("focus this review on token storage and session expiry").