assistant-ui Streaming
Always consult assistant-ui.com/llms.txt for the latest API.
assistant-stream is the wire layer underneath assistant-ui's chat runtimes. It normalizes every backend into one stream of AssistantStreamChunk values, ships encoders and decoders for three wire formats, and adds a resumable-stream layer on top of any of them. If your backend already speaks the Vercel AI SDK, you rarely touch this package directly (streamText plus toUIMessageStream is enough); reach for it when you write a custom endpoint, need to decode a stream yourself, or want resumable streams.
References
When to use it
Streaming the model call through the Vercel AI SDK?
├─ Yes → streamText + toUIMessageStream/createUIMessageStreamResponse (or result.toUIMessageStreamResponse())
│ assistant-stream is optional: only needed to decode the response yourself or add resumable streams
└─ No → build the response with assistant-stream
├─ Emitting message parts (text, reasoning, tool calls) → Data Stream
└─ Streaming a full agent state snapshot with custom commands → Assistant Transport
Installation
npm install assistant-stream
@assistant-ui/ai-sdk is the current AI SDK integration package (framework neutral); @assistant-ui/react-ai-sdk still re-exports the same API for older installs but new code should import from @assistant-ui/ai-sdk.
Build a custom streaming response
createAssistantStreamResponse runs a callback with an AssistantStreamController and returns a Response encoded as Data Stream (see data-stream.md for the alternative encoders).
import { createAssistantStreamResponse } from "assistant-stream";
export async function POST(req: Request) {
return createAssistantStreamResponse(async (controller) => {
controller.appendText("Hello ");
controller.appendText("world!");
controller.appendReasoning("Checking the forecast first.", {
unstable_summary: "Looking up the weather",
});
controller.appendSource({
type: "source",
sourceType: "url",
id: "s1",
url: "https://example.com/forecast",
title: "Forecast",
});
const tool = controller.addToolCallPart({ toolName: "get_weather" });
tool.argsText.append('{"city":"NYC"}');
tool.argsText.close();
tool.setResponse({ result: { temperature: 22 } });
controller.close();
});
}
close() closes any part still open and ends the stream; an uncaught throw inside the callback is turned into an error chunk automatically.
AssistantStreamController
Every server-side stream, whichever encoder ends up wrapping it, is written through this controller (createAssistantStream, createAssistantStreamController, and createAssistantStreamResponse all hand you one).
| Method |
Signature |
Notes |
appendText |
(textDelta: string) => void |
Opens a text part on first call, appends to it on the next |
appendReasoning |
(reasoningDelta: string, options?: { unstable_summary?: string }) => void |
Passing options always opens a new part, so a summary lands on a part of its own |
appendSource |
(part: SourcePart) => void |
SourcePart is { type: "source", sourceType: "url", id, url, title?, parentId? } |
appendFile |
(part: FilePart) => void |
FilePart is { type: "file", data, mimeType, parentId? } |
appendData |
(part: DataPart) => void |
DataPart is { type: "data", name, data, parentId? }, a named app-defined part |
addTextPart |
() => TextStreamController |
Explicit { append(text), close() } writer, for interleaving with other parts |
addReasoningPart |
(options?) => TextStreamController |
Same writer shape as addTextPart |
addToolCallPart |
(toolName: string) => ToolCallStreamController |
Generates a toolCallId; see the object overload below for a stable id |
addToolCallPart |
(init: ToolCallPartInit) => ToolCallStreamController |
{ toolCallId?, toolName, argsText?, args?, response? } |
enqueue |
(chunk: AssistantStreamChunk) => void |
Raw escape hatch; prefer the helpers above |
merge |
(stream: AssistantStream) => void |
Splices another AssistantStream's parts into this one |
withParentId |
(parentId: string) => AssistantStreamController |
Returns a controller whose writes attach parentId (nested or related parts) |
close |
() => void |
Closes the open part, then the stream |
addToolCallPart returns a ToolCallStreamController: { argsText: TextStreamController, setResponse(response), close() }. setResponse takes { result, artifact?, isError?, modelContent?, messages? } (the shape returned by a ToolResponse), closes the part automatically, and ignores a second call.
Stream events and part types
Every decoder, regardless of wire format, yields the same normalized AssistantStreamChunk union ({ path: number[] } & { type, ... }):
type |
Extra fields |
part-start |
part: PartInit (see below) |
part-finish |
none |
tool-call-args-text-finish |
none |
text-delta |
textDelta: string |
annotations |
annotations: ReadonlyJSONValue[] |
data |
data: ReadonlyJSONValue[] |
step-start |
messageId: string |
step-finish |
finishReason, usage: { inputTokens, outputTokens }, isContinued: boolean |
message-finish |
finishReason, usage |
result |
result, isError: boolean, artifact?, modelContent?, messages? |
error |
error: string, code?, severity?: "critical" | "warning" | "info" |
update-state |
operations: AssistantTransportStateOperation[] (see assistant-transport.md) |
PartInit (the part field of part-start) is one of six part types, every variant carrying an optional parentId:
type |
Extra fields |
text |
none |
reasoning |
unstable_summary?: string |
tool-call |
toolCallId: string, toolName: string |
source |
sourceType: "url", id, url, title? |
file |
data: string, mimeType: string |
data |
name: string, data: ReadonlyJSONValue |
Common Gotchas
appendSource, appendFile, or appendData silently drops the part
- Pass the full part object including its
type field ("source", "file", or "data"); the method name does not imply it for you.
A tool call never settles in the UI
addToolCallPart needs a toolName; the id is generated for you unless you pass one. Close argsText (or call setResponse, which closes it for you) or the part never finishes. Register the rendering with a "use generative" toolkit, not the deprecated makeAssistantToolUI; see tools.
Two separate reasoning parts merge into one on the client
- On the Data Stream wire, a reasoning part-start frame is only sent when
unstable_summary is set; a plain appendReasoning(text) call travels only as text deltas, and the decoder has nothing else to tell it a new part started. Opening two summary-less reasoning parts back to back (for example around a tool call) reconstructs as one continuous reasoning part on the client. Give each part a unstable_summary (even an empty-feeling one) or route the tool call through a separate message step to keep them distinct.
Stream not updating the UI
- Check the Content-Type against the encoder you actually used:
DataStreamEncoder (the createAssistantStreamResponse default) sends text/plain; charset=utf-8 with x-vercel-ai-data-stream: v1, not text/event-stream. AssistantTransportEncoder and the AI SDK's UI message stream do send text/event-stream.
Decoder throws "Stream ended abruptly without receiving [DONE] marker"
AssistantTransportDecoder and UIMessageStreamDecoder require the terminal [DONE] sentinel; a proxy, CDN, or middleware that buffers or truncates the body breaks this. DataStreamDecoder has no such marker.
createAssistantStreamResponse always encodes as Data Stream
- It hard-codes
DataStreamEncoder. For a different wire format, encode manually: AssistantStream.toResponse(createAssistantStream(callback), new AssistantTransportEncoder()), or use createAssistantStreamController and encode the returned stream yourself.
Related Skills
- runtime --
useLocalRuntime, useExternalStoreRuntime, and the useAssistantTransportRuntime React hook and state hooks
- setup -- scaffolding an AI SDK route handler and
useChatRuntime
- tools --
"use generative" toolkits and tool-call rendering
- cloud -- persisting streamed threads and messages with assistant-cloud
1---2name: streaming3description: Streaming wire protocols and backend helpers for assistant-ui, built on the assistant-stream package. Use when building a custom streaming endpoint (one that does not go through the Vercel AI SDK) with createAssistantStreamResponse, createAssistantStream, or createAssistantStreamController; writing to the returned AssistantStreamController through appendText, appendReasoning, appendSource, appendFile, appendData, addTextPart, addReasoningPart, or addToolCallPart (whose result exposes argsText and setResponse); choosing between the Data Stream protocol (useDataStreamRuntime from @assistant-ui/react-data-stream, DataStreamEncoder and DataStreamDecoder) and the Assistant Transport protocol (AssistantTransportEncoder and AssistantTransportDecoder, the useAssistantTransportRuntime state snapshot runtime); decoding a response with AssistantStream.fromResponse, UIMessageStreamDecoder, or PlainTextDecoder; or wiring resumable streams through assistant-stream/resumable (createResumableStreamContext, createResumableSes4license: MIT5---6
7# assistant-ui Streaming
8
9**Always consult [assistant-ui.com/llms.txt](https://www.assistant-ui.com/llms.txt) for the latest API.**
10
11`assistant-stream` is the wire layer underneath assistant-ui's chat runtimes. It normalizes every backend into one stream of `AssistantStreamChunk` values, ships encoders and decoders for three wire formats, and adds a resumable-stream layer on top of any of them. If your backend already speaks the Vercel AI SDK, you rarely touch this package directly (`streamText` plus `toUIMessageStream` is enough); reach for it when you write a custom endpoint, need to decode a stream yourself, or want resumable streams.
12
13## References
14
15- [./references/data-stream.md](./references/data-stream.md) -- the Data Stream protocol, `useDataStreamRuntime`, and its wire format
16- [./references/assistant-transport.md](./references/assistant-transport.md) -- the Assistant Transport SSE format and the `useAssistantTransportRuntime` state-snapshot runtime
17- [./references/encoders.md](./references/encoders.md) -- the encoder and decoder catalog, `PlainTextEncoder`, `UIMessageStreamDecoder`, accumulators, and debugging
18- [./references/resumable.md](./references/resumable.md) -- `assistant-stream/resumable`: context, stores, and client wiring
19
20## When to use it
21
22```
23Streaming the model call through the Vercel AI SDK?
24├─ Yes → streamText + toUIMessageStream/createUIMessageStreamResponse (or result.toUIMessageStreamResponse())
25│ assistant-stream is optional: only needed to decode the response yourself or add resumable streams
26└─ No → build the response with assistant-stream
27 ├─ Emitting message parts (text, reasoning, tool calls) → Data Stream
28 └─ Streaming a full agent state snapshot with custom commands → Assistant Transport
29```
30
31## Installation
32
33```bash
34npm install assistant-stream
35```
36
37`@assistant-ui/ai-sdk` is the current AI SDK integration package (framework neutral); `@assistant-ui/react-ai-sdk` still re-exports the same API for older installs but new code should import from `@assistant-ui/ai-sdk`.
38
39## Build a custom streaming response
40
41`createAssistantStreamResponse` runs a callback with an `AssistantStreamController` and returns a `Response` encoded as Data Stream (see [data-stream.md](./references/data-stream.md) for the alternative encoders).
42
43```ts
44import { createAssistantStreamResponse } from "assistant-stream";
45
46export async function POST(req: Request) {
47 return createAssistantStreamResponse(async (controller) => {
48 controller.appendText("Hello ");
49 controller.appendText("world!");
50
51 controller.appendReasoning("Checking the forecast first.", {
52 unstable_summary: "Looking up the weather",
53 });
54
55 controller.appendSource({
56 type: "source",
57 sourceType: "url",
58 id: "s1",
59 url: "https://example.com/forecast",
60 title: "Forecast",
61 });
62
63 const tool = controller.addToolCallPart({ toolName: "get_weather" });
64 tool.argsText.append('{"city":"NYC"}');
65 tool.argsText.close();
66 tool.setResponse({ result: { temperature: 22 } });
67
68 controller.close();
69 });
70}
71```
72
73`close()` closes any part still open and ends the stream; an uncaught throw inside the callback is turned into an `error` chunk automatically.
74
75## AssistantStreamController
76
77Every server-side stream, whichever encoder ends up wrapping it, is written through this controller (`createAssistantStream`, `createAssistantStreamController`, and `createAssistantStreamResponse` all hand you one).
78
79| Method | Signature | Notes |
80| --- | --- | --- |
81| `appendText` | `(textDelta: string) => void` | Opens a text part on first call, appends to it on the next |
82| `appendReasoning` | `(reasoningDelta: string, options?: { unstable_summary?: string }) => void` | Passing `options` always opens a new part, so a summary lands on a part of its own |
83| `appendSource` | `(part: SourcePart) => void` | `SourcePart` is `{ type: "source", sourceType: "url", id, url, title?, parentId? }` |
84| `appendFile` | `(part: FilePart) => void` | `FilePart` is `{ type: "file", data, mimeType, parentId? }` |
85| `appendData` | `(part: DataPart) => void` | `DataPart` is `{ type: "data", name, data, parentId? }`, a named app-defined part |
86| `addTextPart` | `() => TextStreamController` | Explicit `{ append(text), close() }` writer, for interleaving with other parts |
87| `addReasoningPart` | `(options?) => TextStreamController` | Same writer shape as `addTextPart` |
88| `addToolCallPart` | `(toolName: string) => ToolCallStreamController` | Generates a `toolCallId`; see the object overload below for a stable id |
89| `addToolCallPart` | `(init: ToolCallPartInit) => ToolCallStreamController` | `{ toolCallId?, toolName, argsText?, args?, response? }` |
90| `enqueue` | `(chunk: AssistantStreamChunk) => void` | Raw escape hatch; prefer the helpers above |
91| `merge` | `(stream: AssistantStream) => void` | Splices another `AssistantStream`'s parts into this one |
92| `withParentId` | `(parentId: string) => AssistantStreamController` | Returns a controller whose writes attach `parentId` (nested or related parts) |
93| `close` | `() => void` | Closes the open part, then the stream |
94
95`addToolCallPart` returns a `ToolCallStreamController`: `{ argsText: TextStreamController, setResponse(response), close() }`. `setResponse` takes `{ result, artifact?, isError?, modelContent?, messages? }` (the shape returned by a `ToolResponse`), closes the part automatically, and ignores a second call.
96
97## Stream events and part types
98
99Every decoder, regardless of wire format, yields the same normalized `AssistantStreamChunk` union (`{ path: number[] } & { type, ... }`):
100
101| `type` | Extra fields |
102| --- | --- |
103| `part-start` | `part: PartInit` (see below) |
104| `part-finish` | none |
105| `tool-call-args-text-finish` | none |
106| `text-delta` | `textDelta: string` |
107| `annotations` | `annotations: ReadonlyJSONValue[]` |
108| `data` | `data: ReadonlyJSONValue[]` |
109| `step-start` | `messageId: string` |
110| `step-finish` | `finishReason, usage: { inputTokens, outputTokens }, isContinued: boolean` |
111| `message-finish` | `finishReason, usage` |
112| `result` | `result, isError: boolean, artifact?, modelContent?, messages?` |
113| `error` | `error: string, code?, severity?: "critical" \| "warning" \| "info"` |
114| `update-state` | `operations: AssistantTransportStateOperation[]` (see [assistant-transport.md](./references/assistant-transport.md)) |
115
116`PartInit` (the `part` field of `part-start`) is one of six part types, every variant carrying an optional `parentId`:
117
118| `type` | Extra fields |
119| --- | --- |
120| `text` | none |
121| `reasoning` | `unstable_summary?: string` |
122| `tool-call` | `toolCallId: string, toolName: string` |
123| `source` | `sourceType: "url", id, url, title?` |
124| `file` | `data: string, mimeType: string` |
125| `data` | `name: string, data: ReadonlyJSONValue` |
126
127## Common Gotchas
128
129**`appendSource`, `appendFile`, or `appendData` silently drops the part**
130- Pass the full part object including its `type` field (`"source"`, `"file"`, or `"data"`); the method name does not imply it for you.
131
132**A tool call never settles in the UI**
133- `addToolCallPart` needs a `toolName`; the id is generated for you unless you pass one. Close `argsText` (or call `setResponse`, which closes it for you) or the part never finishes. Register the rendering with a `"use generative"` toolkit, not the deprecated `makeAssistantToolUI`; see [tools](../tools/SKILL.md).
134
135**Two separate reasoning parts merge into one on the client**
136- On the Data Stream wire, a reasoning part-start frame is only sent when `unstable_summary` is set; a plain `appendReasoning(text)` call travels only as text deltas, and the decoder has nothing else to tell it a new part started. Opening two summary-less reasoning parts back to back (for example around a tool call) reconstructs as one continuous reasoning part on the client. Give each part a `unstable_summary` (even an empty-feeling one) or route the tool call through a separate message step to keep them distinct.
137
138**Stream not updating the UI**
139- Check the Content-Type against the encoder you actually used: `DataStreamEncoder` (the `createAssistantStreamResponse` default) sends `text/plain; charset=utf-8` with `x-vercel-ai-data-stream: v1`, not `text/event-stream`. `AssistantTransportEncoder` and the AI SDK's UI message stream do send `text/event-stream`.
140
141**Decoder throws "Stream ended abruptly without receiving [DONE] marker"**
142- `AssistantTransportDecoder` and `UIMessageStreamDecoder` require the terminal `[DONE]` sentinel; a proxy, CDN, or middleware that buffers or truncates the body breaks this. `DataStreamDecoder` has no such marker.
143
144**`createAssistantStreamResponse` always encodes as Data Stream**
145- It hard-codes `DataStreamEncoder`. For a different wire format, encode manually: `AssistantStream.toResponse(createAssistantStream(callback), new AssistantTransportEncoder())`, or use `createAssistantStreamController` and encode the returned stream yourself.
146
147## Related Skills
148
149- [runtime](../runtime/SKILL.md) -- `useLocalRuntime`, `useExternalStoreRuntime`, and the `useAssistantTransportRuntime` React hook and state hooks
150- [setup](../setup/SKILL.md) -- scaffolding an AI SDK route handler and `useChatRuntime`
151- [tools](../tools/SKILL.md) -- `"use generative"` toolkits and tool-call rendering
152- [cloud](../cloud/SKILL.md) -- persisting streamed threads and messages with assistant-cloud