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

sequenceDiagram participant B as Browser (useChat or AG-UI client) participant A as Protocol adapter participant C as DocsAssistant core B->>A: POST messages (JSON) A->>C: ask(conversationId, userId, text) loop stream C-->>A: token, citation, done A-->>B: protocol events over SSE end A-->>B: terminal event, stream closes

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

start

messageId

Opens the assistant message.

text-start, text-delta, text-end

id; id + delta; id

One text block: open it, stream the answer tokens, close it. The id ties deltas to their block.

reasoning-start, reasoning-delta, reasoning-end

id, delta

Not used: the core does not expose model reasoning.

source-url

sourceId, url (plus optional title)

One per citation; the UI renders them as source links.

source-document

sourceId, mediaType, title

A citation to a document that has no public URL (an internal PDF).

data-*

data (and an optional id for updating the same part)

Custom typed payloads such as data-usage or data-citation; the suffix is yours.

tool-input-start, tool-input-delta, tool-input-available

toolCallId, toolName; inputTextDelta; input

The retriever tool call as the agent issues it, if you want the UI to show "searching the docs".

tool-output-available

toolCallId, output

The tool result, for example the retrieved chunk titles.

start-step, finish-step

none

Boundaries of one model call inside a multi-step agent run.

error

errorText

A failure the UI should show; keep it free of internals.

finish, abort

none; reason

Normal completion, or an aborted stream.

[DONE]

none

Sent as data: [DONE] after the last part.

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]

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>
  );
}

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

RUN_STARTED, RUN_FINISHED, RUN_ERROR, STEP_STARTED, STEP_FINISHED

RUN_STARTED carries threadId and runId. RUN_FINISHED carries an optional result and an outcome that is either success or an interrupt (the run paused for human input). RUN_ERROR carries message and code. Steps carry a stepName, for example one per graph node.

Text

TEXT_MESSAGE_START, TEXT_MESSAGE_CONTENT, TEXT_MESSAGE_END, TEXT_MESSAGE_CHUNK

Identified by messageId; start carries the role, content carries a delta. The chunk variant is a convenience that a client expands into start, content and end.

Tool calls

TOOL_CALL_START, TOOL_CALL_ARGS, TOOL_CALL_END, TOOL_CALL_RESULT, TOOL_CALL_CHUNK

Identified by toolCallId; start names the tool (toolCallName), args stream a JSON delta, and the result carries content for the tool message.

State

STATE_SNAPSHOT, STATE_DELTA, MESSAGES_SNAPSHOT

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

ACTIVITY_SNAPSHOT, ACTIVITY_DELTA, REASONING_*

Progress panels and visible model reasoning; optional for DocsAssistant.

Escape hatches

RAW, CUSTOM

RAW wraps an event from the underlying framework; CUSTOM carries a name and value defined by the application.

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" }

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, data-*).

A run with lifecycle events, messages, tool calls and shared state.

Shared state between agent and UI?

Possible through data-* parts you design; no standard patch format.

First class: snapshots and JSON Patch deltas, plus message snapshots.

Front-end ecosystem

useChat and the AI SDK component and tooling 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 Flux mapping (shown above); works from FastAPI or Spring.

One helper for LangGraph in Python; in Java you map events yourself.

Pausing for human input

Tool-approval parts.

Interrupt outcome on RUN_FINISHED and a resume input on the next run.

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.