When you type a prompt into Claude Code, you trigger a single API call. At least, that’s what it looks like. Behind the scenes, Claude Code is anything but quiet. The agent, its subagents, and the CLI itself generate a constant stream of background API traffic: file searches, session summaries, title generation, context compaction. All calls you never asked for and never see.
After profiling 160 sessions and 6,767 requests through ai-agent-profiler, here is where the money actually goes (hover rows for detail):
| Kind | % of cost | % of tokens | Triggered by |
|---|---|---|---|
main | 42% | 38% | Your prompt |
search | 31% | 35% | File-search subagent |
compact | 12% | 14% | Context compaction |
recap | 6% | 5% | Idle-time summary |
subagent | 4% | 4% | Other subagents |
title | 2% | 1% | Session title gen |
webfetch | 2% | 2% | Web fetch summary |
guide | 1% | 1% | Claude guide agent |
More than half your API bill comes from calls you never made. search alone rivals your actual turns. This is the hidden machinery of a coding agent. Understanding it is the first step to controlling it.
The real message loop
When you hit Enter in Claude Code, the quiet period ends immediately:
A single user turn typically generates 4-14 API calls, only one of which is your actual prompt. The rest are automated by the agent.
The tool loop
But that is not the whole story. When the model decides to use a tool like Read, Grep, or Bash, each tool call triggers a new API request. The cycle goes:
A task like “find where authentication logic lives and explain it” can chain 6-8 tool calls in sequence: search for the module, read the file, read a related config file, read the middleware, then compose the answer. Each tool round is a paid API request.
This loop is not background subagent traffic. The tool loop is the model working on your request, going back and forth between issuing a tool use and consuming the tool result. Subagents are separate processes the CLI spawns for delegated tasks, and they run their own independent tool loops.
From the API’s perspective the two look identical. Both send the full conversation prefix each turn with a new tool result appended. You can only tell them apart by classifying who initiated the request: the main agent working on your prompt, or a subagent spawned by the CLI.
The 9 request kinds
ai-agent-profiler tags every API call with a kind, classifying who made it and why, so you can separate “cost I asked for” from “overhead the tool spent on my behalf.”
| Kind | Model | User? | Description |
|---|---|---|---|
main | primary | yes | Your interactive turns, the core conversation loop |
search | primary | no | File-search / Explore subagent navigating the codebase. Usually the largest non-user bucket. |
compact | primary | no | Context compaction, full-history summarisation to shrink the context window |
recap | primary | no | Mid-session catch-up summary injected when you return to an idle session (“The user stepped away…“) |
subagent | primary | no | A subagent we couldn’t further identify (fallback) |
title | small/fast | no | Session-title generation (3-7 words), a one-shot Haiku call |
webfetch | primary | no | Subagent summarising fetched web-page content |
guide | primary | no | Claude guide agent answering “how do I…” about Claude Code/SDK/API |
quota | — | no | Usage-limit check (minimal cost) |
How the classifier works
Detection uses two independent signal sources, deliberately kept separate to avoid false positives.
Signal 1: The system prompt. Claude Code prepends an x-anthropic-billing-header system block to every call. Subagents set cc_is_subagent=true and carry a specialist identity in the same block:
x-anthropic-billing-header: cc_version=2.1.209; cc_entrypoint=cli; cc_is_subagent=true;
You are a file search specialist for Claude Code… ← classified as "search"
OpenCode agents carry their identity in per-agent system prompts (prompt/title.txt, prompt/compaction.txt, prompt/explore.txt). The classifier is provider-agnostic. It checks both Anthropic’s top-level system field and OpenAI-style role:"system" messages, accumulating both before classification.
Signal 2: The last message text. Some calls run on the main model with a normal system prompt. Nothing in the system block distinguishes them from a user turn. recap, some compact calls, and webfetch are only identifiable by their final instruction:
| Kind | Marker in last message |
|---|---|
recap | "The user stepped away" + "recap" |
compact | "summary of the conversation" / "create a detailed summary" |
webfetch | (subagent +) "Web page content:" |
title (OpenCode) | "Generate a title for this conversation:" |
False positive traps
Two subtle traps were hit and fixed during development:
-
Match the last message only, not the whole transcript. A prior summary gets echoed into later requests’ context. Matching anywhere in history would mislabel every subsequent normal turn as
compact. -
Read text blocks only, not tool results.
tool_resultcontent gets JSON-stringified, so captured command output mentioning “summary of the conversation” would leak in. The classifier reads onlytype: "text"blocks, so tool output can’t trigger a match.
Both effects are real: an earlier system-prompt-only classifier folded all recaps into main and produced ~100 false compact hits. The current rules reduce that to zero.
Where does the money actually go?
58% of your API bill comes from calls you never made. search rivals your actual turns in cost. The agent needs this, but it is opaque by default. The classifier makes it visible.
How Claude’s cache actually works
Claude’s cache is explicit: the client must opt in by placing cache_control markers in the request body. Without them, nothing is cached.
{
"system": [
{"type": "text", "text": "Short preamble"},
{"type": "text", "text": "Main instructions...",
"cache_control": {"type": "ephemeral"}}
],
"tools": [...],
"messages": [
...,
{"role": "user", "content": [
{"type": "tool_result", "content": "...",
"cache_control": {"type": "ephemeral"}}
]}
]
}
Each marker is a breakpoint. The API caches everything from position 0 up to each breakpoint, keyed by the exact byte sequence of the serialized request (system, then tools, then messages). Any change to any byte before a breakpoint invalidates that entry: reordering, whitespace, a single character in a tool description, or editing a past message.
Where Claude Code places breakpoints
Claude Code places exactly 3 breakpoints (verified across all captured Bedrock traces):
system[1]: after a short preamblesystem[2]: after the full system prompt- Last user message: on the most recent tool_result block
The economics
| Token type | Cost per MTok (Opus 4.x) | Ratio |
|---|---|---|
| Cache read | $0.50 | 0.1x input |
| Cache write (5 min) | $6.25 | 1.25x input |
| Cache write (1 hour) | $10.00 | 2x input |
| Regular input | $5.00 | 1x |
| Output | $25.00 | 5x |
A cache write is 12.5x a cache read. Converting reads to writes by editing a cached prefix hurts: 100K tokens of cache-edit converts $0.05 worth of reads into $0.575 of writes. This one fact defeats nearly every prompt-optimization strategy.
The minimum cacheable block is ~2048 tokens. Content smaller than this is billed as regular input even with cache_control markers present. Claude Code’s system prompt alone exceeds this threshold many times over.
The 5-minute TTL and the 1-hour upgrade
Cache entries expire after inactivity. Anthropic states “at least 5 minutes” for the ephemeral type. Claude Code always sends the 5-minute TTL by default; every captured cache_control marker is bare {"type":"ephemeral"} with no ttl field.
ai-agent-profiler can upgrade these to 1 hour:
aap run claude --cache-1h
The proxy rewrites every ephemeral cache_control marker to { type: "ephemeral", ttl: "1h" } before forwarding upstream. This is a pure cache-lifecycle operation: no prompt shrinking, no editing. One-time write penalty, fully reproducible.
| TTL | Write cost | Read cost | Survives idle gaps of |
|---|---|---|---|
| 5 min (default) | 1.25x input ($6.25) | 0.1x input ($0.50) | < 5 min |
| 1 hour (upgraded) | 2x input ($10.00) | 0.1x input ($0.50) | 5 min – 1 hour |
The 1h upgrade costs 2x on writes but survives 12x longer. It wins when idle gaps often fall between 5 minutes and 1 hour, exactly the pattern of real coding sessions where you read docs, think, or switch contexts between turns.
Keep-alive pings
Preventing cache expiry during idle by replaying the last request with max_tokens: 1:
| Cache TTL | Ping interval | Pings to break even |
|---|---|---|
| 5 min | 4 min | ~12.5 |
| 1 hour | 48 min | ~20 |
On the 5-minute cache, keep-alive barely breaks even (~12.5 pings in 56 min ≈ one rebuild). On the 1-hour cache, it becomes dramatically cheaper: 20 pings at ~$0.10 each vs ~$1.25 per rebuild. Break-even ≈ 12 hours of idle.
However, keep-alive has caveats: it bets the user returns (wasted if they don’t), generates phantom API calls the user never issued (real cost, real quota usage), and breaks the proxy’s transparency (no longer a passive pipe). It’s designed but kept as a future opt-in, gated behind the 1h cache.
Cross-session cache warming: why benchmarks lied
The cache key is the byte prefix, not the session ID. The cache persists across sessions: if two requests from different sessions share the same prefix, the second one gets a cache read instead of a write.
This means:
- Repeated baseline runs get free reads warmed by earlier baselines, making them look artificially cheap.
- First optimized runs start cold (different prefix), paying full write cost.
- Early benchmarks were impressive and wrong. The cost model initially did not count cache-write tokens at all, so it under-reported exactly the cost that prefix-editing creates.
The fix: fair comparisons must warm both arms equally (discard the first run of each), or compare cold-vs-cold after waiting for TTL expiry. The profiler’s benchmark runner now requires this.
Why most optimization strategies fail
The optimization layer attempted to shrink prompts by pruning stale results, collapsing the system prompt, removing unused tools, compacting history, and reordering content. Each strategy reduces prompt size but changes bytes before a cache boundary.
On Anthropic/Bedrock, this turns cheap cache reads into expensive cache writes. On DeepSeek/OpenAI, it re-bills the downstream tail at the miss rate (~10x the hit rate). Either way: shrinking a prompt that was already caching cheaply trades pennies of saved reads for dollars of new writes.
The math on this is harsh. For an edit at position P affecting D downstream tokens:
savings = R removed tokens × cheap read rate
cost = D downstream tokens × (write rate − read rate)
net = savings − cost
On Anthropic with a 5m cache, you’d need to remove roughly 90% of the downstream tail to break even. In practice, coding agent prefixes are massive and the budget for “edits that save” is close to zero.
What actually survives the purge
The layer keeps only the edits that don’t disturb the cached prefix:
stripTools: remove tool defs from turn 1 (stable prefix from the start)stableTruncate: deterministic head+tail truncation, re-applied identically to every matching result on every turnshapeTestOutput: strip passing-test spam and ANSI from test output, deterministicallyupgradeCacheTtl: 5m to 1h cache lifetime extension (no prompt editing)
Everything that edits the middle of the prompt, like pruneStale, collapseSystem, pruneUnusedTools, frozenCompact, is disabled automatically per provider via the cache-family mapping. The default posture is: profile, don’t rewrite.
The double-write trap with optimizeOnCold
A natural idea: “Once the cache has expired, the next request pays a full write anyway, so apply all optimizations for that one turn to shrink the write.” We implemented this. It does not work.
Cold turn N: collapse system S→S', prune history M→M', write S'+T+M'
Turn N+1: layer reverts to steady-state, edits are OFF
client re-sends pristine S+T+M (never knew we edited it)
cache holds S', request sends S → divergence at byte 0
ENTIRE prefix rebuilds — second full write
Net result: two writes instead of one. The only way to avoid this is to apply the same edit identically on every subsequent turn, which rules out one-shot cold rewrites entirely. optimizeOnCold is left in the code, configurable, default OFF.
Two cache families, two failure modes
| Family | Providers | Mechanism | Consequence of prefix edit |
|---|---|---|---|
explicit | Anthropic, Bedrock | Client-placed cache_control breakpoints | Cheap reads become expensive writes (~12.5x) |
prefix | DeepSeek, OpenAI-compatible | Automatic longest-common token prefix | Downstream tail re-billed at miss rate (~10x hit) |
Same conclusion: editing the cached prefix loses. The profiler encodes this by mapping each provider to its cache family, then disabling the prefix-editing strategies for that family automatically. No manual tuning needed.
What this means for you
If you’re using a coding agent with Claude, here’s what the data says:
-
You are paying 2x what you think. More than half your bill is background traffic: searches, compactions, recaps, title generation.
-
Cache beats optimization. On Anthropic’s explicit cache, the native caching is already close to optimal. Attempts to shrink prompts by editing history backfire because the cache-read penalty is so high. The biggest lever is cache lifetime, not prompt size.
-
1h cache is the most cost-effective single change.
aap run claude --cache-1hupgrades Claude’s default 5-minute TTL to 1 hour. One-time 2x write cost, no per-turn edits, fully reproducible. Wins whenever idle gaps exceed 5 minutes. -
Measure, don’t assume. The cache key is the byte prefix, not the session ID. Cross-session warming makes naive benchmarks lie. Fair comparisons require warm-up runs or cold-vs-cold methodology. The profiler’s benchmark runner enforces this.
-
Profile first, rewrite later. Most optimization strategies are disabled by default because the cache math doesn’t favor them. The profiler’s default posture is to make costs visible and let you decide. When rewrite strategies do help, they must be deterministic, reproducible on every turn, and never touch the cached prefix.
All findings are from 160 real coding sessions tracked through ai-agent-profiler. The proxy is open-source (MIT). Run it against your own sessions to see your actual cost breakdown.