UI slots

bb's interface is one tree of slots. The kernel declares root; every other slot is declared by the component that occupies its parent, and your plugin joins the tree by registering a React component into a slot name from the catalog (reference §5). This page teaches the four slot kinds with one built slot each, the registration options, how the winner of an exclusive slot is chosen, what happens when an occupant crashes, and the rules for services, data, and CSS inside a slot component. The worked plugin is deploy, which shows release state around the app.

Use this when

  • Add a button to the sidebar. sidebar.footer is a list slot; every occupant renders.
  • Replace bb's thread list. sidebar.body is a single slot, and Original keeps the stock list inside your wrapper.
  • Badge only the thread rows that need one. threadList.row.trailing is a list slot filtered with when.
  • Paint one kind of timeline row. timeline.row is a keyed slot with one winner per key.
  • Let other plugins extend your panel. Declare a child slot with children and they register into it.

What you build

One app.tsx with five registrations and one pane kind: a DeployButton in the sidebar footer, a FilteredThreadList that wraps the stock thread list with a filter bar, a DeployBadge on rows whose thread has a failed release, a DeployStatusGlyph that nominates itself in the threadList.row.status chain, a ReleaseRow for the row kind deploy/release, and a deploy pane that declares deploy.panel.actions for other plugins. The server half is the counter-shaped service from the introduction, renamed deploy/releases; only its contract object appears here.

Steps

1. Pick the slot and learn its kind

Every row in reference §5 names a kind and a scope. Your registration must repeat both as literals, or the load fails (§4.2). The kind decides who renders and which extra props you receive (§4.3):

Kind Who renders Extra props Options that matter
list every occupant, order ascending, ties by sorted plugin id none order, when(owner)
single one winner Original priority
keyed one winner per key Original key, priority; * is the fallback key
chain the first candidate, in arbitration order, whose select(owner) returns non-null selected, Original select, priority

Scope adds props the kernel injects: threadthreadId; panepaneId and threadId | null; root → nothing.

There are two ways to register. app.slots.register(options, Component) requires the slot's declaration to exist when your plugin commits. app.slots.inject(name, (slots) => slots.register(...)) runs the thunk when the declaration exists, now or on a later commit, and drops the registrations when it vanishes (§4.2). Commits happen in sorted plugin id order per wave (§4.4), so for any slot another plugin declares use inject; the built threads and provider-claude-code plugins do this for every foreign slot. The introduction's counter registers sidebar.footer directly, which works only when ui-sidebar has already committed.

2. Type the owner props once

SlotComponentProps<Name> is typed through the SlotMap declaration-merge hook (§4.2). When the declaring plugin publishes the merge in its contracts module, import that. When it does not, merge the catalog's owner props yourself, naming only the fields you read (the built environments plugin does the same):

// src/slot-types.ts
import type { ReactNode } from "react";
import type { JsonObject } from "@get-bb/plugin-sdk/app";

export interface ThreadListEntry {
  id: string; projectId: string; title: string | null; displayStatus: string; pendingCount: number;
  annotations: Record<string, unknown>;                 // `<pluginId>/<key>` rows from threads/threads.sidebar
}
export interface ThreadRowOwner { thread: ThreadListEntry; isActive: boolean; compact: boolean; depth: number }
export interface SidebarBodyOwner {
  activeThreadId: string | null; activeProjectId: string | null; searchQuery: string; compact: boolean;
  open(target: { kind: string; state: unknown }, opts: { where: "focused" | "new-split" }): void;
}
export interface Row {
  id: string; threadId: string; seqStart: number; seqEnd: number; kind: string; payload: JsonObject;
  status: "pending" | "completed" | "failed" | "interrupted"; presentation: { label: string; icon: string } | null;
}
export interface RowOwner { row: Row; providerId: string; children: ReactNode | null }   // full shape: Guide 9 step 2

