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.

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

Your state
messages
ExternalStoreAdapter
ExternalStoreRuntime
assistant-ui components

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.

Quickstart

Install

npm install @assistant-ui/react

Create the runtime provider

app/MyRuntimeProvider.tsx
"use client";

import { useState, ReactNode } from "react";
import {
  useExternalStoreRuntime,
  ThreadMessageLike,
  AppendMessage,
  AssistantRuntimeProvider,
} from "@assistant-ui/react";

type MyMessage = {
  id: string;
  role: "user" | "assistant";
  content: string;
  timestamp: string;
};

const convertMessage = (message: MyMessage): ThreadMessageLike => ({
  role: message.role,
  content: [{ type: "text", text: message.content }],
});

const backendApi = async (input: string): Promise<MyMessage> => {
  return {
    id: crypto.randomUUID(),
    role: "assistant",
    content: "Hello, world!",
    timestamp: new Date().toISOString(),
  };
};

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,
      {
        id: crypto.randomUUID(),
        role: "user",
        content: input,
        timestamp: new Date().toISOString(),
      },
    ]);

    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

app/page.tsx
import { Thread } from "@/components/assistant-ui/elements/thread.aui";
import { MyRuntimeProvider } from "./MyRuntimeProvider";

export default function Page() {
  return (
    <MyRuntimeProvider>
      <Thread />
    </MyRuntimeProvider>
  );
}

Message conversion

There are two approaches.

Inline convertMessage

const convertMessage = (message: MyMessage): ThreadMessageLike => ({
  role: message.role,
  content: [{ type: "text", text: message.content }],
  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.content }],
    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. 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:

type MyMetadata = useExternalMessageConverter.Metadata & {
  labelById: Record<string, string>;
};

const convertWithMetadata: useExternalMessageConverter.Callback<MyMessage> = (
  message,
  metadata,
) => {
  const { labelById } = metadata as MyMetadata;
  return {
    role: message.role,
    content: [
      {
        type: "text",
        text: `${message.content} ${labelById[message.id] ?? ""}`,
      },
    ],
  };
};

const getMetadataKey: useExternalMessageConverter.GetMetadataKey<MyMessage> = (
  message,
  metadata,
) => (metadata as MyMetadata).labelById[message.id];

const convertedMessages = useExternalMessageConverter({
  callback: convertWithMetadata,
  messages,
  isRunning,
  metadata: { labelById } as MyMetadata,
  getMetadataKey,
});

Handler matrix

Each handler enables a specific UI feature.

HandlerEnables
onNewSending new user messages (required)
setMessagesBranch switching
onEditMessage edit button
onReloadRegenerate button
onCancelCancel button while generating
onRefetchThreadthreads.reloadMainThread() refetching the open thread in place
onAddToolResultClient-side tool result handoff. Any of onAddToolResult, onResumeToolCall, and onRespondToToolApproval turns on s.thread.capabilities.answerToolCall, which the tool fallback's answer controls need
onVoiceTranscriptPersist 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.
queueQueueing 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);
  setMessages((prev) => [
    ...prev,
    { role: "assistant", content: response.content, id: generateId() },
  ]);
  setIsRunning(false);
};

Branching

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:

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

const runtime = useExternalStoreRuntime({
  messageRepository: repo,
  setMessages, // performs branch switches
  onNew,
});

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.

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.

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.

Warning

unstable_onBranchChange is under active development and may change without notice. It complements setMessages; it does not enable branch switching by itself.

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()).

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.

import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createMessageQueue, useExternalStoreRuntime } from "@assistant-ui/react";

const onNewRef = useRef(onNew);
const busyEdges = useRef(0);
const wasRunning = useRef(isRunning);
useLayoutEffect(() => {
  onNewRef.current = onNew;
}, [onNew]);

const [queue] = useState(() => {
  const controller = createMessageQueue({
    run: async (message) => {
      const edgesAtDispatch = busyEdges.current;
      controller.notifyBusy();
      try {
        await onNewRef.current(message);
      } finally {
        if (busyEdges.current === edgesAtDispatch && !wasRunning.current) {
          controller.notifyIdle();
        }
      }
    },
  });
  if (isRunning) controller.notifyBusy();
  return controller;
});

