The Anthropic SDK shows you how to declare a tool. You set a name, an input_schema (JSON Schema or Zod), a description, and a handler. The handler runs when the model decides to call the tool. That is the entire surface the SDK presents. What it does not present is the dispatcher layer between the model and your business logic, which is what determines whether the agent ships to production or to a postmortem.
This post is the dispatcher pattern we ship into every agentic system that runs on a TypeScript + tRPC stack. It has three jobs: route the agent's tool call to the right handler under the right user's session, gate mutation tools on an explicit confirm flag so the agent can't fire side effects without a human in the loop, and write every call to the audit trail. With those three, the agent inherits all the existing per-row authorization the rest of the API enforces, and the human-review surface stays the smallest possible surface that still catches the irreversible actions.
The typed tool definition
A tool definition has four parts: name, description, input schema, and (in the SDK) an optional handler. The Anthropic SDK accepts the input schema as either JSON Schema or a Zod schema; both generate the same model-side validation. We use JSON Schema directly for our tools because the schema lives next to the tool definition and never has to leave the agent server. There is no client-side parsing benefit from Zod for tools the model never sees in TypeScript.
// agent/tools/create-offer.ts
import type { Anthropic } from '@anthropic-ai/sdk';
export const createOfferTool: Anthropic.Tool = {
name: 'create_offer',
description: 'Submit an offer for a restaurant\'s open order. Requires confirm: true to actually fire.',
input_schema: {
type: 'object',
properties: {
orderId: { type: 'string', description: 'UUID of the order being offered on' },
offerType: { type: 'string', enum: ['per_item'] },
items: {
type: 'array',
items: {
type: 'object',
properties: {
orderItemId: { type: 'string' },
unitPrice: { type: 'string' },
name: { type: 'string' },
},
required: ['orderItemId', 'unitPrice'],
},
},
confirm: { type: 'boolean', description: 'Set true to submit; set false to draft for human review.' },
},
required: ['orderId', 'offerType', 'items', 'confirm'],
},
};The model sees this schema in its system context and uses it to construct valid tool calls. Theconfirm: boolean field is the first half of the mutation gate. It is part of the schema so the model knows the field exists; the second half is enforced server-side by the dispatcher, which refuses to fire the tool when confirm is missing or false.
The dispatcher: a closure over the user's session
The dispatcher is a function that takes a userId and a userRole and returns the executor closure the SDK's tool loop calls. The closure captures the session so every tool call runs as the authenticated user, which means existing per-row authorization on the tRPC procedures applies automatically. The agent does not get superuser access just because it is the one calling.
// agent/dispatch.ts
import { createCallerFactory } from '@picknDeal/api';
import { createTRPCContext } from '@picknDeal/api/context';
import { appRouter } from '@picknDeal/api/router';
const GATED_TOOLS = new Set([
'create_order',
'accept_offer',
'create_offer',
]);
export function buildToolExecutor(userId: string, userRole: UserRole) {
// One context + caller per session. The closure binds them, so every tool
// call in this session shares the same authenticated identity.
const ctx = createTRPCContext({ userId, userRole });
const factory = createCallerFactory(appRouter);
const caller = factory(ctx);
return async (toolName: string, toolInput: Record<string, unknown>): Promise<unknown> => {
// 1. Mutation gate. Tools on the allowlist require confirm: true.
// If the model called the tool without confirm, return the draft
// for human review instead of firing. The model treats the return
// value as a normal tool result and surfaces it to the user.
if (GATED_TOOLS.has(toolName) && toolInput['confirm'] !== true) {
return {
gated: true,
message: 'User confirmation required. Present the action summary and set confirm: true to fire.',
};
}
// 2. Route to the right tRPC procedure. The switch is the only
// "tool name -> business logic" mapping the agent server holds.
switch (toolName) {
case 'list_my_orders':
return caller.orders.myOrders();
case 'get_order_details':
return caller.orders.getById({ id: toolInput['orderId'] as string });
case 'list_my_products':
return caller.products.myProducts();
case 'create_offer':
return caller.offers.create({
orderId: toolInput['orderId'] as string,
offerType: 'per_item',
items: toolInput['items'] as { orderItemId: string; unitPrice: string }[],
expiresInHours: 48,
});
// ... 12 more cases for the rest of the tool surface ...
default:
return { error: `Unknown tool: ${toolName}` };
}
};
}The dispatcher is a closure rather than a class or a global registry because the session context has to be captured at construction time, not at call time. Every tool call has access to the authenticated user without the model needing to pass the user id as a tool argument (which would be a security disaster the first time the model gets confused and passes someone else's id).
Role-scoped tools: same name, different routing
Some tools mean different things to different roles. A consumer's list_my_subscriptions lives in the consumerrouter; a restaurant's list_my_subscriptions lives in the groupOrders router. Both have the same name so the model uses a consistent vocabulary across roles. The dispatcher uses the captured role to route to the right router:
case 'list_my_subscriptions':
if (userRole === 'restaurant') return caller.groupOrders.mySubscriptions();
if (userRole === 'consumer') return caller.consumer.mySubscriptions();
return { error: 'Not available for this role' };The tool surface advertised to the model also differs per role. The agent server only puts the tools the role can use into the SDK's tools array when starting the loop. Consumers never see create_offer; suppliers never see subscribe_to_group_order. The model can't call what it doesn't know about, and even if a prompt-injection attack gets it to try, the dispatcher refuses unknown tools.
The audit trail every call writes
Wrapping the dispatcher in audit-trail logging is one line per call, but it is the line that makes principle 03 from the agentic engineering method (“the audit trail is the product”) actually work. Every tool call writes the inputs, the routed outputs, the role, the session id, and the timestamp. When something goes wrong, the trail is how we diagnose without re-running the agent.
// Wrap the dispatcher's return to write audit_trail before bubbling result.
return async (toolName, toolInput) => {
const session_id = sessionId;
const at = new Date();
let result: unknown;
let error: string | null = null;
try {
result = await innerDispatch(toolName, toolInput);
} catch (e) {
error = String(e);
throw e;
} finally {
await db.insert(audit_trail).values({
session_id,
tool_name: toolName,
tool_input: toolInput,
result_summary: error ?? JSON.stringify(result).slice(0, 1000),
role: userRole,
ts: at,
});
}
return result!;
};The audit row writes whether the call succeeded or threw, so we have an honest record even when the agent crashed. The 1000-character truncation on the result summary keeps the table sized for long-term retention; the full result is available in the agent run logs for the retention window (typically 30 days).
What goes wrong without the gates
The first version of PickNDeal's AI offer agent had no mutation gate. The model decided which restaurant orders to bid on, and the dispatcher fired caller.offers.create on each one. Within an hour of going live to one pilot supplier, the agent had submitted 31 offers across orders the supplier did not want to fulfill. The supplier woke up to a dashboard of commitments they had to retract one-by-one. The fix was the GATED_TOOLS allowlist plus the confirm: true field on the tool schema. Now the agent drafts offers; the supplier confirms inline before any mutation fires. The same allowlist now covers create_order, accept_offer, and create_offer, and adding a new mutation tool is one line: add the name to the set.
The asynchronous version of the same pattern (queue table, separate review surface, demotion ladder) is the subject of the approval queue post. Synchronous confirm-flag is the right shape when the reviewer is already in the session; the queue is the right shape when the reviewer is off the clock. Same principle, two implementations.
Why the surface compounds, not the prompt
Prompts change. When a new model lands we tweak the system prompt, sometimes we change the strategy hint (“competitive” vs “premium”), occasionally we add or remove a few-shot example. The tool surface does not change. Once the dispatcher routescreate_offer through caller.offers.create with the right validation, all the per-row auth and idempotency and rate-limit logic in the underlying tRPC procedure protects us regardless of what the prompt says. A well-designed surface makes a mediocre prompt produce reliable results; a great prompt with a leaky surface produces undefined behaviour at scale.
This is principle 01 of the agentic engineering method: define the surface, not the prompt. Most public examples of agent code stop at the SDK's tool-runner example. The dispatcher is what you actually ship.
The principle this post comes from: the agentic engineering method, principle 01. The asynchronous approval pattern for cases where the reviewer is off the clock: the approval queue post. The MCP authentication layer that exposes this tool surface to external agents: MCP server authentication.