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 provides direct access to assistant-ui's state management system, enabling you to build custom components that integrate seamlessly with the assistant runtime.
Introduction
The Context API is assistant-ui's powerful state management system that enables you to build custom components with full access to the assistant's state and capabilities. It 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 automatically know their context (message, thread, etc.)
It's the foundation that powers all assistant-ui primitives. When the built-in components don't meet your needs, you can use the Context API to create custom components with the same capabilities.
The Context API is backed by the runtime you provide to <AssistantRuntimeProvider>. This runtime acts as a unified store that manages all assistant state, handles actions, and dispatches events across your entire application.
Core Concepts
Scopes and Hierarchy
assistant-ui organizes state into scopes - logical boundaries that provide access to relevant data and actions. Each scope corresponds to a specific part of the chat interface and automatically provides context-aware functionality.
🗂️ 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 registrationHow scopes work:
- Scopes are automatically determined by where your component is rendered
- A button inside a
<ThreadPrimitive.Messages>automatically getsmessagescope - A button inside a
<ComposerPrimitive.Attachments>automatically getsattachmentscope - Child scopes can access parent scope data (e.g., a
messagecomponent can accessthreaddata)
// 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 follows a predictable state management pattern:
- State is immutable and flows down through scopes
- Actions are methods that trigger state changes
- Events notify components of state changes and user interactions
- Subscriptions let components react to changes
Essential Hooks
useAuiState
Read state reactively with automatic re-renders when values change. This hook works like Zustand's selector pattern - you provide a function that extracts the specific data you need, and your component only re-renders 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 available scopes for your component's location and should return a specific value. The component re-renders only when that returned value changes.
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 only changes on structural changes (a scope resolving to a different instance), making it well suited for 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>
);
}The API object doesn't cause re-renders on state updates. Use it 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.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" | "negative" });
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 with automatic cleanup on unmount. This hook is perfect for reacting to user interactions, system events, or integrating with external analytics.
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.actionformat in camelCase (e.g.,composer.send,thread.runStart) - Use
"*"as the event name to listen to all events - The
scopeparameter 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 - 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 - 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 automatically resolves the current scope based on component 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 — 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 store update)
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
| Hook | Purpose | Returns |
|---|---|---|
useAuiState(selector) | Subscribe to state changes | Selected value |
useAui() | Get API instance | API object |
useAuiEvent(event, handler) | Subscribe to events | void |
Scope States
| Scope | Key State Properties | Description |
|---|---|---|
| ThreadList | mainThreadId, newThreadId, threadIds, archivedThreadIds, isLoading, threadItems (readonly ThreadListItemState[]) | Manages all available conversation threads |
| ThreadListItem | id, title, status, remoteId, externalId, custom?: Record<string, unknown> | Individual thread metadata and status; custom carries arbitrary per-thread metadata set by remote runtimes |
| Thread | isRunning (may be explicitly set by the runtime rather than derived from last-message status), isLoading, isDisabled, isEmpty, messages, capabilities, suggestions | Active conversation state and message history |
| Message | role, content, status, attachments, parts, parentId, branchNumber, branchCount, isLast, index | Individual message content and metadata |
| Part | type, status, text, toolCallId, toolName | Content parts within messages (text, tool calls) |
| ChainOfThought | parts, collapsed, status | Reasoning steps grouped within a message |
| Composer | text, role, attachments, isEmpty, canCancel, type, isEditing | Text input state for new/edited messages |
| Attachment | id, type, name, contentType, status | File attachments metadata and content |
| Suggestions | suggestions | Collection of follow-up message suggestions |
| Suggestion | title, label, prompt | Individual suggestion with title, label, and prompt |
| ModelContext | (empty — use register() / getToolCallParams() methods; see Model Context) | System instructions, tools, and context providers |
Available Actions by Scope
The table below covers the most commonly used actions. For the full catalog, see the API Reference.
| Scope | Actions | Use Cases |
|---|---|---|
| ThreadList | switchToNewThread(), switchToThread(id), reload(), getLoadThreadsPromise(), item(selector), thread("main"), getState() | Thread navigation, creation, and sync |
| ThreadListItem | switchTo(), rename(title), updateCustom(custom), archive(), unarchive(), delete(), getState() | Thread management operations |
| Thread | append(message), startRun(config), resumeRun(config), cancelRun(), reset(), export(), import(repository), message(selector), composer(), getState() | Message handling and conversation control |
| Message | reload(), speak(), stopSpeaking(), submitFeedback(feedback), switchToBranch(options), getCopyText(), part(selector), attachment(selector), composer(), setIsCopied(value), setIsHovering(value), getState() | Message interactions and regeneration |
| Part | addToolResult(result), resumeToolCall(result), getState() | Tool call result handling |
| ChainOfThought | setCollapsed(collapsed), part({ index }), getState() | Expand/collapse reasoning steps |
| Composer | send(), setText(text), setRole(role), addAttachment(file | attachment), clearAttachments() (async), reset() (async), getState() | Text input and message composition |
| Attachment | remove(), getState() | File management |
| Suggestions | suggestion({ index }), getState() | Access follow-up suggestions |
| Suggestion | getState() | Read individual suggestion data |
| ModelContext | register(provider), 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:
- Rendering a value? → use
useAuiState. - Are you the caller and want immediate feedback? → catch the rejection / read the return value.
- Running a side effect when something happens? → use
useAuiEvent.
Available Events
| Event | When It Fires |
|---|---|
threads.selectionChanged | The main thread selection changed. Payload carries threadId (newly selected) and previousThreadId. Does not fire for the initially selected thread on mount. |
thread.runStart / runEnd | A run started / ended on the thread (also visible as isRunning flipping) |
thread.initialize | The thread transitioned from new to initialized, before the first message is added |
thread.modelContextUpdate | The 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.send | The user sent the composer contents; messageId is set for edit-composer sends |
composer.attachmentAdd | An attachment was added to the composer |
composer.attachmentAddError | An addAttachment() call failed. Payload reason discriminates no-adapter / not-accepted / adapter-error. The event additionally surfaces a human-readable message (and the underlying Error instance via the low-level runtime.unstable_on("attachmentAddError") API; useAuiEvent payloads omit it because raw Error objects are not store-serializable). |
Deprecated Events
| Deprecated Event | Use Instead |
|---|---|
threadListItem.switchedTo | threads.selectionChanged — its threadId is the newly selected thread |
threadListItem.switchedAway | threads.selectionChanged — its previousThreadId is the thread switched away from |
Troubleshooting
Common Errors
"Cannot access [scope] outside of [scope] context"
// ❌ 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");"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);"Scope resolution failed" / 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();