Runtime options

useAgUiRuntime options, adapters, message conversion, thread list.

Reference for the runtime's API surface. Start with quickstart if you have not already.

useAgUiRuntime options

OptionTypeDescription
agentAbstractAgentAn AG-UI client agent from @ag-ui/client, such as HttpAgent. Required.
loggerPartial<Logger>Optional logger overrides. The runtime logs event-parser warnings and run lifecycle events.
showThinkingbooleanWhether to render REASONING_* events as visible reasoning. Defaults to true.
joinStrategy"concat-content" | "none"With "concat-content", join assistant content from one run into a single message, including across MESSAGES_SNAPSHOT events. Defaults to "none", which starts a new message for each new TEXT_MESSAGE_START id.
getRunContext(input: { messages: readonly Message[]; runConfig: RunConfig | undefined }) => readonly Context[]Return AG-UI context entries for each run using the messages and run config sent with that run. On resumes, messages follows resumeTranscript.
autoCancelPendingToolCallsbooleanCancel 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) => voidError callback fired on RUN_ERROR events and protocol errors, and when getRunContext throws; that run still goes ahead without the host entries (see Run context).
onCancel() => voidCancellation callback fired when a run is cancelled, including user cancel and runtime teardown.
unstable_enableMessageQueuebooleanBuffer a message sent while a run is in flight and send it once the run settles. Pending messages are exposed on composer.queue.
adaptersUseAgUiRuntimeAdaptersStandard adapter slots (see below).

The hook also accepts isDisabled, isSendDisabled, suggestions, and unstable_capabilities, which behave as they do on ExternalStoreRuntime.

Run context

Use getRunContext to pass host context through AG-UI's RunAgentInput.context:

const runtime = useAgUiRuntime({
  agent,
  getRunContext: () => [
    { description: "selectedRecord", value: JSON.stringify(record) },
  ],
});

The browser builds these entries, so the backend must not trust them for authorization. The runtime calls the latest getter for every run, including resumes. messages is the exact converted AG-UI transcript in RunAgentInput. On a resume it reflects resumeTranscript: "full" sends the whole thread, while "appended" sends messages after the interrupted assistant message, falling back to the whole thread if that message is missing. runConfig is the run config used to build the input. Returned entries follow the system prompt entry when one is present.

If the getter throws, the error is reported and the run continues without host entries.

Adapter slots

AdapterSlotNotes
Attachmentsadapters.attachmentsSee attachment adapter.
Speechadapters.speechText-to-speech. See speech adapter.
Dictationadapters.dictationSpeech-to-text input.
Voiceadapters.voiceRealtime voice session. See voice.
Feedbackadapters.feedbackThumbs up / down. See feedback adapter.
Historyadapters.historyPer-thread message persistence.
Thread listadapters.threadListMulti-thread switching (experimental, see below).

Activity snapshots

An a2ui-surface activity becomes a present tool-call part. Its artifact.a2ui field contains replayable v0.9 operations, so a stored surface can be rebuilt with applyA2uiOperations and rendered again.

An mcp-apps activity attaches to its tool call and does not become a data part.

Other activity snapshot types become data parts named agui-activity/<type>. Render them with a data renderer when your application recognizes the type. Repeated snapshots replace the existing part for the same activity rather than adding another part, and these UI-only data parts are not included in the next AG-UI run input. When fromAgUiMessages restores a backend messages snapshot, it attaches these parts to the preceding assistant message and replaces a part in place when a later activity record has the same id.

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.

A reasoning record is a message of its own on the wire, so a turn that reasoned before answering arrives as two records. fromAgUiMessages folds the reasoning onto the assistant record that follows it, where it becomes a reasoning part of that message. This is the same binding the export writes and the one ag-ui-langgraph reads back. The run input inverts the fold: the part leaves as a standalone reasoning record placed ahead of that assistant record, under the id it arrived with. A reloaded thread therefore keeps its reasoning history on the next run, and an agent that issued an encryptedValue against a record id gets that id back. A reasoning record that no assistant record follows stays a message of its own.

