Reading Claude Code's source code
2026On 31 March 2026, Anthropic shipped Claude Code 2.1.88 to npm with a source map inside. It held the full original source: 512,685 lines of TypeScript. I read it.
Every link below opens the exact lines in a public mirror (opens in a new tab).
Start inside
You type one line. The model gets a huge request: the tools, the rules, your setup, the whole conversation, and your line at the very end. Press the button below and fly through one.
What a harness is
A model on its own only writes text. To fix a bug it has to read files and run commands. The code that lets it do that is called a harness. Every harness starts as the same few lines:
messages = [your_message]
while True:
reply = model(messages, tools)
if not reply.tool_calls:
break # no tools asked for: done
messages += run_tools(reply.tool_calls)That's enough for a demo. On real work it breaks in five ways. It gets expensive, it's slow, it fills up, it's dangerous, and it drifts. And when the network or the model has a bad moment, it just falls over. Claude Code is a pile of answers to those.
One lap, every step
The loop is still in there. It's one file, query.ts (opens in a new tab), 1,729 lines. Here is one lap of it next to the simple version. The four steps are the same. Everything between them is scaffolding.
expensiveslowfills updangerousdriftsbreaks
Reading the file end to end, three things stand out.
The simple loop has one reason to go around again: a tool ran. query.ts has seven (opens in a new tab). It can resend the same request with a bigger output limit, resend after squashing the conversation, or go again because a hook said "not done yet". Each one is a continue with a named reason. There are ten named ways to stop.
It doesn't trust the API's own flag for "the model wants a tool". It watches the stream instead (opens in a new tab):
// Note: stop_reason === 'tool_use' is unreliable -- it's not always set correctly.
// Set during streaming whenever a tool_use block arrives — the sole
// loop-exit signal.And every tool call gets a result, even when things crash. The API rejects a conversation where a tool call has no answer. So on any error, or when you press Esc, the loop writes an error result for every open call (opens in a new tab) before it stops.
The other 511,000 lines are the answers.
It gets expensive
The simple loop sends the whole list every round. Late in a session, that's over 100K tokens for every call.
The API caches the start of a request, but only the part that didn't change. Change one byte near the top and everything after it is paid in full. So Claude Code orders the request by how often each part changes. What never changes goes first.
$0.04
this request
$0.31
with no cache
Keeping that order is a lot of small, strict rules:
- One marker. Each request carries exactly one cache marker (opens in a new tab), on the last message. Next turn, that end is the start of a longer request, so all of it reads back from cache.
- A split system prompt. The prompt is cut at a marker string. The part before it is cached across all users (opens in a new tab), not just for you. Your CLAUDE.md isn't in the system prompt at all. It goes in as a first, hidden user message (opens in a new tab), so the prompt above it stays the same for everyone.
- Frozen tools. Tool definitions are frozen for the session (opens in a new tab). One changed byte would bust ~11K tokens.
- Latched settings. Anything that could flip mid-session is read once and kept. Whether you get the one-hour cache is decided once, because a flip "would bust the server-side prompt cache (~20K tokens per flip)." (opens in a new tab)
They also measure it. Every request hashes the system prompt, the tool list and each tool's schema. When cache reads drop, a detector (opens in a new tab) diffs the hashes to name the culprit. Per the comment, 77% of tool-related breaks were a tool's description changing, not tools being added or removed.
Even side jobs ride the main cache. When the conversation gets summarized (more on that below), the summary call sends the same prompt, tools and messages as the main loop, so it all reads from cache. The code warns "DO NOT set maxOutputTokens here" (opens in a new tab): changing it would change the thinking settings, and that alone misses the cache.
It's slow
The simple loop waits for the whole reply before it runs a single tool.
Claude Code starts each tool the moment it streams in (opens in a new tab). Each tool says, for each call, whether it's safe to run next to others. A scheduler (opens in a new tab) starts a tool only if nothing is running, or if it and everything running are all safe:
private canExecuteTool(isConcurrencySafe: boolean): boolean {
const executingTools = this.tools.filter(t => t.status === 'executing')
return (
executingTools.length === 0 ||
(isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe))
)
}The answer depends on the input, not just the tool. Bash is safe to run alongside others only when the command is read-only (opens in a new tab). So ls runs with your reads and npm install runs alone. A tool that doesn't say gets "assume not safe" (opens in a new tab). At most 10 run at once (opens in a new tab), and results go back in the order the model asked (opens in a new tab), whatever order they finish in.
A failed shell command cancels the rest, because "mkdir fails → subsequent commands pointless." (opens in a new tab) A failed read doesn't: "one failure shouldn't nuke the rest."
The other trick is hiding work under the stream. A reply takes seconds to stream, so side work starts at the same time and is picked up after. Claude Code searches your memory files and skills for anything relevant, and asks Haiku for a one-line summary of the last tool batch: "haiku (~1s) resolved during model streaming (5-30s)" (opens in a new tab). The skill search used to block the turn. The comment that moved it says "97% of those calls found nothing in prod." (opens in a new tab) For shell commands, the safety check starts early (opens in a new tab), before anyone knows if it's needed.
Speed has a catch: a file can change between the read and the edit. So an edit fails if the file changed since the model last read it (opens in a new tab).
It fills up
The simple loop keeps every result forever. A 200K window runs out fast.
The system prompt tells the model: "The conversation has unlimited context through automatic summarization." (opens in a new tab) It doesn't. It has five layers, cheapest first:
- Cap each result. Every tool declares a size limit. Over it, the full output goes to a file, and the model gets the path and a 2 KB preview (opens in a new tab). The default is 50,000 characters (opens in a new tab). Read has no limit, because "persisting to a file the model reads back with Read is circular." (opens in a new tab)
- Cap each turn. Ten tools at 40K each still add 400K. So one turn's results share a 200,000-character budget (opens in a new tab), and the biggest go to disk first. Once a result's fate is picked, it's frozen for the conversation (opens in a new tab). The same bytes go out every turn, so the cache holds.
- Clear old results. If you've been gone an hour, the cache has expired anyway, so changing old messages costs nothing. All but the last 5 old results become "[Old tool result content cleared]" (opens in a new tab).
- Summarize. Near the limit, the conversation is replaced by a summary. The line sits at the window, minus 20,000 tokens for writing the summary ("p99.99 of compact summary output being 17,387 tokens"), minus a 13,000 buffer. On 200K, that's 167K (opens in a new tab).
- Restore. Right after, it puts back up to 5 recent files (opens in a new tab) (5,000 tokens each), the plan, any skills in use, and the tool and agent lists (opens in a new tab) the summary swallowed.
Some layers are switched on by remote flags, so not every session runs all five.
The summary is written by the same model, with a strict form. It must fill 9 sections (opens in a new tab): the request, key concepts, files and code, errors and fixes, problem solving, every message you sent, pending tasks, current work, and the next step. "Every message you sent" is the clever one. Your corrections survive the squash word for word. The model drafts first in an <analysis> block, which is cut out (opens in a new tab) before the summary goes in.
The prompt opens by shouting. The summary call carries the main loop's tools so it can share the cache, and newer models kept trying to use them. With only one turn, that turn is wasted. The comment counts it: "2.79% on 4.6 vs 0.01% on 4.5." (opens in a new tab)
const NO_TOOLS_PREAMBLE = `CRITICAL: Respond with TEXT ONLY. Do NOT call any tools.
- Do NOT use Read, Bash, Grep, Glob, Edit, Write, or ANY other tool.
- You already have all the context you need in the conversation above.
- Tool calls will be REJECTED and will waste your only turn — you will fail the task.After compacting, the model is told: "Resume directly — do not acknowledge the summary… as if the break never happened." (opens in a new tab)
Summarizing can fail too, when the conversation is too big to even summarize. The loop stops trying after 3 failures in a row (opens in a new tab). Before that limit, "1,279 sessions had 50+ consecutive failures (up to 3,272) in a single session, wasting ~250K API calls/day globally."
It's dangerous
The simple loop runs whatever the model asks for. That includes rm -rf and curl … | sh.
Claude Code parses each shell command into a tree, and only passes parts it knows. The file opens with (opens in a new tab): "FAIL-CLOSED: we never interpret structure we don't understand."
runs
Read-only command is allowed
My favourite trick it catches: in zsh, =curl runs /usr/bin/curl, so a rule that blocks curl never sees it (opens in a new tab).
The parser is one check of many. Every tool call falls through the same ladder, in this order (opens in a new tab). The first check with an answer decides.
The order is the design. Your rules and the protected files are checked before the mode. So "bypass permissions" still can't touch ~/.zshrc or .git/ without asking, and a deny rule like Bash(rm:*) still wins. The protected list (opens in a new tab) is files that "can be used for code execution or data exfiltration": shell configs, .gitconfig, .mcp.json, and the .git, .vscode, .idea and .claude folders.
Before the ladder, your hooks run. A hook is a script you set up that sees each call first. It can allow, deny, or rewrite the input. But a hook's "allow" only skips the prompt (opens in a new tab), not your rules:
// Hook allow skips the interactive prompt, but deny/ask rules still apply.
const ruleCheck = await checkRuleBasedPermissions(
tool,
hookInput,
toolUseContext,
)Matching rules against commands is harder than it looks. A past bug (opens in a new tab): timeout -k$(id) 10 ls was trimmed to ls, matched an allow rule Bash(ls:*), and bash ran $(id) first. Even "read-only" isn't simple. git status is safe, unless the same command first builds a fake git repo (opens in a new tab) with a hook inside, which git status then runs:
mkdir -p objects refs hooks && echo '#!/bin/bash\nmalicious' > hooks/pre-commit && touch HEAD && git statusThe newest layer is auto mode. Instead of asking you, a second model judges each risky call. The way it's built is careful:
- It sees what you typed and the tool calls. It never sees the model's own words, because "assistant text is model-authored and could be crafted to influence the classifier's decision." (opens in a new tab)
- It runs in two stages (opens in a new tab). First a 64-token answer, nudged with "Err on the side of blocking. <block> immediately." (opens in a new tab) Only if that says block does a second pass think it through, to cut false alarms.
- Cheap paths go first. Anything plain edit mode would allow, and a list of safe tools, skip the judge (opens in a new tab).
- If the judge is down, the call is denied (opens in a new tab). After 3 blocks in a row, or 20 in a session (opens in a new tab), it stops judging and asks you.
It drifts
On a long task the simple loop loses the thread. The plan is far back in the list. A file changed after it was read. Nobody tells the model.
Claude Code tells it, quietly. Every turn and every tool round (opens in a new tab), the harness checks what changed and adds notes after your message, each wrapped in <system-reminder> tags. The system prompt says they "are automatically added by the system" (opens in a new tab).
now add a test for it
6words typed
now add a test for it
src/login.ts was modified… Don't tell the user this
Consider using the TodoWrite tool… NEVER mention this reminder to the user
The date has changed… DO NOT mention this to the user
0words sent
There are 37 kinds of note. All of them are worked out at once, with a one-second budget (opens in a new tab). Any that fail or run late are simply skipped (opens in a new tab). A few of them:
- The to-do nudge fires after 10 turns without an update (opens in a new tab), and at most once every 10. It ends with (opens in a new tab): "Make sure that you NEVER mention this reminder to the user".
- If a file the model read changes on disk (opens in a new tab), the model gets the diff and "Don't tell the user this, since they are already aware." (opens in a new tab)
- Past midnight, the new date comes as a reminder (opens in a new tab) instead of changing the date at the top. Changing it would break the cache: "~920K effective tokens per midnight crossing per overnight session."
- Every file read ends with a malware reminder (opens in a new tab): explain malware, but "MUST refuse to improve or augment the code." Opus 4.6 is the one model that skips it.
Drift also happens at the end. The model says "done" too early. Your Stop hooks can say no. If a Stop hook exits with code 2 (opens in a new tab), its error text goes back to the model as "Stop hook feedback:", and the loop goes around again (opens in a new tab). That's how "don't stop until the tests pass" works.
The other cure is a clean start. The Agent tool runs a subagent, and a subagent is the same loop, called again (opens in a new tab), with a new list that holds only its task. The parent gets back only the final report, not the hundred file reads behind it. It can't start agents of its own or ask you questions (list (opens in a new tab)). The built-in search agent, Explore, is trimmed further: no CLAUDE.md and no git status, since it only reads. That "saves ~5-15 Gtok/week across 34M+ Explore spawns." (opens in a new tab)
It breaks
The simple loop assumes every call works. Real calls time out, hit overloaded servers, and run out of room mid-sentence.
Retries back off (opens in a new tab): half a second, doubling up to 32 seconds, plus up to 25% random jitter, so thousands of clients don't retry in step. Up to 10 retries.
const baseDelay = Math.min(
BASE_DELAY_MS * Math.pow(2, attempt - 1),
maxDelayMs,
)
const jitter = Math.random() * 0.25 * baseDelay
return baseDelay + jitterWhen the servers are overloaded, only calls you're waiting on retry. Titles, summaries and other background calls give up at once, because "during a capacity cascade each retry is 3-10× gateway amplification." (opens in a new tab) After 3 overloads in a row (opens in a new tab), the loop switches to a fallback model, if one is set. The half-streamed reply is thrown away (opens in a new tab), and any tool call in it gets an error result. You see one line: "Switched to … due to high demand for …" (opens in a new tab)
Running out of room is handled in steps. With a flag on, each request asks for only 8,000 output tokens (opens in a new tab): "p99 output = 4,911 tokens, so 32k/64k defaults over-reserve 8-16× slot capacity." If a reply hits the cap, the same request goes again at 64,000. If that hits too, the model is told to carry on, up to 3 times (opens in a new tab):
`Output token limit hit. Resume directly — no apology, no recap of what you were doing. ` +
`Pick up mid-thought if that is where the cut happened. Break remaining work into smaller pieces.`The error itself is held back (opens in a new tab) until recovery fails, so you never see a problem that fixed itself.
The model breaks things too. Every tool input is checked against its schema first, with a dry comment: "surprisingly, the model is not great at generating valid input" (opens in a new tab). A bad input doesn't crash anything. It goes back to the model as an error result, and the model fixes its own call next turn.
The best comments are about loops that fed themselves. Stop hooks are skipped after an API error, because the hook would block, the retry would fail, and around it goes: "error → hook blocking → retry → error → …" (opens in a new tab). Resetting one flag at the wrong spot once "caused an infinite loop: compact → still too long → error → stop hook blocking → compact → … burning thousands of API calls." (opens in a new tab)
And the fixes collide. Starting tools mid-stream is the speed fix. Retrying a broken stream without streaming is the reliability fix. Together, a tool started from the broken stream, then the retry asked for the same tool, and it ran twice (opens in a new tab). There's a flag to turn that retry off now.
A swear detector
One fun one before the end. Every prompt you type goes through one regex (opens in a new tab) and gets logged as is_negative.
logged as negative
What I took from it
The loop at the top of this page still runs Claude Code, subagents included. Everything else is a fix for one way those lines fail on real work. The fixes are ordered with care: cheap checks before expensive ones, rules before modes, small cuts before a full summary. And most of them carry a comment with a number from production. The model is the smallest part.
