Skip to content

Chapter 6 — Tool System Deep Dive

What You'll Learn

By the end of this chapter, you will be able to:

  • Describe all five stages of the tool lifecycle and name the method or subsystem responsible for each stage
  • Read the Tool<Input, Output> interface at src/Tool.ts and explain what every method does and when it is called
  • Explain what buildTool() does, why it exists, and which seven keys it provides safe defaults for
  • Articulate the distinction between ToolDef (what tool authors write) and Tool (what the runtime operates on)
  • Trace a FileReadTool invocation through validation, permission checking, dispatch by file extension, dedup, and API serialization
  • Explain how getAllBaseTools() in src/tools.ts acts as the single source of truth for the tool registry
  • Walk through runTools() in src/services/tools/toolOrchestration.ts and explain how partitionToolCalls decides which tools run concurrently and which run serially
  • Implement a minimal but complete custom tool from scratch using buildTool() and register it in the tool registry

6.1 The Tool Lifecycle

Before diving into data structures and code, it helps to have a mental map of the stages every tool call passes through. There are five stages, and they happen in strict order.

Registration happens once at process startup. getAllBaseTools() returns a flat array of Tool objects; the loop reads that array and builds a runtime registry used for every subsequent turn.

Model selection is not controlled by application code — the model decides which tool to call and what arguments to pass based on the conversation context and the prompt() strings each tool exposes. The model emits a tool_use block in its response stream; the loop extracts the name and parses the input JSON.

Validation and permission checking happen before any I/O. validateInput() does pure, synchronous-style logic — checking path formats, blocked extensions, and deny rules — without touching the file system. checkPermissions() consults the permission system (covered in Chapter 7) and may prompt the user for explicit approval. Either method can abort the invocation by returning a rejection result.

Execution is the call() method. This is where all actual side effects occur: reading files, running shell commands, making network requests. The return type is Promise<ToolResult<Output>>, where ToolResult may carry additional messages to inject into the conversation, a context modifier, and MCP metadata.

Result serialization converts the typed Output value into the ToolResultBlockParam format that the Anthropic Messages API understands. This is where images become base64 image blocks, notebooks become formatted text blocks, and large outputs might be truncated or summarized.

UI rendering happens in parallel with serialization (they are independent concerns). The React UI calls renderToolUseMessage() while the tool is running to show a "requesting" state, and renderToolResultMessage() once the result is available.


6.2 The Tool<Input, Output> Interface

The Tool<Input, Output, P> interface at src/Tool.ts is the contract every tool must satisfy. It is parameterized by three types: Input is a Zod schema type, Output is the result type, and P extends ToolProgressData is the type of streaming progress events the tool may emit during execution.

6.2.1 Core Execution Methods

The most important method is call():

typescript
// src/Tool.ts (within the Tool<Input, Output, P> interface)
call(
  args: z.infer<Input>,
  context: ToolUseContext,
  canUseTool: CanUseToolFn,
  parentMessage: AssistantMessage,
  onProgress?: ToolCallProgress<P>,
): Promise<ToolResult<Output>>

args is the parsed, validated input — Zod has already coerced the raw JSON into the typed shape. context is the session-scoped ToolUseContext containing the React store, abort controller, agent identity, and current working directory. canUseTool is the same gate function threaded through the entire loop — it allows the tool to invoke nested tools (the AgentTool uses this to spawn subagents). onProgress is an optional callback for streaming intermediate results to the UI before call() completes.

The return type ToolResult<Output> is defined as:

typescript
// src/Tool.ts:321-336
export type ToolResult<T> = {
  data: T
  newMessages?: (UserMessage | AssistantMessage | AttachmentMessage | SystemMessage)[]
  contextModifier?: (context: ToolUseContext) => ToolUseContext
  mcpMeta?: { _meta?: ...; structuredContent?: ... }
}

data is the typed output value. newMessages is an optional array of messages that should be injected into the conversation immediately after this tool result — this is how tools can synthesize entirely new context without making an additional API call. contextModifier is a function that transforms the current ToolUseContext; this is used by tools that need to update session state, for example recording a newly discovered cwd or registering a file that was just written. The serial execution path in toolOrchestration.ts applies context modifiers immediately and in order; the concurrent path defers them until the entire batch completes, then applies them in tool_use_id order to ensure determinism.

Two other core methods govern how the tool describes itself:

typescript
description(input: z.infer<Input>, options: DescriptionOptions): Promise<string>
prompt(options: PromptOptions): Promise<string>

description() returns a short human-readable summary of what this particular invocation will do — it is shown in the UI before the user approves a sensitive operation. prompt() returns the full model-visible description that appears in the system prompt and tells the model what the tool does, when to use it, and what the schema fields mean. Both are async because they may need to read configuration or feature flags.

