TanStack
Chat & Streaming

Streaming

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.

1. Send the stream from the server

Call chat(). Then wrap the result with toServerSentEventsResponse:

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-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.

2. Render with useChat

tsx
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.

3. Cancel a run

Call stop(). The client aborts the fetch.

Pass the same AbortController to chat() and toServerSentEventsResponse so the server stops the model too:

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 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.

Later

  • No HTTP. Iterate chat() yourself. Branch on chunk.type === "TEXT_MESSAGE_CONTENT". Then read chunk.delta.
  • Callbacks. onChunk fires on each event. onFinish fires with the completed message.
  • Send while a reply is in flight. Messages wait in queue by default. See Message Queue.
  • Event types, thread ids, and tool parts. See Stream Events.
  • Thinking tokens or a refresh mid-stream. See Thinking and Reasoning and Resumable Streams.

Send a message. Text grows in the UI as tokens arrive.