You send a message. The UI sits still until the model is done. That wait feels broken.
Stream the reply. Tokens show up as the model writes them.
Call chat(). Then wrap the result with toServerSentEventsResponse:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} 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-5.6"),
messages,
threadId,
runId,
});
return toServerSentEventsResponse(stream);
}chatParamsFromRequest reads the AG-UI body that useChat sends. If the body is invalid, it throws a Response with status 400. If your framework does not map a thrown Response to HTTP 400, catch it and return it.
import { useState } from "react";
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
export function Chat() {
const [input, setInput] = useState("");
const { messages, sendMessage, isLoading, stop } = useChat({
connection: fetchServerSentEvents("/api/chat"),
});
return (
<>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, index) =>
part.type === "text" ? <p key={index}>{part.content}</p> : null,
)}
</div>
))}
<form
onSubmit={(event) => {
event.preventDefault();
if (input.trim() === "") {
return;
}
sendMessage(input);
setInput("");
}}
>
<input
value={input}
onChange={(event) => setInput(event.target.value)}
/>
{isLoading ? (
<button type="button" onClick={stop}>
Stop
</button>
) : (
<button type="submit">Send</button>
)}
</form>
</>
);
}messages updates as chunks arrive. isLoading is true while the run is in flight.
The same pattern works in every UI framework. See Quick Start.
If SSE is blocked, pick another transport on Connection Adapters.
Call stop(). The client aborts the fetch.
Pass the same AbortController to chat() and toServerSentEventsResponse so the server stops the model too:
import {
chat,
chatParamsFromRequest,
toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
export async function POST(request: Request) {
const { messages, threadId, runId } = await chatParamsFromRequest(request);
const abortController = new AbortController();
const stream = chat({
adapter: openaiText("gpt-5.6"),
messages,
threadId,
runId,
abortController,
});
return toServerSentEventsResponse(stream, { abortController });
}AbortError from stop() is expected. Pending client-tool work for that turn does not resume. A later addToolResult() for that turn is ignored.
A dropped connection mid-line throws StreamTruncatedError. The client then moves to error. See Connection Adapters.
Send a message. Text grows in the UI as tokens arrive.