# ExternalStoreRuntime URL: /docs/runtimes/custom/external-store Bring your own Redux, Zustand, or state manager. Overview \[#overview] `ExternalStoreRuntime` bridges your existing state management with assistant-ui components. It requires an `ExternalStoreAdapter` 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 `ExternalStoreRuntime` gives you total control over state (persist, sync, share), but you must wire up every callback. Example Implementation \[#example-implementation] ```tsx twoslash title="app/MyRuntimeProvider.tsx" type MyMessage = { role: "user" | "assistant"; content: string; }; const backendApi = async (input: string): Promise => { 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([]); 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 ( {children} ); } ``` When to Use \[#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 \[#key-features] Architecture \[#architecture] How It Works \[#how-it-works] `ExternalStoreRuntime` acts as a bridge between your state management and assistant-ui: Key Concepts \[#key-concepts] 1. **State Ownership** - You own and control all message state 2. **Adapter Pattern** - The adapter translates between your state and assistant-ui 3. **Capability-Based Features** - UI features are enabled based on which handlers you provide 4. **Message Conversion** - Automatic conversion between your message format and assistant-ui's format 5. **Optimistic Updates** - Built-in handling for streaming and loading states Getting Started \[#getting-started] Install Dependencies \[#install-dependencies] Create Runtime Provider \[#create-runtime-provider] ```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) => { return message; }; export function MyRuntimeProvider({ children, }: Readonly<{ children: React.ReactNode; }>) { const [messages, setMessages] = useState([]); 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({ messages, setMessages, onNew, convertMessage, }); return ( {children} ); } ``` Use in Your App \[#use-in-your-app] ```tsx title="app/page.tsx" import { Thread } from "@assistant-ui/react"; import { MyRuntimeProvider } from "./MyRuntimeProvider"; export default function Page() { return ( ); } ``` Implementation Patterns \[#implementation-patterns] Message Conversion \[#message-conversion] Two approaches for converting your message format: 1. Simple Conversion (Recommended) \[#1-simple-conversion-recommended] ```tsx 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 \[#2-advanced-conversion-with-useexternalmessageconverter] For complex scenarios with performance optimization: ```tsx import { useExternalMessageConverter } from "@assistant-ui/react"; const convertedMessages = useExternalMessageConverter({ messages, convertMessage: (message: MyMessage): ThreadMessageLike => ({ role: message.role, content: [{ type: "text", text: message.text }], id: message.id, createdAt: new Date(message.timestamp), }), joinStrategy: "concat-content", // Merge adjacent assistant messages }); const runtime = useExternalStoreRuntime({ messages: convertedMessages, onNew, // No convertMessage needed - already converted }); ``` Join Strategy \[#join-strategy] Controls how adjacent assistant messages are combined: * **`concat-content`** (default): Merges adjacent assistant messages into one * **`none`**: 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 \[#essential-handlers] Basic Chat (onNew only) \[#basic-chat-onnew-only] ```tsx 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 \[#full-featured-chat] ```tsx const runtime = useExternalStoreRuntime({ messages, setMessages, // Enables branch switching onNew, // Required onEdit, // Enables message editing onReload, // Enables regeneration onCancel, // Enables cancellation }); ``` Each handler you provide enables specific UI features: - `setMessages` → Branch switching - `onEdit` → Message editing - `onReload` → Regenerate button * `onCancel` → Cancel button during generation Streaming Responses \[#streaming-responses] Implement real-time streaming with progressive updates: ```tsx 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 \[#message-editing] Enable message editing by implementing the `onEdit` handler: You can also implement `onEdit(editedMessage)` and `onRemove(messageId)` callbacks to handle user-initiated edits or deletions in your external store. This enables features like "edit and re-run" on your backend. ```tsx 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 \[#tool-calling] Support tool calls with proper result handling: ```tsx 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 \[#automatic-tool-result-matching] The runtime automatically matches tool results with their corresponding tool calls. When messages are converted and joined: 1. **Tool Call Tracking** - The runtime tracks tool calls by their `toolCallId` 2. **Result Association** - Tool results are automatically associated with their corresponding calls 3. **Message Grouping** - Related tool messages are intelligently grouped together ```tsx // 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 \[#file-attachments] Enable file uploads with the attachment adapter: ```tsx 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, url } = await response.json(); return { id, type: "document", name: file.name, file, url, }; }, async remove(attachment) { // Remove file from server await fetch(`/api/upload/${attachment.id}`, { method: "DELETE", }); }, }; const runtime = useExternalStoreRuntime({ messages, onNew, adapters: { attachments: attachmentAdapter, }, }); ``` Thread Management \[#thread-management] Managing Thread Context \[#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: ```tsx // Create a context for thread management const ThreadContext = createContext<{ currentThreadId: string; setCurrentThreadId: (id: string) => void; threads: Map; setThreads: React.Dispatch< React.SetStateAction> >; }>({ currentThreadId: "default", setCurrentThreadId: () => {}, threads: new Map(), setThreads: () => {}, }); // Thread provider component export function ThreadProvider({ children }: { children: ReactNode }) { const [threads, setThreads] = useState>( new Map([["default", []]]), ); const [currentThreadId, setCurrentThreadId] = useState("default"); return ( {children} ); } // 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 \[#complete-thread-implementation] Here's a full implementation with proper context management: ```tsx function ChatWithThreads() { const { currentThreadId, setCurrentThreadId, threads, setThreads } = useThreadContext(); const [threadList, setThreadList] = useState([ { threadId: "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, { threadId: 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.threadId === threadId ? { ...t, title: newTitle } : t, ), ); }, onArchive: (threadId) => { setThreadList((prev) => prev.map((t) => t.threadId === threadId ? { ...t, status: "archived" } : t, ), ); }, onDelete: (threadId) => { setThreadList((prev) => prev.filter((t) => t.threadId !== 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 ( ); } // App component with proper context wrapping export function App() { return ( ); } ``` Thread Context Best Practices \[#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. 1. **Centralize Thread State**: Always use a context or global state management solution to ensure thread ID consistency: ```tsx // ❌ 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 } ``` 2. **Sync Thread Changes**: Ensure all thread-related operations update both the thread ID and messages: ```tsx // ❌ 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) }; ``` 3. **Handle Edge Cases**: Always provide fallbacks for missing threads: ```tsx // 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, [])); } }; ``` 4. **Persist Thread State**: For production apps, sync thread state with your backend: ```tsx // 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 \[#integration-examples] Redux Integration \[#redux-integration] ```tsx title="app/chatSlice.ts" // 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) => { state.messages = action.payload; }, addMessage: (state, action: PayloadAction) => { state.messages.push(action.payload); }, setIsRunning: (state, action: PayloadAction) => { 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 ( {children} ); } ``` Zustand Integration (v5) \[#zustand-integration-v5] ```tsx title="app/chatStore.ts" // 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) => void; } // Zustand v5 requires the extra parentheses for TypeScript const useChatStore = create()( 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 ( {children} ); } ``` TanStack Query Integration \[#tanstack-query-integration] ```tsx title="app/chatQueries.ts" // 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( 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( messageKeys.thread(threadId), (old = []) => [...old, optimisticMessage], ); return { previousMessages, tempId: optimisticMessage.id }; }, onSuccess: (response, variables, context) => { // Replace optimistic message with real data queryClient.setQueryData( 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 ( {children} ); } ``` Key Features \[#key-features-1] Automatic Optimistic Updates \[#automatic-optimistic-updates] When `isRunning` becomes true, the runtime automatically shows an optimistic assistant message: ```tsx // Your code setIsRunning(true); // Runtime automatically: // 1. Shows empty assistant message with "in_progress" status // 2. Displays typing indicator // 3. Updates status to "complete" when isRunning becomes false ``` Message Status Management \[#message-status-management] Assistant messages get automatic status updates: * `"in_progress"` - When `isRunning` is true * `"complete"` - When `isRunning` becomes false * `"cancelled"` - When cancelled via `onCancel` Tool Result Matching \[#tool-result-matching] The runtime automatically matches tool results with their calls: ```tsx // 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 \[#working-with-external-messages] Converting Back to Your Format \[#converting-back-to-your-format] Use `getExternalStoreMessages` to access your original messages: ```tsx import { getExternalStoreMessages } from "@assistant-ui/react"; const MyComponent = () => { const originalMessages = useMessage((m) => getExternalStoreMessages(m)); // originalMessages is MyMessage[] (your original type) }; ``` After the chat finishes, use `getExternalStoreMessages(runtime)` to convert back to your domain model. Refer to the API reference for return structures and edge-case behaviors. `getExternalStoreMessages` may return multiple messages for a single UI message. This happens because assistant-ui merges adjacent assistant and tool messages for display. Message part Access \[#message-part-access] ```tsx const ToolUI = makeAssistantToolUI({ render: () => { const originalMessages = useMessagePart((p) => getExternalStoreMessages(p)); // Access original message data for this message part }, }); ``` Debugging \[#debugging] Common Debugging Scenarios \[#common-debugging-scenarios] ```tsx // 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 \[#best-practices] 1. Immutable Updates \[#1-immutable-updates] Always create new arrays when updating messages: ```tsx // ❌ Wrong - mutating array messages.push(newMessage); setMessages(messages); // ✅ Correct - new array setMessages([...messages, newMessage]); ``` 2. Stable Handler References \[#2-stable-handler-references] Memoize handlers to prevent runtime recreation: ```tsx const onNew = useCallback( async (message: AppendMessage) => { // Handle new message }, [ /* dependencies */ ], ); const runtime = useExternalStoreRuntime({ messages, onNew, // Stable reference }); ``` 3. Performance Optimization \[#3-performance-optimization] ```tsx // 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 \[#localruntime-vs-externalstoreruntime] When to Choose Which \[#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-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 \[#common-pitfalls] **Features not appearing**: Each UI feature requires its corresponding handler: ```tsx // ❌ No edit button const runtime = useExternalStoreRuntime({ messages, onNew }); // ✅ Edit button appears const runtime = useExternalStoreRuntime({ messages, onNew, onEdit }); ``` **State not updating**: Common causes: 1. Mutating arrays instead of creating new ones 2. Missing `setMessages` for branch switching 3. Not handling async operations properly 4. Incorrect message format conversion Debugging Checklist \[#debugging-checklist] * Are you creating new arrays when updating messages? * Did you provide all required handlers for desired features? * Is your `convertMessage` returning valid `ThreadMessageLike`? * Are you properly handling `isRunning` state? * For threads: Is your thread list adapter complete? Thread-Specific Debugging \[#thread-specific-debugging] Common thread context issues and solutions: **Messages disappearing when switching threads:** ```tsx // 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:** ```tsx // 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:** ```tsx // 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 \[#api-reference] ExternalStoreAdapter \[#externalstoreadapter] The main interface for connecting your state to assistant-ui. 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: 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: "convertMessage", type: "(message: T, index: number) => ThreadMessageLike", description: "Convert your message format to assistant-ui format. Not needed if using ThreadMessage type", }, { name: "joinStrategy", type: '"concat-content" | "none"', description: "How to join adjacent assistant messages when converting", default: '"concat-content"', }, { 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: "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 \[#threadmessagelike] A flexible message format that can be converted to assistant-ui's internal format. ", description: "Custom metadata for your application", }, ], }, ], }, ]} /> ExternalStoreThreadListAdapter \[#externalstorethreadlistadapter] Enable multi-thread support with custom thread management. Promise | void", description: "Handler for creating a new thread", }, { name: "onSwitchToThread", type: "(threadId: string) => Promise | void", description: "Handler for switching to an existing thread", }, { 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", }, ]} /> The thread list adapter enables multi-thread support. Without it, the runtime only manages the current conversation. Related Runtime APIs \[#related-runtime-apis] * [AssistantRuntime API](/docs/api-reference/runtimes/assistant-runtime) - Core runtime interface and methods * [ThreadRuntime API](/docs/api-reference/runtimes/thread-runtime) - Thread-specific operations and state management * [Runtime Providers](/docs/api-reference/context-providers/assistant-runtime-provider) - Context providers for runtime integration Related Resources \[#related-resources] * [Runtime Layer Concepts](/docs/concepts/runtime-layer) * [Pick a Runtime Guide](/docs/runtimes/pick-a-runtime) * [`LocalRuntime` Documentation](/docs/runtimes/custom/local) * [Examples Repository](https://github.com/assistant-ui/assistant-ui/tree/main/examples/with-external-store)