The remaining core method is inputSchema:

typescript
readonly inputSchema: Input

This is the Zod schema that defines the expected shape of the tool's arguments. The loop uses it for two purposes: parsing the raw JSON from the model's tool_use block, and generating the JSON Schema that appears in the API request (telling the model what fields are available). Many tools use lazySchema() to defer initialization — that pattern is explained in Section 6.4.1.

6.2.2 Classification and Concurrency

Four boolean methods on Tool give the orchestration layer the information it needs to make safe scheduling decisions:

typescript
isConcurrencySafe(input: z.infer<Input>): boolean
isEnabled(): boolean
isReadOnly(input: z.infer<Input>): boolean
isDestructive?(input: z.infer<Input>): boolean

isConcurrencySafe() is the most important of these. When the model calls multiple tools in a single response, the orchestration layer groups consecutive invocations into batches. If every tool in a group returns true from isConcurrencySafe(), those invocations run in a concurrent batch — all started at the same time, capped at CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY (default 10). The moment a tool returns false, the concurrent run is broken and a serial batch begins. This is explained in depth in Section 6.6.

isEnabled() lets a tool disable itself at runtime based on environment conditions — a tool that requires a specific binary returns false here when that binary is absent, and the loop omits it from the API request entirely.

isReadOnly() is used by the permission system and the UI. Read-only tools typically receive automatic approval in non-interactive modes; write tools require explicit user consent.

isDestructive() is an optional refinement of isReadOnly(). A tool can be non-read-only (it writes data) without being destructive (it writes to a temp file). This distinction affects which permission tier is required.

The searchHint property is a string of three to ten words used by ToolSearch — an internal fuzzy matcher that helps the model find the right tool when the tool list is very long. It is separate from prompt() because it is designed for keyword matching rather than model comprehension.

6.2.3 Validation and Permission Methods

typescript
validateInput?(input: z.infer<Input>, context: ToolUseContext): Promise<ValidationResult>
checkPermissions(input: z.infer<Input>, context: ToolUseContext): Promise<PermissionResult>

validateInput() is optional and runs first. It is intended for pure logic checks that do not require any I/O: path format validation, extension filtering, deny rule matching. If it returns a rejection, the tool call is aborted before checkPermissions() is even called. The reason for this separation is that validateInput() errors are presented to the model as tool-level validation failures, whereas checkPermissions() denials are presented as permission refusals — the model handles them differently.

checkPermissions() is required (though buildTool() provides a default that always allows). It consults the session's ToolPermissionContext and returns one of three behaviors: 'allow', 'ask' (prompt the user), or 'deny'. The full permission system including the nine denial reasons is covered in Chapter 7.

Two more optional methods support the permission and path matching infrastructure:

typescript
getPath?(input: z.infer<Input>): string
preparePermissionMatcher?(input: z.infer<Input>): Promise<(pattern: string) => boolean>
backfillObservableInput?(input: z.infer<Input>): void

getPath() extracts the primary filesystem path from an input, used when permission rules are path-pattern-based. preparePermissionMatcher() builds a function that tests whether a given allow/deny pattern covers this input — used for tools whose permissions depend on dynamic path resolution (such as glob expansion). backfillObservableInput() is called before any hooks or permission matchers see the input; it is the correct place to expand ~ and relative paths to their absolute forms, ensuring that hook callbacks always receive clean, canonical paths.

6.2.4 UI Rendering Methods

typescript
renderToolUseMessage(input: z.infer<Input>, options: RenderOptions): React.ReactNode
renderToolResultMessage?(content: Output, progressMessages: P[], options: RenderOptions): React.ReactNode
renderToolUseErrorMessage?(result: ToolResult<Output>, options: RenderOptions): React.ReactNode

renderToolUseMessage() is called while the tool is executing. It renders the "requesting" state — for FileReadTool this might show the file path; for BashTool it shows the command. renderToolResultMessage() is called when the result is available and renders the output — a diff for writes, truncated file content for reads, formatted output for bash. renderToolUseErrorMessage() handles the case where call() threw an exception, giving the tool control over how errors are presented rather than relying on a generic error card.

6.2.5 API Serialization

typescript
mapToolResultToToolResultBlockParam(
  content: Output,
  toolUseID: string,
): ToolResultBlockParam

This method converts the typed Output value into the exact JSON structure the Anthropic Messages API expects in the tool_result content block. The toolUseID is the id field from the original tool_use block emitted by the model — it must be echoed back so the API can correlate request and result.

The conversion is non-trivial for media types. Images become { type: 'image', source: { type: 'base64', media_type, data } } blocks. Notebooks have their cells formatted as structured text. Large text results that exceed maxResultSizeChars are saved to disk and the model receives a preview plus a path it can use to request the full content in a follow-up read.

typescript
readonly maxResultSizeChars: number

This field sets the threshold for oversized results. When a tool result exceeds this size, the runtime saves the full content to a temporary file and sends the model a truncated preview with the message "the full result has been saved to [path]". The default in TOOL_DEFAULTS is a finite number; FileReadTool overrides it to Infinity because it manages its own token budget internally rather than relying on this mechanism.


6.3 buildTool(): The Factory Function

Tool authors do not implement Tool<Input, Output> directly. They implement ToolDef<Input, Output>, which is a lighter type, and then pass it to buildTool() to produce a Tool the runtime can use.

6.3.1 ToolDef vs Tool

The distinction is encoded precisely in the type definition at src/Tool.ts:

typescript
// src/Tool.ts:721-726
export type ToolDef<Input, Output, P> =
  Omit<Tool<Input, Output, P>, DefaultableToolKeys> &
  Partial<Pick<Tool<Input, Output, P>, DefaultableToolKeys>>

ToolDef makes seven keys optional by using Partial<Pick<...>> over them. The rest of the Tool interface is required — if you forget name, inputSchema, call, description, or prompt, TypeScript will catch it at compile time. The seven optional keys — called DefaultableToolKeys — are:

typescript
type DefaultableToolKeys =
  | 'isEnabled'
  | 'isConcurrencySafe'
  | 'isReadOnly'
  | 'isDestructive'
  | 'checkPermissions'
  | 'toAutoClassifierInput'
  | 'userFacingName'

Each of these has a safe, conservative default. isEnabled defaults to () => true (the tool is always enabled). isConcurrencySafe defaults to false (assume serial execution is required until the author explicitly opts in). isReadOnly defaults to false (assume writes are possible). isDestructive defaults to false. checkPermissions defaults to always returning { behavior: 'allow', updatedInput: input }. toAutoClassifierInput defaults to () => '' (no classifier hint). userFacingName defaults to () => def.name.

The conservative defaults mean that a new tool written without thinking about concurrency or permissions will behave safely — it will run serially and will not be auto-approved — rather than unsafely.

6.3.2 The buildTool() Implementation

buildTool() at src/Tool.ts:783-792 is deliberately simple:

typescript
// src/Tool.ts:783-792
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
  return {
    ...TOOL_DEFAULTS,
    userFacingName: () => def.name,
    ...def,   // def overrides defaults
  } as BuiltTool<D>
}

TOOL_DEFAULTS is a constant object containing all seven default implementations. The spread order matters: TOOL_DEFAULTS first, then userFacingName (which closes over def.name), then def last so that any method the author provides overrides the default. The result is a plain JavaScript object that satisfies the full Tool<Input, Output> interface.

The reason this is a factory function rather than a class or mixin is flexibility. Tool objects need to be plain values — they are stored in arrays, passed as arguments, and read by the API serialization layer. A class hierarchy would add complexity without benefit. The factory pattern lets each tool be a self-contained module that exports a single named constant.


6.4 Anatomy of FileReadTool

FileReadTool at src/tools/FileReadTool/FileReadTool.ts is the most thoroughly engineered tool in the codebase. At 1,184 lines it handles seven different file types, implements a dedup cache, performs token budget enforcement, appends a security reminder, and serializes results to five different API formats. Walking through it section by section illustrates every concept from Section 6.2 in concrete code.

6.4.1 Input Schema: lazySchema for Deferred Initialization

typescript
// src/tools/FileReadTool/FileReadTool.ts:227-243
const inputSchema = lazySchema(() =>
  z.strictObject({
    file_path: z.string().describe('The absolute path to the file to read'),
    offset: semanticNumber(z.number().int().nonnegative().optional())
      .describe('The line number to start reading from. Only provide if the file is too large to read at once'),
    limit: semanticNumber(z.number().int().positive().optional())
      .describe('The number of lines to read. Only provide if the file is too large to read at once.'),
    pages: z.string().optional()
      .describe('Page range for PDF files (e.g., "1-5", "3", "10-20")...'),
  }),
)

lazySchema() is a wrapper that defers the call to z.strictObject() until the schema is first accessed. This matters because Zod schema construction has non-trivial overhead, and tools are registered at module load time — if every tool eagerly built its schema on import, startup latency would increase meaningfully. With lazySchema, the cost is paid once, on first use, and the result is cached.

z.strictObject() (not z.object()) means that any key not declared in the schema will cause a parse failure rather than being silently ignored. This is the correct default for tool inputs because the model sometimes hallucinates extra fields, and ignoring them silently could hide bugs.

