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.
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),
}),
})
}How you accept the socket depends on where you host. See Hosting on Node or Hosting on Cloudflare below.
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.
One socket carries many turns:
This page covers request and reply only. For pushes that the server starts by itself, you need your own framing on top.
Plain Node has no global WebSocketPair, so it cannot accept a WebSocket upgrade by itself. Accept the socket yourself, then give it to toWebSocketStream:
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.
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.
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.
| 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, initialDelayMs, maxDelayMs, budgetMs). 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.
| 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.
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:
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.
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.
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.
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).
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,
})
}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:
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.