Vercel AI SDK 5 Guide: Type-Safe Chat, Tools & Agents

Photo by Markus Spiske on Pexels
The Vercel AI SDK 5 is a TypeScript toolkit for building AI applications, released on July 31, 2025. It provides provider-agnostic functions like streamText and generateText, type-safe chat with UIMessage and ModelMessage, agentic loop control, and framework UI hooks such as useChat for React, Vue, Svelte, and Angular.
UIMessage is the source of truth for your application state — it holds the full parts array, metadata, and tool inputs and outputs, and it is what you persist. ModelMessage is the trimmed, token-optimized form the model consumes. You convert one to the other with convertToModelMessages right before the model call.
AI SDK 5 replaces the old client-side maxSteps with server-side controls. stopWhen decides when to stop the loop — for example stepCountIs for a hard cap or hasToolCall to end when a tool fires. prepareStep runs before each step so you can change the model, messages, system prompt, or force a specific tool for that turn.
useChat no longer manages the input field for you, so you own the input state. The append function is replaced by sendMessage, and the backend is wired through a transport such as DefaultChatTransport, which you can swap for WebSockets or a direct-to-provider transport. Messages return as UIMessage objects rendered by mapping over their parts.
Yes. Every provider — OpenAI, Anthropic, Google, and many others — implements the same V2 language-model specification, so switching is usually a one-line change to the model argument. Behavior is not guaranteed identical across providers, however, so you should re-run evaluations and check tool-calling reliability after a swap.

Photo by Markus Spiske on Pexels
Key Takeaway
Vercel AI SDK 5 is a TypeScript toolkit for building AI apps, released July 2025. It splits chat state into UIMessage for the UI and ModelMessage for the model, adds agentic loop control with stopWhen and prepareStep, renames tool parameters to inputSchema, streams over Server-Sent Events, and stays provider-agnostic across OpenAI, Anthropic, and Google.
I have shipped enough LLM features to know where the pain lives: it is never the first prompt, it is everything after. Persisting a conversation, replaying it, adding a tool call, swapping models when one provider rate-limits you, and keeping the client and server in agreement about the shape of a message. Vercel AI SDK 5, released on July 31, 2025, is the first version where those problems felt designed-for rather than worked-around.
This is not a changelog. It is the mental model I wish I had before migrating: what the new message types are for, how agentic loops actually terminate, why tool definitions moved, and what the redesigned useChat expects you to own. Every claim here maps to the official announcement and migration guide linked at the end.
The two functions you reach for most have not moved: streamText for token-by-token responses and generateText for a single result. What changed around them is the vocabulary. Tools now declare an inputSchema instead of parameters, you cap the agent loop with stopWhen instead of maxSteps, and you feed the model with convertToModelMessages. Here is a complete route handler that streams a reply and can call a weather tool.
import { streamText, tool, convertToModelMessages, stepCountIs } from "ai"
import { openai } from "@ai-sdk/openai"
import { z } from "zod"
export async function POST(req: Request) {
const { messages } = await req.json()
const result = streamText({
model: openai("gpt-4o"),
messages: convertToModelMessages(messages),
stopWhen: stepCountIs(5),
tools: {
getWeather: tool({
description: "Get the current weather for a city",
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => {
const res = await fetch("https://api.example.com/weather?city=" + city)
return res.json()
},
}),
},
})
return result.toUIMessageStreamResponse()
}This is the change that reorganises everything else. AI SDK 5 splits a message into two types. UIMessage is the source of truth for your application state: it carries the full ordered parts array, metadata, tool inputs and outputs, and any custom typed data you streamed to the browser. ModelMessage is the trimmed, token-optimised form the language model actually consumes. You persist UIMessage, and call convertToModelMessages right before the model call. The old flat content string is gone in favour of a typed parts array, so text, reasoning, tool calls, and files each become a discrete, inspectable part.
Persist UIMessage, never ModelMessage. UIMessage keeps every detail — tool results, metadata, reasoning parts — while ModelMessage deliberately discards what the model does not need. Store the model form and you permanently lose data you cannot rebuild later.
The React hook was rebuilt around a transport. It no longer manages the input field for you, and append is gone — you call sendMessage instead. You wire the backend through a DefaultChatTransport, which you can swap for a WebSocket or a direct-to-provider transport for client-only apps. Messages come back as UIMessage objects, so you render each message by mapping over its parts rather than reading a single string.
"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({ url: "/api/chat" }),
})
return (
<div>
{messages.map((m) => (
<div key={m.id}>
<strong>{m.role}</strong>
{m.parts.map((part, i) =>
part.type === "text" ? <span key={i}>{part.text}</span> : null
)}
</div>
))}
<form
onSubmit={(e) => {
e.preventDefault()
sendMessage({ text: input })
setInput("")
}}
>
<input value={input} onChange={(e) => setInput(e.target.value)} />
</form>
</div>
)
}An agent is just a model that keeps calling tools until it is done, and AI SDK 5 gives you two honest controls over that loop. stopWhen decides when to stop — pass stepCountIs for a hard cap, hasToolCall to end when a specific tool fires, or an array to combine them. prepareStep runs before each step so you can change the model, messages, system prompt, or force a tool for that one turn. Together they replace the blunt client-side maxSteps with real server-side control.
const result = streamText({
model: openai("gpt-4o"),
messages: convertToModelMessages(messages),
tools,
// Keep looping until the model calls finalAnswer OR we hit 8 steps
stopWhen: [stepCountIs(8), hasToolCall("finalAnswer")],
// Reshape each step: force a planning tool on the first turn
prepareStep: async ({ stepNumber }) => {
if (stepNumber === 0) {
return { toolChoice: { type: "tool", toolName: "planTask" } }
}
return {}
},
})Every provider — OpenAI, Anthropic, Google, and dozens more — implements the same V2 language-model specification, so switching is a one-line change to the model argument; the rest of your code is untouched. That is what makes the SDK genuinely portable: you can start on gpt-4o, move a cost-sensitive path to a cheaper model, and route a reasoning-heavy step to Claude without rewriting your tools, your streaming, or your UI.
A one-line model swap does not make behaviour identical. Providers differ in tool-calling reliability, JSON adherence, context limits, and how they format reasoning. Treat a provider change as a real change: re-run your evals and check tool-call rates before trusting it in production.
| Concept | AI SDK 4 | AI SDK 5 |
|---|---|---|
| Message shape | Single content string | Typed parts array on UIMessage |
| Model input | convertToCoreMessages | convertToModelMessages |
| useChat input | Managed for you | You own the input state |
| Send a message | append() | sendMessage() |
| Backend wiring | api option | transport (DefaultChatTransport) |
| Loop limit | maxSteps (client-side) | stopWhen (server-side) |
| Tool schema | parameters and result | inputSchema and output |
My advice after moving real code across: start with the message model, not the hook. Once you internalise that UIMessage is what you store and ModelMessage is what you send, the useChat and tool changes stop feeling arbitrary and start feeling like consequences. Run the official codemod to sweep the mechanical renames, then hand-fix the persistence layer, because that is the one place the codemod cannot reason about your data.