Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/ai-ui-message-stream-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@tanstack/ai-ui-message-stream': minor
'@tanstack/ai-client': patch
---

TanStack `useChat` gets more from an AI SDK route through `fromChatTransport`:

- Client tools go to the route in the request body. Read them on the route with `clientToolsFromAISDKBody(body, serverTools)`, so the route needs no copy.
- With `outputSchema`, an `Output.object` answer is a typed `structured-output` part with partial objects.
- A dropped stream reconnects with the backoff of the TanStack HTTP connections. Set it with the `reconnect` option.

`@tanstack/ai-client` exports `createReconnectTracker` and its `ReconnectTracker` type.
10 changes: 10 additions & 0 deletions .changeset/ai-ui-message-stream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@tanstack/ai-ui-message-stream': minor
---

Add `@tanstack/ai-ui-message-stream`, a stop gap for porting an app from the Vercel AI SDK one side at a time. It connects TanStack AI to the AI SDK UI message stream.

- Serve AI SDK `useChat` from `chat()` with `chatParamsFromAISDKRequest` and `toUIMessageStream`. Use `toResumableUIMessageStreamResponse` and `resumeUIMessageStreamResponse` for `useChat({ resume: true })`.
- Point TanStack `useChat` at an AI SDK route with `fromChatTransport(new DefaultChatTransport({ api }), { threadId, tools })`.

