- MCP Server ToolsUpdated
- Managed MCP with chat()Updated
- Manual MCPUpdated
- MCP Type GenerationUpdated
- MCP Client InputUpdated
- MCP Server Tools: AdvancedNew
- MCP EventsNew
- MCP AppsUpdated
- Serve Tools over HTTPUpdated
- Serve Resources and PromptsUpdated
- Ask for InputUpdated
- Draft Text from a ToolUpdated
- MCP Server TasksUpdated
- MCP Server AuthUpdated
- MCP Server SessionsUpdated
- MCP Server on stdioUpdated
Persistence (Advanced)
- GenerationsUpdated
- Realtime Voice ChatUpdated
- Text-to-SpeechUpdated
- TranscriptionUpdated
- Transcription: AdvancedNew
- Voice CreationUpdated
- Audio RecordingUpdated
- Audio GenerationUpdated
- Image GenerationUpdated
- Image Generation: AdvancedNew
- Video GenerationUpdated
- Video Generation: AdvancedNew
- Live GenerationUpdated
- World GenerationUpdated
- Generation HooksUpdated
- OverviewUpdated
- Quick StartUpdated
- ProvidersUpdated
- HarnessesUpdated
- Harness AuthUpdated
- WorkspaceUpdated
- ToolsUpdated
- PolicyUpdated
- Lifecycle & SnapshotsUpdated
- Portable SnapshotsUpdated
- Keep Files After ReloadUpdated
- Save a Named VersionUpdated
- Branch From a VersionUpdated
- Send a Frozen FileUpdated
- Let the Agent Save and ForkUpdated
- Pick Which Files to KeepUpdated
- What a Snapshot StoresUpdated
- Instance DurabilityUpdated
- Durable RunsUpdated
- EventsUpdated
Sandboxes (Advanced)
- Runtime ContextUpdated
- Bring Your Own KeyUpdated
- Debug LoggingUpdated
- Multimodal ContentUpdated
- Files APIUpdated
- Per-Model Type SafetyUpdated
- Runtime Adapter SwitchingUpdated
- Tree-ShakingUpdated
- Extend AdapterUpdated
- Extend Adapter: AdvancedNew
- Typed Pre-Configured OptionsUpdated
- Test with a Fake ModelUpdated
- Prompt CachingUpdated
- Mid-Conversation ChangesUpdated
- Approval Flow ProcessingUpdated
WebSockets
webSocket() keeps one socket open for the conversation. A durable run reconnects on that socket.
Every turn goes over the socket, and a resume after a drop does not call the model again.
Quick setup#
1. Handle a turn on the server#
toWebSocketStream reads each frame from the client, starts one chat() turn through onRun, and sends the chunks back:
// handle-chat-socket.ts
import { chat, memoryStream, toWebSocketStream } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import type { WebSocketLike } from '@tanstack/ai'
// Connect the per-turn AbortSignal from WsRunContext to the
// AbortController that chat() expects.
function abortControllerFromSignal(signal: AbortSignal): AbortController {
const controller = new AbortController()
if (signal.aborted) controller.abort(signal.reason)
else
signal.addEventListener('abort', () => controller.abort(signal.reason), {
once: true,
})
return controller
}
// `socket` is a WHATWG-shaped server socket that you already accepted (see
// step 2) and `request` is the original handshake request.
export function handleChatSocket(socket: WebSocketLike, request: Request) {
toWebSocketStream(socket, request, {
// One durability log per turn, keyed by the runId of the frame.
durability: (ctx) => memoryStream(ctx.request),
onRun: ({ messages, threadId, runId, signal }) =>
chat({
adapter: openaiText('gpt-6.1-sol'),
messages,
threadId,
runId,
abortController: abortControllerFromSignal(signal),
}),
})
}2. Accept the socket#
How you accept the socket depends on where you host. See Hosting on Node or Hosting on Cloudflare below.
3. Connect from the client#
import { useChat, webSocket } from '@tanstack/ai-react'
const connection = webSocket('/api/chat-ws')
export function Chat() {
const { messages, sendMessage } = useChat({ connection, threadId: 'support-chat' })
return (
<div>
{messages.map((message) => (
<div key={message.id}>{message.role}</div>
))}
<button type="button" onClick={() => void sendMessage('Hello')}>
Send
</button>
</div>
)
}webSocket() opens the socket on the first sendMessage, and uses it again for every later turn. The Solid, Vue, Svelte, and Angular packages export it too.
Everything else stays the same: messages, sendMessage, stop(), tool calls, and persistence work as with any other connection.
If the network drops during a reply, the server stops that reply. The client keeps the part it already got. See Producer vs. socket lifecycle.
How it works#
One socket carries many turns:
- The client opens the socket once.
- Each user message becomes one JSON frame, with the same shape as the SSE or NDJSON POST body.
- The server runs one chat() turn for each frame, and streams the chunks back over the same socket.
- After RUN_FINISHED, the socket stays open for the next frame. It closes when the client closes it, an idle timeout fires, or the process aborts.
This page covers request and reply only. For pushes that the server starts by itself, you need your own framing on top.
Hosting on Node#
Plain Node has no global WebSocketPair, so it cannot accept a WebSocket upgrade by itself. Accept the socket yourself, then give it to toWebSocketStream:
- Listen to the upgrade event of the HTTP server.
- Accept the socket with new WebSocketServer({ noServer: true }) from ws.
- Pass the socket on. The ws socket already has the send, close, and addEventListener methods that WebSocketLike needs.
import { WebSocketServer } from 'ws'
import { memoryStream, resumeWebSocketStream } from '@tanstack/ai'
import type { Plugin } from 'vite'
import { handleChatSocket } from './handle-chat-socket'
const WS_PATH = '/api/chat-ws'
export function webSocketChatPlugin(): Plugin {
return {
name: 'websocket-chat-plugin',
configureServer(server) {
if (!server.httpServer) return
const wss = new WebSocketServer({ noServer: true })
server.httpServer.on('upgrade', (req, socket, head) => {
const url = new URL(req.url ?? '/', `http://${req.headers.host}`)
if (url.pathname !== WS_PATH) return
wss.handleUpgrade(req, socket, head, (ws) => {
// The `ws` socket satisfies WebSocketLike as it is. No adapter needed.
const request = new Request(url)
if (url.searchParams.get('offset') !== null) {
resumeWebSocketStream(ws, { adapter: memoryStream(request) })
} else {
handleChatSocket(ws, request)
}
})
})
},
}
}The examples/ts-react-chat WebSocket example and the e2e suite's durable-delivery-ws-plugin.ts use the same pattern.
Other runtimes follow the same steps: accept the socket of the platform, then call toWebSocketStream or resumeWebSocketStream with it.
- A socket from Deno.upgradeWebSocket is WHATWG-shaped and satisfies WebSocketLike directly.
- The Bun ServerWebSocket (handler-object API) needs a small adapter first.
Hosting on Cloudflare#
Cloudflare Workers and Durable Objects have a global WebSocketPair, so you do not upgrade by hand. toWebSocketResponse creates the pair, accepts the server half, connects it to toWebSocketStream, and returns the 101 upgrade Response:
import { chat, toWebSocketResponse } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
export default {
fetch(request: Request): Response {
return toWebSocketResponse(request, {
onRun: ({ messages, threadId, runId }) =>
chat({ adapter: openaiText('gpt-6.1-sol'), messages, threadId, runId }),
})
},
}resumeWebSocketResponse({ adapter }) is the matching read-only wrapper for a ?offset= resume.
If you call either one in a runtime without WebSocketPair (Node, for example), it throws. The message points to its *Stream version. So you cannot ship the Cloudflare wrapper to a runtime that cannot upgrade a socket.
Advanced#
The onRun context#
onRun gets one WsRunContext for each frame from the client:
| Field | What it is |
|---|---|
| messages | The UIMessage[] or ModelMessage[] of the turn, read from the frame. |
| threadId / runId | The AG-UI ids for this turn. |
| forwardedProps | Any extra data that the client sent with the frame. |
| request | A synthetic Request for the turn, with ?runId= in its URL. Use it to key a durability adapter. |
| signal | Aborts when the socket closes, or when this turn gets an abort frame. It does not abort other turns on the same socket. |
If a socket does not need to survive a drop, leave out durability. toWebSocketStream still sends the chunks, but it cannot replay them on reconnect.
Client options#
| Option | What it does |
|---|---|
| protocols | WebSocket subprotocols, passed to the WebSocket constructor as they are. |
| body | Static extra fields added to every outgoing RunAgentInput frame. Same as body on the HTTP connections. |
| reconnect | Reconnect limits (maxAttempts, delayMs). They work the same as on fetchServerSentEvents. See Advanced. |
| WebSocketImpl | Your own WebSocket implementation, for tests and non-browser runtimes. |
Transports shows where webSocket() fits next to the other connections.
Wire protocol#
| Direction | Frame | Meaning |
|---|---|---|
| Client to server | A RunAgentInput-shaped JSON object (same shape as the SSE or NDJSON POST body) | Start one chat() turn. |
| Client to server | { "type": "abort", "runId": "..." } | Abort one running turn. |
| Server to client | { "id": "...", "chunk": <StreamChunk> } | One chunk with a durability offset. Only when durability is set. |
| Server to client | A bare StreamChunk | One chunk with no offset. When durability is not set. |
| Server to client | { "type": "ping" } | Heartbeat. webSocket() drops these. A hand-written client must ignore anything with type: "ping". |
The two chunk shapes from the server cannot be confused. The envelope never has a top-level type, and every bare StreamChunk has one.
Resume rides in the URL, not a header#
SSE and NDJSON resume with the Last-Event-ID header, because a fetch or XHR request can set headers. The browser WebSocket constructor cannot set custom headers on the handshake. So the offset goes in the URL: ?runId=<id>&offset=<lastId>.
webSocket() does this for you:
- A durable run sends envelopes with offsets. If the socket drops before the terminal chunk of that run (RUN_FINISHED or RUN_ERROR), the client opens the socket again at ?runId=&offset=. It also removes the duplicate at the replay boundary. This is the same reconnect guarantee as fetchServerSentEvents.
- If a run never sent an offset (no durability), there is nothing to resume from. The drop shows as a connection error, and the client does not retry forever.
On the server, a URL with ?offset= is a resume, not a new turn. Send it to resumeWebSocketStream. It replays the durability log, read-only, with no model call:
import { memoryStream, resumeWebSocketStream } from '@tanstack/ai'
import type { WebSocketLike } from '@tanstack/ai'
export function handleResumeSocket(socket: WebSocketLike, request: Request) {
resumeWebSocketStream(socket, { adapter: memoryStream(request) })
}If the request has no resume offset, resumeWebSocketStream closes the socket with code 1008, because there is nothing to replay.
Durability is per turn#
One socket carries many turns, so you cannot make one durability adapter for the whole connection. Each turn needs its own log, keyed by the runId of that turn.
That is why durability in toWebSocketStream is a factory, not a value. It gets the per-turn ctx and reads ctx.request. The URL of that request already has the ?runId= of the turn, and memoryStream and durableStream read it.
Abort a turn#
An { type: 'abort', runId } frame aborts only the onRun work of that turn (ctx.signal fires). The socket stays open for the next turn.
The built-in webSocket() connection sends this frame when the abort signal of the run fires, for example on stop() in useChat. So the server stops the generation, and does not run the model call to the end for a client that stopped listening. A hand-written client must send the same frame to cancel a turn.
When the whole socket closes, every turn that still runs on it aborts.
Heartbeat and idle timeout#
toWebSocketStream sends a { type: 'ping' } frame every heartbeatMs (default 30 seconds). This keeps the connection alive through proxies that drop idle sockets.
It closes the socket if no frame arrives from the client for idleTimeoutMs (default 5 minutes).
- A heartbeat does not count as activity. Only a frame from the client counts.
- The idle timeout never fires while a turn still streams. So a long generation, such as an agent loop longer than the timeout, is never cut off.
import { chat, toWebSocketStream } from '@tanstack/ai'
import { openaiText } from '@tanstack/ai-openai'
import type { WebSocketLike } from '@tanstack/ai'
function handleChatSocket(socket: WebSocketLike, request: Request) {
toWebSocketStream(socket, request, {
onRun: ({ messages, threadId, runId }) =>
chat({ adapter: openaiText('gpt-6.1-sol'), messages, threadId, runId }),
heartbeatMs: 15_000,
idleTimeoutMs: 60_000,
})
}Producer vs. socket lifecycle#
On a WebSocket, the producer is tied to the socket. When the socket closes or errors, toWebSocketStream aborts every turn on it. That abort stops the chat() call behind each turn. This is true with memoryStream and with durableStream.
So after a drop:
- The server stops the reply.
- The durability log keeps the chunks produced up to the drop, then ends with an abort RUN_ERROR.
- A reconnect replays that partial reply. It does not continue the run.
To keep a reply going across a dropped connection, use SSE or NDJSON with durableStream. There the producer does not stop when the client goes away. See memoryStream in production.