semanticNumber() is a Zod transform that accepts either a number or a numeric string (e.g., "10") and coerces it to a number. This handles a common model behavior where numeric arguments arrive as string-encoded JSON values.

offset defaults to 1 (line 1, the first line) in the call() method; the schema marks it optional so the model can omit it when reading an entire file. limit is also optional, meaning "read all lines up to the token budget". pages is PDF-specific and uses a range syntax like "1-5".

6.4.2 Output Schema: Six-Variant Discriminated Union

typescript
// src/tools/FileReadTool/FileReadTool.ts:248-332
const outputSchema = lazySchema(() =>
  z.discriminatedUnion('type', [
    z.object({ type: z.literal('text'),         file: z.object({ filePath, content, numLines, startLine, totalLines }) }),
    z.object({ type: z.literal('image'),        file: z.object({ base64, type, originalSize, dimensions }) }),
    z.object({ type: z.literal('notebook'),     file: z.object({ filePath, cells }) }),
    z.object({ type: z.literal('pdf'),          file: z.object({ filePath, base64, originalSize }) }),
    z.object({ type: z.literal('parts'),        file: z.object({ filePath, originalSize, count, outputDir }) }),
    z.object({ type: z.literal('file_unchanged'), file: z.object({ filePath }) }),
  ])
)

The type discriminant field drives both mapToolResultToToolResultBlockParam() and the UI rendering methods. Each variant carries the minimum data needed to render or serialize that format. The 'parts' variant is used when a file is so large that even the token-budget-limited read would overflow available context — in that case the file is split into parts written to a temporary directory and the model is given outputDir so it can read each part in sequence.

'file_unchanged' is the dedup output. When FileReadTool determines that a file has not changed since the last read in this session, it returns this variant instead of re-reading the file. The serialization layer converts it to a compact stub message that tells the model "the file hasn't changed since you last read it" — saving the cache_creation tokens that would otherwise be spent encoding the file content again. In production data, roughly 18% of all FileReadTool calls are same-file collisions that benefit from this dedup.

6.4.3 The call() Method: Dispatch by Extension

The call() method follows a five-step sequence:

Step 1 — Dedup check. Before doing any I/O, call() looks up the path in readFileState, a session-scoped Map<string, { mtime, range }>. If the entry exists and the file's current mtime matches the cached value and the requested range is the same, the method returns immediately with the file_unchanged variant.

Step 2 — Skill discovery. Before reading, call() inspects the file path to discover and activate any conditional skills (runtime-loaded behavior modules) relevant to this file type. This is an extensibility hook that does not affect the core read logic.

Step 3 — Extension dispatch via callInner(). The inner dispatcher routes by file extension:

typescript
// Dispatch logic inside callInner()
if (extension === '.ipynb')                  → readNotebook(file_path)
else if (imageExtensions.has(extension))     → readImageWithTokenBudget(file_path)
else if (extension === '.pdf' && pages)      → extractPDFPages(file_path, pages)
else if (extension === '.pdf')               → readPDF(file_path)
elsereadFileInRange(file_path, offset, limit)

Each branch returns a typed Output value matching one of the six discriminated union variants. readImageWithTokenBudget() accepts PNG, JPEG, GIF, WebP, and SVG; for SVG it reads the raw text rather than encoding as base64 because SVG is an XML format the model can understand directly.

Step 4 — Token budget enforcement. validateContentTokens() is called on the result before it is returned. It counts the estimated token cost of the output and throws MaxFileReadTokenExceededError if the result would overflow the current context window budget. When this error is thrown, call() catches it, splits the file into parts, and returns the 'parts' variant instead.

Step 5 — State update and listeners. After a successful read, readFileState is updated with the new mtime and range. Then fileReadListeners — a registered set of callbacks — are notified. These listeners drive downstream services such as the language server and the context window tracker.

6.4.4 Validation and Permissions

typescript
// src/tools/FileReadTool/FileReadTool.ts:418-495
async validateInput({ file_path, pages }, toolUseContext) {
  // 1. pages format validation
  // 2. deny rule check (matchingRuleForInput) — path-based, no I/O
  // 3. UNC path early pass-through
  // 4. binary extension rejection (except PDF/images/SVG)
  // 5. blocked device paths (/dev/zero, /dev/random, /dev/stdin...)
  return { result: true }
}

validateInput() has five guard clauses, each of which can return early with a rejection:

The pages format check runs first because it is cheapest — it is a pure string validation that catches malformed ranges like "abc" or "5-3" (reversed range).

The deny rule check calls matchingRuleForInput(), a path-pattern matcher that tests the file_path against the user's configured deny list. This is a path-string operation with no file system access — it cannot know whether the file exists, only whether the path pattern is blocked.

The UNC path check (\\server\share\... on Windows) exits early with a pass-through because UNC paths require different permission handling managed elsewhere.

