Assistant Context API

Read and update assistant state to build custom React components in your chat UI — composable context API for thread, message, and runtime data via assistant-ui.

The Context API gives custom components access to the state, actions, and events of the assistant runtime.

Introduction

The Context API provides:

  • Reactive state access - Subscribe to state changes with automatic re-renders
  • Action execution - Trigger operations like sending messages or reloading responses
  • Event listening - React to user interactions and system events
  • Scope-aware design - Components resolve their context (message, thread, etc.) from where they render

All assistant-ui primitives are built on it. When the built-in components don't meet your needs, use the Context API to build custom components with the same capabilities.

The Context API is backed by the runtime you provide to <AssistantRuntimeProvider>. The runtime is the store that holds assistant state, handles actions, and dispatches events.

Core Concepts

Scopes and Hierarchy

assistant-ui organizes state into scopes. Each scope corresponds to a part of the chat interface and exposes the state and actions for that part.

🗂️  ThreadList (threads) - Manages the list of conversations
    ├── 📄 ThreadListItem (threadListItem) - Individual thread in the list
    └── 💬 Thread (thread) - Active conversation with messages
        ├── 🔵 Message (message) - User or assistant message
        │   ├── 📝 Part (part) - Content within a message (text, tool calls, etc.)
        │   ├── 🧠 ChainOfThought (chainOfThought) - Reasoning steps within a message
        │   │   └── 📝 Part (part) - Individual reasoning/tool-call step
        │   ├── 📎 Attachment (attachment) - Files attached to messages
        │   └── ✏️  Composer (composer) - Edit mode for existing messages
        │       └── 📎 Attachment (attachment) - Files in edit mode
        └── ✏️  Composer (composer) - New message input
            └── 📎 Attachment (attachment) - Files being added

💡 Suggestions (suggestions) - Follow-up message suggestions
    └── 💬 Suggestion (suggestion) - Individual suggestion item
🔧 Tools (tools) - Custom UI components for tool calls
🧩 ModelContext (modelContext) - Model context and tool registration

How scopes work:

  • Scopes are determined by where your component is rendered
  • A button inside a <ThreadPrimitive.Messages> gets message scope
  • A button inside a <ComposerPrimitive.Attachments> gets attachment scope
  • Child scopes can access parent scope data (e.g., a message component can access thread data)
// Inside a message component
function MessageButton() {
  // ✅ Available: message scope (current message)
  const role = useAuiState((s) => s.message.role);

  // ✅ Available: thread scope (parent)
  const isRunning = useAuiState((s) => s.thread.isRunning);
}

State Management Model

The Context API separates four concerns:

  1. State is immutable and flows down through scopes
  2. Actions are methods that trigger state changes
  3. Events notify components of state changes and user interactions
  4. Subscriptions let components react to changes

Essential Hooks

useAuiState

Read state and re-render when the selected value changes. This hook works like Zustand's selector pattern: you provide a function that extracts the data you need, and your component re-renders only when that data changes.

import { useAuiState } from "@assistant-ui/react";

// Basic usage - extract a single property
const role = useAuiState((s) => s.message.role); // "user" | "assistant"
const isRunning = useAuiState((s) => s.thread.isRunning); // boolean

// Access nested data
const attachmentCount = useAuiState(
  (s) => s.composer.attachments.length,
);
const lastMessage = useAuiState((s) => s.thread.messages.at(-1));

The selector function receives all scopes available at your component's location.

Common patterns:

// Access multiple scopes
const canSend = useAuiState(
  (s) => !s.thread.isRunning && s.composer.text.length > 0,
);

// Compute derived state
const messageCount = useAuiState((s) => s.thread.messages.length);

Important: Never create new objects in selectors. Return primitive values or stable references to avoid infinite re-renders.

// ❌ Bad - creates new object every time
const data = useAuiState((s) => ({
  role: s.message.role,
  content: s.message.content,
}));

// ✅ Good - returns stable values
const role = useAuiState((s) => s.message.role);
const content = useAuiState((s) => s.message.content);

useAui

