useAgUiRuntime options, adapters, message conversion, thread list.
Reference for the runtime's API surface. Start with quickstart if you have not already.
useAgUiRuntime options
| Option | Type | Description |
|---|---|---|
agent | HttpAgent | An AG-UI client agent (from @ag-ui/client). Required. |
logger | Partial<Logger> | Optional logger overrides. The runtime logs event-parser warnings and run lifecycle events. |
showThinking | boolean | Whether to render THINKING_* and REASONING_* events as visible reasoning. Defaults to true. |
autoCancelPendingToolCalls | boolean | Cancel unresolved client-side tool calls automatically when the user sends, edits, or reloads a message. Defaults to true. See below. |
resumeTranscript | "full" | "appended" | What messages carries on a run that also carries resume. Defaults to "full". See below. |
onError | (e: Error) => void | Error callback fired on RUN_ERROR events and protocol errors. |
onCancel | () => void | Cancellation callback fired when a run is cancelled, including user cancel and runtime teardown. |
adapters | UseAgUiRuntimeAdapters | Standard adapter slots (see below). |
Adapter slots
| Adapter | Slot | Notes |
|---|---|---|
| Attachments | adapters.attachments | See attachment adapter. |
| Speech | adapters.speech | Text-to-speech. See speech adapter. |
| Dictation | adapters.dictation | Speech-to-text input. |
| Feedback | adapters.feedback | Thumbs up / down. See feedback adapter. |
| History | adapters.history | Per-thread message persistence. |
| Thread list | adapters.threadList | Multi-thread switching (experimental, see below). |
Loading conversation history
If your backend exposes the persisted AG-UI messages of a conversation (for
example a GET /agents/state endpoint), use fromAgUiMessages to convert them
to assistant-ui messages and return them from the history adapter so the thread
is restored on page load:
import { fromAgUiMessages } from "@assistant-ui/react-ag-ui";
import { ExportedMessageRepository } from "@assistant-ui/react";
const runtime = useAgUiRuntime({
agent,
adapters: {
history: {
async load() {
const { messages } = await fetch("/agents/state").then((r) => r.json());
return ExportedMessageRepository.fromArray(fromAgUiMessages(messages));
},
async append({ message }) {
// persist the newly sent message on your backend
},
},
},
});Messages sent during the session are always forwarded to the agent through the
run input, independent of append. A no-op append is therefore only safe when
your backend already persists the conversation on its own; otherwise those
messages are gone on the next page load.
fromAgUiMessages accepts an optional second argument: pass
{ showThinking: false } to match a runtime configured with
showThinking: false, so the readable text of an imported reasoning message is
dropped at conversion time, the same way a live run never stores it. An
encryptedValue on that message is kept, because it is opaque state the agent
needs back rather than something the option hides.
Reasoning makes the round trip in the shape it arrived in. fromAgUiMessages imports a reasoning record as an assistant message holding a reasoning part, and the run input converts that part back into a standalone reasoning record instead of dropping it, so a reloaded thread keeps its reasoning history on the next run. A reasoning part on an assistant message that also has text or tool calls leaves as its own reasoning record placed ahead of that assistant record; the AG-UI message body carries no run identity, so the original position of reasoning within a run is not recoverable.
The encrypted value survives with it. AG-UI describes it as an opaque chain-of-thought blob the client stores and forwards for state continuity, not as a signature computed over the text. An imported ReasoningMessage carrying encryptedValue keeps it at providerMetadata.agui.encryptedValue on the part, and a live run picks the same value up from the REASONING_ENCRYPTED_VALUE event (subtype: "message", keyed by entityId), so reasoning from either source is re-emitted with the value intact and an agent that needs it back can replay it. A record whose readable content is empty and whose payload lives entirely in encryptedValue, the zero-data-retention shape AG-UI describes when an agent advertises capabilities.reasoning.encrypted, is preserved on the import path only. It has nothing to render by construction, so it never becomes a message or a part; it rides on metadata.custom.agui.opaqueReasoning of the message it sat next to on the wire and is replayed into the run input adjacent to that message. showThinking does not discard it. That option hides reasoning from the UI, and the encrypted value is opaque state the agent needs back rather than something rendered, so a hidden record keeps it and loses only the readable text. This is an import-path guarantee: a live run with showThinking: false opens no reasoning block, so a REASONING_ENCRYPTED_VALUE arriving during it resolves no slot and is not retained. The same thread therefore carries the value after a reload but not within the live session that produced it. A consumer calling fromAgUiMessages directly sees the metadata rather than a part.
Three limits apply to that record. A live stream that emits no readable content produces no reasoning part, so the runtime has nothing to attach the value to; only fromAgUiMessages preserves it, whether you call it yourself or the runtime calls it for you while importing a MESSAGES_SNAPSHOT. A record that sat between an assistant message and its own tool result is replayed after that tool result rather than between the two, because the import folds the result into the assistant message and the boundary is gone by export. A record in a snapshot that contains no other message has nothing to anchor to and is dropped.
Sending reasoning back is what the protocol asks for, and the inbound side has to handle it. ag-ui-langgraph is worth pinning for that reason: below 0.0.36 it raises ValueError: Unsupported message role: reasoning on the second turn of any thread that produced reasoning, because its converter recognised only the user, assistant, system, and tool roles. 0.0.36 skips inbound reasoning and developer records instead of raising, so the turn succeeds but the reasoning is discarded. 0.0.42 re-attaches an inbound reasoning record as a content block on the assistant message that follows it, encrypted content included, so the replay actually reaches the model; a record that no assistant message follows is still discarded there, which is what becomes of one replayed after the last message of a thread. Keep it at 0.0.36 or newer to avoid the error, and at 0.0.42 or newer for the replay to be worth anything.
What a server does with a replayed record remains its own choice, so treat continuity as best effort rather than guaranteed. ag-ui-langgraph covers all three behaviours across those three versions, and another integration may pick any of them; an encryptedValue reaches the provider only where the server forwards it.
fromAgUiMessages reconstructs the text, reasoning, and tool calls of each message. Multimodal user input (image, audio, video, and document parts, as well as legacy binary parts) is restored as attachments on the user message, so a backend that persists multimodal messages shows them again on reload and re-sends them on the next run. Legacy binary parts that only reference a file id are not restored.
An assistant message whose tool call has no matching tool result is reconstructed with requires-action status, the same status the runtime derives for a pending tool call, so a reloaded human-in-the-loop call (for example an ask_user tool) is actionable rather than stuck. This matches how every other external-store runtime surfaces a pending tool call on reload. The AG-UI wire snapshot carries no run outcome, so a tool call that a successful run intentionally left without a result is also surfaced as actionable.
Interrupts are restored when your backend persists them on the assistant message. The AG-UI message body has no interrupt field, so persist the runtime's own metadata.custom.agui.interrupts array alongside the message; fromAgUiMessages reads it back, reconstructs requires-action / interrupt status, and re-attaches the metadata, so getPendingInterrupts, useAgUiInterrupts, and submitInterruptResponses work on reload. When both a pending tool call and an interrupt are present on the same message, interrupt status wins. Without the persisted array, interrupt state cannot be reconstructed.
Building AG-UI run input
toAgUiMessages is the converter the runtime uses to build runAgent input. Use it when you own the transport, for example an AG-UI WebSocket, and need the same conversion from onNew's AppendMessage:
import { toAgUiMessages } from "@assistant-ui/react-ag-ui";
const [agUiMessage] = toAgUiMessages([message]);AppendMessage has no id. The converter assigns one so the AG-UI message schema is satisfied. A caller-supplied id is kept. A generated id is new on every call, so convert once and send that object. Only user, assistant, system, developer, tool, and reasoning inputs are converted.
Thread list (experimental)
Warning
The thread list adapter is currently experimental and may change without notice.
UseAgUiThreadListAdapter lets you back the thread list with your own state.
| Option | Type | Description |
|---|---|---|
threadId | string | The currently active thread ID. |
onSwitchToNewThread | () => Promise<void> | Called when the user creates a new thread. Reset your thread state here. |
onSwitchToThread | (threadId: string) => Promise<{ messages, state? }> | Called when the user switches threads. Return the persisted messages (and optional opaque state). |
const runtime = useAgUiRuntime({
agent,
adapters: {
threadList: {
threadId: currentThreadId,
onSwitchToNewThread: async () => {
setCurrentThreadId(await createThread());
},
onSwitchToThread: async (id) => {
setCurrentThreadId(id);
const { messages, state } = await loadThread(id);
return { messages, state };
},
},
},
});Set the selected thread ID before awaiting its history. The runtime ignores messages, state, and resume requests returned by a superseded thread switch, but it cannot undo state updates inside your adapter. If creating a thread is asynchronous, also guard your adapter's thread ID update against a newer selection.
Interrupts (experimental)
Warning
The interrupt API is experimental and may change without notice.
When the agent emits RUN_FINISHED with outcome = { type: "interrupt", interrupts: [...] }, the active assistant message is marked requires-action with reason: "interrupt" and the Interrupt[] payload is written to metadata.custom.agui.interrupts. Render an approval / input UI from there.
Because the protocol carries the outcome only on RUN_FINISHED, a registered frontend tool does not run while the run is still open: its execute fires once the run's outcome is known, so a gate can never land on a call the client has already executed.
useAgUiRuntime returns an AgUiAssistantRuntime with two extra methods:
| Method | Description |
|---|---|
unstable_getPendingInterrupts(): readonly AgUiInterrupt[] | Snapshot of the open interrupts on the most recent assistant message. |
unstable_submitInterruptResponses(responses: ResumeEntry[]): Promise<void> | Submits one ResumeEntry per open interrupt and resumes the run. |
responses must address every open interrupt; missing entries, unknown ids, or expired interrupts (expiresAt) reject before any network call. Each entry is { interruptId, status: "resolved" | "cancelled", payload? }. The next RunAgentInput carries resume: ResumeEntry[].
const runtime = useAgUiRuntime({ agent });
const pending = runtime.unstable_getPendingInterrupts();
await runtime.unstable_submitInterruptResponses(
pending.map((i) => ({
interruptId: i.id,
status: "resolved",
payload: { approved: true },
})),
);Steering away from an interrupt
When the user ignores the interrupt UI and just sends a new message, use the useAgUiSteerAway hook. Every open interrupt defaults to status: "cancelled", the new message is appended, and the run resumes with resume: ResumeEntry[] on the wire.
The same hook also steers away from pending client-side tool calls. When the head assistant message is in requires-action with reason: "tool-calls" (frontend tools awaiting a result) and the user sends a new message, every unresolved tool call is cancelled with an error result, the message is completed, and a single fresh run starts with those cancellations in its history. Passing responses in this case throws, since responses only address interrupts. With nothing pending, steerAway behaves like a normal append.
const steerAway = useAgUiSteerAway();
// the user typed a new message instead of answering the interrupt
await steerAway("actually, let's do something else");The message accepts a plain string or a partial AppendMessage (the parent defaults to the current head, which is the interrupted assistant message). Pass responses to override the status of specific interrupts; the rest still default to cancelled.
await steerAway("continue without the file", [
{ interruptId: "tool-1", status: "resolved", payload: { approved: true } },
]);The transcript on a resume run
The AG-UI interrupt spec constrains the thread id, interrupt coverage, idempotency, and expiry on a resume run, but it never says what messages carries, so hosts read it differently and both readings are conformant.
By default the runtime sends the whole thread, which is what @ag-ui/client itself does. A host that resumes from a checkpoint ignores the transcript, and a host that rebuilds the interrupted run from it needs every message.
Set resumeTranscript: "appended" for a host that owns the thread and seeds a resume request from its own stored snapshot. Such a host appends the request body to that snapshot, so a re-sent transcript duplicates the interrupted turn in its stored history; on reload the duplicated assistant message carries no tool result and the approval UI opens again for a call that was already answered. Under "appended" the runtime sends only what was appended locally after the interrupted assistant message, which is nothing for an approval or a denial and the new user turn for steering away. Microsoft Agent Framework is a host in this category.
const runtime = useAgUiRuntime({ agent, resumeTranscript: "appended" });Do not set it for a host that rebuilds the run from messages, which then has nothing to resume from, or for one that derives its outgoing MESSAGES_SNAPSHOT from the request body, which then emits a truncated one. Runs without resume are unaffected either way.
Auto-cancelling pending tool calls
By default the runtime does the tool-call half of this automatically: when client-side tool calls are still unresolved and the user sends a new message, edits an earlier one, or reloads, every unresolved tool call receives the same cancellation error result, the assistant message is completed, and the run proceeds with those cancellations in its history. Set autoCancelPendingToolCalls: false to opt out, in which case pending tool calls stay unresolved on a plain send and steering away remains the explicit way to cancel them.
Pending interrupts are never auto-cancelled: sending while an interrupt is open still throws, and the interrupt must be answered with useAgUiSubmitInterruptResponses or discarded with useAgUiSteerAway.
Subagents
A backend running the agents-as-tools pattern emits SUBAGENT_STARTED / SUBAGENT_FINISHED / SUBAGENT_ERROR plus a subagentRunId on every event that subagent produces. The runtime groups that activity into one nested assistant message per subagent run and attaches it to the spawning tool call as ToolCallMessagePart.messages, joined on SUBAGENT_STARTED.parentToolCallId. MessagePartPrimitive.Messages renders it with no extra wiring, and the run's name, description, parentSubagentRunId, result, interruptIds, and errorCode ride on the nested message's metadata.custom.agui.
A subagent that names no reachable spawning call has nowhere to nest, so its output renders in the parent thread instead of being dropped. parentToolCallId is optional, may name a call this run never saw, and two runs may name each other; each of those falls back to flat rendering, matching the downgrade the protocol's own pre-subagent compatibility middleware performs. A malformed or cyclic parent chain is bounded by a depth guard rather than followed.
A repeated SUBAGENT_STARTED for an id the run has already seen is treated as a continuation, since a suspended subagent is re-announced on resume. It refreshes the descriptive fields and reopens the status, but never re-parents an established run, which would move already-rendered output to a different tool call.
A subagent's frontend-executed tool calls behave the same way the root agent's do: a call nested on ToolCallMessagePart.messages is reachable by getPendingToolCalls(), resolves through addToolResult, and its result rides the resume as a tool record on the spawning assistant record. Approval gates cover nested calls too, so a gate naming a subagent-scoped call projects onto the nested part, the frontend tool stays unexecuted while the gate is open, and an undecided gate's result is never exported to the backend.
Two limitations are worth knowing before you rely on this:
-
Nested structure does not survive a reload. Thread restore reads the flattened wire shape, so a restored subagent tool call comes back as a root-level part rather than nested under its spawning call. Results and decisions are preserved; only the nesting is not.
-
Nested human-in-the-loop has no resume path. A
SUBAGENT_FINISHEDcarrying asuspendedoutcome marks the nested messagerequires-actionand preserves itsinterruptIdson the message metadata, but nothing answers them yet.
Compared to LangChain
@assistant-ui/react-langchain exposes subagents as discovery; AG-UI exposes them as structure. useLangChainSubagents() returns a namespace-keyed map and leaves message fetching to the caller (see subagent and subgraph views), while this runtime nests the messages directly and offers no discovery hook. Neither runtime provides the other's shape, and the split follows the protocols rather than taste: AG-UI carries an explicit parentToolCallId on the lifecycle event, a precise join that LangChain's namespace scheme cannot give, so nesting is available here and not there.
Supported events
The runtime parses the AG-UI event stream and maps each event type to assistant-ui state.
| Event | Effect |
|---|---|
RUN_STARTED / RUN_FINISHED | Toggles thread isRunning. RUN_FINISHED.outcome is honored (success / interrupt). |
RUN_CANCELLED | Marks the in-flight assistant message as cancelled. |
RUN_ERROR | Marks the message as errored; fires onError. |
TEXT_MESSAGE_START / _CONTENT / _END | Streams text content into an assistant message. |
TEXT_MESSAGE_CHUNK | Appends a delta without explicit lifecycle. |
THINKING_START / _END | Wraps reasoning blocks (when showThinking is on). |
THINKING_TEXT_MESSAGE_* | Streams thinking text deltas. |
REASONING_START / _MESSAGE_* / _END | Streams structured reasoning per message. |
TOOL_CALL_START / _ARGS / _END | Streams tool calls into the current assistant message. |
TOOL_CALL_CHUNK | Streams tool deltas without explicit lifecycle. |
TOOL_CALL_RESULT | Attaches a tool result to a tool call. |
SUBAGENT_STARTED | Opens a nested assistant message for the subagent run, attached to the tool call named by parentToolCallId. |
SUBAGENT_FINISHED | Completes the nested message and records result; a suspended outcome marks it requires-action. |
SUBAGENT_ERROR | Marks the nested message errored with message, and records code as errorCode. |
STATE_SNAPSHOT | Replaces the agent's external state. |
STATE_DELTA | Applies a JSON-patch-style delta to the agent's state. |
MESSAGES_SNAPSHOT | Replaces the full message list (used for thread restore). |
CUSTOM | Appended to the in-flight assistant message as a data part. |
RAW | Parsed and ignored; unrecognized wire event types are normalized into RAW. |
Custom events
CUSTOM events are the protocol's extension mechanism for application-defined data. Each event is appended to the in-flight assistant message as a canonical data part in arrival order: CUSTOM { name: "sources", value: {...} } becomes { type: "data", name: "sources", data: {...} }. Repeated names append separate parts, the value is passed through verbatim, and data parts reset with each run. Tool calls that carry a parentMessageId are anchored under that message rather than at their wire position, so a data part can render after a tool call that arrived later. A run that delivers its assistant message only through MESSAGES_SNAPSHOT, with no streamed text or tool calls, drops that run's data parts when the snapshot supersedes the in-flight message.
Render them by registering a per-name renderer; parts without a registered renderer are not displayed, unless a Data fallback component is registered, in which case the fallback receives every custom event name, including the framework plumbing listed below.
import { useAssistantDataUI } from "@assistant-ui/react";
useAssistantDataUI({
name: "sources",
render: ({ data }) => <SourceList sources={data.sources} />,
});Data parts stay in the assistant-ui message but are not sent back to the agent, since the AG-UI assistant record has no field for them. History adapters, including the assistant-cloud one, persist them as part of the message JSON. Framework integrations emit their own plumbing over this channel (on_interrupt, PredictState, Exit, hook_error, state_update_error, system:*, MultiAgentHandoff), and those names surface as data parts like any other, so only register renderers for names your backend owns.
Feature support
| Feature | Supported |
|---|---|
| Streaming text | Yes |
| Thinking / reasoning blocks | Yes |
| Tool calls and results | Yes |
| Tool result handoff (client-side execution) | Yes |
Subagents (nested ToolCallMessagePart.messages) | Yes, with two limitations |
| State snapshots and deltas | Yes |
Custom events (as data parts) | Yes |
| Cancellation | Yes |
| Message editing | Yes |
| Message reload | Yes |
| Run resumption | Yes |
| Interrupts (human-in-the-loop) | Experimental (unstable_* API, see Interrupts) |
| Multi-thread | Experimental (adapters.threadList) |
| History persistence | Via history adapter |