streaming

v2026.09.24

Streaming wire protocols and backend helpers for assistant-ui, built on the assistant-stream package. Use when building a custom streaming endpoint outside the Vercel AI SDK with createAssistantStreamResponse, createAssistantStream, or createAssistantStreamController; choosing between the Data Stream protocol (useDataStreamRuntime, DataStreamEncoder, DataStreamDecoder) and the Assistant Transport protocol (AssistantTransportEncoder, AssistantTransportDecoder); decoding a response with AssistantStream.fromResponse, UIMessageStreamDecoder, or PlainTextDecoder; or wiring resumable streams through assistant-stream/resumable with in-memory, Redis, or ioredis stores. Route here for wire level symptoms: an unexpected part-start or text-delta shape, a tool call that never settles, a part that silently drops, a Content-Type mismatch, or a reload that cannot resume a response. For runtime hooks use runtime; for AI SDK route handlers without a custom protocol use setup; for cloud persistence use cloud.

GitHub
Install command
npx skhub add assistant-ui/streaming
Markdown
SKILL.md

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

MethodSignatureNotes
appendText(textDelta: string) => voidOpens a text part on first call, appends to it on the next
appendReasoning(reasoningDelta: string, options?: { unstable_summary?: string }) => voidPassing options always opens a new part, so a summary lands on a part of its own
appendSource(part: SourcePart) => voidSourcePart is { type: "source", sourceType: "url", id, url, title?, parentId? }
appendFile(part: FilePart) => voidFilePart is { type: "file", data, mimeType, parentId? }
appendData(part: DataPart) => voidDataPart is { type: "data", name, data, parentId? }, a named app-defined part
addTextPart() => TextStreamControllerExplicit { append(text), close() } writer, for interleaving with other parts
addReasoningPart(options?) => TextStreamControllerSame writer shape as addTextPart
addToolCallPart(toolName: string) => ToolCallStreamControllerGenerates a toolCallId; see the object overload below for a stable id
addToolCallPart(init: ToolCallPartInit) => ToolCallStreamController{ toolCallId?, toolName, argsText?, args?, response? }
enqueue(chunk: AssistantStreamChunk) => voidRaw escape hatch; prefer the helpers above
merge(stream: AssistantStream) => voidSplices another AssistantStream's parts into this one
withParentId(parentId: string) => AssistantStreamControllerReturns a controller whose writes attach parentId (nested or related parts)
close() => voidCloses 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, ... }):

typeExtra fields
part-startpart: PartInit (see below)
part-finishnone
tool-call-args-text-finishnone
text-deltatextDelta: string
annotationsannotations: ReadonlyJSONValue[]
datadata: ReadonlyJSONValue[]
step-startmessageId: string
step-finishfinishReason, usage: { inputTokens, outputTokens }, isContinued: boolean
message-finishfinishReason, usage
resultresult, isError: boolean, artifact?, modelContent?, messages?
errorerror: string, code?, severity?: "critical" | "warning" | "info"
update-stateoperations: AssistantTransportStateOperation[] (see assistant-transport.md)

PartInit (the part field of part-start) is one of six part types, every variant carrying an optional parentId:

typeExtra fields
textnone
reasoningunstable_summary?: string
tool-calltoolCallId: string, toolName: string
sourcesourceType: "url", id, url, title?
filedata: string, mimeType: string
dataname: 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
Discovery
Tags

No tags published for this skill.

Version
Latest version metadata

Version

v2026.09.24

Published

Sep 24, 2026

Category

Uncategorized

License

Not specified

Source path

assistant-ui/skills/streaming

Default branch

main

Latest commit

66befc4

Tree SHA

6a3f150