AI SDK is Vercel's TypeScript library for streaming a model's answer into a user interface. Version 5.0 of its ai package was published on 31 July with @ai-sdk/vue 2.0, and the announcement says chat was rebuilt from the ground up.
If you are starting a chat feature in Vue, start on version 5: tool results reach the template with their types, so vue-tsc catches a misspelt field. If you have a version 4 chat, both the page and the server route change. The example below is a Nuxt 4 chat page with one typed tool, and its API key never leaves the server.
What shipped
The announcement lists these as the headline changes:
- Two message types.
UIMessageis what your app stores and renders;ModelMessageis what goes to the model. You convert one to the other explicitly. - Messages are lists of
parts: text, reasoning, files, typed tool parts namedtool-<name>, and typeddata-<name>parts you stream yourself. The oldcontentstring is gone. - A transport layer.
useChatno longer takes anapioption; it takes atransport, withDefaultChatTransportfor the usual HTTP route and room for your own. - Vue and Svelte have complete feature parity with React, and Angular support is new.
- Agent loop control:
stopWhenreplacesmaxSteps, andprepareSteplets you change the model, tools or messages between steps. - Tool definitions rename
parameterstoinputSchema, tool calls carryinputinstead ofargsandoutputinstead ofresult, and tool input streaming is always on. - Server-Sent Events replace the custom stream protocol.
- Zod 3 and Zod 4 schemas are both accepted; the announcement recommends Zod 4 for new projects.
The migration guide lists the versions that belong together: ai 5.0.0, @ai-sdk/provider-utils 3.0.0, and 2.0.0 for the other @ai-sdk/* packages, UI and provider packages included. For Vue that means @ai-sdk/vue@2.
UI messages, model messages and parts
Version 4 already had a Message type for the screen and a CoreMessage type for the model, but streamText accepted either and converted UI messages quietly. Version 5 renames the two and makes the line between them explicit:
UIMessagehas anid, arole, optional typedmetadataand an orderedpartsarray. It is the source of truth for what the user saw, and the announcement recommends persisting exactly that.ModelMessageis the leaner shape a provider expects.
The server converts with one call, convertToModelMessages(messages), right before streamText. Skip it and streamText fails with an AI_InvalidPromptError: "Invalid prompt: The messages must be a ModelMessage. If you have passed a UIMessage, you can use convertToModelMessages to convert them." The route sends its answer back with toUIMessageStreamResponse(), which replaces toDataStreamResponse().
Parts are what make rendering honest. A single assistant turn can contain a text part, then a tool call, then more text, and the parts array keeps that order. Each tool part has a type of tool-weather for a tool named weather and a state that moves through input-streaming, input-available, output-available and, on failure, output-error. A template branches on type and state and never guesses.
In Vue, useChat becomes Chat
The @ai-sdk/vue 2.0 changelog is blunt: "replace useChat with new Chat". The package exports a Chat class and useCompletion; there is no useChat composable any more. You construct new Chat({ transport }) and read chat.messages, chat.status and chat.error in the template. To act on the chat, call chat.sendMessage(), chat.stop() or chat.regenerate(); to replace the history, assign a new array to chat.messages. The class keeps its state in Vue refs internally, so the template updates without any wrapping on your side.
Two habits from version 4 go. The input field is no longer managed for you: you own a ref and clear it after sending, so handleInputChange and handleSubmit go too. And isLoading is replaced by status, which is one of 'submitted', 'streaming', 'ready' or 'error'. That lets a Send button be disabled while a request is in flight and a Stop button enabled only while tokens arrive.
Build the server route and a shared tool
The example uses the Nuxt 4 layout: app/pages/ for the page, server/api/ for the route and shared/ for what both need. Install the three packages and a schema library:
npm install ai@5 @ai-sdk/vue@2 @ai-sdk/openai@2 zod@4
Put the key in runtime config, so it is read from NUXT_OPENAI_API_KEY on the server and never bundled for the browser:
export default defineNuxtConfig({
runtimeConfig: {
openaiApiKey: '',
},
})
The tool and the message type live in shared/, which Nuxt 4 compiles for both the app and the server. That is what lets the page and the route agree on one type:
import { tool, type InferUITools, type UIDataTypes, type UIMessage } from 'ai'
import { z } from 'zod'
export const chatTools = {
weather: tool({
description: 'Get the current temperature for a city',
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, celsius: 18 + Math.round(Math.random() * 10) }),
}),
}
export type ChatMessage = UIMessage<never, UIDataTypes, InferUITools<typeof chatTools>>
InferUITools reads the inputSchema and the return type of execute for each tool and produces the type of every tool-* part. UIMessage takes three type arguments: metadata, data parts and tools; never says this chat has no metadata. The route then does three things: read the UI messages, convert them, stream back:
import { createOpenAI } from '@ai-sdk/openai'
import { convertToModelMessages, stepCountIs, streamText } from 'ai'
import { chatTools, type ChatMessage } from '#shared/utils/chatTools'
export default defineLazyEventHandler(() => {
const apiKey = useRuntimeConfig().openaiApiKey
if (!apiKey) throw new Error('Set NUXT_OPENAI_API_KEY')
const openai = createOpenAI({ apiKey })
return defineEventHandler(async (event) => {
const { messages } = await readBody<{ messages: ChatMessage[] }>(event)
const result = streamText({
model: openai('gpt-4o'),
system: 'Answer briefly. Use the weather tool when asked about weather.',
messages: convertToModelMessages(messages),
tools: chatTools,
stopWhen: stepCountIs(3),
})
return result.toUIMessageStreamResponse()
})
})
defineLazyEventHandler runs the setup once, on the first request, so the provider is created a single time. The page's transport (below) posts the chat's id, its messages and a trigger; the route reads only messages.
stopWhen: stepCountIs(3) is the new multi-step control: without it the default is one step, so the model would call the tool and stop before it could use the answer. toUIMessageStreamResponse() streams Server-Sent Events that the client's transport understands.
Render message parts in the page
The page creates the chat and renders each message part by part:
<script setup lang="ts">
import { Chat } from '@ai-sdk/vue'
import { DefaultChatTransport } from 'ai'
import type { ChatMessage } from '#shared/utils/chatTools'
const chat = new Chat<ChatMessage>({
transport: new DefaultChatTransport({ api: '/api/chat' }),
})
const input = ref('')
const busy = computed(() => chat.status === 'submitted' || chat.status === 'streaming')
function send() {
const text = input.value.trim()
if (!text || busy.value) return
chat.sendMessage({ text })
input.value = ''
}
</script>
<template>
<section>
<ul>
<li v-for="message in chat.messages" :key="message.id">
<strong>{{ message.role === 'user' ? 'You' : 'Assistant' }}</strong>
<template v-for="(part, index) in message.parts" :key="index">
<p v-if="part.type === 'text'">{{ part.text }}</p>
<p v-else-if="part.type === 'tool-weather' && part.state === 'output-available'">
{{ part.output.city }}: {{ part.output.celsius }} °C
</p>
<p v-else-if="part.type === 'tool-weather' && part.state === 'output-error'">
The weather lookup failed: {{ part.errorText }}
</p>
<p v-else-if="part.type === 'tool-weather'">Checking the weather…</p>
</template>
</li>
</ul>
<p v-if="chat.error" role="alert">{{ chat.error.message }}</p>
<form @submit.prevent="send">
<label for="prompt">Message</label>
<input id="prompt" v-model="input" autocomplete="off">
<button type="submit" :disabled="busy">Send</button>
<button type="button" :disabled="chat.status !== 'streaming'" @click="chat.stop()">Stop</button>
</form>
</section>
</template>
Read the template from the inside out:
- The inner loop walks
message.partsin order. Parts it does not name, such as thestep-startmarker that opens each step, render nothing. - A
textpart renders itstext. - A
tool-weatherpart whosestateisoutput-availablerenderspart.output. Because the message type came fromInferUITools,part.output.cityis astringandpart.output.celsiusanumberas far asvue-tscis concerned; misspellcelsiusand the type check fails. - An
output-errorpart shows the tool'serrorText. Without that branch, a failed lookup would fall through to the waiting line below it and stay there. - Any other state of the same tool,
input-streamingorinput-available, shows a waiting line.
In the script, sendMessage({ text }) wraps the string in a user message with one text part. busy is true only while a request is in flight. Testing chat.status !== 'ready' instead looks the same but is not: after a failed request the status stays 'error', so Send would stay disabled until the page reloads. Sending again clears chat.error.
The key never reaches this file. runtimeConfig.openaiApiKey is server-only because it is not under public, the route runs in Nitro, and the browser only ever sees the stream.
Upgrade a version 4 chat
The migration guide ships a codemod that handles part of the upgrade:
npx @ai-sdk/codemod upgrade
Then check these by hand:
- Templates that read
message.contentswitch to thepartsloop above. - Tool renderers that checked
toolInvocation.state === 'result'now checkpart.state === 'output-available'on atool-<name>part, and readpart.inputandpart.output. - In the route,
maxSteps: 5becomesstopWhen: stepCountIs(5), andtoDataStreamResponse()becomestoUIMessageStreamResponse(). - In Vue, the
useChatcall becomesnew Chat(), theinputit used to own becomes your ownref,appendbecomessendMessage,reloadbecomesregenerate, andisLoadingbecomesstatus. - If you persisted messages, persist
UIMessageobjects from now on and passoriginalMessagestotoUIMessageStreamResponse(). With it, the response reuses the existing message IDs; without it, new IDs can make an assistant message appear twice.
Upgrade when you next touch the chat feature rather than as a separate chore; the parts model and the typed tools are worth the afternoon.