Chat UI Protocols
|
This content was generated with the assistance of AI and should be verified against the official documentation of each tool and framework before being relied on in production. This section’s bibliography lists the reference material consulted while preparing these pages. |
This page was written on 2026-10-01 against the AI SDK (ai 7.0.126 and @ai-sdk/react 4.0.129), AG-UI 1.0
(ag-ui-protocol 1.0.0, @ag-ui/core 1.0.1 and ag-ui-langgraph 0.0.45), FastAPI 0.142.2, langchain 1.4.3 and
langgraph 1.2.12 on Python 3.12, and Spring AI 2.0.1 on Spring Boot 4.1.1 with Java 21 (see the
version baseline). The event and part names were read from
the official protocol references on that date. The snippets are illustrative: they are written from the official
documentation, were not compiled or run by this repository, and the Spring code in particular is a sketch of the wiring
rather than a tested class.
The Streaming Chat APIs page defines a bespoke wire format for
DocsAssistant: SSE events named token, citation and done. That is fine when you write both ends. A chat UI
protocol is the alternative: an agreed vocabulary so that an off-the-shelf front end (React hooks, component kits) can
render streamed text, citations, tool calls and shared state without a custom parser. Two such protocols matter in
2026: the AI SDK UI Message Stream protocol and AG-UI. Both are carried over server-sent events, both are
transport-level contracts with no opinion about the model behind them, and both slot in as one more adapter in front of
the same ask(conversationId, userId, text) core.
Where the protocol sits
The adapter owns three mappings: the incoming message list to text, the browser session to userId and
conversationId, and the core’s token | citation | done stream to protocol events. Nothing else in the core changes.
AI SDK UI Message Stream protocol
The AI SDK’s React hook useChat talks to a server endpoint with a transport. The default transport posts the message
list as JSON and reads a response whose body is a stream of server-sent events; each event’s data: line is a JSON
object with a type field, and the stream ends with the literal data: [DONE]. A custom (non-AI-SDK) back end opts in
by setting the response header x-vercel-ai-ui-message-stream: v1. The protocol is versioned by that header, so a
front end can reject a stream it does not understand.
The older data stream protocol (AI SDK 4, with line-prefixed frames such as 0: for text) is legacy. Tutorials and
blog posts that show those prefixes predate the typed-part format described here; new code should emit the typed SSE
parts below. A separate plain-text text stream protocol also still exists, via the TextStreamChatTransport, for
cases where only text, with no citations or tools, needs to flow.
Stream parts
The parts DocsAssistant needs, grouped by purpose. The names and fields are those of the stream-protocol reference as read on 2026-10-01.
Part type |
Fields | Use in DocsAssistant |
|---|---|---|
|
|
Opens the assistant message. |
|
|
One text block: open it, stream the answer tokens, close it. The |
|
|
Not used: the core does not expose model reasoning. |
|
|
One per citation; the UI renders them as source links. |
|
|
A citation to a document that has no public URL (an internal PDF). |
|
|
Custom typed payloads such as |
|
|
The retriever tool call as the agent issues it, if you want the UI to show "searching the docs". |
|
|
The tool result, for example the retrieved chunk titles. |
|
none |
Boundaries of one model call inside a multi-step agent run. |
|
|
A failure the UI should show; keep it free of internals. |
|
none; |
Normal completion, or an aborted stream. |
|
none |
Sent as |
The reference also lists tool-approval parts (tool-approval-request, tool-approval-response), tool-output-denied,
reset-step, file and custom parts; they are relevant to approval flows
(Human in the Loop) and not to a read-only question-answering assistant.
On the wire, a short DocsAssistant answer with one citation looks like this (blank lines separate SSE events):
data: {"type":"start","messageId":"msg_01"}
data: {"type":"start-step"}
data: {"type":"text-start","id":"t1"}
data: {"type":"text-delta","id":"t1","delta":"Use the retriever "}
data: {"type":"text-delta","id":"t1","delta":"tool [1]."}
data: {"type":"text-end","id":"t1"}
data: {"type":"source-url","sourceId":"c1","url":"https://example.org/docs/rag","title":"RAG overview"}
data: {"type":"finish-step"}
data: {"type":"finish"}
data: [DONE]
Source: AI SDK: Stream Protocol
Emitting it from FastAPI
The adapter is a generator that translates the core’s events and a StreamingResponse that sets the header. The core
is the DocsAssistantCore described in
Channel-Agnostic Architecture, built once at
start-up and stored on app.state.core; its ask(conversation_id, user_id, text) yields the frozen dataclasses
Token(text), Citation(marker, chunk_id, title, url) and Done(conversation_id, answer, citations).
import json
import uuid
from collections.abc import AsyncIterator
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from docsassistant.core import Citation, DocsAssistantCore, Token # see Channel-Agnostic Architecture
app = FastAPI()
def sse(part: dict) -> str:
return f"data: {json.dumps(part, separators=(',', ':'))}\n\n"
def last_user_text(messages: list[dict]) -> str:
# UI messages carry typed parts; the question is the text parts of the last user message.
last = next(m for m in reversed(messages) if m["role"] == "user")
return "".join(p["text"] for p in last["parts"] if p["type"] == "text")
async def ui_message_stream(core: DocsAssistantCore, conversation_id: str, user_id: str,
text: str) -> AsyncIterator[str]:
text_id = f"t-{uuid.uuid4().hex[:8]}"
yield sse({"type": "start", "messageId": f"msg-{uuid.uuid4().hex[:8]}"})
yield sse({"type": "start-step"})
yield sse({"type": "text-start", "id": text_id})
try:
async for event in core.ask(conversation_id, user_id, text):
match event:
case Token(delta):
yield sse({"type": "text-delta", "id": text_id, "delta": delta})
case Citation(_marker, chunk_id, title, url):
yield sse({"type": "source-url", "sourceId": chunk_id, "url": url, "title": title})
except Exception:
yield sse({"type": "error", "errorText": "The assistant could not answer. Try again."})
yield sse({"type": "text-end", "id": text_id})
yield sse({"type": "finish-step"})
yield sse({"type": "finish"})
yield "data: [DONE]\n\n"
@app.post("/chat")
async def chat(request: Request) -> StreamingResponse:
body = await request.json()
user_id = request.state.user_id # set by your auth dependency or middleware
return StreamingResponse(
ui_message_stream(request.app.state.core, body["id"], user_id, last_user_text(body["messages"])),
media_type="text/event-stream",
headers={
"x-vercel-ai-ui-message-stream": "v1",
"cache-control": "no-cache",
"x-accel-buffering": "no",
},
)
The request body from the default transport carries the chat id (used here as the conversationId) and the full
messages array; the server decides how much of it to use. Because the core already holds memory in the Postgres
checkpointer, sending only the last user message to core.ask() is correct, and trusting the client-sent history for
anything but display would be a mistake. The streaming mechanics behind core.ask() are in
LangChain: Streaming.
Emitting it from Spring
In Spring the same mapping is a Flux of server-sent events. The sketch below builds each part as a map and lets the
framework serialise it; the core is the AssistantCore of Channel-Agnostic Architecture, whose Flux emits the sealed
CoreEvent records Token(text), Citation(marker, chunkId, title, url) and Done(conversationId, answer, citations).
ChatClient.stream() and the reactive plumbing are covered in
Spring AI: Streaming & Reactive.
@RestController
class UiMessageStreamController {
private final AssistantCore core; // wraps ChatClient + RAG advisor + JDBC chat memory + ACL filter
UiMessageStreamController(AssistantCore core) {
this.core = core;
}
@PostMapping(path = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
ResponseEntity<Flux<ServerSentEvent<Map<String, Object>>>> chat(
@RequestBody ChatRequest request, Principal principal) {
String textId = "t-" + UUID.randomUUID().toString().substring(0, 8);
Flux<Map<String, Object>> parts = core
.ask(request.id(), principal.getName(), request.lastUserText())
.map(event -> switch (event) {
case CoreEvent.Token t -> Map.<String, Object>of("type", "text-delta", "id", textId,
"delta", t.text());
case CoreEvent.Citation c -> Map.<String, Object>of("type", "source-url", "sourceId", c.chunkId(),
"url", c.url(), "title", c.title());
case CoreEvent.Done d -> Map.<String, Object>of("type", "text-end", "id", textId);
});
Flux<Map<String, Object>> stream = Flux.concat(
Flux.just(Map.<String, Object>of("type", "start", "messageId", "msg-" + textId),
Map.<String, Object>of("type", "text-start", "id", textId)),
parts,
Flux.just(Map.<String, Object>of("type", "finish")))
.onErrorResume(e -> Flux.just(Map.<String, Object>of(
"type", "error", "errorText", "The assistant could not answer.")));
Flux<ServerSentEvent<Map<String, Object>>> sse = stream
.map(p -> ServerSentEvent.builder(p).build());
// The literal [DONE] marker is a raw string, not JSON; see the text below for how to append it.
return ResponseEntity.ok()
.header("x-vercel-ai-ui-message-stream", "v1")
.header(HttpHeaders.CACHE_CONTROL, "no-cache")
.body(sse);
}
record ChatRequest(String id, List<Map<String, Object>> messages) {
String lastUserText() { /* concatenate the text parts of the last user message */ return ""; }
}
}
Mixing a JSON object payload and the bare [DONE] string in one typed Flux is the awkward part in Java. Two clean
options exist: type the whole stream as Flux<ServerSentEvent<String>> and serialise each part with Jackson
yourself (then [DONE] is just another string), or write to the response with a StreamingResponseBody. Whether the
AI SDK client tolerates a missing [DONE] when the connection simply closes was not verified here; emit it.
Consuming it with useChat
On the browser side, useChat is configured with a transport that points at the endpoint. The hook returns the message
list, each message made of typed parts, so a citation is a part of type source-url rather than text you parse.
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';
export function DocsChat() {
const [input, setInput] = useState('');
const { messages, sendMessage, status, error } = useChat({
transport: new DefaultChatTransport({
api: '/chat',
// Add a bearer token from your session layer; never hard-code one.
headers: () => ({ Authorization: `Bearer ${getAccessToken()}` }),
}),
});
return (
<div>
{messages.map((m) => (
<article key={m.id} data-role={m.role}>
{m.parts.map((part, i) => {
if (part.type === 'text') return <p key={i}>{part.text}</p>;
if (part.type === 'source-url')
return <a key={i} href={part.url}>{part.title ?? part.url}</a>;
return null;
})}
</article>
))}
{error && <p role="alert">Something went wrong.</p>}
<form
onSubmit={(e) => {
e.preventDefault();
sendMessage({ text: input });
setInput('');
}}
>
<input value={input} onChange={(e) => setInput(e.target.value)} disabled={status !== 'ready'} />
</form>
</div>
);
}
Source: AI SDK: useChat; AI SDK: Stream Protocol
The React data-fetching and state patterns around such a component are in React:
Data Fetching. The hook’s exact option names and the shape of status have changed across AI SDK majors (the
transport-based design arrived in version 5), so check the useChat reference for the major you install. Because this
is a transport-level contract, the same endpoint also serves any other front end that speaks the protocol; the
component kits in Ready-Made Chat UIs build on it.
AG-UI
AG-UI (Agent-User Interaction protocol) is an open, event-based protocol between an agent back end and a user-facing
application. Its scope is wider than a chat transcript: it standardises how an agent streams text, tool calls, state and
lifecycle to an interactive UI, and how the UI sends context, shared state and tool results back. A run is started by
posting a RunAgentInput (thread ID, run ID, messages, tools, context and state) to the agent endpoint; the response
is a stream of events, typically server-sent events, with each event a JSON object with a type and an optional
timestamp.
Event vocabulary
| Category | Events | Fields and meaning |
|---|---|---|
Lifecycle |
|
|
Text |
|
Identified by |
Tool calls |
|
Identified by |
State |
|
A snapshot replaces the shared state (or the message history); a delta is an RFC 6902 JSON Patch array applied to it. |
Activity and reasoning |
|
Progress panels and visible model reasoning; optional for DocsAssistant. |
Escape hatches |
|
|
A DocsAssistant run in AG-UI, with one retriever call and a citation list kept in shared state, produces this ordering (payloads abbreviated):
RUN_STARTED { threadId: "th_1", runId: "run_1" }
STEP_STARTED { stepName: "retrieve" }
TOOL_CALL_START { toolCallId: "tc_1", toolCallName: "search_docs" }
TOOL_CALL_ARGS { toolCallId: "tc_1", delta: "{\"query\":\"rag api\"}" }
TOOL_CALL_END { toolCallId: "tc_1" }
TOOL_CALL_RESULT { messageId: "m_t", toolCallId: "tc_1", content: "3 chunks" }
STATE_DELTA { delta: [ { op: "add", path: "/citations/-", value: { id: "c1", url: "https://example.org/docs/rag" } } ] }
STEP_FINISHED { stepName: "retrieve" }
TEXT_MESSAGE_START { messageId: "m_1", role: "assistant" }
TEXT_MESSAGE_CONTENT { messageId: "m_1", delta: "Use the retriever tool [1]." }
TEXT_MESSAGE_END { messageId: "m_1" }
RUN_FINISHED { threadId: "th_1", runId: "run_1" }
Source: AG-UI: Events; AG-UI: State Management
The JSON Patch delta is the notable difference from the AI SDK. When an agent maintains structured state (a plan, a list of retrieved sources, a form the user and agent edit together), re-sending the whole object after every change costs bandwidth that grows with the state size. A delta stays proportional to the change. The cost model is simple: if a state of \(S\) bytes changes \(k\) times in a run, snapshots cost about \(kS\) bytes while patches cost about \(S + \sum_i d_i\) bytes, where \(d_i\) is the size of the \(i\)-th patch. For a plain text answer with no shared state none of that matters, which is why chat-only assistants rarely need it.
Serving a LangGraph agent over AG-UI
The ag-ui-langgraph package translates LangGraph’s streamed events into AG-UI events and exposes a FastAPI route. It
wraps a compiled graph; the DocsAssistant create_agent result is one (see
LangChain: Agents with create_agent and
Short-Term Memory & Checkpointers).
import os
from ag_ui_langgraph import LangGraphAgent, add_langgraph_fastapi_endpoint
from fastapi import FastAPI
from langchain.agents import create_agent
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
from docsassistant.tools import search_docs # retriever tool
# The checkpointer is the same Postgres one used by the other channels.
# Opened at application start-up, for example in a FastAPI lifespan handler.
checkpointer: AsyncPostgresSaver = ...
graph = create_agent(
model=os.environ["DOCSASSISTANT_MODEL"], # for example "provider:model-name"
tools=[search_docs],
system_prompt="Answer only from the retrieved documentation and cite sources.",
checkpointer=checkpointer,
)
agui_agent = LangGraphAgent(
name="docs_assistant",
description="Answers questions about the documentation with citations.",
graph=graph,
)
app = FastAPI()
add_langgraph_fastapi_endpoint(app, agui_agent, "/agent")
The endpoint accepts a RunAgentInput, encodes events with the AG-UI event encoder according to the request’s
Accept header, and clones the agent per request so concurrent runs do not share state. The package’s README example
passes the compiled graph directly to the helper, while the current source takes a LangGraphAgent; the snippet above
follows the source, and pinning the package version is advisable because the package is still 0.0.x. Two points specific
to this integration, from its README: graph or provider failures end the stream with a terminal RUN_ERROR and no
RUN_FINISHED, so clients must handle the error event rather than expect an exception; and the AG-UI thread ID maps to
LangGraph’s thread_id, which is why the checkpointer carries conversation memory across runs. Map your authenticated
user to the thread yourself (for example by prefixing thread IDs with the user ID) before handing the request to the
agent, or one user can resume another’s thread by guessing an ID.
On the client side, AG-UI front ends use an HttpAgent from @ag-ui/client (or a framework such as CopilotKit, which
speaks AG-UI to this kind of endpoint); the endpoint above is what they point at. This repository has not exercised that
client path, so treat the front-end half as a pointer to the official quickstarts rather than a recipe.
Choosing between them
| Question | AI SDK UI Message Stream | AG-UI |
|---|---|---|
What is the unit of exchange? |
Messages made of typed parts (text, sources, tools, |
A run with lifecycle events, messages, tool calls and shared state. |
Shared state between agent and UI? |
Possible through |
First class: snapshots and JSON Patch deltas, plus message snapshots. |
Front-end ecosystem |
|
AG-UI clients and frameworks such as CopilotKit, with integrations for LangGraph and other agent frameworks. |
Back-end effort for DocsAssistant |
A small generator or |
One helper for LangGraph in Python; in Java you map events yourself. |
Pausing for human input |
Tool-approval parts. |
Interrupt outcome on |
Maturity signal as of 2026-10-01 |
Mature, versioned by the stream header. |
1.0 protocol; the LangGraph integration package is still 0.0.x. |
For DocsAssistant the practical rule is: start with the AI SDK stream if the front end is a React chat box with
citations, because the mapping is a few lines and either back end can emit it. Choose AG-UI when the agent has state the
user should see and edit while it runs (a plan, a research outline), when you want the same agent usable from several
agent-aware front ends, or when you are already on LangGraph and want events derived from the graph without writing
them by hand. Keep the custom token | citation | done stream from
Streaming Chat APIs as the internal contract: protocols
come and go, and an adapter per protocol is cheap, whereas rewriting the core is not. For agent-interface design beyond
the wire format, see Agent UX.
Operational points that apply to both: send Cache-Control: no-cache and disable proxy buffering or tokens arrive in
bursts; authenticate the POST like any API call (a cross-origin browser client needs CORS and, for EventSource-style
GET streams, cannot send headers, which is why both protocols use fetch with POST); and cap how long a stream may stay
open. Deployment concerns are in Serving RAG as an API.