Tool Dispatch
Tool dispatch is the mechanism that bridges Claude’s intent (expressed as tool_use blocks in the API response) with actual code execution. This chapter covers the dispatch pipeline from block detection through result injection.
Dispatch Architecture
Section titled “Dispatch Architecture”graph TD A[API Stream] --> B{tool_use block?} B -- yes --> C{StreamingToolExecutor enabled?} C -- yes --> D[addTool to executor] C -- no --> E[Collect in toolUseBlocks array] B -- no --> F[Continue streaming]
D --> G{isConcurrencySafe?} G -- yes --> H[Start immediately if safe] G -- no --> I[Queue for serial execution]
E --> J[Stream ends] J --> K[runTools - batch execution]
H --> L[Yield results during stream] I --> M[getRemainingResults after stream] K --> MTool Detection During Streaming
Section titled “Tool Detection During Streaming”As the API response streams in, the queryLoop inspects each assistant message for tool_use content blocks:
// src/query.ts — inside the streaming loopif (message.type === 'assistant') { assistantMessages.push(message);
const msgToolUseBlocks = message.message.content.filter( content => content.type === 'tool_use', ) as ToolUseBlock[];
if (msgToolUseBlocks.length > 0) { toolUseBlocks.push(...msgToolUseBlocks); needsFollowUp = true; // signals the loop to continue after this iteration }
// Feed to streaming executor for immediate execution if (streamingToolExecutor && !toolUseContext.abortController.signal.aborted) { for (const toolBlock of msgToolUseBlocks) { streamingToolExecutor.addTool(toolBlock, message); } }}A ToolUseBlock has this shape:
type ToolUseBlock = { type: 'tool_use'; id: string; // e.g., "toolu_01XFDUDYJgAACzvnptvVer6u" name: string; // e.g., "Bash", "FileRead", "mcp__server__tool" input: unknown; // tool-specific parameters};The StreamingToolExecutor
Section titled “The StreamingToolExecutor”The StreamingToolExecutor in src/services/tools/StreamingToolExecutor.ts is the default execution engine. It starts tool execution while the API is still streaming, which can save seconds per turn.
Tool Status State Machine
Section titled “Tool Status State Machine”Each tool tracked by the executor moves through these states:
stateDiagram-v2 [*] --> queued: addTool() queued --> executing: processQueue() executing --> completed: tool finishes completed --> yielded: getCompletedResults() queued --> completed: abort (synthetic error)type ToolStatus = 'queued' | 'executing' | 'completed' | 'yielded';
type TrackedTool = { id: string; block: ToolUseBlock; assistantMessage: AssistantMessage; status: ToolStatus; isConcurrencySafe: boolean; promise?: Promise<void>; results?: Message[]; pendingProgress: Message[]; contextModifiers?: Array<(context: ToolUseContext) => ToolUseContext>;};Concurrency Control
Section titled “Concurrency Control”The executor uses a simple but effective concurrency model:
private canExecuteTool(isConcurrencySafe: boolean): boolean { const executingTools = this.tools.filter(t => t.status === 'executing'); return ( executingTools.length === 0 || (isConcurrencySafe && executingTools.every(t => t.isConcurrencySafe)) );}Rules:
- If nothing is executing, any tool can start
- Concurrent-safe tools can run in parallel with other concurrent-safe tools
- Non-concurrent tools require exclusive access (no other tools executing)
Which tools are concurrent-safe? It depends on the tool’s isConcurrencySafe(input) method. Generally:
- Concurrent-safe:
FileRead,Glob,Grep,WebFetch— read-only operations - Not concurrent-safe:
Bash,FileWrite,FileEdit— side-effecting operations
Queue Processing
Section titled “Queue Processing”When a tool is added, the executor attempts to process the queue immediately:
addTool(block: ToolUseBlock, assistantMessage: AssistantMessage): void { const toolDefinition = findToolByName(this.toolDefinitions, block.name); // ... setup TrackedTool ... this.tools.push(tracked); void this.processQueue();}
private async processQueue(): Promise<void> { for (const tool of this.tools) { if (tool.status !== 'queued') continue; if (this.canExecuteTool(tool.isConcurrencySafe)) { await this.executeTool(tool); } else { if (!tool.isConcurrencySafe) break; // maintain order for non-concurrent } }}When a tool completes, it re-triggers processQueue() to start any tools that were waiting:
const promise = collectResults();tool.promise = promise;void promise.finally(() => { void this.processQueue();});Sibling Error Cancellation
Section titled “Sibling Error Cancellation”When a Bash tool errors, all sibling tools are cancelled:
if (isErrorResult && tool.block.name === BASH_TOOL_NAME) { this.hasErrored = true; this.erroredToolDescription = this.getToolDescription(tool); this.siblingAbortController.abort('sibling_error');}The siblingAbortController is a child of the main toolUseContext.abortController — aborting it kills sibling tools but does NOT end the turn. Only Bash errors trigger this; read-only tool failures (e.g., FileRead on a missing file) are independent.
Fallback: runTools (Batch Execution)
Section titled “Fallback: runTools (Batch Execution)”When StreamingToolExecutor is disabled, the fallback path in src/services/tools/toolOrchestration.ts runs tools after the stream completes:
export async function* runTools( toolUseMessages: ToolUseBlock[], assistantMessages: AssistantMessage[], canUseTool: CanUseToolFn, toolUseContext: ToolUseContext,): AsyncGenerator<MessageUpdate, void> { for (const { isConcurrencySafe, blocks } of partitionToolCalls( toolUseMessages, toolUseContext )) { if (isConcurrencySafe) { yield* runToolsConcurrently(blocks, assistantMessages, canUseTool, toolUseContext); } else { yield* runToolsSerially(blocks, assistantMessages, canUseTool, toolUseContext); } }}Partitioning
Section titled “Partitioning”Tool calls are partitioned into batches:
function partitionToolCalls(toolUseMessages, toolUseContext): Batch[] { // Groups consecutive concurrent-safe tools into one batch // Non-concurrent tools get their own batch // Example: [Read, Read, Write, Read, Read] → [Read,Read], [Write], [Read,Read]}Concurrent Execution
Section titled “Concurrent Execution”Concurrent batches use all() from src/utils/generators.ts with a max concurrency of 10:
async function* runToolsConcurrently( toolUseMessages, assistantMessages, canUseTool, toolUseContext,): AsyncGenerator<MessageUpdateLazy, void> { yield* all( toolUseMessages.map(async function* (toolUse) { yield* runToolUse(toolUse, assistantMessage, canUseTool, toolUseContext); }), getMaxToolUseConcurrency(), // default: 10 );}Tool Execution Pipeline
Section titled “Tool Execution Pipeline”Whether using StreamingToolExecutor or runTools, each individual tool call goes through runToolUse() in src/services/tools/toolExecution.ts:
graph TD A[runToolUse] --> B[Parse input with Zod] B --> C{Valid?} C -- no --> D[Return parse error] C -- yes --> E[Run PreToolUse hooks] E --> F{Hook decision?} F -- deny --> G[Return denial message] F -- allow/ask --> H[Check permissions] H --> I{Allowed?} I -- no --> J[Return permission denial] I -- yes --> K[Execute tool.call()] K --> L[Run PostToolUse hooks] L --> M[Format result] M --> N[Yield result message]Input Validation
Section titled “Input Validation”Tool input is validated against the tool’s Zod schema:
const parsedInput = tool.inputSchema.safeParse(block.input);if (!parsedInput.success) { // Return error with Zod validation details}Permission Resolution
Section titled “Permission Resolution”Permissions go through a multi-layer check (see resolveHookPermissionDecision in toolHooks.ts):
- PreToolUse hooks may return
allow,deny, orask - Hook
allowdoes NOT bypass settings.json deny/ask rules - Rule-based permissions (
checkRuleBasedPermissions) check settings - canUseTool callback shows the permission dialog if needed
Result Formatting
Section titled “Result Formatting”Tool results are converted to ToolResultBlockParam via mapToolResultToToolResultBlockParam:
const resultContent = tool.mapToolResultToToolResultBlockParam( result.data, toolUseID);// → { type: 'tool_result', tool_use_id: '...', content: '...', is_error: false }Large results (exceeding tool.maxResultSizeChars) are persisted to disk and replaced with a preview + file path.
Result Injection Back Into Conversation
Section titled “Result Injection Back Into Conversation”After all tools complete, their results flow back into the message array:
// src/query.ts — at the continue sitestate = { messages: [...messagesForQuery, ...assistantMessages, ...toolResults], // ... transition: { reason: 'next_turn' },};The next API call sees:
[prior messages][assistant message with tool_use blocks][user message with tool_result blocks] ← injected here[user message with attachment blocks] ← if anyAbort Handling
Section titled “Abort Handling”When a user interrupts (Ctrl+C or submit-interrupt), tool dispatch handles cleanup:
// In StreamingToolExecutorprivate getAbortReason(tool): 'sibling_error' | 'user_interrupted' | 'streaming_fallback' | null { if (this.discarded) return 'streaming_fallback'; if (this.hasErrored) return 'sibling_error'; if (this.toolUseContext.abortController.signal.aborted) { if (this.toolUseContext.abortController.signal.reason === 'interrupt') { return this.getToolInterruptBehavior(tool) === 'cancel' ? 'user_interrupted' : null; } return 'user_interrupted'; } return null;}Tools with interruptBehavior: 'block' (the default) keep running through interrupts. Tools with interruptBehavior: 'cancel' receive synthetic error results.
After abort, the query loop emits an interruption message:
if (toolUseContext.abortController.signal.reason !== 'interrupt') { yield createUserInterruptionMessage({ toolUse: true });}return { reason: 'aborted_tools' };