chat() returns each token while the model writes the reply. useChat renders those tokens. Call stop() to cancel the run.
Call chat(). Then wrap the result with toServerSentEventsResponse:
// api/chat.ts
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-6.1-sol"),
messages,
threadId,
runId,
});
return toServerSentEventsResponse(stream);
}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"),
threadId: "support-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>
</>
);
}Send a message. The text grows in the UI while the tokens arrive.
The same pattern works in every UI framework. See Quick Start for your framework.
If your network blocks SSE, pick another transport on Transports.
Call stop() on the client. The client aborts the fetch.
To stop the model on the server too, give the same AbortController to chat() and to 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 abortController = new AbortController();
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages,
threadId,
runId,
abortController,
});
return toServerSentEventsResponse(stream, { abortController });
}After stop():
You can iterate chat() yourself. Branch on chunk.type, then read chunk.delta:
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
const stream = chat({
adapter: openaiText("gpt-6.1-sol"),
messages: [{ role: "user", content: "Hello!" }],
threadId: "thread-1",
});
for await (const chunk of stream) {
if (chunk.type === "TEXT_MESSAGE_CONTENT") {
process.stdout.write(chunk.delta);
}
}useChat takes two callbacks for the stream:
If the body is not valid, chatParamsFromRequest throws a Response with status 400. If your framework does not send a thrown Response as the HTTP reply, catch it and return it.
The response stream pauses chat() when its output queue is full. It reads more chunks when the client reads the queued data. toHttpResponse with NDJSON does the same.
A response with durability does not pause. Its run keeps writing to the log.
The shared ChatClient processes the chunks that are ready in order. It does not add a task between each chunk. After a fixed amount of work, it yields to keep the main thread responsive.
Chat Completions adapters built on @tanstack/openai-base also make sure that the stream finished. A started stream needs one of these:
If neither arrives, the adapter closes the open lifecycles and emits RUN_ERROR with code incomplete-stream. The partial text stays available. The run calls onError, skips pending server tools, and does not call onFinish.
The usage-only rule keeps providers compatible. Usage on an earlier chunk, or usage next to a choice, does not count. The OpenAI SDK hides [DONE], so this check cannot see that marker. A stream with only [DONE] after the content still needs a finish reason or a usage-only last chunk.