The binary extension rejection rejects files like .exe, .dylib, .so that would produce garbage output if read as text. The exceptions are .pdf (handled by the PDF branch), image extensions (handled by the image branch), and .svg (valid XML text).

The device path check blocks /dev/zero, /dev/random, /dev/urandom, and /dev/stdin. Reading from these special files would either loop forever or expose the terminal's stdin to the model. The check is path-prefix-based, so it works even on unusual paths like /proc/1/mem.

For permissions, FileReadTool delegates to the shared checkReadPermissionForTool() helper:

typescript
async checkPermissions(input, context) {
  return checkReadPermissionForTool(FileReadTool, input, appState.toolPermissionContext)
}

This function consults the permission context to determine whether this path under this agent is covered by an existing allow rule, needs user approval, or should be denied. The full taxonomy of permission reasons is covered in Chapter 7.

6.4.5 mapToolResultToToolResultBlockParam(): Serializing for the API

typescript
// src/tools/FileReadTool/FileReadTool.ts:652-716
mapToolResultToToolResultBlockParam(data, toolUseID) {
  switch (data.type) {
    case 'image':
      return {
        type: 'tool_result',
        tool_use_id: toolUseID,
        content: [{ type: 'image', source: { type: 'base64', media_type: data.file.type, data: data.file.base64 } }],
      }
    case 'notebook':
      return mapNotebookCellsToToolResult(data.file.cells, toolUseID)
    case 'text':
      return {
        type: 'tool_result',
        tool_use_id: toolUseID,
        content: formatFileLines(data.file) + CYBER_RISK_MITIGATION_REMINDER,
      }
    case 'file_unchanged':
      return {
        type: 'tool_result',
        tool_use_id: toolUseID,
        content: FILE_UNCHANGED_STUB,
      }
    // ... pdf and parts cases
  }
}

The switch statement maps each discriminated variant to its API format. The image case wraps the base64 data in the structure the Messages API expects for image content blocks. The notebook case delegates to mapNotebookCellsToToolResult() which formats each cell's type, source, and outputs as structured text that the model can understand. The text case calls formatFileLines() to prepend line numbers (the cat -n format) and then appends CYBER_RISK_MITIGATION_REMINDER.

FILE_UNCHANGED_STUB is a short constant string that the model recognizes as "I already have this file in context, no action needed." This keeps the tool result compact while still giving the model a coherent response to its request.

6.4.6 The Dedup Mechanism: readFileState and mtime

The dedup system trades correctness for efficiency in a principled way. It assumes that if a file's mtime has not changed and the requested byte range is identical, the content is identical. This assumption holds in practice because Claude Code runs in a development environment where most file changes happen through Claude Code itself or explicit user actions — both of which update mtime.

The readFileState map is scoped to the ToolUseContext, meaning it lives for the duration of a single query() invocation. It is reset at the start of each new turn. This scoping prevents stale cache hits across turns while still catching the common pattern within a single turn where the model calls FileReadTool on the same file twice (once to understand it, once to confirm a change).

In production telemetry, the 18% hit rate for same-file collisions within a single turn translates to a meaningful reduction in cache_creation_input_tokens because file content — especially for large source files — is one of the most expensive things to re-encode in the prompt cache.

6.4.7 The CYBER_RISK_MITIGATION_REMINDER

Every text file result has a system reminder appended before it is sent to the model:

typescript
export const CYBER_RISK_MITIGATION_REMINDER = '\n\n<system-reminder>\n...'

The reminder instructs the model to analyze malware or suspicious content if asked but not to improve, optimize, or reproduce it. This is a defense-in-depth measure for the scenario where a user asks Claude Code to read a file that turns out to contain adversarial content designed to hijack the model's behavior.

The reminder is exempt for a specific set of models listed in MITIGATION_EXEMPT_MODELS. These are models that have been trained with the mitigation built into their weights rather than needing it appended as a prompt.

backfillObservableInput() handles path normalization before any of this runs:

typescript
backfillObservableInput(input) {
  if (typeof input.file_path === 'string') input.file_path = expandPath(input.file_path)
}

expandPath() resolves ~ to the home directory and converts relative paths to absolute paths based on the current working directory in ToolUseContext. This is called before hooks and permission matchers see the input, ensuring that a path like ~/project/foo.ts and /home/user/project/foo.ts are treated as the same path by deny rules, permission patterns, and the dedup cache.


6.5 The Tool Registry: getAllBaseTools()

Every tool the runtime knows about is returned by getAllBaseTools() in src/tools.ts. This function is the single source of truth for which tools exist; there is no configuration file, no plugin directory, and no dynamic discovery mechanism beyond what this function returns.

