Why Read Claude Code’s Source?
Most people who use Claude Code stay at the “I can use it” level.
That’s fine. But between “using it” and “understanding it” lies a massive gap. On this side of the gap, it’s a black box — you type something, it spits out code, and you have no idea what happened in between.
Cross that gap, and you’ll discover: Claude Code’s architecture is itself an excellent software engineering lesson.
This is the first post in a series. I’ll walk you from the entry file, tracing through to API calls and tool execution, covering the entire data flow end to end.
After reading this, when you dive into the source code yourself, you’ll be much more efficient — because you’ll already have the map.
Where to Start
When facing an unfamiliar codebase, most people’s first reaction is “where do I start?”
There’s actually a very simple answer: Start with the entry files and configuration files.
Entry files tell you how the program boots. Config files tell you what it depends on. Once you understand these two, you have the program’s “skeleton.” Everything else is just flesh on the bones.
Claude Code’s entry file is src/entrypoints/cli.tsx.
Layer 1: cli.tsx — Where Everything Begins
Open cli.tsx, and the first thing you see is a bunch of “polyfills” — before the program actually runs, it injects some things into the global environment:
// Top of cli.tsx
const feature = (_name: string) => false; // All feature flags return false
globalThis.MACRO = {
VERSION: "1.0.x",
// ... build-time constants
};
That feature = () => false line is crucial. Claude Code has tons of feature flags internally: COORDINATOR_MODE (multi-agent coordination), DAEMON (background daemon), VOICE_MODE (voice mode)… these are all Anthropic-internal features. In the open-source version, a single () => false shuts them all off.
This is a very practical engineering technique — using feature flags to control feature toggles. When shipping, you only need to change one line of code to strip out all internal features.
The actual startup logic is at the bottom of the file:
// cli.tsx:319
void main();
// cli.tsx:59
async function main(): Promise<void> {
const args = process.argv.slice(2);
// Fast path: --version outputs directly, zero module loading
if (args[0] === "--version") {
console.log(`${MACRO.VERSION} (Claude Code)`);
return;
}
// ...other fast paths (mostly skipped by feature flags)
// Key transition: dynamic import of main.tsx
const { main: cliMain } = await import("../main.jsx"); // L312
await cliMain(); // L314
}
Notice this uses dynamic import(), not static import. This means main.tsx and all its dependencies are only loaded when you actually need to start the full CLI. If you just run --version, the entire program exits with virtually zero overhead.
This is “fast path” thinking in software engineering: handle common simple cases as quickly as possible. Don’t make them go through the full complex flow.
Layer 2: main.tsx — Commander Parsing and Initialization
main.tsx is about 1,700 lines — the CLI’s definition center. It uses Commander.js to parse command-line arguments, then runs the initialization flow.
Core call chain:
main.tsx:585 export async function main()
↓
main.tsx:4504 program.parseAsync(process.argv) // Commander parsing
↓
main.tsx:1006 .action(async (prompt, options) => { ... }) // Main command handler
↓
main.tsx:916 await init() // Initialization
↓
main.tsx:3798 await launchRepl(...) // Launch interactive REPL
init() is defined in src/entrypoints/init.ts:57 and is memoized (runs only once). It does a lot, but each step has a clear purpose:
export const init = memoize(async (): Promise<void> => {
enableConfigs(); // Load config system
applySafeConfigEnvironmentVariables(); // Safe env vars
applyExtraCACertsFromConfig(); // TLS certificates
setupGracefulShutdown(); // Graceful shutdown
populateOAuthAccountInfoIfNeeded(); // OAuth info
configureGlobalMTLS(); // mTLS config
configureGlobalAgents(); // Proxy config
preconnectAnthropicApi(); // TCP pre-connect to API
});
That last line, preconnectAnthropicApi(), is interesting — before you’ve typed anything, the program is already establishing a TCP connection with the API server. This “pre-connect” strategy noticeably reduces the wait time for your first query.
After initialization, launchRepl() starts the interactive interface. It’s defined in src/replLauncher.tsx:12:
export async function launchRepl(root, appProps, replProps, renderAndRun) {
const { App } = await import('./components/App.js');
const { REPL } = await import('./screens/REPL.js');
await renderAndRun(root, <App {...appProps}>
<REPL {...replProps} />
</App>);
}
Yes, the terminal UI is built with React. It uses a library called Ink that brings React’s component model to the terminal. Every input box, every message, every loading animation you see in the terminal — they’re all React components.
Layer 3: REPL.tsx — The Entry Point for User Input
REPL.tsx is the core interactive component. When you type something in the terminal and press Enter, it triggers the onQuery callback here.
Key call chain:
User presses Enter
↓
REPL.tsx:2859 onQuery(newMessages, ...)
↓
REPL.tsx:2922 → onQueryImpl(...)
↓
REPL.tsx:2772 → await Promise.all([ // Load context in parallel
getSystemPrompt(...), // System prompt
getUserContext(), // CLAUDE.md user instructions
getSystemContext() // git status, environment info
])
↓
REPL.tsx:2797 → for await (event of query({...})) // Streaming query
↓
REPL.tsx:2806 onQueryEvent(event) // Handle each streaming event
Two design choices worth noting here:
First, context loading is parallelized. Promise.all fetches the system prompt, user context (your project’s CLAUDE.md file), and system context (git status, current date, etc.) simultaneously. Sequential loading would work too, but parallel is faster.
Second, the query is streamed. for await...of consumes an AsyncGenerator, processing each event the instant it arrives. This is why you see Claude’s response appearing character by character — it’s not waiting for the entire response to generate before displaying. It streams as it generates.
One easily overlooked detail: REPL.tsx calls query() directly, not through QueryEngine. QueryEngine (defined in QueryEngine.ts:186) is the orchestrator for SDK/headless mode. Both paths ultimately converge into the same query() function.
Layer 4: query.ts — The Heart of the Conversation Loop
query.ts is the core of the entire system — a while(true) loop.
// query.ts:219
export async function* query(params: QueryParams) {
yield* queryLoop(params, consumedCommandUuids);
}
// query.ts:241
async function* queryLoop(params, consumedCommandUuids) {
const deps = params.deps ?? productionDeps();
// Main loop: each iteration = one API call + tool execution
while (true) {
// 1. Call the model
for await (const message of deps.callModel({
messages, systemPrompt, tools, signal, ...
})) {
// Handle streaming events: text, thinking, tool_use...
}
// 2. Execute tool calls
const toolUpdates = runTools(toolUseBlocks, ...);
for await (const update of toolUpdates) {
yield update.message;
}
// 3. Decision: tool_use → continue, end_turn → return
}
}
The loop’s logic in one sentence: Call the API → get a response → if the response contains tool calls, execute the tools, stuff the results back in, and call the API again → until the model says “I’m done” (end_turn).
This is the so-called Agentic Loop — the core pattern of an Agent. Claude doesn’t just answer your question; it actively completes tasks by calling tools (reading files, writing files, executing commands). Each tool call is one iteration of the loop.
deps.callModel is a dependency injection point. In production, it points to queryModelWithStreaming (from src/services/api/claude.ts):
// query/deps.ts:33
export function productionDeps(): QueryDeps {
return {
callModel: queryModelWithStreaming, // → claude.ts
microcompact: microcompactMessages,
autocompact: autoCompactIfNeeded,
uuid: randomUUID,
};
}
The benefit of dependency injection here: during testing, you can swap in a mock API call without actually hitting the Claude API. This is standard practice for writing testable code.
Layer 5: claude.ts — Where the Request Actually Goes Out
services/api/claude.ts is the bottom layer, directly calling the Anthropic SDK.
// claude.ts:753
export async function* queryModelWithStreaming({ messages, systemPrompt, ... }) {
yield* queryModel(messages, systemPrompt, ...);
}
// claude.ts:1018
async function* queryModel(messages, systemPrompt, ...) {
// ... build request params, compute beta headers, filter tools ...
// The actual API call
const result = await anthropic.beta.messages
.create(
{ ...params, stream: true },
{ signal, headers }
)
.withResponse();
// Iterate over streaming events
for await (const event of stream) {
// message_start, content_block_delta, tool_use, message_stop...
// Build AssistantMessage and yield back to query.ts
}
}
That stream: true is the key to streaming. It makes the API push tokens back one at a time instead of waiting until all content is generated.
Claude Code also supports multiple providers: Anthropic Direct, AWS Bedrock, Google Vertex, and Azure. You can switch backends through configuration.
The Complete Data Flow: Full Picture of a Single Interaction
Connecting all five layers, here’s what a complete interaction looks like:
User types in terminal: "Write me a sorting function"
│
│ cli.tsx:319 void main()
│ cli.tsx:312 Dynamic import main.tsx
│ cli.tsx:314 cliMain()
▼
main.tsx:585 main() starts
main.tsx:916 init() → load config, auth, pre-connect API
main.tsx:3798 launchRepl() → render React/Ink component tree
│
▼ User input → captured by React component
REPL.tsx:2859 onQuery()
REPL.tsx:2772 Parallel load: system prompt + CLAUDE.md + git status
REPL.tsx:2797 for await (event of query({...}))
│
▼
query.ts:307 while (true) { ← Main loop begins
query.ts:659 Call API (streaming)
│ ↓
claude.ts:1823 anthropic.beta.messages.create({stream: true})
claude.ts:1880+ Iterate streaming events → yield back to query.ts
│ ↑
query.ts:1385 If tool_use → execute tools (read files, write files, etc.)
query.ts Append tool results to messages → continue (call API again)
query.ts If end_turn → return (exit loop)
│
▼
REPL.tsx:2806 onQueryEvent(event) → update terminal UI
REPL.tsx:2851 resetLoadingState() → restore input state
Tech Stack: Every Choice Has a Reason
| Technology | Why |
|---|---|
| Bun | Faster than Node.js, native TSX/ESM support, extremely fast startup |
| React + Ink | React’s component model for terminal UI — reuses frontend devs’ mental model |
| React Compiler | Automatic memoization, reduces unnecessary terminal re-renders |
| Commander.js | Mature CLI argument parsing framework |
| Anthropic SDK | Official SDK with native streaming support |
| Zustand-style Store | Lightweight state management, ideal for single-process CLI |
| MCP Protocol | Anthropic’s tool extension protocol for plugging in external tool servers |
The most interesting choice is React + Ink. Using React for terminal UI sounds weird, but think about it — terminal UI is essentially a component tree too, requiring state management and reactive updates. React’s declarative programming model applies perfectly here.
Claude Code even forked the Ink framework, building their own reconciler and virtual list implementation. This shows they have high performance requirements for terminal rendering.
The Tool System: The Agent’s “Hands”
Claude Code’s core capability comes from the tool system. Each tool is a function:
interface Tool {
name: string
description: string
inputSchema: JSONSchema // Parameter definitions
call(input, context): Result // Execution logic
}
Core tools include:
- BashTool — execute shell commands
- FileReadTool / FileEditTool / FileWriteTool — file operations
- GlobTool / GrepTool — search
- AgentTool — spawn sub-agents (Agents can spawn Agents!)
- WebFetchTool / WebSearchTool — network operations
When Claude’s response contains tool_use, the main loop in query.ts executes the corresponding tool, appends the result as a tool_result message to conversation history, then calls the API again. This “call-execute-feedback” loop is the secret behind how Agents can autonomously complete complex tasks.
A Methodology for Reading Source Code
Finally, let me share my approach to reading this codebase.
Step 1: Read the entry files and config files. This gives you the program’s skeleton — where it starts, what it depends on, how it builds.
Step 2: Trace the main data flow. Find where user input enters the system, then trace it through every module until the final output. Don’t get lost in details early on. Walk the main path first.
Step 3: Dive into key modules. Once you’ve traced the main path, pick the modules you’re interested in and go deep. The tool system, permission system, auto-compaction mechanism, etc.
This is the same approach as reading a book: scan the table of contents first, then read through once, then study the key chapters closely. Many people read source code by grinding line by line from the start. After half a day, they still don’t know what they’re looking at. That’s because they never built the big picture first.
Claude Code’s codebase has about 1,341 TypeScript errors (because it’s decompiled), but that doesn’t affect Bun execution at all. This is also an interesting engineering reality: The type system is a tool, not the goal. Running is what matters.
Next up, I’ll deep-dive into the tool system and permission mechanism — how Claude Code decides whether it should execute a dangerous operation.
Stay tuned.