# 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](./overview#quick-setup).

### 1. Stream from the server

Pass `outputSchema` and `stream: true`:

```typescript
// 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](../tutorials/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 server validates the full object with the schema of `chat({ outputSchema })` before it sends `final`. If the object fails, the run ends with an error and `final` stays `null`. See [Where validation runs](./overview#where-validation-runs).

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](./harnesses)). 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](../getting-started/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 type | Part on `messages[i].parts` |
| --- | --- |
| `REASONING_MESSAGE_CONTENT` | `ThinkingPart` on the assistant message |
| `TOOL_CALL_START` / `_ARGS` / `_END` | `ToolCallPart` on the assistant message |
| `TOOL_CALL_RESULT` | `ToolResultPart` on the tool message |
| `TEXT_MESSAGE_CONTENT`, with `outputSchema` | `StructuredOutputPart` on the assistant message. The JSON deltas build `part.raw`, and the parse fills `part.partial`. |
| `TEXT_MESSAGE_CONTENT`, no `outputSchema` | `TextPart` 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.

```typescript ignore
{
  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:

| Adapter | Behavior with `outputSchema` and `stream: true` |
| --- | --- |
| `@tanstack/ai-openai` | Native stream in one request (Responses API, `text.format: json_schema`) |
| `@tanstack/ai-openrouter` | Native stream in one request (`response_format: json_schema`) |
| `@tanstack/ai-grok` | Native stream in one request (Chat Completions, `response_format: json_schema`) |
| `@tanstack/ai-groq` | Native stream in one request (Chat Completions, `response_format: json_schema`) |
| `@tanstack/ai-bedrock` | Native stream through Converse or an OpenAI-compatible API |
| `@tanstack/ai-byteplus` | Native stream in one request on supported models. Other models emit `RUN_ERROR`. |
| `@tanstack/ai-llmgateway` | Native stream in one request (Chat Completions, `response_format: json_schema`) |
| `@tanstack/ai-cloudflare` | Native stream in one request (Chat Completions, `response_format: json_schema`) |
| `@tanstack/ai-neon` | Native stream in one request (Chat Completions, `response_format: json_schema`). Claude uses the Anthropic adapter. |
| `@tanstack/ai-lovable` | Native stream in one request (Responses or Chat Completions) |
| `@tanstack/ai-gemini` | Native 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:

```typescript
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](./with-tools).
