bb.mentions

The headless bb.mentions plugin owns mention providers, search, and send-time resolution.

Purpose

bb.mentions owns provider selection, mention search, and context resolution. It gives each trigger one global provider winner and gives composers one stable service. It keeps provider code on the server and keeps agent-only context out of the visible draft.

This plugin declares no surfaces. It owns no popover, menu, pill, React component, or layout contract.

Surfaces

ID kind replaceable props contract sketch notes
This plugin declares no surface contracts.

The default bb.thread-ui.composer implementation declares bb.thread-ui.composer.mentionPopover as its child surface. A replacement composer can declare that child surface or omit it.

The popover contract belongs to bb.thread-ui. The bb.mentions service stays available when an active composer omits the popover.

Services

ID kind replaceable contract notes
bb.mentions.provider keyed yes MentionProvider The key is one MentionTrigger. The user selects one winner for each trigger.
bb.mentions single yes MentionsService The default provider is the first-party bb.mentions plugin. Composers consume this service.

The bb.mentions service has a watched edge on bb.mentions.provider@^1. It stays live when a provider winner changes. The service reads the new winner before the next search or resolution.

The bb.thread-ui plugin has an optional edge on bb.mentions@^1. Without this service, its composer treats trigger characters as plain text.

bb.mentions.provider

This contract uses a closed key set for version 1. Each key identifies the character that opens the composer menu.

export type MentionTrigger = "@" | "#" | "$" | "!" | "~";

export interface MentionProviderFacts {
  /** A stable, owner-namespaced ID, such as "acme.issues". */
  providerId: string;
  /** The composer menu section label. */
  label: string;
}

export interface MentionScope {
  projectId: string | null;
  threadId: string | null;
}

export interface MentionProviderSearchInput extends MentionScope {
  trigger: MentionTrigger;
  /** Text after the trigger and before the caret. */
  query: string;
  /** The service clamps this value to 100. */
  limit: number;
  signal: AbortSignal;
}

export interface MentionItem {
  /** Stable inside this provider. */
  id: string;
  title: string;
  subtitle?: string;
  /** A BB glyph or one icon that the provider plugin declares. */
  icon?: string;
}

export interface MentionProviderResolveInput extends MentionScope {
  trigger: MentionTrigger;
  itemId: string;
  signal: AbortSignal;
}

export interface MentionResolution {
  /** Fresh agent-only context. The user message does not show this text. */
  context: string;
}

export interface MentionProvider {
  search(
    input: MentionProviderSearchInput,
  ): Promise<readonly MentionItem[]>;

  resolve(
    input: MentionProviderResolveInput,
  ): Promise<MentionResolution>;
}
Method Behavior Failure rule
search() Returns rows for one query, scope, and trigger. It observes the supplied limit and abort signal. A throw reaches bb.mentions, which records the failure and returns an empty group.
resolve() Reads one item again and returns fresh agent-only context. A throw reaches bb.mentions and blocks the send.

The build puts MentionProviderFacts in contract.json. The app can list labels before it runs provider code. The host validates providerId against the claimant's namespace.

A plugin makes one claim for each trigger. Each old triggers array entry maps to one keyed claim. An omitted old array maps to one @ claim.

{
  "claims": [
    {
      "service": "bb.mentions.provider",
      "version": "^1",
      "key": "#"
    }
  ]
}

The contract is replaceable. A second claim for # does not change the active provider. The winner picker shows both claims and keeps the current winner. The default claimant supplies a safe fallback for each supported trigger.

bb.mentions

The service gives the app one API for static provider data, search, and send-time resolution.

export interface MentionProviderSummary {
  trigger: MentionTrigger;
  providerId: string;
  label: string;
  claimantPluginId: string;
  available: boolean;
}

export interface MentionSearchInput extends MentionScope {
  trigger: MentionTrigger;
  query: string;
  limit?: number;
  signal?: AbortSignal;
}

export interface MentionSearchItem {
  itemId: string;
  title: string;
  subtitle: string | null;
  icon: string | null;
}

export interface MentionSearchGroup {
  trigger: MentionTrigger;
  providerId: string;
  label: string;
  items: readonly MentionSearchItem[];
}

export interface MentionReference {
  trigger: MentionTrigger;
  providerId: string;
  itemId: string;
  /** Text that the pill shows. */
  label: string;
}

export interface ResolvedMention extends MentionReference {
  context: string;
}

export interface MentionsService {
  providers(input?: {
    trigger?: MentionTrigger;
  }): Promise<readonly MentionProviderSummary[]>;

  search(input: MentionSearchInput): Promise<{
    groups: readonly MentionSearchGroup[];
  }>;

  resolve(input: {
    mention: MentionReference;
    scope: MentionScope;
    signal?: AbortSignal;
  }): Promise<ResolvedMention>;

  resolveMany(input: {
    mentions: readonly MentionReference[];
    scope: MentionScope;
    signal?: AbortSignal;
  }): Promise<{
    resolved: readonly ResolvedMention[];
  }>;
}
Method Behavior Failure rule
providers() Returns current winner facts in trigger order. An optional trigger filters the result. An unavailable winner stays in the result with available: false.
search() Calls the current provider winner. The default limit is 20. The maximum is 100. A two-second timeout or provider error returns an empty group and records a diagnostic.
resolve() Resolves one selected item at send time. It verifies the trigger and provider winner. A missing, stale, or failed provider blocks the send with a visible error.
resolveMany() Resolves an ordered set. It calls each unique reference once and reuses that result. The method is atomic. One failed reference blocks the send.