declare module "@get-bb/plugin-sdk/app" {
  interface SlotMap {
    "sidebar.footer": { kind: "list"; scope: "root"; props: { compact: boolean; closeOnMobile(): void } };
    "sidebar.body": { kind: "single"; scope: "root"; props: SidebarBodyOwner };
    "threadList.row.trailing": { kind: "list"; scope: "root"; props: ThreadRowOwner };
    "threadList.row.status": { kind: "chain"; scope: "root"; props: ThreadRowOwner };
    "timeline.row": { kind: "keyed"; scope: "thread"; props: RowOwner };   // `Original`/`selected` come from the kind
  }
}

sidebar.footer (§5.2) passes { compact; closeOnMobile() }. Every occupant renders; order: 50 places the button before the stock bug-report button (80). Registrations run inside setup(app) of definePluginApp; the later steps add to that function.

import { definePluginApp, KernelError, useService, type SlotComponentProps } from "@get-bb/plugin-sdk/app";
import { Button, Icon } from "@bb/ui";
import { releases, type ReleasesHandle } from "./contracts.js";
import "./slot-types.js";

function useReleases(): ReleasesHandle | null {
  try { return useService(releases); }
  catch (e) { if (e instanceof KernelError && e.code === "service_unavailable") return null; throw e; }
}

export function DeployButton(props: SlotComponentProps<"sidebar.footer">) {
  const svc = useReleases();
  return (
    <Button variant="outline" size="sm" disabled={svc === null}
      onClick={() => { void svc?.deployLatest({}); props.closeOnMobile(); }}>
      <Icon name="Zap" className="size-4" aria-hidden />
      {props.compact ? null : "Deploy"}
    </Button>
  );
}
export default definePluginApp({
  setup(app) {                                   // every `app.slots.inject` and `app.panes.register` on this page runs here
    app.slots.inject("sidebar.footer", (slots) =>
      slots.register({ name: "sidebar.footer", kind: "list", scope: "root", order: 50 }, DeployButton));
  },
});

4. single with Original: wrap the thread list

sidebar.body (§5.2) is single; the stock occupant is ui-thread-list's ThreadList at priority 0. A registration at priority: 10 wins, and the kernel hands you the displaced component as Original, bound once per mount so its internal state survives your re-renders (§4.3). Pass your own searchQuery through when the sidebar's search is idle:

import { useState } from "react";
import { Input } from "@bb/ui";

export function FilteredThreadList(props: SlotComponentProps<"sidebar.body">) {
  const { Original } = props;
  const [filter, setFilter] = useState("");
  return (
    <div className="flex h-full flex-col">
      <div className="px-2 pb-2">
        <Input value={filter} onChange={(e) => setFilter(e.target.value)} placeholder="Filter by release" aria-label="Filter threads" />
      </div>
      <Original {...props} searchQuery={props.searchQuery === "" ? filter : props.searchQuery} />
    </div>
  );
}
app.slots.inject("sidebar.body", (slots) =>                                                // inside setup(app)
  slots.register({ name: "sidebar.body", kind: "single", scope: "root", priority: 10 }, FilteredThreadList));

Render <Original {...props} /> unchanged and you have a no-op wrapper; return something else and you have replaced the list.

5. list with when: a badge on the rows that need one

threadList.row.trailing (§5.3) is a list slot, not keyed: every occupant renders on every row, so the visibility predicate when(owner) does the filtering, evaluated by the owner before you mount. The badge reads a thread annotation the server half writes through threads/annotations (annotations.set(threadId, "deploy/state", value), §5.14, built).

import { Badge } from "@bb/ui";

const deployState = (t: ThreadListEntry): string | null =>
  typeof t.annotations["deploy/state"] === "string" ? (t.annotations["deploy/state"] as string) : null;

export function DeployBadge(props: SlotComponentProps<"threadList.row.trailing">) {
  return <Badge>{props.compact ? "!" : "deploy failed"}</Badge>;
}
app.slots.inject("threadList.row.trailing", (slots) =>                                     // inside setup(app)
  slots.register(
    { name: "threadList.row.trailing", kind: "list", scope: "root", order: 30, when: (o) => deployState(o.thread) === "failed" },
    DeployBadge));

6. keyed: one renderer for one row kind

