Gerson

Gerson

Passionate developer specializing in web development, cloud architecture, and system design.

TypeScriptReactNext.jsPythonFastAPISQLNode.jsAWS

Building Agents with the AI SDK v6

AI SDK v6 makes agent loops, multi-step tool calling, and provider-agnostic model routing first-class. A walkthrough of the new patterns — tool(), stopWhen, streamText with UIMessage parts, and generateObject — with working code for a research agent.

Gersonhttps://ai-sdk.dev/docs/agents/overview
Abstract neural network nodes representing AI agent orchestration

The AI SDK v6 landed in late 2025 with a fundamentally different model for building agents. Where v4 and v5 felt like a toolkit of primitives you had to stitch together, v6 ships first-class agent loops, multi-step tool calling that pauses and resumes cleanly, and a provider-agnostic gateway as the default. If you've been gluing together generateText calls inside a while loop, this release pays back your refactor time in the first afternoon.

This article walks through the v6 agent pattern end-to-end: a TypeScript agent that reasons over tools, handles multi-step workflows, and streams progress to the client. Everything below runs on Node.js 24 and uses the "provider/model" string form that routes through the Vercel AI Gateway, so you can swap providers without touching code.

The Agent Loop

The core idea is simple: give the model a set of tools, a task, and a stopping condition. The SDK handles the loop — executes tool calls, feeds results back, and stops when the model produces a final answer or hits the step limit. No more hand-rolled while loops checking for tool_calls in the response.

lib/agents/research-agent.ts

import { generateText, tool, stepCountIs } from 'ai';
import { z } from 'zod';

const searchWeb = tool({
  description: 'Search the web for current information',
  inputSchema: z.object({
    query: z.string().describe('The search query'),
  }),
  execute: async ({ query }) => {
    const res = await fetch(
      `https://api.tavily.com/search?q=${encodeURIComponent(query)}`,
      { headers: { Authorization: `Bearer ${process.env.TAVILY_KEY}` } },
    );
    const data = await res.json();
    return data.results.slice(0, 5);
  },
});

const fetchUrl = tool({
  description: 'Fetch the contents of a URL',
  inputSchema: z.object({ url: z.string().url() }),
  execute: async ({ url }) => {
    const res = await fetch(url);
    return (await res.text()).slice(0, 8000);
  },
});

export async function research(question: string) {
  const { text, steps } = await generateText({
    model: 'openai/gpt-4o',
    tools: { searchWeb, fetchUrl },
    stopWhen: stepCountIs(8),
    system: 'You are a research assistant. Cite sources.',
    prompt: question,
  });
  return { text, stepCount: steps.length };
}

A few things to notice. The tool() helper uses inputSchema (v5 called it parameters) and gets full type inference through to execute. The stopWhen: stepCountIs(8) is new — it replaces the old maxSteps number with a composable condition, so you can also stop on custom predicates like hasToolCall('finish') or combine multiple conditions.

Streaming Agent Progress

The second big v6 change is that streamText emits intermediate steps — tool calls, tool results, and text deltas — as part of the stream. On the client, the useChat hook surfaces these as typed message parts, so rendering an agent's reasoning is a matter of iterating over message.parts.

app/api/agent/route.ts

import { streamText, convertToModelMessages, stepCountIs } from 'ai';
import { searchWeb, fetchUrl } from '@/lib/agents/tools';

export async function POST(req: Request) {
  const { messages } = await req.json();
  const result = streamText({
    model: 'anthropic/claude-sonnet-4-6',
    tools: { searchWeb, fetchUrl },
    stopWhen: stepCountIs(6),
    messages: convertToModelMessages(messages),
  });
  return result.toUIMessageStreamResponse();
}

app/agent/page.tsx

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

export default function AgentPage() {
  const { messages, sendMessage, status } = useChat({ api: '/api/agent' });
  return (
    <div>
      {messages.map(m => (
        <div key={m.id}>
          {m.parts.map((p, i) => {
            if (p.type === 'text') return <p key={i}>{p.text}</p>;
            if (p.type.startsWith('tool-'))
              return <pre key={i}>{JSON.stringify(p, null, 2)}</pre>;
          })}
        </div>
      ))}
    </div>
  );
}

Structured Output Without JSON Gymnastics

Agents often need to return typed data, not free-form text. v6's generateObject takes a Zod schema and returns a fully typed result — the SDK handles the prompt engineering, validation, and retry-on-schema-mismatch.

lib/agents/extract.ts

import { generateObject } from 'ai';
import { z } from 'zod';

const invoiceSchema = z.object({
  vendor: z.string(),
  total: z.number(),
  currency: z.string().length(3),
  lineItems: z.array(z.object({
    description: z.string(),
    amount: z.number(),
  })),
});

export async function parseInvoice(text: string) {
  const { object } = await generateObject({
    model: 'openai/gpt-4o',
    schema: invoiceSchema,
    prompt: `Extract invoice data from this email:\n\n${text}`,
  });
  return object;
}

Pro Tip: Pair generateObject with .describe() on each Zod field. The SDK folds descriptions into the prompt, and it noticeably improves extraction accuracy on ambiguous fields — especially dates, currencies, and enums.

Provider-Agnostic via the Gateway

v6's default pattern uses plain "provider/model" strings — "openai/gpt-4o", "anthropic/claude-sonnet-4-6", "google/gemini-2-pro" — which route through the Vercel AI Gateway. You no longer install provider-specific packages unless you need a feature the gateway doesn't proxy.

The practical win is failover and cost routing. One env var (AI_GATEWAY_API_KEY), and you can switch primary and fallback models from the Vercel dashboard rather than a redeploy. For an agent that fires dozens of tool calls per request, a 5% cheaper fallback can pay for itself in a week.

Migration Notes from v5

  • parameters is now inputSchema on tool()
  • maxSteps: 5 is now stopWhen: stepCountIs(5)
  • Provider packages (@ai-sdk/openai, etc.) still work, but string model IDs are the default
  • useChat now consumes UIMessage parts — update your renderer to iterate message.parts instead of message.toolInvocations
  • The codemod (npx @ai-sdk/codemod upgrade) handles about 80% of the mechanical changes

Resources