Install @tanstack/ai-vue. Import the UI factory from @tanstack/ai-vue/ui. Call createChatHook({ options, ...components }) once. Your app calls useAppChat() to create the instance. Pass the descriptor as ui into UIChat, UIProvider, and the other static primitives.
Deprecated. Do not install @tanstack/ai-vue-ui. That package re-exports this subpath until 1.0.0. See Chat UI packages.
The factory needs a toolsComponents entry for every tool name in chatOptions. It also needs an interruptsComponents.generic entry for every interrupt id. generic.fallback is optional. Widgets go in components, partsComponents, toolsComponents, and interruptsComponents, the same way Form and Table register components.
The server route matches the React page. Use gpt-5.6 on the OpenAI text adapter.
The chat UI recipes show the same option groups one at a time. The code there is React, and the shape carries over.
import { defineComponent, h } from 'vue'
import { fetchServerSentEvents } from '@tanstack/ai-vue'
import { createChatHook, UIChat } from '@tanstack/ai-vue/ui'
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
const getWeather = toolDefinition({
name: 'getWeather',
description: 'Look up weather',
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ temperature: z.number() }),
}).client()
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
tools: [getWeather],
}
const { useAppChat, ui } = createChatHook({
options: chatOptions,
components: {
layout: defineComponent((_, { slots }) => () =>
h('div', [slots.messages?.(), slots.interrupts?.(), slots.queue?.(), slots.input?.()]),
),
message: defineComponent((_, { slots }) => () => h('article', slots.parts?.())),
},
partsComponents: {
fallback: defineComponent({
props: ['part'],
setup(props) {
return () => h('span', props.part.type)
},
}),
},
toolsComponents: {
getWeather: defineComponent({
props: ['part'],
setup(props) {
return () => h('strong', props.part.input?.city)
},
}),
},
})
export default defineComponent({
setup() {
const chat = useAppChat({ threadId: 'support-1' })
return () => h(UIChat, { ui, chat })
},
})Layout uses slots messages, interrupts, queue, and input. Message uses slot parts. Manual lists use the default slot on UIMessages with { messages }.
Use ToolProps on the component props. Share the same chatOptions object that you pass to createChatHook.
import { defineComponent, h } from 'vue'
import { fetchServerSentEvents } from '@tanstack/ai-vue'
import { createChatHook, type ToolProps } from '@tanstack/ai-vue/ui'
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
const getWeather = toolDefinition({
name: 'getWeather',
description: 'Look up weather',
inputSchema: z.object({ city: z.string() }),
outputSchema: z.object({ temperature: z.number() }),
}).client()
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
tools: [getWeather],
}
export const WeatherTool = defineComponent(
(props: ToolProps<typeof chatOptions, 'getWeather'>) => {
return () => h('strong', props.part.input?.city)
},
)
export const { useAppChat, ui } = createChatHook({
options: chatOptions,
components: {
layout: defineComponent((_, { slots }) => () =>
h('div', [slots.messages?.(), slots.interrupts?.(), slots.input?.()]),
),
message: defineComponent((_, { slots }) => () => h('article', slots.parts?.())),
},
partsComponents: { fallback: defineComponent(() => () => null) },
toolsComponents: { getWeather: WeatherTool },
})Part components use PartProps<typeof chatOptions, 'text'>. Then part is already a text part.
Interrupt components use InterruptProps<typeof chatOptions, 'choosePlan'>. Then interrupt.payload matches the definition.
Mapped components do not receive chat as a prop. Call ui.useChatContext() when a component needs live chat. That call opts the component into chat updates. Nested children can call it too.
Call ui.useChatContext() inside a child of UIChat or UIProvider.
import { defineComponent, h } from 'vue'
import type { Component } from 'vue'
import { fetchServerSentEvents } from '@tanstack/ai-vue'
import { createChatHook, UIChat } from '@tanstack/ai-vue/ui'
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
}
const { useAppChat, ui } = createChatHook({
options: chatOptions,
components: {
layout: defineComponent((_, { slots }) => () =>
h('main', [h(StatusLine), slots.messages?.()]),
),
message: defineComponent((_, { slots }) => () => h('article', slots.parts?.())),
},
partsComponents: { fallback: defineComponent(() => () => null) },
})
// Declared after `ui`, and annotated, so that reading `ui.useChatContext()`
// here does not form an inference cycle with the components map that renders
// it. `h(StatusLine)` above only runs at render time, so the order is fine.
const StatusLine: Component = defineComponent({
setup() {
const chat = ui.useChatContext()
return () => {
const messages = Array.isArray(chat.messages) ? chat.messages : []
return h('p', String(messages.length) + ' messages')
}
},
})
export default defineComponent({
setup() {
const chat = useAppChat()
return () => h(UIChat, { ui, chat })
},
})useChat(chatOptions) from @tanstack/ai-vue owns the state. ui.useChatContext() reads the instance you passed into UIChat.
Tool approvals sit in the tool when you read the interrupt prop. Put a component on interruptsComponents.tools to send that approval to the list instead. Generic interrupts always sit in the list under interruptsComponents.generic: { choosePlan, fallback }. An unbound interrupt uses fallback. Branch on interrupt.kind === 'unbound' if the copy must differ.
The full map is on the React page.