Skip to content
Dashboard

Introducing type-safe chat and agentic loop control for full-stack AI applications

When customers ask how they should build their agents, I always say the AI SDK. The industry is moving really fast and everything is changing constantly. The AI SDK is the only perfect abstraction I've seen so far. v5 continues that track record. You can tell it was built by people that are obsessed with Typescript. Everything feels right.
Ben Hylak raindrop.ai

Link to headingRedesigned Chat

Link to headingSeparate UI and Model Messages

// Explicitly convert your UIMessages to ModelMessages
const uiMessages: UIMessage[] = [ /* ... */ ]
const modelMessages = convertToModelMessages(uiMessages);
const result = await streamText({
model: openai('gpt-4o'),
// Convert the rich UIMessage format to ModelMessage format
// This can be replaced with any function that returns ModelMessage[]
messages: modelMessages,
});
// When finished: Get the complete UIMessage array for persistence
return result.toUIMessageStreamResponse({
originalMessages: uiMessages,
onFinish: ({ messages, responseMessage }) => {
// Save the complete UIMessage array - your full source of truth
saveChat({ chatId, messages });
// Or save just the response message
saveMessage({ chatId, message: responseMessage })
},
});

Link to headingCustomizable UI Messages

// Define your custom message type once
import { UIMessage } from 'ai';
// ... import your tool and data part types
export type MyUIMessage = UIMessage<MyMetadata, MyDataParts, MyTools>;
// Use it on the client
const { messages } = useChat<MyUIMessage>();
// And use it on the server
const stream = createUIMessageStream<MyUIMessage>(/* ... */);

Link to headingData Parts

// On the server, create a UIMessage stream
// Typing the stream with your custom message type
const stream = createUIMessageStream<MyUIMessage>({
async execute({ writer }) {
// manually write start step if no LLM call
const dataPartId = 'weather-1';
// 1. Send the initial loading state
writer.write({
type: 'data-weather', // type-checked against MyUIMessage
id: dataPartId,
data: { city: 'San Francisco', status: 'loading' },
});
// 2. Later, update the same part (same id) with the final result
writer.write({
type: 'data-weather',
id: dataPartId,
data: { city: 'San Francisco', weather: 'sunny', status: 'success' },
});
},
});

// On the client, data parts are fully typed
const { messages } = useChat<MyUIMessage>();
{
messages.map(message =>
message.parts.map((part, index) => {
switch (part.type) {
case 'data-weather':
return (
<div key={index}>
{/* TS knows part.data has city, status, and optional weather */}
{part.data.status === 'loading'
? `Getting weather for ${part.data.city}...`
: `Weather in ${part.data.city}: ${part.data.weather}`}
</div>
);
}
}),
);
}

// server
writer.write({
type: 'data-notification',
data: { message: 'Processing...', level: 'info' },
transient: true, // Won't be added to message history
});
// client
const [notification, setNotification] = useState();
const { messages } = useChat({
onData: ({ data, type }) => {
if (type === 'data-notification') {
setNotification({ message: data.message, level: data.level });
}
},
});

Link to headingType-Safe Tool Invocations

// On the client, tool parts are fully typed with the new structure
const { messages } = useChat<MyUIMessage>();
{
messages.map(message => (
<>
{message.parts.map(part => {
switch (part.type) {
// Static tools with specific (`tool-${toolName}`) types
case 'tool-getWeather':
// New states for streaming and error handling
switch (part.state) {
case 'input-streaming':
// Automatically streamed partial inputs
return <div>Getting weather for {part.input.location}...</div>;
case 'input-available':
return <div>Getting weather for {part.input.location}...</div>;
case 'output-available':
return <div>The weather is: {part.output}</div>;
case 'output-error':
// Explicit error state with information
return <div>Error: {part.errorText}</div>;
}
}
})}
</>
));
}

const { messages } = useChat<MyUIMessage>();
{
messages.map(message => (
<>
{message.parts.map(part => {
switch (part.type) {
// Dynamic tools use generic `dynamic-tool` type
case 'dynamic-tool':
return (
<div key={index}>
<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>
);
}
})}
</>
));
}

