Back to Articles

Building Type-Safe AI Agents with Zod Validation and Multi-Provider Support

[ View on GitHub ]

Building Type-Safe AI Agents with Zod Validation and Multi-Provider Support

Hook

Most LLM frameworks trust the model to return valid JSON. Strands Agents SDK assumes it won’t—and automatically retries with error context when validation fails, turning runtime chaos into compile-time confidence.

Context

AI agent frameworks have exploded in the past two years, but most suffer from a fundamental tension: they’re built in dynamically-typed languages (Python) or treat TypeScript as an afterthought, bolting on types without runtime guarantees. When you ask an LLM to call a function with specific parameters or return structured data, you’re making a probabilistic request to a neural network—not invoking a deterministic API. The model might return malformed JSON, miss required fields, or hallucinate properties that don’t exist.

This creates a painful developer experience in production. Your types say the agent will return a User object with email and name fields, but at runtime you get a string, or an object missing email, or worse—valid JSON that passes through your application logic and corrupts downstream systems. Most frameworks handle this by letting exceptions bubble up or forcing you to write defensive validation code everywhere. Strands Agents SDK takes a different approach: it puts Zod schemas at the center of the architecture, validating every tool input and structured output with automatic retry logic that feeds validation errors back to the model. It’s TypeScript-first by design, not by accommodation.

Technical Insight

Tool System

sends prompt

routes request

API call

tool call response

valid args

validation error

tool result

continues loop

provides schema

provides handler

final response

structured result

User Application

Agent Orchestrator

Provider Layer

Language Model

Zod Schema Validator

Tool Handler

Tool Definition

Output Schema Validator

System architecture — auto-generated

The SDK’s architecture revolves around three core concepts: the Agent class that orchestrates the interaction loop, the Tool abstraction with Zod-based validation, and a provider layer that abstracts model differences. Let’s start with how tools work, because this is where the validation magic happens.

When you define a tool, you provide a Zod schema that describes its inputs. Here’s a weather tool example:

import { z } from 'zod';
import { Agent, Tool } from '@strands-agents/sdk';
import { BedrockProvider } from '@strands-agents/sdk/bedrock';

const weatherTool = new Tool({
  name: 'get_weather',
  description: 'Get current weather for a location',
  schema: z.object({
    location: z.string().describe('City name or zip code'),
    units: z.enum(['celsius', 'fahrenheit']).default('celsius')
  }),
  handler: async ({ location, units }) => {
    // TypeScript knows location is string, units is 'celsius' | 'fahrenheit'
    const response = await fetch(`https://api.weather.example/${location}`);
    const data = await response.json();
    return { temp: data.temp, units, conditions: data.conditions };
  }
});

const agent = new Agent({
  provider: new BedrockProvider({ model: 'anthropic.claude-3-5-sonnet' }),
  tools: [weatherTool],
  system: 'You are a helpful weather assistant.'
});

When the LLM decides to call get_weather, Strands validates the tool call arguments against the Zod schema before invoking your handler. If validation fails—say the model passes units: "kelvin"—the SDK doesn’t throw an exception. Instead, it sends the validation error back to the model in the next conversation turn with context about what went wrong, giving the LLM a chance to self-correct. This retry mechanism typically resolves validation failures within 1-2 attempts without developer intervention.

The same validation pattern applies to structured outputs. You can request that the agent’s final response conform to a Zod schema:

const UserSchema = z.object({
  name: z.string(),
  email: z.string().email(),
  age: z.number().int().positive()
});

const result = await agent.run(
  'Extract user info: John Doe, age 32, contact: john@example.com',
  { output: UserSchema }
);

// result.output is typed as { name: string; email: string; age: number }
console.log(result.output.email); // TypeScript autocomplete works

If Claude returns { name: "John Doe", email: "invalid-email", age: "32" }, Zod catches the string age and invalid email format, and the SDK automatically retries with error feedback. You get strongly-typed, validated data or a clear failure—no silent corruption.

The provider abstraction is equally thoughtful. Both BedrockProvider and OpenAIProvider implement a common interface, but they handle streaming, function calling syntax, and error handling differently under the hood. Bedrock uses AWS SDK v3 with credential chains and requires explicit model access via AWS Console, while OpenAI uses their official Node.js client with API keys. The SDK normalizes these differences:

// Same agent code works with either provider
const bedrockAgent = new Agent({
  provider: new BedrockProvider({ 
    model: 'anthropic.claude-3-5-sonnet',
    region: 'us-west-2' 
  }),
  tools: [weatherTool]
});

