Let users type / in the composer to trigger predefined actions from a popover picker.
Slash commands let users type / in the composer to open a popover, browse available commands, and execute one. Unlike mentions (which insert text into the message), slash commands trigger actions — the /command text is removed from the composer and a callback fires.
How It Works
User types "/" → Trigger detected → Adapter provides commands
↓
Command executed ← User selects command from popover
↓
"/command" text removed from composerThe slash command system is built on the same trigger popover architecture as mentions. It has two layers:
- Adapter — provides the list of available commands (flat list by default, or categorized for advanced use)
- SlashCommandRoot — a convenience wrapper that pre-configures the trigger character (
/) and action-based select behavior
Quick Start
1. Define Commands
import {
unstable_useSlashCommandAdapter,
ComposerPrimitive,
} from "@assistant-ui/react";
// Define commands outside the component for a stable reference
const COMMANDS = [
{
name: "summarize",
description: "Summarize the conversation",
execute: () => console.log("Summarize!"),
},
{
name: "translate",
description: "Translate text to another language",
execute: () => console.log("Translate!"),
},
{
name: "help",
description: "List all available commands",
},
];
function MyComposer() {
const slashAdapter = unstable_useSlashCommandAdapter({
commands: COMMANDS,
});
return (
<ComposerPrimitive.Unstable_SlashCommandRoot adapter={slashAdapter}>
<ComposerPrimitive.Root>
<ComposerPrimitive.Input placeholder="Type / for commands..." />
<ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
<ComposerPrimitive.Unstable_TriggerPopoverPopover className="popover">
<ComposerPrimitive.Unstable_TriggerPopoverItems>
{(items) =>
items.map((item, index) => (
<ComposerPrimitive.Unstable_TriggerPopoverItem
key={item.id}
item={item}
index={index}
className="popover-item"
>
<strong>{item.label}</strong>
{item.description && <span>{item.description}</span>}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
))
}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopoverPopover>
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_SlashCommandRoot>
);
}2. Handle Command Selection
There are two ways to handle command execution:
Via execute on each item — define the action inline in the command definition:
{ name: "summarize", execute: () => runSummarize() }Via onSelect prop — handle all commands in one place:
<ComposerPrimitive.Unstable_SlashCommandRoot
adapter={slashAdapter}
onSelect={(item) => {
switch (item.id) {
case "summarize": runSummarize(); break;
case "translate": runTranslate(); break;
}
}}
>Both execute and onSelect fire when a command is selected. Use whichever pattern fits your code.
Custom Adapter
The unstable_useSlashCommandAdapter hook uses a flat list — all commands show immediately when / is typed, with search filtering as the user types. This is the recommended UX for most cases.
For categorized navigation (drill-down into groups), build the adapter manually. Return categories from categories() and items from categoryItems(). The popover will show categories first, then items within the selected category:
import type { Unstable_SlashCommandAdapter } from "@assistant-ui/core";
const adapter: Unstable_SlashCommandAdapter = {
categories() {
return [
{ id: "actions", label: "Actions" },
{ id: "export", label: "Export" },
];
},
categoryItems(categoryId) {
if (categoryId === "actions") {
return [
{ id: "summarize", type: "command", label: "/summarize", description: "Summarize the conversation" },
{ id: "translate", type: "command", label: "/translate", description: "Translate text" },
];
}
if (categoryId === "export") {
return [
{ id: "pdf", type: "command", label: "/export pdf", description: "Export as PDF" },
{ id: "markdown", type: "command", label: "/export md", description: "Export as Markdown" },
];
}
return [];
},
// Optional — enables search across all categories
search(query) {
const lower = query.toLowerCase();
const all = [...this.categoryItems("actions"), ...this.categoryItems("export")];
return all.filter(
(item) => item.label.toLowerCase().includes(lower) || item.description?.toLowerCase().includes(lower),
);
},
};When using a categorized adapter, add TriggerPopoverCategories to your popover UI:
<ComposerPrimitive.Unstable_TriggerPopoverPopover>
<ComposerPrimitive.Unstable_TriggerPopoverBack>← Back</ComposerPrimitive.Unstable_TriggerPopoverBack>
<ComposerPrimitive.Unstable_TriggerPopoverCategories>
{(categories) => categories.map((cat) => (
<ComposerPrimitive.Unstable_TriggerPopoverCategoryItem key={cat.id} categoryId={cat.id}>
{cat.label}
</ComposerPrimitive.Unstable_TriggerPopoverCategoryItem>
))}
</ComposerPrimitive.Unstable_TriggerPopoverCategories>
<ComposerPrimitive.Unstable_TriggerPopoverItems>
{(items) => items.map((item, index) => (
<ComposerPrimitive.Unstable_TriggerPopoverItem key={item.id} item={item} index={index}>
{item.label}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
))}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopoverPopover>Combining with Mentions
Slash commands and mentions can coexist on the same composer. Nest both roots — the plugin protocol ensures they don't conflict:
<ComposerPrimitive.Unstable_MentionRoot adapter={mentionAdapter}>
<ComposerPrimitive.Unstable_SlashCommandRoot adapter={slashAdapter}>
<ComposerPrimitive.Root>
<ComposerPrimitive.Input placeholder="Type @ to mention, / for commands..." />
<ComposerPrimitive.Send>Send</ComposerPrimitive.Send>
{/* Mention popover — shows when @ is typed */}
<ComposerPrimitive.Unstable_MentionPopover>
<ComposerPrimitive.Unstable_MentionCategories>
{(categories) => categories.map((cat) => (
<ComposerPrimitive.Unstable_MentionCategoryItem key={cat.id} categoryId={cat.id}>
{cat.label}
</ComposerPrimitive.Unstable_MentionCategoryItem>
))}
</ComposerPrimitive.Unstable_MentionCategories>
<ComposerPrimitive.Unstable_MentionItems>
{(items) => items.map((item) => (
<ComposerPrimitive.Unstable_MentionItem key={item.id} item={item}>
{item.label}
</ComposerPrimitive.Unstable_MentionItem>
))}
</ComposerPrimitive.Unstable_MentionItems>
</ComposerPrimitive.Unstable_MentionPopover>
{/* Slash command popover — shows when / is typed */}
<ComposerPrimitive.Unstable_TriggerPopoverPopover>
<ComposerPrimitive.Unstable_TriggerPopoverItems>
{(items) => items.map((item, index) => (
<ComposerPrimitive.Unstable_TriggerPopoverItem key={item.id} item={item} index={index}>
{item.label}
</ComposerPrimitive.Unstable_TriggerPopoverItem>
))}
</ComposerPrimitive.Unstable_TriggerPopoverItems>
</ComposerPrimitive.Unstable_TriggerPopoverPopover>
</ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_SlashCommandRoot>
</ComposerPrimitive.Unstable_MentionRoot>Each root provides its own TriggerPopoverContext. When the user types @, the mention popover opens. When they type /, the slash command popover opens. Keyboard events route to whichever popover is active.
Keyboard Navigation
Same keyboard bindings as mentions:
| Key | Action |
|---|---|
| ArrowDown | Highlight next item |
| ArrowUp | Highlight previous item |
| Enter | Execute highlighted command / drill into category |
| Escape | Close popover |
| Backspace | Go back to categories (when query is empty) |
Trigger Popover Architecture
Both mentions and slash commands are built on a generic trigger popover system:
ComposerPrimitive.Unstable_TriggerPopoverRoot— the generic root, parameterized by trigger character and select behaviorComposerPrimitive.Unstable_MentionRoot— preset withtrigger="@"andonSelect: insertDirectiveComposerPrimitive.Unstable_SlashCommandRoot— preset withtrigger="/"andonSelect: action
The trigger popover primitives (TriggerPopoverPopover, TriggerPopoverItems, etc.) are shared across both. You can also use TriggerPopoverRoot directly to build custom trigger systems with other characters (e.g. : for emoji).
ComposerInput Plugin Protocol
Under the hood, each trigger root registers a ComposerInputPlugin with the composer input. This is a generic protocol that decouples the input from any specific trigger:
type ComposerInputPlugin = {
handleKeyDown(e: KeyboardEvent): boolean;
setCursorPosition(pos: number): void;
};The input iterates over registered plugins for keyboard events and cursor changes. This is what enables multiple trigger roots to coexist without conflict.
Primitives Reference
| Primitive | Description |
|---|---|
Unstable_SlashCommandRoot | Convenience wrapper — TriggerPopoverRoot with trigger="/" and action behavior |
Unstable_TriggerPopoverRoot | Generic root — configurable trigger character and select behavior |
Unstable_TriggerPopoverPopover | Container — only renders when a trigger is active (role="listbox") |
Unstable_TriggerPopoverCategories | Render-function for the top-level category list |
Unstable_TriggerPopoverCategoryItem | Button that drills into a category (role="option", auto data-highlighted) |
Unstable_TriggerPopoverItems | Render-function for items within the active category or search results |
Unstable_TriggerPopoverItem | Button that selects an item (role="option", auto data-highlighted) |
Unstable_TriggerPopoverBack | Button that navigates back from items to categories |
Related
- Mentions Guide —
@-mention system built on the same architecture - Suggestions Guide — static follow-up prompts (different from slash commands)
- Composer Primitives — underlying composer primitives