Snippets.

February 4, 2026

AI13 min read

Type-Safe AI Agents with Vercel AI SDK

Type-safe AI agents with Vercel AI SDK

If you're building AI features in TypeScript, you've probably noticed the gap between "quick prototype" and "production-ready." LLM responses are unpredictable strings by default, tool inputs arrive as unknown, and swapping providers means rewriting integration code. The Vercel AI SDK closes that gap. It gives you a unified, type-safe API for text generation, structured outputs, tool calling, and multi-step agents -- all with first-class TypeScript and Zod support.

This article walks through how to use it properly. Not the "hello world" version, but the patterns that actually hold up when your agent needs to extract invoices, query databases, and stream results to a Next.js frontend.

Why TypeScript for AI Development

Three reasons this matters more than you'd think:

Structured outputs with Zod. When you ask an LLM to return JSON, you're trusting a probabilistic model to produce valid structure. Zod schemas let you define the exact shape you expect, and the SDK validates the output at runtime. If the model hallucinates a field or returns the wrong type, you get a proper error instead of a silent bug downstream.

Unified provider interface. The SDK abstracts over OpenAI, Anthropic, Google, Mistral, and others behind a single API. You define your tools and schemas once. Swapping from GPT-4o to Claude Sonnet is a one-line change. In production, this means you can add fallback providers or A/B test models without touching your business logic.

End-to-end type safety. Tool inputs are validated with Zod, tool outputs are typed, structured outputs conform to your schema, and the same types flow from your backend agent all the way to your React components. The compiler catches integration errors before they reach users.

Setting Up

Install the core package and at least one provider:

npm install ai @ai-sdk/openai @ai-sdk/anthropic zod

The simplest thing you can do is generate text:

import { generateText } from 'ai';
import { openai } from '@ai-sdk/openai';

interface SummaryResult {
  text: string;
  usage: {
    promptTokens: number;
    completionTokens: number;
  };
}

async function summarizeDocument(documentContent: string): Promise<string> {
  const result: SummaryResult = await generateText({
    model: openai('gpt-4o'),
    prompt: `Summarize the following document in 2-3 sentences:\n\n${documentContent}`,
  });

  return result.text;
}

Nothing special yet. The real power comes when you start adding structure.

Structured Outputs with Zod

Raw text from an LLM is fine for chatbots, but most production use cases need structured data. You want to extract an invoice from an email, parse a support ticket, or classify user intent -- and you need the result as a typed object, not a string you have to parse yourself.

The SDK uses Output.object() with generateText to handle this. You define a Zod schema, and the SDK ensures the model's output conforms to it:

import { generateText, Output } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

const lineItemSchema = z.object({
  description: z.string().describe('Description of the line item'),
  quantity: z.number().describe('Number of units'),
  unitPrice: z.number().describe('Price per unit in dollars'),
  totalPrice: z.number().describe('Total price for this line item'),
});

const invoiceSchema = z.object({
  vendorName: z.string().describe('Name of the vendor or company'),
  vendorEmail: z.string().email().optional().describe('Vendor contact email if present'),
  invoiceNumber: z.string().describe('The invoice or reference number'),
  invoiceDate: z.string().describe('Date of the invoice in ISO 8601 format'),
  dueDate: z.string().optional().describe('Payment due date in ISO 8601 format'),
  lineItems: z.array(lineItemSchema).describe('Individual items on the invoice'),
  subtotal: z.number().describe('Sum before tax'),
  taxAmount: z.number().describe('Tax amount'),
  totalAmount: z.number().describe('Final total including tax'),
  currency: z.string().default('USD').describe('Currency code'),
});

type Invoice = z.infer<typeof invoiceSchema>;

async function extractInvoiceFromEmail(emailBody: string): Promise<Invoice> {
  const { output: extractedInvoice } = await generateText({
    model: openai('gpt-4o'),
    prompt: `Extract the invoice details from this email:\n\n${emailBody}`,
    output: Output.object({ schema: invoiceSchema }),
  });

  if (!extractedInvoice) {
    throw new Error('Failed to extract invoice data from email');
  }

  return extractedInvoice;
}

A few things to notice. The .describe() calls on each field give the model context about what you expect -- this significantly improves extraction accuracy. The schema uses .optional() for fields that might not be present and .default() for sensible fallbacks.

When the model can't produce a valid object, generateText throws an AI_NoObjectGeneratedError. Handle it explicitly:

import { AI_NoObjectGeneratedError } from 'ai';