timeline.row (§5.8) is keyed by the row kind. A registration names its key; the owner walks a key ladder and the first key with an occupant wins. Register deploy/release for your own extension kind and fall back to Original when the payload is not what you expect. Guide 9 covers the ladder and the payload grammar.

export function ReleaseRow(props: SlotComponentProps<"timeline.row">) {
  const { row, Original } = props;
  const version = typeof row.payload["version"] === "string" ? row.payload["version"] : null;
  if (version === null) return <Original {...props} />;
  return <section className="rounded-md border border-border px-3 py-2 text-sm">Release {version} — {row.status}</section>;
}
app.slots.inject("timeline.row", (slots) =>                                                // inside setup(app)
  slots.register({ name: "timeline.row", kind: "keyed", scope: "thread", key: "deploy/release" }, ReleaseRow));

7. chain: nominate yourself with select

threadList.row.status (§5.3) is a chain: candidates are evaluated in arbitration order, and each one's pure select(owner) says whether it wants the slot. Return null to pass; return a value and it arrives as selected. The stock RuntimeStatusGlyph from threads is the fallback at the end.

export function DeployStatusGlyph(props: SlotComponentProps<"threadList.row.status">) {
  const state = String(props.selected);                                   // what our select() returned
  return <Icon name={state === "failed" ? "AlertTriangle" : "Loading"} className="size-3" aria-label={`deploy ${state}`} />;
}
app.slots.inject("threadList.row.status", (slots) =>                                       // inside setup(app)
  slots.register(
    { name: "threadList.row.status", kind: "chain", scope: "root", select: (o) => deployState(o.thread) },
    DeployStatusGlyph));

select runs without mounting anything, so keep it synchronous and free of hooks. To wrap the next candidate instead of replacing it, render <Original {...props} /> inside your component.

8. Declare a child slot for other plugins

A registration's children declares new slots and is the only thing allowed to render them (§4.2). In the product root belongs to ui-shell, so declare children on something you actually occupy: a pane kind (app.panes.register({ children }), the same mechanism ui-thread-page uses for thread.*) or a slot you won. Publish the SlotMap merge in your contracts module so registrants get typed props without importing your code.

// contracts.ts (published)
declare module "@get-bb/plugin-sdk/app" {
  interface SlotMap { "deploy.panel.actions": { kind: "list"; scope: "pane"; props: { releaseId: string } } }
}
// app.tsx
import type { PaneProps } from "@get-bb/plugin-sdk/app";
import { z } from "zod";
const paneState = z.object({ releaseId: z.string() });
type PaneState = z.infer<typeof paneState>;

function DeployPane(props: PaneProps<PaneState>) {
  return (
    <section className="flex flex-col gap-3 p-4">
      <h1 className="text-base font-medium">Release {props.state.releaseId}</h1>
      <div className="flex gap-2">{props.renderSlot("deploy.panel.actions", { releaseId: props.state.releaseId })}</div>
    </section>
  );
}
app.panes.register<PaneState>({                                           // inside setup(app)
  kind: "deploy", schema: paneState, Component: DeployPane, title: (s) => `Release ${s.releaseId}`,
  threadId: () => null, dock: null,
  children: { "deploy.panel.actions": { kind: "list", scope: "pane", props: z.object({ releaseId: z.string() }) } },
});

Another plugin now writes app.slots.inject("deploy.panel.actions", (slots) => slots.register({ name: "deploy.panel.actions", kind: "list", scope: "pane", order: 10 }, TheirButton)). Per-child declaration options: boot: "critical" | "deferred", overlay, fallback: "owner" | "none" (§4.2). A root-scoped registration may not declare a thread-scoped child (D16).

9. The remaining options

priority arbitrates single, keyed, and chain; order sorts list; both default to 0 (§4.2). Pass { regular, compact } instead of one component to get one per layout mode; the kernel remounts on change. store: defineStore({ init, persist?, actions }) gives each instance its own store as props.store; a persist.key must be one your plugin defined (§4.3). On the owner side, renderSlot(name, props, { fallback?, keys?, only?, thread? }) renders a child you declared.

