Reading Claude Code's source code

2026

On 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.

A short Claude Code session played as a machine, one dot per token. Step inside and watch the whole tower go to the model lap after lap, until compaction crushes it.

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)
youmodeltoolsdone
The simple loop. Every lap, the whole list goes back to the model, and each tool makes it longer. After three tools the model asks for none, and the loop stops.

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.

simple loopClaude CodeSend listReplyRun toolsAdd resultsCap big resultsClear old resultsSummarize if fullFixed parts firstSend the listRetry, or switch modelModel repliesCut off? Ask to resumeStart tools mid-replyCheck the inputRun your hooksRules, mode, judgeRun the toolsBig output to diskAdd the resultsAdd reminders

expensiveslowfills updangerousdriftsbreaks

One lap of the simple loop and one lap of Claude Code's, step by step, in the order query.ts runs them. The four outlined steps are the same in both. Each coloured step answers one of the problems below.

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.

toolssystem promptyour setupCLAUDE.mdconversationnew messagepaid fromhere

$0.04

this request

$0.31

with no cache

One request in the order it's sent. Pick what changed: from that block down, everything is paid again. Above the dashed line is the same for every user. Sizes are rough, prices are Opus 4.6.

Keeping that order is a lot of small, strict rules:

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))
  )
}
1 s2 s3 s4 sWait for the whole replythe model's replyStart each tool as it streams inthe model's reply
tool that readstool that changes something
The same five tool calls, twice. Claude Code starts each one the moment its call finishes streaming in, and reads run side by side. Made-up timings, real rules.

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:

  1. 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)
  2. 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.
  3. 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).
  4. 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).
  5. 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.

200K167Kdisk
The 200K window, turn by turn. Big outputs go to disk. Past 167K, the conversation is squashed into a short summary plus up to 5 recently read files. The session is made up; the thresholds are from the source.

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."

grep -n TODO app.ts|wc -l

runs

Read-only command is allowed

Claude Code splits a command into parts and checks each one. One part it can't vouch for stops the whole thing, and it asks you.

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.

Bash git statusdefault modeWhole tool denied?Whole tool must ask?The tool's own checkallowedProtected file?Bypass mode on?Whole tool allowed?Auto mode judgeAsk you
One tool call falling through the permission checks, in the order the code runs them. Deny rules and protected files come before the mode, so even bypass mode can't skip them.

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 status

The newest layer is auto mode. Instead of asking you, a second model judges each risky call. The way it's built is careful:

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).

You type 6 words. The model gets 134, with three notes you never see, each telling it to keep quiet.

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:

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 + jitter

When 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

Type a prompt or pick one. "fucking" alone passes. "fucking broken" doesn't. Neither does "bullshit".

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.