typescript
// src/tools.ts:193+
export function getAllBaseTools(): Tools {
  return [
    AgentTool, TaskOutputTool, BashTool,
    ...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
    ExitPlanModeV2Tool, FileReadTool, FileEditTool, FileWriteTool,
    NotebookEditTool, WebFetchTool, TodoWriteTool, WebSearchTool,
    TaskStopTool, AskUserQuestionTool, SkillTool, EnterPlanModeTool,
    ...(process.env.USER_TYPE === 'ant' ? [ConfigTool] : []),
    ...(SleepTool ? [SleepTool] : []),
    ...(cronTools),
    ...(MonitorTool ? [MonitorTool] : []),
    ...(REPLTool ? [REPLTool] : []),
    TestingPermissionTool, LSPTool, ToolSearchTool,
    // ... more tools
  ].filter(Boolean)
}

Several things are worth noting here.

The hasEmbeddedSearchTools() guard conditionally excludes GlobTool and GrepTool when the binary includes its own fast native search implementation. In that environment the JavaScript glob and grep tools would be redundant and slower than the embedded versions.

process.env.USER_TYPE === 'ant' gates internal tools like ConfigTool and REPLTool to Anthropic employees only. These tools expose capabilities that are too powerful or too experimental for the general user base.

SleepTool, MonitorTool, and REPLTool use conditional expressions (SleepTool ? [SleepTool] : []) because they are conditionally imported — their modules may export null when feature flags are disabled. The .filter(Boolean) at the end of the array removes any nullish values that slipped through.

cronTools is an array (not a single tool) populated when the AGENT_TRIGGERS feature is enabled. It contains the tools needed for scheduling recurring tasks.

The comment in the source notes that this list must stay in sync with the Statsig system caching configuration. The prompt cache key includes a hash of the tool list; if the list changes but the cache config does not, different users may get mismatched cache hits — a cache poisoning scenario that would cause the model to use stale tool descriptions.


6.6 Tool Orchestration: runTools()

When the model's response contains one or more tool_use blocks, the loop calls runTools() from src/services/tools/toolOrchestration.ts. This function is an async generator — it yields MessageUpdate events as tool results arrive, allowing the UI to display partial results without waiting for all tools to finish.

6.6.1 partitionToolCalls: Batching the Call List

The first operation in runTools() is partitioning the tool call list into batches:

typescript
// src/services/tools/toolOrchestration.ts:19-80
export async function* runTools(
  toolUseMessages: ToolUseBlock[],
  assistantMessages: AssistantMessage[],
  canUseTool: CanUseToolFn,
  toolUseContext: ToolUseContext,
): AsyncGenerator<MessageUpdate, void> {
  let currentContext = toolUseContext

  for (const { isConcurrencySafe, blocks } of partitionToolCalls(toolUseMessages, currentContext)) {
    if (isConcurrencySafe) {
      // concurrent batch
      for await (const update of runToolsConcurrently(blocks, assistantMessages, canUseTool, currentContext)) {
        yield { message: update.message, newContext: currentContext }
      }
    } else {
      // serial batch
      for await (const update of runToolsSerially(blocks, assistantMessages, canUseTool, currentContext)) {
        if (update.newContext) currentContext = update.newContext
        yield { message: update.message, newContext: currentContext }
      }
    }
  }
}

partitionToolCalls() walks the toolUseMessages array and groups consecutive calls into the largest possible batches. The batching rule is: a batch is concurrent if and only if every tool in the batch reports isConcurrencySafe() === true. When the scanner encounters a tool that returns false, it closes the current batch and starts a new one.

Consider a response with four tool calls: [FileRead, FileRead, BashTool, FileRead]. FileReadTool.isConcurrencySafe() returns true; BashTool.isConcurrencySafe() returns false. The partition result would be three batches: [FileRead, FileRead] (concurrent), [BashTool] (serial), [FileRead] (concurrent). The two reads before the bash run in parallel, the bash command runs alone, then the final read runs.

This design means that the model's tool call ordering controls both what runs and how it runs. If the model emits all reads before all writes, the reads can be parallelized. If it interleaves reads and writes, each write forces a serial boundary.

6.6.2 Concurrent Batches

runToolsConcurrently() dispatches all tools in the batch simultaneously using Promise.all() (or a concurrency-limited variant) and collects their results. The concurrency cap is read from an environment variable:

typescript
// src/services/tools/toolOrchestration.ts
function getMaxToolUseConcurrency(): number {
  return parseInt(process.env.CLAUDE_CODE_MAX_TOOL_USE_CONCURRENCY || '', 10) || 10
}

The default is 10 parallel tool calls. Users in environments with restrictive API rate limits can lower this value to prevent 429 errors from WebFetchTool or WebSearchTool running too many requests simultaneously.