useEffect(() => {
  if (!wasRunning.current && isRunning) {
    busyEdges.current++;
    queue.notifyBusy();
  }
  if (wasRunning.current && !isRunning) queue.notifyIdle();
  wasRunning.current = isRunning;
}, [isRunning, queue]);

const runtime = useExternalStoreRuntime({
  messages,
  isRunning,
  onNew,
  queue: queue.adapter,
});

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();
    // ...
  },
});

Paging earlier messages

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:

const runtime = useExternalStoreRuntime({
  messages,
  convertMessage,
  onNew,
  hasEarlier: cursor !== null,
  onLoadEarlier: async () => {
    const page = await fetchMessagesBefore(cursor);
    setMessages((current) => [...page.messages, ...current]);
    setCursor(page.previousCursor);
  },
});

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.

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

Info

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.

Redux

app/chatSlice.ts
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;
app/ReduxRuntimeProvider.tsx
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

app/chatStore.ts
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; }),
  })),
);
app/ZustandRuntimeProvider.tsx
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)
}

Warning

getExternalStoreMessages may return multiple messages for a single UI message; assistant-ui merges adjacent assistant and tool messages for display.

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.

Warning

This API is experimental and may change without notice.

Best practices

  1. Immutable updates. Always create new arrays:
    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.
  2. Stable handler references. Memoize onNew, onEdit, etc. with useCallback to avoid recreating the runtime.
  3. Use useShallow with 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

ExternalStoreAdapter<T>
messagesreadonly T[]

Array of messages from your state.

onNew(message: AppendMessage) => Promise<void>

Handler for new messages from the user.

isRunningboolean= false

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.

suggestions?readonly ThreadSuggestion[]

Suggested prompts to display.

extras?unknown

Additional data accessible via runtime.extras.

setMessages?(messages: readonly T[]) => void

Update messages (required for branch switching).

unstable_onBranchChange?(event: ExternalStoreBranchChange) => voidunstable

Called after an explicit branch switch with the canonical persisted head id and visible message path. Complements setMessages and is unstable.

onEdit?(message: AppendMessage) => Promise<void>

Handler for message edits (required for edit feature).

onReload?(parentId: string | null, config: StartRunConfig) => Promise<void>

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.

onAddToolResult?(options: AddToolResultOptions) => Promise<void> | void

Handler for adding tool call results.

onResume?(config: ResumeRunConfig) => Promise<void>

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.

onVoiceTranscript?(message: ThreadMessage) => void

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`.

onResumeToolCall?(options: { toolCallId: string; payload: unknown }) => void

Handler for resuming a suspended tool call (used with human-in-the-loop tool execution).

messageRepository?ExportedMessageRepository

Pre-built message repository with branching history. Use instead of messages when you need to restore branch state.

state?ReadonlyJSONValue

Opaque serializable state passed to onLoadExternalState during thread import.

onImport?(messages: readonly ThreadMessage[]) => void

Called when the runtime imports messages into the external store (e.g. on thread switch). `thread.import()` throws when it is missing.

onExportExternalState?() => any

Called to retrieve external state when the runtime exports a thread snapshot.

onLoadExternalState?(state: any) => void

Called with previously exported external state when restoring a thread snapshot.

convertMessage?(message: T, index: number) => ThreadMessageLike

Convert your message format to assistant-ui format. Not needed if using ThreadMessage type.

adapters?object

Capability adapters: attachments, speech, dictation, voice, feedback, threadList. See /docs/runtimes/concepts/adapters.

unstable_capabilities?objectunstable

Configure runtime capabilities (e.g. copy). Unstable, may change.

ThreadMessageLike

ThreadMessageLike
role"assistant" | "user" | "system"

The role of the message sender.

contentstring | readonly ThreadMessageLikePart[]

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).

Resuming an interrupted run

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.