Access the API instance for imperative operations and actions. Unlike useAuiState, this hook does not subscribe to state updates. The client's identity changes only on structural changes (a scope resolving to a different instance), so it suits event handlers and imperative operations.

import { useAui } from "@assistant-ui/react";

function CustomMessageActions() {
  const aui = useAui();

  // Perform actions in event handlers
  const handleSend = () => {
    aui.composer.send();
  };

  const handleReload = () => {
    aui.message.reload();
  };

  // Read state imperatively when needed
  const handleConditionalAction = () => {
    const { isRunning } = aui.thread.getState();
    const { text } = aui.composer.getState();

    if (!isRunning && text.length > 0) {
      aui.composer.send();
    }
  };

  return (
    <div>
      <button onClick={handleSend}>Send</button>
      <button onClick={handleReload}>Reload</button>
      <button onClick={handleConditionalAction}>Smart Send</button>
    </div>
  );
}

Use the API object for:

  • Triggering actions in event handlers and callbacks
  • Reading current state imperatively when you don't need subscriptions
  • Accessing nested scopes programmatically
  • Checking scope availability before performing actions

Available actions by scope:

// Thread actions
aui.thread.append(message);
aui.thread.startRun(config);
aui.thread.resumeRun(config);
aui.thread.cancelRun();
aui.thread.loadEarlier();
aui.thread.getState();
aui.thread.message({ index: idx });
aui.thread.message({ id: messageId });
aui.thread.composer();

// Message actions
aui.message.reload();
aui.message.speak();
aui.message.stopSpeaking();
aui.message.submitFeedback({ type: "positive" });
aui.message.submitFeedback({ type: "negative", comment: "Quoted the wrong date" });
aui.message.switchToBranch({ position, branchId });
aui.message.getState();
aui.message.part({ index: idx });
aui.message.part({ toolCallId });
aui.message.composer();

// Part actions
aui.part.addToolResult(result);
aui.part.resumeToolCall(result);
aui.part.getState();

// Composer actions
aui.composer.send();
aui.composer.setText(text);
aui.composer.setRole(role);
aui.composer.addAttachment(file); // File object
aui.composer.addAttachment({ name, content }); // external source
await aui.composer.clearAttachments();
await aui.composer.reset();
aui.composer.getState();

// Attachment actions
aui.attachment.remove();
aui.attachment.getState();

// ThreadList actions
aui.threads.switchToNewThread();
aui.threads.switchToThread(threadId);
aui.threads.reload();
await aui.threads.getLoadThreadsPromise();
aui.threads.getState();

// ThreadListItem actions
aui.threadListItem.switchTo();
aui.threadListItem.rename(title);
aui.threadListItem.updateCustom(custom);
aui.threadListItem.archive();
aui.threadListItem.unarchive();
aui.threadListItem.delete();
aui.threadListItem.getState();

// Suggestions actions
aui.suggestions.getState();
aui.suggestions.suggestion({ index: 0 });

// Suggestion actions
aui.suggestion.getState();

// ChainOfThought actions
aui.chainOfThought.getState();
aui.chainOfThought.setCollapsed(collapsed);
aui.chainOfThought.part({ index: 0 });

// ModelContext actions — see /docs/copilots/model-context for full usage
aui.modelContext.register(provider);
aui.modelContext.getState();

// Tools actions
aui.tools.setToolUI(toolName, render);
aui.tools.getState();

useAuiEvent

Subscribe to events. The subscription is removed on unmount. Use this hook to run side effects on user interactions and system events, such as analytics tracking.

import { useAuiEvent } from "@assistant-ui/react";

// Listen to current scope events (most common)
useAuiEvent("composer.send", (event) => {
  console.log("Composer sent message in thread:", event.threadId);
});

// Listen to thread events
useAuiEvent("thread.modelContextUpdate", (event) => {
  console.log("Model context updated in thread:", event.threadId);
});

// Listen to all events of a type across all scopes
useAuiEvent({ event: "composer.send", scope: "*" }, (event) => {
  console.log("Any composer sent a message:", event.threadId);
});

// Listen to ALL events (useful for debugging or analytics)
useAuiEvent("*", (event) => {
  console.log("Event occurred:", event.event, event.payload);
});