Two positions are not recoverable. Reasoning is restored ahead of the assistant record's own parts, so reasoning that arrived between two blocks of one live message comes back before both. An AG-UI assistant record carries content and toolCalls as separate fields with no ordering between them, so its text is restored ahead of its tool calls whatever order the run streamed them in.

A tool call that carries no parentMessageId has no record to join, so the run opens one keyed by the call id, and the prose of the turn lands in records of its own on either side of it. A turn that spoke, called a tool, then answered sits on the wire as three assistant records, and one that called two tools in parallel adds a record per call.

fromAgUiMessages recognizes such a container by its id: an assistant record whose own id is one of the calls it carries, and which carries no text, was opened by the client and is not a boundary an agent drew. It joins the container to the turn the way the live run did:

  • A container joins the assistant record ahead of it when that record holds text or tool calls.
  • A record that follows a container joins it when it is another container, such as a parallel call, or an assistant record without tool calls, such as the answer.
  • Once the turn carries text, a further text record is a message of its own. The live run opens a new message at the same point.

A turn that called first and then answered is one message with the answer inside it. A turn that spoke first is one message carrying its prose and its calls, and an answer that followed is a second message. The merged message takes the id the agent addressed its prose to. On the next run the turn leaves as a single assistant record carrying both the prose and the calls, followed by the tool results, so text that followed a call is restored ahead of it, as the position limit above describes.

A record an agent addressed keeps its own message. A run that sets parentMessageId already puts the whole turn in one record, and two assistant turns in a row stay two messages, because neither record is a container the client opened.

The fold is best effort, because nothing on the wire carries run identity. The container id shows that the client opened a record, but not which run produced the records beside it. Two shapes come back as one message where the live session showed two:

  • An assistant record that a later run produced after a parentless container, with no user message between the two, is folded into that container's turn. A frontend tool round trip produces this when the answer arrives in the run that follows the tool result.
  • A later run that opens with a parentless tool call, after a run whose last assistant record holds prose or calls of its own and with no user message between the two, is folded into that record. An interrupt or A2UI resume can produce this.

The encrypted value is kept with the reasoning. 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). Reasoning from either source is re-emitted with the value intact.

A record whose readable content is empty and whose payload lives entirely in encryptedValue has nothing to render, so it never becomes a message or a part. This is the zero-data-retention shape AG-UI describes when an agent advertises capabilities.reasoning.encrypted. The value is stored on metadata.custom.agui.opaqueReasoning of the message it sat next to and is replayed into the run input adjacent to that message. A live run stores the value of a reasoning block that streamed no text the same way. A consumer calling fromAgUiMessages directly sees the metadata, not a part.

showThinking does not discard the value. That option hides reasoning from the UI, so a hidden record loses only its readable text. This holds for an imported record and for a live run: with showThinking: false, a REASONING_ENCRYPTED_VALUE that names a reasoning block of the run is stored on opaqueReasoning and replayed on the next run. The exception is reasoning inside a subagent run, whose value is not retained while thinking is hidden.

Three limits apply to a value stored on opaqueReasoning. An imported record that sat between an assistant message and its own tool result is replayed after that tool result, because the import folds the result into the assistant message and the boundary is gone by export. An imported record in a snapshot that contains no other message has nothing to anchor to and is dropped. In a live run, a value whose entityId is the id of a text message, the assistant message, or a tool result is dropped, because replaying it would put two records with one id on the wire.

The protocol asks the client to send reasoning back, so the server has to accept inbound reasoning records. With ag-ui-langgraph this depends on the version:

  • 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, 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 reaches the model. A record that no assistant message follows is still discarded, which is what happens to one replayed after the last message of a thread.

Use 0.0.36 or newer to avoid the error, and 0.0.42 or newer for the replay to reach the model.

What a server does with a replayed record is its own choice, so treat continuity as best effort. Another integration may behave like any of those three versions, and 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 useAgUiInterrupts and useAgUiSubmitInterruptResponses 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.

OptionTypeDescription
threadIdstringThe 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?, unstable_resume? }>Called when the user switches threads. Return the persisted messages and, optionally, the opaque state. Set unstable_resume: true when the thread has a run in flight; the runtime then resumes it through the history adapter's resume() method.

