Chapter 02: Startup and Bootstrap
What You'll Learn
By the end of this chapter, you will be able to:
- Trace the complete startup path from
claudeinvocation to the first rendered REPL prompt, identifying each file that executes along the way - Explain why
src/entrypoints/init.tsstructures initialization into distinct phases separated by the trust dialog, and what would break if that boundary were removed - Distinguish
src/bootstrap/state.ts(process-lifetime global singleton) fromAppStateinsrc/state/(React session state), and know which to consult for which kind of data - Read any tool implementation in
src/tools/with full understanding of theToolUseContextobject it receives, knowing exactly how that object was assembled
The Startup Architecture in One Sentence
Claude Code's startup sequence is a deliberate cascade of lazy imports, parallel I/O prefetches, and deferred module loads, all orchestrated to reach a first-rendered REPL prompt as quickly as possible while ensuring that security-sensitive operations never run before the user has granted trust.
Startup Flow Overview
The diagram below maps the complete control flow from binary invocation to live REPL. Every box corresponds to a real function call in the codebase.
The Entry Point: src/entrypoints/cli.tsx
cli.tsx is the actual binary entry point — the file Bun evaluates first when you run claude. Its single architectural responsibility is deciding which code path to activate, while importing as little as possible to do so.
The file begins with three unconditional top-level side-effects before the main function even runs:
// src/entrypoints/cli.tsx:5
process.env.COREPACK_ENABLE_AUTO_PIN = '0';This disables corepack auto-pinning regardless of which path is taken. It must be top-level because corepack could interfere before any real work begins.
// src/entrypoints/cli.tsx:9-14
if (process.env.CLAUDE_CODE_REMOTE === 'true') {
const existing = process.env.NODE_OPTIONS || '';
process.env.NODE_OPTIONS = existing
? `${existing} --max-old-space-size=8192`
: '--max-old-space-size=8192';
}When running in Cloud Code Remote (container mode), the heap limit is raised to 8 GB before any module loads. This is an environment-configuration step that cannot be deferred.
The Version Fast-Path
The first thing main() checks is whether the only argument is a version flag:
// src/entrypoints/cli.tsx:37-42
if (
args.length === 1 &&
(args[0] === '--version' || args[0] === '-v' || args[0] === '-V')
) {
console.log(`${MACRO.VERSION} (Claude Code)`);
return;
}MACRO.VERSION is a build-time constant inlined by Bun's bundler. Executing claude --version evaluates exactly one file and performs zero dynamic imports. This is a conscious design choice: startup time for version queries is measured in single-digit milliseconds.
The Startup Profiler
For every non-version invocation, the first dynamic import loads the startup profiler:
// src/entrypoints/cli.tsx:45-48
const { profileCheckpoint } = await import('../utils/startupProfiler.js');
profileCheckpoint('cli_entry');profileCheckpoint records a high-resolution timestamp against a named label. These checkpoints are what feed the CLAUDE_CODE_STARTUP_DEBUG diagnostic output. The profiler itself is loaded with a dynamic import — even it is not bundled into the entry file — because any static import at the top of cli.tsx would add module evaluation cost to every fast-path invocation including --version.
Feature-Flagged Fast Paths
After the profiler, cli.tsx checks a series of feature-flagged special modes. The pattern is uniform: check feature('FLAG_NAME') (compile-time DCE gate), check the CLI argument, then dynamically import only the specific module needed for that path and return early.
The gates in order are: DUMP_SYSTEM_PROMPT (internal evaluation tool), Chrome extension MCP server (--claude-in-chrome-mcp), Chrome native host (--chrome-native-host), CHICAGO_MCP (computer use), DAEMON daemon worker (--daemon-worker=<kind>), BRIDGE_MODE remote control (remote-control, rc, remote, sync, bridge), DAEMON supervisor (daemon subcommand), BG_SESSIONS session management (ps, logs, attach, kill, --bg), TEMPLATES template jobs (new, list, reply), BYOC_ENVIRONMENT_RUNNER, SELF_HOSTED_RUNNER, and the --worktree --tmux fast-path.
Each gate uses the same structural discipline: await import(...) only runs if the flag matched. Nothing from these modules enters the module cache for a normal claude invocation.
Entering the Main CLI
After exhausting all special cases, cli.tsx activates the early input capture buffer (so keystrokes typed before the REPL is ready are not lost) and loads the full CLI:
// src/entrypoints/cli.tsx:289-298
const { startCapturingEarlyInput } = await import('../utils/earlyInput.js');
startCapturingEarlyInput();
profileCheckpoint('cli_before_main_import');
const { main: cliMain } = await import('../main.js');
profileCheckpoint('cli_after_main_import');
await cliMain();
profileCheckpoint('cli_after_main_complete');The profileCheckpoint calls bracketing the main.js import tell the startup profiler exactly how long module evaluation of main.tsx and its static import tree costs. On a warm filesystem this is typically 100–200 ms — the cost of evaluating React, Commander, lodash, and the other dependencies that main.tsx imports statically.
Side-Effects at the Top of src/main.tsx
main.tsx is the largest file in the codebase. Before a single import statement resolves, three side-effects run at the module's top level:
// src/main.tsx:1-20
// These side-effects must run before all other imports:
// 1. profileCheckpoint marks entry before heavy module evaluation begins
// 2. startMdmRawRead fires MDM subprocesses (plutil/reg query) in parallel
// with the remaining ~135ms of imports below
// 3. startKeychainPrefetch fires both macOS keychain reads (OAuth + legacy
// API key) in parallel — otherwise read sequentially (~65ms on every
// macOS startup)
import { profileCheckpoint } from './utils/startupProfiler.js';
profileCheckpoint('main_tsx_entry');
import { startMdmRawRead } from './utils/settings/mdm/rawRead.js';
startMdmRawRead();
import { startKeychainPrefetch } from './utils/secureStorage/keychainPrefetch.js';
startKeychainPrefetch();The ordering here is architecturally significant. JavaScript module evaluation is synchronous within a module, but the void-like fire-and-forget calls startMdmRawRead() and startKeychainPrefetch() launch subprocess reads and keychain I/O as background async operations. While the remaining ~135 ms of static imports below them evaluate synchronously, the MDM subprocess (plutil on macOS, reg query on Windows) and keychain reads run concurrently in the background. By the time main() needs those values, they are typically already resolved. This pattern of firing I/O before blocking synchronous work is the most impactful startup optimization in the codebase.
Initialization: src/entrypoints/init.ts
init.ts exports a single memoized function:
// src/entrypoints/init.ts:57
export const init = memoize(async (): Promise<void> => {The memoize wrapper (from lodash-es) ensures that the second call to init() returns the already-resolved promise immediately. This property matters because init() is called in multiple code paths — the interactive REPL path, headless --print path, MCP server path — and the guarantee prevents double-initialization.
Phase 1: Pre-Trust Operations
The first block of init() performs operations that are safe to run before the user has granted trust to the current directory:
// src/entrypoints/init.ts:63-159
enableConfigs() // Load and validate settings files
applySafeConfigEnvironmentVariables() // Apply env vars from settings (safe subset only)
applyExtraCACertsFromConfig() // Configure TLS CA certs before any TLS handshake
setupGracefulShutdown() // Register cleanup handlers for SIGINT/SIGTERM
void populateOAuthAccountInfoIfNeeded() // Async: cache OAuth account info
void initJetBrainsDetection() // Async: detect JetBrains IDE for UI adaptation
void detectCurrentRepository() // Async: detect git repository info
if (isEligibleForRemoteManagedSettings()) {
initializeRemoteManagedSettingsLoadingPromise()
}
if (isPolicyLimitsEligible()) {
initializePolicyLimitsLoadingPromise()
}
configureGlobalMTLS() // Configure mutual TLS if enterprise settings are present
configureGlobalAgents() // Configure proxy agents
preconnectAnthropicApi() // Pre-warm TCP+TLS connection to api.anthropic.comThe key constraint here is the word "safe." applySafeConfigEnvironmentVariables() applies only environment variables that cannot grant escalated capabilities — for example, proxy settings and certificate paths. The full applyConfigEnvironmentVariables() call, which applies settings that could affect security decisions (like permission allowlists), is held back until after trust is established.
applyExtraCACertsFromConfig() must happen before the first TLS connection because Bun caches the TLS certificate store at boot via BoringSSL. If custom CA certificates arrive after the first TLS handshake, they are too late.
preconnectAnthropicApi() fires a TCP+TLS handshake to api.anthropic.com and lets it run in the background. The 100–200 ms connection setup time then overlaps with the Commander.js argument parsing and setup screens that run next. By the time the first API request fires, the connection is already warm.
The three void prefetch calls (populateOAuthAccountInfoIfNeeded, initJetBrainsDetection, detectCurrentRepository) are fire-and-forget. They populate caches but are not awaited. If they have not completed by the time their results are needed, the callers fall back to slower synchronous paths.
Why the Two-Phase Boundary Exists
Claude Code displays a trust dialog when you run it in a directory for the first time. The dialog asks the user to confirm that they trust the code in the current directory. This dialog is the security boundary.
Git hooks, core.fsmonitor, diff.external, and similar git configuration entries can execute arbitrary code when git commands run. If Claude Code ran git status or scanned the git repository before the user clicked "Trust," an attacker who placed a malicious repository on disk could execute code simply by having a user run claude in that directory.
prefetchSystemContextIfSafe() — which triggers getSystemContext() and therefore git commands — is explicitly gated:
// src/main.tsx:360-380
function prefetchSystemContextIfSafe(): void {
const isNonInteractiveSession = getIsNonInteractiveSession();
if (isNonInteractiveSession) {
// --print mode: trust is implicit, proceed
void getSystemContext();
return;
}
// Interactive mode: only prefetch if trust already established
const hasTrust = checkHasTrustDialogAccepted();
if (hasTrust) {
void getSystemContext();
}
// If no trust yet, skip — wait for trust dialog to complete first
}Similarly, applyConfigEnvironmentVariables() (the full version) is called from initializeTelemetryAfterTrust(), which is invoked from main.tsx only after the trust dialog has returned successfully.
Phase 2: Post-Trust Operations
After the trust dialog, main.tsx calls:
initializeTelemetryAfterTrust() // Full env var application + telemetry setup
applyConfigEnvironmentVariables() // The full set, including security-relevant varsinitializeTelemetryAfterTrust() handles an important complication: for users eligible for remote-managed settings (enterprise or Claude.ai accounts), telemetry configuration may arrive via a remote settings fetch. The function waits for that fetch to complete before initializing the OpenTelemetry meters, so the correct reporting endpoints are used. The ~400 KB OpenTelemetry and protobuf modules are loaded lazily inside doInitializeTelemetry() — they do not exist in the module cache for sessions that never initialize telemetry.
Global State: src/bootstrap/state.ts
state.ts is the process-lifetime global singleton. The file's comment says it clearly:
// src/bootstrap/state.ts:31
// DO NOT ADD MORE STATE HERE - BE JUDICIOUS WITH GLOBAL STATEThis comment is not decorative. The file has grown to over 240 state fields (the State type definition spans from line 45 to well past line 250) and over 80 getter/setter pair exports. Adding state here is a significant decision because it makes parallel sub-agent scenarios harder to reason about and increases the cognitive load for anyone reading the codebase.
What Lives Here
Scanning the State type reveals several categories of data:
Session identity: sessionId, parentSessionId, originalCwd, projectRoot, cwd — these are set once at startup and consulted throughout the session.
Cost and timing accumulators: totalCostUSD, totalAPIDuration, totalAPIDurationWithoutRetries, totalToolDuration, turnHookDurationMs, turnToolDurationMs, turnToolCount, totalLinesAdded, totalLinesRemoved — these accumulate throughout the session for the status bar and exit summary.
Model configuration: mainLoopModelOverride, initialMainLoopModel, modelStrings, modelUsage — the resolved model settings for the session and per-model token/cost tracking.
Telemetry infrastructure: meter, sessionCounter, locCounter, prCounter, commitCounter, costCounter, tokenCounter, loggerProvider, eventLogger, meterProvider, tracerProvider — the OpenTelemetry handles for emitting metrics and logs.
Session flags: isInteractive, sessionBypassPermissionsMode, sessionTrustAccepted, sessionPersistenceDisabled, hasExitedPlanMode, kairosActive — boolean gates that affect behavior throughout the session.
Infrastructure caches: agentColorMap, lastAPIRequest, registeredHooks, invokedSkills, systemPromptSectionCache — data that must survive React re-renders and is accessed from multiple parts of the codebase.
bootstrap/state.ts vs AppState
Understanding the distinction between these two stores is prerequisite knowledge for reading the codebase clearly.
src/bootstrap/state.ts is a plain module-level object. It is initialized before React mounts, persists across hypothetical session resets, and is accessible from any module without passing a context object. Its setters are direct assignments wrapped in functions. It has no reactivity — nothing re-renders when a value changes.
src/state/AppStateStore.ts (accessed via getAppState/setAppState in ToolUseContext) is a Zustand-style store that drives React rendering. When a tool calls setAppState(prev => ({ ...prev, isLoading: false })), React schedules a re-render. The REPL screen subscribes to AppState via React hooks. It holds data that the UI needs to display: the current message list, loading indicators, active tool calls, permission dialog state, and so on.
The practical rule: if changing a value should update the terminal display immediately, it belongs in AppState. If it is process-wide infrastructure or a stat accumulator that is read at shutdown, it belongs in bootstrap/state.ts.
The Mode Tree: How main.tsx Branches
After init() completes, main.tsx's Commander.js action handler examines the parsed arguments and branches into one of several execution modes. The interactive REPL path is the common case for human use; the others serve scripting, tooling, and server scenarios.
Interactive REPL mode (no -p flag, stdin is a terminal):
The system calls showSetupScreens(), which displays the trust dialog if needed, then assembles the ToolUseContext, calls prefetchSystemContextIfSafe(), and calls launchRepl().
Headless mode (-p "..." flag or piped stdin):
The system skips the trust dialog, skips the React render, and calls runHeadless() directly with the initial prompt. Output is written to stdout in JSON or plain text depending on --output-format.
MCP Server mode (mcp serve subcommand):
The system calls initMcpServer() from src/entrypoints/mcp.ts, which starts a Model Context Protocol server that other tools can connect to. Claude Code becomes an MCP provider rather than an MCP consumer.
Remote/Coordinator mode (--remote flag, COORDINATOR_MODE feature flag):
When feature('COORDINATOR_MODE') is true (internal builds), the action handler delegates to coordinatorModeModule.run(). In external builds, the DCE-eliminated coordinatorModeModule is null and this branch never executes.
Assistant mode (KAIROS feature flag):
Similarly gated by feature('KAIROS'), this path runs assistantModule.run(). Absent from external builds.
Print mode (--print flag with output redirection):
A variant of headless mode that prints each response as it streams and exits, designed for shell scripts.
The Migration System
Immediately before init() runs, main.tsx calls runMigrations():
// src/main.tsx:325-352
const CURRENT_MIGRATION_VERSION = 11;
function runMigrations(): void {
if (getGlobalConfig().migrationVersion !== CURRENT_MIGRATION_VERSION) {
migrateAutoUpdatesToSettings();
migrateBypassPermissionsAcceptedToSettings();
migrateEnableAllProjectMcpServersToSettings();
resetProToOpusDefault();
migrateSonnet1mToSonnet45();
migrateLegacyOpusToCurrent();
migrateSonnet45ToSonnet46();
migrateOpusToOpus1m();
migrateReplBridgeEnabledToRemoteControlAtStartup();
// ... feature-gated migrations ...
saveGlobalConfig(prev =>
prev.migrationVersion === CURRENT_MIGRATION_VERSION
? prev
: { ...prev, migrationVersion: CURRENT_MIGRATION_VERSION }
);
}
// Async migration - fire and forget
migrateChangelogFromConfig().catch(() => {});
}Each migration function lives in src/migrations/ and performs a one-time transformation of ~/.claude/settings.json. The migrations are idempotent and always run in order from oldest to newest; the CURRENT_MIGRATION_VERSION number is the version after all migrations have been applied. When a new migration is added, CURRENT_MIGRATION_VERSION is bumped, which causes existing users to run all migrations again on the next startup. The individual migration functions guard themselves with internal checks to be truly idempotent when re-run.
The saveGlobalConfig call at the end uses a compare-and-replace pattern to avoid overwriting concurrent changes to the config file.
Assembling the ToolUseContext
ToolUseContext is the dependency injection container for every tool call in the system. It is assembled once per session in main.tsx's interactive path, then passed unchanged to launchRepl(), which threads it through to the REPL screen and from there to every tool call.
The key fields assembled in main.tsx:
// src/main.tsx (interactive path, condensed)
const tools = getTools();
const commands = getCommands();
const { tools: mcpTools, commands: mcpCommands, resources: mcpResources } =
await getMcpToolsCommandsAndResources(mcpServerConfigs);
const store = createStore(getDefaultAppState());
const context: ToolUseContext = {
options: {
tools,
commands,
mcpClients: mcpConnections,
mcpResources,
mainLoopModel: getMainLoopModel(),
debug: options.debug ?? false,
verbose: options.verbose ?? false,
// ... additional options
},
abortController: new AbortController(),
getAppState: () => store.getState(),
setAppState: (f) => store.setState(f(store.getState())),
// ... additional fields
};options.tools is the complete list of Tool instances returned by getTools() from src/tools.ts. This list has already been filtered by feature flags — tools behind feature() gates that are false do not appear.
options.commands is the list of slash-command implementations from getCommands() in src/commands.ts.
options.mcpClients holds the active MCP server connections established before the REPL starts. Each connection is a live bidirectional channel to an external MCP server.
getAppState and setAppState are closures over the Zustand store created by createStore(). Calling setAppState(f) applies the updater function and triggers React re-renders for any component subscribed to the changed slice of state.
abortController is the session-level cancellation signal. When the user presses Escape or Ctrl+C mid-turn, abortController.abort() is called. Every async operation in the tool system — file reads, bash executions, API calls — passes abortController.signal to its underlying operations.
When a sub-agent is spawned by AgentTool, it receives a cloned version of this context with a fresh abortController and a no-op setAppState. The sub-agent can call tools and make API requests without any of its state changes propagating to the parent REPL's display.
Deferred Prefetches
After the REPL has rendered its first frame, startDeferredPrefetches() runs:
// src/main.tsx:388-431
export function startDeferredPrefetches(): void {
if (isEnvTruthy(process.env.CLAUDE_CODE_EXIT_AFTER_FIRST_RENDER) || isBareMode()) {
return; // Skip in benchmark and scripted modes
}
void initUser();
void getUserContext();
prefetchSystemContextIfSafe();
void getRelevantTips();
void countFilesRoundedRg(getCwd(), AbortSignal.timeout(3000), []);
void initializeAnalyticsGates();
void prefetchOfficialMcpUrls();
void refreshModelCapabilities();
void settingsChangeDetector.initialize();
void skillChangeDetector.initialize();
}These operations are deferred because they spawn child processes or make network requests. Running them during the critical startup path would contend with the initial React render and show up as startup latency. By deferring to after the first render, they run in the background while the user types their first message — the exact window where their results are needed.
isBareMode() returns true when --bare is passed. In bare mode (intended for scripted -p invocations), there is no "user is typing" window to hide background work in, so all prefetches are skipped as pure overhead.
REPL Launch: src/replLauncher.tsx
launchRepl() is a thin bridge that exists to delay the module-load cost of the React components until they are actually needed:
// src/replLauncher.tsx:12-22
export async function launchRepl(
root: Root,
appProps: AppWrapperProps,
replProps: REPLProps,
renderAndRun: (root: Root, element: React.ReactNode) => Promise<void>
): Promise<void> {
const { App } = await import('./components/App.js');
const { REPL } = await import('./screens/REPL.js');
await renderAndRun(root, <App {...appProps}><REPL {...replProps} /></App>);
}App.js is the root React component — it sets up React contexts (notifications, modal state, stats store) and renders its children. REPL.js is the interactive session host — a ~3,000-line component that renders the message history, the prompt input, and active permission dialogs.
Both are dynamically imported here. App.js imports dozens of components, hooks, and utility modules; REPL.js imports the query engine and tool hooks. By deferring these imports to launchRepl(), they are absent from the module cache until the REPL actually starts — meaning headless and MCP server modes never pay their load cost.
renderAndRun is injected as a parameter (from src/interactiveHelpers.ts) rather than imported directly. This indirection makes replLauncher.tsx testable in isolation: tests can pass a mock renderAndRun that captures what was rendered without needing a real terminal.
The Root object is the Ink render root created earlier in main.tsx. It holds the terminal write stream and the React fiber root. Once renderAndRun is called, the Ink reconciler mounts the React tree, runs the first layout pass using Yoga's Flexbox engine, converts the result to ANSI escape sequences, and writes the first frame to the terminal.
Key Takeaways
The startup sequence in Claude Code is engineered around two principles: minimize latency to first render, and never run security-sensitive operations before the user has granted trust.
Lazy imports are pervasive by design. cli.tsx has no static imports of application code. main.tsx defers App.js and REPL.js to launchRepl(). OpenTelemetry's ~400 KB module tree is deferred to doInitializeTelemetry(). Every await import(...) in the startup path is a deliberate choice to reduce the module-evaluation cost for paths that don't reach that code.
Parallel I/O is fired before blocking work. startMdmRawRead() and startKeychainPrefetch() at the top of main.tsx are the clearest examples: they launch subprocesses that run concurrently with ~135 ms of static import evaluation. By the time their results are needed, they are ready.
The trust dialog is a hard security boundary. Git hooks and git configuration can execute arbitrary code. The two-phase structure of init.ts ensures that git commands, full environment variable application, and telemetry initialization all wait until after the user has explicitly trusted the directory. In non-interactive mode, trust is treated as implicit.
bootstrap/state.ts is infrastructure, AppState is UI. When you read a tool implementation and see it call setAppState, that triggers a React re-render. When you see it call setTotalCostUSD, that accumulates a session-wide metric stored in the process singleton. The distinction matters for understanding what effect a given state mutation has on the system.
ToolUseContext is the dependency injection spine. Every tool call in the system receives the same assembled context object. It carries the tool list, command list, MCP connections, model configuration, app state accessors, and the abort controller. Understanding its structure — which you can find in src/Tool.ts from line 158 onward — is prerequisite knowledge for reading any tool implementation in src/tools/.
Next: Chapter 03 examines the inner agentic loop in src/query.ts — the AsyncGenerator pipeline that drives streaming API calls, tool dispatch, and context compaction.