// Practical example: Track user interactions
function AnalyticsTracker() {
  useAuiEvent("composer.send", (event) => {
    analytics.track("message_sent", {
      threadId: event.threadId,
    });
  });

  return null; // This component only tracks events
}

Event name patterns:

  • Event names follow source.action format in camelCase (e.g., composer.send, thread.runStart)
  • Use "*" as the event name to listen to all events
  • The scope parameter controls which instances trigger the event

Working with Scopes

Available Scopes

Each scope provides access to specific state and actions:

  • ThreadList (threads): Collection and management of threads
  • ThreadListItem (threadListItem): Individual thread in the list
  • Thread (thread): Conversation with messages
  • Task (task): Tool call in the thread that carries a nested conversation
  • Message (message): Individual message (user or assistant)
  • Part (part): Content part within a message (text, tool calls, etc.)
  • ChainOfThought (chainOfThought): Reasoning steps grouped within a message
  • Composer (composer): Text input for sending or editing messages
  • QueueItem (queueItem): Message queued in a composer
  • Attachment (attachment): File or media attached to a message or composer
  • Suggestions (suggestions): Collection of follow-up message suggestions
  • Suggestion (suggestion): Individual follow-up suggestion
  • Tools (tools): Tool UI components
  • ModelContext (modelContext): Model context and tool registration

Scope Resolution

The Context API resolves the current scope from the component's location:

function MessageButton() {
  const aui = useAui();

  // Automatically uses the current message scope
  const handleReload = () => {
    aui.message.reload();
  };

  return <button onClick={handleReload}>Reload</button>;
}

Checking Scope Availability

Read scopes that may be unavailable through the optional view, where they resolve to undefined instead of throwing:

const role = useAuiState((s) => s.optional.message?.role);

Accessing Nested Scopes

Navigate through the scope hierarchy programmatically:

const aui = useAui();

// Access specific message by ID or index
const messageById = aui.thread.message({ id: "msg_123" });
const messageByIndex = aui.thread.message({ index: 0 });

// Access part by index or tool call ID
const partByIndex = aui.message.part({ index: 0 });
const partByToolCall = aui.message.part({ toolCallId: "call_123" });

// Access attachment by index
const attachment = aui.composer.attachment({ index: 0 }).getState();

// Access thread list item by ID, index, or the "main" selector
const threadItem = aui.threads.item({ id: "thread_123" });
const threadByIndex = aui.threads.item({ index: 0 });
const archivedThread = aui.threads.item({ index: 0, archived: true });

// Traverse to the main thread directly
const mainThread = aui.threads.thread("main");
const message = aui.threads.thread("main").message({ id: "msg_123" });

Common Patterns

Conditional Rendering

function RunIndicator() {
  const isRunning = useAuiState((s) => s.thread.isRunning);

  if (!isRunning) return null;
  return <div>Assistant is thinking...</div>;
}

Custom Action Buttons

function CopyButton() {
  const aui = useAui();

  const handleCopy = () => {
    navigator.clipboard.writeText(aui.message.getCopyText());
  };

  return <button onClick={handleCopy}>Copy</button>;
}

State-Aware Components

function SmartComposer() {
  const aui = useAui();
  const isRunning = useAuiState((s) => s.thread.isRunning);
  const text = useAuiState((s) => s.composer.text);

  const canSend = !isRunning && text.length > 0;

  return (
    <div>
      <textarea
        value={text}
        onChange={(e) => aui.composer.setText(e.target.value)}
        disabled={isRunning}
      />
      <button onClick={() => aui.composer.send()} disabled={!canSend}>
        Send
      </button>
    </div>
  );
}

Event-Driven Updates

function MessageCounter() {
  const [sendCount, setSendCount] = useState(0);

  useAuiEvent("composer.send", () => {
    setSendCount((c) => c + 1);
  });

  return <div>Messages sent: {sendCount}</div>;
}

Advanced Topics

Resolution Dynamics

Scopes are resolved during render and bound into the client. State updates never change the client's identity; a structural change (for example, switching threads) produces a new client and re-renders consumers through React:

const aui = useAui();

// Bound to the thread current at this render
aui.thread.append({ role: "user", content: "Hello" });

// After a thread switch, the component re-renders with a new client.
// Accessors read from a previous render keep pointing at the old thread.

Performance Optimization

Selector optimization:

// ❌ Expensive computation in selector (runs on every update of the thread)
const result = useAuiState(
  (s) => s.thread.messages.filter((m) => m.role === "user").length,
);

// ✅ Memoize expensive computations
const messages = useAuiState((s) => s.thread.messages);
const userCount = useMemo(
  () => messages.filter((m) => m.role === "user").length,
  [messages],
);

Minimize re-renders:

// ❌ Subscribes to entire thread state
const thread = useAuiState((s) => s.thread);

// ✅ Subscribe only to needed values
const isRunning = useAuiState((s) => s.thread.isRunning);

API Reference

Hooks

HookPurposeReturns
useAuiState(selector)Subscribe to state changesSelected value
useAui()Get API instanceAPI object
useAuiEvent(event, handler)Subscribe to eventsvoid

Scope States

ScopeKey State PropertiesDescription
ThreadListmainThreadId, newThreadId, threadIds, archivedThreadIds, isLoading, threadItems (readonly ThreadListItemState[])Manages all available conversation threads
ThreadListItemid, title, status, remoteId, externalId, custom?: Record<string, unknown>Individual thread metadata and status; custom carries arbitrary per-thread metadata set by remote runtimes
ThreadisRunning (may be explicitly set by the runtime rather than derived from last-message status), isLoading, isDisabled, isEmpty, hasEarlier, isLoadingEarlier, messages, tasks, capabilities, suggestionsActive conversation state and message history
Messagerole, content, status, attachments, parts, parentId, branchNumber, branchCount, isLast, indexIndividual message content and metadata
Parttype, status, text, toolCallId, toolNameContent parts within messages (text, tool calls)
ChainOfThoughtparts, collapsed, statusReasoning steps grouped within a message
Composertext, role, attachments, isEmpty, canCancel, type, isEditingText input state for new/edited messages
Attachmentid, type, name, contentType, statusFile attachments metadata and content
SuggestionssuggestionsCollection of follow-up message suggestions
Suggestiontitle, label, promptIndividual suggestion with title, label, and prompt
ModelContextmodelName, toolNamesModel name and tool names from the merged model context; see Model Context

Available Actions by Scope

The table below covers the most commonly used actions. For the full catalog, see the API Reference.

ScopeActionsUse Cases
ThreadListswitchToNewThread(), switchToThread(id), reload(), getLoadThreadsPromise(), item(selector), thread("main"), getState()Thread navigation, creation, and sync
ThreadListItemswitchTo(), rename(title), updateCustom(custom), archive(), unarchive(), delete(), getState()Thread management operations
Threadappend(message), startRun(config), resumeRun(config), cancelRun(), loadEarlier(), reset(), export(), import(repository), message(selector), task(selector), composer(), getState()Message handling and conversation control
Messagereload(), speak(), stopSpeaking(), submitFeedback(feedback), switchToBranch(options), getCopyText(), part(selector), attachment(selector), composer(), setIsCopied(value), setIsHovering(value), getState()Message interactions and regeneration
PartaddToolResult(result), resumeToolCall(result), getState()Tool call result handling
ChainOfThoughtsetCollapsed(collapsed), part({ index }), getState()Expand/collapse reasoning steps
Composersend(), setText(text), setRole(role), addAttachment(file | attachment), clearAttachments() (async), reset() (async), getState()Text input and message composition
Attachmentremove(), getState()File management
Suggestionssuggestion({ index }), getState()Access follow-up suggestions
SuggestiongetState()Read individual suggestion data
ModelContextregister(provider), getModelContext(), getState()Register providers; full details in Model Context

Events vs State Observation

Use useAuiState to render the current value of something; use useAuiEvent to react to the moment something happens. Both are supported: many events mark a transition you could also observe in state, and subscribing to the event is the intended way to run a side effect at that transition.

