Mentions

Let users @-mention tools or custom items in the composer to guide the LLM.

Mentions let users type @ in the composer to open a popover picker, select an item (e.g. a tool), and insert a directive into the message text. The LLM can then use the directive as a hint.

How It Works

User types "@"  →  Trigger detected  →  Adapter provides categories/items

              Directive inserted  ←  User selects item from popover

         Message sent with ":tool[Label]{name=id}" in text

The mention system has three layers:

  1. Trigger detection — the composer input watches for a trigger character (@ by default) and extracts the query
  2. Adapter — provides the categories and items to display in the popover (e.g. registered tools)
  3. Formatter — serializes a selected item into directive text (:type[label]{name=id}) and parses it back for rendering

Under the hood, mentions are one kind of trigger popover. A mention declares its behavior with a <TriggerPopover.Directive> sub-primitive, which writes the formatter-serialized directive into the composer on selection.

Quick Start

The fastest path is the pre-built Mention UI components, which wire everything together with two shadcn components — the popover picker and the message-side chip renderer:

npx shadcn@latest add "https://r.assistant-ui.com/composer-trigger-popover" "https://r.assistant-ui.com/directive-text"

See the Composer Trigger Popover and Directive Text guides for setup steps.

The rest of this guide covers the underlying concepts and customization points.

Trigger Adapter

A Unstable_TriggerAdapter provides the data for the popover. All methods are synchronous — use external state management (React Query, SWR, local state) for async data, then expose loaded results through the adapter.

import type { Unstable_TriggerAdapter } from "@assistant-ui/core";

const myAdapter: Unstable_TriggerAdapter = {
  categories() {
    return [
      { id: "tools", label: "Tools" },
      { id: "users", label: "Users" },
    ];
  },

  categoryItems(categoryId) {
    if (categoryId === "tools") {
      return [
        { id: "search", type: "tool", label: "Search" },
        { id: "calculator", type: "tool", label: "Calculator" },
      ];
    }
    if (categoryId === "users") {
      return [
        { id: "alice", type: "user", label: "Alice" },
        { id: "bob", type: "user", label: "Bob" },
      ];
    }
    return [];
  },

  // Optional — global search across all categories
  search(query) {
    const lower = query.toLowerCase();
    const all = [
      ...this.categoryItems("tools"),
      ...this.categoryItems("users"),
    ];
    return all.filter(
      (item) =>
        item.label.toLowerCase().includes(lower) ||
        item.id.toLowerCase().includes(lower),
    );
  },
};

Pass the adapter to TriggerPopover and declare a Directive sub-primitive to bind the insertion behavior:

import { ComposerPrimitive } from "@assistant-ui/react";
import { unstable_defaultDirectiveFormatter } from "@assistant-ui/core";

<ComposerPrimitive.Unstable_TriggerPopoverRoot>
  <ComposerPrimitive.Root>
    <ComposerPrimitive.Input placeholder="Type @ to mention..." />
    <ComposerPrimitive.Unstable_TriggerPopover
      char="@"
      adapter={myAdapter}
    >
      <ComposerPrimitive.Unstable_TriggerPopover.Directive
        formatter={unstable_defaultDirectiveFormatter}
      />
      <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) => (
            <ComposerPrimitive.Unstable_TriggerPopoverItem
              key={item.id}
              item={item}
            >
              {item.label}
            </ComposerPrimitive.Unstable_TriggerPopoverItem>
          ))
        }
      </ComposerPrimitive.Unstable_TriggerPopoverItems>
    </ComposerPrimitive.Unstable_TriggerPopover>
  </ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>

Exactly one behavior sub-primitive (Directive or Action) is allowed per TriggerPopover. The parent reads the registered behavior and wires the selection machinery.

Built-in Mention Adapter

unstable_useMentionAdapter covers the common cases: mention registered tools, add your own items, mix tools with custom items, or show multi-category drill-down.

Tools from model context (default):

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

const mention = unstable_useMentionAdapter();
// → { adapter, directive } — spread into <ComposerTriggerPopover {...mention} />
// Default: single "Tools" category reading from useAssistantTool registrations

Custom items only (no tools):

const mention = unstable_useMentionAdapter({
  items: [
    { id: "alice", type: "user", label: "Alice", icon: "User" },
    { id: "bob", type: "user", label: "Bob", icon: "User" },
  ],
});

Mix custom items with model-context tools (flat):

const mention = unstable_useMentionAdapter({
  items: [{ id: "kb", type: "doc", label: "Knowledge Base", icon: "Book" }],
  includeModelContextTools: true,
});

Multi-category drill-down:

const mention = unstable_useMentionAdapter({
  categories: [
    {
      id: "users",
      label: "Users",
      items: [
        { id: "alice", type: "user", label: "Alice", icon: "User" },
        { id: "bob", type: "user", label: "Bob", icon: "User" },
      ],
    },
    {
      id: "files",
      label: "Files",
      items: [
        { id: "readme", type: "file", label: "README.md", icon: "FileText" },
      ],
    },
  ],
  // Tools auto-appended as their own category (default id "tools", label "Tools")
  includeModelContextTools: true,
});

