Skip to content
Release notesData & APIs

AI SDK 5 with Vue: the Chat class, streamText and typed messages

AI SDK 5.0 and @ai-sdk/vue 2.0 were published on 31 July. Vue is now on equal footing with React, messages are typed lists of parts, and the composable you knew as useChat is a class called Chat.

vuejs.school team7 Aug 20256 min readai 5.0 · @ai-sdk/vue 2.0 · Nuxt 4.0

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. UIMessage is what your app stores and renders; ModelMessage is what goes to the model. You convert one to the other explicitly.
  • Messages are lists of parts: text, reasoning, files, typed tool parts named tool-<name>, and typed data-<name> parts you stream yourself. The old content string is gone.
  • A transport layer. useChat no longer takes an api option; it takes a transport, with DefaultChatTransport for 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: stopWhen replaces maxSteps, and prepareStep lets you change the model, tools or messages between steps.
  • Tool definitions rename parameters to inputSchema, tool calls carry input instead of args and output instead of result, 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:

  • UIMessage has an id, a role, optional typed metadata and an ordered parts array. It is the source of truth for what the user saw, and the announcement recommends persisting exactly that.
  • ModelMessage is 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:

nuxt.config.ts
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:

shared/utils/chatTools.ts
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:

server/api/chat.post.ts
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:

app/pages/chat.vue
<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.parts in order. Parts it does not name, such as the step-start marker that opens each step, render nothing.
  • A text part renders its text.
  • A tool-weather part whose state is output-available renders part.output. Because the message type came from InferUITools, part.output.city is a string and part.output.celsius a number as far as vue-tsc is concerned; misspell celsius and the type check fails.
  • An output-error part shows the tool's errorText. 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-streaming or input-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.content switch to the parts loop above.
  • Tool renderers that checked toolInvocation.state === 'result' now check part.state === 'output-available' on a tool-<name> part, and read part.input and part.output.
  • In the route, maxSteps: 5 becomes stopWhen: stepCountIs(5), and toDataStreamResponse() becomes toUIMessageStreamResponse().
  • In Vue, the useChat call becomes new Chat(), the input it used to own becomes your own ref, append becomes sendMessage, reload becomes regenerate, and isLoading becomes status.
  • If you persisted messages, persist UIMessage objects from now on and pass originalMessages to toUIMessageStreamResponse(). 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.

Check it stuck3 questions from the Streaming AI responses quiz.
Practice now
Was this helpful?

Keep reading