Overview#
ExternalStoreRuntime bridges your existing state management with assistant-ui components. It requires an ExternalStoreAdapter<TMessage> that handles communication between your state and the UI.
Key differences from LocalRuntime:
- You own the state - Full control over message state, thread management, and persistence logic
- Bring your own state management - Works with Redux, Zustand, TanStack Query, or any React state library
- Custom message formats - Use your backend's message structure with automatic conversion
Example Implementation#
type MyMessage = {
role: "user" | "assistant";
content: string;
};
const backendApi = async (input: string): Promise<MyMessage> => {
return { role: "assistant", content: "Hello, world!" };
};
// ---cut---
import { useState, ReactNode } from "react";
import {
useExternalStoreRuntime,
ThreadMessageLike,
AppendMessage,
AssistantRuntimeProvider,
} from "@assistant-ui/react";
const convertMessage = (message: MyMessage): ThreadMessageLike => {
return {
role: message.role,
content: [{ type: "text", text: message.content }],
};
};
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((currentConversation) => [
...currentConversation,
{ role: "user", content: input },
]);
setIsRunning(true);
const assistantMessage = await backendApi(input);
setMessages((currentConversation) => [
...currentConversation,
assistantMessage,
]);
setIsRunning(false);
};
const runtime = useExternalStoreRuntime({
isRunning,
messages,
convertMessage,
onNew,
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
}
When to Use#
Use ExternalStoreRuntime if you need:
- Full control over message state - Manage messages with Redux, Zustand, TanStack Query, or any React state management library
- Custom multi-thread implementation - Build your own thread management system with custom storage
- Integration with existing state - Keep chat state in your existing state management solution
- Custom message formats - Use your backend's message structure with automatic conversion
- Complex synchronization - Sync messages with external data sources, databases, or multiple clients
- Custom persistence logic - Implement your own storage patterns and caching strategies
Key Features#
Architecture#
How It Works#
ExternalStoreRuntime acts as a bridge between your state management and assistant-ui:
Key Concepts#
- State Ownership - You own and control all message state
- Adapter Pattern - The adapter translates between your state and assistant-ui
- Capability-Based Features - UI features are enabled based on which handlers you provide
- Message Conversion - Automatic conversion between your message format and assistant-ui's format
- Optimistic Updates - Built-in handling for streaming and loading states
Getting Started#
### Install Dependencies<InstallCommand npm={["@assistant-ui/react"]} />
```tsx title="app/MyRuntimeProvider.tsx"
"use client";
import { ThreadMessageLike } from "@assistant-ui/react";
import { AppendMessage } from "@assistant-ui/react";
import {
AssistantRuntimeProvider,
useExternalStoreRuntime,
} from "@assistant-ui/react";
import { useState } from "react";
const convertMessage = (message: ThreadMessageLike, idx: number) => {
return message;
};
export function MyRuntimeProvider({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const [messages, setMessages] = useState<readonly ThreadMessageLike[]>([]);
const onNew = async (message: AppendMessage) => {
if (message.content.length !== 1 || message.content[0]?.type !== "text")
throw new Error("Only text content is supported");
const userMessage: ThreadMessageLike = {
role: "user",
content: [{ type: "text", text: message.content[0].text }],
};
setMessages((currentMessages) => [...currentMessages, userMessage]);
// normally you would perform an API call here to get the assistant response
await new Promise((resolve) => setTimeout(resolve, 1000));
const assistantMessage: ThreadMessageLike = {
role: "assistant",
content: [{ type: "text", text: "Hello, world!" }],
};
setMessages((currentMessages) => [...currentMessages, assistantMessage]);
};
const runtime = useExternalStoreRuntime<ThreadMessageLike>({
messages,
setMessages,
onNew,
convertMessage,
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
}
```
```tsx title="app/page.tsx"
import { Thread } from "@/components/assistant-ui/thread";
import { MyRuntimeProvider } from "./MyRuntimeProvider";
export default function Page() {
return (
<MyRuntimeProvider>
<Thread />
</MyRuntimeProvider>
);
}
```
Implementation Patterns#
Message Conversion#
Two approaches for converting your message format:
1. Simple Conversion (Recommended)#
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,
});
2. Advanced Conversion with useExternalMessageConverter#
For complex scenarios with performance optimization:
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,
createdAt: new Date(message.timestamp),
}),
messages,
isRunning: false,
joinStrategy: "concat-content", // Merge adjacent assistant messages
});
const runtime = useExternalStoreRuntime({
messages: convertedMessages,
onNew,
// No convertMessage needed - already converted
});
Join Strategy#
Controls how adjacent assistant messages are combined:
concat-content(default): Merges adjacent assistant messages into onenone: Keeps all messages separate
This is useful when your backend sends multiple message chunks that should appear as a single message in the UI.
`useExternalMessageConverter` provides performance optimization for complex message conversion scenarios. For simpler cases, consider using the basic `convertMessage` approach shown above.Essential Handlers#
Basic Chat (onNew only)#
const runtime = useExternalStoreRuntime({
messages,
onNew: async (message) => {
// Add user message to state
const userMsg = { role: "user", content: message.content };
setMessages([...messages, userMsg]);
// Get AI response
const response = await callAI(message);
setMessages([...messages, userMsg, response]);
},
});
Full-Featured Chat#
const runtime = useExternalStoreRuntime({
messages,
setMessages, // Enables branch switching
onNew, // Required
onEdit, // Enables message editing
onReload, // Enables regeneration
onCancel, // Enables cancellation
});
Streaming Responses#
Implement real-time streaming with progressive updates:
const onNew = async (message: AppendMessage) => {
// Add user message
const userMessage: ThreadMessageLike = {
role: "user",
content: message.content,
id: generateId(),
};
setMessages((prev) => [...prev, userMessage]);
// Create placeholder for assistant message
setIsRunning(true);
const assistantId = generateId();
const assistantMessage: ThreadMessageLike = {
role: "assistant",
content: [{ type: "text", text: "" }],
id: assistantId,
};
setMessages((prev) => [...prev, assistantMessage]);
// Stream response
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#
Enable message editing by implementing the onEdit handler:
const onEdit = async (message: AppendMessage) => {
// Find the index where to insert the edited message
const index = messages.findIndex((m) => m.id === message.parentId) + 1;
// Keep messages up to the parent
const newMessages = [...messages.slice(0, index)];
// Add the edited message
const editedMessage: ThreadMessageLike = {
role: "user",
content: message.content,
id: message.id || generateId(),
};
newMessages.push(editedMessage);
setMessages(newMessages);
// Generate new response
setIsRunning(true);
const response = await api.chat(message);
newMessages.push({
role: "assistant",
content: response.content,
id: generateId(),
});
setMessages(newMessages);
setIsRunning(false);
};
Tool Calling#
Support tool calls with proper result handling:
const onAddToolResult = (options: AddToolResultOptions) => {
setMessages((prev) =>
prev.map((message) => {
if (message.id === options.messageId) {
// Update the specific tool call with its result
return {
...message,
content: message.content.map((part) => {
if (
part.type === "tool-call" &&
part.toolCallId === options.toolCallId
) {
return {
...part,
result: options.result,
};
}
return part;
}),
};
}
return message;
}),
);
};
const runtime = useExternalStoreRuntime({
messages,
onNew,
onAddToolResult,
// ... other props
});
Automatic Tool Result Matching#
The runtime automatically matches tool results with their corresponding tool calls. When messages are converted and joined:
- Tool Call Tracking - The runtime tracks tool calls by their
toolCallId - Result Association - Tool results are automatically associated with their corresponding calls
- Message Grouping - Related tool messages are intelligently grouped together
// Example: Tool call and result in separate messages
const messages = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call_123",
toolName: "get_weather",
args: { location: "San Francisco" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_123",
result: { temperature: 72, condition: "sunny" },
},
],
},
];
// These are automatically matched and grouped by the runtime
File Attachments#
Enable file uploads with the attachment adapter:
const attachmentAdapter: AttachmentAdapter = {
accept: "image/*,application/pdf,.txt,.md",
async add({ file }) {
// Upload file to your server
const formData = new FormData();
formData.append("file", file);
const response = await fetch("/api/upload", {
method: "POST",
body: formData,
});
const { id } = await response.json();
return {
id,
type: "document",
name: file.name,
file,
status: { type: "requires-action", reason: "composer-send" },
};
},
async remove(attachment) {
// Remove file from server
await fetch(`/api/upload/${attachment.id}`, {
method: "DELETE",
});
},
async send(attachment) {
// Convert pending attachment to complete attachment when message is sent
return {
...attachment,
status: { type: "complete" },
content: [{ type: "text", text: `File: ${attachment.name}` }],
};
},
};
const runtime = useExternalStoreRuntime({
messages,
onNew,
adapters: {
attachments: attachmentAdapter,
},
});
Thread Management#
Managing Thread Context#
When implementing multi-thread support with ExternalStoreRuntime, you need to carefully manage thread context across your application. Here's a comprehensive approach:
// Create a context for thread management
const ThreadContext = createContext<{
currentThreadId: string;
setCurrentThreadId: (id: string) => void;
threads: Map<string, ThreadMessageLike[]>;
setThreads: React.Dispatch<
React.SetStateAction<Map<string, ThreadMessageLike[]>>
>;
}>({
currentThreadId: "default",
setCurrentThreadId: () => {},
threads: new Map(),
setThreads: () => {},
});
// Thread provider component
export function ThreadProvider({ children }: { children: ReactNode }) {
const [threads, setThreads] = useState<Map<string, ThreadMessageLike[]>>(
new Map([["default", []]]),
);
const [currentThreadId, setCurrentThreadId] = useState("default");
return (
<ThreadContext.Provider
value={{ currentThreadId, setCurrentThreadId, threads, setThreads }}
{children}
</ThreadContext.Provider>
);
}
// Hook for accessing thread context
export function useThreadContext() {
const context = useContext(ThreadContext);
if (!context) {
throw new Error("useThreadContext must be used within ThreadProvider");
}
return context;
}
Complete Thread Implementation#
Here's a full implementation with proper context management:
function ChatWithThreads() {
const { currentThreadId, setCurrentThreadId, threads, setThreads } =
useThreadContext();
const [threadList, setThreadList] = useState<ExternalStoreThreadData[]>([
{ id: "default", status: "regular", title: "New Chat" },
]);
// Get messages for current thread
const currentMessages = threads.get(currentThreadId) || [];
const threadListAdapter: ExternalStoreThreadListAdapter = {
threadId: currentThreadId,
threads: threadList.filter((t) => t.status === "regular"),
archivedThreads: threadList.filter((t) => t.status === "archived"),
onSwitchToNewThread: () => {
const newId = `thread-${Date.now()}`;
setThreadList((prev) => [
...prev,
{
id: newId,
status: "regular",
title: "New Chat",
},
]);
setThreads((prev) => new Map(prev).set(newId, []));
setCurrentThreadId(newId);
},
onSwitchToThread: (threadId) => {
setCurrentThreadId(threadId);
},
onRename: (threadId, newTitle) => {
setThreadList((prev) =>
prev.map((t) =>
t.id === threadId ? { ...t, title: newTitle } : t,
),
);
},
onArchive: (threadId) => {
setThreadList((prev) =>
prev.map((t) =>
t.id === threadId ? { ...t, status: "archived" } : t,
),
);
},
onDelete: (threadId) => {
setThreadList((prev) => prev.filter((t) => t.id !== threadId));
setThreads((prev) => {
const next = new Map(prev);
next.delete(threadId);
return next;
});
if (currentThreadId === threadId) {
setCurrentThreadId("default");
}
},
};
const runtime = useExternalStoreRuntime({
messages: currentMessages,
setMessages: (messages) => {
setThreads((prev) => new Map(prev).set(currentThreadId, messages));
},
onNew: async (message) => {
// Handle new message for current thread
// Your implementation here
},
adapters: {
threadList: threadListAdapter,
},
});
return (
<AssistantRuntimeProvider runtime={runtime}>
<ThreadList />
<Thread />
</AssistantRuntimeProvider>
);
}
// App component with proper context wrapping
export function App() {
return (
<ThreadProvider>
<ChatWithThreads />
</ThreadProvider>
);
}
Thread Context Best Practices#
**Critical**: When using `ExternalStoreRuntime` with threads, the `currentThreadId` must be consistent across all components and handlers. Mismatched thread IDs will cause messages to appear in wrong threads or disappear entirely.- Centralize Thread State: Always use a context or global state management solution to ensure thread ID consistency:
// ❌ Bad: Local state in multiple components
function ThreadList() {
const [currentThreadId, setCurrentThreadId] = useState("default");
// This won't sync with the runtime!
}
// ✅ Good: Shared context
function ThreadList() {
const { currentThreadId, setCurrentThreadId } = useThreadContext();
// Thread ID is synchronized everywhere
}
- Sync Thread Changes: Ensure all thread-related operations update both the thread ID and messages:
// ❌ Bad: Only updating thread ID
onSwitchToThread: (threadId) => {
setCurrentThreadId(threadId);
// Messages won't update!
};
// ✅ Good: Complete state update
onSwitchToThread: (threadId) => {
setCurrentThreadId(threadId);
// Messages automatically update via currentMessages = threads.get(currentThreadId)
};
- Handle Edge Cases: Always provide fallbacks for missing threads:
// Ensure thread always exists
const currentMessages = threads.get(currentThreadId) || [];
// Initialize new threads properly
const initializeThread = (threadId: string) => {
if (!threads.has(threadId)) {
setThreads((prev) => new Map(prev).set(threadId, []));
}
};
- Persist Thread State: For production apps, sync thread state with your backend:
// Save thread state to backend
useEffect(() => {
const saveThread = async () => {
await api.saveThread(currentThreadId, threads.get(currentThreadId) || []);
};
const debounced = debounce(saveThread, 1000);
debounced();
return () => debounced.cancel();
}, [currentThreadId, threads]);
Integration Examples#
Redux Integration#
// Using Redux Toolkit (recommended)
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { ThreadMessageLike } from "@assistant-ui/react";
interface ChatState {
messages: ThreadMessageLike[];
isRunning: boolean;
}
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;
export const selectMessages = (state: RootState) => state.chat.messages;
export const selectIsRunning = (state: RootState) => state.chat.isRunning;
export default chatSlice.reducer;
// ReduxRuntimeProvider.tsx
import { useSelector, useDispatch } from "react-redux";
import {
selectMessages,
selectIsRunning,
addMessage,
setMessages,
setIsRunning,
} from "./chatSlice";
export function ReduxRuntimeProvider({ children }) {
const messages = useSelector(selectMessages);
const isRunning = useSelector(selectIsRunning);
const dispatch = useDispatch();
const runtime = useExternalStoreRuntime({
messages,
isRunning,
setMessages: (messages) => dispatch(setMessages(messages)),
onNew: async (message) => {
// Add user message
dispatch(
addMessage({
role: "user",
content: message.content,
id: `msg-${Date.now()}`,
createdAt: new Date(),
}),
);
// Generate response
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 Integration (v5)#
// Using Zustand v5 with TypeScript
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;
updateMessage: (id: string, updates: Partial<ThreadMessageLike>) => void;
}
// Zustand v5 requires the extra parentheses for TypeScript
const useChatStore = create<ChatState>()(
immer((set) => ({
messages: [],
isRunning: false,
addMessage: (message) =>
set((state) => {
state.messages.push(message);
}),
setMessages: (messages) =>
set((state) => {
state.messages = messages;
}),
setIsRunning: (isRunning) =>
set((state) => {
state.isRunning = isRunning;
}),
updateMessage: (id, updates) =>
set((state) => {
const index = state.messages.findIndex((m) => m.id === id);
if (index !== -1) {
Object.assign(state.messages[index], updates);
}
}),
})),
);
// ZustandRuntimeProvider.tsx
import { useShallow } from "zustand/shallow";
export function ZustandRuntimeProvider({ children }) {
// Use useShallow to prevent unnecessary re-renders
const { messages, isRunning, addMessage, setMessages, setIsRunning } =
useChatStore(
useShallow((state) => ({
messages: state.messages,
isRunning: state.isRunning,
addMessage: state.addMessage,
setMessages: state.setMessages,
setIsRunning: state.setIsRunning,
})),
);
const runtime = useExternalStoreRuntime({
messages,
isRunning,
setMessages,
onNew: async (message) => {
// Add user message
addMessage({
role: "user",
content: message.content,
id: `msg-${Date.now()}`,
createdAt: new Date(),
});
// Generate response
setIsRunning(true);
const response = await api.chat(message);
addMessage({
role: "assistant",
content: response.content,
id: `msg-${Date.now()}-assistant`,
createdAt: new Date(),
});
setIsRunning(false);
},
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
}
TanStack Query Integration#
// Using TanStack Query v5 with TypeScript
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { ThreadMessageLike, AppendMessage } from "@assistant-ui/react";
// Query key factory pattern
export const messageKeys = {
all: ["messages"] as const,
thread: (threadId: string) => [...messageKeys.all, threadId] as const,
};
// TanStackQueryRuntimeProvider.tsx
export function TanStackQueryRuntimeProvider({ children }) {
const queryClient = useQueryClient();
const threadId = "main"; // Or from context/props
const { data: messages = [] } = useQuery({
queryKey: messageKeys.thread(threadId),
queryFn: () => fetchMessages(threadId),
staleTime: 1000 * 60 * 5, // Consider data fresh for 5 minutes
});
const sendMessage = useMutation({
mutationFn: api.chat,
// Optimistic updates with proper TypeScript types
onMutate: async (message: AppendMessage) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({
queryKey: messageKeys.thread(threadId),
});
// Snapshot the previous value
const previousMessages = queryClient.getQueryData<ThreadMessageLike[]>(
messageKeys.thread(threadId),
);
// Optimistically update with typed data
const optimisticMessage: ThreadMessageLike = {
role: "user",
content: message.content,
id: `temp-${Date.now()}`,
createdAt: new Date(),
};
queryClient.setQueryData<ThreadMessageLike[]>(
messageKeys.thread(threadId),
(old = []) => [...old, optimisticMessage],
);
return { previousMessages, tempId: optimisticMessage.id };
},
onSuccess: (response, variables, context) => {
// Replace optimistic message with real data
queryClient.setQueryData<ThreadMessageLike[]>(
messageKeys.thread(threadId),
(old = []) => {
// Remove temp message and add real ones
return old
.filter((m) => m.id !== context?.tempId)
.concat([
{
role: "user",
content: variables.content,
id: `user-${Date.now()}`,
createdAt: new Date(),
},
response,
]);
},
);
},
onError: (error, variables, context) => {
// Rollback to previous messages on error
if (context?.previousMessages) {
queryClient.setQueryData(
messageKeys.thread(threadId),
context.previousMessages,
);
}
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries({
queryKey: messageKeys.thread(threadId),
});
},
});
const runtime = useExternalStoreRuntime({
messages,
isRunning: sendMessage.isPending,
onNew: async (message) => {
await sendMessage.mutateAsync(message);
},
// Enable message editing
setMessages: (newMessages) => {
queryClient.setQueryData(messageKeys.thread(threadId), newMessages);
},
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{children}
</AssistantRuntimeProvider>
);
}
Key Features#
Automatic Optimistic Updates#
When isRunning becomes true, the runtime automatically shows an optimistic assistant message:
// Your code
setIsRunning(true);
// Runtime automatically:
// 1. Shows empty assistant message with { type: "running" } status
// 2. Displays typing indicator
// 3. Updates status to { type: "complete", reason: "unknown" } when isRunning becomes false
Message Status Management#
Assistant messages get automatic status updates:
{ type: "running" }- WhenisRunningis true{ type: "complete", reason: "unknown" }- WhenisRunningbecomes false{ type: "incomplete", reason: "cancelled" }- When cancelled viaonCancel
Tool Result Matching#
The runtime automatically matches tool results with their calls:
// Tool call and result can be in separate messages
const messages = [
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call_123",
toolName: "get_weather",
args: { location: "SF" },
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_123",
result: { temp: 72 },
},
],
},
];
// Runtime automatically associates these
Working with External Messages#
Converting Back to Your Format#
Use getExternalStoreMessages to access your original messages:
import { getExternalStoreMessages } from "@assistant-ui/react";
const MyComponent = () => {
const originalMessages = useAuiState((s) => getExternalStoreMessages(s.message));
// originalMessages is MyMessage[] (your original type)
};
Message part Access#
const ToolUI = makeAssistantToolUI({
render: () => {
const originalMessages = useAuiState((s) => getExternalStoreMessages(s.part));
// Access original message data for this message part
},
});
Binding External Messages Manually#
Use bindExternalStoreMessage to attach your original message to a ThreadMessage or message part object. This is useful when you construct ThreadMessage objects yourself (outside of the built-in message converter) and want getExternalStoreMessages to work with them.
import {
bindExternalStoreMessage,
getExternalStoreMessages,
} from "@assistant-ui/react";
// Attach your original message to a ThreadMessage
bindExternalStoreMessage(threadMessage, originalMessage);
// Later, retrieve it
const original = getExternalStoreMessages(threadMessage);
bindExternalStoreMessage is a no-op if the target already has a bound message. It mutates the target object in place.
Debugging#
Common Debugging Scenarios#
// Debug message conversion
const convertMessage = (message: MyMessage): ThreadMessageLike => {
console.log("Converting message:", message);
const converted = {
role: message.role,
content: [{ type: "text", text: message.content }],
};
console.log("Converted to:", converted);
return converted;
};
// Debug adapter calls
const onNew = async (message: AppendMessage) => {
console.log("onNew called with:", message);
// ... implementation
};
// Enable verbose logging
const runtime = useExternalStoreRuntime({
messages,
onNew: (...args) => {
console.log("Runtime onNew:", args);
return onNew(...args);
},
// ... other props
});
Best Practices#
1. Immutable Updates#
Always create new arrays when updating messages:
// ❌ Wrong - mutating array
messages.push(newMessage);
setMessages(messages);
// ✅ Correct - new array
setMessages([...messages, newMessage]);
2. Stable Handler References#
Memoize handlers to prevent runtime recreation:
const onNew = useCallback(
async (message: AppendMessage) => {
// Handle new message
},
[
/* dependencies */
],
);
const runtime = useExternalStoreRuntime({
messages,
onNew, // Stable reference
});
3. Performance Optimization#
// For large message lists
const recentMessages = useMemo(
() => messages.slice(-50), // Show last 50 messages
[messages],
);
// For expensive conversions
const convertMessage = useCallback((msg) => {
// Conversion logic
}, []);
LocalRuntime vs ExternalStoreRuntime#
When to Choose Which#
| Scenario | Recommendation |
|---|---|
| Quick prototype | LocalRuntime |
| Using Redux/Zustand | ExternalStoreRuntime |
| Need Assistant Cloud integration | LocalRuntime |
| Custom thread storage | Both (LocalRuntime with adapter or ExternalStoreRuntime) |
| Simple single thread | LocalRuntime |
| Complex state logic | ExternalStoreRuntime |
Feature Comparison#
| Feature | LocalRuntime | ExternalStoreRuntime |
|---|---|---|
| State Management | Built-in | You provide |
| Multi-thread | Via Cloud or custom adapter | Via adapter |
| Message Format | ThreadMessage | Any (with conversion) |
| Setup Complexity | Low | Medium |
| Flexibility | Medium | High |
Common Pitfalls#
**Features not appearing**: Each UI feature requires its corresponding handler:// ❌ No edit button
const runtime = useExternalStoreRuntime({ messages, onNew });
// ✅ Edit button appears
const runtime = useExternalStoreRuntime({ messages, onNew, onEdit });
State not updating: Common causes:
- Mutating arrays instead of creating new ones
- Missing
setMessagesfor branch switching - Not handling async operations properly
- Incorrect message format conversion
Debugging Checklist#
- Are you creating new arrays when updating messages?
- Did you provide all required handlers for desired features?
- Is your
convertMessagereturning validThreadMessageLike? - Are you properly handling
isRunningstate? - For threads: Is your thread list adapter complete?
Thread-Specific Debugging#
Common thread context issues and solutions:
Messages disappearing when switching threads:
// Check 1: Ensure currentThreadId is consistent
console.log("Runtime threadId:", threadListAdapter.threadId);
console.log("Current threadId:", currentThreadId);
console.log("Messages for thread:", threads.get(currentThreadId));
// Check 2: Verify setMessages uses correct thread
setMessages: (messages) => {
console.log("Setting messages for thread:", currentThreadId);
setThreads((prev) => new Map(prev).set(currentThreadId, messages));
};
Thread list not updating:
// Ensure threadList state is properly managed
onSwitchToNewThread: () => {
const newId = `thread-${Date.now()}`;
console.log("Creating new thread:", newId);
// All three updates must happen together
setThreadList((prev) => [...prev, newThreadData]);
setThreads((prev) => new Map(prev).set(newId, []));
setCurrentThreadId(newId);
};
Messages going to wrong thread:
// Add validation to prevent race conditions
const validateThreadContext = () => {
const runtimeThread = threadListAdapter.threadId;
const contextThread = currentThreadId;
if (runtimeThread !== contextThread) {
console.error("Thread mismatch!", { runtimeThread, contextThread });
throw new Error("Thread context mismatch");
}
};
// Call before any message operation
onNew: async (message) => {
validateThreadContext();
// ... handle message
};
API Reference#
ExternalStoreAdapter#
The main interface for connecting your state to assistant-ui.
<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 optimistic assistant message",
default: "false",
},
{
name: "isDisabled",
type: "boolean",
description: "Whether the chat input should be disabled",
default: "false",
},
{
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: "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: "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)",
},
{
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: "isLoading",
type: "boolean",
description:
"Whether the adapter is in a loading state (e.g. initial data fetch). Displays a loading indicator instead of the composer",
},
{
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: "Feature adapters (same as LocalRuntime)",
children: [
{
type: "adapters",
parameters: [
{
name: "attachments",
type: "AttachmentAdapter",
description: "Enable file attachments",
},
{
name: "speech",
type: "SpeechSynthesisAdapter",
description: "Enable text-to-speech",
},
{
name: "dictation",
type: "DictationAdapter",
description: "Enable speech-to-text dictation",
},
{
name: "feedback",
type: "FeedbackAdapter",
description: "Enable message feedback",
},
{
name: "threadList",
type: "ExternalStoreThreadListAdapter",
description: "Enable multi-thread management",
},
],
},
],
},
{
name: "unstable_capabilities",
type: "object",
description: "Configure runtime capabilities",
children: [
{
type: "unstable_capabilities",
parameters: [
{
name: "copy",
type: "boolean",
description: "Enable message copy feature",
default: "true",
},
],
},
],
},
]}
/>
ThreadMessageLike#
A flexible message format that can be converted to assistant-ui's internal format.
<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). Attachment type accepts custom strings beyond "image" | "document" | "file", and contentType is optional.",
},
{
name: "metadata",
type: "object",
description: "Additional message metadata",
children: [
{
type: "metadata",
parameters: [
{
name: "steps",
type: "readonly ThreadStep[]",
description: "Tool call steps for assistant messages",
},
{
name: "custom",
type: "Record<string, unknown>",
description: "Custom metadata for your application",
},
],
},
],
},
]}
/>
ExternalStoreThreadListAdapter#
Enable multi-thread support with custom thread management.
<ParametersTable
type="ExternalStoreThreadListAdapter"
parameters={[
{
name: "threadId",
type: "string",
description:
"ID of the current active thread. Deprecated — this API is still under active development and might change without notice.",
},
{
name: "isLoading",
type: "boolean",
description: "Whether the thread list is currently loading",
},
{
name: "threads",
type: "readonly ExternalStoreThreadData<"regular">[]",
description: "Array of active threads. Each entry is an ExternalStoreThreadData object.",
},
{
name: "archivedThreads",
type: "readonly ExternalStoreThreadData<"archived">[]",
description: "Array of archived threads. Each entry is an ExternalStoreThreadData object.",
},
{
name: "onSwitchToNewThread",
type: "() => Promise | void",
description:
"Handler for creating a new thread. Deprecated — this API is still under active development and might change without notice.",
},
{
name: "onSwitchToThread",
type: "(threadId: string) => Promise | void",
description:
"Handler for switching to an existing thread. Deprecated — this API is still under active development and might change without notice.",
},
{
name: "onRename",
type: "(threadId: string, newTitle: string) => Promise | void",
description: "Handler for renaming a thread",
},
{
name: "onArchive",
type: "(threadId: string) => Promise | void",
description: "Handler for archiving a thread",
},
{
name: "onUnarchive",
type: "(threadId: string) => Promise | void",
description: "Handler for unarchiving a thread",
},
{
name: "onDelete",
type: "(threadId: string) => Promise | void",
description: "Handler for deleting a thread",
},
]}
/>
ExternalStoreThreadData#
Represents a single thread entry in the thread list.
<ParametersTable
type="ExternalStoreThreadData"
parameters={[
{
name: "id",
type: "string",
description: "Unique local identifier for the thread",
required: true,
},
{
name: "status",
type: '"regular" | "archived"',
description: "Whether the thread is active or archived",
required: true,
},
{
name: "title",
type: "string",
description: "Display title for the thread",
},
{
name: "remoteId",
type: "string",
description: "Remote/server-side identifier for the thread (used for persistence)",
},
{
name: "externalId",
type: "string",
description: "External system identifier for the thread (e.g. from a third-party service)",
},
]}
/>
Related Runtime APIs#
- AssistantRuntime API - Core runtime interface and methods
- ThreadRuntime API - Thread-specific operations and state management
- Runtime Providers - Context providers for runtime integration