Tool Use Part 2: Reliable Tools
Quality engineering for tools: descriptions that fire, errors that feed back, and control modes that match your use case.
From Working to Reliable
The previous lesson gave you a working tool loop. This lesson turns it into a reliable one.
The gap between working and reliable is substantial. A working implementation handles the happy path: tool requested, tool executed successfully, result returned, Claude responds. A reliable implementation handles the full surface: tool errors, missing inputs, downstream failures, models that under-call tools, workflows where order matters, and the architectural decision of how much control to retain over the loop.
These are not edge cases in production. They are the normal operating conditions of any integration that users depend on.
The Error Channel: is_error
Tool errors are not exceptions that propagate up to crash your application. They are information that feeds back into the model's reasoning.
The mechanism is is_error: true in the tool result:
interface ToolExecution {
content: string
is_error?: boolean
}
async function executeTool(name: string, input: unknown): Promise<ToolExecution> {
try {
if (name === "lookup_order") {
const typedInput = input as { order_id: string }
const order = await db.orders.findById(typedInput.order_id)
if (!order) {
// Return an error as information — not an exception
return {
is_error: true,
content: `Order ${typedInput.order_id} not found. Orders are retained for 180 days; older orders are in the archive system.`,
}
}
return { content: JSON.stringify(order) }
}
} catch (err) {
const message = err instanceof Error ? err.message : "Unknown error"
return { is_error: true, content: `Tool execution failed: ${message}` }
}
return { is_error: true, content: `Unknown tool: ${name}` }
}
The critical wire-format detail: is_error is a top-level field on the tool_result block itself — the API reads it there, not from text inside content. The loop sets it when building the result block:
const result = await executeTool(block.name, block.input)
toolResults.push({
type: "tool_result",
tool_use_id: block.id,
content: result.content,
is_error: result.is_error,
})
When Claude receives is_error: true with an informative message, it adapts. It might explain the situation to the user, try a different tool, ask for clarification, or suggest alternatives. That adaptive behavior is only possible when the error message contains enough information to reason about.
An empty result — returning "" or null when an error occurs — leaves Claude in the dark. With no information about why the tool failed, Claude has no basis for adapting. It may hallucinate a plausible result, retry the same failing call, or produce an incoherent response. The empty result is the worst possible error handling.
The principle is: never swallow a tool error. Every failure is information. Format it as an informative message and let Claude decide what to do with it.
Validation Before Execution
Input validation inside the tool handler is the first line of defense against bad tool calls. Even with strict: true on the tool definition (which guarantees schema-valid parameters), you may have business-logic constraints that JSON Schema cannot express.
function validateOrderInput(input: unknown): { order_id: string } {
if (!input || typeof input !== "object") {
throw new ValidationError("Input must be an object")
}
const obj = input as Record<string, unknown>
if (typeof obj.order_id !== "string" || obj.order_id.trim() === "") {
throw new ValidationError("order_id must be a non-empty string")
}
if (!/^ORD-[0-9]+$/.test(obj.order_id)) {
throw new ValidationError(
`order_id must match pattern ORD-[number], got: ${obj.order_id}`
)
}
return { order_id: obj.order_id }
}
Validation errors become is_error messages:
try {
const validated = validateOrderInput(input)
return await lookupOrder(validated.order_id)
} catch (err) {
if (err instanceof ValidationError) {
return { is_error: true, content: `Invalid input: ${err.message}` }
}
throw err // unexpected errors still propagate
}
This gives Claude specific, actionable feedback when it calls a tool with invalid parameters. "order_id must match pattern ORD-[number], got: 12345" is information Claude can act on — it might ask the user to confirm the order number format, try a different lookup approach, or explain the constraint.
Idempotency
Tools that mutate state should be idempotent: calling them twice with the same inputs should have the same effect as calling them once. This matters because:
- Claude may retry a tool call if it believes the first attempt failed silently
- The manual loop might re-execute tools during error recovery
- User-facing retry buttons can trigger duplicate calls
The implementation pattern:
async function sendNotification(input: { user_id: string; message_id: string; content: string }) {
// Check if already sent
const existing = await db.notifications.findOne({
user_id: input.user_id,
message_id: input.message_id,
})
if (existing) {
// Already sent — report success without sending again
return { status: "already_sent", sent_at: existing.created_at }
}
const notification = await notificationService.send(input)
await db.notifications.insert({
user_id: input.user_id,
message_id: input.message_id,
created_at: new Date().toISOString(),
})
return { status: "sent", notification_id: notification.id }
}
Return a clear status that distinguishes "sent now" from "already sent" — Claude uses this information in its response to the user.
tool_choice: Controlling When Tools Fire
The matrix shows four modes. The practical decision tree:
Use auto (default) for general-purpose agents where Claude decides whether tools are needed. Most conversational agents. The right default.
Use any when a text-only response is definitively wrong — extraction pipelines, structured data parsing, any situation where Claude generating prose instead of calling a tool is a bug. any forces at least one tool call per turn.
Use { type: "tool", name: "..." } when you need exactly one specific tool called — classification, intent routing, structured output extraction via a named schema tool. Forces Claude to call that one tool and no other.
Use none when you want Claude to produce a final synthesis without calling any more tools — typically after you've already gathered all the data through tool calls and want a clean text response.
// Extraction pipeline — text response is a failure
const extractionResponse = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 2048,
tools: [extractionTool],
tool_choice: { type: "any" },
messages,
})
// Classification — force the intent_classifier tool
const classificationResponse = await client.messages.create({
model: "claude-haiku-4-5",
max_tokens: 256,
tools: [intentClassifierTool],
tool_choice: { type: "tool", name: "classify_intent" },
messages,
})
disable_parallel_tool_use
When execution order matters, add disable_parallel_tool_use: true to any tool_choice mode:
// Sequential workflow — Step 2 depends on Step 1's database record
tool_choice: { type: "auto", disable_parallel_tool_use: true }
This forces Claude to make one tool call per response, wait for the result, then decide whether to make another. Use it when:
- One tool's output is the other tool's input
- State mutations must complete before the next read
- You need to log or audit each step individually
- The tool has side effects that must not happen in parallel
strict: true for Guaranteed-Valid Parameters
Adding strict: true to a tool definition applies structured-output validation to the tool's input parameters — Claude must generate tool inputs that strictly conform to your schema:
{
name: "create_ticket",
description: "Call this when the user wants to file a support ticket. Requires a category and description.",
input_schema: {
type: "object",
properties: {
category: { type: "string", enum: ["billing", "technical", "account"] },
description: { type: "string" },
priority: { type: "string", enum: ["low", "medium", "high"] },
},
required: ["category", "description"],
additionalProperties: false,
},
strict: true, // guarantees schema-valid inputs
}
With strict: true, you will never receive a tool call where category is "unknown" or priority is "urgent". The validation happens before the tool call reaches your handler. This simplifies your validation code and removes a class of defensive checks.
Tool Runner vs Manual Loop
The SDK provides a tool runner (currently in beta) that handles the loop mechanics for you. The runner pairs with Zod, a TypeScript-first runtime schema library (npm install zod) that lets you describe a tool's inputs in code rather than as a hand-written JSON Schema object. You declare the shape with z.object({ ... }), and the SDK helper derives the JSON Schema the API needs from it — one definition that gives you both runtime validation and a static TypeScript type for the run handler's arguments. Zod returns in the capstone (lesson 575), where the same pattern produces a production-legal structured-output schema, so it is worth getting comfortable with here.
import Anthropic from "@anthropic-ai/sdk"
import { betaZodTool } from "@anthropic-ai/sdk/helpers/beta/zod"
import { z } from "zod"
const client = new Anthropic()
// Each tool bundles its schema AND its implementation
const lookupOrder = betaZodTool({
name: "lookup_order",
description:
"Look up an order by ID. Call this whenever the user references an order number.",
inputSchema: z.object({ order_id: z.string() }),
run: async ({ order_id }) => JSON.stringify(await db.orders.findById(order_id)),
})
// The runner handles the while loop, executing tools, and appending history
const finalMessage = await client.beta.messages.toolRunner({
model: "claude-sonnet-4-6",
max_tokens: 4096,
tools: [lookupOrder],
messages: [{ role: "user", content: userMessage }],
})
Use the runner when: you trust the loop, tools are simple, there are no approval gates or audit requirements, and you want to stop maintaining loop boilerplate.
Use the manual loop when: you need human-in-the-loop approval before certain tool calls, you need per-call audit logging, you need conditional execution (skip this tool if precondition fails), or you need rollback logic if a tool fails mid-sequence.
The manual loop is how you understand what is happening. The runner is how you stop hand-rolling it once you do. This track teaches the manual loop first for exactly that reason.
Building Tool Quality Intuitively
The test of a reliable tool is behavioral, not structural. A tool is reliable when:
- It fires when it should, and does not fire when it should not (description quality)
- Errors feed back as information rather than crashing or hallucinating (is_error discipline)
- Calling it twice with the same input has the same effect (idempotency)
- Invalid inputs surface clear messages rather than confusing downstream state (validation)
These four properties are the difference between a tool that works in demos and one that works in production.