search() returns at most one group because each trigger has one winner. The group shape keeps menu code simple and preserves the old wire model.

The composer stores only MentionReference values in its draft. It never stores resolved context. The send path calls resolveMany() to obtain fresh context. It then adds each result as agent-only prompt input.

A winner change can make an old draft reference stale. The composer marks that pill unavailable and lets the user remove it. It never sends context from the old winner.

Exports

Module Exports Use
@bb/mentions/contracts bbMentions, mentionProvider, all service types Authors use tokens for service edges, claims, and direct service calls.

The plugin exports no presentation module. bb.thread-ui owns the default mention popover and its component exports.

Host roles

bb.mentions declares no host roles. Provider methods run in the server process. A provider that needs machine data must use an explicit domain service or its own host role.

Example

This plugin claims the # trigger for issue mentions. The claim joins the global winner picker for that trigger.

// bb.plugin.jsonc
{
  "id": "acme.issues",
  "version": "2.0.0",
  "claims": [
    {
      "service": "bb.mentions.provider",
      "version": "^1",
      "key": "#"
    }
  ],
  "requires": [
    { "service": "acme.issues.store", "range": "^1" }
  ],
  "artifacts": {
    "server": "./dist/server.js"
  }
}

The generated contract record carries the provider facts.

// contract.json excerpt
{
  "claims": [
    {
      "id": "bb.mentions.provider",
      "version": "1.0.0",
      "kind": "keyed",
      "key": "#",
      "facts": {
        "providerId": "acme.issues",
        "label": "Issues"
      }
    }
  ]
}

The server factory provides the keyed implementation.

// src/server.ts
import { defineServerPlugin } from "@get-bb/plugin/server";
import { mentionProvider } from "@bb/mentions/contracts";
import { issueStore } from "./contracts";

export default defineServerPlugin(async (api) => {
  const issues = await api.services.use(issueStore);

  api.services.provide(mentionProvider.key("#"), {
    async search({ query, projectId, limit, signal }) {
      const rows = await issues.search({
        projectId,
        query,
        limit,
        signal,
      });

      return rows.map((issue) => ({
        id: issue.id,
        title: `${issue.key} ${issue.title}`,
        subtitle: issue.state,
        icon: "acme.issues/issue",
      }));
    },

    async resolve({ itemId, projectId, signal }) {
      const issue = await issues.get({
        projectId,
        issueId: itemId,
        signal,
      });

      return {
        context: [
          `# ${issue.key}: ${issue.title}`,
          `State: ${issue.state}`,
          issue.description,
        ].join("\n\n"),
      };
    },
  });
});

The plugin needs no app artifact. The default bb.thread-ui.composer discovers the claim through providers() and calls search() as the user types. That composer declares bb.thread-ui.composer.mentionPopover and passes the search state to its active implementation. At send time, it calls resolveMany() and adds the returned text as agent-only input.

Covers

old item ID new contract/verb note
server.ui.registerMentionProvider bb.mentions.provider keyed claim plus api.services.provide() One old registration becomes one claim for each trigger.
server.ui.mentionProvider bb.mentions.provider: MentionProvider and MentionProviderFacts Static facts and live methods now have separate shapes.
server.ui.mentionSearchContext bb.mentions.provider: MentionProviderSearchInput The new input adds a limit and an abort signal.
server.ui.mentionItem bb.mentions.provider: MentionItem The item keeps its stable ID, title, subtitle, and icon.
server.ui.mentionProvider.id bb.mentions.provider: MentionProviderFacts.providerId The build enforces an owner namespace.
server.ui.mentionProvider.label bb.mentions.provider: MentionProviderFacts.label The app reads the label from static contract facts.
server.ui.mentionProvider.triggers bb.mentions.provider: keyed claim Each array entry becomes one claim. An omitted array becomes the @ key.
server.ui.mentionProvider.search bb.mentions.provider: search() The service adds cancellation, a limit, and a two-second deadline.
server.ui.mentionProvider.resolve bb.mentions.provider: resolve() Resolution runs at send time and returns agent-only context.
app.mentions.PluginMentionTrigger bb.mentions.provider: MentionTrigger key Version 1 keeps the five old trigger characters.
app.mentions.PluginMentionProviderRegistration bb.mentions.provider: claim, facts, and MentionProvider The static claim replaces the app-visible registration record.
app.mentions.PluginMentionSearchContext bb.mentions.provider: MentionProviderSearchInput Project and thread scope remain nullable.
app.mentions.PluginMentionItem bb.mentions.provider: MentionItem The provider returns the same row data.
app.mentions.PluginUi.registerMentionProvider bb.mentions.provider keyed claim plus api.services.provide() The factory uses the ordinary service path.
app.mentions.contributions bb.mentions service: providers() The app no longer uses a special contributions route.
app.mentions.search bb.mentions service: search() The typed service replaces the special mention search route.
app.mentions.PluginMentionSearchGroup bb.mentions service: MentionSearchGroup One trigger produces at most one winner group.
app.mentions.PluginMentionSearchItem bb.mentions service: MentionSearchItem The wire row keeps the old nullable subtitle and icon.