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.
When to use it#
Pick ExternalStoreRuntime when:
- You already keep messages in redux, zustand, tanstack-query, or another store, and want to keep them there.
- You want full control over message state, persistence, and synchronization.
- You have a custom message format and need automatic conversion to assistant-ui's format.
If you do not have an existing store, use LocalRuntime instead; it is lower-friction.
Architecture#
<Flow.Root
llm={graph TD A[Your state] -->|messages| B[ExternalStoreAdapter] B --> C[ExternalStoreRuntime] C --> D[assistant-ui components] D -->|user actions| B B -->|state updates| A}
<Flow.Canvas
className="pr-44"
edges={[
{
from: "components",
to: "adapter",
route: "loop-right",
label: "user actions",
laneOffset: 40,
},
{
from: "adapter",
to: "state",
route: "loop-right",
label: "state updates",
laneOffset: 88,
},
]}
<Flow.Column>
<Flow.Node flowId="state">Your state</Flow.Node>
<Flow.Arrow direction="down" label="messages" length={36} />
<Flow.Node flowId="adapter">ExternalStoreAdapter</Flow.Node>
<Flow.Arrow direction="down" length={36} />
<Flow.Node>ExternalStoreRuntime</Flow.Node>
<Flow.Arrow direction="down" length={36} />
<Flow.Node flowId="components">assistant-ui components</Flow.Node>
</Flow.Column>
</Flow.Canvas>
</Flow.Root>
Key idea: you own the state, the adapter translates between your format and assistant-ui's. UI features are capability-based; if you provide setMessages, branching turns on; if you provide onEdit, editing turns on; etc.
Quickstart#
Install#
<InstallCommand npm={["@assistant-ui/react"]} />
Create the runtime provider#
"use client";
import { useState, ReactNode } from "react";
import {
useExternalStoreRuntime,
ThreadMessageLike,
AppendMessage,
AssistantRuntimeProvider,
} from "@assistant-ui/react";
type MyMessage = { role: "user" | "assistant"; content: string };
const convertMessage = (message: MyMessage): ThreadMessageLike => ({
role: message.role,
content: [{ type: "text", text: message.content }],
});
const backendApi = async (input: string): Promise<MyMessage> => {
return { role: "assistant", content: "Hello, world!" };
};
export function MyRuntimeProvider({
children,
}: Readonly<{ children: ReactNode }>) {
const [isRunning, setIsRunning] = useState(false);
const [messages, setMessages] = useState<MyMessage[]>([]);
const onNew = async (message: AppendMessage) => {
if (message.content[0]?.type !== "text") {
throw new Error("Only text messages are supported");
}
const input = message.content[0].text;
setMessages((prev) => [...prev, { role: "user", content: input }]);
setIsRunning(true);
const assistant = await backendApi(input);
setMessages((prev) => [...prev, assistant]);
setIsRunning(false);
};
const runtime = useExternalStoreRuntime({
isRunning,
messages,
convertMessage,
onNew,
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
}
Use in your app#
import { Thread } from "@/components/assistant-ui/thread";
import { MyRuntimeProvider } from "./MyRuntimeProvider";
export default function Page() {
return (
<MyRuntimeProvider>
<Thread />
</MyRuntimeProvider>
);
}
Message conversion#
Two approaches.
Inline convertMessage#
const convertMessage = (message: MyMessage): ThreadMessageLike => ({
role: message.role,
content: [{ type: "text", text: message.text }],
id: message.id,
createdAt: new Date(message.timestamp),
});
const runtime = useExternalStoreRuntime({
messages: myMessages,
convertMessage,
onNew,
});
useExternalMessageConverter (with join strategy)#
For performance optimization or when you need to merge adjacent assistant messages:
import { useExternalMessageConverter } from "@assistant-ui/react";
const convertedMessages = useExternalMessageConverter({
callback: (message: MyMessage): ThreadMessageLike => ({
role: message.role,
content: [{ type: "text", text: message.text }],
id: message.id,
}),
messages,
isRunning: false,
joinStrategy: "concat-content", // merges adjacent assistant messages
});
const runtime = useExternalStoreRuntime({
messages: convertedMessages,
onNew,
});
joinStrategy controls how adjacent assistant messages combine: concat-content (default) merges them into one; none keeps them separate.
Handler matrix#
Each handler enables a specific UI feature.
| Handler | Enables |
|---|---|
onNew | Sending new user messages (required) |
setMessages | Branch switching |
onEdit | Message edit button |
onReload | Regenerate button |
onCancel | Cancel button while generating |
onRefetchThread | threads.reloadMainThread() refetching the open thread in place |
onAddToolResult | Client-side tool result handoff |
queue | Queueing messages sent while a run is in progress |
Streaming responses#
Stream by mutating the assistant message in place:
const onNew = async (message: AppendMessage) => {
const userMsg: ThreadMessageLike = {
role: "user",
content: message.content,
id: generateId(),
};
setMessages((prev) => [...prev, userMsg]);
setIsRunning(true);
const assistantId = generateId();
setMessages((prev) => [
...prev,
{ role: "assistant", content: [{ type: "text", text: "" }], id: assistantId },
]);
const stream = await api.streamChat(message);
for await (const chunk of stream) {
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? {
...m,
content: [
{ type: "text", text: (m.content[0] as any).text + chunk },
],
}
: m,
),
);
}
setIsRunning(false);
};
Message editing#
const onEdit = async (message: AppendMessage) => {
const index = messages.findIndex((m) => m.id === message.parentId) + 1;
const newMessages = [...messages.slice(0, index)];
newMessages.push({
role: "user",
content: message.content,
id: message.id ?? generateId(),
});
setMessages(newMessages);
setIsRunning(true);
const response = await api.chat(message);
newMessages.push({ role: "assistant", content: response.content, id: generateId() });
setMessages(newMessages);
setIsRunning(false);
};
Branching#
The linear messages array assumes each message's parent is the previous one. For branching (e.g. multiple regenerations), use ExportedMessageRepository.fromBranchableArray() and import via thread.import():
import {
ExportedMessageRepository,
useExternalStoreRuntime,
} from "@assistant-ui/react";
const backendMessages = [
{ id: "user-1", role: "user", content: "Hello", parentId: null },
{ id: "asst-1", role: "assistant", content: "Hi!", parentId: "user-1" },
{ id: "asst-2", role: "assistant", content: "Hey!", parentId: "user-1" }, // branch
];
const repo = ExportedMessageRepository.fromBranchableArray(
backendMessages.map((m) => ({
message: { id: m.id, role: m.role, content: m.content },
parentId: m.parentId,
})),
{ headId: "asst-1" },
);
runtime.thread.import(repo);
Each message must have an explicit id and parentId; messages with the same parentId create branches. Parents must appear before children in the array.
Exporting a snapshot#
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 snapshot
const repo = runtime.thread.export();
await saveToBackend(JSON.stringify(repo));
// later, restore it into a runtime
runtime.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.
Persisting branch selection#
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.
const runtime = useExternalStoreRuntime({
messageRepository: {
messages: storedMessages,
headId: selectedHeadId,
},
setMessages: (messages) => {
setVisibleMessages(messages);
},
unstable_onBranchChange: ({ headId, visibleMessageIds }) => {
saveSelectedBranch({
headId,
visibleMessageIds,
});
},
onNew,
});
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.
Tool calling#
Handle tool results by updating the matching tool-call entry:
const onAddToolResult = (options: AddToolResultOptions) => {
setMessages((prev) =>
prev.map((message) =>
message.id === options.messageId
? {
...message,
content: message.content.map((part) =>
part.type === "tool-call" &&
part.toolCallId === options.toolCallId
? { ...part, result: options.result }
: part,
),
}
: message,
),
);
};
const runtime = useExternalStoreRuntime({
messages,
onNew,
onAddToolResult,
});
The runtime automatically matches tool results to their tool calls by toolCallId and groups related messages for display.
Attachments#
Attachments use the standard adapter contract, see adapters:
const runtime = useExternalStoreRuntime({
messages,
onNew,
adapters: { attachments: myAttachmentAdapter },
});
Queueing messages during a run#
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()).
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 rather than being coerced. Individual items are addressable through composer.queueItem({ id }) (or by index).
import { useEffect, useRef, useState } from "react";
import { createMessageQueue, useExternalStoreRuntime } from "@assistant-ui/react";
const [queue] = useState(() => createMessageQueue({ run: onNew }));
const runtime = useExternalStoreRuntime({
messages,
isRunning,
onNew,
queue: queue.adapter,
});
const wasRunning = useRef(isRunning);
useEffect(() => {
if (!wasRunning.current && isRunning) queue.notifyBusy();
if (wasRunning.current && !isRunning) queue.notifyIdle();
wasRunning.current = isRunning;
}, [isRunning, queue]);
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.
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();
// ...
},
});
Multi-thread#
ExternalStoreRuntime uses ExternalStoreThreadListAdapter (synchronous, inline). See threads for the contract and best practices on keeping currentThreadId in sync with your store.
Integration examples#
**Real-world example:** the [Claude Managed Agents guide](/docs/runtimes/claude-managed-agents) maps Anthropic-hosted agent sessions onto this runtime, with Anthropic's official [quickstart](https://github.com/anthropics/claude-quickstarts/tree/main/managed-agents/assistant-ui) as the runnable reference.Redux#
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ThreadMessageLike } from "@assistant-ui/react";
const chatSlice = createSlice({
name: "chat",
initialState: { messages: [] as ThreadMessageLike[], isRunning: false },
reducers: {
setMessages: (state, action: PayloadAction<ThreadMessageLike[]>) => {
state.messages = action.payload;
},
addMessage: (state, action: PayloadAction<ThreadMessageLike>) => {
state.messages.push(action.payload);
},
setIsRunning: (state, action: PayloadAction<boolean>) => {
state.isRunning = action.payload;
},
},
});
export const { setMessages, addMessage, setIsRunning } = chatSlice.actions;
import { useSelector, useDispatch } from "react-redux";
import { useExternalStoreRuntime, AssistantRuntimeProvider } from "@assistant-ui/react";
export function ReduxRuntimeProvider({ children }) {
const messages = useSelector((s: RootState) => s.chat.messages);
const isRunning = useSelector((s: RootState) => s.chat.isRunning);
const dispatch = useDispatch();
const runtime = useExternalStoreRuntime({
messages,
isRunning,
setMessages: (messages) => dispatch(setMessages(messages)),
onNew: async (message) => {
dispatch(
addMessage({
role: "user",
content: message.content,
id: `msg-${Date.now()}`,
createdAt: new Date(),
}),
);
dispatch(setIsRunning(true));
const response = await api.chat(message);
dispatch(
addMessage({
role: "assistant",
content: response.content,
id: `msg-${Date.now()}`,
createdAt: new Date(),
}),
);
dispatch(setIsRunning(false));
},
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
}
Zustand#
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
import { ThreadMessageLike } from "@assistant-ui/react";
interface ChatState {
messages: ThreadMessageLike[];
isRunning: boolean;
addMessage: (message: ThreadMessageLike) => void;
setMessages: (messages: ThreadMessageLike[]) => void;
setIsRunning: (isRunning: boolean) => void;
}
export const useChatStore = create<ChatState>()(
immer((set) => ({
messages: [],
isRunning: false,
addMessage: (message) => set((s) => { s.messages.push(message); }),
setMessages: (messages) => set((s) => { s.messages = messages; }),
setIsRunning: (isRunning) => set((s) => { s.isRunning = isRunning; }),
})),
);
import { useShallow } from "zustand/shallow";
import { useExternalStoreRuntime, AssistantRuntimeProvider } from "@assistant-ui/react";
export function ZustandRuntimeProvider({ children }) {
const { messages, isRunning, addMessage, setMessages, setIsRunning } =
useChatStore(
useShallow((s) => ({
messages: s.messages,
isRunning: s.isRunning,
addMessage: s.addMessage,
setMessages: s.setMessages,
setIsRunning: s.setIsRunning,
})),
);
const runtime = useExternalStoreRuntime({
messages,
isRunning,
setMessages,
onNew: async (message) => {
addMessage({
role: "user",
content: message.content,
id: `msg-${Date.now()}`,
createdAt: new Date(),
});
setIsRunning(true);
const response = await api.chat(message);
addMessage({
role: "assistant",
content: response.content,
id: `msg-${Date.now()}-a`,
createdAt: new Date(),
});
setIsRunning(false);
},
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
}
TanStack Query#
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useExternalStoreRuntime } from "@assistant-ui/react";
const messageKeys = {
all: ["messages"] as const,
thread: (threadId: string) => [...messageKeys.all, threadId] as const,
};
export function TanStackQueryRuntimeProvider({ children }) {
const queryClient = useQueryClient();
const threadId = "main";
const { data: messages = [] } = useQuery({
queryKey: messageKeys.thread(threadId),
queryFn: () => fetchMessages(threadId),
});
const sendMessage = useMutation({
mutationFn: api.chat,
onMutate: async (message: AppendMessage) => {
await queryClient.cancelQueries({
queryKey: messageKeys.thread(threadId),
});
const previous = queryClient.getQueryData<ThreadMessageLike[]>(
messageKeys.thread(threadId),
);
queryClient.setQueryData<ThreadMessageLike[]>(
messageKeys.thread(threadId),
(old = []) => [
...old,
{
role: "user",
content: message.content,
id: `temp-${Date.now()}`,
createdAt: new Date(),
},
],
);
return { previous };
},
onError: (_err, _msg, context) => {
if (context?.previous) {
queryClient.setQueryData(messageKeys.thread(threadId), context.previous);
}
},
onSettled: () =>
queryClient.invalidateQueries({ queryKey: messageKeys.thread(threadId) }),
});
const runtime = useExternalStoreRuntime({
messages,
isRunning: sendMessage.isPending,
onNew: async (message) => {
await sendMessage.mutateAsync(message);
},
setMessages: (newMessages) => {
queryClient.setQueryData(messageKeys.thread(threadId), newMessages);
},
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
}
Working with external messages#
getExternalStoreMessages#
Retrieve your original message format from any assistant-ui state:
import { getExternalStoreMessages, useAuiState } from "@assistant-ui/react";
function MyComponent() {
const originalMessages = useAuiState((s) => getExternalStoreMessages(s.message));
// originalMessages is MyMessage[] (your original type)
}
bindExternalStoreMessage#
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.
Best practices#
- Immutable updates. Always create new arrays:
setMessages([...messages, newMessage]); // not messages.push(newMessage) - Stable handler references. Memoize
onNew,onEdit, etc. withuseCallbackto avoid recreating the runtime. - Use
useShallowwith zustand to prevent unnecessary re-renders.
Common pitfalls#
Edit / regenerate / cancel buttons missing. Each requires its handler:
useExternalStoreRuntime({
messages,
onNew, // required
setMessages, // branch switching
onEdit, // edit
onReload, // regenerate
onCancel, // cancel
});
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.
API reference#
ExternalStoreAdapter#
<ParametersTable
type="ExternalStoreAdapter"
parameters={[
{
name: "messages",
type: "readonly T[]",
description: "Array of messages from your state.",
required: true,
},
{
name: "onNew",
type: "(message: AppendMessage) => Promise",
description: "Handler for new messages from the user.",
required: true,
},
{
name: "isRunning",
type: "boolean",
description:
"Whether the assistant is currently generating a response. When true, shows an optimistic assistant message and flows directly to thread.isRunning.",
default: "false",
},
{
name: "isDisabled",
type: "boolean",
description:
"Disables the entire composer, including the text input. For a narrower gate that keeps the input usable but blocks only sending, use isSendDisabled.",
default: "false",
},
{
name: "isSendDisabled",
type: "boolean",
description:
"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).",
default: "false",
},
{
name: "isLoading",
type: "boolean",
description:
"Whether the adapter is in a loading state. Displays a loading indicator instead of the composer.",
},
{
name: "suggestions",
type: "readonly ThreadSuggestion[]",
description: "Suggested prompts to display.",
},
{
name: "extras",
type: "unknown",
description: "Additional data accessible via runtime.extras.",
},
{
name: "setMessages",
type: "(messages: readonly T[]) => void",
description: "Update messages (required for branch switching).",
},
{
name: "unstable_onBranchChange",
type: "(event: ExternalStoreBranchChange) => void",
description:
"Called after an explicit branch switch with the canonical persisted head id and visible message path. Complements setMessages and is unstable.",
},
{
name: "onEdit",
type: "(message: AppendMessage) => Promise",
description: "Handler for message edits (required for edit feature).",
},
{
name: "onReload",
type: "(parentId: string | Null, config: StartRunConfig) => Promise",
description:
"Handler for regenerating messages (required for reload feature).",
},
{
name: "onCancel",
type: "() => Promise",
description: "Handler for cancelling the current generation.",
},
{
name: "onRefetchThread",
type: "() => Promise",
description:
"Handler for re-fetching this thread's state in place, driving threads.reloadMainThread(). Unrelated to onReload, which re-generates an assistant message.",
},
{
name: "onAddToolResult",
type: "(options: AddToolResultOptions) => Promise | Void",
description: "Handler for adding tool call results.",
},
{
name: "onResume",
type: "(config: ResumeRunConfig) => Promise",
description:
"Handler for resuming an interrupted run (e.g. after a page reload mid-generation). For AI SDK reload-safe streaming, see the Resumable Streams guide.",
},
{
name: "onResumeToolCall",
type: "(options: { toolCallId: string; payload: unknown }) => void",
description:
"Handler for resuming a suspended tool call (used with human-in-the-loop tool execution).",
},
{
name: "messageRepository",
type: "ExportedMessageRepository",
description:
"Pre-built message repository with branching history. Use instead of messages when you need to restore branch state.",
},
{
name: "state",
type: "ReadonlyJSONValue",
description:
"Opaque serializable state passed to onLoadExternalState during thread import.",
},
{
name: "onImport",
type: "(messages: readonly ThreadMessage[]) => void",
description:
"Called when the runtime imports messages into the external store (e.g. on thread switch).",
},
{
name: "onExportExternalState",
type: "() => any",
description:
"Called to retrieve external state when the runtime exports a thread snapshot.",
},
{
name: "onLoadExternalState",
type: "(state: any) => void",
description:
"Called with previously exported external state when restoring a thread snapshot.",
},
{
name: "convertMessage",
type: "(message: T, index: number) => ThreadMessageLike",
description:
"Convert your message format to assistant-ui format. Not needed if using ThreadMessage type.",
},
{
name: "adapters",
type: "object",
description:
"Capability adapters: attachments, speech, dictation, feedback, threadList. See /docs/runtimes/concepts/adapters.",
},
{
name: "unstable_capabilities",
type: "object",
description:
"Configure runtime capabilities (e.g. copy). Unstable, may change.",
},
]}
/>
ThreadMessageLike#
<ParametersTable
type="ThreadMessageLike"
parameters={[
{
name: "role",
type: '"assistant" | "user" | "system"',
description: "The role of the message sender.",
required: true,
},
{
name: "content",
type: "string | Readonly MessagePart[]",
description:
"Message content as string or structured message parts. Supports data-* prefixed types (e.g. { type: "data-workflow", data: {...} }) which are automatically converted to DataMessagePart.",
required: true,
},
{
name: "id",
type: "string",
description: "Unique identifier for the message.",
},
{
name: "createdAt",
type: "Date",
description: "Timestamp when the message was created.",
},
{
name: "status",
type: "MessageStatus",
description:
'Status of assistant messages ({ type: "running" }, { type: "complete" }, { type: "incomplete" }).',
},
{
name: "attachments",
type: "readonly CompleteAttachment[]",
description:
'File attachments (user messages only). Type accepts custom strings beyond "image" | "document" | "file"; contentType is optional.',
},
{
name: "metadata",
type: "object",
description: "Additional message metadata (steps, custom fields).",
},
]}
/>