async function safeExtractInvoice(emailBody: string): Promise<Invoice | null> {
  try {
    return await extractInvoiceFromEmail(emailBody);
  } catch (error: unknown) {
    if (error instanceof AI_NoObjectGeneratedError) {
      console.error('Model could not generate valid invoice data:', error.message);
      return null;
    }
    throw error;
  }
}

Tool Calling

Tools let the model interact with the outside world. You define a tool with a description (so the model knows when to use it), a Zod schema for inputs (so the inputs are validated), and an async execute function. The model decides which tools to call based on the user's prompt.

import { generateText, tool } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';

interface WeatherData {
  location: string;
  temperature: number;
  humidity: number;
  condition: string;
}

interface DatabaseRecord {
  id: string;
  [key: string]: unknown;
}

const weatherTool = tool({
  description: 'Get current weather conditions for a specific city',
  parameters: z.object({
    city: z.string().describe('City name, e.g. "San Francisco"'),
    units: z.enum(['celsius', 'fahrenheit']).default('celsius'),
  }),
  execute: async ({ city, units }): Promise<WeatherData> => {
    const response = await fetch(
      `https://api.weather.example.com/current?city=${encodeURIComponent(city)}&units=${units}`
    );
    const weatherData: WeatherData = await response.json();
    return weatherData;
  },
});

const databaseQueryTool = tool({
  description: 'Query the application database to look up records by table and filter conditions',
  parameters: z.object({
    table: z.enum(['invoices', 'vendors', 'payments']).describe('The database table to query'),
    filters: z.record(z.string()).describe('Key-value pairs to filter by, e.g. {"vendor_id": "123"}'),
    limit: z.number().default(10).describe('Maximum number of records to return'),
  }),
  execute: async ({ table, filters, limit }): Promise<DatabaseRecord[]> => {
    const queryResults = await db.select(table, { where: filters, limit });
    return queryResults;
  },
});

async function handleUserQuery(userMessage: string): Promise<string> {
  const result = await generateText({
    model: anthropic('claude-sonnet-4-5-20250514'),
    tools: { weather: weatherTool, databaseQuery: databaseQueryTool },
    prompt: userMessage,
    maxSteps: 5,
  });

  return result.text;
}

If the user says "What's the weather in Tokyo?", the model calls weather. If they say "Find all unpaid invoices from Acme Corp", it calls databaseQuery. If they ask something that doesn't need tools, it just responds with text. The model makes the routing decision -- you don't write if/else logic.

The parameters schema does double duty: it tells the model what arguments the tool expects, and it validates those arguments at runtime before your execute function runs. If the model passes { city: 123 } instead of a string, Zod catches it.

Multi-Step Workflows

Single tool calls are useful, but real agents need to chain multiple steps. A research agent might search for information, read a specific result, then synthesize a summary. The maxSteps parameter (or stopWhen on ToolLoopAgent) enables this loop automatically.

import { generateText, tool } from 'ai';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

interface SearchResult {
  title: string;
  url: string;
  snippet: string;
}

interface PageContent {
  url: string;
  title: string;
  bodyText: string;
}

const webSearchTool = tool({
  description: 'Search the web for information on a given topic. Returns a list of results with titles, URLs, and snippets.',
  parameters: z.object({
    query: z.string().describe('The search query'),
    maxResults: z.number().default(5).describe('Number of results to return'),
  }),
  execute: async ({ query, maxResults }): Promise<SearchResult[]> => {
    const searchResults = await searchApi.search(query, { limit: maxResults });
    return searchResults;
  },
});

const readPageTool = tool({
  description: 'Read and extract the main text content from a web page URL',
  parameters: z.object({
    url: z.string().url().describe('The URL of the page to read'),
  }),
  execute: async ({ url }): Promise<PageContent> => {
    const pageContent = await scraper.extractContent(url);
    return pageContent;
  },
});

async function researchTopic(topic: string): Promise<string> {
  const result = await generateText({
    model: openai('gpt-4o'),
    tools: { webSearch: webSearchTool, readPage: readPageTool },
    maxSteps: 10,
    system: `You are a research assistant. When asked about a topic:
1. Search for relevant, recent information
2. Read the most promising 2-3 results in full
3. Synthesize the information into a comprehensive summary
4. Cite your sources with URLs

Be thorough but concise. Focus on facts, not opinions.`,
    prompt: `Research the following topic and provide a detailed summary: ${topic}`,
  });

  return result.text;
}

