Custom Tools with Zod

Lesson 2 · Strands Agents (TypeScript) · ~12 minutes

In Lesson 1 you built a word_counter tool and saw how the agent loop invokes it. Now we go deeper into the tool() function — how Zod schemas give you type safety and generate the JSON Schema the model needs, how descriptions drive deterministic tool selection, and how tools can access agent state through the context parameter.

By the end of this lesson you'll build two tools that work together: a file_reader and a json_validator. The agent will read a file, then validate its contents — exercising multi-tool orchestration that the model decides on its own.

The tool() Function API

Every custom tool in Strands TypeScript is created with the tool() factory function. It takes a single configuration object with four fields:

import { tool } from '@strands-agents/sdk'
import { z } from '@strands-agents/sdk/zod'

const myTool = tool({
  name: 'my_tool',              // Unique identifier the model references
  description: 'What it does',  // The model reads this to decide when to call it
  inputSchema: z.object({...}), // Zod schema → validates input AND generates JSON Schema
  callback: async (input, context?) => {
    // input is fully typed from the Zod schema
    return { result: 'something' }
  },
})

The type signature is generic over your Zod schema — input in the callback is automatically typed as z.infer<typeof yourSchema>. No manual type annotations needed, no runtime type mismatches possible.

Zod ships with the SDK

Import Zod from @strands-agents/sdk/zod — it's bundled so you don't need a separate dependency. This guarantees version compatibility between the schema validation and the JSON Schema generation.

Zod Schemas → JSON Schema for the Model

When you pass a Zod schema as inputSchema, Strands does two things at tool registration time:

  1. Converts it to JSON Schema — sent to the model so it knows what parameters to provide
  2. Validates input at runtime — if the model sends malformed input, Zod catches it before your callback runs

Here's how common Zod types map to the JSON Schema the model sees:

Zod (you write)
JSON Schema (model sees)
z.string() { "type": "string" } z.number() { "type": "number" } z.boolean() { "type": "boolean" } z.enum(['a', 'b', 'c']) { "type": "string", "enum": ["a", "b", "c"] } z.array(z.string()) { "type": "array", "items": { "type": "string" } } z.object({ name: z.string() }) { "type": "object", "properties": { "name": { "type": "string" } }, "required": ["name"] } z.string().optional() Property removed from "required" array z.string().describe('The file path') { "type": "string", "description": "The file path" }

The .describe() method is critical — it adds a description field to the JSON Schema property. The model uses these descriptions to understand what each parameter expects. Think of it as inline documentation that the model reads at inference time.

const searchTool = tool({
  name: 'search_documents',
  description: 'Search documents by keyword with optional filters',
  inputSchema: z.object({
    query: z.string().describe('The search query string'),
    maxResults: z.number().optional().describe('Maximum results to return (default: 10)'),
    format: z.enum(['json', 'text', 'markdown']).describe('Output format for results'),
    tags: z.array(z.string()).optional().describe('Filter by these tags'),
  }),
  callback: async (input) => {
    // input is typed as:
    // { query: string; maxResults?: number; format: 'json' | 'text' | 'markdown'; tags?: string[] }
    return { result: `Found results for: ${input.query}` }
  },
})

Descriptions as the Lever for Determinism

With simpler models — or even with Claude Sonnet 4.6 on ambiguous prompts — the model can pick the wrong tool. The tool description and parameter descriptions are your primary levers for fixing this. The model sees them in the system-level tool definitions before processing user input.

❌ Vague — model guesses

tool({
  name: 'get_data',
  description: 'Gets data',
  inputSchema: z.object({
    input: z.string(),
  }),
  ...
})

✅ Precise — model selects correctly

tool({
  name: 'read_json_file',
  description: 'Read and parse a JSON file from the local filesystem. Returns the parsed object or an error if the file does not exist or contains invalid JSON.',
  inputSchema: z.object({
    path: z.string().describe('Absolute or relative path to the .json file'),
  }),
  ...
})

Three rules for effective descriptions:

Name matters too

Tool names are snake_case identifiers that the model references in its tool_use blocks. A name like file_reader primes the model correctly; a name like utility_1 forces all the disambiguation burden onto the description. Use descriptive names that encode the primary action.

Error Handling in Tools

Two patterns work — both are valid, with different ergonomics:

Pattern 1: Return an error result

Return a string or object describing the error. Strands wraps it as a tool result and the model sees it as a failed tool call it can reason about.

const fileReader = tool({
  name: 'read_file',
  description: 'Read file contents from the local filesystem',
  inputSchema: z.object({
    path: z.string().describe('Path to the file to read'),
  }),
  callback: async (input) => {
    try {
      const content = await fs.readFile(input.path, 'utf-8')
      return { result: content }
    } catch (err) {
      // Model sees this as the tool result — can retry with a different path
      return { error: `File not found: ${input.path}` }
    }
  },
})

