The LLM generates JSON for your tool parameters. Sometimes it sends undefined when you expected a string, or "5" when you needed the number 5. Without validation, your tools crash with cryptic errors the model can't understand or fix.
Zod validates parameters before your tool runs, catches type errors, and returns clear messages the model can act on.
Basic Schema
import { z } from 'zod';
const WRITE_SCHEMA = z.object({
file_path: z.string().describe('The path to the file to write'),
content: z.string().describe('The content to write to the file'),
});The .describe() calls are critical. The model reads these when deciding what values to send. Be specific and include examples:
// Vague
file_path: z.string().describe('A file path')
// Better
file_path: z.string().describe('Path to the API route (e.g., "src/app/api/users/route.ts")')Tool Integration
Convert your Zod schema to JSON Schema for the SDK:
import { tool } from '@openai/agents';
import { zodToJsonSchema } from 'zod-to-json-schema';
const WRITE_PARAMETERS = zodToJsonSchema(WRITE_SCHEMA);
export const writeTool = tool({
name: 'Write',
description: 'Writes a file to the local filesystem',
parameters: WRITE_PARAMETERS as any,
async execute(input: any) {
const parseResult = WRITE_SCHEMA.safeParse(input);
if (!parseResult.success) {
return `Error: ${parseResult.error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ')}`;
}
const { file_path, content } = parseResult.data;
await sandboxClient.writeFile(file_path, content);
return `Successfully wrote ${file_path}`;
}
});Use .safeParse() instead of .parse(). It returns a result object instead of throwing. Return error messages as strings so the model can see them and adjust.
Optional and Default Values
const READ_SCHEMA = z.object({
file_path: z.string(),
offset: z.number().optional().describe('Line to start from (optional)'),
limit: z.number().optional().describe('Number of lines to read (optional)'),
});Handle optional parameters with fallbacks:
const { file_path, offset, limit } = parseResult.data;
const startIndex = offset ?? 0;
const endLimit = limit ?? 2000;Use .default() for common fallback values:
const GREP_SCHEMA = z.object({
pattern: z.string(),
output_mode: z.enum(['content', 'files_with_matches']).default('files_with_matches'),
case_sensitive: z.boolean().default(true),
});Enums and Arrays
Enums restrict choices and prevent the model from inventing values:
const TODO_SCHEMA = z.object({
todos: z.array(
z.object({
id: z.string(),
content: z.string().min(1),
status: z.enum(['pending', 'in_progress', 'completed']),
priority: z.enum(['high', 'medium', 'low']),
})
),
});After validation, TypeScript knows the exact structure. Arrays, nested objects, and enum types are all inferred correctly.
Constraints
Add constraints to catch edge cases:
const SCHEMA = z.object({
query: z.string().min(2),
limit: z.number().min(1).max(100),
email: z.string().email(),
url: z.string().url(),
});Common constraints: .min(), .max(), .regex(), .email(), .url(). Without constraints, the model might send negative numbers or empty strings when you expect valid values.
Real-World Example
Tools often have many optional parameters. Here's a grep tool with 12+ options:
const GREP_SCHEMA = z.object({
pattern: z.string(),
path: z.string().optional(),
glob: z.string().optional(),
output_mode: z.enum(['content', 'files_with_matches']).default('files_with_matches'),
'-B': z.number().optional(),
'-A': z.number().optional(),
'-i': z.boolean().optional(),
case_sensitive: z.boolean().default(true),
head_limit: z.number().optional(),
});Extract with fallbacks:
const { pattern, path, glob, output_mode } = parseResult.data;
const searchPath = path ?? '.';Error Messages
Format validation errors so the model can fix them:
if (!parseResult.success) {
return `Error: ${parseResult.error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ')}`;
}This produces: Error: status: Invalid enum value. Expected 'pending' | 'completed', received 'done'
Don't throw exceptions—return error strings so the model sees them and adjusts.
Common Mistakes
Vague descriptions: Be specific and include examples. 'A file path' vs 'Path to the API route (e.g., "src/api/users.ts")'
Missing constraints: Add .min() and .max() to prevent negative numbers or empty strings.
Throwing exceptions: Use .safeParse() and return error strings. Don't throw—let the model see the error and adjust.
Over-constraining: Validate types and basic constraints, not business logic. Don't use overly restrictive regex patterns.
What We're Skipping
Custom refinements, transforms, async validation, discriminated unions, schema composition. These add complexity you don't need for basic validation.
Type checking, optional fields, enums, arrays, nested objects, and constraints cover 95% of cases. Start there.
What's Next
Your tools now validate parameters before execution. The model gets clear errors it can fix. Next, we'll look at runtime context—how tools access shared resources, emit progress events, and coordinate during multi-step operations.