Chapter 03: The Core Type System
What You'll Learn
By the end of this chapter, you will be able to:
- Explain all three type parameters of
Tool<Input, Output, P>and the role each one plays at both compile time and runtime - Navigate
ToolUseContext's 40+ fields and understand why the dependency injection pattern was chosen over global state - Distinguish the
buildTool()factory function from the rawToolinterface, and explain what theToolDef/BuiltToolmachinery accomplishes - Identify the three variants of the
Commanddiscriminated union and know when each is used - Map the 7
TaskTypevariants and 5TaskStatusstates, and useisTerminalTaskStatus()correctly - Describe the permission type system: modes, decision variants, and the
PermissionDecisionReasonunion - Explain how
DeepImmutable<T>enforces immutability without copying data - Explain the compile-time safety guarantee provided by branded types like
SessionIdandAgentId - Understand Zod v4's role in bridging runtime validation and TypeScript inference
Why the Type System Matters
Claude Code's architecture is a tool-call loop. The model requests actions, the system executes them, and the results flow back. Every component in this loop — calling a tool, deciding whether to permit it, updating application state, routing a slash command — is expressed in a small set of carefully designed types. These types are not incidental annotations. They are the API contracts that make it possible for 60+ tool implementations to share a single execution engine, for permission logic to be tested in isolation, and for the React UI to remain in sync with tool execution without explicit wiring.
This chapter dissects that type system from the ground up.
The Tool<Input, Output, P> Interface
Source: src/Tool.ts:362-466
The Tool generic interface is the universal contract every tool must satisfy. It has three type parameters:
// src/Tool.ts:362-366
export type Tool<
Input extends AnyObject = AnyObject,
Output = unknown,
P extends ToolProgressData = ToolProgressData,
> = { ... }Input is constrained to AnyObject, which is defined as:
// src/Tool.ts:343
export type AnyObject = z.ZodType<{ [key: string]: unknown }>This means Input is not a plain TypeScript object type. It is a Zod schema whose inferred TypeScript type is an object with string keys. The constraint exists because Input must serve two masters simultaneously: at runtime it acts as a validator that rejects malformed API inputs before the tool executes; at compile time z.infer<Input> extracts the strongly-typed parameter object that tool implementations work with.
Output is unconstrained. It defaults to unknown and represents the data returned by the tool. The ToolResult<Output> wrapper carries this data along with optional side effects (new messages to inject, a context modifier to apply).
P extends ToolProgressData and represents the streaming progress payloads emitted while the tool runs. BashTool uses BashProgress, AgentTool uses AgentToolProgress, and tools that have no streaming output accept the default base type.
The call method — the mandatory core
// src/Tool.ts:379-385
call(
args: z.infer<Input>,
context: ToolUseContext,
canUseTool: CanUseToolFn,
parentMessage: AssistantMessage,
onProgress?: ToolCallProgress<P>,
): Promise<ToolResult<Output>>The args parameter is z.infer<Input> — the TypeScript type inferred from the Zod schema. By the time call is invoked, the tool executor has already passed the raw API-supplied JSON through inputSchema.parse(), so args is guaranteed to be well-typed. The tool implementation never needs to validate its own inputs.
context is the dependency injection spine of the system, covered at length in the next section.
canUseTool is a callback the tool can use if it needs to perform permission checks for sub-tools it invokes internally. AgentTool passes this into the sub-agent's query loop so spawned agents inherit the parent session's permission configuration.
onProgress is optional. Tools that stream incremental output (the Bash output buffer, agent thought steps) call this callback as they produce data. The REPL renders each ToolProgress<P> event immediately, which is why terminal output appears character by character rather than all at once at the end.
Key behavioral methods
// src/Tool.ts:401-416
isConcurrencySafe(input: z.infer<Input>): boolean
isEnabled(): boolean
isReadOnly(input: z.infer<Input>): boolean
isDestructive?(input: z.infer<Input>): boolean
interruptBehavior?(): 'cancel' | 'block'These shape the tool executor's behavior without requiring any knowledge of the specific tool.
isConcurrencySafe tells the executor whether this tool call can run in parallel with others in the same turn. File reads are safe; Bash commands are not (a command might change the working directory in ways that affect the next concurrent command).
isReadOnly feeds the permission system. A tool that only reads, never writes, is treated more permissively in acceptEdits mode.
isDestructive flags irreversible operations. When this returns true and the permission mode requires confirmation, the UI presents a more prominent warning. The comment in the source is precise: "irreversible operations (delete, overwrite, send)."
interruptBehavior controls what happens when the user submits a new message while the tool is running. 'cancel' means the tool is aborted and its result discarded. 'block' means the tool continues running and the new message is queued. The default is 'block'. Only tools like BashTool running short commands where the user might realistically want to cancel use 'cancel'.
Deferred loading: shouldDefer and alwaysLoad
// src/Tool.ts:442-449
readonly shouldDefer?: boolean
readonly alwaysLoad?: booleanThe ToolSearch mechanism allows a large tool set to be loaded lazily. Tools with shouldDefer: true are excluded from the initial API call's tool list. The model can search for them by keyword using the ToolSearch tool. Tools with alwaysLoad: true are never deferred, regardless of the tool count. This pair of flags is the mechanism by which 60+ tools can coexist without filling the initial system prompt with schema definitions the model rarely uses.
maxResultSizeChars — the overflow budget
// src/Tool.ts:466
maxResultSizeChars: numberWhen a tool returns a result larger than this limit, the tool executor writes the full result to a temporary file and returns a truncated preview with the file path to the model. This prevents large BashTool outputs (a find result, a full test log) from consuming the context window. Tools whose output must never be persisted (notably FileReadTool, where persisting would create a read-then-read circular loop) set this to Infinity.
ToolResult<T> — Side Effects Alongside Data
// src/Tool.ts:321-336
export type ToolResult<T> = {
data: T
newMessages?: (UserMessage | AssistantMessage | AttachmentMessage | SystemMessage)[]
contextModifier?: (context: ToolUseContext) => ToolUseContext
mcpMeta?: {
_meta?: Record<string, unknown>
structuredContent?: Record<string, unknown>
}
}data is the primary return value. It will be serialized and appended to the conversation as a tool_result block.
newMessages allows a tool to inject additional messages into the conversation after its result. This is used by sub-agents to inject the agent's full conversation transcript as context.
contextModifier is a function that transforms the ToolUseContext after the tool completes. This is only honored for tools that are not concurrency-safe — a race condition in parallel context mutation would be undefined behavior. It allows a tool to update shared context state (such as loaded memory paths) as an atomic side effect of execution.
mcpMeta passes through MCP protocol metadata (structuredContent, _meta) for SDK consumers who need access to the raw MCP response envelope.
buildTool() — The Builder Factory
Source: src/Tool.ts:721-792
Writing a complete Tool implementation from scratch requires providing every method in the interface, many of which have sensible, universal defaults. The buildTool() factory eliminates this boilerplate while preserving full TypeScript fidelity.
The pattern is a three-type machinery:
// src/Tool.ts:721-726
export type ToolDef<
Input extends AnyObject = AnyObject,
Output = unknown,
P extends ToolProgressData = ToolProgressData,
> = Omit<Tool<Input, Output, P>, DefaultableToolKeys> &
Partial<Pick<Tool<Input, Output, P>, DefaultableToolKeys>>ToolDef is like Tool, except that the seven "defaultable" methods (isEnabled, isConcurrencySafe, isReadOnly, isDestructive, checkPermissions, toAutoClassifierInput, userFacingName) become optional. A tool author passes a ToolDef to buildTool() and receives a BuiltTool<D> back — a type that fills in the defaults at the type level so callers can call any of those methods unconditionally.
// src/Tool.ts:735-741
type BuiltTool<D> = Omit<D, DefaultableToolKeys> & {
[K in DefaultableToolKeys]-?: K extends keyof D
? undefined extends D[K]
? ToolDefaults[K]
: D[K]
: ToolDefaults[K]
}The mapped type logic reads: for each defaultable key, if the definition provides a concrete (non-undefined) value, use the definition's type; otherwise, use the default's type. The -? removes optionality, guaranteeing the resulting type has no optional defaultable methods.
The runtime function is deliberately simple:
// src/Tool.ts:783-792
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
return {
...TOOL_DEFAULTS,
userFacingName: () => def.name,
...def,
} as BuiltTool<D>
}The spread order is intentional: defaults first, then the definition, so any method the author provides takes precedence. The defaults are fail-closed: isConcurrencySafe defaults to false (assume not safe), isReadOnly defaults to false (assume writes), isDestructive defaults to false, checkPermissions defaults to { behavior: 'allow', updatedInput } (defer to the general permission system rather than blocking).
A typical tool using this pattern looks like:
export const FileReadTool = buildTool({
name: 'Read',
inputSchema: z.object({ file_path: z.string(), ... }),
maxResultSizeChars: Infinity,
isReadOnly: () => true,
isConcurrencySafe: () => true,
async call(args, context, canUseTool) { ... },
// isEnabled, isDestructive, checkPermissions, etc. come from TOOL_DEFAULTS
})ToolUseContext — The Dependency Injection Spine
Source: src/Tool.ts:158-300
ToolUseContext is passed to every tool call in the system. It is a plain TypeScript object, not a class instance, carrying everything a tool might need without requiring access to global state. The design choice is explicit: tools are pure functions of their args and context. This makes them testable in isolation.
The options sub-object: static session configuration
// src/Tool.ts:159-179
options: {
commands: Command[]
debug: boolean
mainLoopModel: string
tools: Tools
verbose: boolean
thinkingConfig: ThinkingConfig
mcpClients: MCPServerConnection[]
mcpResources: Record<string, ServerResource[]>
isNonInteractiveSession: boolean
agentDefinitions: AgentDefinitionsResult
maxBudgetUsd?: number
customSystemPrompt?: string
appendSystemPrompt?: string
querySource?: QuerySource
refreshTools?: () => Tools
}options contains session-wide configuration that does not change during a turn. The tool registry (tools), the MCP server connections, the thinking configuration, and the budget cap all live here. isNonInteractiveSession is particularly important: tools check this flag before attempting to render UI elements or prompt the user. refreshTools is a callback that re-queries available tools (needed after an MCP server connects mid-session).
State accessors: the React boundary
// src/Tool.ts:182-192
getAppState(): AppState
setAppState(f: (prev: AppState) => AppState): void
setAppStateForTasks?: (f: (prev: AppState) => AppState) => voidTools do not hold references to React state. Instead, they receive getAppState and setAppState callbacks. This decoupling means the same tool implementation works in interactive (REPL), headless (--print), and SDK modes without modification.
setAppStateForTasks is an advanced variant introduced for sub-agents. When a sub-agent is spawned, createSubagentContext replaces the regular setAppState with a no-op to isolate the sub-agent's state mutations from the parent session. But background tasks (a cron job spawned by an agent) need to register themselves in the root store, not the agent's isolated store. setAppStateForTasks bypasses the no-op and always writes to the root store.
UI callbacks: optional wiring
// src/Tool.ts:203-235
setToolJSX?: SetToolJSXFn
addNotification?: (notif: Notification) => void
appendSystemMessage?: (msg: Exclude<SystemMessage, SystemLocalCommandMessage>) => void
sendOSNotification?: (opts: { message: string; notificationType: string }) => void
setStreamMode?: (mode: SpinnerMode) => void
onCompactProgress?: (event: CompactProgressEvent) => voidThese are all optional callbacks wired up only in interactive mode. The ? on each field is a testability signal: a tool that uses sendOSNotification?.() will not throw in headless mode when the callback is absent. The appendSystemMessage type is annotated with Exclude<SystemMessage, SystemLocalCommandMessage> — the source comment explains: "Stripped at the normalizeMessagesForAPI boundary — the Exclude<> makes that type-enforced." The type system itself prevents a UI-only message type from accidentally leaking into API calls.
Sub-agent identity and isolation
// src/Tool.ts:245-249
agentId?: AgentId
agentType?: string
requireCanUseTool?: boolean
messages: Message[]agentId is only set in sub-agent contexts. Tools and hooks use this field to distinguish main-thread calls from sub-agent calls — for example, some hooks apply different security policies to background agents versus interactive sessions.
messages is the current conversation history. Tools that need to examine prior turns (for deduplication, context injection, or summarization) read from this field rather than accessing any global state.
Tracking and budget fields
// src/Tool.ts:251-292
fileReadingLimits?: { maxTokens?: number; maxSizeBytes?: number }
globLimits?: { maxResults?: number }
toolDecisions?: Map<string, { source: string; decision: 'accept' | 'reject'; timestamp: number }>
queryTracking?: QueryChainTracking
localDenialTracking?: DenialTrackingState
contentReplacementState?: ContentReplacementState
renderedSystemPrompt?: SystemPrompttoolDecisions records which tool calls were accepted or rejected in the current turn, along with the source of the decision (hook, user, classifier). This feeds analytics and helps avoid re-prompting for the same decision in the same turn.
localDenialTracking exists specifically for sub-agents whose setAppState is a no-op. Without a writable state store, the denial counter that triggers fallback-to-prompting would never accumulate. This field gives those agents a mutable slot for the counter.
contentReplacementState manages the aggregate tool result budget: when the total size of tool results in a conversation thread exceeds the budget, older results are replaced with references to on-disk files. The state tracks which results have already been replaced to avoid double-replacement.
renderedSystemPrompt carries the parent session's frozen system prompt at fork time. Sub-agents spawned from a fork share the parent's prompt-cache key; re-generating the system prompt at spawn time could diverge (due to GrowthBook cold-to-warm transitions) and bust the cache, defeating the purpose of forking.
The Command Discriminated Union
Source: src/types/command.ts:205-206
Slash commands in Claude Code have three execution models expressed as a discriminated union:
// src/types/command.ts:205-206
export type Command = CommandBase &
(PromptCommand | LocalCommand | LocalJSXCommand)The discriminant is the type field:
PromptCommand (type: 'prompt') — The command is backed by a Markdown template. When invoked, the template is expanded into a ContentBlockParam[] and sent to the model as part of the next turn. Skills loaded from .claude/skills/ are prompt commands. This type carries fields like allowedTools, model, and context ('inline' or 'fork') that configure how the skill runs.
LocalCommand (type: 'local') — The command is a TypeScript function. When invoked, it returns a LocalCommandResult (text, a compaction event, or a skip). These commands run synchronously in the REPL without making an API call. /clear, /reset, and /cost are local commands.
LocalJSXCommand (type: 'local-jsx') — The command renders a React component into the terminal UI. When invoked, it returns a React.ReactNode that is mounted by the REPL until the command calls its onDone callback. Settings dialogs, the /config panel, and the trust prompt are local-jsx commands. The load function is a dynamic import — the heavy React component is not loaded until the command is actually invoked.
CommandBase carries the shared fields: name, description, aliases, isEnabled, isHidden, isMcp, loadedFrom, and availability. The availability field restricts commands to specific auth environments ('claude-ai' for OAuth subscribers, 'console' for direct API key users).
The Task and TaskType System
Source: src/Task.ts
Background tasks are long-running operations that Claude Code spawns and manages across multiple turns. The type system models them with two unions and a state machine.
TaskType — 7 variants
// src/Task.ts:6-13
export type TaskType =
| 'local_bash'
| 'local_agent'
| 'remote_agent'
| 'in_process_teammate'
| 'local_workflow'
| 'monitor_mcp'
| 'dream'local_bash is a shell command running in a background terminal. local_agent is a sub-agent running asynchronously (the agent runs to completion outside the current turn). remote_agent is an agent running in a separate Claude Code process, connected over the bridge protocol. in_process_teammate is an agent running in the same process as the session leader, sharing memory and the React tree. local_workflow is a sequence of tool calls defined by a workflow specification. monitor_mcp is a long-running MCP server observation process. dream is a feature-flagged speculative agent type.
Each type has a single-character prefix used when generating task IDs: b for local_bash, a for local_agent, r for remote_agent, t for in_process_teammate, w for local_workflow, m for monitor_mcp, d for dream.
TaskStatus — 5 states and a terminal predicate
// src/Task.ts:15-20
export type TaskStatus =
| 'pending'
| 'running'
| 'completed'
| 'failed'
| 'killed'The state machine is: pending → running → one of {completed, failed, killed}. The three terminal states never transition further.
// src/Task.ts:27-29
export function isTerminalTaskStatus(status: TaskStatus): boolean {
return status === 'completed' || status === 'failed' || status === 'killed'
}This predicate is used throughout the codebase to guard against injecting messages into dead tasks, evicting finished tasks during AppState cleanup, and skipping notifications for tasks that completed before the UI registered them.
TaskStateBase — the common record
// src/Task.ts:45-57
export type TaskStateBase = {
id: string
type: TaskType
status: TaskStatus
description: string
toolUseId?: string
startTime: number
endTime?: number
totalPausedMs?: number
outputFile: string
outputOffset: number
notified: boolean
}Every task type extends TaskStateBase. outputFile is the path to a file where the task writes its streaming output. outputOffset tracks how many bytes the UI has already consumed — incremental tailing reads from outputOffset forward. totalPausedMs accounts for time when the task was paused (a remote agent whose connection dropped temporarily). notified is a one-way flag: once the user has been notified of a terminal event, it is never reset.
The Task interface — polymorphic kill
// src/Task.ts:72-76
export type Task = {
name: string
type: TaskType
kill(taskId: string, setAppState: SetAppState): Promise<void>
}The comment in the source is direct: "kill. spawn/render were never called polymorphically (removed in #22546). All six kill implementations use only setAppState." The interface was once larger. Refactoring removed the polymorphic spawn and render paths, leaving only kill — the one operation that must work uniformly across all six concrete task types.
The Permission Type System
Source: src/types/permissions.ts
The permission system has three layers: the mode that governs the session, the rules that constrain individual tools, and the decision that resolves each permission check.
PermissionMode — 7 variants
// src/types/permissions.ts:16-29
export const EXTERNAL_PERMISSION_MODES = [
'acceptEdits',
'bypassPermissions',
'default',
'dontAsk',
'plan',
] as const
export type InternalPermissionMode = ExternalPermissionMode | 'auto' | 'bubble'
export type PermissionMode = InternalPermissionModeThe five external modes are user-configurable: default prompts for writes; acceptEdits auto-approves file edits; dontAsk auto-approves everything; bypassPermissions removes all checks; plan disables all tool execution (model can only plan). The two internal modes — auto and bubble — are conditionally included: auto is gated behind the TRANSCRIPT_CLASSIFIER feature flag (an Anthropic-internal mode that routes permission decisions to a classifier model); bubble is used by coordinator workers that delegate permission decisions up to the parent.
PermissionResult — the decision union
// src/types/permissions.ts:251-266
export type PermissionResult<Input> =
| PermissionDecision<Input>
| {
behavior: 'passthrough'
message: string
decisionReason?: ...
suggestions?: PermissionUpdate[]
blockedPath?: string
pendingClassifierCheck?: PendingClassifierCheck
}PermissionDecision<Input> is itself a three-variant union:
PermissionAllowDecision (behavior: 'allow') — the tool may proceed. updatedInput optionally carries a modified version of the input (a hook may rewrite the command). acceptFeedback carries a message to display to the user alongside the allow decision.
PermissionAskDecision (behavior: 'ask') — the user must approve interactively. suggestions carries PermissionUpdate actions (such as "add to always-allow list") that the UI can offer the user as one-click options. pendingClassifierCheck indicates that an async classifier call is in flight and may auto-approve before the user responds.
PermissionDenyDecision (behavior: 'deny') — rejected. Always carries a decisionReason.
passthrough is a fourth behavior added for rule-chaining scenarios: a permission handler that wants to defer to the next handler in the chain returns passthrough rather than a final decision.
PermissionDecisionReason — the audit trail
// src/types/permissions.ts:271-324
export type PermissionDecisionReason =
| { type: 'rule'; rule: PermissionRule }
| { type: 'mode'; mode: PermissionMode }
| { type: 'subcommandResults'; reasons: Map<string, PermissionResult> }
| { type: 'permissionPromptTool'; permissionPromptToolName: string; toolResult: unknown }
| { type: 'hook'; hookName: string; hookSource?: string; reason?: string }
| { type: 'asyncAgent'; reason: string }
| { type: 'sandboxOverride'; reason: 'excludedCommand' | 'dangerouslyDisableSandbox' }
| { type: 'classifier'; classifier: string; reason: string }
| { type: 'workingDir'; reason: string }
| { type: 'safetyCheck'; reason: string; classifierApprovable: boolean }
| { type: 'other'; reason: string }This union records not just the outcome of a permission decision but why the decision was made. Eleven variants cover every decision path: explicit allow/deny rules from settings, the active permission mode, compound commands whose sub-commands each had their own results, external hook scripts, the classifier model, working directory boundary checks, and security hardening overrides. This enables the permission explainer UI to show the user a precise, source-attributed explanation for why an action was blocked.
AppState — the 150+ Field Immutable Session Store
Source: src/state/AppStateStore.ts:89-216
AppState is the single React state object for an interactive session. It carries everything the UI needs to render: the current permission context, the active tasks, MCP connection state, the bridge status, the model selection, notification queues, and much more.
// src/state/AppStateStore.ts:89-158
export type AppState = DeepImmutable<{
settings: SettingsJson
verbose: boolean
mainLoopModel: ModelSetting
mainLoopModelForSession: ModelSetting
statusLineText: string | undefined
expandedView: 'none' | 'tasks' | 'teammates'
toolPermissionContext: ToolPermissionContext
kairosEnabled: boolean
remoteConnectionStatus: 'connecting' | 'connected' | 'reconnecting' | 'disconnected'
replBridgeEnabled: boolean
replBridgeConnected: boolean
// ... 150+ fields total
}> & {
// Excluded from DeepImmutable because TaskState contains function types
tasks: { [taskId: string]: TaskState }
agentNameRegistry: Map<string, AgentId>
mcp: {
clients: MCPServerConnection[]
tools: Tool[]
commands: Command[]
resources: Record<string, ServerResource[]>
pluginReconnectKey: number
}
plugins: {
enabled: LoadedPlugin[]
disabled: LoadedPlugin[]
commands: Command[]
errors: PluginError[]
}
}Most fields are wrapped in DeepImmutable<{...}>. The tasks, agentNameRegistry, mcp, and plugins fields are excluded from DeepImmutable via the intersection & and live as mutable objects. The source comment explains: TaskState contains function types (like the kill callback), and DeepImmutable would recurse into functions and mark their parameters as readonly. Since JavaScript function parameters are never actually readonly at runtime, this would create a false sense of immutability and break the kill call sites.
DeepImmutable<T> — the recursive readonly utility
The DeepImmutable type is defined in src/types/utils.ts (the file exists as a source dependency; it's imported as 'src/types/utils.js' throughout the codebase):
// Conceptual definition used throughout the codebase
export type DeepImmutable<T> = T extends (...args: unknown[]) => unknown
? T // Functions: pass through unchanged
: { readonly [K in keyof T]: DeepImmutable<T[K]> }The type recurses through every nested level of T, adding readonly to each property. A string stays a string. An Array<string> becomes ReadonlyArray<string>. An object { a: { b: number } } becomes { readonly a: { readonly b: number } }. Function types are passed through as-is without recursion, because making function parameters readonly would cause false type errors at call sites.
The practical effect: TypeScript will refuse to compile any mutation of AppState fields that are under DeepImmutable. State changes must go through setAppState(prev => ({ ...prev, fieldToChange: newValue })), producing a new object rather than mutating the existing one. This is the same immutability contract as Redux, enforced by the type system rather than runtime checks.
Branded Types: SessionId and AgentId
Source: src/types/ids.ts
A session ID and an agent ID are both strings. Without additional type information, TypeScript would allow you to pass a raw string where a SessionId is expected, or to mix up a SessionId and an AgentId. In a system that routes messages between parallel agents, this kind of mix-up would cause silent, hard-to-debug routing failures.
Branded types solve this at the type system level:
// src/types/ids.ts:10-17
export type SessionId = string & { readonly __brand: 'SessionId' }
export type AgentId = string & { readonly __brand: 'AgentId' }The intersection with { readonly __brand: 'SessionId' } creates a type that is structurally a string but is not assignable from a plain string. The __brand property only exists at the type level — it is never present at runtime. This costs zero bytes and zero runtime overhead.
Two factory functions control how branded values are created:
// src/types/ids.ts:23-24
export function asSessionId(id: string): SessionId {
return id as SessionId // Escape hatch for trusted sources (loaded from disk, API)
}// src/types/ids.ts:35-43
const AGENT_ID_PATTERN = /^a(?:.+-)?[0-9a-f]{16}$/
export function toAgentId(s: string): AgentId | null {
return AGENT_ID_PATTERN.test(s) ? (s as AgentId) : null
}asAgentId and asSessionId are escape hatches — they perform a compile-time cast with no runtime check. They exist for trusted sources: an ID loaded from a JSON file, or an ID returned by the API in a known field. The source comment "Use sparingly — prefer createAgentId() when possible" signals that asAgentId should be treated as an unsafe cast.
toAgentId is the safe constructor: it validates the format against ^a(?:.+-)?[0-9a-f]{16}$ and returns AgentId | null. The format encodes the type: a prefix + optional label + 16 hex chars. Teammate names (human-readable strings used in coordinator scenarios) do not match this pattern, so toAgentId("alice") returns null.
The compile-time guarantee: code that routes agent messages by ID will not compile if you accidentally pass a SessionId where an AgentId is expected, or a raw string where either branded type is required. The typo that would cause a message to be routed to the wrong process is rejected by the compiler, not discovered in production.
Zod v4: Runtime Validation Meets TypeScript Inference
Source: src/Tool.ts:10, usage throughout src/tools/
Every tool's inputSchema is a Zod v4 schema:
// src/Tool.ts:10
import type { z } from 'zod/v4'The import subpath 'zod/v4' is significant. Claude Code uses the Zod v4 API, which changed the import path from the bare 'zod' of Zod v3. Code that imports from the wrong path would silently get a different API version.
The schema serves three distinct purposes simultaneously:
Runtime validation. Before tool.call() is invoked, the tool executor parses the raw JSON from the API's tool_use block through tool.inputSchema.parse(rawJson). If the model hallucinates a field name, passes a number where a string is expected, or omits a required field, parse() throws and the tool executor returns a validation error without calling the tool implementation. This is Zod's "parse, don't validate" philosophy: rather than checking fields individually and returning booleans, you declare the shape once and get either a fully-typed object or a thrown error.
Compile-time type inference. z.infer<Input> extracts the TypeScript type from a Zod schema at compile time. The tool's call method signature uses z.infer<Input> for its args parameter. This means a single Zod schema definition simultaneously provides runtime validation and compile-time type safety — the two are derived from the same source of truth.
JSON Schema generation. The @anthropic-ai/sdk requires tool schemas in JSON Schema format for the API request's tools parameter. The codebase includes a Zod-to-JSON-Schema converter. The inputJSONSchema field on the Tool interface is an override for MCP tools that supply JSON Schema directly rather than going through Zod.
Type Relationship Diagram
The following diagram shows how the central types relate to one another:
Key Takeaways
The type system in Claude Code is not defensive boilerplate. Each type solves a specific architectural problem.
Tool<Input, Output, P> makes every action Claude can perform interchangeable from the executor's point of view. The three type parameters ensure that the schema used for runtime validation and the TypeScript type used by the implementation are always in sync, because they are derived from the same Zod definition.
buildTool() is a Builder pattern implemented entirely in the TypeScript type system. It eliminates boilerplate for the seven methods that have universal defaults, while preserving exact type fidelity. A tool author cannot accidentally call tool.isConcurrencySafe() on a built tool and get undefined — the type system makes that impossible.
ToolUseContext's 40+ fields are dependency injection rather than global state. The consequence: every tool is testable in isolation by constructing a minimal context, and sub-agents receive a cloned context with isolated state, preventing cross-agent mutation.
DeepImmutable<T> enforces immutability at every level of AppState without requiring explicit readonly annotations on every field. The function-pass-through rule in the type is a practical concession: function parameters are never actually readonly, and marking them as such would produce misleading type errors.
Branded types like SessionId and AgentId cost nothing at runtime but prevent an entire class of routing bugs at compile time. The toAgentId validator combines the branded type pattern with format validation, producing a value that is both correctly typed and known to have the right shape.
PermissionResult's four-variant discriminated union covers every possible outcome of a permission check, including the passthrough variant for rule-chaining. The 11-variant PermissionDecisionReason union creates an audit trail that the UI can display and telemetry can record.
Together, these types form the stable foundation on which 60+ tool implementations, the permission system, the REPL, the task manager, and the sub-agent system are all built.
Next: Chapter 04 traces the startup sequence from src/entrypoints/cli.tsx through initialization to the first rendered REPL prompt, following every dynamic import and initialization step in order.