logoassistant-ui
Custom Backend

ExternalStoreRuntime

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

app/MyRuntimeProvider.tsx
import { ,  } from "react";
import {
  ,
  ,
  ,
  ,
} from "@assistant-ui/react";

const  = (: ):  => {
  return {
    : .,
    : [{ : "text", : . }],
  };
};

export function ({
  ,
}: <{
  : ;
}>) {
  const [, ] = (false);
  const [, ] = <[]>([]);

  const  = async (: ) => {
    if (.[0]?. !== "text")
      throw new ("Only text messages are supported");

    const  = .[0].;
    (() => [
      ...,
      { : "user", :  },
    ]);

    (true);
    const  = await ();
    (() => [
      ...,
      ,
    ]);
    (false);
  };

  const  = ({
    ,
    ,
    ,
    ,
  });

  return (
    < ={}>
      {}
    </>
  );
}

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

State Management Integration

Works seamlessly with Redux, Zustand, TanStack Query, and more

Message Conversion

Automatic conversion between your message format and assistant-ui's format

Real-time Streaming

Built-in support for streaming responses and progressive updates

Thread Management

Multi-conversation support with archiving and thread switching

Architecture

How It Works

ExternalStoreRuntime acts as a bridge between your state management and assistant-ui:

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

npm install @assistant-ui/react

Create Runtime Provider

app/MyRuntimeProvider.tsx
"use client";

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

export function MyRuntimeProvider({ children }) {
  const [messages, setMessages] = useState<ThreadMessageLike[]>([]);
  const [isRunning, setIsRunning] = useState(false);

  const onNew = async (message: AppendMessage) => {
    // Add user message
    const userMessage: ThreadMessageLike = {
      role: "user",
      content: message.content,
    };
    setMessages(prev => [...prev, userMessage]);

    // Generate response
    setIsRunning(true);
    const response = await callYourAPI(message);

    const assistantMessage: ThreadMessageLike = {
      role: "assistant",
      content: response.content,
    };
    setMessages(prev => [...prev, assistantMessage]);
    setIsRunning(false);
  };

  const runtime = useExternalStoreRuntime({
    messages,
    setMessages,
    isRunning,
    onNew,
  });

  return (
    <AssistantRuntimeProvider runtime={runtime}>
      {children}
    </AssistantRuntimeProvider>
  );
}

Use in Your App

app/page.tsx
import { Thread } from "@assistant-ui/react";
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({
  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

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

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

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[]>([
    { 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 (
    <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

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

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

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<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 "in_progress" status
// 2. Displays typing indicator
// 3. Updates status to "complete" when isRunning becomes false

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

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

Content Part Access

const ToolUI = makeAssistantToolUI({
  render: () => {
    const originalMessages = useContentPart((p) => getExternalStoreMessages(p));
    // Access original message data for this content part
  },
});

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.

ExternalStoreAdapter<T>

messages:

readonly T[]

Array of messages from your state

onNew:

(message: AppendMessage) => Promise<void>

Handler for new messages from the user

isRunning:

boolean = false

Whether the assistant is currently generating a response. When true, shows optimistic assistant message

isDisabled:

boolean = false

Whether the chat input should be disabled

suggestions?:

readonly ThreadSuggestion[]

Suggested prompts to display

extras?:

unknown

Additional data accessible via runtime.extras

setMessages?:

(messages: T[]) => void

Update messages (required for branch switching)

onEdit?:

(message: AppendMessage) => Promise<void>

Handler for message edits (required for edit feature)

onReload?:

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

Handler for regenerating messages (required for reload feature)

onCancel?:

() => Promise<void>

Handler for cancelling the current generation

onAddToolResult?:

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

Handler for adding tool call results

convertMessage?:

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

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

joinStrategy:

"concat-content" | "none" = "concat-content"

How to join adjacent assistant messages when converting

adapters?:

object

Feature adapters (same as LocalRuntime)

adapters

attachments?:

AttachmentAdapter

Enable file attachments

speech?:

SpeechSynthesisAdapter

Enable text-to-speech

feedback?:

FeedbackAdapter

Enable message feedback

threadList?:

ExternalStoreThreadListAdapter

Enable multi-thread management

unstable_capabilities?:

object

Configure runtime capabilities

unstable_capabilities

copy:

boolean = true

Enable message copy feature

ThreadMessageLike

A flexible message format that can be converted to assistant-ui's internal format.

ThreadMessageLike

role:

"assistant" | "user" | "system"

The role of the message sender

content:

string | readonly ContentPart[]

Message content as string or structured content parts

id?:

string

Unique identifier for the message

createdAt?:

Date

Timestamp when the message was created

status?:

MessageStatus

Status of assistant messages (in_progress, complete, cancelled)

attachments?:

readonly CompleteAttachment[]

File attachments (user messages only)

metadata?:

object

Additional message metadata

metadata

steps?:

readonly ThreadStep[]

Tool call steps for assistant messages

custom?:

Record<string, unknown>

Custom metadata for your application

ExternalStoreThreadListAdapter

Enable multi-thread support with custom thread management.

ExternalStoreThreadListAdapter

threadId?:

string

ID of the current active thread

threads?:

readonly ExternalStoreThreadData[]

Array of regular threads with { threadId, title }

archivedThreads?:

readonly ExternalStoreThreadData[]

Array of archived threads

onSwitchToNewThread?:

() => Promise<void> | void

Handler for creating a new thread

onSwitchToThread?:

(threadId: string) => Promise<void> | void

Handler for switching to an existing thread

onRename?:

(threadId: string, newTitle: string) => Promise<void> | void

Handler for renaming a thread

onArchive?:

(threadId: string) => Promise<void> | void

Handler for archiving a thread

onUnarchive?:

(threadId: string) => Promise<void> | void

Handler for unarchiving a thread

onDelete?:

(threadId: string) => Promise<void> | void

Handler for deleting a thread

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