+ )
+}
```
Override the configured policy for a single send with the second argument
to `sendMessage`:
```typescript
+import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
+
+const { sendMessage } = useChat({
+ connection: fetchServerSentEvents('/api/chat'),
+})
+
sendMessage('Never mind, do this instead', { whenBusy: 'interrupt' })
```
@@ -561,63 +594,100 @@ option, so it works identically in `@tanstack/ai-react`, `-solid`, `-vue`,
// WRONG
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
-const result = streamText({ model: openai('gpt-5.5'), messages })
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+const result = streamText({ model: openai('gpt-5.6'), messages })
+```
+
+```typescript
// CORRECT
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
-const stream = chat({ adapter: openaiText('gpt-5.5'), messages })
+
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+const stream = chat({ adapter: openaiText('gpt-5.6'), messages })
```
### b. CRITICAL: Using Vercel createOpenAI() provider pattern
```typescript
// WRONG
+import { streamText } from 'ai'
import { createOpenAI } from '@ai-sdk/openai'
-const openai = createOpenAI({ apiKey })
-streamText({ model: openai('gpt-5.5'), messages })
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+const openai = createOpenAI({ apiKey: process.env.OPENAI_API_KEY })
+streamText({ model: openai('gpt-5.6'), messages })
+```
+
+```typescript
// CORRECT
import { openaiText } from '@tanstack/ai-openai'
import { chat } from '@tanstack/ai'
-chat({ adapter: openaiText('gpt-5.5'), messages })
+
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+chat({ adapter: openaiText('gpt-5.6'), messages })
```
### c. CRITICAL: Using monolithic openai() instead of openaiText()
-```typescript
-// WRONG
+```typescript ignore
+// WRONG — `openai()` is no longer exported from @tanstack/ai-openai
import { openai } from '@tanstack/ai-openai'
-chat({ adapter: openai(), model: 'gpt-5.5', messages })
+chat({ adapter: openai(), model: 'gpt-5.6', messages })
+```
+```typescript
// CORRECT
+import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
-chat({ adapter: openaiText('gpt-5.5'), messages })
+
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+chat({ adapter: openaiText('gpt-5.6'), messages })
```
-The monolithic `openai()` adapter is deprecated. Use tree-shakeable adapters:
+The monolithic `openai()` adapter no longer exists. Use tree-shakeable adapters:
`openaiText()`, `openaiImage()`, `openaiSpeech()`, etc.
### d. HIGH: Using toResponseStream instead of toServerSentEventsResponse
-```typescript
-// WRONG
+```typescript ignore
+// WRONG — toResponseStream does not exist
import { toResponseStream } from '@tanstack/ai'
return toResponseStream(stream, { abortController })
+```
+```typescript
// CORRECT
-import { toServerSentEventsResponse } from '@tanstack/ai'
-return toServerSentEventsResponse(stream, { abortController })
+import { chat, toServerSentEventsResponse } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+ const abortController = new AbortController()
+ const stream = chat({
+ adapter: openaiText('gpt-5.6'),
+ messages,
+ abortController,
+ })
+ return toServerSentEventsResponse(stream, { abortController })
+}
```
### e. HIGH: Passing model as separate parameter to chat()
-```typescript
+```typescript ignore
// WRONG
-chat({ adapter: openaiText(), model: 'gpt-5.5', messages })
+chat({ adapter: openaiText(), model: 'gpt-5.6', messages })
+```
+```typescript
// CORRECT
-chat({ adapter: openaiText('gpt-5.5'), messages })
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+chat({ adapter: openaiText('gpt-5.6'), messages })
```
The model is passed to the adapter factory, not to `chat()`.
@@ -628,22 +698,30 @@ Sampling options (`temperature`, token limits, `top_p`/`topP`) are **not**
top-level fields on `chat()`. They live inside `modelOptions` using the
provider's native key.
-```typescript
+```typescript ignore
// WRONG — temperature/maxTokens are not root options
chat({ adapter, messages, temperature: 0.7, maxTokens: 1000 })
// WRONG — there is no `options` field either
chat({ adapter, messages, options: { temperature: 0.7, maxTokens: 1000 } })
+```
+```typescript
// CORRECT — inside modelOptions, provider-native keys (OpenAI shown)
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+
chat({
- adapter,
+ adapter: openaiText('gpt-5.6'),
messages,
modelOptions: { temperature: 0.7, max_output_tokens: 1000 },
})
```
-`temperature` is universal across providers; token limits use provider-native
+`temperature` works on most models (Claude 5 models reject sampling
+parameters; see ai-core/adapter-configuration/SKILL.md). Token limits use provider-native
keys (`max_output_tokens` for OpenAI, `max_tokens` for Anthropic/Grok,
`maxOutputTokens` for Gemini, `max_completion_tokens` for Groq,
`maxCompletionTokens` for OpenRouter, and `num_predict` nested under
@@ -651,19 +729,26 @@ keys (`max_output_tokens` for OpenAI, `max_tokens` for Anthropic/Grok,
### g. HIGH: Using providerOptions instead of modelOptions
-```typescript
+```typescript ignore
// WRONG
chat({
adapter,
messages,
- providerOptions: { responseFormat: { type: 'json_object' } },
+ providerOptions: { text: { format: { type: 'json_object' } } },
})
+```
+
+```typescript
+// CORRECT — provider-native option under modelOptions (OpenAI Responses shown)
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+const messages = [{ role: 'user' as const, content: 'Hello' }]
-// CORRECT
chat({
- adapter,
+ adapter: openaiText('gpt-5.6'),
messages,
- modelOptions: { responseFormat: { type: 'json_object' } },
+ modelOptions: { text: { format: { type: 'json_object' } } },
})
```
@@ -671,23 +756,44 @@ chat({
```typescript
// WRONG
-const readable = new ReadableStream({
- async start(controller) {
- const encoder = new TextEncoder()
- for await (const chunk of stream) {
- controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`))
- }
- controller.enqueue(encoder.encode('data: [DONE]\n\n'))
- controller.close()
- },
-})
-return new Response(readable, {
- headers: { 'Content-Type': 'text/event-stream' },
-})
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+ const stream = chat({ adapter: openaiText('gpt-5.6'), messages })
+
+ const readable = new ReadableStream({
+ async start(controller) {
+ const encoder = new TextEncoder()
+ for await (const chunk of stream) {
+ controller.enqueue(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`))
+ }
+ controller.enqueue(encoder.encode('data: [DONE]\n\n'))
+ controller.close()
+ },
+ })
+ return new Response(readable, {
+ headers: { 'Content-Type': 'text/event-stream' },
+ })
+}
+```
+```typescript
// CORRECT
-import { toServerSentEventsResponse } from '@tanstack/ai'
-return toServerSentEventsResponse(stream, { abortController })
+import { chat, toServerSentEventsResponse } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+ const abortController = new AbortController()
+ const stream = chat({
+ adapter: openaiText('gpt-5.6'),
+ messages,
+ abortController,
+ })
+ return toServerSentEventsResponse(stream, { abortController })
+}
```
`toServerSentEventsResponse` handles SSE formatting, abort signals,
@@ -695,8 +801,8 @@ error events (RUN_ERROR), and correct headers automatically.
### i. HIGH: Implementing custom onEnd/onFinish callbacks instead of middleware
-```typescript
-// WRONG
+```typescript ignore
+// WRONG — chat() has no onEnd/onFinish option
chat({
adapter,
messages,
@@ -704,9 +810,14 @@ chat({
trackAnalytics(result)
},
})
+```
+```typescript
// CORRECT
+import { chat } from '@tanstack/ai'
import type { ChatMiddleware } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { trackAnalytics, trackTokens } from './analytics'
const analytics: ChatMiddleware = {
name: 'analytics',
@@ -718,7 +829,8 @@ const analytics: ChatMiddleware = {
},
}
-chat({ adapter, messages, middleware: [analytics] })
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+chat({ adapter: openaiText('gpt-5.6'), messages, middleware: [analytics] })
```
`chat()` has no `onEnd`/`onFinish` option. Use `middleware` for lifecycle events.
@@ -730,7 +842,9 @@ See also: ai-core/middleware/SKILL.md.
// WRONG
import { fetchServerSentEvents } from '@tanstack/ai-client'
import { useChat } from '@tanstack/ai-react'
+```
+```typescript
// CORRECT
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
```
@@ -746,9 +860,15 @@ exceptions. The `useChat` hook surfaces these via the `error` state and
check for `RUN_ERROR` chunks:
```typescript
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+const stream = chat({ adapter: openaiText('gpt-5.6'), messages })
+
for await (const chunk of stream) {
if (chunk.type === 'RUN_ERROR') {
- console.error('Stream error:', chunk.error.message)
+ console.error('Stream error:', chunk.message)
break
}
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
diff --git a/packages/ai/skills/ai-core/client-persistence/SKILL.md b/packages/ai/skills/ai-core/client-persistence/SKILL.md
index 0991800791..d0395c91d0 100644
--- a/packages/ai/skills/ai-core/client-persistence/SKILL.md
+++ b/packages/ai/skills/ai-core/client-persistence/SKILL.md
@@ -61,6 +61,12 @@ required for normal use.
## Mode A — cache everything (client-authoritative)
```tsx
+import {
+ useChat,
+ fetchServerSentEvents,
+ localStoragePersistence,
+} from '@tanstack/ai-react'
+
function Chat() {
const { messages, sendMessage } = useChat({
threadId: 'support-chat', // stable — required
@@ -79,6 +85,8 @@ Best for: SPA, offline-first, single device, moderate conversation size.
## Mode B — server-authoritative (`persistence: true`)
```tsx
+import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
+
function Chat({ threadId }: { threadId: string }) {
const { messages, sendMessage } = useChat({
threadId,
@@ -135,18 +143,22 @@ The hook return is exactly `generate` / `result` / `isLoading` / `error` /
### Turning it on (`persistence: true`)
```tsx
-const image = useGenerateImage({
- threadId, // REQUIRED — the scope the last generation is hydrated under
- connection: fetchServerSentEvents('/api/generate/image'),
- persistence: true,
-})
-// After a reload: image.status / image.result / image.error are the last
-// generation for `threadId`, fetched from the server — nothing was cached.
+import { useGenerateImage, fetchServerSentEvents } from '@tanstack/ai-react'
+
+function ImageGenerator({ threadId }: { threadId: string }) {
+ const image = useGenerateImage({
+ threadId, // REQUIRED — the scope the last generation is hydrated under
+ connection: fetchServerSentEvents('/api/generate/image'),
+ persistence: true,
+ })
+ // After a reload: image.status / image.result / image.error are the last
+ // generation for `threadId`, fetched from the server — nothing was cached.
+}
```
The server half — the same route handles the run and the hydration `GET`:
-```ts
+```ts group=generation-persistence
import {
generateImage,
generationParamsFromRequest,
@@ -222,7 +234,7 @@ export function GET(request: Request) {
(`stores.artifacts` + `stores.blobs`) AND `withGenerationPersistence` is given an
`artifactUrl` mapper:
-```ts
+```ts group=generation-persistence
withGenerationPersistence(persistence, {
artifactUrl: (ref) => `/api/generate/image/artifact?id=${ref.artifactId}`,
})
diff --git a/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md b/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md
index 7e0a63e17f..3126805a2a 100644
--- a/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md
+++ b/packages/ai/skills/ai-core/custom-backend-integration/SKILL.md
@@ -22,8 +22,9 @@ This skill builds on ai-core and ai-core/chat-experience. Read them first.
Connect `useChat` to a custom SSE backend with auth headers:
-```typescript
+```tsx
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
+import { token } from './auth'
function Chat() {
const { messages, sendMessage, isLoading } = useChat({
@@ -69,6 +70,7 @@ framing. This is the recommended default.
```typescript
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
+import { token, tenantId } from './auth'
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents('https://my-api.com/chat', {
@@ -85,6 +87,7 @@ const { messages, sendMessage } = useChat({
```typescript
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
+import { sessionId, getAccessToken } from './auth'
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents(
@@ -95,7 +98,7 @@ const { messages, sendMessage } = useChat({
},
body: {
provider: 'openai',
- model: 'gpt-4o',
+ model: 'gpt-5.5',
},
}),
),
@@ -110,6 +113,10 @@ The `body` field in options is merged into the POST request body alongside
```typescript
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
+// Same signature as globalThis.fetch — wrap it however you need.
+const myCustomFetch: typeof fetch = (input, init) =>
+ fetch(input, { ...init, credentials: 'include' })
+
const { messages, sendMessage } = useChat({
connection: fetchServerSentEvents('/api/chat', {
fetchClient: myCustomFetch,
@@ -124,6 +131,7 @@ instead of SSE. Each line is one JSON-encoded `StreamChunk` followed by `\n`.
```typescript
import { useChat, fetchHttpStream } from '@tanstack/ai-react'
+import { token } from './auth'
const { messages, sendMessage } = useChat({
connection: fetchHttpStream('https://my-api.com/chat', {
@@ -143,6 +151,7 @@ JSON object per line.
```typescript
import { useChat, fetchHttpStream } from '@tanstack/ai-react'
+import { region, refreshToken } from './auth'
const { messages, sendMessage } = useChat({
connection: fetchHttpStream(
@@ -168,15 +177,11 @@ This is the simpler model and covers most HTTP-based protocols.
```typescript
import { useChat } from '@tanstack/ai-react'
-import type { ConnectionAdapter } from '@tanstack/ai-react'
-import type { StreamChunk, UIMessage } from '@tanstack/ai'
-
-const websocketAdapter: ConnectionAdapter = {
- async *connect(
- messages: Array,
- data?: Record,
- abortSignal?: AbortSignal,
- ): AsyncGenerator {
+import type { ConnectConnectionAdapter } from '@tanstack/ai-react'
+import type { StreamChunk } from '@tanstack/ai'
+
+const websocketAdapter: ConnectConnectionAdapter = {
+ async *connect(messages, data, abortSignal) {
const ws = new WebSocket('wss://my-api.com/chat')
// Wait for connection
@@ -243,25 +248,51 @@ returns an `AsyncIterable` that stays open, and `send` dispatches
messages through it.
```typescript
-import type { StreamChunk, UIMessage } from '@tanstack/ai'
-
-// SubscribeConnectionAdapter is exported from @tanstack/ai-client
-// (not re-exported by framework packages -- use ConnectionAdapter
-// union type from @tanstack/ai-react for typing)
-const pushAdapter = {
- subscribe(abortSignal?: AbortSignal): AsyncIterable {
- // Return a long-lived async iterable that yields chunks
- // whenever the server pushes them
- return createPersistentStream(abortSignal)
+import { useChat } from '@tanstack/ai-react'
+import type { SubscribeConnectionAdapter } from '@tanstack/ai-react'
+import type { StreamChunk } from '@tanstack/ai'
+
+// One socket for the lifetime of the client; every run's chunks arrive on it.
+const ws = new WebSocket('wss://my-api.com/chat')
+const ready = new Promise((resolve) => {
+ ws.addEventListener('open', () => resolve(), { once: true })
+})
+
+const pushAdapter: SubscribeConnectionAdapter = {
+ async *subscribe(abortSignal) {
+ // Long-lived async iterable: yields chunks whenever the server pushes
+ // them, until the socket closes or the signal aborts
+ const queue: Array = []
+ let wake: (() => void) | null = null
+ let closed = false
+
+ ws.addEventListener('message', (event) => {
+ const chunk: StreamChunk = JSON.parse(event.data)
+ queue.push(chunk)
+ wake?.()
+ })
+ ws.addEventListener('close', () => {
+ closed = true
+ wake?.()
+ })
+ abortSignal?.addEventListener('abort', () => ws.close())
+
+ while (!closed || queue.length > 0) {
+ const next = queue.shift()
+ if (next !== undefined) {
+ yield next
+ continue
+ }
+ await new Promise((r) => {
+ wake = r
+ })
+ }
},
- async send(
- messages: Array,
- data?: Record,
- abortSignal?: AbortSignal,
- ): Promise {
+ async send(messages, data) {
// Dispatch messages; chunks arrive through subscribe()
- await persistentConnection.send(JSON.stringify({ messages, ...data }))
+ await ready
+ ws.send(JSON.stringify({ messages, ...data }))
},
}
@@ -279,12 +310,9 @@ a shorthand for creating a `ConnectConnectionAdapter` from an async generator:
```typescript
import { useChat, stream } from '@tanstack/ai-react'
-import type { StreamChunk, UIMessage } from '@tanstack/ai'
+import type { StreamChunk } from '@tanstack/ai'
-const directAdapter = stream(async function* (
- messages: Array,
- data?: Record,
-): AsyncGenerator {
+const directAdapter = stream(async function* (messages, data) {
const response = await fetch('https://my-api.com/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -305,7 +333,8 @@ const directAdapter = stream(async function* (
for (const line of lines) {
if (line.trim()) {
- yield JSON.parse(line) as StreamChunk
+ const chunk: StreamChunk = JSON.parse(line)
+ yield chunk
}
}
}
@@ -324,35 +353,43 @@ The `ConnectionAdapter` interface has two mutually exclusive modes. Providing
both throws at runtime.
```typescript
-// WRONG -- throws "Connection adapter must provide either connect or both
-// subscribe and send, not both modes"
-const adapter = {
+import type {
+ ConnectConnectionAdapter,
+ ConnectionAdapter,
+ SubscribeConnectionAdapter,
+} from '@tanstack/ai-react'
+import { channel } from './channel'
+
+// WRONG -- type-checks (ConnectionAdapter is a union) but throws at runtime:
+// "Connection adapter must provide either connect or both subscribe and
+// send, not both modes"
+const adapter: ConnectionAdapter = {
async *connect(messages) {
/* ... */
},
subscribe(signal) {
- /* ... */
+ return channel.chunks(signal)
},
async send(messages) {
- /* ... */
+ await channel.send(messages)
},
}
// CORRECT -- pick one mode
// Option A: ConnectConnectionAdapter (pull-based)
-const pullAdapter = {
+const pullAdapter: ConnectConnectionAdapter = {
async *connect(messages, data, abortSignal) {
// ... yield StreamChunks
},
}
// Option B: SubscribeConnectionAdapter (push-based)
-const pushAdapter = {
+const pushAdapter: SubscribeConnectionAdapter = {
subscribe(abortSignal) {
- return longLivedAsyncIterable
+ return channel.chunks(abortSignal)
},
async send(messages, data, abortSignal) {
- await connection.dispatch({ messages, ...data })
+ await channel.send({ messages, ...data }, abortSignal)
},
}
```
@@ -388,15 +425,11 @@ streaming, implement retry logic in your connection adapter:
```typescript
import { useChat } from '@tanstack/ai-react'
-import type { ConnectionAdapter } from '@tanstack/ai-react'
-import type { StreamChunk, UIMessage } from '@tanstack/ai'
-
-const resilientAdapter: ConnectionAdapter = {
- async *connect(
- messages: Array,
- data?: Record,
- abortSignal?: AbortSignal,
- ): AsyncGenerator {
+import type { ConnectConnectionAdapter } from '@tanstack/ai-react'
+import type { StreamChunk } from '@tanstack/ai'
+
+const resilientAdapter: ConnectConnectionAdapter = {
+ async *connect(messages, data, abortSignal) {
const maxRetries = 3
let attempt = 0
@@ -427,7 +460,8 @@ const resilientAdapter: ConnectionAdapter = {
for (const line of lines) {
if (line.trim()) {
- yield JSON.parse(line) as StreamChunk
+ const chunk: StreamChunk = JSON.parse(line)
+ yield chunk
}
}
}
diff --git a/packages/ai/skills/ai-core/debug-logging/SKILL.md b/packages/ai/skills/ai-core/debug-logging/SKILL.md
index 926501dbe3..9219b2e3e0 100644
--- a/packages/ai/skills/ai-core/debug-logging/SKILL.md
+++ b/packages/ai/skills/ai-core/debug-logging/SKILL.md
@@ -30,8 +30,10 @@ printed, or pipe logs into a custom logger (pino, winston, etc.). The same
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+
const stream = chat({
- adapter: openaiText('gpt-5.2'),
+ adapter: openaiText('gpt-5.5'),
messages,
debug: true, // all categories on, prints to console
})
@@ -49,8 +51,13 @@ Each log line is prefixed with an emoji and `[tanstack-ai:]`:
## Turn it off
```typescript
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+
chat({
- adapter: openaiText('gpt-5.2'),
+ adapter: openaiText('gpt-5.5'),
messages,
debug: false, // silence everything, including errors
})
@@ -63,6 +70,9 @@ Omitting `debug` is **not** the same as `debug: false`. When omitted, the
## `DebugOption` — the accepted shapes
```typescript
+import type { Logger } from '@tanstack/ai'
+
+// As exported by '@tanstack/ai'
type DebugOption = boolean | DebugConfig
interface DebugConfig {
@@ -95,8 +105,13 @@ Pass a `DebugConfig` object. Unspecified categories default to `true`, so it's
easiest to toggle by setting specific flags to `false`:
```typescript
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+
chat({
- adapter: openaiText('gpt-5.2'),
+ adapter: openaiText('gpt-5.5'),
messages,
debug: { middleware: false }, // everything except middleware
})
@@ -105,8 +120,13 @@ chat({
To print only a specific set, set the rest to `false` explicitly:
```typescript
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+
chat({
- adapter: openaiText('gpt-5.2'),
+ adapter: openaiText('gpt-5.5'),
messages,
debug: {
provider: true,
@@ -124,7 +144,8 @@ chat({
## Pipe into your own logger
```typescript
-import type { Logger } from '@tanstack/ai'
+import { chat, type Logger } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
import pino from 'pino'
const pinoLogger = pino()
@@ -135,8 +156,10 @@ const logger: Logger = {
error: (msg, meta) => pinoLogger.error(meta, msg),
}
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+
chat({
- adapter: openaiText('gpt-5.2'),
+ adapter: openaiText('gpt-5.5'),
messages,
debug: { logger }, // all categories on, piped to pino
})
@@ -170,11 +193,48 @@ concepts don't exist in their pipelines.
Same `debug` option everywhere:
```typescript
-summarize({ adapter, text, debug: true })
-generateImage({ adapter, prompt: 'a cat', debug: { logger } })
-generateSpeech({ adapter, text, debug: { request: true } })
-generateTranscription({ adapter, audio, debug: false })
-generateVideo({ adapter, prompt: 'a wave', debug: { output: true } })
+import {
+ summarize,
+ generateImage,
+ generateSpeech,
+ generateTranscription,
+ generateVideo,
+} from '@tanstack/ai'
+import {
+ openaiSummarize,
+ openaiImage,
+ openaiSpeech,
+ openaiTranscription,
+ openaiVideo,
+} from '@tanstack/ai-openai'
+import { logger } from './logger'
+import { audio } from './recording'
+
+summarize({
+ adapter: openaiSummarize('gpt-5.5'),
+ text: 'Long article…',
+ debug: true,
+})
+generateImage({
+ adapter: openaiImage('gpt-image-2'),
+ prompt: 'a cat',
+ debug: { logger },
+})
+generateSpeech({
+ adapter: openaiSpeech('tts-1-hd'),
+ text: 'Hello',
+ debug: { request: true },
+})
+generateTranscription({
+ adapter: openaiTranscription('gpt-4o-transcribe'),
+ audio,
+ debug: false,
+})
+generateVideo({
+ adapter: openaiVideo('sora-2'),
+ prompt: 'a wave',
+ debug: { output: true },
+})
```
Realtime session adapters in provider packages (e.g. `openaiRealtime`,
@@ -187,6 +247,12 @@ categories don't apply.
### a. HIGH: Treating omitted `debug` as silent
```typescript
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+const adapter = openaiText('gpt-5.5')
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+
// WRONG — expecting this to be completely silent
chat({ adapter, messages })
// Errors still print via [tanstack-ai:errors] ... on failure.
@@ -203,6 +269,12 @@ Source: docs/advanced/debug-logging.md
### b. MEDIUM: Reaching for middleware when `debug` would do
```typescript
+import { chat, type ChatMiddleware } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+const adapter = openaiText('gpt-5.5')
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+
// WRONG — writing logging middleware to see chunks flow
const chunkLogger: ChatMiddleware = {
name: 'chunk-logger',
@@ -234,22 +306,32 @@ prefer implementations that don't throw — silenced exceptions are harder to
debug than loud ones.
```typescript
+import type { Logger } from '@tanstack/ai'
+
// WRONG — a logger that can throw on serialization
const fragile: Logger = {
debug: (msg, meta) => console.debug(msg, JSON.stringify(meta)), // cyclic meta → throws
- /* ... */
+ info: (msg, meta) => console.info(msg, JSON.stringify(meta)),
+ warn: (msg, meta) => console.warn(msg, JSON.stringify(meta)),
+ error: (msg, meta) => console.error(msg, JSON.stringify(meta)),
}
// CORRECT — guard serialization in the logger itself
-const safe: Logger = {
- debug: (msg, meta) => {
+const guarded =
+ (log: (...args: Array) => void): Logger['debug'] =>
+ (msg, meta) => {
try {
- console.debug(msg, meta)
+ log(msg, JSON.stringify(meta))
} catch {
- console.debug(msg)
+ log(msg) // fall back to the bare message rather than throw
}
- },
- /* ... */
+ }
+
+const safe: Logger = {
+ debug: guarded(console.debug),
+ info: guarded(console.info),
+ warn: guarded(console.warn),
+ error: guarded(console.error),
}
```
diff --git a/packages/ai/skills/ai-core/locks/SKILL.md b/packages/ai/skills/ai-core/locks/SKILL.md
index e5ae38a3c9..d81c21bfe9 100644
--- a/packages/ai/skills/ai-core/locks/SKILL.md
+++ b/packages/ai/skills/ai-core/locks/SKILL.md
@@ -35,20 +35,40 @@ per-thread (or other) lock yourself when multi-writer races matter.
## Wire locks
```ts
+import { chat } from '@tanstack/ai'
import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks'
+import { openaiText } from '@tanstack/ai-openai'
-middleware: [
- withLocks(new InMemoryLockStore()), // single process
-]
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+
+chat({
+ adapter: openaiText('gpt-5.6'),
+ messages,
+ middleware: [
+ withLocks(new InMemoryLockStore()), // single process
+ ],
+})
```
Alongside persistence — optional, locks do not require it:
```ts
+import { chat } from '@tanstack/ai'
import { withLocks, InMemoryLockStore } from '@tanstack/ai/locks'
-import { withPersistence } from '@tanstack/ai-persistence'
-
-middleware: [withPersistence(persistence), withLocks(new InMemoryLockStore())]
+import { openaiText } from '@tanstack/ai-openai'
+import { memoryPersistence, withPersistence } from '@tanstack/ai-persistence'
+
+const persistence = memoryPersistence()
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+
+chat({
+ adapter: openaiText('gpt-5.6'),
+ messages,
+ middleware: [
+ withPersistence(persistence),
+ withLocks(new InMemoryLockStore()),
+ ],
+})
```
`withLocks` provides `LocksCapability` for downstream middleware (e.g.
@@ -73,7 +93,9 @@ annotation), then hand it to `withLocks`. Acquire the key, run `fn`, release whe
`fn` settles:
```ts
+import { chat } from '@tanstack/ai'
import { defineLock, withLocks } from '@tanstack/ai/locks'
+import { openaiText } from '@tanstack/ai-openai'
import { acquire } from './my-lock-backend'
const locks = defineLock({
@@ -87,7 +109,13 @@ const locks = defineLock({
},
})
-middleware: [withLocks(locks)]
+const messages = [{ role: 'user' as const, content: 'Hello' }]
+
+chat({
+ adapter: openaiText('gpt-5.6'),
+ messages,
+ middleware: [withLocks(locks)],
+})
```
## Lease semantics
diff --git a/packages/ai/skills/ai-core/media-generation/SKILL.md b/packages/ai/skills/ai-core/media-generation/SKILL.md
index 4323015775..3786984140 100644
--- a/packages/ai/skills/ai-core/media-generation/SKILL.md
+++ b/packages/ai/skills/ai-core/media-generation/SKILL.md
@@ -110,9 +110,10 @@ parses it as SSE automatically:
import { createServerFn } from '@tanstack/react-start'
import { generateImage, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
+import type { OpenAIImageModel } from '@tanstack/ai-openai'
export const generateImageStreamFn = createServerFn({ method: 'POST' })
- .inputValidator((data: { prompt: string; model?: string }) => data)
+ .inputValidator((data: { prompt: string; model?: OpenAIImageModel }) => data)
.handler(({ data }) => {
return toServerSentEventsResponse(
generateImage({
@@ -183,7 +184,7 @@ const openaiResult = await generateImage({
modelOptions: {
quality: 'high',
background: 'transparent',
- outputFormat: 'png',
+ output_format: 'png',
},
})
@@ -250,10 +251,10 @@ await generateImage({
adapter: openaiImage('gpt-image-2'),
prompt: [
{ type: 'text', content: 'Replace the masked region with a tree' },
- { type: 'image', source: { type: 'url', value: photoUrl } },
+ { type: 'image', source: { type: 'url', value: 'https://…/photo.png' } },
{
type: 'image',
- source: { type: 'url', value: maskUrl },
+ source: { type: 'url', value: 'https://…/mask.png' },
metadata: { role: 'mask' },
},
],
@@ -267,11 +268,11 @@ import { falVideo } from '@tanstack/ai-fal'
await generateVideo({
adapter: falVideo('fal-ai/kling-video/v3/pro/image-to-video'),
prompt: [
- { type: 'image', source: { type: 'url', value: firstFrameUrl } },
+ { type: 'image', source: { type: 'url', value: 'https://…/first.png' } },
{ type: 'text', content: 'Slow cinematic push-in' },
{
type: 'image',
- source: { type: 'url', value: lastFrameUrl },
+ source: { type: 'url', value: 'https://…/last.png' },
metadata: { role: 'end_frame' },
},
],
@@ -404,35 +405,58 @@ gpt-4o-mini-transcribe, gpt-4o-transcribe-diarize) and `byteplusTranscription`
> **Capturing audio in the browser:** Use `useAudioRecorder` from `@tanstack/ai-react` to record directly in the browser, then pass the recording as the `audio` input to `generate()`, or use `recording.part` as a prompt part in chat/generation calls. No transcoding or extra dependencies required — the recorder returns the native browser format (`audio/webm` or `audio/mp4`). For transcription, wrap it as a `data:` URL so the provider gets the real content type; passing raw `recording.base64` makes the adapter assume `audio/mpeg` and mislabel the webm/mp4 bytes.
>
-> ```typescript
-> const { isRecording, start, stop } = useAudioRecorder()
-> const { generate } = useTranscription({
-> connection: fetchServerSentEvents('/api/transcribe'),
-> })
-> // ...
-> const recording = await stop()
-> const mimeType = recording.mimeType.split(';')[0] // strip ;codecs=...
-> await generate({ audio: `data:${mimeType};base64,${recording.base64}` })
+> ```tsx
+> import {
+> useAudioRecorder,
+> useTranscription,
+> fetchServerSentEvents,
+> } from '@tanstack/ai-react'
+>
+> function VoiceNote() {
+> const { isRecording, start, stop } = useAudioRecorder()
+> const { generate } = useTranscription({
+> connection: fetchServerSentEvents('/api/transcribe'),
+> })
+>
+> async function finish() {
+> const recording = await stop()
+> const mimeType = recording.mimeType.split(';')[0] // strip ;codecs=...
+> await generate({ audio: `data:${mimeType};base64,${recording.base64}` })
+> }
+>
+> return (
+>
+> )
+> }
> ```
```typescript
-import { generateTranscription } from '@tanstack/ai'
+// routes/api/transcribe.ts
+import { generateTranscription, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiTranscription } from '@tanstack/ai-openai'
-const result = await generateTranscription({
- adapter: openaiTranscription('whisper-1'),
- audio: audioFile, // File, Blob, base64 string, or data URL
- language: 'en',
- responseFormat: 'verbose_json',
- modelOptions: {
- timestamp_granularities: ['word', 'segment'],
- },
-})
+export async function POST(request: Request) {
+ // The client hook below posts { data: { audio: dataUrl, language } }
+ const { audio, language } = (await request.json()).data
+
+ const stream = generateTranscription({
+ adapter: openaiTranscription('whisper-1'),
+ audio, // File, Blob, base64 string, or data URL
+ language,
+ responseFormat: 'verbose_json',
+ modelOptions: {
+ timestamp_granularities: ['word', 'segment'],
+ },
+ stream: true,
+ })
-// result.text -- full transcribed text
-// result.language -- detected/specified language
-// result.duration -- audio duration in seconds
-// result.segments -- timestamped segments (word-level timestamps are in result.words)
+ // On the client, result.text is the transcript, result.language the
+ // detected language, result.duration the seconds, result.segments the
+ // timestamped segments (word-level timestamps are in result.words).
+ return toServerSentEventsResponse(stream)
+}
```
For speaker diarization, use `openaiTranscription('gpt-4o-transcribe-diarize')`.
@@ -486,14 +510,17 @@ while (status.status !== 'completed' && status.status !== 'failed') {
}
// Streaming: server handles polling, client gets real-time updates
-const stream = generateVideo({
- adapter: openaiVideo('sora-2'),
- prompt: 'A flying car over a city',
- stream: true,
- pollingInterval: 3000,
- maxDuration: 600_000,
-})
-return toServerSentEventsResponse(stream)
+export async function POST(request: Request) {
+ const { prompt } = await request.json()
+ const stream = generateVideo({
+ adapter: openaiVideo('sora-2'),
+ prompt,
+ stream: true,
+ pollingInterval: 3000,
+ maxDuration: 600_000,
+ })
+ return toServerSentEventsResponse(stream)
+}
```
Google Veo (`@tanstack/ai-gemini`) uses the same jobs/polling flow. Its
@@ -505,6 +532,7 @@ Image prompt parts route by `metadata.role`: first un-roled /
`'reference'` / `'character'` → `referenceImages`:
```typescript
+import { generateVideo } from '@tanstack/ai'
import { geminiVideo } from '@tanstack/ai-gemini'
const adapter = geminiVideo('veo-3.1-generate-preview')
@@ -538,6 +566,7 @@ media). For conversational editing, pass a prior generation's `jobId` as
on 2026-09-30.
```typescript
+import { generateVideo } from '@tanstack/ai'
import { geminiVideo } from '@tanstack/ai-gemini'
const omni = geminiVideo('gemini-omni-1.1-flash')
@@ -590,6 +619,7 @@ from OpenRouter's published metadata, with the same `availableDurations()` /
`snapDuration()` helpers:
```typescript
+import { generateVideo } from '@tanstack/ai'
import { openRouterVideo } from '@tanstack/ai-openrouter'
const adapter = openRouterVideo('bytedance/seedance-2.0')
@@ -643,6 +673,7 @@ const result = await generateImage({
// usage.billed.quantity is the priced quantity. Multiply by the endpoint unit
// price (GET https://api.fal.ai/v1/models/pricing?endpoint_id=…) for exact cost.
+const unitPrice = 0.025 // USD per unit, from the pricing endpoint
if (result.usage?.billed) {
const cost = result.usage.billed.quantity * unitPrice
}
@@ -769,6 +800,8 @@ Provide either `connection` (streaming SSE transport) or `fetcher`
to transform what is stored:
```tsx
+import { useGenerateSpeech, fetchServerSentEvents } from '@tanstack/ai-react'
+
const { result } = useGenerateSpeech({
connection: fetchServerSentEvents('/api/generate/speech'),
onResult: (raw) => ({
@@ -790,7 +823,7 @@ Agents trained on older code may still generate this pattern.
**Wrong:**
-```typescript
+```typescript ignore
import { embedding } from '@tanstack/ai'
import { openaiEmbed } from '@tanstack/ai-openai'
@@ -825,27 +858,34 @@ stream from a server function will not work.
**Wrong:**
-```typescript
-export const generateImageStreamFn = createServerFn({ method: 'POST' }).handler(
- ({ data }) => {
+```typescript ignore
+import { createServerFn } from '@tanstack/react-start'
+import { generateImage } from '@tanstack/ai'
+import { openaiImage } from '@tanstack/ai-openai'
+
+export const generateImageStreamFn = createServerFn({ method: 'POST' })
+ .inputValidator((data: { prompt: string }) => data)
+ .handler(({ data }) => {
// BUG: returning raw stream -- client cannot parse this
+ // (also a type error: an AsyncIterable is not a valid server-function return)
return generateImage({
adapter: openaiImage('gpt-image-1'),
prompt: data.prompt,
stream: true,
})
- },
-)
+ })
```
**Correct:**
```typescript
+import { createServerFn } from '@tanstack/react-start'
import { generateImage, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiImage } from '@tanstack/ai-openai'
-export const generateImageStreamFn = createServerFn({ method: 'POST' }).handler(
- ({ data }) => {
+export const generateImageStreamFn = createServerFn({ method: 'POST' })
+ .inputValidator((data: { prompt: string }) => data)
+ .handler(({ data }) => {
return toServerSentEventsResponse(
generateImage({
adapter: openaiImage('gpt-image-1'),
@@ -853,8 +893,7 @@ export const generateImageStreamFn = createServerFn({ method: 'POST' }).handler(
stream: true,
}),
)
- },
-)
+ })
```
> Source: maintainer interview.
@@ -866,6 +905,9 @@ later, the image will silently break. Always download or display the image
immediately, or convert to base64 for persistence.
```typescript
+import { generateImage } from '@tanstack/ai'
+import { openaiImage } from '@tanstack/ai-openai'
+
const result = await generateImage({
adapter: openaiImage('dall-e-3'),
prompt: 'A mountain landscape',
@@ -904,7 +946,7 @@ Gemini's `GenerateContentConfig` (used by Lyria 3 Pro / Lyria 3 Clip) does
returns 30-second `audio/mp3`; Lyria 3 Pro returns `audio/mp3`. These fields
are not in `GeminiAudioProviderOptions` — don't reach for them via `as any`.
-```typescript
+```typescript ignore
// WRONG — both fields are silently ignored or rejected by the SDK
generateAudio({
adapter: geminiAudio('lyria-3-pro-preview'),
@@ -914,6 +956,11 @@ generateAudio({
negativePrompt: 'vocals', // unsupported
} as any,
})
+```
+
+```typescript
+import { generateAudio } from '@tanstack/ai'
+import { geminiAudio } from '@tanstack/ai-gemini'
// CORRECT — shape the prompt itself for what you want
generateAudio({
@@ -934,6 +981,10 @@ model's native field like `music_length_ms` or `seconds_total`), but not
for Lyria.
```typescript
+import { generateAudio } from '@tanstack/ai'
+import { geminiAudio } from '@tanstack/ai-gemini'
+import { falAudio } from '@tanstack/ai-fal'
+
// For Lyria: put length guidance in the prompt
generateAudio({
adapter: geminiAudio('lyria-3-pro-preview'),
@@ -958,6 +1009,9 @@ generateAudio({
`as any`.
```typescript
+import { generateSpeech } from '@tanstack/ai'
+import { geminiSpeech } from '@tanstack/ai-gemini'
+
generateSpeech({
adapter: geminiSpeech('gemini-2.5-pro-preview-tts'),
text: '[Alice] Hi. [Bob] Hello!',
@@ -988,7 +1042,7 @@ narrowed per model, so passing an image part to a text-only model
also throw a clear runtime error as a backstop, so users learn at call
time rather than getting silently wrong output.
-```typescript
+```typescript ignore
// WRONG — dall-e-3 has no edit/inputs API; image parts are a type error
generateImage({
adapter: openaiImage('dall-e-3'),
@@ -1006,6 +1060,14 @@ generateImage({
{ type: 'image', source: { type: 'url', value: url } }, // ❌ type error
],
})
+```
+
+```typescript
+import { generateImage } from '@tanstack/ai'
+import { openaiImage } from '@tanstack/ai-openai'
+import { geminiImage } from '@tanstack/ai-gemini'
+
+const url = 'https://…/photo.png'
// CORRECT — use a model that supports image-conditioned generation
generateImage({
@@ -1035,6 +1097,9 @@ same `debug?: DebugOption` option that `chat()` does. Reach for `debug`
instead of wiring up logging middleware.
```typescript
+import { generateSpeech } from '@tanstack/ai'
+import { openaiSpeech } from '@tanstack/ai-openai'
+
// When a speech generation sounds wrong or a transcription returns garbage
generateSpeech({
adapter: openaiSpeech('tts-1'),
diff --git a/packages/ai/skills/ai-core/middleware/SKILL.md b/packages/ai/skills/ai-core/middleware/SKILL.md
index 853fcf471f..d41230b097 100644
--- a/packages/ai/skills/ai-core/middleware/SKILL.md
+++ b/packages/ai/skills/ai-core/middleware/SKILL.md
@@ -24,26 +24,31 @@ sources:
```typescript
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
+import { trackAnalytics, reportError } from './analytics'
-const stream = chat({
- adapter: openaiText('gpt-5.2'),
- messages,
- middleware: [
- {
- onStart: (ctx) => {
- console.log('Chat started:', ctx.model)
- },
- onFinish: (ctx, info) => {
- trackAnalytics({ model: ctx.model, tokens: info.usage?.totalTokens })
- },
- onError: (ctx, info) => {
- reportError(info.error)
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+
+ const stream = chat({
+ adapter: openaiText('gpt-5.5'),
+ messages,
+ middleware: [
+ {
+ onStart: (ctx) => {
+ console.log('Chat started:', ctx.model)
+ },
+ onFinish: (ctx, info) => {
+ trackAnalytics({ model: ctx.model, tokens: info.usage?.totalTokens })
+ },
+ onError: (ctx, info) => {
+ reportError(info.error)
+ },
},
- },
- ],
-})
+ ],
+ })
-return toServerSentEventsResponse(stream)
+ return toServerSentEventsResponse(stream)
+}
```
## Hooks Reference
@@ -119,19 +124,30 @@ specific config changes that should not affect the agent-loop adapter calls.
**Signature:**
```ts
-onStructuredOutputConfig?: (
- ctx: ChatMiddlewareContext,
- config: StructuredOutputMiddlewareConfig,
-) =>
- | void
- | null
- | Partial
- | Promise>
+import type {
+ ChatMiddlewareContext,
+ StructuredOutputMiddlewareConfig,
+} from '@tanstack/ai'
+
+// Excerpt of the `ChatMiddleware` interface exported by '@tanstack/ai'
+interface ChatMiddleware {
+ onStructuredOutputConfig?: (
+ ctx: ChatMiddlewareContext,
+ config: StructuredOutputMiddlewareConfig,
+ ) =>
+ | void
+ | null
+ | Partial
+ | Promise>
+}
```
**`StructuredOutputMiddlewareConfig` shape:**
```ts
+import type { ChatMiddlewareConfig, JSONSchema } from '@tanstack/ai'
+
+// As exported by '@tanstack/ai'
interface StructuredOutputMiddlewareConfig extends Omit<
ChatMiddlewareConfig,
'tools'
@@ -203,13 +219,17 @@ const analytics: ChatMiddleware = {
},
}
-const stream = chat({
- adapter: openaiText('gpt-5.2'),
- messages,
- middleware: [analytics],
-})
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+
+ const stream = chat({
+ adapter: openaiText('gpt-5.5'),
+ messages,
+ middleware: [analytics],
+ })
-return toServerSentEventsResponse(stream)
+ return toServerSentEventsResponse(stream)
+}
```
### Pattern 2: Tool Interception Middleware
@@ -278,11 +298,14 @@ native-combined schema.
```typescript
import type { ChatMiddleware } from '@tanstack/ai'
+import { trace } from '@opentelemetry/api'
const tracing: ChatMiddleware = {
name: 'tracing',
onChunk(ctx, chunk) {
- span.addEvent('chunk', { phase: ctx.phase, type: chunk.type })
+ trace
+ .getActiveSpan()
+ ?.addEvent('chunk', { phase: ctx.phase, type: chunk.type })
},
}
```
@@ -296,6 +319,7 @@ the native-combined path, it observes the structured stream with
```typescript
import type { ChatMiddleware } from '@tanstack/ai'
+import { sharedDefs } from './defs'
const injectDefs: ChatMiddleware = {
name: 'inject-defs',
@@ -317,9 +341,27 @@ Middleware executes in array order (left-to-right). Ordering matters for hooks t
pipe or short-circuit:
```typescript
-import { chat, type ChatMiddleware } from '@tanstack/ai'
+import {
+ chat,
+ toolDefinition,
+ toServerSentEventsResponse,
+ type ChatMiddleware,
+} from '@tanstack/ai'
import { toolCacheMiddleware } from '@tanstack/ai/middlewares'
import { openaiText } from '@tanstack/ai-openai'
+import { z } from 'zod'
+
+const weatherTool = toolDefinition({
+ name: 'getWeather',
+ description: 'Get the current weather for a city',
+ inputSchema: z.object({ city: z.string() }),
+}).server(async ({ city }) => ({ city, tempC: 21 }))
+
+const stockTool = toolDefinition({
+ name: 'getStock',
+ description: 'Get the latest price for a ticker symbol',
+ inputSchema: z.object({ symbol: z.string() }),
+}).server(async ({ symbol }) => ({ symbol, price: 123.45 }))
const logging: ChatMiddleware = {
name: 'logging',
@@ -347,16 +389,22 @@ const configTransform: ChatMiddleware = {
},
}
-const stream = chat({
- adapter: openaiText('gpt-5.2'),
- messages,
- tools: [weatherTool, stockTool],
- middleware: [
- logging, // Runs first
- configTransform, // Transforms config second
- toolCacheMiddleware({ ttl: 60_000 }), // Caches tool results third
- ],
-})
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+
+ const stream = chat({
+ adapter: openaiText('gpt-5.5'),
+ messages,
+ tools: [weatherTool, stockTool],
+ middleware: [
+ logging, // Runs first
+ configTransform, // Transforms config second
+ toolCacheMiddleware({ ttl: 60_000 }), // Caches tool results third
+ ],
+ })
+
+ return toServerSentEventsResponse(stream)
+}
```
**Composition rules by hook:**
@@ -378,7 +426,21 @@ Not a built-in. Cap fan-out with `onBeforeToolCall` skip + `onShouldContinue`.
See `docs/chat/agentic-cycle.md` ("Tool-call budgets").
```typescript
-import { chat, maxIterations, type ChatMiddleware } from '@tanstack/ai'
+import {
+ chat,
+ maxIterations,
+ toolDefinition,
+ toServerSentEventsResponse,
+ type ChatMiddleware,
+} from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { z } from 'zod'
+
+const weatherTool = toolDefinition({
+ name: 'getWeather',
+ description: 'Get the current weather for a city',
+ inputSchema: z.object({ city: z.string() }),
+}).server(async ({ city }) => ({ city, tempC: 21 }))
function toolCallBudget(opts: {
max?: number
@@ -409,13 +471,19 @@ function toolCallBudget(opts: {
}
}
-chat({
- adapter,
- messages,
- tools: [weatherTool],
- agentLoopStrategy: maxIterations(20),
- middleware: [toolCallBudget({ maxPerTurn: 10, max: 20 })],
-})
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+
+ const stream = chat({
+ adapter: openaiText('gpt-5.5'),
+ messages,
+ tools: [weatherTool],
+ agentLoopStrategy: maxIterations(20),
+ middleware: [toolCallBudget({ maxPerTurn: 10, max: 20 })],
+ })
+
+ return toServerSentEventsResponse(stream)
+}
```
## Built-in: toolCacheMiddleware
@@ -423,21 +491,35 @@ chat({
Caches tool call results by name + arguments. Import from `@tanstack/ai/middlewares`:
```typescript
-import { chat } from '@tanstack/ai'
+import { chat, toolDefinition, toServerSentEventsResponse } from '@tanstack/ai'
import { toolCacheMiddleware } from '@tanstack/ai/middlewares'
+import { openaiText } from '@tanstack/ai-openai'
+import { z } from 'zod'
-const stream = chat({
- adapter,
- messages,
- tools: [weatherTool],
- middleware: [
- toolCacheMiddleware({
- ttl: 60_000, // Cache entries expire after 60 seconds
- maxSize: 50, // Max 50 entries (LRU eviction)
- toolNames: ['getWeather'], // Only cache specific tools
- }),
- ],
-})
+const weatherTool = toolDefinition({
+ name: 'getWeather',
+ description: 'Get the current weather for a city',
+ inputSchema: z.object({ city: z.string() }),
+}).server(async ({ city }) => ({ city, tempC: 21 }))
+
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+
+ const stream = chat({
+ adapter: openaiText('gpt-5.5'),
+ messages,
+ tools: [weatherTool],
+ middleware: [
+ toolCacheMiddleware({
+ ttl: 60_000, // Cache entries expire after 60 seconds
+ maxSize: 50, // Max 50 entries (LRU eviction)
+ toolNames: ['getWeather'], // Only cache specific tools
+ }),
+ ],
+ })
+
+ return toServerSentEventsResponse(stream)
+}
```
Options: `maxSize` (default 100), `ttl` (default Infinity), `toolNames` (default all),
@@ -550,7 +632,12 @@ implement, and what `@tanstack/ai-sandbox`'s run driver resolves per run — its
`snapshot()` method alongside `append`, `read`, and `close`:
```ts
-snapshot: () => Promise>
+import type { StreamChunk } from '@tanstack/ai'
+
+// Excerpt of the `StreamDurability` interface exported by '@tanstack/ai'
+interface StreamDurability {
+ snapshot: () => Promise>
+}
```
It returns everything stored for a run right now, in append order, then
@@ -695,11 +782,15 @@ Source: docs/sandbox/observability.md
### a. MEDIUM: Trying to modify StreamChunks in middleware
```typescript
+import type { ChatMiddleware } from '@tanstack/ai'
+
// WRONG -- mutating the chunk object directly
const broken: ChatMiddleware = {
name: 'broken',
onChunk: (ctx, chunk) => {
- chunk.delta = 'modified' // Mutation does nothing; chunk is not modified in-place
+ if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
+ chunk.delta = 'modified' // Mutation does nothing; chunk is not modified in-place
+ }
},
}
@@ -736,6 +827,9 @@ middleware had decided to reject. A throw from either fails the whole stream. Th
is where an unhandled error actually costs you a response:
```typescript
+import type { ChatMiddleware } from '@tanstack/ai'
+import { logChunk, requireEnv } from './logging'
+
// WRONG -- an unhandled error in onChunk kills the entire streaming response
const fragile: ChatMiddleware = {
name: 'fragile-chunk-logger',
@@ -745,7 +839,12 @@ const fragile: ChatMiddleware = {
},
onConfig: (ctx, config) => {
// Same for a config transform that reads an env var that is not set
- return { model: requireEnv('MODEL_OVERRIDE') }
+ return {
+ modelOptions: {
+ ...config.modelOptions,
+ temperature: Number(requireEnv('TEMPERATURE')),
+ },
+ }
},
}
@@ -761,9 +860,15 @@ const resilient: ChatMiddleware = {
// Return void to pass through
},
onConfig: (ctx, config) => {
- const override = process.env.MODEL_OVERRIDE
+ const temperature = process.env.TEMPERATURE
// Decide, do not throw: no override means no transform.
- return override === undefined ? undefined : { model: override }
+ if (temperature === undefined) return undefined
+ return {
+ modelOptions: {
+ ...config.modelOptions,
+ temperature: Number(temperature),
+ },
+ }
},
onFinish: (ctx, info) => {
// Already guarded by core — but prefer ctx.defer() anyway, so a slow
diff --git a/packages/ai/skills/ai-core/structured-outputs/SKILL.md b/packages/ai/skills/ai-core/structured-outputs/SKILL.md
index 799d816267..3bea8cbaea 100644
--- a/packages/ai/skills/ai-core/structured-outputs/SKILL.md
+++ b/packages/ai/skills/ai-core/structured-outputs/SKILL.md
@@ -139,7 +139,7 @@ const company = await chat({
// Full type safety on nested properties
console.log(company.headquarters.city)
-console.log(company.employees[0].role)
+console.log(company.employees[0]?.role)
console.log(company.financials?.revenue)
```
@@ -147,7 +147,7 @@ console.log(company.financials?.revenue)
Pass `stream: true` alongside `outputSchema` to get an async iterable of standard streaming chunks plus a completed typed object. Use this when you're a single process end-to-end — Node script, CLI, test, or a server endpoint that responds with one JSON blob. For the in-browser progressive-UI case, jump to Pattern 4 instead.
-```typescript
+```typescript group=person-stream
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
@@ -316,7 +316,7 @@ function RecipeBuilder() {
.filter((p) => p.type === 'text')
.map((p) => p.content)
.join('')
- return
+ return
{text}
}
if (m.role === 'assistant') {
// `data` is `Recipe` because the schema generic flows from
@@ -338,8 +338,8 @@ function RecipeBuilder() {
function RecipeCard({ part }: { part: RecipePart }) {
// `data` lands on complete, `partial` fills in while streaming.
// Both are typed against the schema. No casts.
- const recipe = part.data ?? part.partial ?? ({} as Partial)
- return
+ )
+}
```
- Claude Code: `--json-schema`. Codex: `--output-schema`. OpenCode, Grok Build, and `acpCompatible`: prompt-and-parse.
@@ -419,28 +426,51 @@ final?.name
Earlier versions of the library routed structured-output JSON deltas through `TextPart`, so renderers had to filter them out:
-```tsx
-// OBSOLETE — this guard was needed only because JSON used to land in a TextPart
-const last = messages.at(-1)
-last?.parts.map((part) => {
- if (part.type === 'text') return null // ❌ hides the structured JSON
- // ...
+```tsx group=recipe-renderer
+import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
+import { z } from 'zod'
+import { ReasoningView, ToolCallView, RecipeCard } from './views'
+
+const RecipeSchema = z.object({
+ title: z.string(),
+ steps: z.array(z.string()),
})
+
+function useRecipeChat() {
+ return useChat({
+ connection: fetchServerSentEvents('/api/recipes'),
+ outputSchema: RecipeSchema,
+ })
+}
+
+function ObsoleteRenderer() {
+ const { messages } = useRecipeChat()
+ const last = messages.at(-1)
+ // OBSOLETE — this guard was needed only because JSON used to land in a TextPart
+ return last?.parts.map((part, i) => {
+ if (part.type === 'text') return null // ❌ hides the structured JSON
+ return
{JSON.stringify(part)}
+ })
+}
```
That hack is **gone**. With `outputSchema` set, `TEXT_MESSAGE_CONTENT` deltas now route into a dedicated `StructuredOutputPart` (with `raw`, `partial`, `data`, `status`, optional `errorMessage`). Render the structured part directly; let real `TextPart`s through.
-```tsx
-// CORRECT — find the structured-output part directly; let actual TextParts render
-last?.parts.map((part, i) => {
- if (part.type === 'thinking')
- return
- if (part.type === 'tool-call') return
- if (part.type === 'structured-output')
- return
- if (part.type === 'text') return
{part.content}
// ← real text, not JSON
- return null
-})
+```tsx group=recipe-renderer
+function RecipeRenderer() {
+ const { messages } = useRecipeChat()
+ const last = messages.at(-1)
+ // CORRECT — find the structured-output part directly; let actual TextParts render
+ return last?.parts.map((part, i) => {
+ if (part.type === 'thinking')
+ return
+ if (part.type === 'tool-call') return
+ if (part.type === 'structured-output')
+ return
+ if (part.type === 'text') return
{part.content}
// ← real text, not JSON
+ return null
+ })
+}
```
If you still have an `if (part.type === 'text') return null` line in a structured-output renderer specifically for "hiding the JSON," delete it.
@@ -456,18 +486,24 @@ Source: PR #577 — structured-output became a typed UIMessage part.
To render history, walk `messages` directly (see Pattern 5). Use `partial` / `final` for a sticky summary of the **most recent** turn only.
-```tsx
-// WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view
-{final && }
-
-// CORRECT for history — walk messages, render each structured-output part
-{messages.map((m) =>
- m.role === 'assistant'
- ? m.parts.find((p) => p.type === 'structured-output')
- ?
- : null
- : null
-)}
+```tsx group=recipe-renderer
+function RecipeHistory() {
+ const { messages, final } = useRecipeChat()
+
+ return (
+ <>
+ {/* WRONG — `final` only reflects the latest turn; earlier recipes vanish from this view */}
+ {final &&
{final.title}
}
+
+ {/* CORRECT for history — walk messages, render each structured-output part */}
+ {messages.map((m) => {
+ if (m.role !== 'assistant') return null
+ const part = m.parts.find((p) => p.type === 'structured-output')
+ return part ? : null
+ })}
+ >
+ )
+}
```
Source: PR #577 — partial/final derive from the most recent structured-output part after the latest user message.
@@ -476,7 +512,7 @@ Source: PR #577 — partial/final derive from the most recent structured-output
When iterating `chat({ outputSchema, stream: true })` directly (Pattern 3), the `TEXT_MESSAGE_CONTENT` chunks contain _partial_ JSON fragments — they are not valid JSON until the stream completes. Read the completed typed object from the terminal `structured-output.complete` event. Standard Schema validation remains the consumer's responsibility.
-```typescript
+```typescript group=person-stream
// WRONG -- partial JSON, throws SyntaxError mid-stream, no schema validation
for await (const chunk of stream) {
if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
@@ -500,8 +536,9 @@ Source: maintainer interview
The adapter already handles provider differences (OpenAI uses `response_format`, Anthropic uses tool-based extraction, Gemini uses `responseSchema`). Never configure this yourself.
-```typescript
+```typescript ignore
// WRONG -- do not set provider-specific response format
+// (this does not compile: modelOptions has no response-format field)
chat({
adapter,
messages,
@@ -509,11 +546,17 @@ chat({
responseFormat: { type: 'json_schema', json_schema: mySchema },
},
})
+```
+```typescript
// CORRECT -- just pass outputSchema, the adapter handles the rest
-chat({
- adapter,
- messages,
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { z } from 'zod'
+
+const person = await chat({
+ adapter: openaiText('gpt-5.2'),
+ messages: [{ role: 'user', content: 'John Doe, 30' }],
outputSchema: z.object({ name: z.string(), age: z.number() }),
})
```
@@ -529,8 +572,15 @@ of using the schema validation library already in the project (Zod, ArkType,
Valibot). Always check what the project uses and match it.
```typescript
-// WRONG -- raw schema object, no schema-library type inference
-chat({
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { z } from 'zod'
+
+const adapter = openaiText('gpt-5.2')
+const messages = [{ role: 'user' as const, content: 'John Doe, 30' }]
+
+// WRONG -- raw schema object, no schema-library type inference (result is unknown)
+const untyped = await chat({
adapter,
messages,
outputSchema: {
@@ -545,9 +595,7 @@ chat({
})
// CORRECT -- use the project's schema library (e.g. Zod)
-import { z } from 'zod'
-
-chat({
+const person = await chat({
adapter,
messages,
outputSchema: z.object({
@@ -555,6 +603,7 @@ chat({
age: z.number(),
}),
})
+person.name // string
```
Using the project's schema library gives you TypeScript type inference and
diff --git a/packages/ai/skills/ai-core/tool-calling/SKILL.md b/packages/ai/skills/ai-core/tool-calling/SKILL.md
index 210e8bb487..62da45d393 100644
--- a/packages/ai/skills/ai-core/tool-calling/SKILL.md
+++ b/packages/ai/skills/ai-core/tool-calling/SKILL.md
@@ -26,8 +26,9 @@ This skill builds on ai-core. Read it first for critical rules.
## Setup
Complete end-to-end example: shared definition, server tool, client tool, server route, React client.
+The four files below share one scope, so later files use the earlier exports directly.
-```typescript
+```typescript group=product-catalog
// tools/definitions.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
@@ -54,24 +55,23 @@ export const updateCartUIDef = toolDefinition({
})
```
-```typescript
-// tools/server.ts
-import { getProductsDef } from './definitions'
+```typescript group=product-catalog
+// tools/server.ts (uses getProductsDef from tools/definitions.ts)
+import { db } from './db'
export const getProducts = getProductsDef.server(async ({ query, limit }) => {
- const results = await db.products.search(query, { limit: limit ?? 10 })
+ const results: Array<{ id: string; name: string; price: number }> =
+ await db.products.search(query, { limit: limit ?? 10 })
return {
products: results.map((p) => ({ id: p.id, name: p.name, price: p.price })),
}
})
```
-```typescript
-// api/chat/route.ts
+```typescript group=product-catalog
+// api/chat/route.ts (uses getProducts and updateCartUIDef from tools/)
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
-import { getProducts } from '@/tools/server'
-import { updateCartUIDef } from '@/tools/definitions'
export async function POST(request: Request) {
const { messages } = await request.json()
@@ -84,32 +84,31 @@ export async function POST(request: Request) {
}
```
-```typescript
-// app/chat.tsx
+```tsx group=product-catalog
+// app/chat.tsx (uses updateCartUIDef from tools/definitions.ts)
import {
useChat,
fetchServerSentEvents,
- clientTools,
createChatClientOptions,
type InferChatMessages,
-} from "@tanstack/ai-react";
-import { updateCartUIDef } from "@/tools/definitions";
-import { useState } from "react";
+} from '@tanstack/ai-react'
+import { clientTools } from '@tanstack/ai-client'
+import { useState } from 'react'
function ChatPage() {
- const [cartCount, setCartCount] = useState(0);
+ const [cartCount, setCartCount] = useState(0)
const updateCartUI = updateCartUIDef.client((input) => {
- setCartCount(input.itemCount);
- return { displayed: true };
- });
+ setCartCount(input.itemCount)
+ return { displayed: true }
+ })
- const tools = clientTools(updateCartUI);
+ const tools = clientTools(updateCartUI)
const chatOptions = createChatClientOptions({
- connection: fetchServerSentEvents("/api/chat"),
+ connection: fetchServerSentEvents('/api/chat'),
tools,
- });
- const { messages, sendMessage } = useChat(chatOptions);
+ })
+ const { messages, sendMessage } = useChat(chatOptions)
// InferChatMessages ties part types to the configured tools when needed:
// type Messages = InferChatMessages
@@ -119,16 +118,20 @@ function ChatPage() {
{messages.map((msg) => (
{msg.parts.map((part) => {
- if (part.type === "text") return
{part.content}
;
- if (part.type === "tool-call") {
- return
Tool: {part.name} ({part.state})
;
+ if (part.type === 'text') return
{part.content}
+ if (part.type === 'tool-call') {
+ return (
+
+ Tool: {part.name} ({part.state})
+
+ )
}
- return null;
+ return null
})}
))}
- );
+ )
}
```
@@ -192,8 +195,10 @@ Define with `toolDefinition()`, implement with `.server()`, pass to `chat({ tool
The server executes it automatically. The client never runs code for this tool.
```typescript
-import { toolDefinition } from '@tanstack/ai'
+import { chat, toolDefinition, toServerSentEventsResponse } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
+import { db } from './db'
const getUserDataDef = toolDefinition({
name: 'get_user_data',
@@ -210,11 +215,15 @@ const getUserData = getUserDataDef.server(async ({ userId }) => {
})
// In your route handler:
-const stream = chat({
- adapter: openaiText('gpt-5.5'),
- messages,
- tools: [getUserData],
-})
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+ const stream = chat({
+ adapter: openaiText('gpt-5.5'),
+ messages,
+ tools: [getUserData],
+ })
+ return toServerSentEventsResponse(stream)
+}
```
### Pattern 2: Client-Only Tool
@@ -222,7 +231,8 @@ const stream = chat({
Pass the bare definition (no `.server()`) to `chat({ tools })` so the LLM knows
about it. Pass the `.client()` implementation to `useChat` via `clientTools()`.
-```typescript
+```typescript group=notification-tool
+// tools/definitions.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
@@ -239,41 +249,49 @@ export const showNotificationDef = toolDefinition({
Server -- pass definition only (no execute function):
-```typescript
-const stream = chat({
- adapter: openaiText('gpt-5.5'),
- messages,
- tools: [showNotificationDef],
-})
+```typescript group=notification-tool
+// api/chat/route.ts (uses showNotificationDef from tools/definitions.ts)
+import { chat, toServerSentEventsResponse } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+ const stream = chat({
+ adapter: openaiText('gpt-5.5'),
+ messages,
+ tools: [showNotificationDef],
+ })
+ return toServerSentEventsResponse(stream)
+}
```
Client -- pass `.client()` implementation:
-```typescript
+```tsx group=notification-tool
+// app/chat.tsx (uses showNotificationDef from tools/definitions.ts)
import {
useChat,
fetchServerSentEvents,
- clientTools,
createChatClientOptions,
-} from "@tanstack/ai-react";
-import { showNotificationDef } from "@/tools/definitions";
-import { useState } from "react";
+} from '@tanstack/ai-react'
+import { clientTools } from '@tanstack/ai-client'
+import { useState } from 'react'
function ChatPage() {
- const [toast, setToast] = useState(null);
+ const [toast, setToast] = useState(null)
const showNotification = showNotificationDef.client((input) => {
- setToast(input.message);
- setTimeout(() => setToast(null), 3000);
- return { shown: true };
- });
+ setToast(input.message)
+ setTimeout(() => setToast(null), 3000)
+ return { shown: true }
+ })
const { messages, sendMessage } = useChat(
createChatClientOptions({
- connection: fetchServerSentEvents("/api/chat"),
+ connection: fetchServerSentEvents('/api/chat'),
tools: clientTools(showNotification),
- })
- );
+ }),
+ )
return (
@@ -281,12 +299,12 @@ function ChatPage() {
{messages.map((msg) => (
{msg.parts.map((part) =>
- part.type === "text" ?
{part.content}
: null
+ part.type === 'text' ?
{part.content}
: null,
)}
))}
- );
+ )
}
```
@@ -298,9 +316,11 @@ Set `needsApproval: true` in the definition. Execution pauses with
`addToolApprovalResponse` and `pendingInterrupts` remain as deprecated
compatibility shims during migration.
-```typescript
+```typescript group=email-approval
+// tools/email.ts
import { toolDefinition } from '@tanstack/ai'
import { z } from 'zod'
+import { emailService } from './email-service'
export const sendEmailDef = toolDefinition({
name: 'send_email',
@@ -323,18 +343,20 @@ export const sendEmail = sendEmailDef.server(async ({ to, subject, body }) => {
Server route must forward `resume` / `parentRunId` (via `chatParamsFromRequest`
or equivalent). Client -- render bound interrupts:
-```typescript
-import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
+```tsx group=email-approval
+// app/chat.tsx (registers sendEmailDef so the approval interrupt is typed)
+import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
function ChatPage() {
const { messages, interrupts, sendMessage } = useChat({
- connection: fetchServerSentEvents("/api/chat"),
- });
+ connection: fetchServerSentEvents('/api/chat'),
+ tools: [sendEmailDef],
+ })
return (
{interrupts.map((interrupt) => {
- if (interrupt.kind !== "tool-approval") return null;
+ if (interrupt.kind !== 'tool-approval') return null
return (
Approve "{interrupt.toolName}"?
@@ -347,33 +369,54 @@ function ChatPage() {
- );
+ )
})}
{messages.map((msg) => (
{msg.parts.map((part) =>
- part.type === "text" ?
{part.content}
: null
+ part.type === 'text' ? (
+
{part.content}
+ ) : null,
)}
))}
- );
+ )
}
```
Batch all pending approvals with `resolveInterrupts` (void — submission is
async; watch `resuming` / `interruptErrors`):
-```typescript
-// Payloadless tool-approvals only
-resolveInterrupts(true)
+```tsx group=email-approval
+function ApproveAllButton() {
+ const { resolveInterrupts, resuming } = useChat({
+ connection: fetchServerSentEvents('/api/chat'),
+ tools: [sendEmailDef],
+ })
-// Or per-item:
-resolveInterrupts((interrupt) => {
- if (interrupt.kind === 'tool-approval') {
- interrupt.resolveInterrupt(true)
- }
-})
+ // Payloadless tool-approvals only
+ const approveAll = () => resolveInterrupts(true)
+
+ // Or per-item:
+ const approveEach = () =>
+ resolveInterrupts((interrupt) => {
+ if (interrupt.kind === 'tool-approval') {
+ interrupt.resolveInterrupt(true)
+ }
+ })
+
+ return (
+ <>
+
+
+ >
+ )
+}
```
Migration: `pendingInterrupts` aliases `interrupts`; `addToolApprovalResponse`
@@ -385,15 +428,17 @@ above for new code. See `docs/interrupts/`.
Set `lazy: true` on rarely-needed tools. The LLM sees their names via a synthetic
`__lazy__tool__discovery__` tool and discovers schemas on demand. Saves tokens.
-```typescript
+```typescript group=lazy-tools
import {
toolDefinition,
chat,
toServerSentEventsResponse,
maxIterations,
+ type ModelMessage,
} from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { z } from 'zod'
+import { db } from './db'
const getProductsDef = toolDefinition({
name: 'getProducts',
@@ -440,14 +485,17 @@ When all lazy tools are discovered, the discovery tool is removed automatically.
By default the discovery-tool catalog lists only bare names (`'none'`). Pass
`lazyToolsConfig` to `chat()` to include more context:
-```typescript
-const stream = chat({
- adapter: openaiText('gpt-5.5'),
- messages,
- tools: [getProducts, compareProducts],
- agentLoopStrategy: maxIterations(20),
- lazyToolsConfig: { includeDescription: 'first-sentence' },
-})
+```typescript group=lazy-tools
+// Same tools as the route above, with a richer discovery catalog:
+export function chatWithCatalog(messages: Array) {
+ return chat({
+ adapter: openaiText('gpt-5.5'),
+ messages,
+ tools: [getProducts, compareProducts],
+ agentLoopStrategy: maxIterations(20),
+ lazyToolsConfig: { includeDescription: 'first-sentence' },
+ })
+}
```
`includeDescription` values:
@@ -475,48 +523,41 @@ See the `@tanstack/ai-mcp` skill for the full MCP Apps API
### Basic usage — auto-discovery
```typescript
-// src/routes/api.chat.ts
-import { createFileRoute } from '@tanstack/react-router'
+// api/chat/route.ts
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'
-export const Route = createFileRoute('/api/chat')({
- server: {
- handlers: {
- POST: async ({ request }) => {
- const { messages } = await request.json()
-
- // 1. Connect to the MCP server.
- const mcp = await createMCPClient({
- transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
- })
-
- // 2. Discover all tools from the server (returns ServerTool[]).
- const mcpTools = await mcp.tools()
-
- // 3. Spread them into chat() — they work exactly like hand-written tools.
- // Caller owns the lifecycle — chat() never closes the client. Tools run
- // while the response streams, so close in a middleware terminal hook
- // (a try/finally around the return would close before tools execute).
- const stream = chat({
- adapter: openaiText('gpt-5.5'),
- messages,
- tools: [...mcpTools],
- middleware: [
- {
- name: 'mcp-close',
- onFinish: () => mcp.close(),
- onAbort: () => mcp.close(),
- onError: () => mcp.close(),
- },
- ],
- })
- return toServerSentEventsResponse(stream)
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+
+ // 1. Connect to the MCP server.
+ const mcp = await createMCPClient({
+ transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
+ })
+
+ // 2. Discover all tools from the server (returns ServerTool[]).
+ const mcpTools = await mcp.tools()
+
+ // 3. Spread them into chat() — they work exactly like hand-written tools.
+ // Caller owns the lifecycle — chat() never closes the client. Tools run
+ // while the response streams, so close in a middleware terminal hook
+ // (a try/finally around the return would close before tools execute).
+ const stream = chat({
+ adapter: openaiText('gpt-5.5'),
+ messages,
+ tools: [...mcpTools],
+ middleware: [
+ {
+ name: 'mcp-close',
+ onFinish: () => mcp.close(),
+ onAbort: () => mcp.close(),
+ onError: () => mcp.close(),
},
- },
- },
-})
+ ],
+ })
+ return toServerSentEventsResponse(stream)
+}
```
### Typed path — pass toolDefinition instances
@@ -526,7 +567,8 @@ The MCP client supplies a `callTool` proxy as the execute function, while
input/output validation and types come from the definitions' Zod schemas.
```typescript
-import { toolDefinition } from '@tanstack/ai'
+import { chat, toolDefinition } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'
import { z } from 'zod'
@@ -545,12 +587,15 @@ const mcp = await createMCPClient({
// Throws MCPToolNotFoundError if the server does not expose a tool with that name.
const tools = await mcp.tools([getWeather])
+const messages = [{ role: 'user' as const, content: 'Weather in Paris?' }]
const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools })
```
### Multiple servers with `createMCPClients`
```typescript
+import { chat } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
import { createMCPClients } from '@tanstack/ai-mcp'
// Each key becomes the default prefix for that server's tools.
@@ -562,6 +607,7 @@ await using pool = await createMCPClients({
// Tools auto-prefixed: 'github_search_repos', 'linear_create_issue', etc.
const tools = await pool.tools()
+const messages = [{ role: 'user' as const, content: 'Open an issue for #42' }]
const stream = chat({ adapter: openaiText('gpt-5.5'), messages, tools })
```
@@ -578,9 +624,18 @@ cancelled automatically.
You can also forward it from your own server tools:
```typescript
-const longRunningTool = myToolDef.server(async (args, ctx) => {
+import { toolDefinition } from '@tanstack/ai'
+import { z } from 'zod'
+
+const fetchReportDef = toolDefinition({
+ name: 'fetch_report',
+ description: 'Fetch a report from the slow reporting API',
+ inputSchema: z.object({ reportId: z.string() }),
+})
+
+const fetchReport = fetchReportDef.server(async ({ reportId }, ctx) => {
// Forward to fetch, a DB query, or an MCP callTool call.
- const response = await fetch('https://slow.api/data', {
+ const response = await fetch(`https://slow.api/reports/${reportId}`, {
signal: ctx?.abortSignal,
})
return response.json()
@@ -639,39 +694,33 @@ Instead of manually calling `client.tools()` and managing `close()`, pass an
**Example:**
```typescript
-import { createFileRoute } from '@tanstack/react-router'
+// api/chat/route.ts
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'
-export const Route = createFileRoute('/api/chat')({
- server: {
- handlers: {
- POST: async ({ request }) => {
- const { messages } = await request.json()
-
- const mcpClient = await createMCPClient({
- transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
- })
-
- const stream = chat({
- adapter: openaiText('gpt-5.5'),
- messages,
- mcp: {
- clients: [mcpClient],
- connection: 'keep-alive',
- onDiscoveryError: (err, source) => {
- console.warn('MCP discovery failed, skipping source:', err)
- // returning (not throwing) skips this source and continues
- },
- },
- })
-
- return toServerSentEventsResponse(stream)
+export async function POST(request: Request) {
+ const { messages } = await request.json()
+
+ const mcpClient = await createMCPClient({
+ transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
+ })
+
+ const stream = chat({
+ adapter: openaiText('gpt-5.5'),
+ messages,
+ mcp: {
+ clients: [mcpClient],
+ connection: 'keep-alive',
+ onDiscoveryError: (err) => {
+ console.warn('MCP discovery failed, skipping source:', err)
+ // returning (not throwing) skips this source and continues
},
},
- },
-})
+ })
+
+ return toServerSentEventsResponse(stream)
+}
```
## Provider Skills
@@ -763,23 +812,61 @@ Server tools need `chat({ tools })`. Client tools need their definition in
Wrong -- tool only on server, client cannot execute:
-```typescript
+```tsx group=tool-wiring
+import { chat, toolDefinition } from '@tanstack/ai'
+import { openaiText } from '@tanstack/ai-openai'
+import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
+import { clientTools } from '@tanstack/ai-client'
+import { z } from 'zod'
+
+const myToolDef = toolDefinition({
+ name: 'my_tool',
+ description: 'Example client-executed tool',
+ inputSchema: z.object({ id: z.string() }),
+ outputSchema: z.object({ success: z.boolean() }),
+})
+const adapter = openaiText('gpt-5.5')
+const messages = [{ role: 'user' as const, content: 'Run my tool' }]
+
+// server
chat({ adapter, messages, tools: [myToolDef] })
-useChat({ connection: fetchServerSentEvents('/api/chat') }) // no tools
+// client
+function ChatServerOnly() {
+ useChat({ connection: fetchServerSentEvents('/api/chat') }) // no tools
+ return null
+}
```
Wrong -- tool only on client, LLM does not know about it:
-```typescript
-chat({ adapter, messages }); // no tools
-useChat({ ..., tools: clientTools(myToolDef.client(() => result)) });
+```tsx group=tool-wiring
+// server
+chat({ adapter, messages }) // no tools
+// client
+function ChatClientOnly() {
+ useChat({
+ connection: fetchServerSentEvents('/api/chat'),
+ tools: clientTools(myToolDef.client(() => ({ success: true }))),
+ })
+ return null
+}
```
Correct:
-```typescript
-chat({ adapter, messages, tools: [myToolDef] });
-useChat({ ..., tools: clientTools(myToolDef.client((input) => ({ success: true }))) });
+```tsx group=tool-wiring
+// server
+chat({ adapter, messages, tools: [myToolDef] })
+// client
+function ChatWired() {
+ useChat({
+ connection: fetchServerSentEvents('/api/chat'),
+ tools: clientTools(
+ myToolDef.client((input) => ({ success: input.id !== '' })),
+ ),
+ })
+ return null
+}
```
Source: docs/tools/tools.md