logoassistant-ui

Context API

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.)
        │   ├── 📎 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

🔧 ToolUIs (toolUIs) - Custom UI components for tool calls

How scopes work:

  • Scopes are automatically determined by where your component is rendered
  • A button inside a <ThreadPrimitive.Messages> automatically gets message scope
  • A button inside a <ComposerPrimitive.Attachments> automatically 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 = useAssistantState(({ message }) => message.role);

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

State Management Model

The Context API follows a predictable state management pattern:

  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

useAssistantState

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 { useAssistantState } from "@assistant-ui/react";

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

// Access nested data
const attachmentCount = useAssistantState(
  ({ composer }) => composer.attachments.length,
);
const lastMessage = useAssistantState(({ thread }) => 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 = useAssistantState(
  ({ thread, composer }) => !thread.isRunning && composer.text.length > 0,
);

// Compute derived state
const messageCount = useAssistantState(({ thread }) => 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 = useAssistantState(({ message }) => ({
  role: message.role,
  content: message.content,
}));

// ✅ Good - returns stable values
const role = useAssistantState(({ message }) => message.role);
const content = useAssistantState(({ message }) => message.content);

useAssistantApi

Access the API instance for imperative operations and actions. Unlike useAssistantState, this hook returns a stable object that never changes, making it perfect for event handlers and imperative operations.

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

function CustomMessageActions() {
  const api = useAssistantApi();

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

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

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

    if (!isRunning && text.length > 0) {
      api.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
api.thread().append(message);
api.thread().startRun(config);
api.thread().cancelRun();
api.thread().switchToNewThread();
api.thread().switchToThread(threadId);
api.thread().getState();
api.thread().message(idOrIndex);
api.thread().composer;

// Message actions
api.message().reload();
api.message().speak();
api.message().stopSpeaking();
api.message().submitFeedback({ type: "positive" | "negative" });
api.message().switchToBranch({ position, branchId });
api.message().getState();
api.message().part(indexOrToolCallId);
api.message().composer;

// Part actions
api.part().addResult(result);
api.part().getState();

// Composer actions
api.composer().send();
api.composer().setText(text);
api.composer().setRole(role);
api.composer().addAttachment(file);
api.composer().clearAttachments();
api.composer().reset();
api.composer().getState();

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

// ThreadList actions
api.threads().switchToNewThread();
api.threads().switchToThread(threadId);
api.threads().getState();

// ThreadListItem actions
api.threadListItem().switchTo();
api.threadListItem().rename(title);
api.threadListItem().archive();
api.threadListItem().unarchive();
api.threadListItem().delete();
api.threads().getState();

// ToolUIs actions
api.toolUIs().setToolUI(toolName, render);
api.toolUIs().getState();

useAssistantEvent

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 { useAssistantEvent } from "@assistant-ui/react";

// Listen to current scope events (most common)
useAssistantEvent("composer.send", (event) => {
  console.log("Current composer sent message:", event.message);
});

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

// Listen to ALL events (useful for debugging or analytics)
useAssistantEvent("*", (event) => {
  console.log("Event occurred:", event.type, "from:", event.source);
  // Send to analytics, logging, etc.
});

// Practical example: Track user interactions
function AnalyticsTracker() {
  useAssistantEvent("composer.send", (event) => {
    analytics.track("message_sent", {
      messageLength: event.message.content.length,
      hasAttachments: event.message.attachments.length > 0,
    });
  });

  return null; // This component only tracks events
}

Event name patterns:

  • Event names follow source.action format (e.g., composer.send, thread.run-start)
  • 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
  • Message (message): Individual message (user or assistant)
  • Part (part): Content part within a message (text, tool calls, etc.)
  • Composer (composer): Text input for sending or editing messages
  • Attachment (attachment): File or media attached to a message or composer
  • ToolUIs (toolUIs): Tool UI components

Scope Resolution

The Context API automatically resolves the current scope based on component location:

function MessageButton() {
  const api = useAssistantApi();

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

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

Checking Scope Availability

Before accessing a scope, check if it's available:

const api = useAssistantApi();

// Check if message scope exists
if (api.message.source) {
  // Safe to use message scope
  const { role } = api.message().getState();
}

Accessing Nested Scopes

Navigate through the scope hierarchy programmatically:

const api = useAssistantApi();

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

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

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

// Access thread from thread list
const thread = api.threads().thread("main");
const threadItem = api.threads().item({ id: "thread_123" });

Common Patterns

Conditional Rendering

function RunIndicator() {
  const isRunning = useAssistantState(({ thread }) => thread.isRunning);

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

Custom Action Buttons

function CopyButton() {
  const api = useAssistantApi();

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

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

State-Aware Components

function SmartComposer() {
  const api = useAssistantApi();
  const isRunning = useAssistantState(({ thread }) => thread.isRunning);
  const text = useAssistantState(({ composer }) => composer.text);

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

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

Event-Driven Updates

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

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

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

Advanced Topics

Resolution Dynamics

When you call api.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 api = useAssistantApi();

// Get current thread
const thread1 = api.thread();
thread1.append({ role: "user", content: "Hello" });

// User might switch threads here

// This could be a different thread
const thread2 = api.thread();
thread2.cancelRun(); // Cancels the current thread's run, not necessarily thread1's

For 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 = useAssistantState(
  ({ thread }) => thread.messages.filter((m) => m.role === "user").length,
);

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

Minimize re-renders:

// ❌ Subscribes to entire thread state
const thread = useAssistantState(({ thread }) => thread);

// ✅ Subscribe only to needed values
const isRunning = useAssistantState(({ thread }) => thread.isRunning);

API Reference

Hooks

HookPurposeReturns
useAssistantState(selector)Subscribe to state changesSelected value
useAssistantApi()Get API instanceAPI object
useAssistantEvent(event, handler)Subscribe to eventsvoid

Scope States

ScopeKey State PropertiesDescription
ThreadListmainThreadId, threadIds, isLoading, threadItemsManages all available conversation threads
ThreadListItemid, title, status, remoteId, externalIdIndividual thread metadata and status
ThreadisRunning, isLoading, isDisabled, messages, capabilities, suggestionsActive conversation state and message history
Messagerole, content, status, attachments, parentId, branchNumber, isLastIndividual message content and metadata
Composertext, role, attachments, isEmpty, canCancel, type, isEditingText input state for new/edited messages
Parttype, content, status, text, toolCallId, toolNameContent parts within messages (text, tool calls)
Attachmentid, type, name, url, size, mimeTypeFile attachments metadata and content

Available Actions by Scope

ScopeActionsUse Cases
ThreadListswitchToNewThread(), switchToThread(id), getState()Thread navigation and creation
ThreadListItemswitchTo(), rename(title), archive(), unarchive(), delete()Thread management operations
Threadappend(message), startRun(), cancelRun(), switchToNewThread()Message handling and conversation control
Messagereload(), speak(), stopSpeaking(), submitFeedback(feedback)Message interactions and regeneration
Composersend(), setText(text), addAttachment(file), reset()Text input and message composition
PartaddResult(result), getState()Tool call result handling
Attachmentremove(), getState()File management

Common Events

EventDescription
thread.run-startAssistant starts generating
thread.run-endAssistant finishes generating
thread.initializeThread is initialized
thread.model-context-updateModel context is updated
composer.sendMessage is sent
composer.attachment-addAttachment added to composer
thread-list-item.switched-toSwitched to a thread
thread-list-item.switched-awaySwitched away from a thread

Troubleshooting

Common Errors

"Cannot access [scope] outside of [scope] context"

// ❌ This will throw if not inside a message component
const role = useAssistantState(({ message }) => message.role);

// ✅ Check scope availability first
function SafeMessageButton() {
  const api = useAssistantApi();

  const role = useAssistantState(({ message }) =>
    api.message.source !== undefined ? 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 = useAssistantState(({ message }) => ({
  role: message.role,
  content: message.content, // New object every time!
}));

// ✅ Return primitive values or use separate selectors
const role = useAssistantState(({ message }) => message.role);
const content = useAssistantState(({ message }) => message.content);

"Scope resolution failed" / Stale scope references

// ❌ Storing scope references can lead to stale data
const api = useAssistantApi();
const thread = api.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 api = useAssistantApi();

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

Quick Reference

// Read state
const value = useAssistantState(({ scope }) => scope.property);

// Perform action
const api = useAssistantApi();
api.scope().action();

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

// Check scope availability
if (api.scope.source) {
  /* scope exists */
}

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

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