Switching threads cancels the active run and discards messages queued for the previous thread. No run starts until the switch finishes, including one that onCancel would start; once a newer switch finishes, its thread accepts sends even while an older load is still pending.

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:

MethodDescription
unstable_getPendingInterrupts(): readonly AgUiInterrupt[]Snapshot of the open interrupts on the most recent assistant message.
unstable_submitInterruptResponses(responses: readonly AgUiResumeEntry[]): Promise<void>Submits one AgUiResumeEntry per open interrupt and resumes the run.

Both methods are deprecated. Inside the runtime provider, use the useAgUiInterrupts and useAgUiSubmitInterruptResponses hooks, which return the same interrupts and the same submit function.

responses must address every open interrupt; missing entries and unknown ids reject before any network call. An interrupt past expiresAt accepts a cancelled response but rejects a resolved response. 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 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 cancels pending client-side tool calls without an explicit steer away. When 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 apply:

  • 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_FINISHED carrying a suspended outcome marks the nested message requires-action and preserves its interruptIds on the message metadata, but nothing answers them yet.

Compared to LangChain

@assistant-ui/react-langchain exposes subagents both ways: useLangChainSubagents() returns a namespace-keyed discovery map, and each task call's transcript rides on ToolCallMessagePart.messages (see subagent and subgraph views). This runtime nests the messages directly and offers no discovery hook, because AG-UI carries the join as an explicit parentToolCallId on the lifecycle event rather than as a namespace to resolve, so there is no discovery step to expose.

Supported events

The runtime parses the AG-UI event stream and maps each event type to assistant-ui state.

EventEffect
RUN_STARTED / RUN_FINISHEDToggles thread isRunning. A cancelled RUN_FINISHED.outcome leaves the message incomplete with reason cancelled and no answerable interrupts. An interrupt outcome leaves it requiring a response. On success, a non-empty pendingToolCallIds list makes the run require tool calls only when a listed call is still unresolved, and getPendingToolCalls then returns only the listed calls, so the run resumes once those are answered; with no list or an empty list, any unresolved call counts.
RUN_CANCELLEDMarks the in-flight assistant message as cancelled.
RUN_ERRORMarks the message as errored; fires onError.
TEXT_MESSAGE_START / _CONTENT / _ENDStreams text content into an assistant message.
REASONING_START / _MESSAGE_* / _ENDStreams structured reasoning per message.
TOOL_CALL_START / _ARGS / _ENDStreams tool calls into the current assistant message.
TOOL_CALL_RESULTAttaches a tool result to a tool call.
SUBAGENT_STARTEDOpens a nested assistant message for the subagent run, attached to the tool call named by parentToolCallId.
SUBAGENT_FINISHEDCompletes the nested message and records result; a suspended outcome marks it requires-action.
SUBAGENT_ERRORMarks the nested message errored with message, and records code as errorCode.
STATE_SNAPSHOTReplaces the agent's external state.
STATE_DELTAApplies a JSON Patch to the agent's state.
ACTIVITY_SNAPSHOTFor activity types other than a2ui-surface and mcp-apps (see above), appended to the in-flight assistant message as an agui-activity/<activityType> data part keyed by messageId (or by activityType when absent); a later snapshot for the same messageId replaces it unless replace is false.
ACTIVITY_DELTAApplies a JSON Patch to the activity part that the ACTIVITY_SNAPSHOT with the same messageId created. A delta with no matching snapshot, or whose patch fails to apply, is ignored.
STEP_STARTED / STEP_FINISHEDReceived and ignored; the runtime has no step boundary to map them onto.
MESSAGES_SNAPSHOTReplaces the full message list (used for thread restore).
CUSTOMAppended to the in-flight assistant message as a data part.
RAWParsed 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

FeatureSupported
Streaming textYes
Thinking / reasoning blocksYes
Tool calls and resultsYes
Tool result handoff (client-side execution)Yes
Subagents (nested ToolCallMessagePart.messages)Yes, with two limitations
State snapshots and deltasYes
Custom events (as data parts)Yes
CancellationYes
Message editingYes
Message reloadYes
Run resumptionYes
Interrupts (human-in-the-loop)Experimental (unstable_* API, see Interrupts)
Multi-threadExperimental (adapters.threadList)
History persistenceVia history adapter