Link to headingMessage Metadata

// on the server
const result = streamText({
/* ... */
});
return result.toUIMessageStreamResponse({
messageMetadata: ({ part }) => {
if (part.type === "start") {
return {
// This object is checked against your metadata type
model: "gpt-4o",
};
}
if (part.type === "finish") {
return {
model: part.response.modelId,
totalTokens: part.totalUsage.totalTokens,
};
}
},
});

// on the client
const { messages } = useChat<MyUIMessage>();
{
messages.map(message => (
<div key={message.id}>
{/* TS knows message.metadata may have model and totalTokens */}
{message.metadata?.model && (
<span>Model: {message.metadata.model}</span>
)}
{message.metadata?.totalTokens && (
<span>{message.metadata.totalTokens} tokens</span>
)}
</div>
));
}

Link to headingModular Architecture & Extensibility

Link to headingVue, Svelte, and Angular Support

Link to headingSSE Streaming

Link to headingAgentic Loop Control

Link to headingstopWhen

import { openai } from "@ai-sdk/openai";
import { generateText, stepCountIs, hasToolCall } from "ai";
const result = await generateText({
model: openai("gpt-4o"),
tools: {
/* your tools */
},
// Stop a tool-calling loop after 5 steps or;
// When weather tool is called
stopWhen: [stepCountIs(5), hasToolCall("weather")],
});

Link to headingprepareStep

const result = await streamText({
model: openai('gpt-4o'),
messages: convertToModelMessages(messages),
tools: {
/* Your tools */
},
prepareStep: async ({ stepNumber, messages }) => {
if (stepNumber === 0) {
return {
// Use a different model for the first step
model: openai('gpt-4o-mini'),
// Force a specific tool choice
toolChoice: { type: 'tool', toolName: 'analyzeIntent' },
};
}
// Compress context for longer conversations
if (messages.length > 10) {
return {
// use a model with a larger context window
model: openai('gpt-4.1'),
messages: messages.slice(-10),
};
}
},
});

Link to headingAgent Abstraction

import { openai } from "@ai-sdk/openai";
import { Experimental_Agent as Agent, stepCountIs } from "ai";
const codingAgent = new Agent({
model: openai("gpt-4o"),
system: "You are a coding agent. You specialise in Next.js and TypeScript.",
stopWhen: stepCountIs(10),
tools: {
/* Your tools */
},
});
// Calls `generateText`
const result = codingAgent.generate({
prompt: "Build an AI coding agent.",
});
// Calls `streamText`
const result = codingAgent.stream({
prompt: "Build an AI coding agent.",
});

Link to headingExperimental Speech Generation & Transcription

import {
experimental_generateSpeech as generateSpeech,
experimental_transcribe as transcribe,
} from 'ai';
import { openai } from '@ai-sdk/openai';
// Text-to-Speech: Generate audio from text
const { audio } = await generateSpeech({
model: openai.speech('tts-1'),
text: 'Hello, world!',
voice: 'alloy',
});
// Speech-to-Text: Transcribe audio to text
const { text, segments } = await transcribe({
model: openai.transcription('whisper-1'),
audio: await readFile('audio.mp3'),
});

Link to headingTool Improvements

Link to headingParameter & Result Renaming

// Before (v4)
const weatherTool = tool({
name: 'getWeather',
parameters: z.object({ location: z.string() }),
execute: async ({ location }) => {
return `Weather in ${location}: sunny, 72°F`;
}
});
// After (v5)
const weatherTool = tool({
description: 'Get the weather for a location',
inputSchema: z.object({ location: z.string() }),
outputSchema: z.string(), // New in v5 (optional)
execute: async ({ location }) => {
return `Weather in ${location}: sunny, 72°F`;
}
});

Link to headingDynamic Tools

