Skip to content
Back to skills

Chat Experience

ASecurity

End-to-end chat implementation: server endpoint with chat() and toServerSentEventsResponse(), client-side useChat hook with fetchServerSentEvents(), message rendering with UIMessage parts, multimodal content, thinking/reasoning display. Covers streaming states, connection adapters, and message format conversions. NOT Vercel AI SDK — uses chat() not streamText().

  • 3,166 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added May 26, 2026
ai-agentstypescriptreactvueangularapi

Works with

  • cursor
  • cli
  • api
  • mcp

Security analysis

A100/100

Scanned September 28, 2026

npx -y skills add TanStack/ai --skill chat-experience --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Chat Experience?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Chat Experience
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/tanstack-chat-experience/badge)](https://www.skillsdirectory.com/skills/tanstack-chat-experience)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: ai-core/chat-experience
description: >
  End-to-end chat implementation: server endpoint with chat() and
  toServerSentEventsResponse(), client-side useChat hook with
  fetchServerSentEvents(), message rendering with UIMessage parts,
  multimodal content, thinking/reasoning display. Covers streaming
  states, connection adapters, and message format conversions.
  NOT Vercel AI SDK — uses chat() not streamText().
type: sub-skill
library: tanstack-ai
library_version: '0.42.0'
sources:
  - 'TanStack/ai:docs/getting-started/quick-start.md'
  - 'TanStack/ai:docs/chat/streaming.md'
  - 'TanStack/ai:docs/chat/connection-adapters.md'
  - 'TanStack/ai:docs/chat/thinking-content.md'
  - 'TanStack/ai:docs/advanced/multimodal-content.md'
  - 'TanStack/ai:docs/resumable-streams/overview.md'
  - 'TanStack/ai:docs/persistence/client-persistence.md'
---

# Chat Experience

This skill builds on ai-core. Read it first for critical rules.

## Setup — Minimal Chat App

### Server: API Route (TanStack Start)

```typescript ignore
// src/routes/api.chat.ts
import { createFileRoute } from '@tanstack/react-router'
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'

export const Route = createFileRoute('/api/chat')({
  server: {
    handlers: {
      POST: async ({ request }) => {
        const abortController = new AbortController()
        const body = await request.json()
        const { messages } = body

        const stream = chat({
          adapter: openaiText('gpt-5.5'),
          messages,
          systemPrompts: ['You are a helpful assistant.'],
          abortController,
        })

        return toServerSentEventsResponse(stream, { abortController })
      },
    },
  },
})
```

### Client: React Component

```tsx
// src/routes/index.tsx
import { useState } from 'react'
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
import type { UIMessage } from '@tanstack/ai-react'

function ChatPage() {
  const [input, setInput] = useState('')

  const { messages, sendMessage, isLoading, error, stop } = useChat({
    connection: fetchServerSentEvents('/api/chat'),
  })

  const handleSubmit = () => {
    if (!input.trim()) return
    sendMessage(input.trim())
    setInput('')
  }

  return (
    <div>
      <div>
        {messages.map((message: UIMessage) => (
          <div key={message.id}>
            <strong>{message.role}:</strong>
            {message.parts.map((part, i) => {
              if (part.type === 'text') {
                return <p key={i}>{part.content}</p>
              }
              return null
            })}
          </div>
        ))}
      </div>

      {error && <div>Error: {error.message}</div>}

      <div>
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          onKeyDown={(e) => {
            if (e.key === 'Enter' && !e.shiftKey) {
              e.preventDefault()
              handleSubmit()
            }
          }}
          disabled={isLoading}
          placeholder="Type a message..."
        />
        {isLoading ? (
          <button onClick={stop}>Stop</button>
        ) : (
          <button onClick={handleSubmit} disabled={!input.trim()}>
            Send
          </button>
        )}
      </div>
    </div>
  )
}
```

Vue/Solid/Svelte/Preact have identical patterns with different hook imports
(e.g., `import { useChat } from '@tanstack/ai-solid'`).

## Core Patterns

### 1. Streaming Chat with SSE

Server returns a streaming SSE Response; client parses it automatically.

**Server:**

```typescript
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { anthropicText } from '@tanstack/ai-anthropic'

export async function POST(request: Request) {
  const { messages } = await request.json()
  const abortController = new AbortController()

  const stream = chat({
    adapter: anthropicText('claude-opus-5'),
    messages,
    modelOptions: {
      temperature: 0.7,
      max_tokens: 2000, // Anthropic-native key
    },
    systemPrompts: ['You are a helpful assistant.'],
    abortController,
  })

  return toServerSentEventsResponse(stream, { abortController })
}
```

To make the SSE response resumable (reconnect after a drop/refresh without
re-running the provider), pass a delivery-durability adapter:
`toServerSentEventsResponse(stream, { durability: { adapter: memoryStream(request) } })`
(`memoryStream` from `@tanstack/ai` is process-local, for dev/tests) or
`durableStream(request, { server })` from `@tanstack/ai-durable-stream`
(Durable Streams protocol, production). Each SSE event gets an opaque
adapter-owned `id:`; `fetchServerSentEvents` auto-reconnects with
`Last-Event-ID` and exposes `joinRun(runId)` to replay a run from the start.
See `docs/resumable-streams/overview.md`.

**Client:**

```typescript
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'

const { messages, sendMessage, isLoading, error, stop, status } = useChat({
  connection: fetchServerSentEvents('/api/chat'),
  body: { provider: 'anthropic', model: 'claude-opus-5' },
  onFinish: (message) => {
    console.log('Response complete:', message.id)
  },
  onError: (err) => {
    console.error('Stream error:', err)
  },
})
```

The `body` field is merged into the POST request body alongside `messages`,
letting the server read `data.provider`, `data.model`, etc.

The `status` field tracks the chat lifecycle: `'ready'` | `'submitted'` | `'streaming'` | `'error'`.

### 2. Rendering Thinking/Reasoning Content

Models with extended thinking (Claude, Gemini) emit `ThinkingPart` in the message parts array.

```tsx
import type { UIMessage } from '@tanstack/ai-react'

function MessageRenderer({ message }: { message: UIMessage }) {
  return (
    <div>
      {message.parts.map((part, i) => {
        if (part.type === 'thinking') {
          const isComplete = message.parts
            .slice(i + 1)
            .some((p) => p.type === 'text')
          return (
            <details key={i} open={!isComplete}>
              <summary>
                {isComplete ? 'Thought process' : 'Thinking...'}
              </summary>
              <pre>{part.content}</pre>
            </details>
          )
        }

        if (part.type === 'text' && part.content) {
          return <p key={i}>{part.content}</p>
        }

        if (part.type === 'tool-call') {
          return (
            <div key={part.id}>
              Tool call: {part.name} ({part.state})
            </div>
          )
        }

        return null
      })}
    </div>
  )
}
```

Server-side, enable thinking via `modelOptions` on the adapter:

```typescript
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { geminiText } from '@tanstack/ai-gemini'

export async function POST(request: Request) {
  const { messages } = await request.json()

  const stream = chat({
    adapter: geminiText('gemini-3.8-flash'),
    messages,
    modelOptions: {
      thinkingConfig: {
        includeThoughts: true,
        thinkingLevel: 'HIGH', // Gemini 3.x; Gemini 2.x uses thinkingBudget
      },
    },
  })

  return toServerSentEventsResponse(stream)
}
```

### 3. Sending Multimodal Content (Images)

Use `sendMessage` with a `MultimodalContent` object instead of a plain string.

```typescript
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
import type { ContentPart } from '@tanstack/ai'

const { sendMessage } = useChat({
  connection: fetchServerSentEvents('/api/chat'),
})

function sendImageMessage(text: string, imageBase64: string, mimeType: string) {
  const contentParts: Array<ContentPart> = [
    { type: 'text', content: text },
    {
      type: 'image',
      source: { type: 'data', value: imageBase64, mimeType },
    },
  ]

  sendMessage({ content: contentParts })
}

function sendImageUrl(text: string, imageUrl: string) {
  const contentParts: Array<ContentPart> = [
    { type: 'text', content: text },
    {
      type: 'image',
      source: { type: 'url', value: imageUrl },
    },
  ]

  sendMessage({ content: contentParts })
}
```

Render image parts in received messages:

```tsx
import type { UIMessage } from '@tanstack/ai-react'

function ImagePart({ part }: { part: UIMessage['parts'][number] }) {
  if (part.type !== 'image') return null
  // A provider file handle is an opaque id, so the browser cannot load it.
  if (part.source.type === 'file') return null
  const src =
    part.source.type === 'url'
      ? part.source.value
      : `data:${part.source.mimeType};base64,${part.source.value}`
  return <img src={src} alt="Attached image" />
}
```

For media reused across turns, upload once via a provider Files adapter
(`openaiFiles()`, `anthropicFiles()`, `geminiFiles()`, `grokFiles()`,
`falFiles()`) and send a `{ type: 'file' }` source built with
`fileSourceFromHandle(handle)` instead of re-sending base64 each request. The
source is `{ type: 'file', value, provider }`: an opaque handle and the adapter
that issued it. A different provider (or one without Files API support at all)
rejects it with a clear error before any request is sent.
The source crosses the chat wire, so the browser can put it straight into the
`sendMessage` content. Import `fileSourceFromHandle` from the browser-safe
`@tanstack/ai/client` entry. See `ai-core/adapter-configuration/SKILL.md` §7
and `docs/advanced/files-api.md`.

### 4. Sending Audio Messages (Browser Recording)

Use `useAudioRecorder` from `@tanstack/ai-react` (or `createAudioRecorder` in Svelte) to capture audio in the browser. The resolved `AudioRecording` includes a ready-to-use `part` that slots directly into `sendMessage`.

```typescript
import {
  useAudioRecorder,
  useChat,
  fetchServerSentEvents,
} from '@tanstack/ai-react'

const { isRecording, isSupported, start, stop } = useAudioRecorder()
const { sendMessage } = useChat({
  connection: fetchServerSentEvents('/api/chat'),
})

async function toggle() {
  if (!isRecording) {
    await start()
    return
  }
  const recording = await stop()
  await sendMessage({ content: [recording.part] })
}
```

`recording.part` is `{ type: 'audio', source: { type: 'data', value: base64, mimeType } }`. Returns the recorder's native format (`audio/webm` or `audio/mp4`) with no transcoding.

### 5. HTTP Stream Format (Alternative to SSE)

Use `toHttpResponse` + `fetchHttpStream` for newline-delimited JSON instead of SSE.

**Server:**

```typescript
import { chat, toHttpResponse } 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 toHttpResponse(stream, { abortController })
}
```

**Client:**

```typescript
import { useChat, fetchHttpStream } from '@tanstack/ai-react'

const { messages, sendMessage } = useChat({
  connection: fetchHttpStream('/api/chat'),
})
```

The only difference is swapping `toServerSentEventsResponse` / `fetchServerSentEvents`
for `toHttpResponse` / `fetchHttpStream`. Everything else stays identical.

This includes resumability: pass the same `durability` adapter to
`toHttpResponse(stream, { durability: { adapter: memoryStream(request) } })` and
each NDJSON line becomes an `{ id, chunk }` envelope. `fetchHttpStream`
auto-reconnects with `Last-Event-ID`, de-dupes the replayed prefix, and exposes
`joinRun(runId)` — the same guarantees as resumable SSE. The XHR adapters
(`xhrServerSentEvents` / `xhrHttpStream`) are resumable too.

### 6. MCP Tool Discovery via `chat({ mcp })`

Pass `mcp` to let `chat()` own discovery **and** lifecycle for one or more MCP
clients. Useful when you want minimal boilerplate and don't need to reuse the
clients across calls.

`createMCPClient` tries spec `2026-07-28` first.
If the server does not support that spec, the client uses the 2025 initialize handshake.

```typescript
// Prop shape:
// chat({
//   ...,
//   mcp: {
//     clients: Array<MCPClient | MCPClients>,
//     connection?: 'close' | 'keep-alive',  // default: 'close'
//     lazyTools?: boolean,
//     onDiscoveryError?: (error: unknown, source) => void,
//   }
// })
```

- **`clients`** — one or more `MCPClient` / `MCPClients` instances.
- **`connection`** — `'close'` (default) closes each client when the run ends
  (after the agent loop completes and the stream is drained); with
  `'keep-alive'`, `chat()` never closes the clients — the caller owns their
  lifecycle (keep connections warm across requests).
- **`lazyTools`** — forwarded to `tools({ lazy: true })` so tool schemas are
  sent to the LLM on demand.
- **`onDiscoveryError`** — throw (or re-throw) to fail the entire call fast;
  return normally to skip that source and continue. Omit to rethrow (fail-fast).

**When to use `mcp` vs. the tools spread:**

| Approach                                                | Use when                                                                                        |
| ------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
| `chat({ mcp: { clients: [...] } })`                     | You want discovery + lifecycle managed for you, and don't need fully-typed input/output schemas |
| `tools: [...await client.tools([toolDefinition(...)])]` | You want fully-typed MCP tools with Zod input/output validation                                 |

**Server-side example:**

```typescript
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'

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.6'),
    messages,
    mcp: {
      clients: [mcpClient],
      connection: 'keep-alive', // chat() won't close it — reuse across requests
    },
  })

  return toServerSentEventsResponse(stream)
  // connection: 'keep-alive' — chat() never closes mcpClient; it stays open for reuse across runs.
}
```

**Host an MCP server.** Import `createMCPServer` from `@tanstack/ai-mcp/server`.
Call `server.fetch(request)` in your HTTP route.

```typescript
import { toolDefinition } from '@tanstack/ai'
import { createMCPServer } from '@tanstack/ai-mcp/server'
import { z } from 'zod'

const getWeather = toolDefinition({
  name: 'get_weather',
  description: 'Current weather for a city',
  inputSchema: z.object({ city: z.string() }),
}).server(async ({ city }) => {
  return { city, temperature: 18, conditions: 'clear' }
})

const server = createMCPServer({
  name: 'weather',
  version: '1.0.0',
  tools: [getWeather],
})

export function handleMcp(request: Request) {
  return server.fetch(request)
}

// Mount handleMcp on GET, POST, and DELETE.
// GET is the spec 2025 stream.
// DELETE closes a spec 2025 session.
```

`serveMCPStdio` from `@tanstack/ai-mcp/server/stdio` serves that server on stdin and stdout.
Write logs with `console.error`.
stdout carries only protocol messages.

```typescript
import { toolDefinition } from '@tanstack/ai'
import { createMCPServer } from '@tanstack/ai-mcp/server'
import { serveMCPStdio } from '@tanstack/ai-mcp/server/stdio'
import { z } from 'zod'

const getWeather = toolDefinition({
  name: 'get_weather',
  description: 'Current weather for a city',
  inputSchema: z.object({ city: z.string() }),
}).server(async ({ city }) => {
  return { city, temperature: 18, conditions: 'clear' }
})

const server = createMCPServer({
  name: 'weather',
  version: '1.0.0',
  tools: [getWeather],
})

serveMCPStdio(server)
```

**MCP input interrupt.** When `chat()` receives an MCP input request, the run outcome is an interrupt.
The stream ends with `RUN_FINISHED`.
The outcome type is `interrupt`.
Read each interrupt whose `reason` is `mcp_input`.
The payload key is `tanstack:interruptPayload`.

`form` means the server asks the user for input.
`sampling` means the server asks for a model result.

```typescript
import { chat, INTERRUPT_PAYLOAD_METADATA_KEY } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import { createMCPClient } from '@tanstack/ai-mcp'

const client = await createMCPClient({
  transport: { type: 'http', url: 'https://mcp.example.com/mcp' },
})

try {
  const stream = chat({
    adapter: openaiText('gpt-5.6'),
    messages: [{ role: 'user', content: 'Weather in Paris?' }],
    tools: await client.tools(),
  })

  for await (const chunk of stream) {
    if (chunk.type !== 'RUN_FINISHED') continue
    if (chunk.outcome?.type !== 'interrupt') continue

    for (const item of chunk.outcome.interrupts) {
      if (item.reason !== 'mcp_input') continue
      const payload = item.metadata?.[INTERRUPT_PAYLOAD_METADATA_KEY]
      if (typeof payload !== 'object' || payload === null) continue
      if (!('kind' in payload)) continue
      // payload.kind is 'form' or 'sampling'
    }
  }
} finally {
  await client.close()
}
```

To continue, answer the interrupt:

1. In `useChat`, the item `kind` is `generic`.
2. For a `form`, call `resolveInterrupt` with an object that matches `request.requestedSchema`. A `createMCPServer` server asks for `{ value: string }`.
3. For `sampling`, call `resolveInterrupt` with the reply text.
4. Call `cancel()` to decline.
5. The route passes `parentRunId` and `resume` to `chat()`. The tool runs again with the answer.

```tsx ignore
// `interrupt` is one item from `useChat().interrupts`.
if (interrupt.reason === 'mcp_input' && interrupt.kind === 'generic') {
  interrupt.resolveInterrupt({ value: 'Paris' })
}
```

This works on spec `2026-07-28`. On spec 2025, the server asks the client in the middle of the tool call. `chat()` cannot pause that call, so the tool call fails.

### 7. Queueing Messages Sent While Streaming

By default, a `sendMessage` call that arrives while a stream is in flight is
**queued** and sent automatically once the run settles **successfully** —
this is a behavior change: such sends used to be silently dropped. Configure
it with the `queue` option on `useChat`:

```typescript
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'

const { messages, queue, sendMessage, cancelQueued, isLoading } = useChat({
  connection: fetchServerSentEvents('/api/chat'),
  queue: { whenBusy: 'queue', drain: 'fifo', maxSize: 5, onOverflow: 'reject' },
})
```

- **`whenBusy`** — `'queue'` (default) holds the message until a successful
  settle; `'drop'` ignores the send (never appears in `queue`/`messages`);
  `'interrupt'` aborts the current stream and sends immediately (unlike
  `stop()`, does **not** flush already-queued items — they drain after the
  interrupting send **succeeds**).
- **`drain`** — `'fifo'` (default) sends queued items one at a time in
  order; `'batch'` merges everything queued into a single send once the
  run settles successfully.
- **`maxSize`** / **`onOverflow`** — cap the queue length; `'reject'`
  (default) silently ignores overflow sends (does not throw),
  `'drop-oldest'` evicts the oldest queued item to make room.

The top-level `queue` option also accepts a plain `WhenBusy` string
shorthand (e.g. `queue: 'interrupt'`) or a `QueueStrategy` function for
per-send action control. Strategy form always drains FIFO. Actions use
the `WhenBusy` type.

**Drain vs flush:** queued messages auto-send only after a **successful**
settle. They are **discarded** on stream error/abort of the active
generation, `stop()`, `clear()`, `unsubscribe()`, and `reload()`.
`interrupt` does not flush.

`queue: Array<QueuedMessage>` (`{ id, content, createdAt }`) is separate
from `messages` — render pending sends distinctly and cancel with
`cancelQueued(id)`:

```tsx
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'

function QueuedMessages() {
  const { queue, cancelQueued } = useChat({
    connection: fetchServerSentEvents('/api/chat'),
  })

  return (
    <div>
      {queue.map((q) => (
        <div key={q.id}>
          {typeof q.content === 'string' ? q.content : '[attachment]'}
          <button onClick={() => cancelQueued(q.id)}>Cancel</button>
        </div>
      ))}
    </div>
  )
}
```

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' })
```

### 8. Browser-Refresh Durability (client persistence)

By default a `ChatClient` / `useChat` keeps messages in memory only, so a full
page reload loses the conversation. The optional `persistence` option (a
`ChatClientPersistence` adapter) fixes this from the client side: it stores one
combined record — `{ messages, resume? }` (`ChatPersistedState`) — per chat `id`,
so a reload restores the transcript **and** rehydrates any pending interrupt /
rejoins a run that was still streaming. No manual `initialMessages` + `onFinish`
boilerplate.

Three storage adapters ship from `@tanstack/ai-client`:
`localStoragePersistence` (survives reloads and browser restarts),
`sessionStoragePersistence` (scoped to the tab), and `indexedDBPersistence`
(async, structured-clone storage — no codec needed for `Date`/`Map`/etc.).
Give the chat a stable `threadId` so the reload finds the same record.
Persistence keys on `threadId`; the storage adapters are re-exported from each
framework package, so a single import works:

```typescript
import {
  useChat,
  fetchServerSentEvents,
  localStoragePersistence,
} from '@tanstack/ai-react'

// Defaults to the ChatPersistedState shape and a JSON codec, so no type
// argument or serialize/deserialize is needed. indexedDBPersistence stores via
// structured clone (a Date round-trips exactly).
const persistence = localStoragePersistence()

function Chat() {
  const { messages, sendMessage } = useChat({
    threadId: 'support-chat',
    connection: fetchServerSentEvents('/api/chat'),
    persistence,
  })
  // ...render messages, call sendMessage(text)
}
```

**Keep large transcripts off the client.** `persistence` also accepts `true`
(server-authoritative): the client caches nothing, and on mount it hydrates the
thread from the server by `threadId` (transcript plus a cursor to any run still
generating). An adapter is client-authoritative; `true` leaves history on the
server and needs a connection with a `hydrate` handler plus a server GET
endpoint (`reconstructChat`), since the delivery log only holds one run.

**Mid-stream reload rejoin.** If the run was still streaming when the page
reloaded, the client re-attaches instead of showing a frozen half-reply — but
only when the connection is **resumable**: a delivery-durability-backed route
that records the stream and exposes a GET replay handler (see
`docs/resumable-streams/overview.md` and Pattern 1's `durability` adapter). Given
that, `useChat` finds the persisted in-flight run on load and auto-rejoins it via
`joinRun`, replaying from the server's log so the reply finishes where it left
off. No extra client code beyond the resumable connection.

**Every framework, no extra code.** Durability rides the existing `persistence`
option, so it works identically in `@tanstack/ai-react`, `-solid`, `-vue`,
`-svelte`, `-angular`, and `-preact` — pass `persistence` (and a stable
`threadId`, which is the chat's identity) to the framework's `useChat` /
`createChat` / `injectChat`; nothing is framework-specific.

> **Client vs. server durability.** This is the client (per-browser) half.
> The authoritative, multi-user, server-side copy is the `withPersistence`
> middleware — see ai-core/middleware/SKILL.md. The two are independent; use
> both for instant reload restore plus a durable record of record.

## Common Mistakes

### a. CRITICAL: Using Vercel AI SDK patterns (streamText, generateText)

```typescript
// WRONG
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'

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 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 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'

const messages = [{ role: 'user' as const, content: 'Hello' }]
chat({ adapter: openaiText('gpt-5.6'), messages })
```

### c. CRITICAL: Using monolithic openai() instead of openaiText()

```typescript ignore
// WRONG — `openai()` is no longer exported from @tanstack/ai-openai
import { openai } from '@tanstack/ai-openai'
chat({ adapter: openai(), model: 'gpt-5.6', messages })
```

```typescript
// CORRECT
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 monolithic `openai()` adapter no longer exists. Use tree-shakeable adapters:
`openaiText()`, `openaiImage()`, `openaiSpeech()`, etc.

### d. HIGH: Using toResponseStream instead of toServerSentEventsResponse

```typescript ignore
// WRONG — toResponseStream does not exist
import { toResponseStream } from '@tanstack/ai'
return toResponseStream(stream, { abortController })
```

```typescript
// CORRECT
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 ignore
// WRONG
chat({ adapter: openaiText(), model: 'gpt-5.6', messages })
```

```typescript
// CORRECT
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()`.

### f. HIGH: Passing sampling options at the root of chat()

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 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: openaiText('gpt-5.6'),
  messages,
  modelOptions: { temperature: 0.7, max_output_tokens: 1000 },
})
```

`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
`modelOptions.options` for Ollama). See ai-core/adapter-configuration/SKILL.md.