Pattern 2: Throw an error

Strands catches thrown errors and converts them to error tool results automatically. The model sees the error message and can decide what to do.

const fileReader = tool({
  name: 'read_file',
  description: 'Read file contents from the local filesystem',
  inputSchema: z.object({
    path: z.string().describe('Path to the file to read'),
  }),
  callback: async (input) => {
    // If this throws, Strands wraps it as an error result for the model
    const content = await fs.readFile(input.path, 'utf-8')
    return { result: content }
  },
})

Which to choose? Return errors when you want to provide a structured, informative message that helps the model recover. Throw when you want the simplest code path and are fine with the raw error message reaching the model. Either way, the agent loop continues — it does not crash.

Zod validation errors

If the model sends input that doesn't match your Zod schema (wrong type, missing required field), Strands catches the Zod validation error before your callback runs and returns it as an error tool result. You don't need to validate input yourself — Zod handles it.

Accessing Agent Context

The callback's second parameter is an optional ToolContext object that provides access to the running agent, the current tool use metadata, and per-invocation state:

callback: async (input, context?) => {
  // The agent instance — access appState, messages, cancelSignal
  context?.agent.appState    // Durable state (persists across invocations)
  context?.agent.cancelSignal // AbortSignal for cooperative cancellation
  context?.agent.messages     // Conversation history

  // The current tool use request metadata
  context?.toolUse.toolUseId  // Unique ID for this specific tool call
  context?.toolUse.name       // The tool name the model requested

  // Per-invocation ephemeral state (shared across tools within one invoke())
  context?.invocationState    // Mutable object — read/write freely
}

Using appState for cross-invocation persistence

const counterTool = tool({
  name: 'increment_counter',
  description: 'Increment a named counter and return the new value',
  inputSchema: z.object({
    name: z.string().describe('Counter name'),
  }),
  callback: async (input, context) => {
    const state = context?.agent.appState
    const current = (state?.get('counters') as Record<string, number>) ?? {}
    current[input.name] = (current[input.name] ?? 0) + 1
    state?.set('counters', current)
    return { result: `${input.name} = ${current[input.name]}` }
  },
})

Using cancelSignal for cooperative cancellation

const slowProcessor = tool({
  name: 'process_items',
  description: 'Process a list of items one by one',
  inputSchema: z.object({
    items: z.array(z.string()).describe('Items to process'),
  }),
  callback: async (input, context) => {
    const results: string[] = []
    for (const item of input.items) {
      // Check cancellation between items
      if (context?.agent.cancelSignal.aborted) {
        return { result: `Processed ${results.length}/${input.items.length} before cancellation`, partial: results }
      }
      results.push(await processItem(item))
    }
    return { result: results }
  },
})

Using invocationState for request-scoped context

// Set invocation state before invoke()
const result = await agent.invoke('Validate the config', {
  invocationState: { requestId: 'req-123', userId: 'user-456' },
})

// Tools read it during execution
const auditTool = tool({
  name: 'audit_log',
  description: 'Write an audit log entry',
  inputSchema: z.object({ action: z.string() }),
  callback: async (input, context) => {
    const requestId = context?.invocationState?.requestId
    console.log(`[${requestId}] Action: ${input.action}`)
    return { result: 'logged' }
  },
})

Practical Example: file_reader + json_validator

Let's build two tools that naturally compose. The agent will decide on its own to read a file first, then validate its JSON structure — multi-tool orchestration driven entirely by the model's reasoning.

import { Agent, tool } from '@strands-agents/sdk'
import { z } from '@strands-agents/sdk/zod'
import * as fs from 'node:fs/promises'

// Tool 1: Read a file from disk
const fileReader = tool({
  name: 'file_reader',
  description:
    'Read the contents of a file from the local filesystem. Returns the raw text content. ' +
    'Use this when you need to inspect file contents before processing them.',
  inputSchema: z.object({
    path: z.string().describe('Absolute or relative path to the file'),
    encoding: z
      .enum(['utf-8', 'ascii', 'base64'])
      .optional()
      .describe('File encoding (default: utf-8)'),
  }),
  callback: async (input) => {
    try {
      const content = await fs.readFile(input.path, input.encoding ?? 'utf-8')
      return { result: content }
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err)
      return { error: `Failed to read file: ${message}` }
    }
  },
})