import { dynamicTool } from 'ai';
import { z } from 'zod';
const customDynamicTool = dynamicTool({
description: 'Execute a custom user-defined function',
inputSchema: z.object({}),
// input is typed as 'unknown'
execute: async input => {
const { action, parameters } = input as any;
// Execute your dynamic logic
return {
result: `Executed ${action} with ${JSON.stringify(parameters)}`,
};
},
});
const weatherTool = tool({ /* ... */ })
const result = await generateText({
model: 'openai/gpt-4o',
tools: {
// Static tool with known types
weatherTool,
// Dynamic tool
customDynamicTool,
},
onStepFinish: ({ toolCalls, toolResults }) => {
// Type-safe iteration
for (const toolCall of toolCalls) {
if (toolCall.dynamic) {
// Dynamic tool: input is 'unknown'
console.log('Dynamic:', toolCall.toolName, toolCall.input);
continue;
}
// Static tool: full type inference
switch (toolCall.toolName) {
case 'weather':
console.log(toolCall.input.location); // typed as string
break;
}
}
},
});

Link to headingProvider-Executed Tools

import { openai } from '@ai-sdk/openai';
import { generateText } from 'ai';
const result = await generateText({
model: openai.responses('gpt-4o-mini'),
tools: {
web_search_preview: openai.tools.webSearchPreview({}),
},
// ...
});

Link to headingTool Lifecycle Hooks

const weatherTool = tool({
description: 'Get the weather for a given city',
inputSchema: z.object({ city: z.string() }),
onInputStart: ({ toolCallId }) => {
console.log('Tool input streaming started:', toolCallId);
},
onInputDelta: ({ inputTextDelta, toolCallId }) => {
console.log('Tool input delta:', inputTextDelta);
},
onInputAvailable: ({ input, toolCallId }) => {
console.log('Tool input ready:', input);
},
execute: async ({ city }) => {
return `Weather in ${city}: sunny, 72°F`;
},
});

Link to headingTool Provider Options

const result = await generateText({
model: anthropic('claude-3-5-sonnet-20240620'),
tools: {
cityAttractions: tool({
inputSchema: z.object({ city: z.string() }),
// Apply provider-specific options to individual tools
providerOptions: {
anthropic: {
cacheControl: { type: 'ephemeral' },
},
},
execute: async ({ city }) => {
// Implementation
},
}),
},
});

Link to headingV2 Specifications

Link to headingGlobal Provider

import { streamText } from 'ai';
const result = await streamText({
model: 'openai/gpt-4o', // Uses the global provider (defaults to AI Gateway)
prompt: 'Invent a new holiday and describe its traditions.',
});

Link to headingCustomizing the Global Provider

import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';
// Initialise once during startup:
globalThis.AI_SDK_DEFAULT_PROVIDER = openai;
// Somewhere else in your codebase:
const result = streamText({
model: 'gpt-4o', // Uses OpenAI provider without prefix
prompt: 'Invent a new holiday and describe its traditions.',
});

Link to headingAccess Raw Responses

Link to headingRaw Streaming Chunks

import { openai } from "@ai-sdk/openai";
import { streamText } from "ai";
const result = streamText({
model: openai("gpt-4o-mini"),
prompt: "Invent a new holiday and describe its traditions.",
includeRawChunks: true,
});
// Access raw chunks through fullStream
for await (const part of result.fullStream) {
if (part.type === "raw") {
// Access provider-specific data structures
// e.g., OpenAI's choices, usage, etc.
console.log("Raw chunk:", part.rawValue);
}
}

Link to headingRequest and Response Bodies

const result = await generateText({
model: openai("gpt-4o"),
prompt: "Write a haiku about debugging",
});
// Access the raw request body sent to the provider
// See exact prompt formatting, parameters, etc.
console.log("Request:", result.request.body);
// Access the raw response body from the provider
// Full provider response including metadata
console.log("Response:", result.response.body);

Link to headingZod 4 Support

Link to headingMigrating to AI SDK 5

npx @ai-sdk/codemod upgrade

Link to headingGetting started

Link to headingContributors