ai_ui_stream
A pure-Dart client for the AI SDK UI Message Stream protocol (ai v7), the
SSE format behind useChat. Point it at your chat endpoint and you get typed
messages that update as the model streams, with tool calls, reasoning, sources,
files, data parts and human-in-the-loop tool approval. No UI framework, no
backend assumptions. Not affiliated with Vercel.
Install
dart pub add ai_ui_stream
Quick start
import 'package:ai_ui_stream/ai_ui_stream.dart';
import 'package:ai_ui_stream/http.dart';
final chat = ChatController(
transport: HttpChatTransport(
endpoint: Uri.parse('https://api.example.com/chat'),
headers: () async => {'authorization': 'Bearer ${await getToken()}'},
),
);
chat.snapshots.listen((snapshot) => render(snapshot.messages));
await chat.sendText('Hello!');
Render the parts
Each UIMessage holds a list of UIParts that grow as the stream arrives.
UIPart is sealed, so a switch is exhaustive:
Widget renderPart(UIPart part) => switch (part) {
TextUIPart(:final text) => Text(text),
ReasoningUIPart(:final text) => Text(text, style: dim),
ToolUIPart(:final toolName, :final state) => ToolCard(toolName, state),
FileUIPart(:final url) => Image.network(url),
SourceUrlUIPart(:final url, :final title) => Link(url, title),
_ => const SizedBox.shrink(),
};
snapshot.status is submitted while waiting for the first byte, streaming
during the reply, then ready or error (with snapshot.error).
ChatController
| Method | Does |
|---|---|
sendText(text) / sendMessage(message) |
Sends a user message (text, or parts of any type). |
regenerate({messageId}) |
Re-answers with trigger regenerate-message: drops the last reply, or the given assistant message (or what follows a given user message). |
addToolOutput / addToolOutputError |
Records a client tool's result locally; send with continueTurn(). |
continueTurn() |
Resends the transcript without a new message. |
resumeStream() |
Reattaches to a running stream via ChatTransport.reconnect; no-op if it returns null. |
respondToApprovals(decisions) |
Answers pending tool approvals and resends (see below). |
addToolApprovalResponse(...) |
Records one approval decision locally, without sending. |
clearError() |
Moves an error status back to ready. |
stop() |
Cancels the turn and aborts the request; lastTurnAborted is set. |
removeLastExchange() / seedMessages(...) |
Drops the last exchange / loads history. |
Every send takes optional extraBody and extraHeaders. Pass onFinish
(ChatFinishDetails) and onError to the constructor to observe each turn's
end, and idGenerator if your backend needs UUID ids.
Send methods never throw for a failed turn: they complete when the turn ends,
and the failure is on status and error. A turn fails with
ChatProtocolException unless the response is 2xx, text/event-stream and
carries x-vercel-ai-ui-message-stream: v1, or when the stream ends without a
finish or abort chunk.
Tool approval
A tool-approval-request chunk moves the tool part to
ToolState.approvalRequested with a ToolApproval and lists it in
chat.pendingApprovals (automatic approvals excluded). Answer with
respondToApprovals:
await chat.respondToApprovals([
for (final approval in chat.pendingApprovals)
ApprovalDecision(
approvalId: approval.approvalId,
toolCallId: approval.toolCallId,
toolName: approval.toolName,
args: approval.input as Map<String, dynamic>,
approved: true, // or false, with an optional `reason`
),
]);
Each part moves to approvalResponded and the transcript is resent with no new
user message; the server continues the same assistant message, ending the part
in outputAvailable or outputDenied. Pass userText to append a user message
instead, for backends that reject a transcript ending in an assistant turn. For
backends that predate the approval chunk, ChatController(inferApprovalOnFinish: true) treats a client tool still waiting on its input at finish as awaiting
approval, using the toolCallId as the approval id.
Transport
HttpChatTransport lives in package:ai_ui_stream/http.dart so the core
library stays free of package:http. It POSTs {id, messages, trigger, ...}
as JSON with accept: text/event-stream, content-type: application/json and
x-vercel-ai-ui-message-stream: v1; your headers() merge over them, and
body: replaces the default body builder. stop() aborts the in-flight
request (AbortableRequest, package:http >= 1.5). Without a client, the
transport owns one and closes it on dispose(). See
example/main.dart for a runnable CLI.
To use another HTTP client, auth scheme or body shape, extend ChatTransport:
import 'dart:convert';
import 'package:ai_ui_stream/ai_ui_stream.dart';
import 'package:http/http.dart' as http;
class MyTransport extends ChatTransport {
final _client = http.Client();
@override
Future<ChatResponse> send(
ChatRequest request, {
required Future<void> abort,
}) async {
final req = http.AbortableRequest(
'POST',
Uri.parse('https://api.example.com/chat'),
abortTrigger: abort,
)
..headers['content-type'] = 'application/json'
..headers['accept'] = 'text/event-stream'
..body = jsonEncode({
'id': request.conversationId,
'trigger': request.trigger,
'messages': request.messages.map((m) => m.toJson()).toList(),
});
final res = await _client.send(req);
return ChatResponse(
statusCode: res.statusCode,
headers: {
for (final e in res.headers.entries) e.key.toLowerCase(): e.value,
},
body: res.stream,
);
}
@override
void dispose() => _client.close();
}
ChatTransport.reconnect (default null) backs resumeStream(); extend rather
than implement to inherit it.
Lower-level pieces
ChatController is built from parts you can use on their own:
UiMessageStreamParser turns SSE bytes into UIStreamEvents, and
MessageReducer.apply folds one event into a list of UIMessages.
Supported protocol
Every chunk type of the UI Message Stream is parsed; unknown ones are not dropped.
| Chunk | Event | Result |
|---|---|---|
start, message-metadata, finish |
StartEvent, MessageMetadataEvent, FinishEvent |
Message id and metadata (shallow-merged) |
start-step, finish-step, reset-step |
StartStepEvent, FinishStepEvent, ResetStepEvent |
StepStartUIPart; reset-step drops the step's parts |
abort |
AbortEvent |
Turn ends ready; lastTurnAborted |
error |
ErrorEvent |
Turn ends in error with AiStreamException |
text-start / -delta / -end |
TextStartEvent, ... |
TextUIPart |
reasoning-start / -delta / -end |
ReasoningStartEvent, ... |
ReasoningUIPart |
source-url, source-document |
AppendPartEvent |
SourceUrlUIPart, SourceDocumentUIPart |
file, reasoning-file |
AppendPartEvent |
FileUIPart (isReasoning) |
tool-input-start / -delta / -available |
ToolInput*Event |
ToolUIPart (static and dynamic-tool) |
tool-input-error |
ToolInputErrorEvent |
ToolUIPart in outputError, raw input kept |
tool-output-available / -error / -denied |
ToolOutput*Event |
outputAvailable / outputError / outputDenied |
tool-approval-request / -response |
ToolApprovalRequestEvent, ToolApprovalResponseEvent |
ToolUIPart.approval |
data-* |
DataEvent |
DataUIPart, or dataEvents only if transient |
custom and any unknown type |
UnknownStreamEvent |
Surfaced as an event only; the reducer leaves the message unchanged |
[DONE] |
DoneEvent |
Clean end of stream |
A data-* chunk becomes a DataUIPart, replacing an earlier one with the same
type and id. Transient chunks never touch the message. Listen to
ChatController.dataEvents to see every DataEvent, transient ones included
(for progress indicators, heartbeats and the like).
Tests run against byte-exact fixtures generated from ai@7.0.133; see
test/fixtures/README.md.
License
Apache-2.0. See LICENSE.
Libraries
- ai_ui_stream
- A transport-agnostic Dart client for the AI SDK "UI Message Stream" protocol.
- http
- Optional
package:httptransport forai_ui_stream.