Why AI Tool Validation Needs Two Layers (Not One)
How we fixed our BrainGrid Requirements Agent crashes by implementing a two-layer validation pattern—LLM-level constraints plus app-level safeParse.
For weeks, our BrainGrid Requirements Agent had a nasty habit. It would be happily creating tasks, breaking down requirements, doing exactly what it was supposed to do—and then it would just die. No graceful error. No helpful message. The entire conversation would blow up and become completely unrecoverable.
Users had to start over. Every. Single. Time.
We knew exactly what was happening. The AI model would occasionally send tool arguments that passed JSON syntax validation but failed semantic validation—an array with wrong element types, a number outside valid range, a missing required field. Our Zod schemas would reject the input, and the whole thing would crash.
We were playing whack-a-mole trying to fix it.
#The Gap Between Syntax and Semantics
When you're building AI agents with tool calling, strict: true and Anthropic's structured outputs feature sound like exactly what you want. Constrained decoding ensures the model physically cannot generate malformed JSON. Every token matches the JSON schema.
Here's the catch that took us weeks to fully understand: constrained decoding prevents syntax errors, not semantic errors.
The model can't produce {title: } (invalid JSON). But it CAN produce {"blocked_by": "task-1"} when the schema expects an array. It parsed correctly—it's just wrong.
Here's what our tool definition looked like:
1// Tool definition with schema validation 2const createTaskTool = { 3 name: 'create_task', 4 description: 'Creates a new task', 5 inputSchema: CreateTaskInputSchema, // Zod schema 6 strict: true, // Enable Anthropic's constrained decoding 7 8 async execute(args: CreateTaskInput) { 9 // When Zod validation fails, this NEVER RUNS 10 console.log('Creating task:', args.title); 11 return await taskService.create(args); 12 } 13};
The SDK flow: model generates output → constrained decoding ensures valid JSON → SDK parses JSON → SDK validates against Zod schema → if Zod throws, execute() never runs.
See the problem? Anthropic's strict: true does its job—the JSON is always syntactically valid. But Zod validation happens after JSON parsing and before your execute function. When Zod throws (wrong types, out-of-range values, missing fields), your code never sees the input. The SDK catches the error, the API returns a failure, the model retries with similar bad input, fails again, and the conversation enters a death loop.
The user sees: "Error processing your request. Please try again."
They try again. Same error. Conversation is now unrecoverable.
You can't catch what you can't see.
#The Whack-a-Mole Phase
We tried everything the SDK offered. Every attempt followed the same pattern: implement "fix," deploy, wait for it to fail in a new way, repeat.
#Attempt #1: Lenient schema coercion
Maybe we could make the schema more forgiving? Coerce strings to arrays, handle type mismatches gracefully:
1// Defensive coercion - trying to be "helpful" 2const coerceToStringArray = (val: unknown): string[] => { 3 if (Array.isArray(val)) return val.map(String); 4 if (typeof val === 'string') { 5 try { return JSON.parse(val); } // Model sent "[1, 2]" as a string 6 catch { return [val]; } 7 } 8 return []; 9}; 10 11export const CreateTaskInputSchema = z.object({ 12 title: z.string().min(1), 13 blocked_by: z.preprocess(coerceToStringArray, z.array(z.string())), 14 // ... increasingly complex coercion for every field 15}).strict();
This helped with some edge cases. But the schema became unreadable, and we were still playing whack-a-mole with new malformed inputs. Worse: .strict() would still reject any unexpected properties, and we were still throwing before execute() could run.
#Attempt #2: experimental_repairToolCall
The SDK has a hook specifically for this! Intercept the failed tool call and fix it:
1const result = await streamText({ 2 model, 3 tools, 4 experimental_repairToolCall: async ({ toolCall, error }) => { 5 // Try to fix the malformed input 6 const fixed = attemptRepair(toolCall.args, error); 7 return { ...toolCall, args: fixed }; 8 }, 9});
Sounds perfect. The problem? If your repair logic also fails—and it will, because you're trying to guess what the model meant to send—the conversation still dies. And you have to write repair logic for every possible way every tool's input could be malformed. How much defensive code do you want to write for every permutation of garbage an AI model can send?
The answer is none. That's insane.
#Attempt #3: Error callbacks
We tried catching errors in onStepFinish, onError, everywhere the SDK would let us:
1const result = await streamText({ 2 model, 3 tools, 4 onError: (error) => { 5 // By the time this fires, it's too late 6 // The tool call already failed, the model is retrying, 7 // and we're in a death loop 8 logger.error('Tool call failed', error); 9 }, 10});
By the time these callbacks fire, the damage is done. The tool call has already been rejected at the SDK level. The model retries. Same bad input. Same rejection. The user watches their cursor spin until the whole thing times out.
Nothing worked. The fundamental problem remained: strict mode validates before your code runs. Full stop.
#The Two-Layer Solution
The breakthrough wasn't abandoning validation—it was splitting it into two layers. (If you're working with the Vercel AI SDK, our AI SDK v5 migration guide covers related patterns.)
Layer 1: LLM-level (keep strict: true) — Anthropic's constrained decoding ensures syntactically valid JSON. This stays.
Layer 2: App-level (permissive input, graceful validation) — Accept any valid JSON object at the SDK boundary, then validate inside execute() where you can catch errors and return helpful messages.
Here's what that looks like:
1// Layer 1: Permissive input schema - accepts any object 2// The SDK won't throw, so execute() always runs 3export const CreateTaskInputSchema = z.object({}).passthrough(); 4 5// Layer 2: Strict validation schema - used inside execute() 6export const CreateTaskValidationSchema = z.object({ 7 title: z.string().min(1), 8 content: z.string().min(1), 9 complexity: z.number().min(1).max(5).optional(), 10 blocked_by: z.preprocess(coerceToStringArray, z.array(z.string())).optional(), 11});
The SDK sees a permissive schema: "accept any object." The real validation happens inside execute() where we control the error handling:
1async execute(args: unknown, context?: ToolExecutionContext): Promise<CreateTaskOutput> { 2 const parsed = CreateTaskValidationSchema.safeParse(args); 3 4 if (!parsed.success) { 5 const errorDetails = parsed.error.errors 6 .map(e => `${e.path.join('.')}: ${e.message}`) 7 .join('; '); 8 9 // Return the error WITH the expected schema 10 return { 11 success: false, 12 error: `Invalid arguments: ${errorDetails}. Retry with correct schema:\n${expectedSchema}`, 13 }; 14 } 15 16 // Now we have validated, typed data 17 const { title, content, complexity } = parsed.data; 18 // ... rest of the tool logic 19}
The key insight: when validation fails, we don't throw. We return a structured error that includes the expected schema. The model sees exactly what it got wrong and what it should send instead. It retries. The user never knows anything happened.
#The Results
Twelve files changed. 358 lines added. And the numbers speak for themselves:
Before the fix (Jan 18-23):
- 85
toolUse.input is invaliderrors - 5,613 agent conversations
- 1.5% failure rate (peaking at 3.3% on heavy days)
After the fix (Jan 24):
- 0 errors
- 81 conversations
- 0% failure rate
Here's an actual error from our logs before the fix:
1AI_APICallError: The format of the value at 2messages.7.content.9.toolUse.input is invalid. 3Provide a json object for the field and try again.
The model sent something that looked like valid JSON but failed semantic validation. The SDK rejected it. The model retried with the same bad input. Death loop. Session dead.
Now, conversations that would have crashed recover gracefully. We also added monitoring—every validation failure emits a tool.failed event so we can track patterns and improve our prompts over time. For more on optimizing tool performance, see our post on Anthropic tool caching with AI SDK v5.
#The Takeaway
The insight isn't that strict mode is bad—it's that you need two layers of validation for AI tool calling:
-
LLM-level constraints (
strict: true) prevent malformed JSON. Keep this. Constrained decoding genuinely reduces errors. -
App-level validation (permissive input + safeParse) handles the semantic errors that slip through. This is where you catch wrong types, out-of-range values, and missing fields—and return helpful error messages instead of crashing.
This isn't a hack or a workaround. It's the Input DTO / Validation DTO pattern that backend developers have used for decades, applied to AI tool calling. Accept wide at the boundary, validate narrow inside your handler.
The difference matters: when validation fails, your user doesn't see "Error processing your request." They don't even know anything went wrong. The model sees exactly what it got wrong, corrects itself, and continues.
That's the goal. Graceful recovery, invisible to the user.
About the Author
Tyler Wells is the Co-founder & CTO of BrainGrid, where we're building the future of AI-assisted software development. With over 25 years of experience in distributed systems and developer tools, Tyler focuses on making complex technology accessible to engineering teams.
Want to discuss AI coding workflows or share your experiences? Find me on X or connect on LinkedIn.
Keep Reading
Ready to build without the back-and-forth?
Turn messy thoughts into engineering-grade prompts that coding agents can nail the first time.
Re-love coding with AI