const openaiAgent = new Agent({
  provider: new OpenAIProvider({ 
    model: 'gpt-4-turbo',
    apiKey: process.env.OPENAI_API_KEY 
  }),
  tools: [weatherTool]
});

The SDK also shines in its MCP (Model Context Protocol) integration. MCP is an emerging standard for connecting LLMs to external data sources and tools through a client-server protocol. Strands includes an MCP client that discovers and registers tools from MCP servers automatically:

import { MCPClient } from '@strands-agents/sdk/mcp';

const mcpClient = new MCPClient({
  serverUrl: 'http://localhost:3000',
  transportType: 'stdio' // or 'sse' for server-sent events
});

await mcpClient.connect();
const mcpTools = await mcpClient.listTools(); // Auto-discovers available tools

const agent = new Agent({
  provider: new BedrockProvider({ model: 'anthropic.claude-3-5-sonnet' }),
  tools: [...mcpTools, weatherTool] // Mix MCP and local tools
});

This matters because MCP is positioning itself as the standard way to give agents access to databases, APIs, and file systems without writing custom integrations for each tool. Strands’ native MCP support means you can plug into this ecosystem immediately.

For advanced use cases, the SDK provides lifecycle hooks for observability. You can intercept every stage of the agent loop—before tool calls, after model responses, on validation failures:

const agent = new Agent({
  provider: new BedrockProvider({ model: 'anthropic.claude-3-5-sonnet' }),
  tools: [weatherTool],
  hooks: {
    onToolCall: async (toolName, args) => {
      console.log(`Calling ${toolName} with`, args);
      // Send to observability platform
    },
    onValidationError: async (error, attempt) => {
      console.warn(`Validation failed (attempt ${attempt}):`, error);
    }
  }
});

These hooks integrate cleanly with OpenTelemetry, which the SDK supports natively, making it production-ready for teams that need tracing and metrics.

Gotcha

The Node.js 20+ requirement isn’t arbitrary—it’s needed for native fetch support and modern ES module handling—but it immediately excludes AWS Lambda runtimes below Node 20 and any organization stuck on LTS versions for compliance reasons. This is a real deployment constraint if you’re in a regulated environment with slow runtime approval processes.

The automatic retry mechanism for validation failures is elegant in theory but can become expensive in practice. Each retry consumes tokens and adds latency. If you design a complex Zod schema with nested objects, discriminated unions, and strict refinements, you might trigger 3-4 retries before the model gets it right—or exceeds the retry limit and fails. The SDK doesn’t currently expose fine-grained control over retry strategies (exponential backoff, max attempts per schema complexity), so you’re stuck with the defaults. For high-throughput applications where every 100ms matters, this unpredictability is problematic.

Bedrock setup friction is higher than it should be. The SDK assumes you’ve already configured AWS credentials (via environment variables, IAM roles, or AWS CLI) and have explicitly requested model access through the AWS Console. First-time users often hit opaque authentication errors because Bedrock’s permission model is separate from standard IAM policies. The error messages don’t always clarify whether you’re missing credentials, model access, or hitting quota limits. OpenAI’s API key approach is simpler, but then you’re locked into OpenAI’s ecosystem unless you invest time building a custom provider.

Finally, browser support is advertised but limited. While the core Agent class works in browsers, Bedrock provider requires AWS SDK v3’s credential chain, which doesn’t work client-side without exposing credentials. You’d need to proxy Bedrock calls through your backend anyway, at which point the browser support is mostly useful for OpenAI with API keys—something you probably shouldn’t expose client-side either.

Verdict

Use if: You’re building production AI agents in Node.js 20+ environments where type safety and validation are non-negotiable, you need to support multiple LLM providers without rewriting agent logic, you’re integrating MCP tools or want future-proof tool connectivity, or you’re part of a TypeScript-first team that values Zod’s runtime guarantees over lightweight abstractions. It’s especially strong for internal tools, enterprise backends, and multi-agent systems where structured outputs feed into downstream services that expect validated data. Skip if: You’re prototyping and need maximum velocity without validation overhead, you’re locked into Node.js 18 or below for operational reasons, you want Python-based agent ecosystems with richer libraries (LangChain Python, CrewAI), or you’re building simple chatbots where the added complexity of Zod schemas and provider abstractions outweighs the benefits. Also skip if you need cutting-edge agentic features like memory systems, human-in-the-loop workflows, or planning algorithms—Strands is deliberately minimal and focused on the orchestration layer, not higher-level agent capabilities.

// ADD TO YOUR README
[![Featured on Starlog](https://starlog.is/api/badge/ai-agents/strands-agents-sdk-typescript.svg)](https://starlog.is/api/badge-click/ai-agents/strands-agents-sdk-typescript)