Skip to content
Structured Outputs

Streaming Structured Output UIs

A streaming structured output emits the object as the model writes it. The UI can render each finished field before the object is complete.

Stream the JSON instead. useChat parses it as it arrives and gives you a typed partial object that fills in field by field.

Quick setup

This setup assumes chat already works in your app. For the schema file and the zod install, see the overview.

1. Stream from the server

Pass outputSchema and stream: true:

ts
// api/extract-person.ts
import {
  chat,
  chatParamsFromRequest,
  toServerSentEventsResponse,
} from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { PersonSchema } from "../schemas/person";

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,
    outputSchema: PersonSchema,
    stream: true,
  });

  return toServerSentEventsResponse(stream);
}

2. Render partial on the client

tsx
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { PersonSchema } from "./schemas/person";

export function PersonExtractor() {
  const { sendMessage, isLoading, partial, final } = useChat({
    connection: fetchServerSentEvents("/api/extract-person"),
    threadId: "support-chat",
    outputSchema: PersonSchema,
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        void sendMessage("Extract: John Doe, 30, john@example.com");
      }}
    >
      <button disabled={isLoading}>Extract</button>
      <p>Name: {partial.name ?? "..."}</p>
      <p>Age: {partial.age ?? "..."}</p>
      <p>Email: {partial.email ?? "..."}</p>
      {final && <pre>{JSON.stringify(final, null, 2)}</pre>}
    </form>
  );
}

Click Extract. The name, age, and email appear one after the other. When the run ends, the full object shows in the <pre> block.

For a full TanStack Start app that fills a table from partial.rows, open Streaming Structured Table.

How it works

  • partial is DeepPartial<T>. Every property and every array element is optional. The hook parses the JSON deltas as they arrive. It reads {} from sendMessage() until the first chunk.
  • final is T | null. It is null until the run completes. Then it holds the full object from the structured-output.complete event.
  • The schema on useChat gives you types. The streaming path does not validate the object with your schema. If you need validation, do it in your own code.

Some adapters cannot stream the JSON. Anthropic and Ollama send the object as one event. Harness agents also send it at the end (see Harness Agents). In these cases partial stays {} until final is set. Your client code does not change.

If you leave out outputSchema, useChat returns its normal shape with no partial or final.

Vue, Solid, Svelte, Angular, Preact, and Octane take the same outputSchema option and return partial and final. Only the reactive type changes. See Quick Start for the code in each framework.

Show reasoning and tool calls too

partial and final show only the object of the latest turn. Reasoning, tool calls, and the object of every turn are on messages[].parts:

tsx
import { useChat, fetchServerSentEvents } from "@tanstack/ai-react";
import { z } from "zod";

// The same schema as schemas/person.ts. In your app, import it from that file.
const PersonSchema = z.object({
  name: z.string().meta({ description: "The full name of the person" }),
  age: z.number().meta({ description: "The age in years" }),
  email: z.string().email(),
});

export function PersonRun() {
  const { messages, sendMessage } = useChat({
    connection: fetchServerSentEvents("/api/extract-person"),
    threadId: "support-chat",
    outputSchema: PersonSchema,
  });

  return (
    <div>
      <button onClick={() => void sendMessage("Extract: John Doe, 30")}>
        Extract
      </button>
      {messages.map((message) => (
        <div key={message.id}>
          {message.parts.map((part, i) => {
            if (part.type === "thinking") {
              return <em key={i}>{part.content}</em>;
            }
            if (part.type === "tool-call") {
              return (
                <p key={part.id}>
                  {part.name} ({part.state})
                </p>
              );
            }
            if (part.type === "text") {
              return <p key={i}>{part.content}</p>;
            }
            if (part.type === "structured-output") {
              const person = part.data ?? part.partial;
              return <p key={i}>Name: {person?.name ?? "..."}</p>;
            }
            return null;
          })}
        </div>
      ))}
    </div>
  );
}

The structured-output part has these fields:

  • data: the full object, after structured-output.complete.
  • partial: the progressive parse of raw while the JSON streams.
  • raw: the source JSON string.
  • status: streaming, complete, or error.
  • errorMessage: set when status is error.

