Skip to main content

ai-core-chat-experience

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().

Source facts

Repository
TanStack/ai
Last source activity
October 13, 2026 at 07:07
Detected SKILL.md language
English
Stars
3,199
Forks
366

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
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/transports/overview.md","TanStack/ai:docs/transports/sse.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, turn thinking on with the `reasoning` option on `chat()`: ```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, reasoning: 'high', // Gemini 3.x: thinkingLevel; Gemini 2.x: 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, // } // }) ```
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub