Documents
external-store
external-store
Type
External
Status
Published
Created
Mar 17, 2026
Updated
Mar 17, 2026

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
`ExternalStoreRuntime` gives you total control over state (persist, sync, share), but you must wire up every callback.

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:

Loading diagram...

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#

### Install Dependencies
<InstallCommand npm={["@assistant-ui/react"]} />
### 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, 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>
  );
}
```
### Use in Your App
```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:

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 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#

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

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:

You can implement `onEdit(editedMessage)` to handle user-initiated edits in your external store. This enables features like "edit and re-run" on your backend.
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:

  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
// 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.
  1. 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
}
  1. 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)
};
  1. 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, []));
  }
};
  1. 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" } - When isRunning is true
  • { type: "complete", reason: "unknown" } - When isRunning becomes false
  • { type: "incomplete", reason: "cancelled" } - When cancelled via onCancel

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)
};
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#

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);
This API is experimental and may change without notice.

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#

ScenarioRecommendation
Quick prototypeLocalRuntime
Using Redux/ZustandExternalStoreRuntime
Need Assistant Cloud integrationLocalRuntime
Custom thread storageBoth (LocalRuntime with adapter or ExternalStoreRuntime)
Simple single threadLocalRuntime
Complex state logicExternalStoreRuntime

Feature Comparison#

FeatureLocalRuntimeExternalStoreRuntime
State ManagementBuilt-inYou provide
Multi-threadVia Cloud or custom adapterVia adapter
Message FormatThreadMessageAny (with conversion)
Setup ComplexityLowMedium
FlexibilityMediumHigh

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:

  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#

  • 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#

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",
},
]}
/>

The thread list adapter enables multi-thread support. Without it, the runtime only manages the current conversation.

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