
Ai Sdk 6
- 174 installs
- 57 repo stars
- Updated August 3, 2026
- laguagu/claude-code-nextjs-skills
Add streaming chat, tool calling, and structured model outputs in Next.js apps using AI SDK v6 patterns without relearning provider APIs on every feature.
About
ai-sdk-6 from laguagu/claude-code-nextjs-skills teaches agents to integrate Vercel AI SDK version 6 into Next.js apps with streaming chat, tool calling, and structured generations. It standardizes provider wiring, React hooks, and server route patterns so LLM features ship faster without rediscovering SDK breaking changes each release.
- AI SDK v6 with Next.js
- Streaming chat UI
- Tool-calling patterns
- Provider-agnostic hooks
- Structured model outputs
Ai Sdk 6 by the numbers
- 174 all-time installs (skills.sh)
- +4 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #3,097 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/laguagu/claude-code-nextjs-skills --skill ai-sdk-6Add your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 174 |
|---|---|
| repo stars | ★ 57 |
| Last updated | August 3, 2026 |
| Repository | laguagu/claude-code-nextjs-skills ↗ |
What it does
Add streaming chat, tool calling, and structured model outputs in Next.js apps using AI SDK v6 patterns without relearning provider APIs on every feature.
Files
Vercel AI SDK v6 Development Guide
Use this skill when developing AI-powered features using Vercel AI SDK v6 (ai package).
Docs location: bundled innode_modules/ai/docs/. In Bun/pnpm/Yarn workspace monorepos deps aren't hoisted — useapps/*/node_modules/ai/docs/orpackages/*/node_modules/ai/docs/instead.
Quick Reference
Installation
bun add ai @ai-sdk/openai zod # or @ai-sdk/anthropic, @ai-sdk/google, etc.Core Functions
| Function | Purpose |
|---|---|
generateText | Non-streaming text generation (+ structured output with Output) |
streamText | Streaming text generation (+ structured output with Output) |
v6 Note:generateObject/streamObjectare deprecated.
UsegenerateText/streamTextwithoutput: Output.object({ schema })instead.
Structured Output (v6)
import { generateText, Output } from "ai";
import { z } from "zod";
const { output } = await generateText({
model: anthropic("claude-sonnet-4-6"),
output: Output.object({
schema: z.object({
sentiment: z.enum(["positive", "neutral", "negative"]),
topics: z.array(z.string()),
}),
}),
prompt: "Analyze this feedback...",
});Output types: Output.object(), Output.array(), Output.choice(), Output.json(), Output.text() (default)
Agent Class (v6 Key Feature)
import { ToolLoopAgent, tool, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const myAgent = new ToolLoopAgent({
model: anthropic("claude-sonnet-4-6"),
instructions: "You are a helpful assistant.",
tools: {
getData: tool({
description: "Fetch data from API",
inputSchema: z.object({
query: z.string(),
}),
execute: async ({ query }) => {
return { result: "data" };
},
}),
},
stopWhen: stepCountIs(20),
});
// Usage
const { text } = await myAgent.generate({ prompt: "Hello" });
const stream = await myAgent.stream({ prompt: "Hello" });API Route with Agent
// app/api/chat/route.ts
import { createAgentUIStreamResponse } from "ai";
import { myAgent } from "@/agents/my-agent";
export async function POST(request: Request) {
const { messages } = await request.json();
return createAgentUIStreamResponse({
agent: myAgent,
uiMessages: messages,
});
}Smooth Streaming
import { createAgentUIStreamResponse, smoothStream } from "ai";
return createAgentUIStreamResponse({
agent: myAgent,
uiMessages: messages,
experimental_transform: smoothStream({
delayInMs: 15,
chunking: "word", // "word" | "line" | RegExp | Intl.Segmenter | callback
}),
});useChat Hook (Client)
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useState } from "react";
export function Chat() {
const [input, setInput] = useState("");
const { messages, sendMessage, status } = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
});
return (
<>
{messages.map((msg) => (
<div key={msg.id}>
{msg.parts.map((part, i) =>
part.type === "text" ? <span key={i}>{part.text}</span> : null
)}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput("");
}
}}
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={status !== "ready"}
/>
<button type="submit" disabled={status !== "ready"}>
Send
</button>
</form>
</>
);
}v6 Note:useChatno longer manages input state internally. UseuseStatefor controlled inputs.
Reference Documentation
For detailed information, see:
- agents.md - ToolLoopAgent, loop control, workflows
- core-functions.md - generateText, streamText, Output patterns
- tools.md - Tool definition with Zod schemas
- workflows.md - Sequential, parallel, routing, and orchestrator-worker patterns
- ui-hooks.md - useChat, UIMessage, streaming
- middleware.md - Custom middleware patterns
- mcp.md - MCP server integration
- examples.md - Canonical provider × feature examples from vercel/ai repo
Official Documentation
For the latest information, see AI SDK docs.
Agents in AI SDK v6
ToolLoopAgent Class
The ToolLoopAgent class encapsulates LLM configuration, tools, and behavior into reusable components.
Creating an Agent
import { ToolLoopAgent, tool, stepCountIs } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const codeAgent = new ToolLoopAgent({
model: anthropic("claude-sonnet-4-6"),
instructions: `You are a senior software engineer.
Focus on security, performance, and maintainability.`,
tools: {
runCode: tool({
description: "Execute Python code",
inputSchema: z.object({
code: z.string(),
}),
execute: async ({ code }) => {
return { output: "Code executed successfully" };
},
}),
},
stopWhen: stepCountIs(20),
});Configuration Options
| Option | Description |
|---|---|
model | AI model to use |
instructions | System prompt defining agent behavior |
tools | Tools the agent can use |
stopWhen | Stopping conditions (default: 20 steps) |
toolChoice | Control tool usage: auto, required, none |
output | Structured output schema |
prepareStep | Callback to modify settings per step |
callOptionsSchema | Type-safe call options schema |
Using the Agent
// Non-streaming
const { text, toolCalls } = await myAgent.generate({
prompt: "Analyze this data",
});
// Streaming
const stream = await myAgent.stream({
prompt: "Tell me a story",
});
for await (const chunk of stream.textStream) {
console.log(chunk);
}API Route Integration
// app/api/chat/route.ts
import { createAgentUIStreamResponse } from "ai";
import { myAgent } from "@/agents/my-agent";
export async function POST(request: Request) {
const { messages } = await request.json();
return createAgentUIStreamResponse({
agent: myAgent,
uiMessages: messages,
sendSources: true,
includeUsage: true,
});
}Loop Control
Stop Conditions
import { ToolLoopAgent, stepCountIs, hasToolCall } from "ai";
const agent = new ToolLoopAgent({
model: anthropic("claude-sonnet-4-6"),
tools: {
/* ... */
},
stopWhen: [
stepCountIs(20), // Max 20 steps
hasToolCall("finalize"), // Stop after specific tool
],
});Custom Stop Conditions
import { StopCondition, ToolSet } from "ai";
const tools = {
/* ... */
} satisfies ToolSet;
const hasAnswer: StopCondition<typeof tools> = ({ steps }) => {
return steps.some((step) => step.text?.includes("ANSWER:")) ?? false;
};
const budgetExceeded: StopCondition<typeof tools> = ({ steps }) => {
const totalUsage = steps.reduce(
(acc, step) => ({
inputTokens: acc.inputTokens + (step.usage?.inputTokens ?? 0),
outputTokens: acc.outputTokens + (step.usage?.outputTokens ?? 0),
}),
{ inputTokens: 0, outputTokens: 0 },
);
const cost =
(totalUsage.inputTokens * 0.01 + totalUsage.outputTokens * 0.03) / 1000;
return cost > 0.5;
};prepareStep - Dynamic Configuration
const agent = new ToolLoopAgent({
model: anthropic("claude-sonnet-4-6"),
tools: {
search: searchTool,
analyze: analyzeTool,
summarize: summarizeTool,
},
prepareStep: async ({ stepNumber, messages }) => {
// Search phase (steps 0-2)
if (stepNumber <= 2) {
return {
activeTools: ["search"],
toolChoice: "required",
};
}
// Analysis phase (steps 3-5)
if (stepNumber <= 5) {
return { activeTools: ["analyze"] };
}
// Summary phase
return {
activeTools: ["summarize"],
toolChoice: "required",
};
},
});Context Management
prepareStep: async ({ messages }) => {
if (messages.length > 20) {
return {
messages: [
messages[0], // Keep system instructions
...messages.slice(-10), // Keep last 10 messages
],
};
}
return {};
},Call Options
Type-safe runtime configuration:
const supportAgent = new ToolLoopAgent({
model: anthropic("claude-sonnet-4-6"),
callOptionsSchema: z.object({
userId: z.string(),
accountType: z.enum(["free", "pro", "enterprise"]),
}),
instructions: "You are a customer support agent.",
prepareCall: ({ options, ...settings }) => ({
...settings,
instructions:
settings.instructions +
`
User context:
- Account: ${options.accountType}
- User ID: ${options.userId}
`,
}),
});
const result = await supportAgent.generate({
prompt: "How do I upgrade?",
options: {
userId: "user_123",
accountType: "free",
},
});Async prepareCall with RAG
prepareCall can be async for fetching context:
const ragAgent = new ToolLoopAgent({
model: anthropic("claude-sonnet-4-6"),
callOptionsSchema: z.object({
query: z.string(),
complexity: z.enum(["simple", "complex"]).optional(),
}),
prepareCall: async ({ options, ...settings }) => {
// Fetch relevant documents (async)
const documents = await vectorSearch(options.query);
return {
...settings,
// Dynamic model selection
model:
options.complexity === "complex"
? anthropic("claude-sonnet-4-6")
: anthropic("claude-haiku-4-5"),
// Inject context into instructions
instructions: `Answer using this context:
${documents.map((doc) => doc.content).join("\n\n")}`,
};
},
});Structured Output
import { Output } from "ai";
const analysisAgent = new ToolLoopAgent({
model: anthropic("claude-sonnet-4-6"),
output: Output.object({
schema: z.object({
sentiment: z.enum(["positive", "neutral", "negative"]),
summary: z.string(),
keyPoints: z.array(z.string()),
}),
}),
stopWhen: stepCountIs(10),
});
const { output } = await analysisAgent.generate({
prompt: "Analyze customer feedback",
});
console.log(output.sentiment); // Type-safe accessType-Safe UIMessage
import { ToolLoopAgent, InferAgentUIMessage } from "ai";
const myAgent = new ToolLoopAgent({
/* config */
});
export type MyAgentUIMessage = InferAgentUIMessage<typeof myAgent>;Use in client:
"use client";
import { useChat } from "@ai-sdk/react";
import type { MyAgentUIMessage } from "@/agents/my-agent";
export function Chat() {
const { messages } = useChat<MyAgentUIMessage>();
// Full type safety for messages and tools
}Core Functions
generateText
Non-interactive text generation for automation tasks, agents, and structured output.
import { generateText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
const { text, usage, finishReason } = await generateText({
model: anthropic("claude-sonnet-4-6"),
prompt: "Write a vegetarian lasagna recipe for 4 people.",
});With System Prompt
const { text } = await generateText({
model: anthropic("claude-sonnet-4-6"),
system: "You are an expert chef specializing in Italian cuisine.",
prompt: "Write a vegetarian lasagna recipe.",
});With Messages
const { text } = await generateText({
model: anthropic("claude-sonnet-4-6"),
messages: [
{ role: "user", content: "Hello!" },
{ role: "assistant", content: "Hi! How can I help?" },
{ role: "user", content: "What's the weather?" },
],
});Return Object
| Property | Description |
|---|---|
text | Generated text |
content | Generated content from last step |
finishReason | Why generation stopped ('stop', 'length', 'content-filter', 'tool-calls', 'error', 'other') |
usage | Token usage for final step |
totalUsage | Cumulative usage across all steps |
toolCalls | Tool invocations made |
toolResults | Results from tool executions |
response | Full response with headers, id, modelId, timestamp |
steps | Details for all intermediate steps |
reasoning | Model reasoning (only some models) |
reasoningText | Reasoning as string |
sources | Sources used for generation (RAG models) |
files | Generated files |
output | Structured output when using Output specification |
providerMetadata | Provider-specific metadata |
warnings | Provider warnings |
streamText
Real-time streaming for interactive applications.
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
prompt: "Write a poem about AI.",
});
for await (const chunk of result.textStream) {
process.stdout.write(chunk);
}Stream Properties
| Property | Description |
|---|---|
textStream | Async iterable of text chunks |
fullStream | Complete event stream (text, tools, reasoning, sources, etc.) |
partialOutputStream | Stream of partial parsed outputs (with Output spec) |
Callbacks
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
prompt: "Hello",
onChunk: ({ chunk }) => {
// chunk types: 'text-delta', 'reasoning-delta', 'source', 'tool-call',
// 'tool-input-start', 'tool-input-delta', 'tool-result', 'raw'
if (chunk.type === "text-delta") {
console.log("Text:", chunk.text);
}
},
onStepFinish: ({ text, toolCalls, toolResults, usage }) => {
console.log("Step finished");
},
onFinish: ({ text, usage, finishReason, steps }) => {
console.log("Finished:", text.length, "chars");
},
onError: (error) => {
console.error("Stream error:", error);
},
});API Response
// app/api/chat/route.ts
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
messages,
});
return result.toUIMessageStreamResponse();
}Structured Output with Output Specification (v6)
Important: In AI SDK v6,generateObjectandstreamObjectare deprecated.
UsegenerateText/streamTextwith theoutputproperty instead.
Output.object() - Typed Object Generation
import { generateText, Output } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const { output } = await generateText({
model: anthropic("claude-sonnet-4-6"),
output: Output.object({
schema: z.object({
sentiment: z.enum(["positive", "neutral", "negative"]),
topics: z.array(z.string()),
summary: z.string().max(200),
}),
}),
prompt: "Analyze: 'The product is amazing but shipping was slow'",
});
console.log(output.sentiment); // "positive" | "neutral" | "negative"
console.log(output.topics); // string[]Output.array() - Array Generation
import { generateText, Output } from "ai";
import { z } from "zod";
const { output } = await generateText({
model: anthropic("claude-sonnet-4-6"),
output: Output.array({
element: z.object({
title: z.string(),
priority: z.number().min(1).max(5),
}),
}),
prompt: "Generate 5 todo items for a web developer",
});
// output is typed as Array<{ title: string; priority: number }>Output.choice() - Enum/Choice Generation
import { generateText, Output } from "ai";
const { output } = await generateText({
model: anthropic("claude-sonnet-4-6"),
output: Output.choice({
options: ["bug", "feature", "question", "documentation"],
}),
prompt: "Classify: 'The login button is not working'",
});
// output is typed as "bug" | "feature" | "question" | "documentation"Output.json() - Unstructured JSON
import { generateText, Output } from "ai";
const { output } = await generateText({
model: anthropic("claude-sonnet-4-6"),
output: Output.json(),
prompt: "Return user data as JSON",
});
// output is unknown JSON objectStreaming Structured Output
import { streamText, Output } from "ai";
import { z } from "zod";
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
output: Output.object({
schema: z.object({
recipe: z.object({
name: z.string(),
ingredients: z.array(z.string()),
steps: z.array(z.string()),
}),
}),
}),
prompt: "Generate a pasta recipe",
});
// Stream partial objects as they're generated
for await (const partial of result.partialOutputStream) {
console.log("Partial:", partial);
}
// Get final complete output
const { output } = await result;
console.log("Final:", output);Combined: Tools + Structured Output
AI SDK v6 enables multi-step tool calling with structured output at the end:
import { generateText, Output, tool } from "ai";
import { z } from "zod";
const { output, steps } = await generateText({
model: anthropic("claude-sonnet-4-6"),
tools: {
getWeather: tool({
description: "Get weather for a location",
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({ temp: 22, conditions: "sunny" }),
}),
},
output: Output.object({
schema: z.object({
recommendation: z.string(),
confidence: z.number(),
}),
}),
prompt: "Should I bring an umbrella to Helsinki today?",
});
// Tools are called first, then structured output is generated
console.log(output.recommendation);Legacy: generateObject / streamObject (Deprecated)
Deprecation Notice: These functions will be removed in a future version.
Migrate togenerateText/streamTextwithOutputspecification.
// DEPRECATED - Don't use in new code
import { generateObject } from "ai";
const { object } = await generateObject({
model: anthropic("claude-sonnet-4-6"),
schema: z.object({ ... }),
prompt: "...",
});
// NEW - Use this instead
import { generateText, Output } from "ai";
const { output } = await generateText({
model: anthropic("claude-sonnet-4-6"),
output: Output.object({ schema: z.object({ ... }) }),
prompt: "...",
});Common Options
All core functions support these options:
{
model: anthropic("claude-sonnet-4-6"),
prompt: "...",
system: "...",
messages: [...],
// Generation settings
temperature: 0.7,
maxOutputTokens: 1000,
topP: 0.9,
topK: 40,
presencePenalty: 0,
frequencyPenalty: 0,
stopSequences: ["END"],
seed: 12345,
// Multi-step / Agent settings
tools: { ... },
toolChoice: "auto" | "none" | "required" | { type: "tool", toolName: "..." },
stopWhen: stepCountIs(10),
prepareStep: ({ steps, stepNumber }) => ({ ... }),
// Structured output (v6)
output: Output.object({ schema }) | Output.array({ element }) | Output.choice({ options }) | Output.text(),
// Provider options
providerOptions: {
anthropic: { ... },
},
// Request control
abortSignal: controller.signal,
timeout: 30000,
maxRetries: 2,
headers: { ... },
}Migration: v5 to v6
# Run automatic migration
npx @ai-sdk/codemod v6Key changes:
toDataStreamResponse()→toUIMessageStreamResponse()generateObject()→generateText()withOutput.object()streamObject()→streamText()withOutput.object()parametersin tools →inputSchema
Canonical Examples
The vercel/ai repo maintains runnable examples for every supported AI SDK function, provider, and feature combination. These fill the gap between conceptual docs in node_modules/ai/docs/ and high-level API reference on ai-sdk.dev — working, copy-pasteable code that tracks the current main branch (v6 APIs).
Do not clone the repo. Fetch individual files on demand via WebFetch or gh api.Path Pattern
examples/ai-functions/src/{function}/{provider}/{feature}.tsTop-Level Categories
| Category | Directories |
|---|---|
| Text generation | generate-text, stream-text, stream-text-custom-loop |
| Agent | agent |
| Embedding | embed, embed-many, rerank |
| Media | generate-image, generate-video, generate-speech, transcribe |
| Tooling | tools, middleware, registry, telemetry, gateway |
| Integration | complex, upload-file |
Each function directory splits by provider: anthropic, openai, google, amazon, azure, bedrock, cohere, groq, xai, deepseek, fireworks, huggingface, and more.
Discovery
List files under a provider subdirectory:
gh api repos/vercel/ai/contents/examples/ai-functions/src/{function}/{provider} \
--jq '[.[] | select(.type=="file") | .name]'Or fetch the GitHub tree page with WebFetch:
https://github.com/vercel/ai/tree/main/examples/ai-functions/src/{function}/{provider}Fetching a Single File
Use the raw URL:
https://raw.githubusercontent.com/vercel/ai/main/examples/ai-functions/src/{function}/{provider}/{feature}.tsConcrete example — Anthropic prompt caching:
https://raw.githubusercontent.com/vercel/ai/main/examples/ai-functions/src/generate-text/anthropic/cache-control.tsWhen to Reach for This
- Provider-specific features (Anthropic
adaptive-thinking, OpenAIcomputer-use, Google grounding) - Version-suffixed feature flags (e.g.
code-execution-20250825.ts) - Multi-step agent patterns not covered in docs
- Middleware compositions
- Streaming edge cases (tool-call streaming, reasoning streams)
When Not to Reach for This
- Basic API usage —
node_modules/ai/docs/is faster - High-level API reference — ai-sdk.dev is faster
- Examples assume you already know the API; they are working patterns, not tutorials
MCP (Model Context Protocol) Integration
Connect to MCP servers to access external tools, resources, and prompts.
Installation
bun add @ai-sdk/mcpTransport Types
HTTP Transport (Production)
import { createMCPClient } from "@ai-sdk/mcp";
const mcpClient = await createMCPClient({
transport: {
type: "http",
url: "https://your-server.com/mcp",
headers: { Authorization: "Bearer my-api-key" },
},
});SSE Transport
const mcpClient = await createMCPClient({
transport: {
type: "sse",
url: "https://my-server.com/sse",
headers: { Authorization: "Bearer my-api-key" },
},
});Stdio Transport (Local Development)
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
const mcpClient = await createMCPClient({
transport: new StdioClientTransport({
command: "node",
args: ["src/server.js"],
}),
});Tool Discovery
Auto-discovery
const tools = await mcpClient.tools();Type-safe Schema Definition
import { z } from "zod";
const tools = await mcpClient.tools({
schemas: {
"get-weather": {
inputSchema: z.object({
location: z.string().describe("City name"),
}),
outputSchema: z.object({
temperature: z.number(),
conditions: z.string(),
}),
},
},
});Full Integration Example
import { createMCPClient } from "@ai-sdk/mcp";
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
const mcpClient = await createMCPClient({
transport: {
type: "http",
url: "https://your-server.com/mcp",
headers: { Authorization: "Bearer my-api-key" },
},
});
const tools = await mcpClient.tools({
schemas: {
"get-weather": {
inputSchema: z.object({ location: z.string() }),
outputSchema: z.object({
temperature: z.number(),
conditions: z.string(),
}),
},
},
});
const result = await streamText({
model: anthropic("claude-sonnet-4-6"),
tools,
prompt: "What is the weather in Brooklyn?",
onFinish: async () => {
await mcpClient.close();
},
});Multiple MCP Clients
const weatherClient = await createMCPClient({
transport: { type: "http", url: "https://weather-server.com/mcp" },
});
const dataClient = await createMCPClient({
transport: { type: "http", url: "https://data-server.com/mcp" },
});
const combinedTools = {
...(await weatherClient.tools()),
...(await dataClient.tools()),
};
try {
await streamText({
model: anthropic("claude-sonnet-4-6"),
tools: combinedTools,
prompt: "Fetch and analyze data",
});
} finally {
await weatherClient.close();
await dataClient.close();
}Additional Features
| Method | Description |
|---|---|
mcpClient.listResources() | List available resources |
mcpClient.readResource(uri) | Read a specific resource |
mcpClient.listResourceTemplates() | List resource templates |
mcpClient.experimental_listPrompts() | List available prompts |
mcpClient.experimental_getPrompt(name) | Get a specific prompt |
Elicitation Support
For interactive tool flows requiring user input:
const mcpClient = await createMCPClient({
transport: {
type: "sse",
url: "https://your-server.com/sse",
},
capabilities: {
elicitation: {},
},
});
mcpClient.onElicitationRequest(ElicitationRequestSchema, async (request) => {
try {
const userInput = await getInputFromUser(
request.params.message,
request.params.requestedSchema,
);
return {
action: "accept",
content: userInput,
};
} catch (error) {
return { action: "decline" };
}
});Error Handling
let mcpClient: MCPClient | undefined;
try {
mcpClient = await createMCPClient({
transport: {
type: "http",
url: "https://your-server.com/mcp",
},
});
const tools = await mcpClient.tools();
await streamText({
model: anthropic("claude-sonnet-4-6"),
tools,
prompt: "Your prompt here",
});
} catch (error) {
console.error("MCP Client Error:", error);
} finally {
await mcpClient?.close();
}Best Practices
1. Always close the client - Use onFinish or try/finally 2. HTTP/SSE for production - Stdio only for local dev 3. Define schemas explicitly - Better TypeScript integration 4. Handle errors gracefully - MCP servers may be unavailable 5. Use OAuth when available - Pass authProvider for auto-auth
Middleware
Language model middleware for intercepting and modifying model behavior.
Core Interception Points
| Hook | Purpose |
|---|---|
transformParams | Modify parameters before model call |
wrapGenerate | Wrap non-streaming calls |
wrapStream | Wrap streaming calls |
Built-in Middleware
Extract Reasoning
import { wrapLanguageModel, extractReasoningMiddleware } from "ai";
const model = wrapLanguageModel({
model: anthropic("claude-sonnet-4-6"),
middleware: extractReasoningMiddleware({ tagName: "think" }),
});Simulate Streaming
import { wrapLanguageModel, simulateStreamingMiddleware } from "ai";
const model = wrapLanguageModel({
model: yourModel,
middleware: simulateStreamingMiddleware(),
});Default Settings
import { wrapLanguageModel, defaultSettingsMiddleware } from "ai";
const model = wrapLanguageModel({
model: anthropic("claude-sonnet-4-6"),
middleware: defaultSettingsMiddleware({
settings: {
temperature: 0.5,
maxOutputTokens: 800,
},
}),
});Multiple Middleware
Stack in application order:
const wrappedModel = wrapLanguageModel({
model: yourModel,
middleware: [firstMiddleware, secondMiddleware],
// Applied as: firstMiddleware(secondMiddleware(yourModel))
});Custom Middleware
Logging
import { LanguageModelV3Middleware } from "ai";
export const logMiddleware: LanguageModelV3Middleware = {
wrapGenerate: async ({ doGenerate, params }) => {
console.log("Parameters:", JSON.stringify(params, null, 2));
const result = await doGenerate();
console.log("Generated:", result.text);
return result;
},
wrapStream: async ({ doStream, params }) => {
const { stream, ...rest } = await doStream();
const transformStream = new TransformStream({
transform(chunk, controller) {
console.log("Chunk:", chunk);
controller.enqueue(chunk);
},
});
return { stream: stream.pipeThrough(transformStream), ...rest };
},
};Caching
const cache = new Map<string, any>();
export const cacheMiddleware: LanguageModelV3Middleware = {
wrapGenerate: async ({ doGenerate, params }) => {
const key = JSON.stringify(params);
if (cache.has(key)) {
return cache.get(key);
}
const result = await doGenerate();
cache.set(key, result);
return result;
},
};RAG (Retrieval-Augmented Generation)
export const ragMiddleware: LanguageModelV3Middleware = {
transformParams: async ({ params }) => {
const messageText = getLastUserMessageText({ prompt: params.prompt });
if (!messageText) return params;
const sources = await vectorSearch(messageText);
const contextInstruction = sources
.map((chunk) => JSON.stringify(chunk))
.join("\n");
return addToLastUserMessage({ params, text: contextInstruction });
},
};Guardrails
export const guardrailMiddleware: LanguageModelV3Middleware = {
wrapGenerate: async ({ doGenerate }) => {
const { text, ...rest } = await doGenerate();
const cleaned = text?.replace(/badword/g, "<REDACTED>");
return { text: cleaned, ...rest };
},
};Per-Request Metadata
Pass context through providerOptions:
const { text } = await generateText({
model: wrapLanguageModel({
model: anthropic("claude-sonnet-4-6"),
middleware: customMiddleware,
}),
prompt: "Your prompt...",
providerOptions: {
customMiddleware: {
userId: "123",
timestamp: Date.now(),
},
},
});Access in middleware:
wrapGenerate: async ({ doGenerate, params }) => {
const metadata = params?.providerMetadata?.customMiddleware;
console.log("User:", metadata?.userId);
return doGenerate();
};Best Practices
1. Order matters - Stack logically (logging before caching) 2. Handle streams carefully - Buffering may be needed for guardrails 3. Reuse middleware - Share across model instances 4. Type safety - Implement proper TypeScript interfaces 5. Error handling - Prevent cascade failures
Tools
Defining Tools
import { tool } from "ai";
import { z } from "zod";
const weatherTool = tool({
description: "Get the current weather in a location",
inputSchema: z.object({
location: z.string().describe("City name"),
unit: z.enum(["celsius", "fahrenheit"]).optional().default("celsius"),
}),
execute: async ({ location, unit }) => {
// Fetch weather data
return {
temperature: 22,
conditions: "sunny",
unit,
};
},
});Tool Properties
| Property | Required | Description |
|---|---|---|
description | No | Helps model decide when to use tool |
inputSchema | Yes | Zod schema for input validation |
outputSchema | No | Zod schema for output type safety |
execute | No | Async function to run when tool is called |
needsApproval | No | Require user approval before execution (boolean or function) |
Using Tools with generateText
import { generateText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
const { text, toolCalls, toolResults } = await generateText({
model: anthropic("claude-sonnet-4-6"),
prompt: "What's the weather in Tokyo?",
tools: {
weather: weatherTool,
},
});
console.log("Tool calls:", toolCalls);
console.log("Tool results:", toolResults);Using Tools with streamText
import { streamText } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
prompt: "What's the weather in Tokyo?",
tools: {
weather: weatherTool,
},
});
for await (const event of result.fullStream) {
switch (event.type) {
case "tool-call":
console.log("Tool called:", event.toolName, event.input);
break;
case "tool-result":
console.log("Tool result:", event.output);
break;
case "text-delta":
process.stdout.write(event.text);
break;
}
}Tool Choice
Control how the model uses tools:
// Let model decide (default)
{ toolChoice: "auto" }
// Force tool use
{ toolChoice: "required" }
// Disable tools
{ toolChoice: "none" }
// Force specific tool
{
toolChoice: {
type: "tool",
toolName: "weather",
}
}Tool Execution Approval
Require user confirmation before server-side tools execute:
Basic Approval
const deleteFileTool = tool({
description: "Delete a file from the system",
inputSchema: z.object({
filename: z.string(),
}),
needsApproval: true, // Always require approval
execute: async ({ filename }) => {
await fs.unlink(filename);
return { deleted: true };
},
});Dynamic Approval
const transferTool = tool({
description: "Transfer funds",
inputSchema: z.object({
amount: z.number(),
to: z.string(),
}),
// Only require approval for large amounts
needsApproval: ({ amount }) => amount > 1000,
execute: async ({ amount, to }) => {
return await transferFunds(amount, to);
},
});Client-Side Approval Handling
See ui-hooks.md for client-side approval UI.
Client-Side Tools (No execute)
For tools that run in the browser:
const confirmTool = tool({
description: "Request user confirmation",
inputSchema: z.object({
message: z.string(),
}),
// No execute - handled by client via onToolCall
});
const getLocationTool = tool({
description: "Get user's current location",
inputSchema: z.object({}),
// No execute - handled by client
});Tool Part States
When rendering tool calls in the UI, handle these states:
| State | Description |
|---|---|
input-streaming | Tool input is being streamed (partial args) |
input-available | Tool input is complete, awaiting execution |
approval-requested | Awaiting user approval (needsApproval: true) |
approval-responded | User responded, awaiting execution result |
output-available | Tool execution completed successfully |
output-denied | User denied approval (needsApproval only) |
output-error | Tool execution failed |
{message.parts.map((part) => {
if (part.type === "tool-weather") {
switch (part.state) {
case "input-streaming":
return <div>Preparing request...</div>;
case "input-available":
return <div>Getting weather for {part.input.location}...</div>;
case "approval-requested":
return <ApprovalDialog part={part} />;
case "output-available":
return <WeatherCard data={part.output} />;
case "output-error":
return <div>Error: {part.errorText}</div>;
}
}
})}Dynamic Tools
Tools with unknown schemas at compile time use the dynamic-tool type:
// Server-side: Tools loaded at runtime (e.g., from MCP)
const dynamicTools = await loadMCPTools();
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
tools: dynamicTools,
// ...
});
// Client-side: Handle dynamic-tool type
{message.parts.map((part) => {
if (part.type === "dynamic-tool") {
return (
<div key={part.toolCallId}>
<h4>Tool: {part.toolName}</h4>
{part.state === "input-streaming" && (
<pre>{JSON.stringify(part.input, null, 2)}</pre>
)}
{part.state === "output-available" && (
<pre>{JSON.stringify(part.output, null, 2)}</pre>
)}
{part.state === "output-error" && (
<div>Error: {part.errorText}</div>
)}
</div>
);
}
})}Dynamic Tool Check in onToolCall
async onToolCall({ toolCall }) {
// IMPORTANT: Check dynamic first for TypeScript type narrowing
if (toolCall.dynamic) {
// Handle unknown tools
console.log("Dynamic tool:", toolCall.toolName, toolCall.args);
return;
}
// TypeScript now knows this is a static tool
if (toolCall.toolName === "getLocation") {
addToolOutput({
tool: "getLocation",
toolCallId: toolCall.toolCallId,
output: "Helsinki",
});
}
},Tool Call Streaming
Tool call streaming is enabled by default in AI SDK v6:
// Tool inputs stream as they're generated
{message.parts.map((part) => {
if (part.type === "tool-search") {
if (part.state === "input-streaming") {
// Show partial input as it streams
return <pre>{JSON.stringify(part.input, null, 2)}</pre>;
}
if (part.state === "input-available") {
return <div>Searching for: {part.input.query}</div>;
}
}
})}Multi-Step Tool Calls
Server-Side Multi-Step
import { streamText, stepCountIs } from "ai";
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
messages: await convertToModelMessages(messages),
tools: {
search: searchTool,
calculate: calculateTool,
},
stopWhen: stepCountIs(5), // Max 5 tool call iterations
});
return result.toUIMessageStreamResponse();Step Boundaries in UI
{message.parts.map((part, index) => {
switch (part.type) {
case "step-start":
// Show step boundaries as horizontal lines
return index > 0 ? <hr key={index} /> : null;
case "text":
return <p key={index}>{part.text}</p>;
case "tool-search":
case "tool-calculate":
return <ToolDisplay key={index} part={part} />;
}
})}Client-Side Auto-Submit
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
const { messages, sendMessage, addToolOutput } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
// Auto-resubmit when all tool results available
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
async onToolCall({ toolCall }) {
if (toolCall.dynamic) return;
// Provide tool result...
},
});Error Handling
Client-Side Tool Errors
async onToolCall({ toolCall }) {
if (toolCall.dynamic) return;
if (toolCall.toolName === "fetchData") {
try {
const data = await fetchData(toolCall.input);
addToolOutput({
tool: "fetchData",
toolCallId: toolCall.toolCallId,
output: data,
});
} catch (err) {
// Report error state
addToolOutput({
tool: "fetchData",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: err instanceof Error ? err.message : "Unknown error",
});
}
}
},Server-Side Error Handling
return result.toUIMessageStreamResponse({
onError: (error) => {
if (error == null) return "Unknown error";
if (typeof error === "string") return error;
if (error instanceof Error) return error.message;
return JSON.stringify(error);
},
});Complex Tool Schemas
const createTaskTool = tool({
description: "Create a new task",
inputSchema: z.object({
title: z.string().min(1).max(100),
description: z.string().optional(),
priority: z.enum(["low", "medium", "high"]),
dueDate: z.string().datetime().optional(),
tags: z.array(z.string()).max(5).optional(),
assignee: z
.object({
id: z.string(),
name: z.string(),
})
.optional(),
}),
execute: async (task) => {
const created = await db.tasks.create(task);
return { id: created.id, status: "created" };
},
});Typed Tool Results
const weatherTool = tool({
description: "Get weather",
inputSchema: z.object({
location: z.string(),
}),
outputSchema: z.object({
temperature: z.number(),
conditions: z.string(),
}),
execute: async ({ location }) => {
return {
temperature: 22,
conditions: "sunny",
};
},
});Multiple Tools with Agent
import { ToolLoopAgent } from "ai";
const agent = new ToolLoopAgent({
model: anthropic("claude-sonnet-4-6"),
tools: {
search: tool({
description: "Search the web",
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => searchWeb(query),
}),
calculate: tool({
description: "Perform calculations",
inputSchema: z.object({ expression: z.string() }),
execute: async ({ expression }) => {
const { evaluate } = await import("mathjs");
return evaluate(expression);
},
}),
weather: tool({
description: "Get weather data",
inputSchema: z.object({ location: z.string() }),
execute: async ({ location }) => getWeather(location),
}),
},
});Provider-Specific Tools
Some providers offer built-in tools:
OpenAI Web Search
import { openai } from "@ai-sdk/openai";
const agent = new ToolLoopAgent({
model: openai("gpt-5.4"),
tools: {
web_search: openai.tools.webSearch({
searchContextSize: "low", // "low" | "medium" | "high"
userLocation: { type: "approximate", country: "FI" },
}),
// ...other tools
},
});Provider tools appear as tool-{name} parts in the UI and produce source-url parts with citation URLs.
Schema Libraries
Zod (Recommended)
import { z } from "zod";
const schema = z.object({
name: z.string(),
age: z.number().int().positive(),
});Valibot
import { valibotSchema } from "@ai-sdk/valibot";
import * as v from "valibot";
const schema = valibotSchema(
v.object({
name: v.string(),
age: v.number(),
}),
);JSON Schema
import { jsonSchema } from "ai";
const schema = jsonSchema({
type: "object",
properties: {
name: { type: "string" },
age: { type: "integer" },
},
required: ["name", "age"],
});Type Inference
import { InferUITool, InferUITools, ToolSet } from "ai";
// Single tool
type WeatherUITool = InferUITool<typeof weatherTool>;
// { input: { location: string }; output: { temperature: number; conditions: string } }
// Tool set
const tools = {
weather: weatherTool,
search: searchTool,
} satisfies ToolSet;
type MyUITools = InferUITools<typeof tools>;
// { weather: { input: ...; output: ... }; search: { input: ...; output: ... } }UI Hooks & Components
useChat Hook
"use client";
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
import { useState } from "react";
export function Chat() {
const [input, setInput] = useState("");
const {
messages,
sendMessage,
status,
error,
stop,
regenerate,
setMessages,
clearError,
addToolOutput,
addToolApprovalResponse,
} = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
}),
});
return (
<div>
{messages.map((message) => (
<div key={message.id}>
<strong>{message.role}:</strong>
{message.parts.map((part, i) =>
part.type === "text" ? <p key={i}>{part.text}</p> : null
)}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput("");
}
}}
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
disabled={status !== "ready"}
/>
<button type="submit" disabled={status !== "ready"}>
Send
</button>
</form>
</div>
);
}v6 Note:useChatno longer manages input state internally. UseuseStatefor controlled inputs.
useChat Parameters
| Parameter | Type | Description |
|---|---|---|
id | string | Unique chat identifier |
messages | UIMessage[] | Initial messages |
transport | ChatTransport | Transport for API communication (see below) |
onToolCall | function | Called when tool call received |
onFinish | function | Called when response finished |
onError | function | Error callback |
onData | function | Called when data part received |
sendAutomaticallyWhen | function | Condition for auto-submitting (e.g., tool calls) |
resume | boolean | Enable stream resumption for recovery |
experimental_throttle | number | Throttle UI updates (ms) |
useChat Return Values
| Property | Type | Description |
|---|---|---|
id | string | Chat ID |
messages | UIMessage[] | Current messages |
status | `'submitted' \ | 'streaming' \ |
error | `Error \ | undefined` |
sendMessage | function | Send new message |
regenerate | function | Regenerate last response |
stop | function | Stop streaming |
setMessages | function | Update messages locally |
resumeStream | function | Resume interrupted stream |
addToolOutput | function | Provide tool result |
addToolApprovalResponse | function | Approve/deny tool execution |
clearError | function | Clear current error |
Status Values
submitted- Message sent, awaiting response startstreaming- Response is actively streamingready- Ready for new messageerror- An error occurred
Transport Options
DefaultChatTransport (Recommended)
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport } from "ai";
const { messages, sendMessage } = useChat({
transport: new DefaultChatTransport({
api: "/api/chat",
headers: { Authorization: `Bearer ${token}` },
body: { user_id: "123" },
credentials: "same-origin",
}),
});Dynamic Configuration
transport: new DefaultChatTransport({
api: "/api/chat",
headers: () => ({ Authorization: `Bearer ${getAuthToken()}` }),
body: () => ({ sessionId: getCurrentSessionId() }),
}),Custom Request Preparation
transport: new DefaultChatTransport({
api: "/api/chat",
prepareSendMessagesRequest: ({ id, messages, trigger }) => {
if (trigger === "submit-user-message") {
return { body: { id, message: messages[messages.length - 1] } };
}
return { body: { id, messages } };
},
}),TextStreamChatTransport
For plain text streams without tool support:
import { TextStreamChatTransport } from "ai";
const { messages } = useChat({
transport: new TextStreamChatTransport({
api: "/api/chat",
}),
});DirectChatTransport
For direct agent communication without HTTP:
import { DirectChatTransport, ToolLoopAgent } from "ai";
const agent = new ToolLoopAgent({
model: anthropic("claude-sonnet-4-6"),
instructions: "You are a helpful assistant.",
});
const { messages, sendMessage } = useChat({
transport: new DirectChatTransport({ agent }),
});UIMessage Type
interface UIMessage<METADATA, DATA_PARTS, TOOLS> {
id: string;
role: "system" | "user" | "assistant";
metadata?: METADATA;
parts: Array<UIMessagePart>;
}Message Part Types
// Text content
type TextUIPart = {
type: "text";
text: string;
state?: "streaming" | "done";
};
// Tool call (typed by tool name)
type ToolUIPart = {
type: `tool-${NAME}`;
toolCallId: string;
state:
| "input-streaming"
| "input-available"
| "approval-requested"
| "approval-responded"
| "output-available"
| "output-denied"
| "output-error";
input: ToolInput;
output?: ToolOutput;
errorText?: string;
approval?: { id: string };
};
// Dynamic tool (unknown at compile time)
type DynamicToolUIPart = {
type: "dynamic-tool";
toolName: string;
toolCallId: string;
state: "input-streaming" | "input-available" | "output-available" | "output-error";
input: unknown;
output?: unknown;
errorText?: string;
};
// Reasoning (for models that support it)
type ReasoningUIPart = {
type: "reasoning";
text: string;
state?: "streaming" | "done";
};
// File attachment
type FileUIPart = {
type: "file";
mediaType: string;
filename?: string;
url: string;
};
// Source references (RAG)
type SourceUrlUIPart = {
type: "source-url";
url: string;
title?: string;
};
// Step boundaries (multi-step)
type StepStartUIPart = {
type: "step-start";
};Tool Handling
Client-Side Tool Execution
import { useChat } from "@ai-sdk/react";
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from "ai";
const { messages, sendMessage, addToolOutput } = useChat({
transport: new DefaultChatTransport({ api: "/api/chat" }),
// Auto-submit when all tool results available
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
async onToolCall({ toolCall }) {
// IMPORTANT: Check dynamic first for type narrowing
if (toolCall.dynamic) {
return;
}
if (toolCall.toolName === "getLocation") {
const cities = ["Helsinki", "Tokyo", "New York"];
// No await - avoids potential deadlocks
addToolOutput({
tool: "getLocation",
toolCallId: toolCall.toolCallId,
output: cities[Math.floor(Math.random() * cities.length)],
});
}
},
});Tool Error Handling
async onToolCall({ toolCall }) {
if (toolCall.dynamic) return;
if (toolCall.toolName === "fetchData") {
try {
const data = await fetchData(toolCall.input);
addToolOutput({
tool: "fetchData",
toolCallId: toolCall.toolCallId,
output: data,
});
} catch (err) {
addToolOutput({
tool: "fetchData",
toolCallId: toolCall.toolCallId,
state: "output-error",
errorText: "Failed to fetch data",
});
}
}
},Tool Approval (needsApproval)
const { messages, addToolApprovalResponse } = useChat({
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
});
// In render:
{message.parts.map((part) => {
if (part.type === "tool-deleteFile" && part.state === "approval-requested") {
return (
<div key={part.toolCallId}>
<p>Delete {part.input.filename}?</p>
<button onClick={() => addToolApprovalResponse({
id: part.approval.id,
approved: true,
})}>
Approve
</button>
<button onClick={() => addToolApprovalResponse({
id: part.approval.id,
approved: false,
})}>
Deny
</button>
</div>
);
}
})}Rendering Tool Parts
{messages.map((message) =>
message.parts.map((part, i) => {
switch (part.type) {
case "text":
return <p key={i}>{part.text}</p>;
case "tool-weather":
switch (part.state) {
case "input-streaming":
return <div key={i}>Loading...</div>;
case "input-available":
return <div key={i}>Getting weather for {part.input.city}...</div>;
case "output-available":
return <WeatherCard key={i} data={part.output} />;
case "output-error":
return <div key={i}>Error: {part.errorText}</div>;
}
break;
case "dynamic-tool":
return (
<div key={i}>
<strong>{part.toolName}</strong>
<pre>{JSON.stringify(part.output ?? part.input, null, 2)}</pre>
</div>
);
case "reasoning":
return (
<details key={i}>
<summary>Thinking...</summary>
{part.text}
</details>
);
case "step-start":
return i > 0 ? <hr key={i} /> : null;
}
})
)}Message Persistence
consumeStream - Handling Client Disconnects
By default, streamText uses backpressure - when client disconnects (browser tab closed, network issue), the LLM stream is aborted and onFinish never fires. This leaves conversations in a broken state.
Solution: Call result.consumeStream() (without await) to ensure the stream completes and onFinish triggers even after client disconnect.
// app/api/chat/route.ts
import { streamText, convertToModelMessages, UIMessage } from "ai";
import { saveChat } from "@/lib/chat-storage";
export async function POST(req: Request) {
const { messages, chatId }: { messages: UIMessage[]; chatId: string } =
await req.json();
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
messages: await convertToModelMessages(messages),
});
// IMPORTANT: Consume stream to ensure completion even if client disconnects
// This removes backpressure - stream runs to completion regardless of client state
result.consumeStream(); // no await!
return result.toUIMessageStreamResponse({
originalMessages: messages,
onFinish: ({ messages }) => {
// This now fires even if client disconnected mid-stream
saveChat({ chatId, messages });
},
});
}Note: When client reloads after disconnect, chat restores from storage. For production, also track request state (in-progress/complete) to handle page reloads during active streaming.
Server-Side with createUIMessageStream
import {
createUIMessageStream,
createUIMessageStreamResponse,
streamText,
convertToModelMessages,
} from "ai";
export async function POST(req: Request) {
const { messages } = await req.json();
return createUIMessageStreamResponse({
stream: createUIMessageStream({
execute: async ({ writer }) => {
// Write sources first
writer.write({
type: "source-url",
sourceId: "src-1",
url: "https://example.com",
title: "Example Source",
});
// Stream LLM response
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
messages: await convertToModelMessages(messages),
});
writer.merge(result.toUIMessageStream());
},
}),
});
}Client-Side with Initial Messages
// app/chat/[id]/page.tsx
export default async function ChatPage({ params }: { params: { id: string } }) {
const initialMessages = await loadChat(params.id);
return <Chat id={params.id} initialMessages={initialMessages} />;
}
// components/chat.tsx
function Chat({ id, initialMessages }: { id: string; initialMessages: UIMessage[] }) {
const [input, setInput] = useState("");
const { messages, sendMessage } = useChat({
id,
messages: initialMessages,
transport: new DefaultChatTransport({ api: "/api/chat" }),
});
// ...
}Sending Only Last Message
// Client
transport: new DefaultChatTransport({
api: "/api/chat",
prepareSendMessagesRequest({ messages, id }) {
return { body: { message: messages[messages.length - 1], id } };
},
}),
// Server
export async function POST(req: Request) {
const { message, id } = await req.json();
const previousMessages = await loadChat(id);
const messages = [...previousMessages, message];
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
messages: await convertToModelMessages(messages),
});
return result.toUIMessageStreamResponse({
originalMessages: messages,
onFinish: ({ messages }) => saveChat({ chatId: id, messages }),
});
}Validating Messages
import { validateUIMessages, TypeValidationError } from "ai";
export async function POST(req: Request) {
const { message, id } = await req.json();
const previousMessages = await loadChat(id);
try {
const validatedMessages = await validateUIMessages({
messages: [...previousMessages, message],
tools, // if using tools
metadataSchema, // if using custom metadata
});
const result = streamText({
model: anthropic("claude-sonnet-4-6"),
messages: await convertToModelMessages(validatedMessages),
});
return result.toUIMessageStreamResponse({ originalMessages: validatedMessages });
} catch (error) {
if (error instanceof TypeValidationError) {
console.error("Validation failed:", error);
// Handle invalid messages
}
throw error;
}
}Message Metadata
Server-Side
return result.toUIMessageStreamResponse({
messageMetadata: ({ part }) => {
if (part.type === "start") {
return { createdAt: Date.now(), model: "gpt-5-mini" };
}
if (part.type === "finish") {
return { totalTokens: part.totalUsage.totalTokens };
}
},
});Client-Side
{messages.map((message) => (
<div key={message.id}>
{message.metadata?.createdAt && (
<span>{new Date(message.metadata.createdAt).toLocaleTimeString()}</span>
)}
{message.metadata?.totalTokens && (
<span>{message.metadata.totalTokens} tokens</span>
)}
</div>
))}Streaming Options
Enable Reasoning
return result.toUIMessageStreamResponse({
sendReasoning: true,
});Enable Sources
return result.toUIMessageStreamResponse({
sendSources: true,
});Error Handling
return result.toUIMessageStreamResponse({
onError: (error) => {
if (error instanceof Error) return error.message;
return "An error occurred";
},
});Type Inference for Tools
import { InferUITools, UIMessage, UIDataTypes, ToolSet } from "ai";
const tools = {
weather: tool({
description: "Get weather",
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({ temp: 22, conditions: "sunny" }),
}),
} satisfies ToolSet;
type MyUITools = InferUITools<typeof tools>;
type MyUIMessage = UIMessage<never, UIDataTypes, MyUITools>;
// Use in hook
const { messages } = useChat<MyUIMessage>({
transport: new DefaultChatTransport({ api: "/api/chat" }),
});Workflow Patterns
Structured patterns for building reliable AI workflows.
Pattern Overview
| Pattern | Use Case |
|---|---|
| Sequential (Chains) | Steps in predefined order |
| Parallel | Independent tasks simultaneously |
| Routing | Context-based path selection |
| Orchestrator-Worker | Coordinated specialized workers |
| Evaluator-Optimizer | Quality control with iteration |
Sequential Processing
import { generateText, Output } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
async function generateMarketingCopy(input: string) {
const model = anthropic("claude-sonnet-4-6");
// Step 1: Generate copy
const { text: copy } = await generateText({
model,
prompt: `Write persuasive marketing copy for: ${input}`,
});
// Step 2: Quality check
const { output: quality } = await generateText({
model,
output: Output.object({
schema: z.object({
hasCallToAction: z.boolean(),
emotionalAppeal: z.number().min(1).max(10),
clarity: z.number().min(1).max(10),
}),
}),
prompt: `Evaluate this marketing copy: ${copy}`,
});
// Step 3: Improve if needed
if (!quality.hasCallToAction || quality.emotionalAppeal < 7) {
const { text: improved } = await generateText({
model,
prompt: `Improve this copy with better CTA and emotion: ${copy}`,
});
return { copy: improved, quality };
}
return { copy, quality };
}Routing
import { generateText, Output } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";
async function handleCustomerQuery(query: string) {
const model = anthropic("claude-sonnet-4-6");
// Classify the query
const { output: classification } = await generateText({
model,
output: Output.object({
schema: z.object({
type: z.enum(["general", "refund", "technical"]),
complexity: z.enum(["simple", "complex"]),
}),
}),
prompt: `Classify this query: ${query}`,
});
// Route based on classification
const { text: response } = await generateText({
model:
classification.complexity === "simple"
? openai("gpt-5.4-mini")
: openai("gpt-5.4"),
system: {
general: "You handle general inquiries.",
refund: "You specialize in refund requests.",
technical: "You are a technical support specialist.",
}[classification.type],
prompt: query,
});
return { response, classification };
}Parallel Processing
import { generateText, Output } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
async function parallelCodeReview(code: string) {
const model = anthropic("claude-sonnet-4-6");
// Run reviews in parallel
const [security, performance, maintainability] = await Promise.all([
generateText({
model,
system: "You are a security expert.",
output: Output.object({
schema: z.object({
vulnerabilities: z.array(z.string()),
riskLevel: z.enum(["low", "medium", "high"]),
}),
}),
prompt: `Review this code for security: ${code}`,
}),
generateText({
model,
system: "You are a performance expert.",
output: Output.object({
schema: z.object({
issues: z.array(z.string()),
optimizations: z.array(z.string()),
}),
}),
prompt: `Review this code for performance: ${code}`,
}),
generateText({
model,
system: "You are a code quality expert.",
output: Output.object({
schema: z.object({
concerns: z.array(z.string()),
qualityScore: z.number().min(1).max(10),
}),
}),
prompt: `Review this code for quality: ${code}`,
}),
]);
// Aggregate results
const { text: summary } = await generateText({
model,
system: "You are a tech lead summarizing reviews.",
prompt: `Synthesize these reviews: ${JSON.stringify({
security: security.output,
performance: performance.output,
maintainability: maintainability.output,
})}`,
});
return { reviews: { security, performance, maintainability }, summary };
}Orchestrator-Worker
import { generateText, Output } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
async function implementFeature(request: string) {
// Orchestrator: Plan the implementation
const { output: plan } = await generateText({
model: anthropic("claude-sonnet-4-6"),
output: Output.object({
schema: z.object({
files: z.array(
z.object({
purpose: z.string(),
filePath: z.string(),
changeType: z.enum(["create", "modify", "delete"]),
}),
),
}),
}),
system: "You are a software architect.",
prompt: `Plan implementation for: ${request}`,
});
// Workers: Execute changes in parallel
const changes = await Promise.all(
plan.files.map(async (file) => {
const { output: change } = await generateText({
model: anthropic("claude-sonnet-4-6"),
output: Output.object({
schema: z.object({
explanation: z.string(),
code: z.string(),
}),
}),
system: {
create: "You implement new files.",
modify: "You modify existing code safely.",
delete: "You safely remove code.",
}[file.changeType],
prompt: `Implement ${file.filePath}: ${file.purpose}`,
});
return { file, implementation: change };
}),
);
return { plan, changes };
}Evaluator-Optimizer
import { generateText, Output } from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { z } from "zod";
async function translateWithFeedback(text: string, targetLanguage: string) {
const model = anthropic("claude-sonnet-4-6");
let currentTranslation = "";
let iterations = 0;
const MAX_ITERATIONS = 3;
// Initial translation
const { text: translation } = await generateText({
model,
system: "You are an expert translator.",
prompt: `Translate to ${targetLanguage}: ${text}`,
});
currentTranslation = translation;
// Evaluation-optimization loop
while (iterations < MAX_ITERATIONS) {
// Evaluate
const { output: evaluation } = await generateText({
model,
output: Output.object({
schema: z.object({
qualityScore: z.number().min(1).max(10),
preservesTone: z.boolean(),
issues: z.array(z.string()),
suggestions: z.array(z.string()),
}),
}),
system: "You evaluate translations.",
prompt: `Evaluate: Original: ${text} Translation: ${currentTranslation}`,
});
// Check quality
if (evaluation.qualityScore >= 8 && evaluation.preservesTone) {
break;
}
// Improve based on feedback
const { text: improved } = await generateText({
model,
system: "You are an expert translator.",
prompt: `Improve based on: ${evaluation.suggestions.join(", ")}
Original: ${text}
Current: ${currentTranslation}`,
});
currentTranslation = improved;
iterations++;
}
return { translation: currentTranslation, iterations };
}Choosing Your Approach
| Factor | Consideration |
|---|---|
| Flexibility | Agents for dynamic decisions |
| Control | Workflows for deterministic outcomes |
| Error Tolerance | More checks = more reliability |
| Cost | Complex systems = more LLM calls |
| Maintenance | Simpler = easier to debug |
Start simple, add complexity only when needed.