Tool formatting and category override:

const mention = unstable_useMentionAdapter({
  categories: [{ id: "users", label: "Users", items: [...] }],
  includeModelContextTools: {
    category: { id: "integrations", label: "Integrations" },
    formatLabel: (name) =>
      name.replaceAll("_", " ").replace(/\b\w/g, (c) => c.toUpperCase()),
    icon: "Wrench",
  },
});

Options summary:

OptionTypeBehavior
itemsUnstable_Mention[]Flat list (ignored when categories is set)
categories{id, label, items}[]Drill-down groups
includeModelContextToolsboolean | objectDefault: true iff neither items nor categories
formatterUnstable_DirectiveFormatterOverride directive serialization (default: unstable_defaultDirectiveFormatter)
onInserted(item) => voidFires after the directive is inserted into the composer
iconMapRecord<string, IconComponent>Maps metadata.icon / category id strings to React components
fallbackIconIconComponentFallback when no entry in iconMap matches

icon on each mention is a shortcut for metadata.icon that the picker UI resolves via iconMap. Dedup between custom items and model-context tools is by id — explicit items win.

The hook returns { adapter, directive, iconMap?, fallbackIcon? } — spread into <ComposerTriggerPopover {...mention} /> for one-line wiring. Callers consuming the raw primitives instead destructure: mention.adapter, mention.directive.formatter, etc.

Directive Format

When a user selects a mention item, it is serialized into the composer text as a directive. The default format is:

:type[label]{name=id}

For example, selecting a tool named "get_weather" with label "Get Weather" produces:

:tool[Get Weather]{name=get_weather}

When id equals label, the {name=…} attribute is omitted for brevity:

:tool[search]

Custom Formatter

Implement Unstable_DirectiveFormatter to use a different format:

import type { Unstable_DirectiveFormatter } from "@assistant-ui/core";

const slashFormatter: Unstable_DirectiveFormatter = {
  serialize(item) {
    return `/${item.id}`;
  },

  parse(text) {
    const segments = [];
    const re = /\/(\w+)/g;
    let lastIndex = 0;
    let match;

    while ((match = re.exec(text)) !== null) {
      if (match.index > lastIndex) {
        segments.push({ kind: "text" as const, text: text.slice(lastIndex, match.index) });
      }
      segments.push({
        kind: "mention" as const,
        type: "tool",
        label: match[1]!,
        id: match[1]!,
      });
      lastIndex = re.lastIndex;
    }

    if (lastIndex < text.length) {
      segments.push({ kind: "text" as const, text: text.slice(lastIndex) });
    }

    return segments;
  },
};

Pass it to the trigger's Directive sub-primitive and the message renderer:

// Composer
<ComposerPrimitive.Unstable_TriggerPopover
  char="@"
  adapter={adapter}
>
  <ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={slashFormatter} />
  ...
</ComposerPrimitive.Unstable_TriggerPopover>

// User messages
const SlashDirectiveText = createDirectiveText(slashFormatter);
<MessagePrimitive.Parts components={{ Text: SlashDirectiveText }} />

Textarea vs Lexical

The mention system supports two input modes:

Textarea (default)Lexical
Input componentComposerPrimitive.InputLexicalComposerInput
Mention display in composerRaw directive text (:tool[Label])Inline chips (atomic nodes)
DependenciesNone@assistant-ui/react-lexical, lexical, @lexical/react
Best forSimple setups, minimal bundleRich editing, polished UX

With textarea, selecting a mention inserts the directive string directly into the text. The user sees :tool[Get Weather]{name=get_weather} in the input.

With Lexical, selected mentions appear as styled inline chips that behave as atomic units — they can be selected, deleted, and undone as a whole. The underlying text still uses the directive format.

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

<ComposerPrimitive.Unstable_TriggerPopoverRoot>
  <ComposerPrimitive.Root>
    <LexicalComposerInput placeholder="Type @ to mention..." />
    <ComposerPrimitive.Send />
    <ComposerPrimitive.Unstable_TriggerPopover
      char="@"
      adapter={adapter}
    >
      <ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={formatter} />
      ...
    </ComposerPrimitive.Unstable_TriggerPopover>
  </ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>

LexicalComposerInput automatically discovers every Directive trigger registered under TriggerPopoverRoot and renders their selections as inline chips.

Rendering Mentions in Messages

Use DirectiveText as the Text component for user messages so directives render as inline chips instead of raw syntax:

import { DirectiveText } from "@/components/assistant-ui/directive-text";

<MessagePrimitive.Parts
  components={{
    Text: DirectiveText,
  }}
/>

For assistant messages, keep using your markdown renderer (e.g. MarkdownText) — the LLM typically does not emit directive syntax.

For a custom formatter, use createDirectiveText:

import { createDirectiveText } from "@/components/assistant-ui/directive-text";

const MyDirectiveText = createDirectiveText(myFormatter);

Processing Mentions on the Backend

The message text arrives at your backend with directives inline. Parse them to extract mentioned items:

// Default format: :type[label]{name=id}
const DIRECTIVE_RE = /:([\w-]+)\[([^\]]+)\](?:\{name=([^}]+)\})?/g;

function parseMentions(text: string) {
  const mentions = [];
  let match;
  while ((match = DIRECTIVE_RE.exec(text)) !== null) {
    mentions.push({
      type: match[1],        // e.g. "tool"
      label: match[2],       // e.g. "Get Weather"
      id: match[3] ?? match[2], // e.g. "get_weather"
    });
  }
  return mentions;
}

// Example:
// parseMentions("Use :tool[Get Weather]{name=get_weather} to check")
// → [{ type: "tool", label: "Get Weather", id: "get_weather" }]

You can use the extracted mentions to:

  • Force-enable specific tools for the LLM call
  • Add context about mentioned users or documents to the system prompt
  • Log which tools users request most often

Reading Mention State

Use unstable_useTriggerPopoverScopeContext inside the TriggerPopover to programmatically access the popover state for that trigger:

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

function MyPopoverContent() {
  const scope = unstable_useTriggerPopoverScopeContext();

  // scope.open — whether the popover is visible
  // scope.query — current search text after the trigger
  // scope.categories — filtered category list
  // scope.items — filtered item list
  // scope.highlightedIndex — keyboard-navigated index
  // scope.isSearchMode — true when global search is active
  // scope.selectItem(item) — programmatically select an item
  // scope.close() — close the popover

  return null;
}

This hook must be used inside a ComposerPrimitive.Unstable_TriggerPopover.

To iterate every registered trigger (e.g. from a custom input implementation), use unstable_useTriggerPopoverTriggers inside TriggerPopoverRoot.

Building a Custom Popover

Use the trigger popover primitives to build a fully custom popover:

<ComposerPrimitive.Unstable_TriggerPopoverRoot>
  <ComposerPrimitive.Root>
    <ComposerPrimitive.Input />

    <ComposerPrimitive.Unstable_TriggerPopover
      char="@"
      adapter={adapter}
      className="popover"
    >
      <ComposerPrimitive.Unstable_TriggerPopover.Directive formatter={formatter} />

      <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) => (
            <ComposerPrimitive.Unstable_TriggerPopoverItem
              key={item.id}
              item={item}
            >
              {item.label}
            </ComposerPrimitive.Unstable_TriggerPopoverItem>
          ))
        }
      </ComposerPrimitive.Unstable_TriggerPopoverItems>
    </ComposerPrimitive.Unstable_TriggerPopover>

    <ComposerPrimitive.Send />
  </ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>

Primitives Reference

PrimitiveDescription
Unstable_TriggerPopoverRootRoot provider — groups one or more triggers, manages plugin registry
Unstable_TriggerPopoverDeclares a trigger (id, char, adapter) and renders the popover container
Unstable_TriggerPopover.DirectiveBehavior sub-primitive — inserts a formatted directive into the composer on selection
Unstable_TriggerPopover.ActionBehavior sub-primitive — runs a callback on selection; inserts a chip by default
Unstable_TriggerPopoverCategoriesRender-function for the top-level category list
Unstable_TriggerPopoverCategoryItemButton that drills into a category (role="option", auto data-highlighted)
Unstable_TriggerPopoverItemsRender-function for items within the active category or search results
Unstable_TriggerPopoverItemButton that selects an item (role="option", auto data-highlighted)
Unstable_TriggerPopoverBackButton that navigates back from items to categories

See the Composer API reference for full prop details.

Combining with Slash Commands

Mentions and slash commands coexist on the same composer — they're both just triggers on the shared TriggerPopoverRoot:

<ComposerPrimitive.Unstable_TriggerPopoverRoot>
  <ComposerPrimitive.Root>
    <ComposerPrimitive.Input placeholder="Type @ to mention, / for commands..." />
    <ComposerPrimitive.Send />

    {/* Mention popover (shows on @) */}
    <ComposerPrimitive.Unstable_TriggerPopover
      char="@"
      adapter={mention.adapter}
    >
      <ComposerPrimitive.Unstable_TriggerPopover.Directive {...mention.directive} />
      <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) => (
          <ComposerPrimitive.Unstable_TriggerPopoverItem key={item.id} item={item}>
            {item.label}
          </ComposerPrimitive.Unstable_TriggerPopoverItem>
        ))}
      </ComposerPrimitive.Unstable_TriggerPopoverItems>
    </ComposerPrimitive.Unstable_TriggerPopover>

    {/* Slash command popover (shows on /) */}
    <ComposerPrimitive.Unstable_TriggerPopover
      char="/"
      adapter={slashAdapter}
    >
      <ComposerPrimitive.Unstable_TriggerPopover.Action
        formatter={unstable_defaultDirectiveFormatter}
        onExecute={(item) => commandHandlers[item.id]?.()}
      />
      <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_TriggerPopover>
  </ComposerPrimitive.Root>
</ComposerPrimitive.Unstable_TriggerPopoverRoot>

Each TriggerPopover declares its own scope — its popover UI reads state only from that declaration, so @ and / never collide.