// Tool 2: Validate JSON against a shape
const jsonValidator = tool({
  name: 'json_validator',
  description:
    'Validate a JSON string for correct syntax and optionally check that specific ' +
    'top-level keys exist. Returns validation status and any errors found. ' +
    'Use this after reading a file to verify its structure.',
  inputSchema: z.object({
    jsonString: z.string().describe('The raw JSON string to validate'),
    requiredKeys: z
      .array(z.string())
      .optional()
      .describe('Top-level keys that must be present in the parsed object'),
  }),
  callback: async (input) => {
    let parsed: unknown
    try {
      parsed = JSON.parse(input.jsonString)
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err)
      return { valid: false, error: `Invalid JSON: ${message}` }
    }

    if (input.requiredKeys && typeof parsed === 'object' && parsed !== null) {
      const missing = input.requiredKeys.filter(
        (key) => !(key in (parsed as Record<string, unknown>))
      )
      if (missing.length > 0) {
        return { valid: false, error: `Missing required keys: ${missing.join(', ')}` }
      }
    }

    return { valid: true, result: 'JSON is valid and contains all required keys' }
  },
})

// Wire them into an agent
const agent = new Agent({
  systemPrompt:
    'You are a file validation assistant. When asked to validate a JSON file, ' +
    'first read it with file_reader, then validate it with json_validator. ' +
    'Report the results clearly.',
  model: 'us.anthropic.claude-sonnet-4-6-20250725-v1:0',
  tools: [fileReader, jsonValidator],
})

// The agent will: 1) call file_reader  2) call json_validator  3) report results
const result = await agent.invoke(
  'Validate the file ./config.json and check it has "name", "version", and "dependencies" keys',
  { limits: { turns: 5 } }
)

console.log(result.message?.content)

Notice how the descriptions guide the model's tool selection: file_reader says "use this when you need to inspect file contents" and json_validator says "use this after reading a file." These phrases create a natural ordering in the model's reasoning without requiring explicit orchestration code.

Why not one combined tool?

You could make a single validate_json_file tool. But separate tools give the model more flexibility: it can read a file without validating, validate JSON that came from somewhere else (user input, another tool), or retry validation with different required keys. Composable tools are more reusable than monolithic ones.

Putting It All Together: Context-Aware File Reader

Here's the file_reader enhanced with context — it logs reads to invocation state and respects cancellation for large files:

const fileReader = tool({
  name: 'file_reader',
  description:
    'Read file contents from the local filesystem. Tracks all files read during ' +
    'this invocation. Returns raw text content.',
  inputSchema: z.object({
    path: z.string().describe('Path to the file to read'),
  }),
  callback: async (input, context) => {
    // Check cancellation before potentially slow I/O
    if (context?.agent.cancelSignal.aborted) {
      return { error: 'Operation cancelled' }
    }

    try {
      const content = await fs.readFile(input.path, 'utf-8')

      // Track which files were read during this invocation
      const state = context?.invocationState as Record<string, unknown> | undefined
      if (state) {
        const reads = (state.filesRead as string[]) ?? []
        reads.push(input.path)
        state.filesRead = reads
      }

      return { result: content }
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err)
      return { error: `Cannot read ${input.path}: ${message}` }
    }
  },
})
What happens if the model sends input that doesn't match your Zod schema (e.g., a number where a string is expected)?
Correct. Zod validates input before your callback executes. If validation fails, Strands wraps the Zod error as a tool result so the model can see what went wrong and potentially retry with corrected input.
Zod validation happens before your callback runs. When it fails, Strands converts the error into a tool result message — just like a thrown error from inside your callback. The model sees it and can try again.
You have two tools: search_files and read_file. The model keeps using search_files when the user provides an exact path. What's the most effective fix?
Correct. Tool descriptions are the primary lever for guiding tool selection. Adding "when to use" guidance directly to each tool's description is more robust than system prompt rules because it's co-located with the tool definition the model reads.
The most effective approach is clarifying the when to use in each tool's description. The model reads these descriptions before deciding which tool to call. System prompts work too but descriptions are more maintainable — they travel with the tool definition.
What's the difference between context?.agent.appState and context?.invocationState?
Correct. appState is a persistent store that survives across multiple invoke() calls — think session-level data. invocationState lives only for the duration of one invoke() — think request-scoped context like a requestId or accumulated tool results. Both are mutable and shared across all tools during execution.
appState is durable and JSON-serializable — it persists across multiple invocations of the same agent (like session data). invocationState is ephemeral — it exists only for one invoke() call and can hold arbitrary (non-serializable) values. Both are mutable and visible to all tools.
📖 Primary Source

Creating Custom Tools — Strands Agents Docs — Official reference covering tool() configuration, Zod and JSON Schema inputs, ToolContext, streaming tools, and class-based tools.

💬 Questions? Ask me about Zod schema patterns, when to use context vs system prompts for tool guidance, or how to debug tool selection issues with simpler models.
← Back Next →