final equals data on the latest assistant turn.

Advanced

Where each chunk lands

Chunk typePart on messages[i].parts
REASONING_MESSAGE_CONTENTThinkingPart on the assistant message
TOOL_CALL_START / _ARGS / _ENDToolCallPart on the assistant message
TOOL_CALL_RESULTToolResultPart on the tool message
TEXT_MESSAGE_CONTENT, with outputSchemaStructuredOutputPart on the assistant message. The JSON deltas build part.raw, and the parse fills part.partial.
TEXT_MESSAGE_CONTENT, no outputSchemaTextPart on the assistant message

What the stream contains

chat({ outputSchema, stream: true }) returns a StructuredOutputStream<T>. It is an AsyncIterable of the normal stream events, plus two CUSTOM events:

  • structured-output.start fires once, when the JSON begins. It carries { messageId }. The client uses this id to send the next TEXT_MESSAGE_CONTENT deltas into a StructuredOutputPart on that message.
  • structured-output.complete fires before RUN_FINISHED and carries the parsed object.
ts
{
  type: "CUSTOM",
  name: "structured-output.complete",
  value: {
    object: T;          // full parsed object
    raw: string;        // full JSON text
    reasoning?: string; // only for thinking or reasoning models
  },
  // standard event fields, such as timestamp
  // the model id is in metadata.tanstack when present
}

If the parse fails, the stream emits RUN_ERROR in place of RUN_FINISHED.

At runtime, value on the complete event also holds the same messageId. The client uses it to find the right message. This field is not on the public StructuredOutputCompleteEvent<T> type, but you can read it from value if you need it.

Adapter coverage

Streaming structured output works with every adapter. Only some send the JSON as a true stream in one request:

AdapterBehavior with outputSchema and stream: true
@tanstack/ai-openaiNative stream in one request (Responses API, text.format: json_schema)
@tanstack/ai-openrouterNative stream in one request (response_format: json_schema)
@tanstack/ai-grokNative stream in one request (Chat Completions, response_format: json_schema)
@tanstack/ai-groqNative stream in one request (Chat Completions, response_format: json_schema)
@tanstack/ai-bedrockNative stream through Converse or an OpenAI-compatible API
@tanstack/ai-byteplusNative stream in one request on supported models. Other models emit RUN_ERROR.
@tanstack/ai-llmgatewayNative stream in one request (Chat Completions, response_format: json_schema)
@tanstack/ai-cloudflareNative stream in one request (Chat Completions, response_format: json_schema)
@tanstack/ai-lovableNative stream in one request (Responses or Chat Completions)
@tanstack/ai-geminiNative stream in one request (generateContentStream, responseSchema)
Other adapters (Anthropic, Ollama, and more)Runs structuredOutput without streaming, then emits the object as one structured-output.complete event

Your client code is the same on all adapters. You see deltas only when the adapter implements structuredOutputStream.

Read the stream in a script

With no HTTP boundary (a Node script, a CLI, a test), iterate the stream directly:

ts
import { chat } from "@tanstack/ai";
import { openaiText } from "@tanstack/ai-openai";
import { PersonSchema } from "./schemas/person";

const stream = chat({
  adapter: openaiText("gpt-6.1-sol"),
  messages: [{ role: "user", content: "Extract: John Doe is 30, john@example.com" }],
  threadId: "thread-1",
  outputSchema: PersonSchema,
  stream: true,
});

for await (const chunk of stream) {
  if (chunk.type === "CUSTOM" && chunk.name === "structured-output.complete") {
    // Typed from PersonSchema. Validate here if you need to.
    console.log(chunk.value.object.name, chunk.value.object.age);
  }
}

This is the same StructuredOutputStream<T> that the server endpoint gives to toServerSentEventsResponse.

Watch each chunk with onChunk

useChat also takes onChunk. The hook updates partial and final first. Then it calls your onChunk with the same chunk. Use it for a custom progress UI.

With tools

When you set outputSchema, stream: true, and tools, the agent loop runs first. The structured stream emits its complete event only after every tool finishes. Tool approvals and client tools work the same as in a normal chat. See With Tools.