Chapter 3: Tool System
Source references: All line numbers refer to the Claude Code open-source snapshot at
anthhub/claude-code(theanthhub-claude-codemirror used throughout this guide).
Table of Contents
- Introduction
- Tool Interface Deep Dive
- The buildTool() Factory
- Tool Registration: Three-Layer Architecture
- Core Tool Implementations
- 5.1 BashTool
- 5.2 FileEditTool
- 5.3 FileReadTool
- 5.4 GlobTool and GrepTool
- 5.5 AgentTool
- Tool Execution Flow
- ToolSearch and Deferred Loading
- Tool Result Storage
- Hands-on: Build Your Own Tool
- Key Takeaways and What's Next
- Hands-on: API Service Layer and System Prompt
1. Introduction
The tool system is the capability layer of Claude Code. Every action Claude takes—reading a file, running a shell command, searching code, spawning a sub-agent—goes through this system. Understanding it unlocks the ability to extend Claude Code with custom capabilities, reason about permission boundaries, and trace execution paths from model output to actual side effects.
At its heart, the tool system answers three questions:
- What can Claude do? — The
Toolinterface insrc/Tool.tsdefines a contract every tool must satisfy. - Which tools are available right now? —
src/tools.tsimplements a three-layer registration and filtering mechanism. - How does a tool call become real? — The execution flow converts a model's
tool_useblock into a validated, permission-checked, side-effect-producing result.
graph LR
A[Claude Model Output<br/>tool_use block] --> B[Tool Registry<br/>src/tools.ts]
B --> C[Permission Check<br/>checkPermissions]
C --> D[Input Validation<br/>validateInput]
D --> E[Tool Execution<br/>call]
E --> F[Result Storage<br/>maxResultSizeChars]
F --> G[Render to UI<br/>renderToolResultMessage]
G --> H[Feed back to Model<br/>tool_result block]Key Takeaways (Introduction)
- Every Claude action is a tool call
- The
Toolinterface is the contract;buildTool()is the factory;tools.tsis the registry - The system is both typesafe (TypeScript generics) and runtime-safe (permission checks, validation)
2. Tool Interface Deep Dive
The Tool type is defined in src/Tool.ts (lines 362–695). It is generic across three type parameters:
// src/Tool.ts lines 362-365
export type Tool<
Input extends AnyObject = AnyObject,
Output = unknown,
P extends ToolProgressData = ToolProgressData,
> = { ... }Input— a Zod schema type; constrains what the model can passOutput— the TypeScript type of the result returned bycall()P— progress event type for streaming updates
2.1 Identity Fields
// src/Tool.ts lines 371-377
readonly name: string
aliases?: string[]
searchHint?: string| Field | Purpose | Example |
|---|---|---|
name | Primary identifier used in API calls | "Bash", "Edit" |
aliases | Legacy names for backwards compat when a tool is renamed | ["computer_tool"] |
searchHint | 3–10 word phrase for keyword search when tool is deferred | "modify file contents in place" |
aliases are used by toolMatchesName() (line 348):
// src/Tool.ts lines 348-353
export function toolMatchesName(
tool: { name: string; aliases?: string[] },
name: string,
): boolean {
return tool.name === name || (tool.aliases?.includes(name) ?? false)
}searchHint feeds the ToolSearch deferred-loading system (see §7). The hint should not repeat the tool name—it should provide complementary vocabulary (e.g. "jupyter" for NotebookEdit).
2.2 Core Methods: call, checkPermissions, validateInput
call() — The actual implementation (lines 379-385):
// src/Tool.ts lines 379-385
call(
args: z.infer<Input>,
context: ToolUseContext,
canUseTool: CanUseToolFn,
parentMessage: AssistantMessage,
onProgress?: ToolCallProgress<P>,
): Promise<ToolResult<Output>>ToolUseContext (lines 158-299) is a rich bag of everything the tool might need: abortController, readFileState, getAppState(), setToolJSX, messages, permission context, and more. Tools receive a snapshot of this context; it is not mutable by tools directly.
ToolResult<Output> (lines 321-336):
// src/Tool.ts lines 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> }
}The contextModifier field lets tools like AgentTool alter the context for subsequent turns (e.g., injecting conversation history from a sub-agent).
validateInput() — Pre-execution validation (lines 489-492):
// src/Tool.ts lines 489-492
validateInput?(
input: z.infer<Input>,
context: ToolUseContext,
): Promise<ValidationResult>ValidationResult (lines 95-101) is either { result: true } or { result: false; message: string; errorCode: number }. This runs before checkPermissions, so it can short-circuit without ever showing a permission prompt.
checkPermissions() — User-facing permission gate (lines 500-503):
// src/Tool.ts lines 500-503
checkPermissions(
input: z.infer<Input>,
context: ToolUseContext,
): Promise<PermissionResult>PermissionResult has a behavior field: 'allow', 'deny', 'ask'. When 'ask', the UI shows a permission dialog. The general permission logic lives in permissions.ts; checkPermissions contains tool-specific logic only.
Execution order:
sequenceDiagram
participant M as Model
participant R as Runtime
participant V as validateInput
participant P as checkPermissions
participant C as call()
M->>R: tool_use block
R->>V: run validateInput()
V-->>R: ValidationResult
alt result: false
R->>M: error tool_result
else result: true
R->>P: run checkPermissions()
P-->>R: PermissionResult
alt behavior: deny
R->>M: error tool_result
else behavior: ask
R->>R: show permission dialog
R->>P: wait for user
else behavior: allow
R->>C: run call()
C-->>R: ToolResult
R->>M: tool_result
end
end2.3 Render Methods
Tools control their own UI rendering through five optional render methods:
| Method | When called | Notes |
|---|---|---|
renderToolUseMessage | While parameters stream in (partial input!) | Input is Partial<z.infer<Input>> |
renderToolUseProgressMessage | During execution | Receives ProgressMessage[] |
renderToolUseQueuedMessage | When tool is waiting in queue | Optional |
renderToolResultMessage | After execution completes | Full output available |
renderToolUseRejectedMessage | When user denies permission | Optional; falls back to generic |
renderToolUseErrorMessage | When tool throws | Optional; falls back to generic |
The style?: 'condensed' option passed to renderToolResultMessage lets the UI request a compact summary instead of the full output—used in non-verbose mode.
2.4 Behavioral Flags
// src/Tool.ts lines 402-416
isConcurrencySafe(input: z.infer<Input>): boolean
isEnabled(): boolean
isReadOnly(input: z.infer<Input>): boolean
isDestructive?(input: z.infer<Input>): boolean
interruptBehavior?(): 'cancel' | 'block'| Flag | Default | Meaning |
|---|---|---|
isConcurrencySafe | false (from TOOL_DEFAULTS) | Can this tool run in parallel with others? |
isEnabled | true | Is the tool available at all? |
isReadOnly | false | Informs --no-write / read-only mode enforcement |
isDestructive | false | Irreversible operations (delete, overwrite, send) |
interruptBehavior | 'block' | What happens when user sends a new message mid-run |
GlobTool and GrepTool both set isConcurrencySafe() { return true } (GlobTool line 76-78), enabling Claude to fire multiple searches simultaneously without a serialization barrier.
2.5 Result Storage: maxResultSizeChars
// src/Tool.ts lines 464-467
maxResultSizeChars: numberEvery tool must set this limit. When a tool result exceeds this threshold, the result is written to disk and Claude receives a preview plus a file path instead of the full content.
Special values:
Infinity— Never persist (used byFileReadToolto prevent circular Read→file→Read loops)100_000— Common value for most tools (BashTool, GlobTool, GrepTool, FileEditTool)
2.6 Deferred Loading: shouldDefer
// src/Tool.ts lines 438-449
readonly shouldDefer?: boolean
readonly alwaysLoad?: booleanWhen ToolSearch is active, tools with shouldDefer: true are excluded from the initial system prompt. The model uses ToolSearch to discover and load them on demand. alwaysLoad: true forces inclusion even when ToolSearch is enabled—useful for tools the model must see on turn 1.
Key Takeaways (Interface)
Tool<Input, Output, P>is generic: Zod schema types flow through everywherevalidateInputruns before permissions; use it for cheap format checksmaxResultSizeCharsis required and guards against context window overflowisConcurrencySafeunlocks parallel execution—set it when safe
3. The buildTool() Factory
Rather than implementing the full Tool interface directly, every tool in the codebase uses buildTool():
// src/Tool.ts lines 783-792
export function buildTool<D extends AnyToolDef>(def: D): BuiltTool<D> {
return {
...TOOL_DEFAULTS,
userFacingName: () => def.name,
...def,
} as BuiltTool<D>
}The defaults (lines 757-769) are fail-closed where it matters:
// src/Tool.ts lines 757-769
const TOOL_DEFAULTS = {
isEnabled: () => true,
isConcurrencySafe: (_input?: unknown) => false, // assume not safe
isReadOnly: (_input?: unknown) => false, // assume writes
isDestructive: (_input?: unknown) => false,
checkPermissions: (input, _ctx?) =>
Promise.resolve({ behavior: 'allow', updatedInput: input }),
toAutoClassifierInput: (_input?: unknown) => '', // skip classifier
userFacingName: (_input?: unknown) => '',
}The ToolDef type (lines 721-726) is Tool with all defaultable keys made optional:
// src/Tool.ts lines 721-726
export type ToolDef<Input, Output, P> =
Omit<Tool<Input, Output, P>, DefaultableToolKeys> &
Partial<Pick<Tool<Input, Output, P>, DefaultableToolKeys>>And BuiltTool<D> (lines 735-741) mirrors the runtime spread at the type level—meaning TypeScript knows exactly which methods came from def (with their original literal types) versus which came from defaults.
Pattern used in every tool:
export const MyTool = buildTool({
name: 'MyTool',
maxResultSizeChars: 100_000,
// ... only what you need to override
} satisfies ToolDef<...>)The satisfies keyword (or the ToolDef constraint on buildTool) ensures TypeScript catches missing required fields at compile time.
Key Takeaways (buildTool)
buildTool()is the single factory — never constructToolobjects manually- Defaults are fail-closed:
isConcurrencySafe=false,isReadOnly=false BuiltTool<D>preserves literal types from your definitionsatisfies ToolDef<...>catches type errors at the definition site
4. Tool Registration: Three-Layer Architecture
src/tools.ts implements three increasingly refined views of the tool pool:
graph TD
A[getAllBaseTools<br/>Complete exhaustive list<br/>respects process.env flags] --> B[getTools<br/>Filtered by permission context<br/>isEnabled, deny rules, REPL mode]
B --> C[assembleToolPool<br/>Built-in + MCP tools<br/>deduplicated by name]Layer 1: getAllBaseTools()
// src/tools.ts lines 193-251
export function getAllBaseTools(): Tools {
return [
AgentTool,
TaskOutputTool,
BashTool,
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),
// ... many more
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),
]
}This is the source of truth for all tools. The comment on line 191-192 flags that it must stay in sync with the Statsig dynamic config used for system prompt caching. Conditional includes handle:
- Feature flags (
feature('PROACTIVE'),feature('KAIROS')) - Environment variables (
process.env.USER_TYPE === 'ant') - Optional capabilities (
isTodoV2Enabled(),isWorktreeModeEnabled())
Layer 2: getTools()
// src/tools.ts lines 271-327
export const getTools = (permissionContext: ToolPermissionContext): Tools => {
// Simple mode: only Bash, Read, Edit
if (isEnvTruthy(process.env.CLAUDE_CODE_SIMPLE)) { ... }
const tools = getAllBaseTools().filter(tool => !specialTools.has(tool.name))
let allowedTools = filterToolsByDenyRules(tools, permissionContext)
// REPL mode: hide primitive tools
if (isReplModeEnabled()) { ... }
const isEnabled = allowedTools.map(_ => _.isEnabled())
return allowedTools.filter((_, i) => isEnabled[i])
}filterToolsByDenyRules() (lines 262-269) uses the same wildcard matcher as the runtime permission check—so mcp__server in a deny rule strips all tools from that MCP server before the model even sees them.
Layer 3: assembleToolPool()
// src/tools.ts lines 345-367
export function assembleToolPool(
permissionContext: ToolPermissionContext,
mcpTools: Tools,
): Tools {
const builtInTools = getTools(permissionContext)
const allowedMcpTools = filterToolsByDenyRules(mcpTools, permissionContext)
const byName = (a: Tool, b: Tool) => a.name.localeCompare(b.name)
return uniqBy(
[...builtInTools].sort(byName).concat(allowedMcpTools.sort(byName)),
'name',
)
}The sort-then-deduplicate pattern is deliberate: built-ins and MCP tools are sorted separately before concatenation, keeping built-ins as a contiguous prefix. This preserves the server's prompt-cache breakpoint—interleaving MCP tools alphabetically into built-ins would bust cache keys for every user who adds an MCP server.
Key Takeaways (Registration)
getAllBaseTools()→getTools()→assembleToolPool()is the three-layer funnel- Deny rules strip tools before the model sees them, not just at call time
- Sort stability matters for prompt caching—built-ins form a stable prefix
5. Core Tool Implementations
5.1 BashTool
File: src/tools/BashTool/BashTool.tsx
BashTool is the most complex tool in the system. Its key implementation details:
AST-Based Security Parsing
// src/tools/BashTool/BashTool.tsx line 17
import { parseForSecurity } from '../../utils/bash/ast.js'Commands are parsed into an AST before execution. This enables:
- Detecting
cdcommands to reset CWD if outside project (resetCwdIfOutsideProject) - Parsing compound commands to classify read vs. write operations
- Extracting permission-matchable command prefixes
Input Schema
// src/tools/BashTool/BashTool.tsx lines 227-247
const fullInputSchema = lazySchema(() => z.strictObject({
command: z.string(),
timeout: semanticNumber(z.number().optional()),
description: z.string().optional(),
run_in_background: semanticBoolean(z.boolean().optional()),
dangerouslyDisableSandbox: semanticBoolean(z.boolean().optional()),
_simulatedSedEdit: z.object({ ... }).optional(), // internal only
}))semanticNumber and semanticBoolean are wrapper schemas that accept both typed values and string representations—the model sometimes serializes booleans as "true" strings.
Auto-Backgrounding
// src/tools/BashTool/BashTool.tsx lines 56-57
const ASSISTANT_BLOCKING_BUDGET_MS = 15_000In assistant mode, blocking bash commands that take longer than 15 seconds are automatically moved to background tasks. The user sees a BackgroundHint UI component.
Output Truncation
BashTool uses EndTruncatingAccumulator (imported at line 36) to cap output. The accumulator streams output and truncates from the end when the limit is reached, ensuring the most recent output is preserved.
Collapsible UI Classification
// src/tools/BashTool/BashTool.tsx lines 59-67
const BASH_SEARCH_COMMANDS = new Set(['find', 'grep', 'rg', ...])
const BASH_READ_COMMANDS = new Set(['cat', 'head', 'tail', ...])
const BASH_LIST_COMMANDS = new Set(['ls', 'tree', 'du'])The isSearchOrReadBashCommand() function (lines 95-172) parses the command and classifies it for collapsible display. All parts of a pipeline must be read/search commands for the whole command to collapse.
5.2 FileEditTool
File: src/tools/FileEditTool/FileEditTool.ts
Modification Time Verification
FileEditTool tracks file modification times to detect concurrent edits. Before applying a patch, it checks that the file hasn't been modified outside Claude since it was last read:
// src/tools/FileEditTool/FileEditTool.ts line 56
import { FILE_UNEXPECTEDLY_MODIFIED_ERROR } from './constants.js'If the modification time doesn't match, the edit is rejected with FILE_UNEXPECTEDLY_MODIFIED_ERROR, preventing silent overwrites of concurrent changes.
Fuzzy String Matching
// src/tools/FileEditTool/utils.ts (referenced at line 72)
import { findActualString, getPatchForEdit } from './utils.js'findActualString implements fuzzy matching when old_string doesn't match exactly—handling whitespace normalization and similar minor differences. This prevents tool failures when the model quotes strings with slightly different formatting.
LSP Integration
// src/tools/FileEditTool/FileEditTool.ts lines 5-7
import { getLspServerManager } from '../../services/lsp/manager.js'
import { clearDeliveredDiagnosticsForFile } from '../../services/lsp/LSPDiagnosticRegistry.js'After an edit, FileEditTool notifies the LSP server to refresh diagnostics. This keeps the IDE's error highlighting in sync with Claude's changes.
OOM Protection
// src/tools/FileEditTool/FileEditTool.ts lines 84
const MAX_EDIT_FILE_SIZE = 1024 * 1024 * 1024 // 1 GiBFiles larger than 1 GiB are rejected before any string manipulation—V8/Bun string length limit is ~2^30 characters, so 1 GiB is the safe ceiling.
5.3 FileReadTool
File: src/tools/FileReadTool/FileReadTool.ts
Multi-Format Support
FileReadTool handles several formats beyond plain text:
- Jupyter notebooks (
.ipynb) — maps cells to readable output viamapNotebookCellsToToolResult - PDFs — extracts pages via
readPDF, supports page ranges - Images — detected by extension, compressed and base64-encoded
- Binary files — detected via
hasBinaryExtension, rejected with helpful error
Image Compression
// src/tools/FileReadTool/FileReadTool.ts lines 48-51
import {
compressImageBufferWithTokenLimit,
maybeResizeAndDownsampleImageBuffer,
} from '../../utils/imageResizer.js'Images are compressed to fit within token limits before being embedded. The resizer uses adaptive quality reduction.
Blocking Device Protection
// src/tools/FileReadTool/FileReadTool.ts lines 97-104
const BLOCKED_DEVICE_PATHS = new Set([
'/dev/zero', // infinite output
// ...
])Paths like /dev/zero would cause the process to hang reading infinite output. FileReadTool checks the path before any I/O.
maxResultSizeChars: Infinity
// src/tools/FileReadTool/FileReadTool.ts (via buildTool call)
maxResultSizeChars: InfinityFileReadTool sets maxResultSizeChars to Infinity, bypassing the disk-persistence path. Persisting a file read result would create a circular dependency: Read → persist to file → next Read reads the persisted file → etc. FileReadTool self-bounds through its own limit and offset parameters instead.
5.4 GlobTool and GrepTool
Files: src/tools/GlobTool/GlobTool.ts, src/tools/GrepTool/GrepTool.ts
Both tools are concurrency-safe (isConcurrencySafe() { return true }) and read-only, enabling Claude to fire multiple parallel searches.
GlobTool
// src/tools/GlobTool/GlobTool.ts lines 57-78
export const GlobTool = buildTool({
name: GLOB_TOOL_NAME,
searchHint: 'find files by name pattern or wildcard',
maxResultSizeChars: 100_000,
isConcurrencySafe() { return true },
isReadOnly() { return true },
// ...
})Output schema (lines 39-52) includes truncated: boolean—results are capped at 100 files. The model must use more specific patterns or narrow the search directory if truncated.
GrepTool
// src/tools/GrepTool/GrepTool.ts line 21
import { ripGrep } from '../../utils/ripgrep.js'GrepTool wraps ripgrep (not grep) for performance on large codebases. The input schema mirrors ripgrep's flags: -B, -A, -C for context lines, -i for case-insensitive, type for file type filtering.
The output_mode field supports three modes:
"content"— matching lines with optional context"files_with_matches"— only file paths (default, lower cost)"count"— match count per file
Embedded Search Tools
// src/tools.ts line 201
...(hasEmbeddedSearchTools() ? [] : [GlobTool, GrepTool]),On Ant-internal builds, bfs and ugrep are embedded in the binary. When present, find and grep in Claude's shell are aliased to these faster tools—so the dedicated Glob/Grep tools become redundant and are excluded.
5.5 AgentTool
File: src/tools/AgentTool/AgentTool.tsx
AgentTool is Claude's mechanism for spawning sub-agents. It is the most architecturally significant tool because it implements the agent coordination system (Chapter 9).
Key behaviors:
- Assembles a fresh tool pool for the sub-agent via
assembleToolPool()(line 16 import) - Supports worktrees — can fork a git worktree for isolation
- Supports remote agents — can teleport to a remote machine
- Auto-backgrounding — long-running agents move to background after
getAutoBackgroundMs()(120 seconds by default when enabled)
// src/tools/AgentTool/AgentTool.tsx lines 72-76
function getAutoBackgroundMs(): number {
if (isEnvTruthy(process.env.CLAUDE_AUTO_BACKGROUND_TASKS) ||
getFeatureValue_CACHED_MAY_BE_STALE('tengu_auto_background_agents', false)) {
return 120_000
}
return 0
}Key Takeaways (Core Tools)
- BashTool uses AST parsing for security—it's more than
exec(command) - FileEditTool validates modification time to prevent silent overwrites
- FileReadTool sets
maxResultSizeChars: Infinityto avoid circular persistence - GlobTool/GrepTool are
isConcurrencySafe: true, enabling parallel search - AgentTool assembles an independent tool pool for each sub-agent
6. Tool Execution Flow
The following diagram traces a complete tool call from model output to feedback:
sequenceDiagram
participant API as Anthropic API
participant Q as query.ts
participant TR as Tool Registry<br/>(tools.ts)
participant V as validateInput()
participant CP as checkPermissions()
participant T as Tool.call()
participant RS as Result Storage
participant UI as Ink UI
API->>Q: stream tool_use block
Q->>TR: findToolByName(name)
TR-->>Q: Tool instance
Q->>UI: renderToolUseMessage(partial input)
Q->>V: validateInput(fullInput, context)
V-->>Q: ValidationResult
alt invalid
Q->>API: tool_result { error }
else valid
Q->>CP: checkPermissions(input, context)
CP-->>Q: PermissionResult
alt behavior: ask
Q->>UI: show permission dialog
UI-->>Q: user decision
end
Q->>T: call(args, context, canUseTool, msg, onProgress)
T-->>Q: ToolResult<Output>
Q->>RS: check result size vs maxResultSizeChars
alt exceeds limit
RS->>RS: write to disk
RS-->>Q: preview + file path
end
Q->>UI: renderToolResultMessage(output)
Q->>API: tool_result block
endThe query.ts orchestrator (not shown in the source listing, but the central coordinator) handles the streaming nature of tool_use blocks—renderToolUseMessage is called with partial input as parameters stream in, giving the UI real-time feedback before execution begins.
Key Takeaways (Execution Flow)
- Rendering starts before validation—partial input is streamed to UI immediately
validateInput→checkPermissions→callis the guaranteed order- Result size is checked after execution; large results go to disk transparently
7. ToolSearch and Deferred Loading
When the tool pool exceeds a certain token threshold, Claude Code enables ToolSearch. This mechanism reduces initial prompt length by deferring tool schemas.
graph LR
A[Tool Pool] --> B{Token threshold<br/>exceeded?}
B -->|No| C[All tools in prompt]
B -->|Yes| D[alwaysLoad tools in prompt]
D --> E[shouldDefer tools<br/>excluded from prompt]
E --> F[ToolSearchTool added]
F --> G[Model calls ToolSearch<br/>to discover tools]
G --> H[Tool schemas loaded<br/>on demand]The optimistic check at registration time:
// src/tools.ts lines 248-250
...(isToolSearchEnabledOptimistic() ? [ToolSearchTool] : []),searchHint is the vocabulary for keyword matching. When the model needs a capability (e.g., "edit a Jupyter notebook"), it calls ToolSearch with keywords like "jupyter notebook", which matches NotebookEdit's searchHint: 'jupyter'.
Key Takeaways (ToolSearch)
shouldDefer: trueexcludes a tool from initial promptalwaysLoad: trueforces inclusion even when ToolSearch is onsearchHintis the discovery vocabulary — make it complementary to the tool name
8. Tool Result Storage
When ToolResult.data serializes to more than maxResultSizeChars characters, the result storage system kicks in:
// src/tools/BashTool/BashTool.tsx line 40
import { buildLargeToolResultMessage, ensureToolResultsDir,
generatePreview, getToolResultPath, PREVIEW_SIZE_BYTES
} from '../../utils/toolResultStorage.js'The flow:
- Serialize the result
- Compare length to
maxResultSizeChars - If exceeded: write full content to
~/.claude/tool_results/<uuid>.txt - Return a preview (first
PREVIEW_SIZE_BYTESbytes) plus the file path - Claude receives the preview and can read the full file via
FileReadToolif needed
This prevents any single tool result from consuming the entire context window.
Key Takeaways (Result Storage)
maxResultSizeCharsis a hard wall — exceeded results always go to diskInfinitybypasses disk persistence (use for tools that self-bound their output)- Preview + path allows Claude to read the full result on demand
9. Hands-on: Build Your Own Tool
Let's build a WordCountTool that counts words, lines, and characters in text.
Step 1: Define the Tool
// examples/03-tool-system/simple-tool.ts
import { buildTool } from './tool-interface'
import { z } from 'zod'
export const WordCountTool = buildTool({
name: 'WordCount',
searchHint: 'count words lines characters in text',
maxResultSizeChars: 10_000,
inputSchema: z.object({
text: z.string().describe('The text to analyze'),
include_details: z.boolean().optional()
.describe('Include per-line breakdown'),
}),
async call(args, _context) {
const lines = args.text.split('\n')
const words = args.text.split(/\s+/).filter(w => w.length > 0)
const chars = args.text.length
const result = {
lines: lines.length,
words: words.length,
chars,
...(args.include_details ? {
breakdown: lines.map((line, i) => ({
lineNumber: i + 1,
words: line.split(/\s+/).filter(w => w.length > 0).length,
chars: line.length,
}))
} : {}),
}
return { data: result }
},
// Read-only: pure computation, no side effects
isReadOnly() { return true },
// Safe to run in parallel with other tools
isConcurrencySafe() { return true },
renderToolUseMessage(input) {
const preview = (input.text ?? '').slice(0, 50)
return `Counting words in: "${preview}${(input.text?.length ?? 0) > 50 ? '...' : ''}"`
},
renderToolResultMessage(content) {
return `${content.words} words, ${content.lines} lines, ${content.chars} chars`
},
mapToolResultToToolResultBlockParam(content, toolUseID) {
return {
type: 'tool_result',
tool_use_id: toolUseID,
content: JSON.stringify(content),
}
},
async prompt() {
return 'Count words, lines, and characters in text.'
},
async description() {
return 'Counts words, lines, and characters in the provided text.'
},
async checkPermissions(_input, _context) {
return { behavior: 'allow', updatedInput: _input }
},
})Step 2: Register the Tool
// In your custom tools.ts or plugin entrypoint:
import { WordCountTool } from './simple-tool'
// Add to your tool pool alongside built-in tools
const myTools = [...getTools(permissionContext), WordCountTool]Step 3: Verify the Interface Contract
The buildTool() factory will fill in defaults for:
isEnabled: () => trueisConcurrencySafe: () => false(we override this)isReadOnly: () => false(we override this)checkPermissions: () => allow(we explicitly implement)toAutoClassifierInput: () => ''userFacingName: () => 'WordCount'
Common mistakes to avoid:
- Forgetting
maxResultSizeChars— TypeScript will catch this (required field) - Setting
isConcurrencySafe: truefor tools with side effects - Using
mapToolResultToToolResultBlockParamto return raw objects instead of strings - Setting
maxResultSizeChars: Infinityfor tools that don't self-bound their output
Key Takeaways (Build Your Own)
- Use
buildTool()— never implementTooldirectly - Override only what differs from defaults
- Set
isReadOnly: trueandisConcurrencySafe: truefor pure read tools mapToolResultToToolResultBlockParammust return aToolResultBlockParam
10. Key Takeaways and What's Next
Summary
The tool system is built around a clear separation of concerns:
| Concern | Where it lives |
|---|---|
| Contract definition | src/Tool.ts — Tool<Input, Output, P> |
| Default behaviors | src/Tool.ts — TOOL_DEFAULTS, buildTool() |
| Registration & filtering | src/tools.ts — three-layer funnel |
| Permission checking | checkPermissions() + src/utils/permissions/ |
| Input validation | validateInput() per tool |
| Result size limits | maxResultSizeChars per tool |
| Deferred loading | shouldDefer, alwaysLoad, ToolSearch |
| UI rendering | Render methods per tool |
Architecture Diagram
graph TB
subgraph "Tool Definition Layer"
TI[Tool Interface<br/>src/Tool.ts:362]
BT[buildTool factory<br/>src/Tool.ts:783]
TD[TOOL_DEFAULTS<br/>src/Tool.ts:757]
end
subgraph "Registration Layer"
AB[getAllBaseTools<br/>src/tools.ts:193]
GT[getTools<br/>src/tools.ts:271]
AT[assembleToolPool<br/>src/tools.ts:345]
end
subgraph "Execution Layer"
V[validateInput]
CP[checkPermissions]
CL[call]
RS[Result Storage]
end
subgraph "UI Layer"
RUM[renderToolUseMessage]
RRM[renderToolResultMessage]
end
TD --> BT
TI --> BT
BT --> AB
AB --> GT
GT --> AT
AT --> V
V --> CP
CP --> CL
CL --> RS
RS --> RRM
CL --> RUMWhat's Next
- Chapter 4: Command System — How
/commands(slash commands) work alongside tools; theCommandtype and how it differs fromTool - Chapter 7: Permission System — Deep dive into
ToolPermissionContext, deny rules, and the permission dialog flow - Chapter 9: Agent Coordination — How
AgentToolspawns sub-agents, manages their tool pools, and handles async lifecycles
11. Hands-on: API Service Layer and System Prompt
In the previous chapter, we added the tool factory and registry to mini-claude. This chapter introduces two new files: an API client for communicating with Anthropic, and a system prompt builder that tells the AI who it is and what it can do.
11.1 Project Structure Update
demo/
├── main.ts
├── Tool.ts # Chapter 2
├── tools.ts # Chapter 2
├── context.ts # ← New: system prompt builder
├── services/
│ └── api/
│ └── claude.ts # ← New: Anthropic API client
└── types/ # Chapter 111.2 context.ts: System Prompt Builder
Open demo/context.ts — this is the first core file in this chapter.
What the System Prompt Does
The system prompt is the first message sent to the AI, defining its identity, available capabilities, and behavioral rules. Without a system prompt, the AI is a generic chatbot; with one, it knows it's a programming assistant that can read/write files and execute commands.
buildSystemPrompt() Construction Logic
The buildSystemPrompt() function assembles the system prompt from several parts:
- Identity declaration — tells the AI "you are a programming assistant"
- Tool descriptions — extracts names and descriptions of all available tools from the registry, so the AI knows what it can do
- Working directory — tells the AI which directory it's working in, avoiding path confusion
- Behavioral rules — constraints like "prefer editing existing files over creating new ones"
This is a pure function: given a tool list and working directory, it outputs a formatted prompt string.
Comparison with Real Claude Code
Real Claude Code's system prompt spans thousands of tokens with a layered priority system (system prompt → CLAUDE.md → user instructions). Our simplified version captures the core idea — telling the AI its identity and capabilities — but omits the priority chain, CLAUDE.md merging, dynamic feature flag injection, and other complex mechanisms.
11.3 services/api/claude.ts: API Client
Open demo/services/api/claude.ts — this is the second core file.
createClient(): Creating the SDK Instance
createClient() wraps the Anthropic SDK initialization. It reads the ANTHROPIC_API_KEY environment variable and creates a reusable client instance.
streamMessage(): Streaming Response Handler
streamMessage() is the core function, using the AsyncGenerator pattern for streaming responses. It sends a request to the API, then yields events one by one:
text— text tokens output by the modeltool_use_start— the model begins calling a tool (with tool name)tool_use_delta— incremental fragments of tool call argumentstool_use_end— tool call arguments fully transmittedmessage_end— the entire message is complete
Why AsyncGenerator?
AsyncGenerator (async function*) lets the caller process each event in real time rather than waiting for the complete response. This is critical for user experience:
Non-streaming: wait for full response → process at once (simple but slow)
Streaming: receive token by token → render in real time (complex but fast, better UX)By consuming the event stream with for await (const event of streamMessage(...)), the caller can:
- Render text in the terminal in real time
- Show UI feedback immediately when a tool call starts
- Execute tools as soon as full arguments are received
This pattern mirrors the streaming logic in real Claude Code's src/services/api/claude.ts.
11.4 Running the Demo
cd demo
bun run main.ts # Works without API key (shows tool registry and system prompt)
ANTHROPIC_API_KEY=sk-xxx bun run main.ts # With a key, experience streaming calls11.5 Mapping to Real Claude Code
| Demo File | Real File | What's Simplified |
|---|---|---|
services/api/claude.ts | src/services/api/claude.ts | Single provider, no retries, no caching |
context.ts | System prompt construction logic | No priority chain, no CLAUDE.md merging |
These simplifications preserve the core architectural patterns: SDK wrapping, streaming event handling, and system prompt assembly. The next chapter will wire these components together.
What's Next
Chapter 4 will implement query.ts — the query loop (Agentic Loop), connecting API calls and tool execution to complete the autonomous reasoning cycle.
Source references in this chapter: src/Tool.ts (all line numbers relative to the anthhub-claude-code snapshot), src/tools.ts, src/tools/BashTool/BashTool.tsx, src/tools/FileEditTool/FileEditTool.ts, src/tools/FileReadTool/FileReadTool.ts, src/tools/GlobTool/GlobTool.ts, src/tools/GrepTool/GrepTool.ts, src/tools/AgentTool/AgentTool.tsx.
Demo code for this chapter: demo/context.ts, demo/services/api/claude.ts