Text, reasoning with its signature, tool calls and results, client tools, tool approvals, activity, subagents and stream resume cross in both directions. Shared state goes from a TanStack server to an AI SDK client.
17 changes: 16 additions & 1 deletion docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -1951,13 +1951,28 @@
"label": "From Vercel AI SDK",
"to": "migration/migration-from-vercel-ai",
"addedAt": "2026-04-20",
"updatedAt": "2026-10-22"
"updatedAt": "2026-10-23"
},
{
"label": "Vercel AI SDK: full API mapping",
"to": "migration/migration-from-vercel-ai-advanced",
"addedAt": "2026-10-21"
},
{
"label": "AI SDK useChat with a TanStack AI server",
"to": "migration/ai-sdk-client-tanstack-server",
"addedAt": "2026-10-23"
},
{
"label": "TanStack useChat with an AI SDK server",
"to": "migration/tanstack-client-ai-sdk-server",
"addedAt": "2026-10-23"
},
{
"label": "AI SDK bridge compatibility",
"to": "migration/ai-sdk-compatibility",
"addedAt": "2026-10-23"
},
{
"label": "AG-UI Client Compliance",
"to": "migration/ag-ui-compliance",
Expand Down
262 changes: 262 additions & 0 deletions docs/migration/ai-sdk-client-tanstack-server.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
---
title: AI SDK useChat with a TanStack AI server
id: ai-sdk-client-tanstack-server
order: 3
description: "Serve the Vercel AI SDK useChat hook from TanStack AI chat(). Read the request with chatParamsFromAISDKRequest and convert the reply with toUIMessageStream. Your AI SDK UI does not change."
keywords:
- tanstack ai
- vercel ai sdk
- useChat
- ai elements
- ui message stream
- toUIMessageStream
- tool approval
- resumable streams
- migration
---

Serve the AI SDK `useChat` hook from TanStack AI `chat()`. Read the request with `chatParamsFromAISDKRequest`, and convert the reply with `toUIMessageStream`. Your AI SDK client code, AI Elements included, does not change.

Use this page to move the server first. To move the client first, see [TanStack useChat with an AI SDK server](./tanstack-client-ai-sdk-server).

> [!WARNING]
> This bridge is a stop gap while you port. Some features change shape or are dropped on the way (see [AI SDK bridge compatibility](./ai-sdk-compatibility)). When the server works, port the client to TanStack `useChat` with the [migration guide](./migration-from-vercel-ai), then remove the bridge.

## 1. Install the packages

<!-- ::start:tabs variant="package-manager" mode="install" -->

react: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai
vue: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai
solid: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai
svelte: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai
preact: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai
angular: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai
octane: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai
vanilla: @tanstack/ai @tanstack/ai-ui-message-stream @tanstack/ai-openai

<!-- ::end:tabs -->

Keep the `ai` package. The server uses its response helper, and your client still uses `useChat`.

## 2. Replace the server route

```typescript
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import {
chatParamsFromAISDKRequest,
toUIMessageStream,
} from '@tanstack/ai-ui-message-stream'
import { createUIMessageStreamResponse } from 'ai'

export async function POST(request: Request) {
const params = await chatParamsFromAISDKRequest(request)

const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
...params,
})

return createUIMessageStreamResponse({ stream: toUIMessageStream(stream) })
}
```

What each call does:

- `chatParamsFromAISDKRequest` reads the body that `useChat` sends. It returns the `threadId` (the chat id), the `messages`, and the answers to tool approvals. If the body is not valid, it throws a `Response` with status 400.
- `toUIMessageStream` converts the events of `chat()` into the AI SDK UI message stream.

## 3. Keep the client

The client from your AI SDK app works as it is:

```tsx
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'

export function Chat() {
const { messages, sendMessage } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
})

return (
<div>
{messages.map((m) => (
<div key={m.id}>
{m.parts.map((p, i) => (p.type === 'text' ? <span key={i}>{p.text}</span> : null))}
</div>
))}
<button onClick={() => sendMessage({ text: 'Hello' })}>Send</button>
</div>
)
}
```

## 4. Add tools

Server tools run in `chat()`. The client shows each call as a `tool-<name>` part with its input and output.

- **Client tool:** a tool definition with no `.server()` implementation. `chat()` stops at the call, and the AI SDK client runs it in `onToolCall`.
- **Tool approval:** set `needsApproval: true`. `chat()` stops before the tool runs, and the client shows the part in the `approval-requested` state.

```typescript
import { chat, toolDefinition } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import {
chatParamsFromAISDKRequest,
toUIMessageStream,
} from '@tanstack/ai-ui-message-stream'
import { createUIMessageStreamResponse } from 'ai'
import { z } from 'zod'

const getWeather = toolDefinition({
name: 'getWeather',
description: 'Get the weather for a city',
inputSchema: z.object({ city: z.string() }),
}).server(({ city }) => ({ city, temperature: 72 }))

const bookFlight = toolDefinition({
name: 'bookFlight',
description: 'Book a flight',
inputSchema: z.object({ to: z.string() }),
needsApproval: true,
}).server(({ to }) => ({ booked: to }))

export async function POST(request: Request) {
const params = await chatParamsFromAISDKRequest(request)

const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
tools: [getWeather, bookFlight],
...params,
})

return createUIMessageStreamResponse({ stream: toUIMessageStream(stream) })
}
```

On the client, answer the approval with `addToolApprovalResponse`. Then `sendAutomaticallyWhen` sends the answer to the server, and `chat()` runs or skips the tool.

The server keeps the id of the paused run in the message metadata, under `tanstack.runId`. If you validate metadata with `messageMetadataSchema`, allow that key.

```tsx
import { useChat } from '@ai-sdk/react'
import {
DefaultChatTransport,
lastAssistantMessageIsCompleteWithApprovalResponses,
} from 'ai'

export function Chat() {
const { messages, addToolApprovalResponse } = useChat({
transport: new DefaultChatTransport({ api: '/api/chat' }),
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
})

return messages.map((m) =>
m.parts.map((part) =>
part.type === 'tool-bookFlight' && part.state === 'approval-requested' ? (
<button
key={part.toolCallId}
onClick={() =>
addToolApprovalResponse({ id: part.approval.id, approved: true })
}
>
Book the flight
</button>
) : null,
),
)
}
```

## 5. Show activity, state and subagents

`toUIMessageStream` sends each of these as a data part. The client replaces a data part in place when it gets a new value for the same id.

- **Activity:** a `data-<activityType>` part with the activity id.
- **Shared state:** a `data-state` part with the full state.
- **Subagent:** a `data-subagent` part per child, with its name, status and text. Its data has the type `SubagentData`.

Type the data parts on `useChat` to read them:

```tsx
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import type { SubagentData } from '@tanstack/ai-ui-message-stream'
import type { UIMessage } from 'ai'

type ChatMessage = UIMessage<unknown, { subagent: SubagentData }>

export function Subagents() {
const { messages } = useChat<ChatMessage>({
transport: new DefaultChatTransport({ api: '/api/chat' }),
})

return messages.flatMap((m) =>
m.parts.map((part) =>
part.type === 'data-subagent' ? (
<p key={part.id}>
{part.data.name}: {part.data.status}
</p>
) : null,
),
)
}
```

The AI SDK client does not send state back. A change to the state on the client does not reach `chat()`.

## 6. Resume a stream after a reload

AI SDK `useChat({ resume: true })` asks `GET /api/chat/<id>/stream` for the active run when the page loads. To answer it, record each run in a durability log, and keep a record of the active run of each chat. This example uses the runs store of [`@tanstack/ai-persistence`](../persistence/overview):

```typescript
import { chat, memoryStream } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { withPersistence } from '@tanstack/ai-persistence'
import {
chatParamsFromAISDKRequest,
resumeUIMessageStreamResponse,
toResumableUIMessageStreamResponse,
} from '@tanstack/ai-ui-message-stream'
import { persistence } from './persistence'

// POST /api/chat
export async function POST(request: Request) {
const params = await chatParamsFromAISDKRequest(request)
const runId = crypto.randomUUID()

const stream = chat({
adapter: openaiText('gpt-6.1-sol'),
runId,
middleware: [withPersistence(persistence)],
...params,
})

return toResumableUIMessageStreamResponse(stream, memoryStream({ runId }))
}

// GET /api/chat/:id/stream
export async function GET(request: Request) {
const chatId = new URL(request.url).pathname.split('/').at(-2) ?? ''
const run = await persistence.stores.runs.findActiveRun(chatId)
return resumeUIMessageStreamResponse(run && memoryStream({ runId: run.runId }))
}
```

- `toResumableUIMessageStreamResponse` writes the run to the log. The run continues when the client goes away.
- `resumeUIMessageStreamResponse` replays the run from its start. With no active run, it returns 204, and the client does nothing.
- `memoryStream` keeps the log in the memory of one process. For more than one server, use [`@tanstack/ai-durable-stream`](../resumable-streams/overview).

## What does not go through

- A change to shared state on the client. The AI SDK client does not send state.
- The `reason` text of an approval answer.
- A `stop()` on the client stops the reading, not the run. To stop the run, pass an `abortController` to `chat()`.

For every event and message part, see [AI SDK bridge compatibility](./ai-sdk-compatibility).

## Done

Your AI SDK `useChat` UI streams replies, tools, approvals, activity and subagents from `chat()`, and it continues a reply after a reload.
Loading
Loading