Your agent should not trust its own tool input.
An agent I wired up called createOrder with whatever the model produced. Most days the input was fine. One day the model sent an order where one item had no sku, and the tool ran anyway. The payment step had already started when the order write failed on that item, and the cleanup took longer than the order itself.
What actually happened
The agent had one job: take what the user asked for and turn it into a createOrder call. The model did that well most of the time. It picked the right items, the right quantities, and the right customer.
Then one call arrived with three items, and one of them had an empty sku. Nothing in the system checked the input before the tool ran, so createOrder started work on an order that could never complete. It charged the customer for the order, then failed when it tried to write the item it could not identify. The order was dead, the payment needed reversing, and the cleanup took longer than the order itself would have taken.
The model was not being careless. It was doing what models do: producing a plausible answer even when it is unsure. The system treated that answer as a command, and ran it without checking.
The wrong fix
The wrong fix is a better prompt telling the model to be careful.
A careful model still invents or drops a field when it is unsure, and a prompt cannot stop a bad call once the tool is running. The model does not run the tool. Your code does. So the check belongs in your code, not in the prompt.
The real fix: validate before the tool runs
Treat model output as untrusted input. Validate it before the tool runs, and only validated data reaches the tool.
Define the shape the tool expects with Zod:
import { z } from 'zod'; const CreateOrderInput = z.object({ customerId: z.string().trim().min(1), items: z.array( z.object({ sku: z.string().trim().min(1), qty: z.number().int().positive(), }), ).min(1), }); type CreateOrderInput = z.infer<typeof CreateOrderInput>; .trim().min(1) matters: z.string() on its own accepts an empty string, and .min(1) on its own still accepts a string of spaces. Both are exactly the kind of "technically a string" values a model produces when it is unsure.
Then parse every call before it touches the real tool:
async function createOrder(input: CreateOrderInput) { // charge the customer, write the order, send the confirmation return { ok: true, orderId: 'ord_123' }; } export async function runCreateOrder(args: unknown) { const parsed = CreateOrderInput.safeParse(args); if (!parsed.success) { return { ok: false, error: parsed.error.issues .map((i) => `${i.path.join('.')}: ${i.message}`) .join('; '), }; } return createOrder(parsed.data); } Now a malformed call stops before any side effect. Only validated data reaches the tool, and because the error names the exact field, like items.1.sku, the model can ask the user which product they meant instead of failing halfway through an order.
The important detail is that the error goes back to the model as the tool result, not into a log nobody reads. A missing sku becomes a question to the user. An empty items list becomes a question too. The model gets a second chance with the field named, and the user gets an order that actually completes.
Doesn't my agent framework already do this?
Partly. The Vercel AI SDK, and the OpenAI and Anthropic tool APIs, all take a schema for each tool, and the SDK checks the model's input against it before your tool runs. If you use one of them, you already have a guard. The question is how strict it is.
This article's advice still applies in two cases:
- You run your own tool loop. You parse the model's tool calls yourself and call the function directly. Nothing validates anything unless you do.
- The schema you gave the framework is looser than the tool needs. A schema with
customerId: z.string()passes an empty string. A schema withqty: z.number()passes-3and1.5. The framework validated the input, and the bad call still ran.
Either way, the rule is the same: the schema the tool is checked against has to describe what the tool actually needs, not just the rough types.
One guard for every tool
If you run your own loop, every tool needs the same guard, so write it once and reuse it:
async function callTool<T>( schema: z.ZodType<T>, args: unknown, tool: (input: T) => Promise<unknown>, ) { const parsed = schema.safeParse(args); if (!parsed.success) { return { ok: false, error: parsed.error.issues .map((i) => `${i.path.join('.')}: ${i.message}`) .join('; '), }; } return tool(parsed.data); } Every tool call in the agent goes through this one function. The schema is the only thing that changes per tool. The rule stays the same: no validated input, no tool run.
This also gives you a single place to log rejected calls. When the model keeps sending the same bad field, that log tells you the prompt is missing something, or the schema is stricter than the model expects. Without the guard, the same bad call shows up as a failed order in production, and you find out from the user.
Validation checks shape, not truth
A real but wrong customer id still needs business checks. The schema cannot tell you whether the customer exists, whether the sku is in stock, or whether the user is allowed to order it. Those checks still belong inside the tool, after validation passes.
What validation removes is a whole class of half finished orders: calls that were never valid in the first place. The tool only has to deal with input that is shaped correctly, so its own checks can focus on business rules instead of defending against missing fields.
Think of it as two gates. The schema is the first gate, and it checks shape. The business logic is the second gate, and it checks truth. A call that fails the first gate never reaches the second one, and never starts a payment it cannot finish.
A checklist for your own agents
Three quick checks catch this mistake anywhere in your agent.
First, find every place your agent calls a tool and ask what happens if the model sends an empty object, or an empty string in a required field. If the answer is that the tool runs anyway, the guard is missing or too loose.
Second, check that every tool has a schema defined before it is called, and that the schema is the same one the tool actually uses. A schema that exists but is never checked is the same as no schema at all.
Third, send a bad call on purpose in development: a missing required field, a negative quantity, an empty list, a string of spaces. The tool should reject it before any side effect, and the error should name the exact field. If it does not, the guard is not wired up yet.
If your agent sent a bad input tonight, would the tool run it or reject it? Drop it in the comments.
I'm Sohail Khan, a Senior Full Stack Developer building AI agents and automation. I write about backend engineering, AI integration and practical system design.
This article was written with AI assistance and reviewed by me.