Here's what happens at runtime: the model gets your prompt, decides it needs to search, calls webSearch, gets results back in its context, decides to read two pages, calls readPage twice, and then synthesizes everything into a final response. Each tool call + response is one "step." The loop continues until the model decides it has enough information to answer, or until maxSteps is reached.

Combining It All

Now let's put structured outputs, tool calling, and multi-step orchestration together. This is an invoice processing agent that extracts data from emails, validates against a vendor database, and files the invoice:

import { ToolLoopAgent, stepCountIs, tool, Output } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { z } from 'zod';

const invoiceDataSchema = z.object({
  vendorName: z.string(),
  invoiceNumber: z.string(),
  totalAmount: z.number(),
  currency: z.string(),
  lineItems: z.array(z.object({
    description: z.string(),
    quantity: z.number(),
    unitPrice: z.number(),
  })),
  status: z.enum(['validated', 'needs_review', 'rejected']),
  validationNotes: z.string().optional(),
});

type ProcessedInvoice = z.infer<typeof invoiceDataSchema>;

const lookupVendorTool = tool({
  description: 'Look up a vendor in the database by name to verify they are a known vendor and get their vendor ID',
  parameters: z.object({
    vendorName: z.string().describe('The vendor name to search for'),
  }),
  execute: async ({ vendorName }): Promise<{ found: boolean; vendorId: string | null; approvedVendor: boolean }> => {
    const vendorRecord = await db.vendors.findByName(vendorName);
    return {
      found: !!vendorRecord,
      vendorId: vendorRecord?.id ?? null,
      approvedVendor: vendorRecord?.approved ?? false,
    };
  },
});

const checkDuplicateInvoiceTool = tool({
  description: 'Check if an invoice with this number already exists in the system to prevent duplicate processing',
  parameters: z.object({
    invoiceNumber: z.string().describe('The invoice number to check'),
    vendorId: z.string().describe('The vendor ID to scope the search'),
  }),
  execute: async ({ invoiceNumber, vendorId }): Promise<{ isDuplicate: boolean; existingInvoiceId: string | null }> => {
    const existingInvoice = await db.invoices.findByNumber(invoiceNumber, vendorId);
    return {
      isDuplicate: !!existingInvoice,
      existingInvoiceId: existingInvoice?.id ?? null,
    };
  },
});

const fileInvoiceTool = tool({
  description: 'File a validated invoice into the accounting system for payment processing',
  parameters: z.object({
    vendorId: z.string(),
    invoiceNumber: z.string(),
    totalAmount: z.number(),
    currency: z.string(),
    lineItems: z.array(z.object({
      description: z.string(),
      quantity: z.number(),
      unitPrice: z.number(),
    })),
  }),
  execute: async (invoiceData): Promise<{ success: boolean; filedInvoiceId: string }> => {
    const filedInvoice = await db.invoices.create(invoiceData);
    return { success: true, filedInvoiceId: filedInvoice.id };
  },
});

const invoiceAgent = new ToolLoopAgent({
  model: anthropic('claude-sonnet-4-5-20250514'),
  instructions: `You are an invoice processing agent. When given an email containing an invoice:
1. Extract the invoice details (vendor, amount, line items, dates)
2. Look up the vendor in our database to verify they are known and approved
3. Check for duplicate invoices
4. If the vendor is approved and the invoice is not a duplicate, file it
5. If anything looks wrong (unknown vendor, duplicate, suspicious amounts), mark it for review

Always explain your reasoning for the final status.`,
  tools: {
    lookupVendor: lookupVendorTool,
    checkDuplicateInvoice: checkDuplicateInvoiceTool,
    fileInvoice: fileInvoiceTool,
  },
  stopWhen: stepCountIs(8),
});

async function processInvoiceEmail(emailBody: string): Promise<ProcessedInvoice> {
  const { output: processedInvoice } = await invoiceAgent.generate({
    prompt: `Process this invoice email:\n\n${emailBody}`,
    output: Output.object({ schema: invoiceDataSchema }),
  });

  if (!processedInvoice) {
    throw new Error('Agent failed to produce a structured invoice result');
  }

  return processedInvoice;
}

The agent receives an email, extracts the data, runs validations through tool calls, and returns a typed ProcessedInvoice object. If the vendor isn't in the database, the agent sets status: 'needs_review' and explains why in validationNotes. The entire output conforms to your Zod schema.

Streaming to the Frontend

For user-facing applications, you want to stream responses as they're generated. The AI SDK integrates with Next.js through the useChat hook and createAgentUIStreamResponse:

// app/api/chat/route.ts
import { invoiceAgent } from '@/agents/invoice-agent';
import { createAgentUIStreamResponse } from 'ai';

