For AI agents: a documentation index is available at llms.txt. Use .md for canonical markdown pages; .mdx is kept as a backwards-compatible alias on supported URL paths.
ExternalStoreRuntime
Bring your own redux, zustand, or state manager.
ExternalStoreRuntime bridges your existing state management with assistant-ui. You provide messages and callbacks; the runtime renders whatever you give it. UI features turn on based on which callbacks are present.
You own the state, and the adapter translates between your format and assistant-ui's. UI features are capability-based: providing setMessages turns on branching, and providing onEdit turns on editing.
joinStrategy controls how adjacent assistant messages combine: concat-content (default) merges them into one; none keeps them separate. A message carrying metadata.modality: "voice" never joins under either strategy, so a voice transcript stays its own thread message.
When shared metadata changes frequently but each message reads only a small part
of it, use getMetadataKey to keep unrelated messages cached. The returned key
must change whenever the metadata read by callback changes for that message.
Return a primitive or a value whose identity stays stable while that metadata is
unchanged:
threads.reloadMainThread() refetching the open thread in place
onAddToolResult
Client-side tool result handoff. Any of onAddToolResult, onResumeToolCall, and onRespondToToolApproval turns on s.thread.capabilities.answerToolCall, which the tool fallback's answer controls need
onVoiceTranscript
Persist finalized voice transcripts. Convert the ThreadMessage to the host message type when one is used and retain its id. A transcript that finalizes while isLoading is true arrives once loading ends, and is not delivered at all when the conversation changed while it waited, which is recognized by a new unstable_messageRepositoryInstance.
The linear messages array assumes each message's parent is the previous one. For branching (e.g. multiple regenerations), build the tree with ExportedMessageRepository.fromBranchableArray() and pass it to the store as messageRepository:
thread.import() has a counterpart, thread.export(), which captures the current thread (including its full branch tree) as a serializable ExportedMessageRepository. Use it to persist a conversation and re-import it later:
// capture the current thread as a serializable snapshotconst repo = runtime.thread.export();await saveToBackend(JSON.stringify(repo));// later, restore it into a runtimeruntime.thread.import(repo);
The exported shape round-trips through thread.import() directly, so the same value is both your persistence format and what you load back.
On an external store, thread.import() hands the imported messages to onImport, which writes them back into your store. A store without onImport rejects the import instead of letting the runtime's copy drift from the store, so implement onImport, or pass the snapshot back as messageRepository.
If you store the full branch tree outside assistant-ui, persist the selected branch head too and pass it back as messageRepository.headId. setMessages still performs the branch switch; unstable_onBranchChange is an additional signal that fires after an explicit switchToBranch action, such as a BranchPicker click.
headId is the canonical persisted head of the visible branch. Optimistic or transient message ids are not surfaced there. visibleMessageIds is the currently visible path in order, which can include an optimistic leaf while headId points to its persisted ancestor.
The callback only fires for explicit branch switches, and consecutive switches that resolve to the same canonical head are de-duped. It does not fire on adapter resync, messageRepository reset, append, edit/regenerate, content-only updates, or while the thread is running.
Warning
unstable_onBranchChange is under active development and may change without
notice. It complements setMessages; it does not enable branch switching by
itself.
By default, sending while the thread is running is disabled. Provide a queue adapter to buffer a message sent during a run and process it once the run settles. The pending message is exposed on composer.queue and renders through ComposerPrimitive.Queue.
The createMessageQueue helper owns the two-lane ordering and the in-flight guard: steerItems drain before items, and within each lane items drain in order. Supply a driver that runs a message, pass its adapter to the runtime, and tell the queue when a run starts (notifyBusy(), so concurrent sends buffer) and ends (notifyIdle()).
The driver can return a promise. A throw or rejection before notifyBusy() restores the message and pauses draining until the next explicit send. A failure after notifyBusy() leaves the started message with the runtime. A rejected dispatch that never reported busy needs no idle notification. Promise fulfillment does not replace notifyIdle(). Handle the failure in the driver if you need to display an error; the queue logs failures other than aborts.
Call notifyBusy() synchronously before committing a message or starting work that can fail. A busy notification from a later React effect can arrive after a fast failure and is too late to establish that the message was already accepted. After accepting a message, the driver owns failure handling and must eventually report notifyIdle() when that run settles.
A hand-rolled adapter must implement the same contract: items and steerItems expose the lanes (each item carries a required parts projection of its content), enqueue/steer add to a lane, and move(queueItemId, placement) repositions with fail-fast anchors: unknown ids or anchors throw instead of being coerced. Individual items are addressable through composer.queueItem({ id }) (or by index).
The example below expects onNew to resolve or reject only when its run settles. If it starts work and returns early, such as dispatching a socket message, keep busy and idle notifications tied to the store's actual run lifecycle and omit the idle notification in the driver's finally block.
With a createMessageQueue adapter, cancelling pauses the queue for you: the runtime tells the queue before your onCancel runs, so the cancelled run's settle keeps the pending items instead of dispatching the next one, and the next send resumes draining. Call queue.clear() in onCancel instead if you want a cancel to drop them. A hand-rolled adapter has no such channel, so it owns its cancel policy the same way it owns the rest. Edit and reload stay host-owned: call queue.clear() in your onEdit and onReload handlers so stale items do not drain onto the new branch.
For client tool calls, publish the current run's id as activeRunId and keep it through cancelRun. Set metadata.runId on each assistant message from that run, including late messages. The runtime discards calls from the cancelled run in addition to calls caught by its message and parent rules. A replacement started under the same parent can run its calls once that parent rule closes. Run ids should be unique within a thread session.
const runtime = useExternalStoreRuntime({ // ... onCancel: async () => { // the runtime already paused the queue; clear() here to drop the items await cancelRun(); }, onEdit: async (message) => { queue.clear(); // ... }, onReload: async (parentId) => { queue.clear(); // ... },});
A long thread can open on its latest page and fetch older messages on demand. Report whether older messages exist with hasEarlier, and load the page before the first message in onLoadEarlier:
The runtime surfaces them as thread.hasEarlier, thread.isLoadingEarlier and aui.thread.loadEarlier(). ThreadPrimitive.LoadEarlier is a button that loads the next page and is disabled while none exists (a press while one loads joins it, so the button keeps keyboard focus), ThreadPrimitive.Viewport keeps the visible messages in place when the page lands above them, and the React Native ThreadPrimitive.MessagesFlatList loads one when the list nears its start.
Give every message a stable id; a message without one is renumbered when earlier ones arrive, which remounts it.
Merge the page into the latest messages, not a copy taken when the request started, so tokens that streamed meanwhile are kept.
With messageRepository, publish the full merged repository with its head; a repository replaces the previous one rather than extending it.
ExternalStoreRuntime uses ExternalStoreThreadListAdapter (synchronous, inline). See threads for the contract and best practices on keeping currentThreadId in sync with your store.
Real-world example: the Claude Managed Agents guide maps Anthropic-hosted agent sessions onto this runtime, with Anthropic's official quickstart as the runnable reference.
Attach your original message to a ThreadMessage you constructed manually (outside the built-in converter):
import { bindExternalStoreMessage, getExternalStoreMessages,} from "@assistant-ui/react";bindExternalStoreMessage(threadMessage, originalMessage);const original = getExternalStoreMessages(threadMessage);
bindExternalStoreMessage is a no-op if the target already has a bound message. It mutates the target in place.
Warning
This API is experimental and may change without notice.
setMessages([...messages, newMessage]); // not messages.push(newMessage)
In an async handler, build the next array from your store's current messages rather than the messages the handler captured when it was created, for example a functional update with React state or getState() with zustand.
Stable handler references. Memoize onNew, onEdit, etc. with useCallback to avoid recreating the runtime.
Use useShallow with zustand to prevent unnecessary re-renders.
State not updating. Check for: array mutation instead of new arrays, missing setMessages, broken async handling, or invalid convertMessage output.
Messages going to the wrong thread. The runtime's currentThreadId and your store's selected thread must stay in sync. Centralize thread id in a context, never in component-local state. See threads.
Whether the assistant is currently generating a response. When true, shows an optimistic assistant message and flows directly to thread.isRunning.
activeRunId?string
Id of the current run. Keep it through cancelRun so late assistant tool calls from that run can be discarded.
isDisabledboolean= false
Disables typing, attaching, sending (including direct aui.composer.send() calls), and editing messages on the thread. For a narrower gate that keeps the input usable but blocks only sending, use isSendDisabled.
isSendDisabledboolean= false
Blocks new-message sending while leaving the input usable. When true, the thread composer's canSend becomes false, the Send button is disabled, Enter and the steer hotkey are no-ops, and aui.composer.send() short-circuits. Edit composers (saving message edits) ignore this flag. Use this to gate sending on external React state (e.g. while tools or auth are still loading).
isLoading?boolean
Whether the adapter is in a loading state. Displays a loading indicator instead of the composer.
hasEarlierboolean= false
Whether messages exist before the first one in messages. Surfaces as thread.hasEarlier, but only together with onLoadEarlier.
onLoadEarlier?() => Promise<void>
Loads the page before the first message; prepend it to messages. One call runs at a time, thread.isLoadingEarlier stays true until the promise settles, and a rejection is logged rather than passed on.
Handler for regenerating messages (required for reload feature).
onCancel?() => Promise<void>
Handler for cancelling the current generation.
onRefetchThread?() => Promise<void>
Handler for re-fetching this thread's state in place, driving threads.reloadMainThread(). Unrelated to onReload, which re-generates an assistant message.
Receives each finalized voice transcript message; when your host uses its own message type, convert the `ThreadMessage` while keeping its id, then append it so it persists. Without it, transcripts stay for the session only. A transcript that finalizes while `isLoading` is true arrives once loading ends, and is not delivered at all when the conversation changed while it waited, which is recognized by a new `unstable_messageRepositoryInstance`.
Message content as string or structured message parts. Supports data-* prefixed types (e.g. { type: "data-workflow", data: {...} }) which are automatically converted to DataMessagePart. A part's optional id, including on data-* parts, keys the part when it is non-empty and no other part of the same type in the message shares it, preserving component state when parts are reordered or updated with the same id. Every part with a missing or empty id, or an id shared by another part of the same type, is keyed by its type and position.
id?string
Unique identifier for the message.
createdAt?Date
Timestamp when the message was created.
status?MessageStatus
Status of assistant messages ({ type: "running" }, { type: "complete" }, { type: "incomplete" }).
attachments?readonly CompleteAttachment[]
File attachments (user messages only). Type accepts custom strings beyond "image" | "document" | "file"; contentType is optional.
metadata?object
Additional message metadata (steps, custom fields, and assistant runId for cancellation attribution).
A cancellation is not necessarily resumable. Set canResume: true only when your backend retains a checkpoint for the current run, and supply onResume to reconnect to that checkpoint. The composer then shows Resume when its draft is empty. Adapters without an explicit checkpoint keep the Send/Cancel controls.
onResume must continue the same assistant message and tool state. Do not implement it with append, reload, or an empty user message. Set isRunning while reconnecting and clear canResume when the checkpoint is consumed, expires, or the thread changes. With canResume: true, useExternalStoreRuntime and ExternalThread share one pending resume attempt per thread. Later calls join that attempt even if the callback or run configuration changes; the first call's configuration is used. Once it settles, a new call uses the current callback and its own configuration. useExternalStoreRuntime also releases a pending attempt when the runtime resets, imports state, switches branches, or receives a different message repository. Adapters that discard a backing session without replacing the runtime should call unstable_notifySessionReset(). An older attempt settling does not clear a newer one. Without this opt-in, explicit resume calls retain their existing independent behavior. A rejected callback re-enables the control for retry; report the failure through your adapter's normal error state. The runtime also suppresses repeated clicks across controls for the same thread while the callback is pending and preserves nonempty composer drafts.
The same opt-in fields are available on ExternalThread, useAISDKRuntime, and useChatRuntime. Merely providing onResume does not advertise a resumable checkpoint. useAssistantTransportRuntime accepts canResume with its existing resumeApi; the flag must come from the backend's checkpoint state for the current thread.
For AI SDK replay requirements, retry policy, and branch cleanup, see Resumable Streams.
useAgUiRuntime does not expose the generic Resume control: its default resume path starts a new AG-UI run, which does not establish checkpoint availability. Its existing interrupt handling and history-stream resumption APIs remain available.