A critical detail of concurrent execution is context modifier handling. When multiple tools each return a contextModifier, applying them in arbitrary order could produce non-deterministic session state. The concurrent runner defers all modifiers until every tool in the batch has finished, then applies them in tool_use_id order — the order the model originally emitted them. This ensures that the same tool response sequence always produces the same final context, regardless of which network call happened to complete first.

6.6.3 Serial Batches

runToolsSerially() runs each tool one at a time and applies its contextModifier immediately after it completes, before the next tool starts. This is the correct behavior for write tools because the next tool may need to observe the context changes made by the previous one — for example, if the first write tool updates the current working directory, the second tool needs that updated cwd to resolve relative paths correctly.

The serial path is also where canUseTool updates are applied. If a write operation's result triggers a permission state change (for instance, because the user denied a specific path), that change is visible to all subsequent tools in the same turn.

6.6.4 StreamingToolExecutor: Parallel Execution During Streaming

StreamingToolExecutor is a separate path from runTools that operates during the active streaming phase — while the model is still generating tokens. It is used when the model emits a tool_use block in its streaming response and the tool is marked safe to start immediately without waiting for the full response to complete.

The key difference from runTools is timing. runTools is called after the model's response is complete and all tool calls are known. StreamingToolExecutor can start a tool call as soon as the model closes the tool_use block's input JSON, potentially overlapping tool execution with the model generating subsequent content in the same response.

The conditions for streaming tool execution are stricter: the tool must be isConcurrencySafe(), must be isReadOnly(), and the model must have finished serializing the complete input JSON for that block. When these conditions are met, the executor starts the tool and collects its result in a background promise, then waits for the full response before merging all results into the tool result batch.

This optimization is most impactful for FileReadTool and GlobTool invocations where the model reads several files in sequence — the second and third reads can be dispatched while the model is still generating the tool call for the fourth.


6.7 Practical Guide: Building a New Tool from Scratch

This section walks through creating WordCountTool — a tool that counts lines, words, and characters in a text file — from an empty module to a registered, testable tool. It touches every required method and shows which defaults are appropriate to accept.

Step 1: Define the Input and Output Types

Create src/tools/WordCountTool/WordCountTool.ts:

typescript
// src/tools/WordCountTool/WordCountTool.ts

import { z } from 'zod'
import { buildTool } from '../../Tool.js'
import { lazySchema } from '../../utils/lazySchema.js'

// Input: the file path to count words in.
// Use lazySchema to defer Zod initialization until first use.
const inputSchema = lazySchema(() =>
  z.strictObject({
    file_path: z
      .string()
      .describe('The absolute path to the text file to analyze'),
  }),
)

// Output: a typed record of the three counts.
type WordCountOutput = {
  file_path: string
  lines: number
  words: number
  chars: number
}

Step 2: Implement the Tool with buildTool()

typescript
export const WordCountTool = buildTool({
  // Required identity fields
  name: 'WordCount',
  searchHint: 'count lines words characters in a text file',

  // Required schema
  inputSchema,

  // maxResultSizeChars: the result is tiny, but we must still declare it.
  // Using a finite value means the runtime will save it to disk if somehow
  // it exceeds this threshold (it won't, but the contract requires it).
  maxResultSizeChars: 4096,

  // description(): shown in the UI before the user approves
  async description({ file_path }) {
    return `Count lines, words, and characters in ${file_path}`
  },

  // prompt(): the model-visible description in the system prompt
  async prompt() {
    return [
      'Count the number of lines, words, and characters in a text file.',
      'Use this tool when you need statistics about file size or content volume.',
      '',
      'Input: file_path — the absolute path to a text file.',
      'Output: an object with fields lines, words, and chars.',
    ].join('\n')
  },

  // This tool is read-only and safe to run concurrently with other reads.
  isConcurrencySafe() { return true },
  isReadOnly() { return true },

  // validateInput: pure logic, no I/O.
  // Reject non-absolute paths before touching the file system.
  async validateInput({ file_path }) {
    if (!file_path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(file_path)) {
      return {
        result: false,
        message: `file_path must be an absolute path; received: ${file_path}`,
      }
    }
    return { result: true }
  },

  // checkPermissions: delegate to the standard read permission helper.
  // If you do not override this, buildTool() fills in "always allow",
  // which is fine for development but not for production read tools.
  async checkPermissions(input, context) {
    return checkReadPermissionForTool(WordCountTool, input, appState.toolPermissionContext)
  },

  // backfillObservableInput: expand ~ and relative paths before hooks see them.
  backfillObservableInput(input) {
    if (typeof input.file_path === 'string') {
      input.file_path = expandPath(input.file_path)
    }
  },

  // call: the actual implementation
  async call({ file_path }) {
    const content = await fs.readFile(file_path, 'utf-8')
    const lines = content.split('\n').length
    const words = content.trim() === '' ? 0 : content.trim().split(/\s+/).length
    const chars = content.length
    return {
      data: { file_path, lines, words, chars },
    }
  },

  // mapToolResultToToolResultBlockParam: serialize Output → API format
  mapToolResultToToolResultBlockParam({ file_path, lines, words, chars }, toolUseID) {
    return {
      type: 'tool_result' as const,
      tool_use_id: toolUseID,
      content: `${file_path}: ${lines} lines, ${words} words, ${chars} characters`,
    }
  },

  // renderToolUseMessage: shown in the UI while the tool is executing
  renderToolUseMessage({ file_path }) {
    return `Counting words in ${file_path}…`
  },

  // toAutoClassifierInput: hint for the auto-approval classifier.
  // Return the path so the classifier can apply path-based rules.
  toAutoClassifierInput({ file_path }) {
    return file_path
  },
})