interface ChatRequestBody {
  messages: Array<{ role: string; content: string }>;
}

export async function POST(request: Request): Promise<Response> {
  const { messages }: ChatRequestBody = await request.json();

  return createAgentUIStreamResponse({
    agent: invoiceAgent,
    messages,
  });
}
// components/sections/InvoiceChat.tsx
'use client';

import { useChat } from '@ai-sdk/react';

interface Message {
  id: string;
  role: 'user' | 'assistant';
  content: string;
}

const InvoiceChat = (): React.ReactElement => {
  const { messages, input, handleInputChange, handleSubmit, status } = useChat({
    api: '/api/chat',
  });

  const isLoading = status === 'streaming' || status === 'loading';

  return (
    <section className="mx-auto flex max-w-2xl flex-col gap-4 p-4">
      <div className="flex flex-col gap-3">
        {messages.map((message: Message) => (
          <div
            key={message.id}
            className={`rounded-lg p-3 ${
              message.role === 'user'
                ? 'bg-blue-100 self-end'
                : 'bg-gray-100 self-start'
            }`}
          >
            <p className="text-sm">{message.content}</p>
          </div>
        ))}
      </div>

      <form onSubmit={handleSubmit} className="flex gap-2">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Paste an invoice email..."
          disabled={isLoading}
          className="flex-1 rounded-md border p-2"
        />
        <button
          type="submit"
          disabled={isLoading}
          className="rounded-md bg-blue-600 px-4 py-2 text-white disabled:opacity-50"
        >
          {isLoading ? 'Processing...' : 'Send'}
        </button>
      </form>
    </section>
  );
};

export { InvoiceChat };

The useChat hook manages message state, input handling, and streaming automatically. As the agent works through its steps -- looking up vendors, checking for duplicates -- the streamed text appears in real time.

Production Considerations

Cost optimization. Token usage adds up fast with multi-step agents. Set reasonable maxSteps limits. Use cheaper models for simple classification tasks and expensive models only where quality matters. Log token usage per request so you can spot cost spikes early.

Error boundaries. Wrap agent calls in try/catch and handle AI_NoObjectGeneratedError (schema validation failure), network errors (provider is down), and timeout errors (model took too long). Return meaningful error messages to users, not raw exceptions.

Fallback providers. The unified API makes this straightforward. If your primary provider returns an error, retry with a different one:

import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
import { generateText, Output } from 'ai';
import { z } from 'zod';

type ModelProvider = ReturnType<typeof openai> | ReturnType<typeof anthropic>;

interface FallbackConfig {
  primary: ModelProvider;
  fallback: ModelProvider;
}

const PROVIDER_CONFIG: FallbackConfig = {
  primary: openai('gpt-4o'),
  fallback: anthropic('claude-sonnet-4-5-20250514'),
};

async function generateWithFallback<TSchema extends z.ZodType>(
  prompt: string,
  schema: TSchema,
): Promise<z.infer<TSchema>> {
  try {
    const { output: result } = await generateText({
      model: PROVIDER_CONFIG.primary,
      prompt,
      output: Output.object({ schema }),
    });
    return result;
  } catch (primaryError: unknown) {
    console.warn('Primary provider failed, attempting fallback:', primaryError);
    const { output: fallbackResult } = await generateText({
      model: PROVIDER_CONFIG.fallback,
      prompt,
      output: Output.object({ schema }),
    });
    return fallbackResult;
  }
}

Observability. Log every step of your agent's execution: which tools it called, what arguments it passed, how long each step took, and token counts. The SDK's result.steps array gives you all of this. Pipe it to your logging system so you can debug issues in production and understand how your agents actually behave.

Rate limiting. Multi-step agents can fire many LLM calls in quick succession. Implement rate limiting at the API route level and consider queuing for batch processing workloads.

Wrapping Up

The Vercel AI SDK removes most of the boilerplate around building AI features in TypeScript. Zod schemas give you runtime validation on LLM outputs, tools give your agents the ability to act, and the unified provider interface means you're not locked into a single vendor.

The patterns covered here -- structured extraction, tool calling, multi-step orchestration, and streaming to the frontend -- cover the majority of production use cases. Start with generateText and Output.object() for structured extraction. Add tools when your agent needs to interact with external systems. Use ToolLoopAgent when you need reusable agent definitions across your application.

The important thing is that every piece is typed end-to-end. Your Zod schemas define the contract, and TypeScript enforces it from the model's output to your React components. When something changes, the compiler tells you where to fix it.