diff --git a/.changeset/typed-headless-chat-ui.md b/.changeset/typed-headless-chat-ui.md
new file mode 100644
index 0000000000..db5dfc9eec
--- /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 `createChatUI()` adapters. Chat options control the types of message parts, tools, structured output, and interrupts. `defineComponents` requires a component for every tool name and every registered interrupt id. `InterruptProps` pins a tool approval or a registered generic interrupt. 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 59af3902b1..e5aad09ddc 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..ff4fafe768 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 createChatUI](../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..3fd64dfb6a 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 createChatUI](../migration/create-ui).
+
## Installation
```bash
diff --git a/docs/api/ai-svelte.md b/docs/api/ai-svelte.md
index 60488ce058..a59e25da52 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 createChatUI](../migration/create-ui).
+
## Installation
```bash
diff --git a/docs/api/ai-vue.md b/docs/api/ai-vue.md
index 6606cf4444..cd46cf22e6 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 createChatUI](../migration/create-ui).
+
## Installation
```bash
diff --git a/docs/config.json b/docs/config.json
index 46eb82d575..f02ac79ee8 100644
--- a/docs/config.json
+++ b/docs/config.json
@@ -211,6 +211,42 @@
}
]
},
+ {
+ "label": "UI",
+ "tab": "guides",
+ "children": [
+ {
+ "label": "React",
+ "to": "ui/react",
+ "addedAt": "2026-08-26",
+ "updatedAt": "2026-08-27"
+ },
+ {
+ "label": "Solid",
+ "to": "ui/solid",
+ "addedAt": "2026-08-26",
+ "updatedAt": "2026-08-27"
+ },
+ {
+ "label": "Vue",
+ "to": "ui/vue",
+ "addedAt": "2026-08-26",
+ "updatedAt": "2026-08-27"
+ },
+ {
+ "label": "Svelte",
+ "to": "ui/svelte",
+ "addedAt": "2026-08-26",
+ "updatedAt": "2026-08-27"
+ },
+ {
+ "label": "Custom Adapters",
+ "to": "ui/custom-adapters",
+ "addedAt": "2026-08-26",
+ "updatedAt": "2026-08-26"
+ }
+ ]
+ },
{
"label": "Interrupts",
"tab": "guides",
@@ -843,6 +879,12 @@
"addedAt": "2026-04-15",
"updatedAt": "2026-07-22"
},
+ {
+ "label": "createChatUI",
+ "to": "migration/create-ui",
+ "addedAt": "2026-08-26",
+ "updatedAt": "2026-08-27"
+ },
{
"label": "From Vercel AI SDK",
"to": "migration/migration-from-vercel-ai",
@@ -875,19 +917,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",
@@ -899,13 +941,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..6a6836231f
--- /dev/null
+++ b/docs/migration/create-ui.md
@@ -0,0 +1,121 @@
+---
+title: Migrate to createChatUI
+id: migrate-create-ui
+order: 5
+description: "Move chat-state ownership out of the old Chat component and onto createChatUI with a typed component map."
+keywords:
+ - tanstack ai
+ - createChatUI
+ - migration
+ - deprecation
+---
+
+The old `Chat` component owned chat state and lost configured types. `createChatUI` 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. `createChatUI()` 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.2.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 { createChatUI } from '@tanstack/ai-react-ui'
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+}
+
+const UI = createChatUI(chatOptions)
+
+const components = UI.defineComponents({
+ layout: ({ renderMessages, renderInput }) => (
+
+ {renderMessages()}
+ {renderInput()}
+
+ ),
+ message: ({ renderParts }) => {renderParts()},
+ input: () => {
+ const chat = UI.useChat()
+ return (
+
+ )
+ },
+ 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 `createChatUI(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`.
+- A mapped tool can read `interrupt` and render the approval itself. That approval stays off the list. A component on `interrupts.tools` uses the list.
+- Generic interrupts live under `interrupts.generic`: a registered id such as `choosePlan`, plus `fallback`. Unbound interrupts use `fallback`.
+- TypeScript requires a `tools` component for every tool name and an `interrupts.generic` component for every interrupt id. `generic.fallback` is optional.
+- 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..d51a4e8f60
--- /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
+ - createChatUI
+ - 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..2a05727143
--- /dev/null
+++ b/docs/ui/react.md
@@ -0,0 +1,545 @@
+---
+title: React Chat UI
+id: typed-headless-ui-react
+order: 1
+description: "Build a typed, headless React chat UI with createChatUI. Your chat options control the types of tools, parts, and interrupts."
+keywords:
+ - tanstack ai
+ - createChatUI
+ - react
+ - headless ui
+ - useChat
+ - ToolProps
+---
+
+Install `@tanstack/ai-react-ui`, then call `createChatUI(chatOptions)` once at module scope. Your app owns `useChat`. The UI only renders. Call `UI.useChat()` inside a mapped component when it needs live chat. That call is the same value you passed into `UI.Chat`.
+
+You supply every visible component. There is no default markup, style, or copy.
+
+`defineComponents` needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional.
+
+## 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.6'),
+ messages: Array.isArray(messages) ? messages : [],
+ })
+ return toServerSentEventsResponse(stream)
+}
+```
+
+## Client
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { createChatUI } 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 = createChatUI(chatOptions)
+
+const components = UI.defineComponents({
+ layout: function Layout({
+ renderMessages,
+ renderInterrupts,
+ renderInput,
+ }) {
+ const chat = UI.useChat()
+ if (chat.error) return
{chat.error.message}
+ if (chat.isLoading && chat.messages.length === 0) return
,
+ },
+ },
+})
+
+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.
+
+Part components work the same way. `PartProps` already has a text part. You do not check `part.type`. Use `'structuredOutput'`, `'thinking'`, `'toolResult'`, and the other keys from the `parts` map. `fallback` still sees every part type.
+
+```tsx
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { createChatUI, type PartProps, 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
+ )
+}
+
+export function TextPart({ part }: PartProps) {
+ return
{part.content}
+}
+
+const UI = createChatUI(chatOptions)
+
+export const components = UI.defineComponents({
+ layout: ({ renderMessages }) => renderMessages(),
+ message: ({ renderParts }) => {renderParts()},
+ parts: { text: TextPart, fallback: () => null },
+ tools: { getWeather: WeatherTool },
+})
+```
+
+1. Put `chatOptions` in a shared module.
+2. Import `ToolProps` or `PartProps` from `@tanstack/ai-react-ui`.
+3. Type the component with `ToolProps` or `PartProps`.
+4. Pass that component into `tools.getWeather` or `parts.text`.
+
+For an interrupt, use `InterruptProps`. Pass a tool name or a registered interrupt id as the second type argument. Then you do not check `interrupt.kind`.
+
+- A tool approval: `InterruptProps`. Then `interrupt.toolName` is `'purchaseItem'`.
+- A registered generic interrupt: `InterruptProps`. Then `interrupt.payload` and `interrupt.resolveInterrupt` match the definition.
+
+```tsx
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { createChatUI, type InterruptProps } 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,
+}: InterruptProps) {
+ return (
+
+ )
+}
+
+const UI = createChatUI(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` with a part key such as `'text'`
+- `InterruptProps` for tool approvals, registered generic interrupts, and `generic.fallback`. Pass a tool name or interrupt id as the second type argument.
+
+## Read chat from `UI.useChat()`
+
+Mapped components do not receive `chat` as a prop. Call `UI.useChat()` inside a component when it needs live chat. That call opts the component into chat re-renders. Nested children can call it too.
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { createChatUI } from '@tanstack/ai-react-ui'
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+}
+
+const UI = createChatUI(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
+
+Read `interrupt` on the tool. Render the approval in that same component. Do not register `interrupts.tools` for that name. A mapped tool keeps its approval off the list.
+
+`interrupt` is already the approval for that tool name. You do not check `interrupt.kind`.
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { createChatUI, type ToolProps } 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 = createChatUI(chatOptions)
+
+function PurchaseItem({
+ part,
+ interrupt,
+}: ToolProps) {
+ return (
+
+ )
+}
+
+const components = UI.defineComponents({
+ layout: ({ renderMessages, renderInterrupts }) => (
+
+ {renderMessages()}
+ {renderInterrupts()}
+
+ ),
+ message: ({ renderParts }) => {renderParts()},
+ parts: { fallback: () => null },
+ tools: {
+ purchaseItem: PurchaseItem,
+ },
+})
+
+export function InlineApprovalChat() {
+ const chat = useChat(chatOptions)
+ return
+}
+```
+
+To split the approval into its own file, type it with `InterruptProps`. Render that component from the tool.
+
+### List, in `renderInterrupts()`
+
+Register the approval under `interrupts.tools`. That component appears in the interrupt list. Do not also render `interrupt` on the tool unless you want it in both places.
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-react'
+import { createChatUI } 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 = createChatUI(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 }) => (
+
+ ),
+ },
+ },
+})
+
+export function ListApprovalChat() {
+ const chat = useChat(chatOptions)
+ return
+}
+```
+
+## 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 { createChatUI } 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 = createChatUI(chatOptions)
+
+const components = UI.defineComponents({
+ layout: ({ renderInterrupts }) => renderInterrupts(),
+ message: ({ renderParts }) => {renderParts()},
+ parts: { fallback: () => null },
+ interrupts: {
+ generic: {
+ choosePlan: ({ interrupt }) => (
+
+ ),
+ 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 { createChatUI } from '@tanstack/ai-react-ui'
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+}
+
+const UI = createChatUI(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..b5fcaeb36e
--- /dev/null
+++ b/docs/ui/solid.md
@@ -0,0 +1,153 @@
+---
+title: Solid Chat UI
+id: typed-headless-ui-solid
+order: 2
+description: "Build a typed, headless Solid chat UI with createChatUI. Accessors stay tracked. Your app owns useChat."
+keywords:
+ - tanstack ai
+ - createChatUI
+ - solid
+ - headless ui
+ - ToolProps
+---
+
+Install `@tanstack/ai-solid-ui`, then call `createChatUI(chatOptions)` once at module scope. Do not destructure reactive props.
+
+`defineComponents` needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional.
+
+The server route matches the [React page](./react). Use `gpt-5.6` on the OpenAI text adapter.
+
+## Client
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid'
+import { createChatUI } 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 = createChatUI(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},
+ },
+})
+
+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 { createChatUI, 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 = createChatUI(chatOptions)
+
+export const components = UI.defineComponents({
+ layout: (props) => props.renderMessages(),
+ message: (props) => {props.renderParts()},
+ parts: { fallback: () => null },
+ tools: { getWeather: WeatherTool },
+})
+```
+
+Part components use `PartProps`. Then `part` is already a text part.
+
+Interrupt components use `InterruptProps`. Then `interrupt.payload` matches the definition.
+
+Mapped components do not receive `chat` as a prop. Call `UI.useChat()` when a component needs live chat. That call opts the component into chat updates. Nested children can call it too.
+
+## Read chat from `UI.useChat()`
+
+```tsx
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-solid'
+import { createChatUI } from '@tanstack/ai-solid-ui'
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+}
+
+const UI = createChatUI(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 sit in the tool when you read `props.interrupt`. Put a component on `interrupts.tools` to send that approval to the list instead. 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..1fea54dca2
--- /dev/null
+++ b/docs/ui/svelte.md
@@ -0,0 +1,101 @@
+---
+title: Svelte Chat UI
+id: typed-headless-ui-svelte
+order: 4
+description: "Build a typed, headless Svelte 5 chat UI with createChatUI, snippets, and static components."
+keywords:
+ - tanstack ai
+ - createChatUI
+ - svelte
+ - headless ui
+ - ToolProps
+---
+
+Install `@tanstack/ai-svelte-ui`. Call `createChatUI(chatOptions)` once. Pass `{ui}`, `{chat}`, and `{components}` into `UIChat`.
+
+`defineComponents` needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional.
+
+The server route matches the [React page](./react). Use `gpt-5.6` on the OpenAI text adapter.
+
+## Client
+
+```svelte
+
+
+
+```
+
+`Layout.svelte` receives snippets `messages`, `interrupts`, and `input`. `Message.svelte` receives snippet `parts`. A tool with an approval receives prop `interrupt`.
+
+## 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 `createChatUI`.
+
+```svelte
+
+
+{part.input?.city}
+```
+
+Part components use `PartProps`. Then `part` is already a text part.
+
+Interrupt components use `InterruptProps`. Then `interrupt.payload` matches the definition.
+
+Mapped components do not receive `chat` as a prop. Call `ui.useChat()` when a component needs live chat. That call opts the component into chat updates. Nested children can call it too.
+
+## 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 sit in the tool when you read the `interrupt` prop. Put a component on `interrupts.tools` to send that approval to the list instead. 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..0624e31bb6
--- /dev/null
+++ b/docs/ui/vue.md
@@ -0,0 +1,170 @@
+---
+title: Vue Chat UI
+id: typed-headless-ui-vue
+order: 3
+description: "Build a typed, headless Vue chat UI with createChatUI and static primitives. Slots replace render callbacks."
+keywords:
+ - tanstack ai
+ - createChatUI
+ - vue
+ - headless ui
+ - ToolProps
+---
+
+Install `@tanstack/ai-vue-ui`. Call `createChatUI(chatOptions)` once. Pass the descriptor as `ui` into `UIChat`, `UIProvider`, and the other static primitives.
+
+`defineComponents` needs a `tools` entry for every tool name in `chatOptions`. It also needs an `interrupts.generic` entry for every interrupt id. `generic.fallback` is optional.
+
+The server route matches the [React page](./react). Use `gpt-5.6` on the OpenAI text adapter.
+
+## Client
+
+```ts
+import { defineComponent, h } from 'vue'
+import { fetchServerSentEvents, useChat } from '@tanstack/ai-vue'
+import { createChatUI, 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 = createChatUI(chatOptions)
+
+const components = ui.defineComponents({
+ layout: defineComponent((_, { slots }) => () =>
+ h('div', [slots.messages?.(), slots.interrupts?.(), slots.input?.()]),
+ ),
+ 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)
+ },
+ }),
+ },
+})
+
+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 `createChatUI`.
+
+```ts
+import { defineComponent, h } from 'vue'
+import { fetchServerSentEvents } from '@tanstack/ai-vue'
+import { createChatUI, 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 = createChatUI(chatOptions)
+
+export const components = ui.defineComponents({
+ layout: defineComponent((_, { slots }) => () =>
+ h('div', [slots.messages?.(), slots.interrupts?.(), slots.input?.()]),
+ ),
+ message: defineComponent((_, { slots }) => () => h('article', slots.parts?.())),
+ parts: { fallback: defineComponent(() => () => null) },
+ tools: { getWeather: WeatherTool },
+})
+```
+
+Part components use `PartProps`. Then `part` is already a text part.
+
+Interrupt components use `InterruptProps`. Then `interrupt.payload` matches the definition.
+
+Mapped components do not receive `chat` as a prop. Call `ui.useChat()` when a component needs live chat. That call opts the component into chat updates. Nested children can call it too.
+
+## 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 { createChatUI, UIChat } from '@tanstack/ai-vue-ui'
+
+const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+}
+
+const ui = createChatUI(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 sit in the tool when you read the `interrupt` prop. Put a component on `interrupts.tools` to send that approval to the list instead. 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..84b0f5180e 100644
--- a/examples/ag-ui/src/App.tsx
+++ b/examples/ag-ui/src/App.tsx
@@ -1,12 +1,71 @@
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, createChatUI } from '@tanstack/ai-react-ui'
+
+const UI = createChatUI({})
+
+function AgUiChat({
+ connection,
+ placeholder,
+ emptyLabel,
+}: {
+ connection: ConnectionAdapter
+ placeholder: string
+ emptyLabel: string
+}) {
+ const chat = useChat({ connection })
+ const [draft, setDraft] = useState('')
+ const components = UI.defineComponents({
+ layout: ({ renderMessages, renderInput }) => {
+ const current = UI.useChat()
+ return (
+
-
+ placeholder={`Message via ${active.label} + ${activeProvider.label}…`}
+ emptyLabel={`Chat with ${active.label} over AG-UI SSE using ${activeProvider.label}.`}
+ />
) : (
)}
diff --git a/examples/ts-react-ui-chatbot/.gitignore b/examples/ts-react-ui-chatbot/.gitignore
new file mode 100644
index 0000000000..4ece634c04
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/.gitignore
@@ -0,0 +1,11 @@
+node_modules
+.DS_Store
+dist
+dist-ssr
+*.local
+.env
+.nitro
+.tanstack
+.output
+.vinxi
+.data
diff --git a/examples/ts-react-ui-chatbot/README.md b/examples/ts-react-ui-chatbot/README.md
new file mode 100644
index 0000000000..b7d7e829c7
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/README.md
@@ -0,0 +1,12 @@
+# Typed headless chat + shadcn AI components
+
+One-route TanStack Start app. It uses `createChatUI()` from `@tanstack/ai-react-ui` and the chatbot pieces from [shadcn.io/ai/chatbot](https://www.shadcn.io/ai/chatbot): Conversation, Message, Prompt Input, Model Selector, Reasoning, Sources, plus Tool for approvals.
+
+```bash
+pnpm install
+cp env.example .env
+# set OPENAI_API_KEY
+pnpm --filter ts-react-ui-chatbot dev
+```
+
+Open http://localhost:3000.
diff --git a/examples/ts-react-ui-chatbot/env.example b/examples/ts-react-ui-chatbot/env.example
new file mode 100644
index 0000000000..e570b8b559
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/env.example
@@ -0,0 +1 @@
+OPENAI_API_KEY=
diff --git a/examples/ts-react-ui-chatbot/package.json b/examples/ts-react-ui-chatbot/package.json
new file mode 100644
index 0000000000..9508019edc
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/package.json
@@ -0,0 +1,48 @@
+{
+ "name": "ts-react-ui-chatbot",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite dev --port 3000",
+ "build": "vite build",
+ "serve": "vite preview",
+ "test:types": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@radix-ui/react-slot": "^1.2.4",
+ "@tailwindcss/vite": "^4.1.18",
+ "@tanstack/ai": "workspace:*",
+ "@tanstack/ai-client": "workspace:*",
+ "@tanstack/ai-openai": "workspace:*",
+ "@tanstack/ai-react": "workspace:*",
+ "@tanstack/ai-react-ui": "workspace:*",
+ "@tanstack/react-devtools": "^0.9.10",
+ "@tanstack/react-router": "^1.158.4",
+ "@tanstack/react-router-devtools": "^1.158.4",
+ "@tanstack/react-start": "^1.159.0",
+ "@tanstack/router-plugin": "^1.158.4",
+ "class-variance-authority": "^0.7.1",
+ "clsx": "^2.1.1",
+ "lucide-react": "^0.561.0",
+ "nitro": "3.0.260610-beta",
+ "radix-ui": "^1.4.3",
+ "react": "^19.2.3",
+ "react-dom": "^19.2.3",
+ "react-markdown": "^10.1.0",
+ "remark-gfm": "^4.0.1",
+ "tailwind-merge": "^3.4.0",
+ "tailwindcss": "^4.1.18",
+ "tw-animate-css": "^1.4.0",
+ "use-stick-to-bottom": "^1.1.1",
+ "zod": "^4.2.0"
+ },
+ "devDependencies": {
+ "@tanstack/devtools-vite": "^0.5.3",
+ "@types/node": "^24.10.1",
+ "@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
+ "@vitejs/plugin-react": "^5.2.0",
+ "typescript": "5.9.3",
+ "vite": "^8.2.1"
+ }
+}
diff --git a/examples/ts-react-ui-chatbot/public/brand/fonts/BricolageGrotesque-Bold.ttf b/examples/ts-react-ui-chatbot/public/brand/fonts/BricolageGrotesque-Bold.ttf
new file mode 100644
index 0000000000..ea2465b48a
Binary files /dev/null and b/examples/ts-react-ui-chatbot/public/brand/fonts/BricolageGrotesque-Bold.ttf differ
diff --git a/examples/ts-react-ui-chatbot/public/brand/fonts/Inter-Regular.ttf b/examples/ts-react-ui-chatbot/public/brand/fonts/Inter-Regular.ttf
new file mode 100644
index 0000000000..399a6e0c3f
Binary files /dev/null and b/examples/ts-react-ui-chatbot/public/brand/fonts/Inter-Regular.ttf differ
diff --git a/examples/ts-react-ui-chatbot/public/brand/fonts/Inter-latin.woff2 b/examples/ts-react-ui-chatbot/public/brand/fonts/Inter-latin.woff2
new file mode 100644
index 0000000000..b0d0e2e5c6
Binary files /dev/null and b/examples/ts-react-ui-chatbot/public/brand/fonts/Inter-latin.woff2 differ
diff --git a/examples/ts-react-ui-chatbot/public/brand/fonts/OFL-Bricolage-Grotesque.txt b/examples/ts-react-ui-chatbot/public/brand/fonts/OFL-Bricolage-Grotesque.txt
new file mode 100644
index 0000000000..4dfd4fe066
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/public/brand/fonts/OFL-Bricolage-Grotesque.txt
@@ -0,0 +1,93 @@
+Copyright 2022 The Bricolage Grotesque Project Authors (https://github.com/ateliertriay/bricolage)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/examples/ts-react-ui-chatbot/public/brand/logos/tanstack-emblem-cream.svg b/examples/ts-react-ui-chatbot/public/brand/logos/tanstack-emblem-cream.svg
new file mode 100644
index 0000000000..ce610b729e
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/public/brand/logos/tanstack-emblem-cream.svg
@@ -0,0 +1 @@
+
diff --git a/examples/ts-react-ui-chatbot/public/brand/logos/tanstack-landscape-white.svg b/examples/ts-react-ui-chatbot/public/brand/logos/tanstack-landscape-white.svg
new file mode 100644
index 0000000000..50ad4162f5
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/public/brand/logos/tanstack-landscape-white.svg
@@ -0,0 +1,23 @@
+
+
diff --git a/examples/ts-react-ui-chatbot/public/favicon.ico b/examples/ts-react-ui-chatbot/public/favicon.ico
new file mode 100644
index 0000000000..a11777cc47
Binary files /dev/null and b/examples/ts-react-ui-chatbot/public/favicon.ico differ
diff --git a/examples/ts-react-ui-chatbot/public/tanstack-circle-logo.png b/examples/ts-react-ui-chatbot/public/tanstack-circle-logo.png
new file mode 100644
index 0000000000..9db3e67bad
Binary files /dev/null and b/examples/ts-react-ui-chatbot/public/tanstack-circle-logo.png differ
diff --git a/examples/ts-react-ui-chatbot/public/tanstack-word-logo-white.svg b/examples/ts-react-ui-chatbot/public/tanstack-word-logo-white.svg
new file mode 100644
index 0000000000..b6ec5086c2
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/public/tanstack-word-logo-white.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/examples/ts-react-ui-chatbot/src/chat/byok.ts b/examples/ts-react-ui-chatbot/src/chat/byok.ts
new file mode 100644
index 0000000000..c902cf3d37
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/src/chat/byok.ts
@@ -0,0 +1,9 @@
+import { defineByok, defaultByokStorage } from '@tanstack/ai-client/byok'
+
+export const byok = defineByok({
+ storage: defaultByokStorage(),
+})
+
+// Env OPENAI_API_KEY on the server can fill the request. Do not block send
+// when the browser has no saved key.
+byok.setServerCoverage(true)
diff --git a/examples/ts-react-ui-chatbot/src/chat/interrupts.ts b/examples/ts-react-ui-chatbot/src/chat/interrupts.ts
new file mode 100644
index 0000000000..67dbed365c
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/src/chat/interrupts.ts
@@ -0,0 +1,17 @@
+import { defineInterrupt } from '@tanstack/ai'
+import { z } from 'zod'
+
+export const BUDGET_OPTIONS = ['thrifty', 'comfort', 'splash'] as const
+
+export const chooseBudget = defineInterrupt({
+ id: 'chooseBudget',
+ payloadSchema: z.object({
+ city: z.string(),
+ options: z.array(z.enum(BUDGET_OPTIONS)),
+ }),
+ responseSchema: z.object({
+ budget: z.enum(BUDGET_OPTIONS),
+ }),
+})
+
+export const chatInterrupts = [chooseBudget]
diff --git a/examples/ts-react-ui-chatbot/src/chat/model.ts b/examples/ts-react-ui-chatbot/src/chat/model.ts
new file mode 100644
index 0000000000..9c2910f112
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/src/chat/model.ts
@@ -0,0 +1,7 @@
+import type { ChatModelId } from '@/components/ai/model-selector'
+
+export let selectedModel: ChatModelId = 'gpt-5.5'
+
+export function setSelectedModel(next: ChatModelId) {
+ selectedModel = next
+}
diff --git a/examples/ts-react-ui-chatbot/src/chat/options.ts b/examples/ts-react-ui-chatbot/src/chat/options.ts
new file mode 100644
index 0000000000..73870c0f50
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/src/chat/options.ts
@@ -0,0 +1,17 @@
+import { fetchServerSentEvents } from '@tanstack/ai-react'
+import { createChatUI } from '@tanstack/ai-react-ui'
+import { byok } from './byok'
+import { chatInterrupts } from './interrupts'
+import { itinerarySchema } from './schema'
+import { clientTools } from './tools'
+
+export const chatOptions = {
+ connection: fetchServerSentEvents('/api/chat'),
+ tools: clientTools,
+ interrupts: chatInterrupts,
+ outputSchema: itinerarySchema,
+ byok,
+ byokProvider: () => 'openai' as const,
+}
+
+export const UI = createChatUI(chatOptions)
diff --git a/examples/ts-react-ui-chatbot/src/chat/schema.ts b/examples/ts-react-ui-chatbot/src/chat/schema.ts
new file mode 100644
index 0000000000..76bcb1fa77
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/src/chat/schema.ts
@@ -0,0 +1,12 @@
+import { z } from 'zod'
+
+export const itinerarySchema = z.object({
+ title: z.string(),
+ summary: z.string(),
+ days: z.array(
+ z.object({
+ label: z.string(),
+ plan: z.string(),
+ }),
+ ),
+})
diff --git a/examples/ts-react-ui-chatbot/src/chat/tools.ts b/examples/ts-react-ui-chatbot/src/chat/tools.ts
new file mode 100644
index 0000000000..278a2e47b6
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/src/chat/tools.ts
@@ -0,0 +1,48 @@
+import { toolDefinition } from '@tanstack/ai'
+import { z } from 'zod'
+
+export const lookupPlace = toolDefinition({
+ name: 'lookupPlace',
+ description: 'Look up a place, weather, and citation links.',
+ inputSchema: z.object({ city: z.string() }),
+ outputSchema: z.object({
+ city: z.string(),
+ blurb: z.string(),
+ sources: z.array(z.object({ title: z.string(), url: z.string() })),
+ }),
+})
+
+export const bookStay = toolDefinition({
+ name: 'bookStay',
+ description: 'Hold a hotel or guesthouse. Needs approval before it runs.',
+ needsApproval: true,
+ inputSchema: z.object({
+ city: z.string(),
+ nights: z.number(),
+ }),
+ outputSchema: z.object({
+ ok: z.boolean(),
+ confirmation: z.string(),
+ }),
+})
+
+export const confirmPayment = toolDefinition({
+ name: 'confirmPayment',
+ description:
+ 'Demo hold for a trip amount. Call this when the user asks to pay or hold a charge. Not a real card charge.',
+ needsApproval: true,
+ inputSchema: z.object({
+ city: z.string(),
+ amount: z.number(),
+ }),
+ outputSchema: z.object({
+ ok: z.boolean(),
+ receipt: z.string(),
+ }),
+})
+
+export const clientTools = [
+ lookupPlace.client(),
+ bookStay.client(),
+ confirmPayment.client(),
+]
diff --git a/examples/ts-react-ui-chatbot/src/components/ai/conversation.tsx b/examples/ts-react-ui-chatbot/src/components/ai/conversation.tsx
new file mode 100644
index 0000000000..e016d9b095
--- /dev/null
+++ b/examples/ts-react-ui-chatbot/src/components/ai/conversation.tsx
@@ -0,0 +1,103 @@
+import type { ComponentProps } from 'react'
+import { useCallback } from 'react'
+import { ArrowDownIcon } from 'lucide-react'
+import { StickToBottom, useStickToBottomContext } from 'use-stick-to-bottom'
+import { Button } from '@/components/ui/button'
+import cn from '@/utils/cn'
+
+export type ConversationProps = ComponentProps
+
+export function Conversation({ className, ...props }: ConversationProps) {
+ return (
+
+ )
+}
+
+export type ConversationContentProps = ComponentProps<
+ typeof StickToBottom.Content
+>
+
+export function ConversationContent({
+ className,
+ ...props
+}: ConversationContentProps) {
+ return (
+
+ )
+}
+
+export type ConversationEmptyStateProps = ComponentProps<'div'> & {
+ title?: string
+ description?: string
+ icon?: React.ReactNode
+}
+
+export function ConversationEmptyState({
+ className,
+ title = 'No messages yet',
+ description = 'Start a conversation to see messages here',
+ icon,
+ children,
+ ...props
+}: ConversationEmptyStateProps) {
+ return (
+