AI Agent Tool Calling Patterns That Actually Work in Production
The Demo vs. Production Gap
Every AI agent demo looks the same: ask the agent to book a flight, it calls a tool, returns a confirmation. Clean, simple, impressive. Then you build it for real customers and discover: the agent calls the wrong tool 15% of the time, passes invalid parameters 8% of the time, and when a tool fails, it hallucinates a success message.
Production agents need guardrails that demos don't show. Here's what works.
Tool Definition: Be Explicit, Not Clever
The quality of tool definitions determines whether the agent calls the right tool with the right parameters:
// ✗ Bad tool definition (too vague)
const badTool = {
name: "search",
description: "Search for things",
parameters: {
query: { type: "string" },
},
};
// ✓ Good tool definition (explicit about everything)
const goodTool = {
name: "search_orders",
description: "Search customer orders by order ID, email, or date range. Use this when the customer asks about an existing order, shipment status, or order history. Do NOT use for product searches.",
parameters: {
type: "object",
required: ["search_type"],
properties: {
search_type: {
type: "string",
enum: ["order_id", "email", "date_range"],
description: "The type of search to perform",
},
order_id: {
type: "string",
pattern: "^ORD-[0-9]{5,8}$",
description: "Order ID in format ORD-XXXXX. Required when search_type is 'order_id'",
},
email: {
type: "string",
format: "email",
description: "Customer email. Required when search_type is 'email'",
},
date_from: { type: "string", format: "date", description: "Start date (YYYY-MM-DD)" },
date_to: { type: "string", format: "date", description: "End date (YYYY-MM-DD)" },
},
},
};Key principles: tell the model when to use the tool and when NOT to use it. Use enums to constrain choices. Add format/pattern for validation. Make required fields explicit.
Parameter Validation Layer
Never trust the model's parameter output. Validate everything:
import { z } from "zod";
// Define strict schemas for each tool
const toolSchemas = {
search_orders: z.object({
search_type: z.enum(["order_id", "email", "date_range"]),
order_id: z.string().regex(/^ORD-[0-9]{5,8}$/).optional(),
email: z.string().email().optional(),
date_from: z.string().date().optional(),
date_to: z.string().date().optional(),
}).refine(data => {
if (data.search_type === "order_id" && !data.order_id) return false;
if (data.search_type === "email" && !data.email) return false;
return true;
}, "Missing required field for search type"),
cancel_order: z.object({
order_id: z.string().regex(/^ORD-[0-9]{5,8}$/),
reason: z.string().min(5).max(500),
refund_type: z.enum(["full", "partial", "store_credit"]),
}),
};
async function executeToolCall(toolName: string, params: unknown) {
const schema = toolSchemas[toolName];
if (!schema) throw new ToolError(`Unknown tool: ${toolName}`);
const validated = schema.safeParse(params);
if (!validated.success) {
// Return validation error to the model so it can self-correct
return {
error: `Invalid parameters: ${validated.error.issues.map(i => i.message).join(", ")}`,
hint: "Please check the parameter requirements and try again.",
};
}
return await tools[toolName](validated.data);
}Error Handling: Tell the Model What Went Wrong
When a tool call fails, the model needs structured feedback to recover:
async function safeToolExecution(toolName: string, params: unknown) {
try {
const result = await executeToolCall(toolName, params);
return { status: "success", data: result };
} catch (error) {
if (error instanceof NotFoundError) {
return {
status: "not_found",
message: `No ${toolName.replace("_", " ")} found with those parameters.`,
suggestion: "Ask the customer to verify the information and try again.",
};
}
if (error instanceof PermissionError) {
return {
status: "unauthorized",
message: "This action requires customer verification.",
suggestion: "Ask the customer to verify their identity before proceeding.",
};
}
if (error instanceof RateLimitError) {
return {
status: "rate_limited",
message: "Too many requests. Please wait a moment.",
suggestion: "Inform the customer there's a brief delay.",
};
}
// Unknown errors — don't expose internals
return {
status: "error",
message: "Unable to complete this action right now.",
suggestion: "Apologize and offer to escalate to a human agent.",
};
}
}Confirmation for Destructive Actions
Never let the agent execute destructive actions without confirmation:
const DESTRUCTIVE_TOOLS = ["cancel_order", "process_refund", "delete_account"];
async function handleToolCall(toolName: string, params: unknown, conversationState: State) {
if (DESTRUCTIVE_TOOLS.includes(toolName)) {
if (!conversationState.pendingConfirmation) {
// Don't execute — ask for confirmation first
conversationState.pendingConfirmation = { toolName, params };
return {
status: "confirmation_required",
message: `Please confirm: ${describeAction(toolName, params)}`,
action: describeAction(toolName, params),
};
}
// User confirmed — execute
conversationState.pendingConfirmation = null;
}
return await safeToolExecution(toolName, params);
}Tool Call Limits
Prevent agents from getting stuck in loops:
const MAX_TOOL_CALLS_PER_TURN = 5;
const MAX_RETRIES_PER_TOOL = 2;
async function agentLoop(messages: Message[]) {
let toolCallCount = 0;
const retryTracker = new Map<string, number>();
while (toolCallCount < MAX_TOOL_CALLS_PER_TURN) {
const response = await llm.chat({ messages, tools });
if (!response.tool_calls?.length) break; // Agent is done
for (const call of response.tool_calls) {
const retries = retryTracker.get(call.id) || 0;
if (retries >= MAX_RETRIES_PER_TOOL) {
messages.push({
role: "tool",
content: JSON.stringify({
error: "Maximum retries exceeded. Please respond without this tool.",
}),
});
continue;
}
const result = await safeToolExecution(call.function.name, call.function.arguments);
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify(result) });
if (result.status === "error") retryTracker.set(call.id, retries + 1);
toolCallCount++;
}
}
if (toolCallCount >= MAX_TOOL_CALLS_PER_TURN) {
messages.push({
role: "system",
content: "Tool call limit reached. Provide your best response with available information.",
});
}
return await llm.chat({ messages }); // Final response without tools
}Production AI agents aren't about making the model smarter. They're about making the system around the model robust enough to handle every way the model can fail — and it will fail in ways you never predicted. Build the guardrails before you build the features.