10. Arbitration and the user pin

One rule from kernel-core decides every exclusive slot, every key of a keyed slot, chain evaluation order, and pane and tab kinds: replaces → user pin → priority desc → sorted plugin id asc (§4.4). The pin is the profile preference kernel/pins, keyed by target; the CLI writes it:

bb composition pin slot:sidebar.body deploy          # the user prefers our thread list
bb composition pin slot:timeline.row:deploy/release deploy
bb composition pin slot:sidebar.body --clear
bb composition pins

A pinned plugin that is absent or crashed is skipped, never an error. list slots skip arbitration entirely.

11. Services and data inside a slot

Components never receive a context object; services arrive through hooks (§4.3). useService(contract) suspends while the binding resolves and throws a KernelError with code service_unavailable when nothing provides it. Catch that after every other hook, as useReleases in step 3 does, so the hook order stays stable and the component renders a degraded state instead of crashing. defineQuery with invalidateOn is the read path; usePreference(ref) reads a setting defined in setup (§4.7–4.8).

12. Style it

The build rewrites your Tailwind utilities to :where([data-bb-plugin="deploy"]) .cls so they apply only inside your mounts; never use @scope (§4.11). Authored CSS must root every selector, including those inside @media, at [data-bb-plugin-effect="deploy"], or the build fails (D18). Spread usePortalScopeProps() on overlay content you portal to body; kit overlays do it themselves. Prefer @bb/ui components (Button, Input, Badge, Icon, Tooltip*, DropdownMenu*, PersistentDrawer, …; the full list is §4.11). <Icon name> takes a core or extended name; a contributes.icons.named glyph has no renderer today (§4.11).

What happens at runtime

  1. bb plugin build runs setup headless and writes dist/app.contributions.json; contributes.slots in the manifest is cross-checked against it (D9).
  2. The browser fetches the bootstrap, loads plugins in two waves, runs setup again, and compares the claim with the file; a mismatch fails the load (§4.12). Commit is atomic per plugin in sorted id order; inject thunks run once their declaration exists.
  3. Each occupant instance mounts under PluginContextPortalScopeProvider → release registry → CrashBoundarySuspense<div data-bb-plugin="deploy"> (§4.3). The Suspense fallback is the owner's fallback.
  4. An occupant that throws is latched for the generation, its useOwnedEffect releases run, one toast appears, and the next candidate renders; none left means the owner fallback (fallback: "none" renders nothing). A reload of the plugin clears its latches (§4.4).
  5. kernel/activation.changed reconciles: changed hashes reload, vanished plugins unload, and occupants remount because the generation is in the instance key.

Pitfalls

  • Registering into a slot no live occupant declares fails the load with SlotAuthorityError; use app.slots.inject for foreign slots (§4.2).
  • list slots ignore priority; single/keyed/chain ignore order and when (§4.2). threadList.row.trailing is list, not keyed (§5.3): filter with when, not key.
  • The SDK face has no slots.declare; declare children on a registration or a pane kind (§4.1). Two live occupants declaring the same child name: the later plugin fails with SlotDeclarationConflict (§4.4).
  • Owner props are typed by SlotMap but not re-validated at render (§4.3); treat a foreign owner's props as a boundary when you cannot import the merge.
  • Write setup synchronous; the harness refuses a Promise (D20).
  • root at priority: -1000 is only a fallback for a composition without ui-shell; in the product it never mounts (§4.2, §4.5).
  • zod is bundled into every tier; React, @bb/ui, and Radix come from the import map, never from your bundle (§4.11, D18).

See also

  • Reference §4.2–4.4 (register, props, arbitration), §4.11 (kit and CSS), §5 (every slot).
  • Guide 8, Pages, panes, navigation, and commands — the pane kind from step 8 gets a URL.
  • Guide 9, Timeline rows, interactions, and the composer — the timeline.row ladder in full.
  • next/examples/plugins/hello-slot/src/app.tsx — a root fallback plus a pane kind and route.