createChatHook from @tanstack/ai-vue/ui binds your chat options and components once. Tool calls and approvals stay typed.
Each screen then creates a chat with useAppChat() and renders it with UIChat. You supply every visible component. There is no default markup or style.
Note
Upgrading from @tanstack/ai-vue-ui? Read Upgrade to 1.0.
npm i @tanstack/ai @tanstack/ai-vue @tanstack/ai-openaipnpm add @tanstack/ai @tanstack/ai-vue @tanstack/ai-openaiyarn add @tanstack/ai @tanstack/ai-vue @tanstack/ai-openaibun add @tanstack/ai @tanstack/ai-vue @tanstack/ai-openaiimport {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request)
const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
runId,
})
return toServerSentEventsResponse(stream)
}import { defineComponent, h } from 'vue'
import { fetchServerSentEvents } from '@tanstack/ai-vue'
import { createChatHook } from '@tanstack/ai-vue/ui'
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
}
export const { useAppChat, ui } = createChatHook({
options: chatOptions,
components: {
layout: defineComponent((_, { slots }) => () =>
h('section', slots.messages?.()),
),
message: defineComponent({
props: ['message'],
setup(props, { slots }) {
return () =>
h('article', { 'data-role': props.message.role }, slots.parts?.())
},
}),
},
partsComponents: {
text: defineComponent({
props: ['part'],
setup(props) {
return () => h('p', props.part.content)
},
}),
fallback: defineComponent(() => () => null),
},
})<script setup lang="ts">
import { ref } from 'vue'
import { UIChat } from '@tanstack/ai-vue/ui'
import { ui, useAppChat } from './chat-ui'
const chat = useAppChat({ threadId: 'support-chat' })
const draft = ref('')
function send() {
const text = draft.value.trim()
if (!text) return
draft.value = ''
void chat.sendMessage(text)
}
</script>
<template>
<main>
<UIChat :ui="ui" :chat="chat" />
<form @submit.prevent="send">
<input v-model="draft" />
<button type="submit">Send</button>
</form>
</main>
</template>The reply streams into the page as the model writes it.
Each step adds one thing to the quick setup. The chat UI recipes have the full file for each one. The recipes use React, and the option names are the same in Vue.
Add the tool to tools in chatOptions. Then register a component under the tool name in toolsComponents. A tool component gets the part, result, and interrupt props.
toolsComponents: {
getWeather: defineComponent({
props: ['part'],
setup(props) {
return () =>
props.part.state === 'complete'
? h('p', `${props.part.input?.city}: ${props.part.output?.temperature}`)
: h('p', 'Looking up weather')
},
}),
},The factory needs one entry for every tool in chatOptions. Full example: Make a tool look right.
Set needsApproval: true on the tool. The tool component then gets an interrupt prop:
toolsComponents: {
deleteFile: defineComponent({
props: ['part', 'interrupt'],
setup(props) {
return () =>
props.interrupt?.status === 'pending'
? h(
'button',
{ onClick: () => props.interrupt.resolveInterrupt(true) },
`Delete ${props.part.input?.path}`,
)
: h('p', props.part.input?.path)
},
}),
},resolveInterrupt(true) approves. resolveInterrupt(false) denies. Full example: Ask before a tool runs.
Define the question with defineInterrupt. Add it to interrupts in chatOptions. Register a component under its id in interruptsComponents.generic, and render the interrupts slot in the layout:
const choosePlan = defineInterrupt({
id: 'choosePlan',
payloadSchema: z.object({ title: z.string() }),
responseSchema: z.string(),
})
// In createChatHook:
interruptsComponents: {
generic: {
choosePlan: defineComponent({
props: ['interrupt'],
setup(props) {
return () =>
h(
'button',
{ onClick: () => props.interrupt.resolveInterrupt('pro') },
props.interrupt.payload?.title,
)
},
}),
},
},The factory needs one entry for every interrupt id in chatOptions. Full example: Ask the user your own question.
Mapped components do not get chat as a prop. Read the chat from context inside the widget. This also moves the composer into the layout: register it as input, then render the input slot.
A widget that the factory registers cannot read ui from the same call. Create the context first with createChatHookContexts(), and pass its chatKey under context:
import { defineComponent, h, ref } from 'vue'
import { fetchServerSentEvents } from '@tanstack/ai-vue'
import {
createChatHook,
createChatHookContexts,
} from '@tanstack/ai-vue/ui'
const { chatKey, useChatContext } = createChatHookContexts()
const ChatInput = defineComponent(() => {
const chat = useChatContext()
const draft = ref('')
return () =>
h(
'form',
{
onSubmit: (event: Event) => {
event.preventDefault()
const text = draft.value.trim()
if (!text) return
draft.value = ''
void chat.sendMessage(text)
},
},
[
h('input', {
value: draft.value,
disabled: chat.isLoading.value,
onInput: (event: Event) => {
if (event.target instanceof HTMLInputElement) {
draft.value = event.target.value
}
},
}),
h('button', { type: 'submit' }, 'Send'),
],
)
})
const chatOptions = {
connection: fetchServerSentEvents('/api/chat'),
}
export const { useAppChat, ui } = createChatHook({
options: chatOptions,
context: { chatKey },
components: {
input: ChatInput,
layout: defineComponent((_, { slots }) => () =>
h('main', [slots.messages?.(), slots.input?.()]),
),
message: defineComponent((_, { slots }) => () =>
h('article', slots.parts?.()),
),
},
partsComponents: { fallback: defineComponent(() => () => null) },
})In Vue, chat.messages, chat.isLoading, and chat.error are refs. Read them with .value in script. Call useChatContext() only inside UIChat or UIProvider. A call outside that tree throws.
Model replies arrive as markdown. Register TextPart from @tanstack/ai-vue/ui as the text part:
import { TextPart } from '@tanstack/ai-vue/ui'
partsComponents: {
text: defineComponent({
props: ['part'],
setup(props) {
return () => h(TextPart, { content: props.part.content })
},
}),
},TextPart uses TanStack Markdown. It escapes raw HTML and removes executable URLs. To color fenced code, read Highlight markdown code.
Move a widget to its own file and type its props with ToolProps, PartProps, or InterruptProps. Pass typeof chatOptions and a name:
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 },
})Put chatOptions in a shared module so every widget file can import its type.
You now have a typed chat UI in Vue. Each widget is your own component.
The config keys, part keys, and prop types are the same as in React. See Every widget slot. In Vue, components place content with slots:
useAppChat() takes per-instance options: threadId (required), initialMessages, live, and forwardedProps.
A child component in its own file can import ui and call ui.useChatContext(). Inside the factory module, declare the child after the createChatHook call. Annotate it as Component, so its type does not depend on the components map that renders it:
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) },
})
// h(StatusLine) runs only at render time, so this order is safe.
const StatusLine: Component = defineComponent({
setup() {
const chat = ui.useChatContext()
return () => h('p', `${chat.messages.value.length} messages`)
},
})
export default defineComponent({
setup() {
const chat = useAppChat({ threadId: 'support-chat' })
return () => h(UIChat, { ui, chat })
},
})UIMessages has a default slot with { messages }. UIMessage has a default slot with { parts }. Pass ui to each one.
If you still pass remarkPlugins, rehypePlugins, or disableDefaultPlugins, read TextPart markdown.