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 returns a stable object that never changes, making it perfect 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 is stable and doesn't cause re-renders. 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
Before accessing a scope, check if it's available:
const aui = useAui();
// Check if message scope exists
if (aui.message.source) {
// Safe to use message scope
const { role } = aui.message().getState();
}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
When you call aui.scope(), the API resolves the current scope at that moment. This resolution happens each time you call the function, which matters when dealing with changing contexts:
const aui = useAui();
// Get current thread
const thread1 = aui.thread();
thread1.append({ role: "user", content: "Hello" });
// User might switch threads here
// This could be a different thread
const thread2 = aui.thread();
thread2.cancelRun(); // Cancels the current thread's run, not necessarily thread1'sFor most use cases, this behavior is intuitive. In advanced scenarios where you need to track specific instances, store the resolved reference.
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
useAuiEvent is the escape hatch for transient occurrences that are not derivable from state. State-derivable transitions (attachment list changing, run progress, thread switching) should be observed with useAuiState, not subscribed via events.
The rule of thumb:
- Can you read the new value from state right now? → use
useAuiState. - Are you the caller and want immediate feedback? → catch the rejection / read the return value.
- Did something happen that has no representation in state at all? → use
useAuiEvent.
Most existing events are kept for backward compatibility but duplicate state. They are marked @deprecated in the type definitions; new code should follow the rule above.
Currently Recommended (Truly Transient)
| Event | When It Fires |
|---|---|
composer.attachmentAddError | An addAttachment() call failed. Payload reason discriminates no-adapter / not-accepted / adapter-error. no-adapter and not-accepted are non-state-derivable. adapter-error is partially state-derivable: if the adapter produced any attachment before failing, the errored attachment also appears in composer.attachments with status.reason === "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). |
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. |
Legacy (State-Derivable, Prefer useAuiState)
These events fire at the same transition you can observe via state. They are kept for backward compatibility but new code should observe state instead.
| Legacy Event | Observe Instead |
|---|---|
composer.send | composer text clearing |
composer.attachmentAdd | composer attachments |
thread.runStart / runEnd | thread isRunning flipping to true / false |
thread.initialize | thread messages becoming non-empty (or isEmpty flipping) |
threadListItem.switchedTo / switchedAway | compare s.threads.mainThreadId against s.threadListItem.id |
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);
// ✅ Check scope availability first
function SafeMessageButton() {
const aui = useAui();
const role = useAuiState((s) =>
aui.message.source !== undefined ? s.message.role : "none",
);
return <div>Role: {role}</div>;
}"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) => {});
// Check scope availability
if (aui.scope.source) {
/* scope exists */
}
// Get state imperatively
const state = aui.scope().getState();
// Navigate scopes
aui.thread().message({ id: "..." }).getState();