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.
tool() Function APIEvery 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.
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.
When you pass a Zod schema as inputSchema, Strands does two things at tool registration time:
Here's how common Zod types map to the JSON Schema the 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}` }
},
})
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.
tool({
name: 'get_data',
description: 'Gets data',
inputSchema: z.object({
input: z.string(),
}),
...
})
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:
read_file and search_files, make the descriptions explain when to use each: "Use when you know the exact file path" vs "Use when you need to find files matching a pattern."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.
Two patterns work — both are valid, with different ergonomics:
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}` }
}
},
})
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.
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.
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
}
appState for cross-invocation persistenceconst 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]}` }
},
})
cancelSignal for cooperative cancellationconst 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 }
},
})
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' }
},
})
file_reader + json_validatorLet'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.
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.
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}` }
}
},
})
search_files and read_file. The model keeps using search_files when the user provides an exact path. What's the most effective fix?context?.agent.appState and context?.invocationState?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.Creating Custom Tools — Strands Agents Docs — Official reference covering tool() configuration, Zod and JSON Schema inputs, ToolContext, streaming tools, and class-based tools.