Back to home

02 — TOOL CREATION

Zod schema for parameter validation

8 min read

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.