A custom connection adapter reads your protocol and writes chunks into useChat. Pick the shape from the channel:
Check the built-in transports first. A server function with stream() takes less code than a custom adapter.
If your transport makes one request per user message, implement ConnectConnectionAdapter.
This adapter posts an AG-UI request body and reads newline-delimited JSON back. Replace the fetch call and the read loop with your own protocol:
// my-adapter.ts
import type { ConnectConnectionAdapter } from "@tanstack/ai-react";
import { restoreInboundChunk, uiMessagesToWire } from "@tanstack/ai/client";
export const myAdapter: ConnectConnectionAdapter = {
async *connect(messages, data, abortSignal, runContext) {
const response = await fetch("/api/chat", {
method: "POST",
headers: {
"Content-Type": "application/json",
...runContext.headers,
},
body: JSON.stringify({
threadId: runContext.threadId,
runId: runContext.runId,
...(runContext.resume ? { resume: runContext.resume } : {}),
state: {},
messages: uiMessagesToWire(messages),
tools: runContext.clientTools ?? [],
context: [],
forwardedProps: { ...runContext.forwardedProps, ...data },
}),
...(abortSignal ? { signal: abortSignal } : {}),
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
if (!response.body) throw new Error("Response has no body");
// Newline-delimited JSON: one chunk per line.
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.trim()) {
yield restoreInboundChunk(JSON.parse(line));
}
}
}
},
};Copy runContext.headers onto the request headers, not into the body. If your connect drops them, it drops BYOK keys (x-byok-*). See Bring Your Own Key.
import { useChat } from "@tanstack/ai-react";
import { myAdapter } from "./my-adapter";
export function Chat() {
const { messages, sendMessage } = useChat({ connection: myAdapter, threadId: "support-chat" });
return (
<div>
{messages.map((message) => (
<p key={message.id}>
{message.parts.map((part) =>
part.type === "text" ? part.content : "",
)}
</p>
))}
<button onClick={() => void sendMessage("Hello")}>Send</button>
</div>
);
}The body is a valid AG-UI RunAgentInput, so the standard server helpers read it:
// api/chat.ts
import { chat, chatParamsFromRequest, toHttpResponse } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
});
return toHttpResponse(stream);
}Click Send. The chunks from your adapter stream into messages like any built-in transport.
A WebSocket, a BroadcastChannel, postMessage between iframes, or a shared worker works in a different way from request and response. You open the channel once. Then you send and receive on it for the full life of the client. connect cannot model this, because it expects one async iterable per request.
For a resumable WebSocket, use the built-in webSocket() transport. For other persistent channels, implement SubscribeConnectionAdapter:
The runtime connects the two. The chunks that arrive between send() and the next terminal event (RUN_FINISHED or RUN_ERROR) belong to that run.
If webSocket() does not fit (a different wire format, no need for resume, or a server that you do not control), write the adapter yourself:
import { useChat, type SubscribeConnectionAdapter } from "@tanstack/ai-react";
import type { StreamChunk } from "@tanstack/ai";
function websocketConnection(url: string): SubscribeConnectionAdapter {
const ws = new WebSocket(url);
const queue: Array<StreamChunk> = [];
let pending: ((chunk: StreamChunk | null) => void) | null = null;
let closed = false;
const ready = new Promise<void>((resolve) => {
ws.addEventListener("open", () => resolve(), { once: true });
});
function deliver(chunk: StreamChunk | null) {
const resolve = pending;
if (resolve) {
pending = null;
resolve(chunk);
} else if (chunk !== null) {
queue.push(chunk);
}
}
ws.addEventListener("message", (event) => {
const chunk: StreamChunk = JSON.parse(event.data);
deliver(chunk);
});
ws.addEventListener("close", () => {
closed = true;
deliver(null);
});
return {
async *subscribe(abortSignal) {
// Register the abort listener once (not per-iteration) so it can't
// accumulate on a long-lived socket.
const onAbort = () => deliver(null);
abortSignal?.addEventListener("abort", onAbort, { once: true });
try {
while (!abortSignal?.aborted) {
// Drain buffered chunks BEFORE honoring `closed`: a burst of messages
// followed by a close event (common within one macrotask) must still
// deliver the queued chunks (including a trailing RUN_FINISHED),
// otherwise the client would hang waiting for a terminal it dropped.
const buffered = queue.shift();
if (buffered !== undefined) {
yield buffered;
continue;
}
if (closed) return;
const chunk = await new Promise<StreamChunk | null>((resolve) => {
pending = resolve;
});
if (chunk === null) return;
yield chunk;
}
} finally {
abortSignal?.removeEventListener("abort", onAbort);
}
},
async send(messages, data, _abortSignal, runContext) {
await ready;
ws.send(
JSON.stringify({
threadId: runContext.threadId,
runId: runContext.runId,
messages,
data,
}),
);
},
};
}
const connection = websocketConnection("wss://example.com/chat");
export function Chat() {
const { messages, sendMessage } = useChat({ connection, threadId: "support-chat" });
return (
<div>
{messages.map((message) => (
<p key={message.id}>
{message.parts.map((part) =>
part.type === "text" ? part.content : "",
)}
</p>
))}
<button onClick={() => void sendMessage("Hello")}>Send</button>
</div>
);
}Warning
Make sure that your server emits RUN_FINISHED (or RUN_ERROR) at the end of each run. Without it, the client does not know that the assistant turn ended, and it waits forever. See Stream Events.
The chat client gives your adapter a runContext on each send:
The built-in fetch and XHR transports copy runContext.headers. stream() and rpcStream() do not, because a server function call has no request headers. They pass the rest of the run as the 4th factory argument.
restoreInboundChunk rebuilds the TanStack fields of a chunk after the wire, such as token usage. The built-in transports call it on every chunk.
The runtime adds the terminal event of a connect stream for you:
If one or more of these is true, use subscribe / send:
In other cases, use fetchServerSentEvents or stream(). They are simpler, and you do not manage a connection lifecycle.
A ConnectionAdapter is a union. Give it connect, or give it both subscribe and send. Never give it both modes.
import type { RunAgentResumeItem, UIMessage } from "@tanstack/ai-client";
import type { ModelMessage, StreamChunk } from "@tanstack/ai";
export interface RunAgentInputContext {
threadId: string;
runId: string;
parentRunId?: string;
resume?: Array<RunAgentResumeItem>;
clientTools?: Array<{ name: string; description: string; parameters: unknown }>;
forwardedProps?: Record<string, unknown>;
headers?: Record<string, string>;
}
export interface ConnectConnectionAdapter {
connect(
messages: UIMessage[] | ModelMessage[],
data: Record<string, any> | undefined,
abortSignal: AbortSignal | undefined,
runContext: RunAgentInputContext,
): AsyncIterable<StreamChunk>;
}
export interface SubscribeConnectionAdapter {
subscribe(abortSignal?: AbortSignal): AsyncIterable<StreamChunk>;
send(
messages: UIMessage[] | ModelMessage[],
data: Record<string, any> | undefined,
abortSignal: AbortSignal | undefined,
runContext: RunAgentInputContext,
): Promise<void>;
}
export type ConnectionAdapter =
| ConnectConnectionAdapter
| SubscribeConnectionAdapter;Both shapes can also carry optional persistence members:
The built-in fetch, JSON, and XHR transports have them. See Server Functions: Persistence handlers.
Internally, ChatClient changes both shapes to one subscribe / send pair with normalizeConnectionAdapter():