Several decisions in this implementation are worth explaining.

isConcurrencySafe() returns true because the tool only reads; it cannot affect the file system and running multiple instances in parallel is safe. If you are unsure whether a tool is concurrency-safe, leave this unimplemented and accept the false default.

validateInput() returns early with { result: false, message: ... } for non-absolute paths. This is a pure string check — no file system access. If the path is valid, it returns { result: true }. The runtime treats any object without result: false as a pass.

backfillObservableInput() is called before permission matchers and deny rules process the input. Expanding ~ here means that ~/foo.ts and /home/user/foo.ts resolve to the same deny rule match.

checkPermissions() references WordCountTool itself (a circular-looking reference that works because the module is fully initialized by the time checkPermissions is ever called at runtime — JavaScript's module loading guarantees this).

The call() method returns { data: { file_path, lines, words, chars } }. Notice that newMessages, contextModifier, and mcpMeta are omitted — TypeScript accepts this because they are all optional fields on ToolResult.

Step 3: Register the Tool

Open src/tools.ts and add WordCountTool to the import list and the getAllBaseTools() array:

typescript
// src/tools.ts — add import near the other tool imports
import { WordCountTool } from './tools/WordCountTool/WordCountTool.js'

// Inside getAllBaseTools(), add to the array:
export function getAllBaseTools(): Tools {
  return [
    AgentTool, TaskOutputTool, BashTool,
    // ... existing tools ...
    WordCountTool,   // <-- add here
    // ... rest of list
  ].filter(Boolean)
}

No other registration is needed. On the next startup, the model's system prompt will include WordCountTool's prompt() text and its JSON schema, making it available for selection in every subsequent conversation.

Step 4: Verify the Tool Is Wired Up

Run the test suite targeted at the tools module:

bash
npx jest --testPathPattern='WordCount' --no-coverage

A minimal test should verify three things: that the schema rejects unknown keys (due to z.strictObject), that call() returns the correct counts for a known file, and that mapToolResultToToolResultBlockParam() produces a string the model can understand.


Key Takeaways

The tool system is built on a small number of composable ideas that remain consistent across all 30+ tools in the codebase.

Every tool is a plain JavaScript object satisfying Tool<Input, Output>. There are no classes, no inheritance, no decorators. The interface is a contract of named methods and properties, and buildTool() fills in safe conservative defaults for the seven keys that most tools do not need to customize.

The ToolDef / Tool split encodes the distinction between what tool authors need to think about (the required methods) and what the runtime needs to operate correctly (the complete interface). TypeScript enforces this split at compile time.

Validation and permission checking are separated into two methods with different contracts. validateInput() is pure logic with no I/O. checkPermissions() consults the session's permission context and may involve user interaction. This separation means validation errors and permission denials are reported differently to the model.

The orchestration layer in toolOrchestration.ts uses isConcurrencySafe() to automatically parallelize groups of read-only tool calls while ensuring that write operations run in strict sequence. Tool authors control this behavior through a single boolean method rather than managing concurrency primitives themselves.

FileReadTool is the reference implementation that demonstrates every advanced feature: lazySchema, discriminated union outputs, backfillObservableInput, validateInput with multiple guard clauses, dedup via readFileState, token budget enforcement, and format-specific API serialization. Reading it in full is the fastest way to understand every corner of the tool interface.

When building a new tool, the checklist is: define a lazySchema for the input, decide whether the tool is read-only and concurrency-safe, implement validateInput() for pure checks, implement call() for actual I/O, implement mapToolResultToToolResultBlockParam() for API serialization, and add the tool to getAllBaseTools() in src/tools.ts.

Built for learners who want to read Claude Code like a real system.