@tanstack/ai
Version:
Core TanStack AI library - Open source AI SDK
507 lines (402 loc) • 13.2 kB
Markdown
---
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.10.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'
---
# Chat Experience
This skill builds on ai-core. Read it first for critical rules.
## Setup — Minimal Chat App
### Server: API Route (TanStack Start)
```typescript
// 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.2'),
messages,
systemPrompts: ['You are a helpful assistant.'],
abortController,
})
return toServerSentEventsResponse(stream, { abortController })
},
},
},
})
```
### Client: React Component
```typescript
// 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'
const stream = chat({
adapter: anthropicText('claude-sonnet-4-5'),
messages,
temperature: 0.7,
maxTokens: 2000,
systemPrompts: ['You are a helpful assistant.'],
abortController,
})
return toServerSentEventsResponse(stream, { abortController })
```
**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-sonnet-4-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.
```typescript
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 { geminiText } from '@tanstack/ai-gemini'
const stream = chat({
adapter: geminiText('gemini-2.5-flash'),
messages,
modelOptions: {
thinkingConfig: {
includeThoughts: true,
thinkingBudget: 100,
},
},
})
```
### 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:
```typescript
if (part.type === 'image') {
const src =
part.source.type === 'url'
? part.source.value
: `data:${part.source.mimeType};base64,${part.source.value}`
return <img key={i} src={src} alt="Attached image" />
}
```
### 4. 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'
const stream = chat({
adapter: openaiText('gpt-5.2'),
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.
## Common Mistakes
### a. CRITICAL: Using Vercel AI SDK patterns (streamText, generateText)
```typescript
// WRONG
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'
const result = streamText({ model: openai('gpt-4o'), messages })
// CORRECT
import { chat } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
const stream = chat({ adapter: openaiText('gpt-5.2'), messages })
```
### b. CRITICAL: Using Vercel createOpenAI() provider pattern
```typescript
// WRONG
import { createOpenAI } from '@ai-sdk/openai'
const openai = createOpenAI({ apiKey })
streamText({ model: openai('gpt-4o'), messages })
// CORRECT
import { openaiText } from '@tanstack/ai-openai'
import { chat } from '@tanstack/ai'
chat({ adapter: openaiText('gpt-5.2'), messages })
```
### c. CRITICAL: Using monolithic openai() instead of openaiText()
```typescript
// WRONG
import { openai } from '@tanstack/ai-openai'
chat({ adapter: openai(), model: 'gpt-5.2', messages })
// CORRECT
import { openaiText } from '@tanstack/ai-openai'
chat({ adapter: openaiText('gpt-5.2'), messages })
```
The monolithic `openai()` adapter is deprecated. Use tree-shakeable adapters:
`openaiText()`, `openaiImage()`, `openaiSpeech()`, etc.
### d. HIGH: Using toResponseStream instead of toServerSentEventsResponse
```typescript
// WRONG
import { toResponseStream } from '@tanstack/ai'
return toResponseStream(stream, { abortController })
// CORRECT
import { toServerSentEventsResponse } from '@tanstack/ai'
return toServerSentEventsResponse(stream, { abortController })
```
### e. HIGH: Passing model as separate parameter to chat()
```typescript
// WRONG
chat({ adapter: openaiText(), model: 'gpt-5.2', messages })
// CORRECT
chat({ adapter: openaiText('gpt-5.2'), messages })
```
The model is passed to the adapter factory, not to `chat()`.
### f. HIGH: Nesting temperature/maxTokens in options object
```typescript
// WRONG
chat({ adapter, messages, options: { temperature: 0.7, maxTokens: 1000 } })
// CORRECT
chat({ adapter, messages, temperature: 0.7, maxTokens: 1000 })
```
All parameters are top-level on the `chat()` options object.
### g. HIGH: Using providerOptions instead of modelOptions
```typescript
// WRONG
chat({
adapter,
messages,
providerOptions: { responseFormat: { type: 'json_object' } },
})
// CORRECT
chat({
adapter,
messages,
modelOptions: { responseFormat: { type: 'json_object' } },
})
```
### h. HIGH: Implementing custom SSE stream instead of using toServerSentEventsResponse
```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' },
})
// CORRECT
import { toServerSentEventsResponse } from '@tanstack/ai'
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
// WRONG
chat({
adapter,
messages,
onEnd: (result) => {
trackAnalytics(result)
},
})
// CORRECT
import type { ChatMiddleware } from '@tanstack/ai'
const analytics: ChatMiddleware = {
name: 'analytics',
onFinish(ctx, info) {
trackAnalytics({ reason: info.finishReason, iterations: ctx.iteration })
},
onUsage(ctx, usage) {
trackTokens(usage.totalTokens)
},
}
chat({ adapter, 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'
// 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
for await (const chunk of stream) {
if (chunk.type === 'RUN_ERROR') {
console.error('Stream error:', chunk.error.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