The rule of thumb:

  1. Rendering a value? → use useAuiState.
  2. Are you the caller and want immediate feedback? → catch the rejection / read the return value.
  3. Running a side effect when something happens? → use useAuiEvent.

Available Events

EventWhen It Fires
threads.selectionChangedThe main thread selection changed. Payload carries threadId (newly selected) and previousThreadId. Does not fire for the initially selected thread on mount.
thread.runStart / runEndA run started / ended on the thread (also visible as isRunning flipping)
thread.cancelRunThe user stopped the run in progress on the thread
thread.initializeThe thread transitioned from new to initialized, before its first message is sent or its first run starts; the initialMessages a local runtime starts with do not initialize it
thread.modelContextUpdateThe model context provider notified a change. The model context lives in a provider, not in thread state, so this event has no state-derivable equivalent.
composer.sendThe user sent the composer contents; messageId is set for edit-composer sends
composer.attachmentAddAn attachment was added to the composer
composer.attachmentAddErrorAn addAttachment() call failed. Payload reason is no-adapter, not-accepted, or adapter-error, and message is a human-readable description. The underlying Error instance is available only through the low-level runtime.unstable_on("attachmentAddError") API, because raw Error objects are not store-serializable.
composer.textChangeA write changed the composer text. Payload text is the text that write left and origin names the writer: user (typing, dictation), recall (input history), send (the clear when the draft goes into the thread), return (a failed or cancelled send coming back), reset, or external (any other setText, which accepts { origin } as a second argument)
composer.cancelThe composer cancelled the run in progress
message.reloadThe user asked for the assistant message to be generated again
message.branchSwitchedThe user moved to another branch at the message
message.copiedThe user copied the message
message.speakSpeech synthesis started for the message
message.errorThe message entered an error status
thread.toolApprovalAnsweredA tool approval request was answered. Payload carries messageId, toolCallId, toolName, and approved.
thread.historyWriteErrorA local runtime history write failed. Payload carries threadId, operation (append, update, or delete), affected messageIds, and a readable message. The underlying error is available only through the low-level runtime.unstable_on("historyWriteError") API. The stored history may differ from the thread.
thread.voiceStartedThe user started a voice session on the thread

Deprecated Events

Deprecated EventUse Instead
threadListItem.switchedTothreads.selectionChanged; its threadId is the newly selected thread
threadListItem.switchedAwaythreads.selectionChanged; its previousThreadId is the thread switched away from

Troubleshooting

Common Errors

"The current scope does not have a "message" property."

// ❌ This will throw if not inside a message component
const role = useAuiState((s) => s.message.role);

// ✅ Read through the optional view — unavailable scopes resolve to undefined
const role = useAuiState((s) => s.optional.message?.role ?? "none");

A component that renders outside AssistantRuntimeProvider has no scopes at all, and the error asks for an AuiProvider instead. Move it under the provider or publish the client to it; see Reading State Outside the Thread.

"Maximum update depth exceeded" / Infinite re-renders

// ❌ Creating new objects in selectors causes infinite re-renders
const data = useAuiState((s) => ({
  role: s.message.role,
  content: s.message.content, // New object every time!
}));

// ✅ Return primitive values or use separate selectors
const role = useAuiState((s) => s.message.role);
const content = useAuiState((s) => s.message.content);

Stale scope references

// ❌ Storing scope references can lead to stale data
const aui = useAui();
const thread = aui.thread; // This reference might become stale

useEffect(() => {
  // This might reference the wrong thread if user switched
  thread.cancelRun();
}, [thread]);

// ✅ Resolve scopes fresh each time
const aui = useAui();

useEffect(() => {
  // Always gets the current thread
  aui.thread.cancelRun();
}, [aui]);

Quick Reference

// Read state
const value = useAuiState((s) => s.scope.property);

// Perform action
const aui = useAui();
aui.scope.action();

// Listen to events
useAuiEvent("source.event", (e) => {});

// Read a scope that may be unavailable
const maybe = useAuiState((s) => s.optional.scope?.property);

// Get state imperatively
const state = aui.scope.getState();

// Navigate scopes
aui.thread.message({ id: "..." }).getState();