### g. HIGH: Using providerOptions instead of modelOptions

```typescript ignore
// WRONG
chat({
  adapter,
  messages,
  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' }]

chat({
  adapter: openaiText('gpt-5.6'),
  messages,
  modelOptions: { text: { format: { type: 'json_object' } } },
})
```

### h. HIGH: Implementing custom SSE stream instead of using toServerSentEventsResponse

```typescript
// WRONG
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 { 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,
error events (RUN_ERROR), and correct headers automatically.

### i. HIGH: Implementing custom onEnd/onFinish callbacks instead of middleware

```typescript ignore
// WRONG — chat() has no onEnd/onFinish option
chat({
  adapter,
  messages,
  onEnd: (result) => {
    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',
  onFinish(ctx, info) {
    trackAnalytics({ reason: info.finishReason, iterations: ctx.iteration })
  },
  onUsage(ctx, usage) {
    trackTokens(usage.totalTokens)
  },
}

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.
See also: ai-core/middleware/SKILL.md.

### j. HIGH: Importing from @tanstack/ai-client instead of framework package

```typescript
// WRONG
import { fetchServerSentEvents } from '@tanstack/ai-client'
import { useChat } from '@tanstack/ai-react'
```

```typescript
// CORRECT
import { useChat, fetchServerSentEvents } from '@tanstack/ai-react'
```

Framework packages re-export everything needed from `@tanstack/ai-client`.
Import from `@tanstack/ai-client` only in vanilla JS (no framework).

### k. MEDIUM: Not handling RUN_ERROR events in streaming context

Streaming errors arrive as `RUN_ERROR` events in the stream, not as thrown
exceptions. The `useChat` hook surfaces these via the `error` state and
`onError` callback. If you consume the stream manually (without `useChat`),
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.message)
    break
  }
  if (chunk.type === 'TEXT_MESSAGE_CONTENT') {
    process.stdout.write(chunk.delta)
  }
}
```

If not handled, the UI appears to hang with no feedback.

## Cross-References

- See also: **ai-core/tool-calling/SKILL.md** -- Most chats include tools
- See also: **ai-core/adapter-configuration/SKILL.md** -- Adapter choice affects available features
- See also: **ai-core/middleware/SKILL.md** -- Use middleware for analytics and lifecycle events
- See also: **`@tanstack/ai-persistence` skills** (`skills/ai-persistence/SKILL.md` in that package) -- Server + client state persistence, store contracts, adapter recipes (deeper than Pattern 8)

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.