claude-prompt-drift
Detect drift in the Claude system prompt that agent-container sends to Anthropic. Captures two axes — the bare `claude_code` preset (Anthropic baseline) and agent-container's fully custom wire request
SKILL.md.claude/skills/claude-prompt-drift/SKILL.md
GitHubClaude Prompt Drift Check
Capture + diff of the /v1/messages request body that claude-agent-sdk emits, at two layers:
| Axis | What it captures |
|---|---|
pure-claude |
Bare { type: 'preset', preset: 'claude_code' } baseline — no MCP, no append, no custom tools |
superagent |
What agent-container actually puts on the wire (skills, MCP, claudeMd, etc.) |
Key architectural fact: claude-code.ts passes systemPrompt as a plain string (rendered from agent-container/src/system-prompt.md), which REPLACES the claude_code preset entirely — the preset never reaches our sessions. Our prompt is a separate, parallel document covering similar ground in its own words, so when Anthropic improves the preset, our prompt silently falls behind unless someone ports the change. That protection is the point of this skill.
The interesting signal is each axis's drift across captures:
pure-claudedrift → Anthropic changed the preset. This is never merely informational — because we replace the preset, no preset improvement reaches our agents automatically. Run the porting triage below on every hunk.superagentdrift → our own prompt/wiring changed (edited system-prompt.md, added/removed an MCP server, changed claudeMd, bumped SDK, etc.). Expected when we shipped prompt changes; a red flag when we didn't (means the SDK/CLI altered how it assembles our request).
Porting triage (mandatory when pure-claude drifts)
For each hunk in the pure-claude system.md diff, decide port / adapt / skip against our prompt sources:
agent-container/src/system-prompt.md— the main session promptagent-container/src/web-browser-agent-prompt.md,dashboard-builder-agent-prompt.md,computer-use-agent-prompt.md— subagent prompts
Triage rules:
- Behavioral guidance (tone, safety, communication, memory discipline — e.g. the 2.1.219 they/them pronoun paragraph): usually port, adapted to our prompt's voice and section structure. Anthropic adds these from behavioral data; our agents talk to real humans and inherit none of it.
- Product-surface text (fast mode, /commands, terminal UI, Claude Code feature descriptions): skip — describes their product, not ours.
- Facts that go stale (model rosters, model IDs, knowledge cutoffs, pricing): our main prompt deliberately carries none of these — keep it that way, but check the subagent prompts for hardcoded model IDs and route those to the
update-modelsskill. - Tool-description drift (
tools.mddiff): only actionable if our prompt or subagent prompts restate guidance about that tool — check before dismissing.
Record the port/skip decision per hunk in the upgrade PR description so the next drift run doesn't re-litigate old hunks.
Files in this skill
.claude/skills/claude-prompt-drift/
├── SKILL.md
├── .gitignore ← ignores `snapshots/`
├── capture.sh ← orchestrator (containers + proxy + drive)
├── diff.sh ← cross-snapshot diff
├── proxy.mjs ← pass-through capture proxy (runs in node:20 container)
├── pure-baseline/run.mjs ← bare-preset SDK driver (runs inside agent-container image)
└── snapshots/ ← captured data, local only (gitignored)
snapshots/ is build output, not source. It lives next to the script so it's trivially discoverable (ls .claude/skills/claude-prompt-drift/snapshots/) but never enters git.
Snapshot layout
Default location: <this skill>/snapshots/. Override precedence: --snapshots-dir <path> > $SNAPSHOTS_DIR env > default.
Layout under the snapshots root:
pure-claude/<sdk-version>/<model>/{system,messages,tools}.md + meta.json
superagent/<sa-key>/<model>/{system,messages,tools}.md + meta.json
<sa-key> shape:
| Source | Shape | Example |
|---|---|---|
| main / release | <sdk>+<sa-version> |
0.2.118+0.3.24 |
--pr <num> |
<sdk>+pr<num>-<short-sha> |
0.2.118+pr73-fef927c2 |
| dirty tree | suffix -dirty (needs --allow-dirty) |
0.2.118+0.3.24-dirty |
The two axes are keyed differently because they depend on different things: pure-claude only on the SDK version, superagent also on what SuperAgent ships. Mixing them in one key would silently overwrite the superagent axis when SuperAgent changes without an SDK bump.
When to use
- After bumping
@anthropic-ai/claude-agent-sdkinagent-container/package.json. - "Model is behaving weirdly since the SDK upgrade" — diff to see what shifted.
- Reviewing a PR that touches
agent-containerprompts/tools — capture the PR head and diff against main. - Auditing whether Anthropic silently changed the
claude_codepreset.
Prerequisites
- Docker running.
jq,curl,giton PATH.- Skill run from inside the SuperAgent repo (so
--superagent-pathcan auto-resolve).
Usage
Capture the current SDK version (default = working tree)
cd /path/to/SuperAgent
.claude/skills/claude-prompt-drift/capture.sh
Capture a PR head without touching the working tree
.claude/skills/claude-prompt-drift/capture.sh --pr 73
--pr fetches origin/pull/<num>/head, builds in a detached worktree, captures, removes the worktree on exit.
Capture against an arbitrary checkout
git worktree add --detach /tmp/sa-old origin/main~10
.claude/skills/claude-prompt-drift/capture.sh --superagent-path /tmp/sa-old
git worktree remove /tmp/sa-old
Flags
| Flag | Default | Meaning |
|---|---|---|
--model |
claude-opus-4-7 |
Model id (one snapshot per model) |
--axis |
both |
pure-claude, superagent, or both |
--pr <num> |
none | Capture against a PR head (mutually exclusive with --superagent-path) |
--force |
off | Re-capture even if snapshot exists |
--allow-dirty |
off | Allow superagent capture from a dirty working tree. Key gets a -dirty suffix. |
--snapshots-dir |
$SNAPSHOTS_DIR or <skill>/snapshots |
Where to write snapshots (gitignored at the default location) |
--superagent-path |
auto | Override SuperAgent repo path |
--anthropic-api-key |
dummy-for-capture |
API key passed to the containers. Proxy captures the request body before forwarding upstream, so a 401 is fine. Claude Code's CLI does light local validation — pass a real-looking key if it complains. |
--tool-search |
true |
ENABLE_TOOL_SEARCH on the superagent axis — true is what the Anthropic, Platform and subscription providers send. Always pinned: behind the capture proxy an unset value means on since CLI 2.1.304 and off before it. The pure-claude axis always runs with it false, so every preset tool's full description is on the wire for triage. Snapshots taken before this flag (≤ 0.3.303) are tool-search-off captures; compare them to a --tool-search false capture. |
Diff two snapshots
.claude/skills/claude-prompt-drift/diff.sh pure-claude <old-sdk> <new-sdk>
.claude/skills/claude-prompt-drift/diff.sh superagent <old-key> <new-key>
Exits non-zero on drift (CI-friendly). Diff is per-axis on purpose — the two axes have different key shapes.
What the between-axes diff means
In a single capture, pure-claude/<sdk>/<model>/system.md and superagent/<key>/<model>/system.md are two unrelated documents — the preset vs our full replacement prompt (~80 vs ~700 lines, different section structure). Diffing them line-by-line is meaningless and is not what this skill is for. This skill tracks how each axis evolves over time; the pure axis exists purely as the canary for preset changes to feed the porting triage above.
Noise handling
proxy.mjs redacts two volatile fields before writing the rendered .md:
- The capture timestamp is dropped from
.md(it lives inmeta.json). cch=<hex>in the billing header (system[0]) is replaced withcch=<redacted>. Claude Code regenerates this per request even for identical input, so leaving it raw makes every diff flap.
Real signal fields like cc_version=... and cc_entrypoint= are kept.
Notes
- One capture per
(axis, key, model)is intentional — captures are large and the static prefix is stable per session. Use--forceto redo. raw.jsonand.seen-models.jsonare produced during capture but excluded from diffs.pure-baseline/run.mjsuses the SDK that's already installed inside the agent-container image (no separatenpm install), avoiding the glibc/musl mismatch that broke earlier attempts at running the baseline undernode:20-alpine.- Working tree state matters. When pointing at the working repo directly, the script reads
git rev-parse HEADandgit status --porcelain— make sure that's what you mean to capture (typicallyorigin/mainor a PR head, not a stale local branch). --modelmust match what agent-container actually puts on the wire. The container ignores the session's requested model and uses its own default (currentlyclaude-opus-4-8); if--modelnames anything else, the proxy writes the snapshot under the real model's dir while the script polls the wrong path and times out. The pure axis honors--modelvia theDRIFT_MODELenv var.- Windows/Git Bash: capture.sh disables MSYS path conversion for container-side docker args (
MSYS2_ARG_CONV_EXCL) and renders host mount paths viacygpath -m— required, or the-vmounts get rewritten toC:\Program Files\Git\...and the proxy crash-loops. Needsjqon PATH (not bundled with Git for Windows).
Known caveat: local build ≠ GHCR image
capture.sh runs docker build ./agent-container locally each invocation; it does not pull ghcr.io/skillfulagents/superagent-agent-container-base:<sha> (the image users actually run). The two are not byte-identical — different base-image digest resolution, npm install transitive-dep timing, build-cache state — so for a strict bit-for-bit integrity audit this skill is not the right tool.
For wire-prompt drift, this is acceptable: system / tools / messages content all originate in agent-container/src/ (system-prompt.md, MCP registrations, claude-code.ts tool wiring), which both the local build and CI bake in the same way. If a finding ever looks suspicious, re-check by pulling the GHCR image at the same SHA and inspecting out-of-band — but the common case doesn't require it.
CI only publishes the base image on push to main (see .github/workflows/build-container.yml), so for PR captures local build is the only option regardless.