diff --git a/.changeset/typed-headless-chat-ui.md b/.changeset/typed-headless-chat-ui.md
new file mode 100644
index 0000000000..9a201bca4e
--- /dev/null
+++ b/.changeset/typed-headless-chat-ui.md
@@ -0,0 +1,9 @@
+---
+'@tanstack/ai-client': minor
+'@tanstack/ai-react-ui': minor
+'@tanstack/ai-solid-ui': minor
+'@tanstack/ai-vue-ui': minor
+'@tanstack/ai-svelte-ui': minor
+---
+
+Add typed headless `createUI()` adapters. Chat options control the types of message parts, tools, structured output, and interrupts. Old Chat orchestration stays importable and deprecated until 1.0.
diff --git a/docs/api/ai-client.md b/docs/api/ai-client.md
index 16456962b5..9edd42f279 100644
--- a/docs/api/ai-client.md
+++ b/docs/api/ai-client.md
@@ -15,6 +15,8 @@ keywords:
Framework-agnostic headless client for managing chat state and streaming.
+For typed headless chat UI types and selectors, import `@tanstack/ai-client/ui`. See [Custom Chat UI Adapters](../ui/custom-adapters).
+
## Installation
```bash
diff --git a/docs/api/ai-react.md b/docs/api/ai-react.md
index 7d86fa3839..241a8a251d 100644
--- a/docs/api/ai-react.md
+++ b/docs/api/ai-react.md
@@ -13,6 +13,8 @@ keywords:
---
React hooks for TanStack AI, providing convenient React bindings for the headless client.
+
+For a typed headless chat UI, see [React Chat UI](../ui/react) and [Migrate to createUI](../migration/create-ui).
For React Native, the documented support surface is narrow: `useChat` with chat
connection adapters. React DOM-specific UI packages and TanStack AI devtools UI
are not part of the React Native support surface.
diff --git a/docs/api/ai-solid.md b/docs/api/ai-solid.md
index f495993822..2cc7cccb7a 100644
--- a/docs/api/ai-solid.md
+++ b/docs/api/ai-solid.md
@@ -15,6 +15,8 @@ keywords:
SolidJS primitives for TanStack AI, providing convenient SolidJS bindings for the headless client.
+For a typed headless chat UI, see [Solid Chat UI](../ui/solid) and [Migrate to createUI](../migration/create-ui).
+
## Installation
```bash
diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md
index 60488ce058..639f39ae15 100644
--- a/docs/api/ai-svelte.md
+++ b/docs/api/ai-svelte.md
@@ -15,6 +15,8 @@ keywords:
Svelte 5 bindings for TanStack AI, providing reactive factory functions for the headless client using Svelte runes.
+For a typed headless chat UI, see [Svelte Chat UI](../ui/svelte) and [Migrate to createUI](../migration/create-ui).
+
## Installation
```bash
diff --git a/docs/api/ai-vue.md b/docs/api/ai-vue.md
index 6606cf4444..8e82e46e8b 100644
--- a/docs/api/ai-vue.md
+++ b/docs/api/ai-vue.md
@@ -15,6 +15,8 @@ keywords:
Vue composables for TanStack AI, providing convenient Vue 3 bindings for the headless client.
+For a typed headless chat UI, see [Vue Chat UI](../ui/vue) and [Migrate to createUI](../migration/create-ui).
+
## Installation
```bash
diff --git a/docs/config.json b/docs/config.json
index dfe4cd2779..74ba0b9179 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -189,6 +189,42 @@
}
]
},
+ {
+ "label": "UI",
+ "tab": "guides",
+ "children": [
+ {
+ "label": "React",
+ "to": "ui/react",
+ "addedAt": "2026-08-26",
+ "updatedAt": "2026-08-26"
+ },
+ {
+ "label": "Solid",
+ "to": "ui/solid",
+ "addedAt": "2026-08-26",
+ "updatedAt": "2026-08-26"
+ },
+ {
+ "label": "Vue",
+ "to": "ui/vue",
+ "addedAt": "2026-08-26",
+ "updatedAt": "2026-08-26"
+ },
+ {
+ "label": "Svelte",
+ "to": "ui/svelte",
+ "addedAt": "2026-08-26",
+ "updatedAt": "2026-08-26"
+ },
+ {
+ "label": "Custom Adapters",
+ "to": "ui/custom-adapters",
+ "addedAt": "2026-08-26",
+ "updatedAt": "2026-08-26"
+ }
+ ]
+ },
{
"label": "Interrupts",
"tab": "guides",
@@ -821,6 +857,12 @@
"addedAt": "2026-04-15",
"updatedAt": "2026-07-22"
},
+ {
+ "label": "createUI",
+ "to": "migration/create-ui",
+ "addedAt": "2026-08-26",
+ "updatedAt": "2026-08-26"
+ },
{
"label": "From Vercel AI SDK",
"to": "migration/migration-from-vercel-ai",
@@ -853,19 +895,19 @@
"label": "@tanstack/ai-client",
"to": "api/ai-client",
"addedAt": "2026-04-15",
- "updatedAt": "2026-08-24"
+ "updatedAt": "2026-08-26"
},
{
"label": "@tanstack/ai-react",
"to": "api/ai-react",
"addedAt": "2026-04-15",
- "updatedAt": "2026-08-24"
+ "updatedAt": "2026-08-26"
},
{
"label": "@tanstack/ai-solid",
"to": "api/ai-solid",
"addedAt": "2026-04-15",
- "updatedAt": "2026-08-24"
+ "updatedAt": "2026-08-26"
},
{
"label": "@tanstack/ai-preact",
@@ -877,13 +919,13 @@
"label": "@tanstack/ai-vue",
"to": "api/ai-vue",
"addedAt": "2026-04-15",
- "updatedAt": "2026-08-24"
+ "updatedAt": "2026-08-26"
},
{
"label": "@tanstack/ai-svelte",
"to": "api/ai-svelte",
"addedAt": "2026-04-15",
- "updatedAt": "2026-08-24"
+ "updatedAt": "2026-08-26"
},
{
"label": "@tanstack/ai-angular",
diff --git a/docs/migration/create-ui.md b/docs/migration/create-ui.md
new file mode 100644
index 0000000000..9d311c293b
--- /dev/null
+++ b/docs/migration/create-ui.md
@@ -0,0 +1,117 @@
+---
+title: Migrate to createUI
+id: migrate-create-ui
+order: 5
+description: "Move chat-state ownership out of the old Chat component and onto createUI with a typed component map."
+keywords:
+ - tanstack ai
+ - createUI
+ - migration
+ - deprecation
+---
+
+The old `Chat` component owned chat state and lost configured types. `createUI` keeps types from your `chatOptions` and leaves `useChat` in your app.
+
+This is a semantic migration. There is no codemod.
+
+## What changes
+
+1. You call `useChat` or `createChat` yourself.
+2. You supply every visible component.
+3. Tool inputs stay optional while they stream.
+4. Tool approvals come from `chat.interrupts`.
+5. Unknown runtime keys can use a fallback or render nothing.
+6. `createUI()` must run at module scope so identity stays stable.
+
+## Why
+
+The old APIs drop configured types, keep unused properties, use a deprecated approval path, cover only part of the message protocol, and own chat state. Two orchestration models duplicate fixes.
+
+## Minimum versions
+
+- `@tanstack/ai-react-ui` 0.9.0
+- `@tanstack/ai-solid-ui` 0.8.0
+- `@tanstack/ai-vue-ui` 0.3.0
+- `@tanstack/ai-svelte-ui` 0.1.0
+
+Old orchestration exports stay importable until each package's `1.0.0`. `TextPart` and `ThinkingPart` stay supported.
+
+## Before
+
+```tsx
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { Chat, ChatMessages, ChatInput } from '@tanstack/ai-react-ui'
+
+const connection = fetchServerSentEvents('/api/chat')
+
+export function OldChat() {
+ return (
+
+
+
+
+ )
+}
+```
+
+## After
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { createUI } from '@tanstack/ai-react-ui'
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+}
+
+const UI = createUI(chatOptions)
+
+const components = UI.defineComponents({
+ layout: ({ renderMessages, renderInput }) => (
+
+ {renderMessages()}
+ {renderInput()}
+
+ ),
+ message: ({ renderParts }) => {renderParts()} ,
+ input: ({ chat }) => (
+
+ ),
+ parts: { fallback: () => null },
+})
+
+export function NewChat() {
+ const chat = useChat(chatOptions)
+ return
+}
+```
+
+## Steps
+
+1. Move `connection`, `tools`, and `interrupts` into a module-level `chatOptions` object.
+2. Call `createUI(chatOptions)` next to that object.
+3. Call `useChat(chatOptions)` in the screen component.
+4. Define `layout`, `message`, `parts`, `tools`, and `interrupts` in `defineComponents`.
+5. Replace `` with ` `.
+
+## Gotchas
+
+- A shared `chatOptions` variable does not need `as const`.
+- `{ component, placement: 'inline' }` puts a tool approval in the tool slot. A direct tool interrupt component uses the list.
+- Generic interrupts live under `interrupts.generic`: a registered id such as `choosePlan`, plus `fallback`. Unbound interrupts use `fallback`.
+- Matched `tool-result` parts are hidden in automatic traversal. Unmatched results stay visible.
+- Nested providers use the nearest chat instance.
+
+See the [React UI guide](../ui/react) for a full map.
diff --git a/docs/ui/custom-adapters.md b/docs/ui/custom-adapters.md
new file mode 100644
index 0000000000..b0ae0c3201
--- /dev/null
+++ b/docs/ui/custom-adapters.md
@@ -0,0 +1,34 @@
+---
+title: Custom Chat UI Adapters
+id: typed-headless-ui-custom-adapters
+order: 5
+description: "Build a framework adapter on @tanstack/ai-client/ui. The core is types and selectors only."
+keywords:
+ - tanstack ai
+ - createUI
+ - custom adapter
+ - headless ui
+---
+
+Import `@tanstack/ai-client/ui`. Do not import it from the main client entry.
+
+The subpath gives you:
+
+1. `selectChatUI` to match tool results and split list vs inline interrupts
+2. `partTypeToKey` to turn `tool-call` into `toolCall`
+3. Option types for tools, generic interrupts, and `outputSchema`
+
+Your adapter owns:
+
+1. Native components and context
+2. Native reactivity
+3. Render callbacks, slots, or snippets
+4. Development warnings for missing mapped keys
+
+Do not add default markup. Do not add a new store. The app owns `useChat` or `createChat`.
+
+Call `selectChatUI({ messages, interrupts, inlineToolNames })`. Automatic traversal skips a `tool-result` only when `matched` is true. Keep unmatched results.
+
+Warn once per missing runtime key in development. Each build tool detects development mode differently, so the adapter prints the warning.
+
+See the [React](./react), [Solid](./solid), [Vue](./vue), and [Svelte](./svelte) adapters for the public names to match: `Chat`, `Provider`, `Messages`, `Message`, `Part`, `Interrupts`, `Interrupt`, and `defineComponents`.
diff --git a/docs/ui/react.md b/docs/ui/react.md
new file mode 100644
index 0000000000..0a1732bde2
--- /dev/null
+++ b/docs/ui/react.md
@@ -0,0 +1,549 @@
+---
+title: React Chat UI
+id: typed-headless-ui-react
+order: 1
+description: "Build a typed, headless React chat UI with createUI. Your chat options control the types of tools, parts, and interrupts."
+keywords:
+ - tanstack ai
+ - createUI
+ - react
+ - headless ui
+ - useChat
+ - ToolProps
+---
+
+Install `@tanstack/ai-react-ui`, then call `createUI(chatOptions)` once at module scope. Your app owns `useChat`. The UI only renders.
+
+You supply every visible component. There is no default markup, style, or copy.
+
+## Server
+
+```ts
+import { chat, toServerSentEventsResponse } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+export async function POST(request: Request) {
+ const json: unknown = await request.json()
+ if (typeof json !== 'object' || json === null || !('messages' in json)) {
+ return new Response('Invalid body', { status: 400 })
+ }
+ const messages = json.messages
+ const stream = chat({
+ adapter: openaiText('gpt-5.2'),
+ messages: Array.isArray(messages) ? messages : [],
+ })
+ return toServerSentEventsResponse(stream)
+}
+```
+
+## Client
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { createUI } from '@tanstack/ai-react-ui'
+import { defineInterrupt, 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 purchaseItem = toolDefinition({
+ name: 'purchaseItem',
+ description: 'Buy an item',
+ needsApproval: true,
+ inputSchema: z.object({ item: z.string() }),
+ outputSchema: z.object({ ok: z.boolean() }),
+}).client()
+
+const choosePlan = defineInterrupt({
+ id: 'choosePlan',
+ payloadSchema: z.object({ title: z.string() }),
+ responseSchema: z.string(),
+})
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+ tools: [getWeather, purchaseItem],
+ interrupts: [choosePlan],
+ outputSchema: z.object({ answer: z.string() }),
+}
+
+const UI = createUI(chatOptions)
+
+const components = UI.defineComponents({
+ layout: function Layout({
+ chat,
+ renderMessages,
+ renderInterrupts,
+ renderInput,
+ }) {
+ if (chat.error) return {chat.error.message}
+ if (chat.isLoading && chat.messages.length === 0) return Loading
+ if (chat.messages.length === 0) return Empty
+ return (
+
+ {renderMessages()}
+ {renderInterrupts()}
+ {renderInput()}
+
+ )
+ },
+ message: function Message({ message, renderParts }) {
+ return {renderParts()}
+ },
+ input: function Input({ chat }) {
+ return (
+
+ )
+ },
+ parts: {
+ text: ({ part }) => (part.type === 'text' ? {part.content}
: null),
+ structuredOutput: ({ part }) =>
+ part.type === 'structured-output' ? (
+ {part.raw}
+ ) : null,
+ toolResult: ({ part }) =>
+ part.type === 'tool-result' ? {String(part.content)} : null,
+ fallback: ({ part }) => {part.type} ,
+ },
+ tools: {
+ getWeather: ({ part, result }) => {
+ if (part.state === 'awaiting-input') return Waiting
+ if (part.state === 'input-streaming') return Streaming input
+ if (part.state === 'input-complete') return {part.input?.city}
+ if (part.state === 'approval-requested') return Need approval
+ if (part.state === 'approval-responded') return Responded
+ if (part.state === 'error') return Error
+ return (
+
+ {part.input?.city}: {String(part.output?.temperature ?? result?.content)}
+
+ )
+ },
+ purchaseItem: ({ part, renderInterrupt }) => (
+
+ {part.input?.item}
+ {renderInterrupt()}
+
+ ),
+ },
+ interrupts: {
+ tools: {
+ purchaseItem: {
+ component: ({ interrupt }) =>
+ interrupt.kind === 'tool-approval' ? (
+ interrupt.status === 'pending' ? (
+ interrupt.resolveInterrupt(true)}>
+ Approve
+
+ ) : (
+ {interrupt.status}
+ )
+ ) : null,
+ placement: 'inline',
+ },
+ },
+ generic: {
+ choosePlan: ({ interrupt }) => (
+ interrupt.resolveInterrupt('approved')}>
+ {interrupt.payload?.title ?? 'Choose plan'}
+
+ ),
+ fallback: ({ interrupt }) => {interrupt.reason}
,
+ },
+ },
+})
+
+export function ChatScreen() {
+ const chat = useChat(chatOptions)
+ return
+}
+```
+
+## Type a component in its own file
+
+A tool map grows fast. Move a tool into its own file and type the props with `ToolProps`.
+
+`ToolProps` takes your `chatOptions` type and the tool name. Then `part.input` and `part.output` stay exact.
+
+```tsx
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { createUI, type ToolProps } from '@tanstack/ai-react-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 function WeatherTool({
+ part,
+ result,
+}: ToolProps) {
+ if (part.state === 'awaiting-input') return Waiting
+ if (part.state === 'input-streaming') return Streaming input
+ if (part.state === 'error') return Error
+ return (
+
+ {part.input?.city}: {String(part.output?.temperature ?? result?.content)}
+
+ )
+}
+
+const UI = createUI(chatOptions)
+
+export const components = UI.defineComponents({
+ layout: ({ renderMessages }) => renderMessages(),
+ message: ({ renderParts }) => {renderParts()} ,
+ parts: { fallback: () => null },
+ tools: { getWeather: WeatherTool },
+})
+```
+
+1. Put `chatOptions` in a shared module.
+2. Import `ToolProps` from `@tanstack/ai-react-ui`.
+3. Type the component with `ToolProps`.
+4. Pass that component into `tools.getWeather`.
+
+For a registered generic interrupt, use `RegisteredInterruptProps`. Then `interrupt.payload` and `interrupt.resolveInterrupt` match the definition.
+
+```tsx
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { createUI, type RegisteredInterruptProps } from '@tanstack/ai-react-ui'
+import { defineInterrupt } from '@tanstack/ai'
+import { z } from 'zod'
+
+const choosePlan = defineInterrupt({
+ id: 'choosePlan',
+ payloadSchema: z.object({ title: z.string() }),
+ responseSchema: z.string(),
+})
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+ interrupts: [choosePlan],
+}
+
+export function ChoosePlan({
+ interrupt,
+}: RegisteredInterruptProps) {
+ return (
+ interrupt.resolveInterrupt('approved')}>
+ {interrupt.payload?.title ?? 'Choose plan'}
+
+ )
+}
+
+const UI = createUI(chatOptions)
+
+export const components = UI.defineComponents({
+ layout: ({ renderInterrupts }) => renderInterrupts(),
+ message: ({ renderParts }) => {renderParts()} ,
+ parts: { fallback: () => null },
+ interrupts: {
+ generic: {
+ choosePlan: ChoosePlan,
+ },
+ },
+})
+```
+
+Other prop types from the same package:
+
+- `LayoutProps`
+- `MessageProps`
+- `InputProps`
+- `PartProps`
+- `InterruptProps` for tool approvals and `generic.fallback`
+
+## Read chat from `UI.useChat()`
+
+Every mapped component already gets `chat` as a prop. Nested children that you write yourself can call `UI.useChat()` instead of threading that prop.
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { createUI } from '@tanstack/ai-react-ui'
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+}
+
+const UI = createUI(chatOptions)
+
+function StatusLine() {
+ const chat = UI.useChat()
+ if (chat.error) return {chat.error.message}
+ if (chat.isLoading) return Loading
+ return {chat.messages.length} messages
+}
+
+const components = UI.defineComponents({
+ layout: ({ renderMessages, renderInput }) => (
+
+
+ {renderMessages()}
+ {renderInput()}
+
+ ),
+ message: ({ renderParts }) => {renderParts()} ,
+ parts: { fallback: () => null },
+})
+
+export function ChatScreen() {
+ const chat = useChat(chatOptions)
+ return
+}
+```
+
+Call `UI.useChat()` only inside `UI.Chat` or `UI.Provider`. A call outside that tree throws.
+
+`useChat(chatOptions)` from `@tanstack/ai-react` still owns the state. `UI.useChat()` only reads the instance that you passed into the provider.
+
+## Tool approvals: inline or list
+
+A tool with `needsApproval: true` can render its approval in two places.
+
+### Inline, next to the tool
+
+Set `placement: 'inline'`. Call `renderInterrupt()` inside the tool component. The approval appears in the tool slot. It does not appear in the interrupt list.
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { createUI } from '@tanstack/ai-react-ui'
+import { toolDefinition } from '@tanstack/ai'
+import { z } from 'zod'
+
+const purchaseItem = toolDefinition({
+ name: 'purchaseItem',
+ description: 'Buy an item',
+ needsApproval: true,
+ inputSchema: z.object({ item: z.string() }),
+ outputSchema: z.object({ ok: z.boolean() }),
+}).client()
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+ tools: [purchaseItem],
+}
+
+const UI = createUI(chatOptions)
+
+const components = UI.defineComponents({
+ layout: ({ renderMessages, renderInterrupts }) => (
+
+ {renderMessages()}
+ {renderInterrupts()}
+
+ ),
+ message: ({ renderParts }) => {renderParts()} ,
+ parts: { fallback: () => null },
+ tools: {
+ purchaseItem: ({ part, renderInterrupt }) => (
+
+ {part.input?.item}
+ {renderInterrupt()}
+
+ ),
+ },
+ interrupts: {
+ tools: {
+ purchaseItem: {
+ component: ({ interrupt }) =>
+ interrupt.kind === 'tool-approval' ? (
+ interrupt.resolveInterrupt(true)}>
+ Approve
+
+ ) : null,
+ placement: 'inline',
+ },
+ },
+ },
+})
+
+export function InlineApprovalChat() {
+ const chat = useChat(chatOptions)
+ return
+}
+```
+
+### List, in `renderInterrupts()`
+
+Pass the approval component directly. Do not set `placement: 'inline'`. The tool can skip `renderInterrupt()`. The approval appears in the interrupt list.
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { createUI } from '@tanstack/ai-react-ui'
+import { toolDefinition } from '@tanstack/ai'
+import { z } from 'zod'
+
+const purchaseItem = toolDefinition({
+ name: 'purchaseItem',
+ description: 'Buy an item',
+ needsApproval: true,
+ inputSchema: z.object({ item: z.string() }),
+ outputSchema: z.object({ ok: z.boolean() }),
+}).client()
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+ tools: [purchaseItem],
+}
+
+const UI = createUI(chatOptions)
+
+const components = UI.defineComponents({
+ layout: ({ renderMessages, renderInterrupts }) => (
+
+ {renderMessages()}
+ {renderInterrupts()}
+
+ ),
+ message: ({ renderParts }) => {renderParts()} ,
+ parts: { fallback: () => null },
+ tools: {
+ purchaseItem: ({ part }) => {part.input?.item}
,
+ },
+ interrupts: {
+ tools: {
+ purchaseItem: ({ interrupt }) =>
+ interrupt.kind === 'tool-approval' ? (
+ interrupt.resolveInterrupt(true)}>
+ Approve
+
+ ) : null,
+ },
+ },
+})
+
+export function ListApprovalChat() {
+ const chat = useChat(chatOptions)
+ return
+}
+```
+
+`placement: 'list'` is the same as a direct component.
+
+## Generic interrupts
+
+Generic interrupts always render in the list (`renderInterrupts()` / ``). They never render inside a tool.
+
+Map them under `interrupts.generic`:
+
+- A registered id such as `choosePlan`: the component for that definition
+- `fallback`: every other list interrupt, including an unknown generic id and an unbound interrupt this chat does not own
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { createUI } from '@tanstack/ai-react-ui'
+import { defineInterrupt } from '@tanstack/ai'
+import { z } from 'zod'
+
+const choosePlan = defineInterrupt({
+ id: 'choosePlan',
+ payloadSchema: z.object({ title: z.string() }),
+ responseSchema: z.string(),
+})
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+ interrupts: [choosePlan],
+}
+
+const UI = createUI(chatOptions)
+
+const components = UI.defineComponents({
+ layout: ({ renderInterrupts }) => renderInterrupts(),
+ message: ({ renderParts }) => {renderParts()} ,
+ parts: { fallback: () => null },
+ interrupts: {
+ generic: {
+ choosePlan: ({ interrupt }) => (
+ interrupt.resolveInterrupt('approved')}>
+ {interrupt.payload?.title ?? 'Choose plan'}
+
+ ),
+ fallback: ({ interrupt }) =>
+ interrupt.kind === 'unbound' ? (
+ Paused elsewhere: {interrupt.reason}
+ ) : (
+ {interrupt.reason}
+ ),
+ },
+ },
+})
+
+export function GenericInterruptChat() {
+ const chat = useChat(chatOptions)
+ return
+}
+```
+
+You can mix this map with `interrupts.tools` in the same `defineComponents` call.
+
+## Manual traversal
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { createUI } from '@tanstack/ai-react-ui'
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+}
+
+const UI = createUI(chatOptions)
+
+const components = UI.defineComponents({
+ layout: ({ renderMessages }) => renderMessages(),
+ message: ({ renderParts }) => {renderParts()} ,
+ parts: { fallback: () => null },
+})
+
+export function ManualChat() {
+ const chat = useChat(chatOptions)
+ return (
+
+
+ {(messages) =>
+ messages.map((message) => (
+
+ {(parts) =>
+ parts.map((part, index) => (
+ {part.key}
+ ))
+ }
+
+ ))
+ }
+
+
+ )
+}
+```
+
+Unknown runtime tool names warn once in development and render nothing. Add a `parts.fallback` for unknown part types.
+
+See also [Solid](./solid), [Vue](./vue), [Svelte](./svelte), and [custom adapters](./custom-adapters).
diff --git a/docs/ui/solid.md b/docs/ui/solid.md
new file mode 100644
index 0000000000..da5ab4ace1
--- /dev/null
+++ b/docs/ui/solid.md
@@ -0,0 +1,148 @@
+---
+title: Solid Chat UI
+id: typed-headless-ui-solid
+order: 2
+description: "Build a typed, headless Solid chat UI with createUI. Accessors stay tracked. Your app owns useChat."
+keywords:
+ - tanstack ai
+ - createUI
+ - solid
+ - headless ui
+ - ToolProps
+---
+
+Install `@tanstack/ai-solid-ui`, then call `createUI(chatOptions)` once at module scope. Do not destructure reactive props.
+
+The server route matches the [React page](./react). Use `gpt-5.2` on the OpenAI text adapter.
+
+## Client
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid'
+import { createUI } from '@tanstack/ai-solid-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 UI = createUI(chatOptions)
+
+const components = UI.defineComponents({
+ layout: (props) => (
+ <>
+ {props.renderMessages()}
+ {props.renderInterrupts()}
+ {props.renderInput()}
+ >
+ ),
+ message: (props) => {props.renderParts()} ,
+ parts: {
+ fallback: (props) => {props.part.type} ,
+ },
+ tools: {
+ getWeather: (props) => {props.part.input?.city} ,
+ },
+ interrupts: { generic: { fallback: () => null } },
+})
+
+export function ChatScreen() {
+ const chat = useChat(chatOptions)
+ return
+}
+```
+
+## Type a component in its own file
+
+Use `ToolProps` the same way as React. Keep the `props` object so Solid can track it.
+
+```tsx
+import { fetchServerSentEvents } from '@tanstack/ai-solid'
+import { createUI, type ToolProps } from '@tanstack/ai-solid-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 function WeatherTool(
+ props: ToolProps,
+) {
+ return {props.part.input?.city}
+}
+
+const UI = createUI(chatOptions)
+
+export const components = UI.defineComponents({
+ layout: (props) => props.renderMessages(),
+ message: (props) => {props.renderParts()} ,
+ parts: { fallback: () => null },
+ tools: { getWeather: WeatherTool },
+})
+```
+
+Registered generic interrupts use `RegisteredInterruptProps`.
+
+## Read chat from `UI.useChat()`
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid'
+import { createUI } from '@tanstack/ai-solid-ui'
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+}
+
+const UI = createUI(chatOptions)
+
+function StatusLine() {
+ const chat = UI.useChat()
+ return {chat.messages.length} messages
+}
+
+const components = UI.defineComponents({
+ layout: (props) => (
+ <>
+
+ {props.renderMessages()}
+ >
+ ),
+ message: (props) => {props.renderParts()} ,
+ parts: { fallback: () => null },
+})
+
+export function ChatScreen() {
+ const chat = useChat(chatOptions)
+ return
+}
+```
+
+Call `UI.useChat()` only inside `UI.Chat` or `UI.Provider`.
+
+## Interrupts
+
+Tool approvals can sit in the tool (`placement: 'inline'` plus `props.renderInterrupt()`) or in the list (a direct component). Generic interrupts always sit in the list under `interrupts.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](./react).
+
+Manual list: `{(messages) => {messages().length} } `.
+
+Pass `props.chat`, `props.part`, and `props.renderParts()` without destructure.
diff --git a/docs/ui/svelte.md b/docs/ui/svelte.md
new file mode 100644
index 0000000000..2041b07586
--- /dev/null
+++ b/docs/ui/svelte.md
@@ -0,0 +1,96 @@
+---
+title: Svelte Chat UI
+id: typed-headless-ui-svelte
+order: 4
+description: "Build a typed, headless Svelte 5 chat UI with createUI, snippets, and static components."
+keywords:
+ - tanstack ai
+ - createUI
+ - svelte
+ - headless ui
+ - ToolProps
+---
+
+Install `@tanstack/ai-svelte-ui`. Call `createUI(chatOptions)` once. Pass `{ui}`, `{chat}`, and `{components}` into `UIChat`.
+
+The server route matches the [React page](./react). Use `gpt-5.2` on the OpenAI text adapter.
+
+## Client
+
+```svelte
+
+
+
+```
+
+`Layout.svelte` receives snippets `messages`, `interrupts`, and `input`. `Message.svelte` receives snippet `parts`. A tool with an inline approval receives snippet `renderInterrupt`.
+
+## Type a component in its own file
+
+Type the `$props()` of a tool file with `ToolProps`. Share the same `chatOptions` module that you pass to `createUI`.
+
+```svelte
+
+
+{part.input?.city}
+```
+
+Registered generic interrupts use `RegisteredInterruptProps`.
+
+## Read chat from `ui.useChat()`
+
+Import the same `ui` descriptor in a child file. Call `ui.useChat()` only under `UIChat` or `UIProvider`.
+
+```svelte
+
+
+{chat.messages.length} messages
+```
+
+`createChat(chatOptions)` owns the state. `ui.useChat()` reads the instance you passed into `UIChat`. A call outside that tree throws.
+
+## Interrupts
+
+Tool approvals can sit in the tool (`placement: 'inline'` plus the `renderInterrupt` snippet) or in the list (a direct component). Generic interrupts always sit in the list under `interrupts.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](./react).
diff --git a/docs/ui/vue.md b/docs/ui/vue.md
new file mode 100644
index 0000000000..de35f5845f
--- /dev/null
+++ b/docs/ui/vue.md
@@ -0,0 +1,161 @@
+---
+title: Vue Chat UI
+id: typed-headless-ui-vue
+order: 3
+description: "Build a typed, headless Vue chat UI with createUI and static primitives. Slots replace render callbacks."
+keywords:
+ - tanstack ai
+ - createUI
+ - vue
+ - headless ui
+ - ToolProps
+---
+
+Install `@tanstack/ai-vue-ui`. Call `createUI(chatOptions)` once. Pass the descriptor as `ui` into `UIChat`, `UIProvider`, and the other static primitives.
+
+The server route matches the [React page](./react). Use `gpt-5.2` on the OpenAI text adapter.
+
+## Client
+
+```ts
+import { defineComponent, h } from 'vue'
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-vue'
+import { createUI, 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 ui = createUI(chatOptions)
+
+const components = ui.defineComponents({
+ layout: defineComponent((_, { slots }) => () => slots.messages?.()),
+ message: defineComponent((_, { slots }) => () => h('article', slots.parts?.())),
+ parts: {
+ fallback: defineComponent({
+ props: ['part'],
+ setup(props) {
+ return () => h('span', props.part.type)
+ },
+ }),
+ },
+ tools: {
+ getWeather: defineComponent({
+ props: ['part'],
+ setup(props) {
+ return () => h('strong', props.part.input?.city)
+ },
+ }),
+ },
+ interrupts: { generic: { fallback: defineComponent(() => () => null) } },
+})
+
+export default defineComponent({
+ setup() {
+ const chat = useChat(chatOptions)
+ return () => h(UIChat, { ui, chat, components })
+ },
+})
+```
+
+Layout uses slots `messages`, `interrupts`, and `input`. Message uses slot `parts`. Manual lists use the default slot on `UIMessages` with `{ messages }`.
+
+## Type a component in its own file
+
+Use `ToolProps` on the component props. Share the same `chatOptions` object that you pass to `createUI`.
+
+```ts
+import { defineComponent, h } from 'vue'
+import { fetchServerSentEvents } from '@tanstack/ai-vue'
+import { createUI, 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) => {
+ return () => h('strong', props.part.input?.city)
+ },
+)
+
+const ui = createUI(chatOptions)
+
+export const components = ui.defineComponents({
+ layout: defineComponent((_, { slots }) => () => slots.messages?.()),
+ message: defineComponent((_, { slots }) => () => h('article', slots.parts?.())),
+ parts: { fallback: defineComponent(() => () => null) },
+ tools: { getWeather: WeatherTool },
+})
+```
+
+Registered generic interrupts use `RegisteredInterruptProps`.
+
+## Read chat from `ui.useChat()`
+
+Call `ui.useChat()` inside a child of `UIChat` or `UIProvider`.
+
+```ts
+import { defineComponent, h } from 'vue'
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-vue'
+import { createUI, UIChat } from '@tanstack/ai-vue-ui'
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+}
+
+const ui = createUI(chatOptions)
+
+const StatusLine = defineComponent({
+ setup() {
+ const chat = ui.useChat()
+ return () => {
+ const messages = Array.isArray(chat.messages) ? chat.messages : []
+ return h('p', String(messages.length) + ' messages')
+ }
+ },
+})
+
+const components = ui.defineComponents({
+ layout: defineComponent((_, { slots }) => () =>
+ h('main', [h(StatusLine), slots.messages?.()]),
+ ),
+ message: defineComponent((_, { slots }) => () => h('article', slots.parts?.())),
+ parts: { fallback: defineComponent(() => () => null) },
+})
+
+export default defineComponent({
+ setup() {
+ const chat = useChat(chatOptions)
+ return () => h(UIChat, { ui, chat, components })
+ },
+})
+```
+
+`useChat(chatOptions)` from `@tanstack/ai-vue` owns the state. `ui.useChat()` reads the instance you passed into `UIChat`.
+
+## Interrupts
+
+Tool approvals can sit in the tool (`placement: 'inline'` plus the `renderInterrupt` slot) or in the list (a direct component). Generic interrupts always sit in the list under `interrupts.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](./react).
diff --git a/examples/ag-ui/src/App.tsx b/examples/ag-ui/src/App.tsx
index 8fca0f93a2..74fd4ded5d 100644
--- a/examples/ag-ui/src/App.tsx
+++ b/examples/ag-ui/src/App.tsx
@@ -1,12 +1,68 @@
import { useEffect, useMemo, useState } from 'react'
-import { fetchServerSentEvents } from '@tanstack/ai-react'
-import type { UIMessage } from '@tanstack/ai-react'
-import {
- Chat,
- ChatInput,
- ChatMessage,
- ChatMessages,
-} from '@tanstack/ai-react-ui'
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import type { ConnectionAdapter } from '@tanstack/ai-react'
+import { ChatMessage, createUI } from '@tanstack/ai-react-ui'
+
+const UI = createUI({})
+
+function AgUiChat({
+ connection,
+ placeholder,
+ emptyLabel,
+}: {
+ connection: ConnectionAdapter
+ placeholder: string
+ emptyLabel: string
+}) {
+ const chat = useChat({ connection })
+ const [draft, setDraft] = useState('')
+ const components = UI.defineComponents({
+ layout: ({ chat: current, renderMessages, renderInput }) => (
+
+ {current.error ? (
+
{current.error.message}
+ ) : current.messages.length === 0 ? (
+
+ {emptyLabel}
+
+ ) : (
+
+ {renderMessages()}
+
+ )}
+
{renderInput()}
+
+ ),
+ message: ({ message }) => ,
+ input: () => (
+
+ ),
+ parts: { fallback: () => null },
+ })
+ return
+}
type Backend = 'go' | 'rust' | 'php' | 'zig' | 'bash' | 'python'
type Provider = 'openai' | 'anthropic'
@@ -280,28 +336,12 @@ export function App() {
Loading backend availability…
) : active.available && connection ? (
-
-
- Chat with {active.label} over AG-UI SSE using{' '}
- {activeProvider.label}.
-
- }
- >
- {(message: UIMessage) => }
-
-
-
-
-
+ placeholder={`Message via ${active.label} + ${activeProvider.label}…`}
+ emptyLabel={`Chat with ${active.label} over AG-UI SSE using ${activeProvider.label}.`}
+ />
) : (
)}
diff --git a/examples/ts-solid-chat/src/routeTree.gen.ts b/examples/ts-solid-chat/src/routeTree.gen.ts
index f474ce588d..09f7e9514c 100644
--- a/examples/ts-solid-chat/src/routeTree.gen.ts
+++ b/examples/ts-solid-chat/src/routeTree.gen.ts
@@ -9,11 +9,17 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
+import { Route as SolidUiRouteImport } from './routes/solid-ui'
import { Route as IndexRouteImport } from './routes/index'
import { Route as ApiChatRouteImport } from './routes/api.chat'
import { Route as ExampleGuitarsIndexRouteImport } from './routes/example.guitars/index'
import { Route as ExampleGuitarsGuitarIdRouteImport } from './routes/example.guitars/$guitarId'
+const SolidUiRoute = SolidUiRouteImport.update({
+ id: '/solid-ui',
+ path: '/solid-ui',
+ getParentRoute: () => rootRouteImport,
+} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -37,12 +43,14 @@ const ExampleGuitarsGuitarIdRoute = ExampleGuitarsGuitarIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
+ '/solid-ui': typeof SolidUiRoute
'/api/chat': typeof ApiChatRoute
'/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute
'/example/guitars': typeof ExampleGuitarsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
+ '/solid-ui': typeof SolidUiRoute
'/api/chat': typeof ApiChatRoute
'/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute
'/example/guitars': typeof ExampleGuitarsIndexRoute
@@ -50,6 +58,7 @@ export interface FileRoutesByTo {
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
+ '/solid-ui': typeof SolidUiRoute
'/api/chat': typeof ApiChatRoute
'/example/guitars/$guitarId': typeof ExampleGuitarsGuitarIdRoute
'/example/guitars/': typeof ExampleGuitarsIndexRoute
@@ -58,14 +67,21 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
+ | '/solid-ui'
| '/api/chat'
| '/example/guitars/$guitarId'
| '/example/guitars'
fileRoutesByTo: FileRoutesByTo
- to: '/' | '/api/chat' | '/example/guitars/$guitarId' | '/example/guitars'
+ to:
+ | '/'
+ | '/solid-ui'
+ | '/api/chat'
+ | '/example/guitars/$guitarId'
+ | '/example/guitars'
id:
| '__root__'
| '/'
+ | '/solid-ui'
| '/api/chat'
| '/example/guitars/$guitarId'
| '/example/guitars/'
@@ -73,6 +89,7 @@ export interface FileRouteTypes {
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
+ SolidUiRoute: typeof SolidUiRoute
ApiChatRoute: typeof ApiChatRoute
ExampleGuitarsGuitarIdRoute: typeof ExampleGuitarsGuitarIdRoute
ExampleGuitarsIndexRoute: typeof ExampleGuitarsIndexRoute
@@ -80,6 +97,13 @@ export interface RootRouteChildren {
declare module '@tanstack/solid-router' {
interface FileRoutesByPath {
+ '/solid-ui': {
+ id: '/solid-ui'
+ path: '/solid-ui'
+ fullPath: '/solid-ui'
+ preLoaderRoute: typeof SolidUiRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/': {
id: '/'
path: '/'
@@ -113,6 +137,7 @@ declare module '@tanstack/solid-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
+ SolidUiRoute: SolidUiRoute,
ApiChatRoute: ApiChatRoute,
ExampleGuitarsGuitarIdRoute: ExampleGuitarsGuitarIdRoute,
ExampleGuitarsIndexRoute: ExampleGuitarsIndexRoute,
diff --git a/examples/ts-solid-chat/src/routes/solid-ui.tsx b/examples/ts-solid-chat/src/routes/solid-ui.tsx
new file mode 100644
index 0000000000..22e60f6e98
--- /dev/null
+++ b/examples/ts-solid-chat/src/routes/solid-ui.tsx
@@ -0,0 +1,61 @@
+import { createFileRoute } from '@tanstack/solid-router'
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid'
+import { createUI } from '@tanstack/ai-solid-ui'
+import { createSignal } from 'solid-js'
+import { clientTools } from '@/lib/guitar-tools'
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+ tools: clientTools,
+}
+
+const UI = createUI(chatOptions)
+
+function SolidUIPage() {
+ const chat = useChat(chatOptions)
+ const [draft, setDraft] = createSignal('')
+ const components = UI.defineComponents({
+ layout: (props) => (
+
+
+ {props.renderMessages()}
+
+ {props.renderInput()}
+
+ ),
+ message: (props) => (
+
+ {props.renderParts()}
+
+ ),
+ input: () => (
+
+ ),
+ parts: {
+ fallback: (props) =>
+ props.part.type === 'text' ? {props.part.content}
: null,
+ },
+ })
+
+ return
+}
+
+export const Route = createFileRoute('/solid-ui')({
+ component: SolidUIPage,
+})
diff --git a/examples/ts-svelte-chat/package.json b/examples/ts-svelte-chat/package.json
index 80a3e08237..5ce76cb7c7 100644
--- a/examples/ts-svelte-chat/package.json
+++ b/examples/ts-svelte-chat/package.json
@@ -20,6 +20,7 @@
"@tanstack/ai-ollama": "workspace:*",
"@tanstack/ai-openai": "workspace:*",
"@tanstack/ai-svelte": "workspace:*",
+ "@tanstack/ai-svelte-ui": "workspace:*",
"highlight.js": "^11.11.1",
"lucide-svelte": "^0.468.0",
"marked": "^15.0.6",
diff --git a/examples/ts-svelte-chat/src/routes/svelte-ui/+page.svelte b/examples/ts-svelte-chat/src/routes/svelte-ui/+page.svelte
new file mode 100644
index 0000000000..7a354b566d
--- /dev/null
+++ b/examples/ts-svelte-chat/src/routes/svelte-ui/+page.svelte
@@ -0,0 +1,71 @@
+
+
+
+ TanStack AI - Svelte createUI
+
+
+
diff --git a/examples/ts-svelte-chat/src/routes/svelte-ui/Fallback.svelte b/examples/ts-svelte-chat/src/routes/svelte-ui/Fallback.svelte
new file mode 100644
index 0000000000..636744520d
--- /dev/null
+++ b/examples/ts-svelte-chat/src/routes/svelte-ui/Fallback.svelte
@@ -0,0 +1,7 @@
+
+
+{#if part.type === 'text'}
+ {part.content}
+{/if}
diff --git a/examples/ts-svelte-chat/src/routes/svelte-ui/Layout.svelte b/examples/ts-svelte-chat/src/routes/svelte-ui/Layout.svelte
new file mode 100644
index 0000000000..1dab0ceebb
--- /dev/null
+++ b/examples/ts-svelte-chat/src/routes/svelte-ui/Layout.svelte
@@ -0,0 +1,35 @@
+
+
+
+
+ {@render messages?.()}
+
+
+
diff --git a/examples/ts-svelte-chat/src/routes/svelte-ui/Message.svelte b/examples/ts-svelte-chat/src/routes/svelte-ui/Message.svelte
new file mode 100644
index 0000000000..d3dd175e52
--- /dev/null
+++ b/examples/ts-svelte-chat/src/routes/svelte-ui/Message.svelte
@@ -0,0 +1,15 @@
+
+
+
+ {@render parts?.()}
+
diff --git a/examples/ts-vue-chat/src/views/VueUIView.vue b/examples/ts-vue-chat/src/views/VueUIView.vue
index c45bb4b937..763de0b8ce 100644
--- a/examples/ts-vue-chat/src/views/VueUIView.vue
+++ b/examples/ts-vue-chat/src/views/VueUIView.vue
@@ -1,8 +1,8 @@
@@ -109,41 +179,7 @@ const connection = fetchServerSentEvents('/api/chat')
-
-
-
-
-
-
-
Welcome to the Vue UI Demo!
-
- This view uses
- @tanstack/ai-vue-ui
- components.
-
-
Send a message to get started.
-
-
-
-
-
-
-
-
-
+
diff --git a/kiira.config.ts b/kiira.config.ts
index b85ad1212a..ffb8cef276 100644
--- a/kiira.config.ts
+++ b/kiira.config.ts
@@ -74,7 +74,7 @@ export default defineConfig({
},
// Solid snippets compile JSX through solid-js.
{
- include: ['docs/api/ai-solid.md'],
+ include: ['docs/api/ai-solid.md', 'docs/ui/solid.md'],
jsx: 'preserve',
jsxImportSource: 'solid-js',
},
diff --git a/packages/ai-client/package.json b/packages/ai-client/package.json
index d8112524e9..010c941290 100644
--- a/packages/ai-client/package.json
+++ b/packages/ai-client/package.json
@@ -46,6 +46,10 @@
"./byok": {
"types": "./dist/esm/byok.d.ts",
"import": "./dist/esm/byok.js"
+ },
+ "./ui": {
+ "types": "./dist/esm/ui.d.ts",
+ "import": "./dist/esm/ui.js"
}
},
"files": [
diff --git a/packages/ai-client/src/ui.ts b/packages/ai-client/src/ui.ts
new file mode 100644
index 0000000000..c57654aeaf
--- /dev/null
+++ b/packages/ai-client/src/ui.ts
@@ -0,0 +1,30 @@
+export { partTypeToKey, selectChatUI } from './ui/selectors'
+export {
+ automaticPartsForMessage,
+ collectInlineToolNames,
+ getMappedComponent,
+ isInlineInterruptConfig,
+ resolveInterruptComponent,
+ selectMessageUI,
+} from './ui/selectors'
+export type {
+ ChatUIData,
+ ChatUIInterrupt,
+ ChatUIInterruptsOf,
+ ChatUIMessages,
+ ChatUIPartKey,
+ ChatUIRegisteredInterruptId,
+ ChatUISchemaOf,
+ ChatUISelectInput,
+ ChatUISelectedGenericPart,
+ ChatUISelectedMessage,
+ ChatUISelectedPart,
+ ChatUISelectedResultPart,
+ ChatUISelectedToolPart,
+ ChatUISelection,
+ ChatUIStructuredPart,
+ ChatUIToolName,
+ ChatUIToolPart,
+ ChatUIToolsOf,
+ RegisteredUIInterrupt,
+} from './ui/types'
diff --git a/packages/ai-client/src/ui/selectors.ts b/packages/ai-client/src/ui/selectors.ts
new file mode 100644
index 0000000000..5527548fce
--- /dev/null
+++ b/packages/ai-client/src/ui/selectors.ts
@@ -0,0 +1,200 @@
+import type {
+ MessagePart,
+ ToolApprovalInterrupt,
+ ToolCallPart,
+ ToolResultPart,
+ UIMessage,
+} from '../types'
+import type {
+ ChatUIInterrupt,
+ ChatUIPartKey,
+ ChatUISelectInput,
+ ChatUISelectedMessage,
+ ChatUISelectedPart,
+ ChatUISelection,
+} from './types'
+
+const PART_KEY_BY_TYPE: Record = {
+ text: 'text',
+ image: 'image',
+ audio: 'audio',
+ video: 'video',
+ document: 'document',
+ thinking: 'thinking',
+ 'tool-call': 'toolCall',
+ 'tool-result': 'toolResult',
+ 'structured-output': 'structuredOutput',
+ 'ui-resource': 'uiResource',
+}
+
+export function partTypeToKey(type: string): ChatUIPartKey | string {
+ const mapped = PART_KEY_BY_TYPE[type]
+ if (mapped) return mapped
+ return type.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase())
+}
+
+export function isInlineInterruptConfig(entry: unknown): boolean {
+ return (
+ typeof entry === 'object' &&
+ entry !== null &&
+ 'placement' in entry &&
+ (entry as { placement?: unknown }).placement === 'inline'
+ )
+}
+
+export function getMappedComponent(entry: unknown): unknown {
+ if (
+ typeof entry === 'object' &&
+ entry !== null &&
+ 'component' in entry &&
+ 'placement' in entry
+ ) {
+ return (entry as { component: unknown }).component
+ }
+ return entry
+}
+
+export function collectInlineToolNames(
+ toolInterrupts?: Record,
+): Array {
+ if (!toolInterrupts) return []
+ return Object.keys(toolInterrupts).filter((name) =>
+ isInlineInterruptConfig(toolInterrupts[name]),
+ )
+}
+
+export function resolveInterruptComponent(
+ interrupt: ChatUIInterrupt,
+ interruptsMap:
+ | {
+ tools?: Record
+ generic?: Record
+ }
+ | undefined,
+): unknown {
+ if (!interruptsMap) return undefined
+ if (interrupt.kind === 'tool-approval') {
+ return getMappedComponent(interruptsMap.tools?.[interrupt.toolName])
+ }
+ const generic = interruptsMap.generic
+ if (!generic) return undefined
+ const definitionId =
+ 'definitionId' in interrupt && typeof interrupt.definitionId === 'string'
+ ? interrupt.definitionId
+ : undefined
+ if (definitionId && definitionId !== 'fallback') {
+ const registered = generic[definitionId]
+ if (registered) return getMappedComponent(registered)
+ }
+ return generic.fallback
+}
+
+function isToolCallPart(part: MessagePart): part is ToolCallPart {
+ return part.type === 'tool-call'
+}
+
+function isToolResultPart(part: MessagePart): part is ToolResultPart {
+ return part.type === 'tool-result'
+}
+
+function isToolApproval(
+ interrupt: ChatUIInterrupt,
+): interrupt is ToolApprovalInterrupt {
+ return interrupt.kind === 'tool-approval'
+}
+
+export function selectChatUI(input: ChatUISelectInput): ChatUISelection {
+ const messages = input.messages
+ const interrupts = input.interrupts ?? []
+ const inlineToolNames = new Set(input.inlineToolNames ?? [])
+
+ const resultsByCallId = new Map()
+ const callIds = new Set()
+ for (const message of messages) {
+ for (const part of message.parts) {
+ if (isToolCallPart(part)) callIds.add(part.id)
+ if (isToolResultPart(part)) resultsByCallId.set(part.toolCallId, part)
+ }
+ }
+
+ const approvalByCallId = new Map()
+ for (const interrupt of interrupts) {
+ if (isToolApproval(interrupt)) {
+ approvalByCallId.set(interrupt.toolCallId, interrupt)
+ }
+ }
+
+ const selectedMessages: Array = messages.map(
+ (message) => ({
+ message,
+ parts: message.parts.map((part) =>
+ toSelectedPart(part, resultsByCallId, approvalByCallId, callIds),
+ ),
+ }),
+ )
+
+ return {
+ messages: selectedMessages,
+ interrupts: interrupts.filter((interrupt) => {
+ if (!isToolApproval(interrupt)) return true
+ if (!inlineToolNames.has(interrupt.toolName)) return true
+ return !callIds.has(interrupt.toolCallId)
+ }),
+ }
+}
+
+function toSelectedPart(
+ part: MessagePart,
+ resultsByCallId: Map,
+ approvalByCallId: Map,
+ callIds: Set,
+): ChatUISelectedPart {
+ if (isToolCallPart(part)) {
+ const result = resultsByCallId.get(part.id)
+ const interrupt = approvalByCallId.get(part.id)
+ return {
+ key: 'toolCall',
+ part,
+ ...(result ? { result } : {}),
+ ...(interrupt ? { interrupt } : {}),
+ input: part.input,
+ output: part.output,
+ }
+ }
+
+ if (isToolResultPart(part)) {
+ return {
+ key: 'toolResult',
+ part,
+ matched: callIds.has(part.toolCallId),
+ }
+ }
+
+ return {
+ key: partTypeToKey(part.type) as Exclude<
+ ChatUIPartKey,
+ 'toolCall' | 'toolResult'
+ >,
+ part,
+ }
+}
+
+export function automaticPartsForMessage(
+ selected: ChatUISelectedMessage,
+): Array {
+ return selected.parts.filter(
+ (part) => !(part.key === 'toolResult' && part.matched),
+ )
+}
+
+export function selectMessageUI(
+ message: UIMessage,
+ input: Omit,
+): ChatUISelectedMessage {
+ return (
+ selectChatUI({ ...input, messages: [message] }).messages[0] ?? {
+ message,
+ parts: [],
+ }
+ )
+}
diff --git a/packages/ai-client/src/ui/types.ts b/packages/ai-client/src/ui/types.ts
new file mode 100644
index 0000000000..026e71f564
--- /dev/null
+++ b/packages/ai-client/src/ui/types.ts
@@ -0,0 +1,156 @@
+import type {
+ AnyClientTool,
+ InferSchemaType,
+ InferToolInput,
+ InferToolOutput,
+ InterruptDefinition,
+ SchemaInput,
+} from '@tanstack/ai/client'
+import type {
+ ChatInterrupt,
+ MessagePart,
+ RegisteredGenericInterrupt,
+ StructuredOutputPart,
+ ToolApprovalInterrupt,
+ ToolCallPart,
+ ToolResultPart,
+ UIMessage,
+} from '../types'
+
+export type ChatUIPartKey =
+ | 'text'
+ | 'image'
+ | 'audio'
+ | 'video'
+ | 'document'
+ | 'thinking'
+ | 'toolCall'
+ | 'toolResult'
+ | 'structuredOutput'
+ | 'uiResource'
+
+export type ChatUIToolsOf = TOptions extends {
+ tools: infer TTools
+}
+ ? TTools extends ReadonlyArray
+ ? TTools
+ : ReadonlyArray
+ : ReadonlyArray
+
+export type ChatUIInterruptsOf = TOptions extends {
+ interrupts: infer TInterrupts
+}
+ ? TInterrupts extends ReadonlyArray>
+ ? TInterrupts
+ : readonly []
+ : readonly []
+
+export type ChatUISchemaOf = TOptions extends {
+ outputSchema: infer TSchema
+}
+ ? TSchema extends SchemaInput
+ ? TSchema
+ : undefined
+ : undefined
+
+export type ChatUIData = TOptions extends {
+ outputSchema: infer TSchema
+}
+ ? TSchema extends SchemaInput
+ ? InferSchemaType
+ : unknown
+ : unknown
+
+export type ChatUIToolName =
+ ChatUIToolsOf[number] extends infer TTool
+ ? TTool extends AnyClientTool
+ ? TTool['name']
+ : string
+ : string
+
+export type ChatUIRegisteredInterruptId =
+ ChatUIInterruptsOf[number] extends infer TDefinition
+ ? TDefinition extends InterruptDefinition
+ ? TId
+ : string
+ : string
+
+type ToolByName = Extract<
+ ChatUIToolsOf extends ReadonlyArray ? TTool : never,
+ { name: TName }
+>
+
+export type ChatUIInterrupt = ChatInterrupt | ToolApprovalInterrupt
+
+export type ChatUIToolPart<
+ TOptions,
+ TName extends ChatUIToolName = ChatUIToolName,
+> = {
+ key: 'toolCall'
+ part: Extract>, { name: TName }>
+ result?: ToolResultPart
+ interrupt?: Extract<
+ ChatInterrupt, ChatUIInterruptsOf>,
+ { kind: 'tool-approval'; toolName: TName }
+ >
+ input?: InferToolInput>
+ output?: InferToolOutput>
+}
+
+export type RegisteredUIInterrupt<
+ TOptions,
+ TId extends ChatUIRegisteredInterruptId =
+ ChatUIRegisteredInterruptId,
+> = Extract<
+ RegisteredGenericInterrupt>,
+ { definitionId: TId }
+>
+
+export type ChatUISelectedToolPart = {
+ key: 'toolCall'
+ part: ToolCallPart
+ result?: ToolResultPart
+ interrupt?: ChatUIInterrupt
+ input?: unknown
+ output?: unknown
+}
+
+export type ChatUISelectedResultPart = {
+ key: 'toolResult'
+ part: ToolResultPart
+ matched: boolean
+}
+
+export type ChatUISelectedGenericPart = {
+ key: Exclude
+ part: MessagePart
+}
+
+export type ChatUISelectedPart =
+ | ChatUISelectedToolPart
+ | ChatUISelectedResultPart
+ | ChatUISelectedGenericPart
+
+export type ChatUISelectedMessage = {
+ message: UIMessage
+ parts: Array
+}
+
+export type ChatUISelection = {
+ messages: Array
+ interrupts: Array
+}
+
+export type ChatUISelectInput = {
+ messages: ReadonlyArray
+ interrupts?: ReadonlyArray
+ inlineToolNames?: ReadonlyArray
+}
+
+export type ChatUIMessages = Array<
+ UIMessage, ChatUIData>
+>
+
+export type ChatUIStructuredPart = StructuredOutputPart<
+ ChatUIData
+>
diff --git a/packages/ai-client/tests/ui-fixtures.ts b/packages/ai-client/tests/ui-fixtures.ts
new file mode 100644
index 0000000000..19f833bc15
--- /dev/null
+++ b/packages/ai-client/tests/ui-fixtures.ts
@@ -0,0 +1,261 @@
+import { defineInterrupt, toolDefinition } from '@tanstack/ai/client'
+import { z } from 'zod'
+import type { ChatUIInterrupt } from '../src/ui'
+import type { UIMessage } from '../src/types'
+
+const getWeather = toolDefinition({
+ name: 'getWeather',
+ description: 'Look up weather',
+ inputSchema: z.object({ city: z.string() }),
+ outputSchema: z.object({ temperature: z.number() }),
+}).client()
+
+const purchaseItem = toolDefinition({
+ name: 'purchaseItem',
+ description: 'Purchase an item',
+ needsApproval: true,
+ inputSchema: z.object({ item: z.string() }),
+ outputSchema: z.object({ ok: z.boolean() }),
+}).client()
+
+const choosePlan = defineInterrupt({
+ id: 'choosePlan',
+ payloadSchema: z.object({ title: z.string() }),
+ responseSchema: z.string(),
+})
+
+const answerSchema = z.object({ answer: z.string() })
+
+const stubConnection = {
+ connect: async function* () {
+ return
+ },
+}
+
+export const chatOptions = {
+ connection: stubConnection,
+ tools: [getWeather, purchaseItem],
+ interrupts: [choosePlan],
+ outputSchema: answerSchema,
+}
+
+function noop(): void {
+ return
+}
+
+export const approvalInterrupt = {
+ kind: 'tool-approval',
+ id: 'approval-1',
+ interruptId: 'approval-1',
+ reason: 'tool_call',
+ message: 'Approve purchase',
+ threadId: 'thread-1',
+ interruptedRunId: 'run-1',
+ generation: 0,
+ status: 'pending',
+ errors: [],
+ canResolve: true,
+ toolName: 'purchaseItem',
+ toolCallId: 'call-weather',
+ originalArgs: { item: 'keyboard' },
+ binding: {
+ kind: 'tool-approval',
+ toolCallId: 'call-weather',
+ toolName: 'purchaseItem',
+ },
+ cancel: noop,
+ clearResolution: noop,
+ resolveInterrupt: noop,
+} as unknown as ChatUIInterrupt
+
+export const genericInterrupt = {
+ kind: 'generic',
+ id: 'generic-1',
+ interruptId: 'generic-1',
+ reason: 'choose-plan',
+ message: 'Choose a plan',
+ threadId: 'thread-1',
+ interruptedRunId: 'run-1',
+ generation: 0,
+ status: 'pending',
+ errors: [],
+ canResolve: true,
+ definitionId: 'choosePlan',
+ key: 'choosePlan',
+ payload: { title: 'Pro' },
+ binding: {
+ kind: 'generic',
+ definitionId: 'choosePlan',
+ key: 'choosePlan',
+ batchIndex: 0,
+ },
+ cancel: noop,
+ clearResolution: noop,
+ resolveInterrupt: noop,
+} as unknown as ChatUIInterrupt
+
+export const unboundInterrupt = {
+ kind: 'unbound',
+ id: 'unbound-1',
+ interruptId: 'unbound-1',
+ reason: 'foreign',
+ message: 'Owned elsewhere',
+ threadId: 'thread-1',
+ interruptedRunId: 'run-1',
+ generation: 0,
+ status: 'pending',
+ errors: [],
+ canResolve: false,
+} as unknown as ChatUIInterrupt
+
+export const messageWithToolResults: UIMessage = {
+ id: 'message-1',
+ role: 'assistant',
+ parts: [
+ {
+ type: 'tool-call',
+ id: 'call-weather',
+ name: 'getWeather',
+ arguments: '{"city":"Paris"}',
+ input: { city: 'Paris' },
+ output: { temperature: 18 },
+ state: 'complete',
+ },
+ {
+ type: 'tool-result',
+ toolCallId: 'call-weather',
+ content: '{"temperature":18}',
+ state: 'complete',
+ },
+ {
+ type: 'structured-output',
+ status: 'complete',
+ raw: '{"answer":"yes"}',
+ data: { answer: 'yes' },
+ },
+ ],
+}
+
+export const unknownToolMessage: UIMessage = {
+ id: 'message-unknown',
+ role: 'assistant',
+ parts: [
+ {
+ type: 'tool-call',
+ id: 'call-unknown',
+ name: 'notConfigured',
+ arguments: '{}',
+ state: 'complete',
+ },
+ ],
+}
+
+export const orphanResultMessage: UIMessage = {
+ id: 'message-orphan-result',
+ role: 'assistant',
+ parts: [
+ {
+ type: 'text',
+ content: 'result follows',
+ },
+ {
+ type: 'tool-result',
+ toolCallId: 'missing-call',
+ content: 'standalone',
+ state: 'complete',
+ },
+ ],
+}
+
+export const purchaseApprovalMessage: UIMessage = {
+ id: 'message-purchase',
+ role: 'assistant',
+ parts: [
+ {
+ type: 'tool-call',
+ id: 'call-purchase',
+ name: 'purchaseItem',
+ arguments: '{"item":"keyboard"}',
+ input: { item: 'keyboard' },
+ state: 'approval-requested',
+ approval: {
+ id: 'approval-purchase',
+ needsApproval: true,
+ },
+ },
+ ],
+}
+
+export const purchaseApprovalInterrupt = {
+ ...approvalInterrupt,
+ id: 'approval-purchase',
+ interruptId: 'approval-purchase',
+ toolCallId: 'call-purchase',
+ binding: {
+ kind: 'tool-approval',
+ toolCallId: 'call-purchase',
+ toolName: 'purchaseItem',
+ },
+} as unknown as ChatUIInterrupt
+
+export function createChatResult(init: {
+ messages?: Array
+ interrupts?: Array
+ error?: Error
+ isLoading?: boolean
+ status?: 'ready' | 'submitted' | 'streaming' | 'error'
+}) {
+ const interrupts = init.interrupts ?? []
+ return {
+ messages: init.messages ?? [],
+ interrupts,
+ pendingInterrupts: interrupts,
+ error: init.error,
+ isLoading: init.isLoading ?? false,
+ status: init.status ?? 'ready',
+ sendMessage: async () => undefined,
+ stop: noop,
+ reload: async () => undefined,
+ clear: noop,
+ setMessages: noop,
+ }
+}
+
+export function createSolidChatResult(
+ messages: Array,
+ interrupts: Array = [],
+) {
+ const chat = createChatResult({ messages, interrupts })
+ return {
+ ...chat,
+ messages: () => chat.messages,
+ interrupts: () => chat.interrupts,
+ pendingInterrupts: () => chat.pendingInterrupts,
+ error: () => chat.error,
+ isLoading: () => chat.isLoading,
+ status: () => chat.status,
+ }
+}
+
+export function createVueChatResult(
+ messages: Array,
+ interrupts: Array = [],
+) {
+ const chat = createChatResult({ messages, interrupts })
+ return {
+ ...chat,
+ messages: { value: chat.messages },
+ interrupts: { value: chat.interrupts },
+ pendingInterrupts: { value: chat.pendingInterrupts },
+ error: { value: chat.error },
+ isLoading: { value: chat.isLoading },
+ status: { value: chat.status },
+ }
+}
+
+export function createSvelteChatResult(
+ messages: Array,
+ interrupts: Array = [],
+) {
+ return createChatResult({ messages, interrupts })
+}
diff --git a/packages/ai-client/tests/ui-selectors.test.ts b/packages/ai-client/tests/ui-selectors.test.ts
new file mode 100644
index 0000000000..12e590e91e
--- /dev/null
+++ b/packages/ai-client/tests/ui-selectors.test.ts
@@ -0,0 +1,204 @@
+import { describe, expect, it } from 'vitest'
+import {
+ partTypeToKey,
+ resolveInterruptComponent,
+ selectChatUI,
+} from '../src/ui'
+import {
+ approvalInterrupt,
+ genericInterrupt,
+ messageWithToolResults,
+ orphanResultMessage,
+ purchaseApprovalInterrupt,
+ purchaseApprovalMessage,
+ unboundInterrupt,
+} from './ui-fixtures'
+
+describe('selectChatUI', () => {
+ it('matches results and partitions interrupts', () => {
+ const selected = selectChatUI({
+ messages: [messageWithToolResults],
+ interrupts: [approvalInterrupt, genericInterrupt],
+ inlineToolNames: ['purchaseItem'],
+ })
+
+ const parts = selected.messages[0]?.parts
+ expect(parts?.map((part) => part.key)).toEqual([
+ 'toolCall',
+ 'toolResult',
+ 'structuredOutput',
+ ])
+
+ const call = parts?.[0]
+ expect(call?.key).toBe('toolCall')
+ if (call?.key === 'toolCall') {
+ expect(call.result?.toolCallId).toBe(call.part.id)
+ expect(call.interrupt).toBe(approvalInterrupt)
+ }
+
+ expect(selected.interrupts).toEqual([genericInterrupt])
+ })
+
+ it('uses camel-case component keys', () => {
+ expect(partTypeToKey('tool-call')).toBe('toolCall')
+ expect(partTypeToKey('tool-result')).toBe('toolResult')
+ expect(partTypeToKey('structured-output')).toBe('structuredOutput')
+ expect(partTypeToKey('ui-resource')).toBe('uiResource')
+ })
+
+ it('keeps unmatched tool-result parts', () => {
+ const selected = selectChatUI({
+ messages: [orphanResultMessage],
+ interrupts: [],
+ })
+ const resultPart = selected.messages[0]?.parts[1]
+ expect(resultPart?.key).toBe('toolResult')
+ if (resultPart?.key === 'toolResult') {
+ expect(resultPart.matched).toBe(false)
+ expect(resultPart.part.toolCallId).toBe('missing-call')
+ }
+ })
+
+ it('keeps orphan approvals in the interrupt list', () => {
+ const selected = selectChatUI({
+ messages: [messageWithToolResults],
+ interrupts: [purchaseApprovalInterrupt],
+ inlineToolNames: ['purchaseItem'],
+ })
+ expect(selected.interrupts).toEqual([purchaseApprovalInterrupt])
+ })
+
+ it('attaches list-placement approvals to the call and keeps them in the list', () => {
+ const selected = selectChatUI({
+ messages: [purchaseApprovalMessage],
+ interrupts: [purchaseApprovalInterrupt],
+ inlineToolNames: [],
+ })
+ const call = selected.messages[0]?.parts[0]
+ expect(call?.key).toBe('toolCall')
+ if (call?.key === 'toolCall') {
+ expect(call.interrupt).toBe(purchaseApprovalInterrupt)
+ }
+ expect(selected.interrupts).toEqual([purchaseApprovalInterrupt])
+ })
+
+ it('hides inline approvals from the interrupt list when a call matches', () => {
+ const selected = selectChatUI({
+ messages: [purchaseApprovalMessage],
+ interrupts: [purchaseApprovalInterrupt, genericInterrupt],
+ inlineToolNames: ['purchaseItem'],
+ })
+ expect(selected.interrupts).toEqual([genericInterrupt])
+ })
+
+ it('preserves empty arrays', () => {
+ const selected = selectChatUI({ messages: [], interrupts: [] })
+ expect(selected.messages).toEqual([])
+ expect(selected.interrupts).toEqual([])
+ })
+
+ it('preserves message and part order across every part key', () => {
+ const selected = selectChatUI({
+ messages: [
+ {
+ id: 'ordered',
+ role: 'assistant',
+ parts: [
+ { type: 'thinking', content: 'hmm' },
+ { type: 'text', content: 'hi' },
+ {
+ type: 'image',
+ source: { type: 'url', value: 'https://example.com/a.png' },
+ },
+ {
+ type: 'audio',
+ source: { type: 'url', value: 'https://example.com/a.wav' },
+ },
+ {
+ type: 'video',
+ source: { type: 'url', value: 'https://example.com/a.mp4' },
+ },
+ {
+ type: 'document',
+ source: { type: 'url', value: 'https://example.com/a.pdf' },
+ },
+ {
+ type: 'tool-call',
+ id: 'call-a',
+ name: 'getWeather',
+ arguments: '{}',
+ state: 'awaiting-input',
+ },
+ {
+ type: 'tool-result',
+ toolCallId: 'other',
+ content: 'orphan',
+ state: 'complete',
+ },
+ {
+ type: 'structured-output',
+ status: 'streaming',
+ raw: '{',
+ },
+ {
+ type: 'ui-resource',
+ resource: {
+ uri: 'ui://widget',
+ mimeType: 'text/html',
+ text: '
',
+ },
+ toolCallId: 'call-a',
+ toolName: 'getWeather',
+ },
+ ],
+ },
+ ],
+ })
+
+ expect(selected.messages[0]?.parts.map((part) => part.key)).toEqual([
+ 'thinking',
+ 'text',
+ 'image',
+ 'audio',
+ 'video',
+ 'document',
+ 'toolCall',
+ 'toolResult',
+ 'structuredOutput',
+ 'uiResource',
+ ])
+ })
+
+ it('keeps unbound and registered generic interrupts in the list', () => {
+ const selected = selectChatUI({
+ messages: [],
+ interrupts: [genericInterrupt, unboundInterrupt],
+ })
+ expect(selected.interrupts).toEqual([genericInterrupt, unboundInterrupt])
+ })
+})
+
+describe('resolveInterruptComponent', () => {
+ const Plan = { id: 'plan' }
+ const Fallback = { id: 'fallback' }
+ const Purchase = { id: 'purchase' }
+ const map = {
+ tools: { purchaseItem: Purchase },
+ generic: {
+ choosePlan: Plan,
+ fallback: Fallback,
+ },
+ }
+
+ it('resolves a registered generic interrupt from generic[id]', () => {
+ expect(resolveInterruptComponent(genericInterrupt, map)).toBe(Plan)
+ })
+
+ it('resolves unbound through generic.fallback', () => {
+ expect(resolveInterruptComponent(unboundInterrupt, map)).toBe(Fallback)
+ })
+
+ it('resolves a tool approval from tools[name]', () => {
+ expect(resolveInterruptComponent(approvalInterrupt, map)).toBe(Purchase)
+ })
+})
diff --git a/packages/ai-client/tests/ui-types.test-d.ts b/packages/ai-client/tests/ui-types.test-d.ts
new file mode 100644
index 0000000000..b8de8adb42
--- /dev/null
+++ b/packages/ai-client/tests/ui-types.test-d.ts
@@ -0,0 +1,23 @@
+import { expectTypeOf } from 'vitest'
+import type {
+ ChatUIData,
+ ChatUIToolPart,
+ RegisteredUIInterrupt,
+} from '../src/ui'
+import { chatOptions } from './ui-fixtures'
+
+type WeatherPart = ChatUIToolPart
+type PlanInterrupt = RegisteredUIInterrupt
+
+expectTypeOf().toEqualTypeOf<
+ { city: string } | undefined
+>()
+expectTypeOf().toEqualTypeOf<
+ { temperature: number } | undefined
+>()
+expectTypeOf().toEqualTypeOf<
+ { title: string } | undefined
+>()
+expectTypeOf>().toEqualTypeOf<{
+ answer: string
+}>()
diff --git a/packages/ai-client/vite.config.ts b/packages/ai-client/vite.config.ts
index 40c5f49c43..2e3210f99b 100644
--- a/packages/ai-client/vite.config.ts
+++ b/packages/ai-client/vite.config.ts
@@ -40,7 +40,12 @@ export default mergeConfig(
// implementations; declare it as its own entry so the build emits
// it independently and the main entry can stay free of the bridge
// classes (they're imported only via `import type` from clients).
- entry: ['./src/index.ts', './src/devtools.ts', './src/byok.ts'],
+ entry: [
+ './src/index.ts',
+ './src/devtools.ts',
+ './src/byok.ts',
+ './src/ui.ts',
+ ],
srcDir: './src',
cjs: false,
}),
diff --git a/packages/ai-react-ui/src/chat-input.tsx b/packages/ai-react-ui/src/chat-input.tsx
index d90cdcd5a9..f20e9e989b 100644
--- a/packages/ai-react-ui/src/chat-input.tsx
+++ b/packages/ai-react-ui/src/chat-input.tsx
@@ -31,6 +31,9 @@ export interface ChatInputProps {
}
/**
+ * @deprecated Use `createUI()` and an application-owned input component.
+ * Deprecated in 0.9.0. Removed in 1.0.0.
+ *
* Chat input component - handles message input and submission
*
* Features:
diff --git a/packages/ai-react-ui/src/chat-message.tsx b/packages/ai-react-ui/src/chat-message.tsx
index 7665b1ba06..83ce913149 100644
--- a/packages/ai-react-ui/src/chat-message.tsx
+++ b/packages/ai-react-ui/src/chat-message.tsx
@@ -41,6 +41,8 @@ export interface ChatMessageProps {
}
/**
+ * @deprecated Use `createUI()` Message instead. Deprecated in 0.9.0. Removed in 1.0.0.
+ *
* Message component - renders a single message with all its parts
*
* This component natively understands TanStack AI's parts-based message format:
diff --git a/packages/ai-react-ui/src/chat-messages.tsx b/packages/ai-react-ui/src/chat-messages.tsx
index 2cdf27b391..804abb34f8 100644
--- a/packages/ai-react-ui/src/chat-messages.tsx
+++ b/packages/ai-react-ui/src/chat-messages.tsx
@@ -4,6 +4,7 @@ import { ChatMessage } from './chat-message'
import type { ReactNode } from 'react'
import type { UIMessage } from '@tanstack/ai-react'
+/** @deprecated Use `createUI()` Messages instead. Deprecated in 0.9.0. Removed in 1.0.0. */
export interface ChatMessagesProps {
/** Custom render function for each message */
children?: (message: UIMessage, index: number) => ReactNode
@@ -23,6 +24,8 @@ export interface ChatMessagesProps {
}
/**
+ * @deprecated Use `createUI()` Messages instead. Deprecated in 0.9.0. Removed in 1.0.0.
+ *
* Messages container - renders all messages in the conversation
*
* @example
diff --git a/packages/ai-react-ui/src/chat.tsx b/packages/ai-react-ui/src/chat.tsx
index 09b00572ff..c0f20791f6 100644
--- a/packages/ai-react-ui/src/chat.tsx
+++ b/packages/ai-react-ui/src/chat.tsx
@@ -16,6 +16,7 @@ const ChatContext = createContext(null)
* Hook to access chat context
* @throws Error if used outside of Chat component
*/
+/** @deprecated Use `createUI().useChat()` instead. Deprecated in 0.9.0. Removed in 1.0.0. */
export function useChatContext() {
const context = useContext(ChatContext)
if (!context) {
@@ -26,6 +27,7 @@ export function useChatContext() {
return context
}
+/** @deprecated Use `createUI()` Chat/Provider instead. Deprecated in 0.9.0. Removed in 1.0.0. */
export interface ChatProps {
/** Child components (Chat.Messages, Chat.Input, etc.) */
children: ReactNode
@@ -52,6 +54,10 @@ export interface ChatProps {
}
/**
+ * @deprecated Use `createUI()` from `@tanstack/ai-react-ui` instead.
+ * See https://tanstack.com/ai/latest/docs/migration/create-ui
+ * Deprecated in 0.9.0. Removed in 1.0.0.
+ *
* Root Chat component - provides context for all chat subcomponents
*
* @example
diff --git a/packages/ai-react-ui/src/create-ui.tsx b/packages/ai-react-ui/src/create-ui.tsx
new file mode 100644
index 0000000000..33813ba6e1
--- /dev/null
+++ b/packages/ai-react-ui/src/create-ui.tsx
@@ -0,0 +1,401 @@
+import { createContext, useContext } from 'react'
+import type { ComponentType, ReactNode } from 'react'
+import {
+ automaticPartsForMessage,
+ collectInlineToolNames,
+ resolveInterruptComponent,
+ selectChatUI,
+ selectMessageUI,
+} from '@tanstack/ai-client/ui'
+import type {
+ ChatUIData,
+ ChatUIInterrupt,
+ ChatUIPartKey,
+ ChatUIRegisteredInterruptId,
+ ChatUISelectedPart,
+ ChatUIToolName,
+ ChatUIToolsOf,
+ RegisteredUIInterrupt,
+} from '@tanstack/ai-client/ui'
+import type {
+ MessagePart,
+ ToolCallPart,
+ ToolResultPart,
+ UIMessage,
+} from '@tanstack/ai-client'
+
+// ponytail: duck-typed so UseChatReturn assigns; UIMessage generics are invariant
+export type ChatUIHost = {
+ messages: ReadonlyArray
+ interrupts?: ReadonlyArray
+ error?: Error
+ isLoading?: boolean
+ status?: string
+ sendMessage?: (content: string, ...args: Array) => Promise | void
+}
+
+export type LayoutProps = {
+ chat: ChatUIHost
+ renderMessages: () => ReactNode
+ renderInterrupts: () => ReactNode
+ renderInput: () => ReactNode
+ readonly __ui?: TOptions
+}
+
+export type MessageProps = {
+ chat: ChatUIHost
+ message: UIMessage, ChatUIData>
+ renderParts: () => ReactNode
+}
+
+export type InputProps = {
+ chat: ChatUIHost
+ readonly __ui?: TOptions
+}
+
+export type PartProps = {
+ chat: ChatUIHost
+ part: MessagePart, ChatUIData>
+}
+
+export type ToolProps<
+ TOptions,
+ TName extends ChatUIToolName = ChatUIToolName,
+> = {
+ chat: ChatUIHost
+ part: Extract>, { name: TName }>
+ result?: ToolResultPart
+ interrupt?: Extract<
+ ChatUIInterrupt,
+ { kind: 'tool-approval'; toolName: TName }
+ >
+ renderInterrupt: () => ReactNode
+}
+
+export type InterruptProps = {
+ chat: ChatUIHost
+ interrupt: ChatUIInterrupt
+ readonly __ui?: TOptions
+}
+
+export type RegisteredInterruptProps<
+ TOptions,
+ TId extends ChatUIRegisteredInterruptId =
+ ChatUIRegisteredInterruptId,
+> = {
+ chat: ChatUIHost
+ interrupt: RegisteredUIInterrupt
+}
+
+type InterruptEntry =
+ | ComponentType>
+ | {
+ component: ComponentType>
+ placement?: 'inline' | 'list'
+ }
+
+type GenericInterruptComponents = {
+ [K in ChatUIRegisteredInterruptId as K extends 'fallback'
+ ? never
+ : K]?: ComponentType>
+} & {
+ fallback?: ComponentType>
+}
+
+export type ChatUIComponents = {
+ layout: ComponentType>
+ message: ComponentType>
+ input?: ComponentType>
+ parts: {
+ [K in ChatUIPartKey]?: ComponentType>
+ } & {
+ fallback?: ComponentType>
+ }
+ tools?: {
+ [K in ChatUIToolName]?: ComponentType>
+ }
+ interrupts?: {
+ tools?: {
+ [K in ChatUIToolName]?: InterruptEntry
+ }
+ generic?: GenericInterruptComponents
+ }
+}
+
+type UIContextValue = {
+ chat: ChatUIHost
+ components: ChatUIComponents
+ warn: (key: string, message: string) => void
+}
+
+function createWarnOnce() {
+ const seen = new Set()
+ return (key: string, message: string) => {
+ if (process.env.NODE_ENV === 'production') return
+ if (seen.has(key)) return
+ seen.add(key)
+ console.warn(message)
+ }
+}
+
+function readInterrupts(chat: ChatUIHost) {
+ return chat.interrupts ?? []
+}
+
+export function createUI(options: TOptions) {
+ void options
+ const warn = createWarnOnce()
+ const UIContext = createContext | null>(null)
+
+ function useUIContext() {
+ const value = useContext(UIContext)
+ if (!value) {
+ throw new Error(
+ 'Chat UI components must be wrapped in UI.Provider or UI.Chat.',
+ )
+ }
+ return value
+ }
+
+ function useChat() {
+ return useUIContext().chat
+ }
+
+ function defineComponents(
+ components: ChatUIComponents,
+ ): ChatUIComponents {
+ return components
+ }
+
+ function inlineNames(components: ChatUIComponents) {
+ return collectInlineToolNames(
+ components.interrupts?.tools as Record | undefined,
+ )
+ }
+
+ function Provider({
+ chat,
+ components,
+ children,
+ }: {
+ chat: ChatUIHost
+ components: ChatUIComponents
+ children?: ReactNode
+ }) {
+ return (
+
+ {children}
+
+ )
+ }
+
+ function Chat({
+ chat,
+ components,
+ }: {
+ chat: ChatUIHost
+ components: ChatUIComponents
+ }) {
+ const Layout = components.layout
+ return (
+
+ }
+ renderInterrupts={() => }
+ renderInput={() => {
+ const Input = components.input
+ return Input ? : null
+ }}
+ />
+
+ )
+ }
+
+ function Messages({
+ children,
+ }: {
+ children?: (messages: ChatUIHost['messages']) => ReactNode
+ } = {}) {
+ const { chat } = useUIContext()
+ if (children) return <>{children(chat.messages)}>
+ return (
+ <>
+ {chat.messages.map((message) => (
+
+ ))}
+ >
+ )
+ }
+
+ function Message({
+ message,
+ children,
+ }: {
+ message: ChatUIHost['messages'][number]
+ children?: (parts: Array) => ReactNode
+ }) {
+ const { chat, components } = useUIContext()
+ const inlineToolNames = inlineNames(components)
+ const selected = selectMessageUI(message, {
+ interrupts: readInterrupts(chat),
+ inlineToolNames,
+ })
+ if (children) return <>{children(selected.parts)}>
+ const MessageComponent = components.message
+ return (
+ }
+ />
+ )
+ }
+
+ function AutomaticParts({
+ message,
+ }: {
+ message: ChatUIHost['messages'][number]
+ }) {
+ const { chat, components } = useUIContext()
+ const inlineToolNames = inlineNames(components)
+ const selected = selectMessageUI(message, {
+ interrupts: readInterrupts(chat),
+ inlineToolNames,
+ })
+ return (
+ <>
+ {automaticPartsForMessage(selected).map((part, index) => (
+
+ ))}
+ >
+ )
+ }
+
+ function SelectedPartView({
+ selected,
+ inline,
+ }: {
+ selected: ChatUISelectedPart
+ inline: boolean
+ }) {
+ const { chat, components, warn: warnMissing } = useUIContext()
+ if (selected.key === 'toolCall') {
+ const name = selected.part.name
+ const Tool = components.tools?.[name as ChatUIToolName] as
+ | ComponentType>
+ | undefined
+ if (!Tool) {
+ warnMissing(
+ `tool:${name}`,
+ `[tanstack-ai-ui] Missing tools.${name} component`,
+ )
+ return null
+ }
+ return (
+ ['part']}
+ result={selected.result}
+ interrupt={selected.interrupt as ToolProps['interrupt']}
+ renderInterrupt={() =>
+ inline ? : null
+ }
+ />
+ )
+ }
+
+ const PartComponent =
+ components.parts[selected.key] ?? components.parts.fallback
+ if (!PartComponent) {
+ warnMissing(
+ `part:${selected.key}`,
+ `[tanstack-ai-ui] Missing parts.${selected.key} component`,
+ )
+ return null
+ }
+ return (
+ ['part']}
+ />
+ )
+ }
+
+ function InlineInterrupt({ interrupt }: { interrupt?: ChatUIInterrupt }) {
+ const { chat, components } = useUIContext()
+ if (!interrupt || interrupt.kind !== 'tool-approval') return null
+ const inlineToolNames = inlineNames(components)
+ if (!inlineToolNames.includes(interrupt.toolName)) return null
+ const Component = resolveInterruptComponent(
+ interrupt,
+ components.interrupts,
+ ) as ComponentType> | undefined
+ if (!Component) return null
+ return
+ }
+
+ function Part({ part }: { part: MessagePart }) {
+ const { chat } = useUIContext()
+ const selected = selectMessageUI(
+ { id: 'part', role: 'assistant', parts: [part] },
+ { interrupts: readInterrupts(chat), inlineToolNames: [] },
+ ).parts[0]
+ if (!selected) return null
+ return
+ }
+
+ function Interrupts({
+ children,
+ }: {
+ children?: (interrupts: ReadonlyArray) => ReactNode
+ } = {}) {
+ const { chat, components } = useUIContext()
+ const inlineToolNames = inlineNames(components)
+ const selected = selectChatUI({
+ messages: chat.messages,
+ interrupts: readInterrupts(chat),
+ inlineToolNames,
+ })
+ if (children) return <>{children(selected.interrupts)}>
+ return (
+ <>
+ {selected.interrupts.map((interrupt) => (
+
+ ))}
+ >
+ )
+ }
+
+ function Interrupt({ interrupt }: { interrupt: ChatUIInterrupt }) {
+ const { chat, components, warn: warnMissing } = useUIContext()
+ const Component = resolveInterruptComponent(
+ interrupt,
+ components.interrupts,
+ ) as ComponentType> | undefined
+ if (!Component) {
+ warnMissing(
+ `interrupt:${interrupt.id}`,
+ `[tanstack-ai-ui] Missing interrupt component for ${interrupt.kind}`,
+ )
+ return null
+ }
+ return
+ }
+
+ return {
+ Chat,
+ Provider,
+ Messages,
+ Message,
+ Part,
+ Interrupts,
+ Interrupt,
+ defineComponents,
+ useChat,
+ }
+}
diff --git a/packages/ai-react-ui/src/index.ts b/packages/ai-react-ui/src/index.ts
index eec1ce48e4..b2e71e47b6 100644
--- a/packages/ai-react-ui/src/index.ts
+++ b/packages/ai-react-ui/src/index.ts
@@ -24,6 +24,19 @@
* ```
*/
+export {
+ createUI,
+ type ChatUIComponents,
+ type ChatUIHost,
+ type InputProps,
+ type InterruptProps,
+ type LayoutProps,
+ type MessageProps,
+ type PartProps,
+ type RegisteredInterruptProps,
+ type ToolProps,
+} from './create-ui'
+
// Main components
export { Chat, useChatContext, type ChatProps } from './chat'
export { ChatMessages, type ChatMessagesProps } from './chat-messages'
diff --git a/packages/ai-react-ui/src/tool-approval.tsx b/packages/ai-react-ui/src/tool-approval.tsx
index 2c0707aedf..c079ae8aa4 100644
--- a/packages/ai-react-ui/src/tool-approval.tsx
+++ b/packages/ai-react-ui/src/tool-approval.tsx
@@ -36,6 +36,9 @@ export interface ToolApprovalRenderProps {
}
/**
+ * @deprecated Use `createUI()` interrupt components with `chat.interrupts`.
+ * Deprecated in 0.9.0. Removed in 1.0.0.
+ *
* Tool approval component - renders approve/deny buttons for tools that need approval
*
* @example
diff --git a/packages/ai-react-ui/tests/coexistence.test.ts b/packages/ai-react-ui/tests/coexistence.test.ts
new file mode 100644
index 0000000000..7391e81b02
--- /dev/null
+++ b/packages/ai-react-ui/tests/coexistence.test.ts
@@ -0,0 +1,22 @@
+import { describe, expect, it } from 'vitest'
+import {
+ Chat,
+ ChatInput,
+ ChatMessage,
+ ChatMessages,
+ TextPart,
+ ThinkingPart,
+ createUI,
+} from '../src'
+
+describe('public coexistence', () => {
+ it('exports old and new APIs before 1.0', () => {
+ expect(Chat).toBeDefined()
+ expect(ChatInput).toBeDefined()
+ expect(ChatMessage).toBeDefined()
+ expect(ChatMessages).toBeDefined()
+ expect(TextPart).toBeDefined()
+ expect(ThinkingPart).toBeDefined()
+ expect(createUI).toBeDefined()
+ })
+})
diff --git a/packages/ai-react-ui/tests/create-ui-types.test.tsx b/packages/ai-react-ui/tests/create-ui-types.test.tsx
new file mode 100644
index 0000000000..036d2b2369
--- /dev/null
+++ b/packages/ai-react-ui/tests/create-ui-types.test.tsx
@@ -0,0 +1,57 @@
+import { expectTypeOf, it } from 'vitest'
+import { createUI } from '../src/create-ui'
+import type { RegisteredInterruptProps, ToolProps } from '../src/create-ui'
+import { chatOptions } from '../../ai-client/tests/ui-fixtures'
+
+it('types tool and interrupt component props from chatOptions', () => {
+ type WeatherToolProps = ToolProps
+ expectTypeOf().toEqualTypeOf<
+ { city: string } | undefined
+ >()
+
+ type ChoosePlanProps = RegisteredInterruptProps<
+ typeof chatOptions,
+ 'choosePlan'
+ >
+ expectTypeOf().toEqualTypeOf<
+ { title: string } | undefined
+ >()
+
+ const UI = createUI(chatOptions)
+
+ UI.defineComponents({
+ layout: ({ chat, renderMessages }) => {
+ expectTypeOf(chat.messages).toMatchTypeOf>()
+ return renderMessages()
+ },
+ message: ({ renderParts }) => renderParts(),
+ parts: {
+ fallback: () => null,
+ },
+ tools: {
+ getWeather: ({ part, result }) => {
+ expectTypeOf(part.input).toEqualTypeOf<{ city: string } | undefined>()
+ expectTypeOf(part.output).toEqualTypeOf<
+ { temperature: number } | undefined
+ >()
+ expectTypeOf(result?.toolCallId).toEqualTypeOf()
+ return null
+ },
+ // @ts-expect-error This tool is not in chatOptions.
+ unknownTool: () => null,
+ },
+ interrupts: {
+ generic: {
+ choosePlan: ({ interrupt }) => {
+ interrupt.resolveInterrupt('approved')
+ // @ts-expect-error The response schema accepts a string.
+ interrupt.resolveInterrupt(42)
+ return null
+ },
+ // @ts-expect-error This interrupt is not in chatOptions.
+ unknownInterrupt: () => null,
+ fallback: () => null,
+ },
+ },
+ })
+})
diff --git a/packages/ai-react-ui/tests/create-ui.test.tsx b/packages/ai-react-ui/tests/create-ui.test.tsx
new file mode 100644
index 0000000000..1fb766d64a
--- /dev/null
+++ b/packages/ai-react-ui/tests/create-ui.test.tsx
@@ -0,0 +1,310 @@
+import { renderToStaticMarkup } from 'react-dom/server'
+import { describe, expect, it, vi } from 'vitest'
+import { createUI } from '../src/create-ui'
+import {
+ chatOptions,
+ createChatResult,
+ genericInterrupt,
+ messageWithToolResults,
+ orphanResultMessage,
+ purchaseApprovalInterrupt,
+ purchaseApprovalMessage,
+ unboundInterrupt,
+ unknownToolMessage,
+} from '../../ai-client/tests/ui-fixtures'
+import type { ToolCallState } from '@tanstack/ai-client'
+
+describe('createUI', () => {
+ it('renders automatic and manual trees', () => {
+ const UI = createUI(chatOptions)
+ const components = UI.defineComponents({
+ layout: ({ renderMessages, renderInterrupts }) => (
+ <>
+ {renderMessages()}
+ {renderInterrupts()}
+ >
+ ),
+ message: ({ renderParts }) => {renderParts()} ,
+ parts: { fallback: ({ part }) => {part.type} },
+ tools: {
+ getWeather: ({ part }) => {part.input?.city} ,
+ purchaseItem: () => null,
+ },
+ interrupts: {
+ generic: { fallback: ({ interrupt }) => {interrupt.reason} },
+ },
+ })
+
+ const chat = createChatResult({ messages: [messageWithToolResults] })
+ const automatic = renderToStaticMarkup(
+ ,
+ )
+ expect(automatic).toContain('Paris ')
+
+ const manual = renderToStaticMarkup(
+
+
+ {(messages) => {messages.length} }
+
+ ,
+ )
+ expect(manual).toContain('1 ')
+ })
+
+ it('warns once for a missing runtime key', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const UI = createUI(chatOptions)
+ const chat = createChatResult({ messages: [unknownToolMessage] })
+ const components = UI.defineComponents({
+ layout: ({ renderMessages }) => renderMessages(),
+ message: ({ renderParts }) => renderParts(),
+ parts: { fallback: () => null },
+ tools: {
+ getWeather: () => null,
+ purchaseItem: () => null,
+ },
+ interrupts: { generic: { fallback: () => null } },
+ })
+ renderToStaticMarkup( )
+ renderToStaticMarkup( )
+ expect(warn).toHaveBeenCalledTimes(1)
+ warn.mockRestore()
+ })
+
+ it('keeps unmatched tool results and suppresses matched ones', () => {
+ const UI = createUI(chatOptions)
+ const components = UI.defineComponents({
+ layout: ({ renderMessages }) => renderMessages(),
+ message: ({ renderParts }) => renderParts(),
+ parts: {
+ toolResult: ({ part }) =>
+ part.type === 'tool-result' ? {String(part.content)} : null,
+ fallback: () => null,
+ },
+ tools: {
+ getWeather: () => weather ,
+ purchaseItem: () => null,
+ },
+ interrupts: { generic: { fallback: () => null } },
+ })
+
+ const matched = renderToStaticMarkup(
+ ,
+ )
+ expect(matched).toContain('weather ')
+ expect(matched).not.toContain('')
+
+ const unmatched = renderToStaticMarkup(
+ ,
+ )
+ expect(unmatched).toContain('standalone ')
+ })
+
+ it('puts inline approvals in the tool slot and list approvals in Interrupts', () => {
+ const UI = createUI(chatOptions)
+ const inline = UI.defineComponents({
+ layout: ({ renderMessages, renderInterrupts }) => (
+ <>
+ {renderMessages()}
+ {renderInterrupts()}
+ >
+ ),
+ message: ({ renderParts }) => renderParts(),
+ parts: { fallback: () => null },
+ tools: {
+ getWeather: () => null,
+ purchaseItem: ({ renderInterrupt }) => (
+
+ tool
+ {renderInterrupt()}
+
+ ),
+ },
+ interrupts: {
+ tools: {
+ purchaseItem: {
+ component: () => inline-approval ,
+ placement: 'inline',
+ },
+ },
+ generic: { fallback: ({ interrupt }) => {interrupt.reason} },
+ },
+ })
+
+ const inlineMarkup = renderToStaticMarkup(
+ ,
+ )
+ expect(inlineMarkup).toContain('inline-approval')
+ expect(inlineMarkup).not.toContain('')
+
+ const list = UI.defineComponents({
+ layout: ({ renderMessages, renderInterrupts }) => (
+ <>
+ {renderMessages()}
+ {renderInterrupts()}
+ >
+ ),
+ message: ({ renderParts }) => renderParts(),
+ parts: { fallback: () => null },
+ tools: {
+ getWeather: () => null,
+ purchaseItem: ({ renderInterrupt }) => (
+
+ tool
+ {renderInterrupt()}
+
+ ),
+ },
+ interrupts: {
+ tools: {
+ purchaseItem: () => list-approval ,
+ },
+ generic: { fallback: () => null },
+ },
+ })
+
+ const listMarkup = renderToStaticMarkup(
+ ,
+ )
+ expect(listMarkup).toContain('list-approval')
+ })
+
+ it('renders registered generic interrupts and sends the rest to fallback', () => {
+ const UI = createUI(chatOptions)
+ const components = UI.defineComponents({
+ layout: ({ renderInterrupts }) => renderInterrupts(),
+ message: ({ renderParts }) => renderParts(),
+ parts: { fallback: () => null },
+ tools: {
+ getWeather: () => null,
+ purchaseItem: () => null,
+ },
+ interrupts: {
+ generic: {
+ choosePlan: () => plan ,
+ fallback: () => fallback ,
+ },
+ },
+ })
+
+ const markup = renderToStaticMarkup(
+ ,
+ )
+ expect(markup).toContain('plan')
+ expect(markup).toContain('fallback')
+ })
+
+ it('omits input when no input component exists', () => {
+ const UI = createUI(chatOptions)
+ const components = UI.defineComponents({
+ layout: ({ renderInput }) => {renderInput()} ,
+ message: ({ renderParts }) => renderParts(),
+ parts: { fallback: () => null },
+ })
+ const markup = renderToStaticMarkup(
+ ,
+ )
+ expect(markup).toBe(' ')
+ })
+
+ it('reads chat from nested provider context and throws outside a provider', () => {
+ const UI = createUI(chatOptions)
+ const components = UI.defineComponents({
+ layout: () => {
+ const chat = UI.useChat()
+ return {chat.messages.length}
+ },
+ message: ({ renderParts }) => renderParts(),
+ parts: { fallback: () => null },
+ })
+
+ const inner = createChatResult({ messages: [messageWithToolResults] })
+ const outer = createChatResult({ messages: [] })
+ const markup = renderToStaticMarkup(
+
+
+ ,
+ )
+ expect(markup).toContain('1
')
+
+ function Broken() {
+ UI.useChat()
+ return null
+ }
+ expect(() => renderToStaticMarkup( )).toThrow(
+ /UI.Provider or UI.Chat/,
+ )
+ })
+
+ it('renders a tool component for every ToolCallState', () => {
+ const states: Array = [
+ 'awaiting-input',
+ 'input-streaming',
+ 'input-complete',
+ 'approval-requested',
+ 'approval-responded',
+ 'complete',
+ 'error',
+ ]
+ const UI = createUI(chatOptions)
+ const components = UI.defineComponents({
+ layout: ({ renderMessages }) => renderMessages(),
+ message: ({ renderParts }) => renderParts(),
+ parts: { fallback: () => null },
+ tools: {
+ getWeather: ({ part }) => {part.state} ,
+ purchaseItem: () => null,
+ },
+ interrupts: { generic: { fallback: () => null } },
+ })
+
+ for (const state of states) {
+ const markup = renderToStaticMarkup(
+ ,
+ )
+ expect(markup).toContain(`${state} `)
+ }
+ })
+})
diff --git a/packages/ai-react-ui/tests/react-dom-server.d.ts b/packages/ai-react-ui/tests/react-dom-server.d.ts
new file mode 100644
index 0000000000..df9bff243d
--- /dev/null
+++ b/packages/ai-react-ui/tests/react-dom-server.d.ts
@@ -0,0 +1,4 @@
+declare module 'react-dom/server' {
+ import type { ReactNode } from 'react'
+ export function renderToStaticMarkup(node: ReactNode): string
+}
diff --git a/packages/ai-react-ui/tsconfig.json b/packages/ai-react-ui/tsconfig.json
index 65915cd005..d3e610bff5 100644
--- a/packages/ai-react-ui/tsconfig.json
+++ b/packages/ai-react-ui/tsconfig.json
@@ -2,12 +2,10 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
- "rootDir": "./src",
"jsx": "react-jsx",
- "declarationMap": true,
- "composite": true,
- "lib": ["ES2022", "DOM"]
+ "lib": ["ES2022", "DOM"],
+ "rootDir": "../.."
},
- "include": ["src"],
+ "include": ["src", "tests"],
"exclude": ["node_modules", "dist"]
}
diff --git a/packages/ai-react-ui/vite.config.ts b/packages/ai-react-ui/vite.config.ts
index 77bcc2e60b..782c82ec49 100644
--- a/packages/ai-react-ui/vite.config.ts
+++ b/packages/ai-react-ui/vite.config.ts
@@ -9,7 +9,7 @@ const config = defineConfig({
watch: false,
globals: true,
environment: 'node',
- include: ['tests/**/*.test.ts'],
+ include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
diff --git a/packages/ai-solid-ui/src/chat-input.tsx b/packages/ai-solid-ui/src/chat-input.tsx
index b720b9c1cc..7be552ad9e 100644
--- a/packages/ai-solid-ui/src/chat-input.tsx
+++ b/packages/ai-solid-ui/src/chat-input.tsx
@@ -17,6 +17,7 @@ export interface ChatInputRenderProps {
ref: (el: HTMLInputElement | HTMLTextAreaElement) => void
}
+/** @deprecated Use `createUI()` and an application-owned input component. Deprecated in 0.8.0. Removed in 1.0.0. */
export interface ChatInputProps {
/** Render prop for full control */
children?: (props: ChatInputRenderProps) => JSX.Element
@@ -31,6 +32,8 @@ export interface ChatInputProps {
}
/**
+ * @deprecated Use `createUI()` and an application-owned input component. Deprecated in 0.8.0. Removed in 1.0.0.
+ *
* Chat input component - handles message input and submission
*
* Features:
diff --git a/packages/ai-solid-ui/src/chat-message.tsx b/packages/ai-solid-ui/src/chat-message.tsx
index 10b3c5715e..1f623cc33b 100644
--- a/packages/ai-solid-ui/src/chat-message.tsx
+++ b/packages/ai-solid-ui/src/chat-message.tsx
@@ -12,6 +12,7 @@ export interface ToolCallRenderProps {
output?: any
}
+/** @deprecated Use `createUI()` Message instead. Deprecated in 0.8.0. Removed in 1.0.0. */
export interface ChatMessageProps {
/** The message to render */
message: UIMessage
@@ -41,6 +42,8 @@ export interface ChatMessageProps {
}
/**
+ * @deprecated Use `createUI()` Message instead. Deprecated in 0.8.0. Removed in 1.0.0.
+ *
* Message component - renders a single message with all its parts
*
* This component natively understands TanStack AI's parts-based message format:
diff --git a/packages/ai-solid-ui/src/chat-messages.tsx b/packages/ai-solid-ui/src/chat-messages.tsx
index c0136696a3..071d4ef50e 100644
--- a/packages/ai-solid-ui/src/chat-messages.tsx
+++ b/packages/ai-solid-ui/src/chat-messages.tsx
@@ -4,6 +4,7 @@ import { ChatMessage } from './chat-message'
import type { JSX } from 'solid-js'
import type { UIMessage } from '@tanstack/ai-solid'
+/** @deprecated Use `createUI()` Messages instead. Deprecated in 0.8.0. Removed in 1.0.0. */
export interface ChatMessagesProps {
/** Custom render function for each message */
children?: (message: UIMessage, index: number) => JSX.Element
@@ -23,6 +24,8 @@ export interface ChatMessagesProps {
}
/**
+ * @deprecated Use `createUI()` Messages instead. Deprecated in 0.8.0. Removed in 1.0.0.
+ *
* Messages container - renders all messages in the conversation
*
* @example
diff --git a/packages/ai-solid-ui/src/chat.tsx b/packages/ai-solid-ui/src/chat.tsx
index 2a94bcd9d0..dda77eb0a4 100644
--- a/packages/ai-solid-ui/src/chat.tsx
+++ b/packages/ai-solid-ui/src/chat.tsx
@@ -16,6 +16,7 @@ const ChatContext = createContext(null)
* Hook to access chat context
* @throws Error if used outside of Chat component
*/
+/** @deprecated Use `createUI().useChat()` instead. Deprecated in 0.8.0. Removed in 1.0.0. */
export function useChatContext(): UseChatReturn {
const context = useContext(ChatContext)
if (!context) {
@@ -26,6 +27,7 @@ export function useChatContext(): UseChatReturn {
return context
}
+/** @deprecated Use `createUI()` Chat/Provider instead. Deprecated in 0.8.0. Removed in 1.0.0. */
export interface ChatProps {
/** Child components (Chat.Messages, Chat.Input, etc.) */
children: JSX.Element
@@ -57,6 +59,10 @@ export interface ChatProps {
}
/**
+ * @deprecated Use `createUI()` from `@tanstack/ai-solid-ui` instead.
+ * See https://tanstack.com/ai/latest/docs/migration/create-ui
+ * Deprecated in 0.8.0. Removed in 1.0.0.
+ *
* Root Chat component - provides context for all chat subcomponents
*
* @example
diff --git a/packages/ai-solid-ui/src/create-ui.tsx b/packages/ai-solid-ui/src/create-ui.tsx
new file mode 100644
index 0000000000..d36217ff07
--- /dev/null
+++ b/packages/ai-solid-ui/src/create-ui.tsx
@@ -0,0 +1,398 @@
+import { createContext, useContext } from 'solid-js'
+import type { Accessor, Component, JSX } from 'solid-js'
+import {
+ automaticPartsForMessage,
+ collectInlineToolNames,
+ resolveInterruptComponent,
+ selectChatUI,
+ selectMessageUI,
+} from '@tanstack/ai-client/ui'
+import type {
+ ChatUIData,
+ ChatUIInterrupt,
+ ChatUIPartKey,
+ ChatUIRegisteredInterruptId,
+ ChatUISelectedPart,
+ ChatUIToolName,
+ ChatUIToolsOf,
+ RegisteredUIInterrupt,
+} from '@tanstack/ai-client/ui'
+import type {
+ MessagePart,
+ ToolCallPart,
+ ToolResultPart,
+ UIMessage,
+} from '@tanstack/ai-client'
+
+// ponytail: duck-typed so UseChatReturn assigns; accessors and UIMessage generics are invariant
+export type ChatUIHost = {
+ messages: Accessor> | ReadonlyArray
+ interrupts?: Accessor> | ReadonlyArray
+ error?: Accessor | Error
+ isLoading?: Accessor | boolean
+ status?: Accessor | string
+ sendMessage?: (content: string, ...args: Array) => Promise | void
+}
+
+export type LayoutProps = {
+ chat: ChatUIHost
+ renderMessages: () => JSX.Element
+ renderInterrupts: () => JSX.Element
+ renderInput: () => JSX.Element
+ readonly __ui?: TOptions
+}
+
+export type MessageProps = {
+ chat: ChatUIHost
+ message: UIMessage, ChatUIData>
+ renderParts: () => JSX.Element
+}
+
+export type InputProps = {
+ chat: ChatUIHost
+ readonly __ui?: TOptions
+}
+
+export type PartProps = {
+ chat: ChatUIHost
+ part: MessagePart, ChatUIData>
+}
+
+export type ToolProps<
+ TOptions,
+ TName extends ChatUIToolName = ChatUIToolName,
+> = {
+ chat: ChatUIHost
+ part: Extract>, { name: TName }>
+ result?: ToolResultPart
+ interrupt?: Extract<
+ ChatUIInterrupt,
+ { kind: 'tool-approval'; toolName: TName }
+ >
+ renderInterrupt: () => JSX.Element
+}
+
+export type InterruptProps = {
+ chat: ChatUIHost
+ interrupt: ChatUIInterrupt
+ readonly __ui?: TOptions
+}
+
+export type RegisteredInterruptProps<
+ TOptions,
+ TId extends ChatUIRegisteredInterruptId =
+ ChatUIRegisteredInterruptId,
+> = {
+ chat: ChatUIHost
+ interrupt: RegisteredUIInterrupt
+}
+
+type InterruptEntry =
+ | Component>
+ | {
+ component: Component>
+ placement?: 'inline' | 'list'
+ }
+
+type GenericInterruptComponents = {
+ [K in ChatUIRegisteredInterruptId as K extends 'fallback'
+ ? never
+ : K]?: Component>
+} & {
+ fallback?: Component>
+}
+
+export type ChatUIComponents = {
+ layout: Component>
+ message: Component>
+ input?: Component>
+ parts: {
+ [K in ChatUIPartKey]?: Component>
+ } & {
+ fallback?: Component>
+ }
+ tools?: {
+ [K in ChatUIToolName]?: Component>
+ }
+ interrupts?: {
+ tools?: {
+ [K in ChatUIToolName]?: InterruptEntry
+ }
+ generic?: GenericInterruptComponents
+ }
+}
+
+type UIContextValue = {
+ chat: ChatUIHost
+ components: ChatUIComponents
+ warn: (key: string, message: string) => void
+}
+
+function createWarnOnce() {
+ const seen = new Set()
+ return (key: string, message: string) => {
+ if (process.env.NODE_ENV === 'production') return
+ if (seen.has(key)) return
+ seen.add(key)
+ console.warn(message)
+ }
+}
+
+function readMessages(chat: ChatUIHost): ReadonlyArray {
+ return typeof chat.messages === 'function' ? chat.messages() : chat.messages
+}
+
+function readInterrupts(chat: ChatUIHost): ReadonlyArray {
+ const value = chat.interrupts
+ if (typeof value === 'function') return value()
+ return value ?? []
+}
+
+function messagesAccessor(chat: ChatUIHost): Accessor> {
+ const value = chat.messages
+ if (typeof value === 'function') return value
+ return () => value
+}
+
+export function createUI(options: TOptions) {
+ void options
+ const warn = createWarnOnce()
+ const UIContext = createContext | null>(null)
+
+ function useUIContext() {
+ const value = useContext(UIContext)
+ if (!value) {
+ throw new Error(
+ 'Chat UI components must be wrapped in UI.Provider or UI.Chat.',
+ )
+ }
+ return value
+ }
+
+ function useChat() {
+ return useUIContext().chat
+ }
+
+ function defineComponents(components: ChatUIComponents) {
+ return components
+ }
+
+ function inlineNames(components: ChatUIComponents) {
+ return collectInlineToolNames(
+ components.interrupts?.tools as Record | undefined,
+ )
+ }
+
+ function Provider(props: {
+ chat: ChatUIHost
+ components: ChatUIComponents
+ children?: JSX.Element
+ }) {
+ return (
+
+ {props.children}
+
+ )
+ }
+
+ function Chat(props: {
+ chat: ChatUIHost
+ components: ChatUIComponents
+ }) {
+ const Layout = props.components.layout
+ return (
+
+ }
+ renderInterrupts={() => }
+ renderInput={() => {
+ const Input = props.components.input
+ return Input ? : null
+ }}
+ />
+
+ )
+ }
+
+ function Messages(props: {
+ children?: (
+ messages: Accessor>>,
+ ) => JSX.Element
+ }) {
+ const ctx = useUIContext()
+ if (props.children) return <>{props.children(messagesAccessor(ctx.chat))}>
+ return (
+ <>
+ {readMessages(ctx.chat).map((message) => (
+
+ ))}
+ >
+ )
+ }
+
+ function Message(props: {
+ message: UIMessage
+ children?: (parts: Array) => JSX.Element
+ }) {
+ const ctx = useUIContext()
+ const inlineToolNames = inlineNames(ctx.components)
+ const selected = selectMessageUI(props.message, {
+ interrupts: readInterrupts(ctx.chat),
+ inlineToolNames,
+ })
+ if (props.children) return <>{props.children(selected.parts)}>
+ const MessageComponent = ctx.components.message
+ return (
+ ['message']}
+ renderParts={() => }
+ />
+ )
+ }
+
+ function AutomaticParts(props: { message: UIMessage }) {
+ const ctx = useUIContext()
+ const selected = selectMessageUI(props.message, {
+ interrupts: readInterrupts(ctx.chat),
+ inlineToolNames: inlineNames(ctx.components),
+ })
+ return (
+ <>
+ {automaticPartsForMessage(selected).map((part) => (
+
+ ))}
+ >
+ )
+ }
+
+ function SelectedPartView(props: {
+ selected: ChatUISelectedPart
+ inline: boolean
+ }) {
+ const ctx = useUIContext()
+ if (props.selected.key === 'toolCall') {
+ const name = props.selected.part.name
+ const Tool = ctx.components.tools?.[name as ChatUIToolName] as
+ | Component>
+ | undefined
+ if (!Tool) {
+ ctx.warn(
+ `tool:${name}`,
+ `[tanstack-ai-ui] Missing tools.${name} component`,
+ )
+ return null
+ }
+ return (
+ ['part']}
+ result={props.selected.result}
+ interrupt={
+ (props.selected.key === 'toolCall'
+ ? props.selected.interrupt
+ : undefined) as ToolProps['interrupt']
+ }
+ renderInterrupt={() =>
+ props.inline && props.selected.key === 'toolCall' ? (
+
+ ) : null
+ }
+ />
+ )
+ }
+ const PartComponent =
+ ctx.components.parts[props.selected.key] ?? ctx.components.parts.fallback
+ if (!PartComponent) {
+ ctx.warn(
+ `part:${props.selected.key}`,
+ `[tanstack-ai-ui] Missing parts.${props.selected.key} component`,
+ )
+ return null
+ }
+ return (
+ ['part']}
+ />
+ )
+ }
+
+ function InlineInterrupt(props: { interrupt?: ChatUIInterrupt }) {
+ const ctx = useUIContext()
+ const interrupt = props.interrupt
+ if (!interrupt || interrupt.kind !== 'tool-approval') return null
+ if (!inlineNames(ctx.components).includes(interrupt.toolName)) return null
+ const Component = resolveInterruptComponent(
+ interrupt,
+ ctx.components.interrupts,
+ ) as Component> | undefined
+ if (!Component) return null
+ return
+ }
+
+ function Part(props: { part: MessagePart }) {
+ const ctx = useUIContext()
+ const selected = selectMessageUI(
+ { id: 'part', role: 'assistant', parts: [props.part] },
+ { interrupts: readInterrupts(ctx.chat), inlineToolNames: [] },
+ ).parts[0]
+ if (!selected) return null
+ return
+ }
+
+ function Interrupts(props: {
+ children?: (
+ interrupts: Accessor>,
+ ) => JSX.Element
+ }) {
+ const ctx = useUIContext()
+ const selected = () =>
+ selectChatUI({
+ messages: readMessages(ctx.chat),
+ interrupts: readInterrupts(ctx.chat),
+ inlineToolNames: inlineNames(ctx.components),
+ })
+ if (props.children) {
+ return <>{props.children(() => selected().interrupts)}>
+ }
+ return (
+ <>
+ {selected().interrupts.map((interrupt) => (
+
+ ))}
+ >
+ )
+ }
+
+ function Interrupt(props: { interrupt: ChatUIInterrupt }) {
+ const ctx = useUIContext()
+ const Component = resolveInterruptComponent(
+ props.interrupt,
+ ctx.components.interrupts,
+ ) as Component> | undefined
+ if (!Component) {
+ ctx.warn(
+ `interrupt:${props.interrupt.id}`,
+ `[tanstack-ai-ui] Missing interrupt component for ${props.interrupt.kind}`,
+ )
+ return null
+ }
+ return
+ }
+
+ return {
+ Chat,
+ Provider,
+ Messages,
+ Message,
+ Part,
+ Interrupts,
+ Interrupt,
+ defineComponents,
+ useChat,
+ }
+}
diff --git a/packages/ai-solid-ui/src/index.ts b/packages/ai-solid-ui/src/index.ts
index 415bdd200b..434411c513 100644
--- a/packages/ai-solid-ui/src/index.ts
+++ b/packages/ai-solid-ui/src/index.ts
@@ -24,6 +24,19 @@
* ```
*/
+export {
+ createUI,
+ type ChatUIComponents,
+ type ChatUIHost,
+ type InputProps,
+ type InterruptProps,
+ type LayoutProps,
+ type MessageProps,
+ type PartProps,
+ type RegisteredInterruptProps,
+ type ToolProps,
+} from './create-ui'
+
// Main components
export { Chat, useChatContext, type ChatProps } from './chat'
export { ChatMessages, type ChatMessagesProps } from './chat-messages'
diff --git a/packages/ai-solid-ui/src/tool-approval.tsx b/packages/ai-solid-ui/src/tool-approval.tsx
index 51129ff264..79f1f0f694 100644
--- a/packages/ai-solid-ui/src/tool-approval.tsx
+++ b/packages/ai-solid-ui/src/tool-approval.tsx
@@ -1,6 +1,7 @@
import { useChatContext } from './chat'
import type { JSX } from 'solid-js'
+/** @deprecated Use `createUI()` interrupt components with `chat.interrupts`. Deprecated in 0.8.0. Removed in 1.0.0. */
export interface ToolApprovalProps {
/** Tool call ID */
toolCallId: string
@@ -36,6 +37,8 @@ export interface ToolApprovalRenderProps {
}
/**
+ * @deprecated Use `createUI()` interrupt components with `chat.interrupts`. Deprecated in 0.8.0. Removed in 1.0.0.
+ *
* Tool approval component - renders approve/deny buttons for tools that need approval
*
* @example
diff --git a/packages/ai-solid-ui/tests/coexistence.test.ts b/packages/ai-solid-ui/tests/coexistence.test.ts
new file mode 100644
index 0000000000..86bc7944d8
--- /dev/null
+++ b/packages/ai-solid-ui/tests/coexistence.test.ts
@@ -0,0 +1,21 @@
+import { describe, expect, it } from 'vitest'
+import { createUI } from '../src/create-ui'
+import { readFileSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const src = dirname(fileURLToPath(import.meta.url))
+const index = readFileSync(join(src, '../src/index.ts'), 'utf8')
+
+describe('public coexistence', () => {
+ it('exports old and new APIs before 1.0', () => {
+ expect(createUI).toBeDefined()
+ expect(index).toContain('export { Chat')
+ expect(index).toContain('ChatInput')
+ expect(index).toContain('ChatMessage')
+ expect(index).toContain('ChatMessages')
+ expect(index).toContain('TextPart')
+ expect(index).toContain('ThinkingPart')
+ expect(index).toContain('createUI')
+ })
+})
diff --git a/packages/ai-solid-ui/tests/create-ui-types.test.tsx b/packages/ai-solid-ui/tests/create-ui-types.test.tsx
new file mode 100644
index 0000000000..09f679d0dd
--- /dev/null
+++ b/packages/ai-solid-ui/tests/create-ui-types.test.tsx
@@ -0,0 +1,40 @@
+import { expectTypeOf } from 'vitest'
+import { createUI } from '../src/create-ui'
+import { chatOptions } from '../../ai-client/tests/ui-fixtures'
+
+const UI = createUI(chatOptions)
+
+UI.defineComponents({
+ layout: (props) => {
+ expectTypeOf(props.chat.messages).toMatchTypeOf()
+ return props.renderMessages()
+ },
+ message: (props) => props.renderParts(),
+ parts: {
+ fallback: () => null,
+ },
+ tools: {
+ getWeather: (props) => {
+ expectTypeOf(props.part.input).toEqualTypeOf<
+ { city: string } | undefined
+ >()
+ expectTypeOf(props.part.output).toEqualTypeOf<
+ { temperature: number } | undefined
+ >()
+ return null
+ },
+ // @ts-expect-error This tool is not in chatOptions.
+ unknownTool: () => null,
+ },
+ interrupts: {
+ generic: {
+ choosePlan: (props) => {
+ props.interrupt.resolveInterrupt('approved')
+ // @ts-expect-error The response schema accepts a string.
+ props.interrupt.resolveInterrupt(42)
+ return null
+ },
+ fallback: () => null,
+ },
+ },
+})
diff --git a/packages/ai-solid-ui/tests/create-ui.test.tsx b/packages/ai-solid-ui/tests/create-ui.test.tsx
new file mode 100644
index 0000000000..1722a2c474
--- /dev/null
+++ b/packages/ai-solid-ui/tests/create-ui.test.tsx
@@ -0,0 +1,65 @@
+import { render } from 'solid-js/web'
+import { describe, expect, it, vi } from 'vitest'
+import { createUI } from '../src/create-ui'
+import {
+ chatOptions,
+ createSolidChatResult,
+ messageWithToolResults,
+ unknownToolMessage,
+} from '../../ai-client/tests/ui-fixtures'
+
+function renderHtml(node: () => unknown) {
+ const container = document.createElement('div')
+ render(node as () => import('solid-js').JSX.Element, container)
+ return container.innerHTML
+}
+
+describe('Solid createUI', () => {
+ it('renders automatic and manual traversal', () => {
+ const UI = createUI(chatOptions)
+ const chat = createSolidChatResult([messageWithToolResults])
+ const components = UI.defineComponents({
+ layout: (props) => <>{props.renderMessages()}>,
+ message: (props) => {props.renderParts()} ,
+ parts: { fallback: (props) => {props.part.type} },
+ tools: {
+ getWeather: (props) => {props.part.input?.city} ,
+ purchaseItem: () => null,
+ },
+ interrupts: { generic: { fallback: () => null } },
+ })
+
+ expect(
+ renderHtml(() => ),
+ ).toContain('Paris ')
+ expect(
+ renderHtml(() => (
+
+
+ {(messages) => {messages().length} }
+
+
+ )),
+ ).toContain('1 ')
+ })
+
+ it('warns once for a missing runtime key', () => {
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
+ const UI = createUI(chatOptions)
+ const chat = createSolidChatResult([unknownToolMessage])
+ const components = UI.defineComponents({
+ layout: (props) => props.renderMessages(),
+ message: (props) => props.renderParts(),
+ parts: { fallback: () => null },
+ tools: {
+ getWeather: () => null,
+ purchaseItem: () => null,
+ },
+ interrupts: { generic: { fallback: () => null } },
+ })
+ renderHtml(() => )
+ renderHtml(() => )
+ expect(warn).toHaveBeenCalledTimes(1)
+ warn.mockRestore()
+ })
+})
diff --git a/packages/ai-solid-ui/tsconfig.json b/packages/ai-solid-ui/tsconfig.json
index 92effd4795..3cd8594f74 100644
--- a/packages/ai-solid-ui/tsconfig.json
+++ b/packages/ai-solid-ui/tsconfig.json
@@ -2,13 +2,11 @@
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
- "rootDir": "./src",
"jsx": "preserve",
"jsxImportSource": "solid-js",
- "declarationMap": true,
- "composite": true,
- "lib": ["ES2022", "DOM"]
+ "lib": ["ES2022", "DOM"],
+ "rootDir": "../.."
},
- "include": ["src"],
+ "include": ["src", "tests"],
"exclude": ["node_modules", "dist"]
}
diff --git a/packages/ai-solid-ui/vite.config.ts b/packages/ai-solid-ui/vite.config.ts
index 36849e034e..31293a7dbd 100644
--- a/packages/ai-solid-ui/vite.config.ts
+++ b/packages/ai-solid-ui/vite.config.ts
@@ -18,7 +18,7 @@ const config = defineConfig({
// vite-plugin-solid transforms them into the test graph, so tests run in
// a DOM environment rather than 'node'.
environment: 'happy-dom',
- include: ['tests/**/*.test.ts'],
+ include: ['tests/**/*.test.ts', 'tests/**/*.test.tsx'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html', 'lcov'],
diff --git a/packages/ai-svelte-ui/LICENSE b/packages/ai-svelte-ui/LICENSE
new file mode 100644
index 0000000000..308cb68dc0
--- /dev/null
+++ b/packages/ai-svelte-ui/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2025 Tanner Linsley
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/ai-svelte-ui/README.md b/packages/ai-svelte-ui/README.md
new file mode 100644
index 0000000000..26f3915c3b
--- /dev/null
+++ b/packages/ai-svelte-ui/README.md
@@ -0,0 +1,5 @@
+# @tanstack/ai-svelte-ui
+
+Headless Svelte 5 chat UI. Call `createUI(chatOptions)` and supply every visible component.
+
+See https://tanstack.com/ai/latest/docs/ui/svelte
diff --git a/packages/ai-svelte-ui/package.json b/packages/ai-svelte-ui/package.json
new file mode 100644
index 0000000000..65726a9803
--- /dev/null
+++ b/packages/ai-svelte-ui/package.json
@@ -0,0 +1,70 @@
+{
+ "name": "@tanstack/ai-svelte-ui",
+ "version": "0.1.0",
+ "description": "Headless Svelte 5 components for building TanStack AI chat interfaces with typed createUI adapters.",
+ "author": "Tanner Linsley",
+ "license": "MIT",
+ "homepage": "https://tanstack.com/ai",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/TanStack/ai.git",
+ "directory": "packages/ai-svelte-ui"
+ },
+ "bugs": {
+ "url": "https://github.com/TanStack/ai/issues"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "type": "module",
+ "svelte": "./dist/index.js",
+ "module": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "svelte": "./dist/index.js",
+ "import": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "src"
+ ],
+ "scripts": {
+ "clean": "rm -rf ./dist",
+ "test:oxlint": "oxlint src --type-aware",
+ "test:lib": "vitest run",
+ "test:lib:dev": "pnpm test:lib --watch",
+ "test:types": "svelte-check",
+ "test:build": "publint --strict",
+ "build": "svelte-package -i src -o dist"
+ },
+ "keywords": [
+ "ai",
+ "tanstack",
+ "svelte",
+ "svelte5",
+ "chat",
+ "ui",
+ "headless"
+ ],
+ "dependencies": {
+ "@tanstack/ai-client": "workspace:^"
+ },
+ "peerDependencies": {
+ "@tanstack/ai-svelte": "workspace:^",
+ "svelte": "^5.0.0"
+ },
+ "devDependencies": {
+ "@sveltejs/package": "^2.3.10",
+ "@sveltejs/vite-plugin-svelte": "^7.3.0",
+ "@types/node": "^24.10.1",
+ "@vitest/coverage-v8": "4.1.10",
+ "svelte": "^5.56.9",
+ "svelte-check": "^4.2.0",
+ "typescript": "5.9.3",
+ "vite": "^8.2.1"
+ }
+}
diff --git a/packages/ai-svelte-ui/src/create-ui.ts b/packages/ai-svelte-ui/src/create-ui.ts
new file mode 100644
index 0000000000..bb74433c81
--- /dev/null
+++ b/packages/ai-svelte-ui/src/create-ui.ts
@@ -0,0 +1,214 @@
+import { getContext, setContext } from 'svelte'
+import {
+ automaticPartsForMessage,
+ collectInlineToolNames,
+ resolveInterruptComponent,
+ selectChatUI,
+ selectMessageUI,
+} from '@tanstack/ai-client/ui'
+import type {
+ ChatUIData,
+ ChatUIInterrupt,
+ ChatUIPartKey,
+ ChatUIRegisteredInterruptId,
+ ChatUISelectedPart,
+ ChatUIToolName,
+ ChatUIToolsOf,
+ RegisteredUIInterrupt,
+} from '@tanstack/ai-client/ui'
+import type {
+ MessagePart,
+ ToolCallPart,
+ ToolResultPart,
+ UIMessage,
+} from '@tanstack/ai-client'
+
+// ponytail: duck-typed so createChat assigns; UIMessage generics are invariant
+export type ChatUIHost = {
+ messages: ReadonlyArray
+ interrupts?: ReadonlyArray
+ error?: Error
+ isLoading?: boolean
+ status?: string
+ sendMessage?: (content: string, ...args: Array) => Promise | void
+}
+
+type GenericInterruptComponents = {
+ [K in ChatUIRegisteredInterruptId as K extends 'fallback'
+ ? never
+ : K]?: unknown
+} & {
+ fallback?: unknown
+}
+
+export type ChatUIComponents = {
+ layout: unknown
+ message: unknown
+ input?: unknown
+ parts: {
+ [K in ChatUIPartKey]?: unknown
+ } & {
+ fallback?: unknown
+ }
+ tools?: {
+ [K in ChatUIToolName]?: unknown
+ }
+ interrupts?: {
+ tools?: {
+ [K in ChatUIToolName]?:
+ | unknown
+ | { component: unknown; placement?: 'inline' | 'list' }
+ }
+ generic?: GenericInterruptComponents
+ }
+}
+
+export type UIDescriptor = {
+ key: symbol
+ warn: (key: string, message: string) => void
+ defineComponents: (
+ components: ChatUIComponents,
+ ) => ChatUIComponents
+ useChat: () => ChatUIHost
+}
+
+export type UIContextValue = {
+ chat: ChatUIHost
+ components: ChatUIComponents
+ ui: UIDescriptor
+}
+
+function createWarnOnce() {
+ const seen = new Set()
+ return (key: string, message: string) => {
+ if (process.env.NODE_ENV === 'production') return
+ if (seen.has(key)) return
+ seen.add(key)
+ console.warn(message)
+ }
+}
+
+export function createUI(
+ options: TOptions,
+): UIDescriptor {
+ void options
+ const ui: UIDescriptor = {
+ key: Symbol('tanstack-ai-ui'),
+ warn: createWarnOnce(),
+ defineComponents(components) {
+ return components
+ },
+ useChat() {
+ return getUIContext(ui).chat
+ },
+ }
+ return ui
+}
+
+export function setUIContext(value: UIContextValue) {
+ setContext(value.ui.key, value)
+}
+
+export function getUIContext(ui: UIDescriptor) {
+ const value = getContext | undefined>(ui.key)
+ if (!value) {
+ throw new Error(
+ 'Chat UI components must be wrapped in UIProvider or UIChat.',
+ )
+ }
+ return value
+}
+
+export function readMessages(chat: ChatUIHost) {
+ return chat.messages ?? []
+}
+
+export function readInterrupts(chat: ChatUIHost) {
+ return chat.interrupts ?? []
+}
+
+export function inlineNames(components: ChatUIComponents) {
+ return collectInlineToolNames(
+ components.interrupts?.tools as Record | undefined,
+ )
+}
+
+export function messageParts(
+ ctx: UIContextValue,
+ message: UIMessage,
+): Array {
+ return automaticPartsForMessage(
+ selectMessageUI(message, {
+ interrupts: readInterrupts(ctx.chat),
+ inlineToolNames: inlineNames(ctx.components),
+ }),
+ )
+}
+
+export function listInterrupts(ctx: UIContextValue) {
+ return selectChatUI({
+ messages: readMessages(ctx.chat),
+ interrupts: readInterrupts(ctx.chat),
+ inlineToolNames: inlineNames(ctx.components),
+ }).interrupts
+}
+
+export function toolComponent(ctx: UIContextValue, name: string) {
+ return ctx.components.tools?.[name as never]
+}
+
+export function partComponent(ctx: UIContextValue, key: string) {
+ return (
+ ctx.components.parts[key as ChatUIPartKey] ?? ctx.components.parts.fallback
+ )
+}
+
+export function interruptComponent(
+ ctx: UIContextValue,
+ interrupt: ChatUIInterrupt,
+) {
+ return resolveInterruptComponent(interrupt, ctx.components.interrupts)
+}
+
+export type LayoutProps = {
+ chat: ChatUIHost
+ readonly __ui?: TOptions
+}
+export type MessageProps = {
+ chat: ChatUIHost
+ message: UIMessage, ChatUIData>
+}
+export type InputProps = {
+ chat: ChatUIHost
+ readonly __ui?: TOptions
+}
+export type PartProps = {
+ chat: ChatUIHost
+ part: MessagePart, ChatUIData>
+}
+export type ToolProps<
+ TOptions,
+ TName extends ChatUIToolName = ChatUIToolName,
+> = {
+ chat: ChatUIHost
+ part: Extract>, { name: TName }>
+ result?: ToolResultPart
+ interrupt?: Extract<
+ ChatUIInterrupt,
+ { kind: 'tool-approval'; toolName: TName }
+ >
+ readonly __ui?: TOptions
+}
+export type InterruptProps