1.0 archive. This section describes the previous 1.0 API. The current model is the 2.0 design.

4. App tier — @get-bb/plugin-sdk/app

The browser entry. It runs only through the import map (app.mjs leaves @get-bb/plugin-sdk/app bare; packages/plugin-build/src/externals.ts). It re-exports React types only; React itself comes from the map (07 §2.1, I8). Everything here is a line in packages/plugin-sdk/api.lock.json (29 values, 85 types at e1efe72a3; LayoutRoot, useCommands, ClientCommands, DragSession, LayoutRootProps arrived with that delta, §0.8). Slot names are §5; this section is the mechanism. Source of truth: packages/plugin-sdk/src/app/{index,define-plugin-app,slots,hooks}.ts over packages/kernel-ui/src/**; where spec 07 and the built kernel differ, the "As built" column wins (04-kernel-as-built D16–D18).

4.1 definePluginApp and setup(app, config)

Member Signature (abridged) What it does Spec As built
definePluginApp (def: { setup(app: PluginAppApi, config: ActivationConfig): void | Promise<void> }) => PluginApp The default export of app.tsx. Returns kernel-ui's branded product (Symbol.for("bb.plugin-app")); the loader refuses a default export without the brand (appTier: "error", "default export is not a definePluginApp() product"). 07 §2.3 step 3, 01 §2.4 define-plugin-app.ts: wraps kernel-ui's definePluginApp; setup(api, config?) on the product, config ?? {}.
PluginApp { [brand]: true; setup(api: CollectorApi, config?: ActivationConfig) } What the loader imports and calls. 07 §2.3 config is {} in the product; only createAppHarness passes row config (SDK README deviation 12; optional in the signature because the wrapper fills {}).
PluginAppApi { slots: SlotsApi; routes; panes; tabs; commands; preferences } The typed face setup receives. routes/panes/tabs/commands are kernel-ui's CollectorApi members verbatim; slots and preferences are SDK-narrowed. 07 §2.3 step 4 No slots.declare on the SDK face (kernel CollectorApi has it; catalog-only sugar). commands.handle returns void at setup time.
setup contract runs twice (1) bb plugin build runs it in the headless collector (no DOM) to write dist/app.contributions.json; (2) the browser runs it at load against the same collector and compares output with the file (contributions_mismatch fails the load). setup only records; nothing touches a store until commit. At e1efe72a3 the build also runs every slots.inject thunk against a recorder, so the children its registrations declare land in declares; a thunk that throws there records nothing and the kernel reports the failure when it realizes it. 07 §2.3, §5.9 contributions.ts createCollector; plugin-frontends.ts prepareFrontend (checkClaim: true). The type admits void | Promise<void>; the browser (plugin-frontends.ts await app.setup(api)) and the build hook (cli/src/_pending/plugin-dev-hooks.ts await app.setup(api)) await a Promise, and only createAppHarness refuses one (invalid_contract, D20). Write setup synchronous so one module passes all three.

Rules: every register/inject needs literal name, kind, scope (the collector throws otherwise). A registration into a name no live occupant declares fails the plugin's load (SlotAuthorityError, "use slots.inject"). Commit is atomic per plugin: any failure restores the previous generation in routes, commands, kinds, slots and preferences (kernel-ui README "Failed reload rollback").

4.2 app.slots.register

register<Name, O extends RegisterOptions<Name>>(options: O & { name: Name }, component: SlotComponentInput<SlotComponentProps<Name, O>>): void. kind/scope are narrowed by the merged SlotMap entry for Name; an unmerged name accepts the open unions and the component's props take kind/scope from the literal options (SDK README deviation 9).

Field Type Default (filled once by the collector) Meaning
name Name extends string required, literal The slot. Must be declared (kernel root, the two kit facade slots, a live occupant's children, or — at e1efe72a3 — a live pane kind's children, §4.5) or the load fails (07 §5.1; slot-tree.ts validate). Before that commit a registration into a pane-kind child (thread.timeline) failed with slot … is not declared.
kind SlotKindOf<Name> = "single" | "list" | "keyed" | "chain" required Must equal the declaration's kind (07 §5.1).
scope SlotScopeOf<Name> = "root" | "thread" | "pane" required Must equal the declaration's scope (07 §5.3).
key string null keyed only, required there ("command", "claude-code:task", "*" as the conventional fallback key; snake_case core kinds, R27).
priority number 0 Arbitration for single/keyed/chain; higher wins (§4.4).
order number 0 list ordering, ascending; ties by sorted plugin id.
select (owner: SlotPropsOf<Name>) => unknown null chain only, required there: pure self-nomination; null/undefined = pass. The non-null value arrives as selected.
when (owner: SlotPropsOf<Name>) => boolean null list only: visibility predicate the outlet evaluates with the owner props.
children Record<string, SlotDeclarationInput> {} Declares and exclusively authorizes child slots (07 §5.5). Each: { kind, scope, props: ZodType, boot?: "critical" | "deferred" (deferred), overlay?: boolean (false), fallback?: "owner" | "none" (owner) }. A root-scoped registration may not declare a thread-scoped child (SlotScopeError at load); pane-scoped children of root occupants are allowed and checked at render (D16). A pane kind declares children the same way (PaneKindInput.children, §4.5); at e1efe72a3 those are live declarations while the kind's plugin wins the kind (slot-tree.ts kindReg, create-ui-kernel.ts kindLive).
store StoreDefinition<unknown> from defineStore({ init, persist?, actions }) null Per-instance store; the component receives store: StoreFace<T> (§4.3). persist: { key, scope: "client" | "tab" | "thread" } routes through preferences; the key must be one this plugin defined (07 §5.7.1), else the store is memory-only.
Member Signature (abridged) What it does Spec As built
SlotsApi.inject (name: string, thunk: (slots: Pick<SlotsApi, "register">) => void) => void Late contributor: the thunk runs when name's declaration exists (now or on a later commit), its registrations are dropped when the declaration vanishes, and it re-runs on redeclaration. The sanctioned form for a plugin without requires on the declarer. At e1efe72a3 the thunk also runs at bb plugin build against a recorder (contributions.ts toAppContributionsJson), so the children of its registrations appear in declares; write it as plain registration code that needs nothing from the live tree. 07 §5.6 slot-tree.ts runInjects (≤ 8 passes per commit). A throwing thunk drops its partial registrations, marks the injecting plugin degraded (inject into <slot> failed) with one toast, and is not retried until that plugin reloads.
SlotComponentInput<P> SlotComponent<P> | { regular: SlotComponent<P>; compact: SlotComponent<P> } One component, or one per layout mode; the kernel picks by useLayoutMode() and remounts on change. 07 §8 SlotMount.tsx pickComponent; the mount key includes the mode.
SlotComponent<P> ComponentType<P> A React component. 07 §5.1 —
SlotMap / SlotEntry interface SlotMap {}; SlotEntry<P> = { kind: SlotKind; scope: SlotScope; props: P } Declaration-merge hook: a declarer's contracts module adds "<slot>": { kind; scope; props } and every consumer gets typed register and props with no value import. Also exported from /contracts. 07 §5.9 slots.ts; fixture src/fixture/contracts.ts merges notes.panel/notes.badge.
SlotKindOf<N> / SlotScopeOf<N> / SlotPropsOf<N> conditional types The merged entry's kind/scope/props, or the open union / Record<string, unknown> for an unmerged name. 07 §5.9 slots.ts.
SlotDeclarationInput / StoreDefinition / defineStore see table above; defineStore<T, A>({ init: () => T; persist?; actions: A }) => StoreDefinition<T, A> defineStore fills persist: null. Actions are (draft: T, ...args) => void and run on a one-level structural copy, so every action yields a new reference. 07 §5.7.1 slots/store.ts.
// contracts.ts (published):  declare module "@get-bb/plugin-sdk/app" {
//   interface SlotMap { "notes.panel": { kind: "list"; scope: "root"; props: { heading: string } } } }
app.slots.register(
  { name: "root", kind: "single", scope: "root", priority: -1000,
    children: { "notes.panel": { kind: "list", scope: "root", props: z.object({ heading: z.string() }) } } },
  ({ renderSlot, Original }) => <main>{renderSlot("notes.panel", { heading: "Notes" })}</main>,   // renderSlot typed to "notes.panel"
);
app.slots.register({ name: "notes.panel", kind: "list", scope: "root", order: 10 }, (p: SlotComponentProps<"notes.panel">) => <b>{p.heading}</b>);

The root registration at priority: -1000 is a fallback for a composition or test without ui-shell: in the product ui-shell (priority 0) wins root, this occupant never mounts, and the plugin's panel reaches the screen as a pane kind plus a route (§4.5, "How a page appears").

4.3 Slot component props

SlotComponentProps<Name, O> = SlotPropsOf<Name> & ScopeProps<scope> & { renderSlot: RenderSlot<children of O> } & ({ store: StoreFace<T> } when O.store) & ({ Original: SlotComponent } unless kind is list) & ({ selected: unknown } when kind is chain). Never ctx, the container, or a query client (07 §5.7, A5); services arrive through hooks (§4.9).

Member Signature (abridged) What it does Spec As built
owner props SlotPropsOf<Name> Whatever the owner passed to renderSlot(name, props); JSON data and callbacks. Validated by the declaration's zod schema in the catalog, typed by SlotMap. 07 §5.7 The runtime spreads props as given (SlotMount.tsx Occupant); no render-time zod parse.
ScopeProps<S> thread → { threadId: string }; pane → { paneId: string; threadId: string | null }; root → {} Scope keys the kernel injects from the ambient ScopeContext (pane subtree sets paneId and the pane kind's threadId(state)). 07 §5.3 Occupant: threadId for every non-root scope, paneId for pane.
renderSlot RenderSlot<Names> = (name: Names, props: Record<string, unknown>, opts?: RenderSlotOptions) => ReactNode The owner-side call, bound to this registration and narrowed to its children. Any other name is a type error and a runtime SlotAuthorityError. The bound function may be handed to an exported component and mounted by another plugin (foreign mount): the instance key then gains |mount:<useId> so stores and latches stay separate. 07 §5.5 bindRenderSlot; SlotOutlet computes foreignMount from PluginContext.
RenderSlotOptions { fallback?: ReactNode; keys?: readonly string[]; only?: readonly string[]; thread?: string } fallback: what renders when nothing does (single: no candidate; keyed/chain: no match; list: ignored). keys: the keyed ladder, first key with an occupant wins. only: restrict a list to these plugin ids. thread: explicit thread scope key for a thread-scoped child at a non-occupant mount. 07 §5.2, §5.5 SlotMount.tsx. A pane-scoped slot outside a pane subtree or a thread-scoped slot with no thread key is SlotScopeError: thrown in dev, fallback in production.
Original SlotComponent (absent for list) The next candidate in arbitration order, ending at the owner fallback (fallback: "none" ends in nothing). Bound once per mount; identity changes only when the chain behind this occupant changes, so <Original {...props} /> keeps the original's state across re-renders. 07 §5.7 Occupant useMemo on the chain's ids. Pane and tab kinds get the same prop (PaneComponentProps, TabComponentProps).
selected unknown (chain only) The non-null value this registration's select(owner) returned. With overlay: true the owner fallback stays mounted in a <div hidden> beside the winner; at e1efe72a3 that wrapper has one shape whether or not an occupant is selected (hidden={occupant !== null}), so React never remounts the fallback across a takeover and its DOM state (a composer's editor) survives. 07 §5.2 SlotOutlet chain branch.
store StoreFace<T, A> = { use<S>(selector: (state: T) => S): S; get(): T; actions: BoundActions<A> } One instance per (registration, instance key), created on first render, discarded with the plugin's generation. use is useSyncExternalStore; actions.x(...args) calls the draft action and notifies. persist loads/saves through the preferences facade (thread scope takes the thread id from the scope key). 07 §5.7.1 slots/store.ts, SlotTree.store, create-ui-kernel.ts persistence.

Mount: every occupant instance renders under PluginContext {pluginId, generation} → the kit's PortalScopeProvider → release registry → CrashBoundary → Suspense (fallback = the owner fallback) → <div data-bb-plugin-root data-bb-plugin="<id>" class="contents">. React key = instance key ${name}|${key ?? ""}|${pluginId}|${generation}|${scopeKey}|${registrationId} (+ |<mode>), so a new generation or a mode change remounts. The trailing registration id (added at e1efe72a3) keeps two list registrations of one plugin into one slot apart — each has its own store and crash latch — while the latch's one-toast tuple stays (slot, key, pluginId, generation) (07 §5.8; slot-tree.ts instanceKey, SlotMount.tsx).

4.4 Arbitration and pins

One rule, owned by kernel-core (02 §3.4 order(candidates, pin)): replaces → user pin → priority desc → sorted plugin id asc. kernel-ui adds only the crash-latch filter in front of it and never ranks on its own (12 U1). It applies to single, each key of keyed, the evaluation order of chain, and every pane/tab kind (LayoutStore.paneKinds_/tabKinds_). list slots skip arbitration: all occupants, order asc then plugin id, when(owner) and only filtered.

Member Signature (abridged) What it does Spec As built
pins profile preference kernel/pins: Record<target, pluginId>; targets slot:<name>, slot:<name>:<key>, pane:<kind>, tab:<kind> (and service:<id> for the container) The user's override at one point; a pinned plugin that is absent or latched is skipped, never an error. Written by bb composition pin <target> <pluginId> / --clear, listed by bb composition pins; Settings › Appearance writes the same map. 07 §5.4, 02 §3.4 Defined by kernel-ui at boot (defineKernelPreferences); a kernel/preferences.changed for it re-arbitrates every outlet. Keyed lookup tries slot:<name>:<key> then slot:<name>.
crash latch CrashLatch (Set of instance keys) An occupant that throws latches its instance for the current generation, runs its useOwnedEffect releases, toasts once per (slot, key, pluginId, generation) ("<id> crashed in <slot>"), and the next candidate renders; none left → owner fallback. A reload of the plugin clears its latches. 07 §5.4, §5.8 crash-latch.ts, SlotMount.tsx onCrash; pane/tab kinds latch per instance (kindInstanceKey) and fall through the same chain.
commit order sorted plugin id per wave A route, command, or child-declaration collision always fails the later id, never the later bundle to arrive (hello-slot < ui-shell). 07 §2.2 (150 ms batching) plugin-frontends.ts runWave (D16; batching subsumed).
SlotDeclarationConflict thrown at commit Two live occupants declaring the same child name: the later plugin fails to load. 07 §5.5 slot-tree.ts checkDeclarationConflicts.

4.5 Routes, panes, tabs

URL = the focused pane (Q21). The kernel has no routes of its own; / belongs to ui-shell (D16). A pane is { kind, state }; a route maps a path to a pane kind; a dock holds a flat tab list (no splits inside a dock).

How a page appears. KernelRoot renders NoRoutePage when no route matches the URL and otherwise the root slot, renderSlot("root", {}, { fallback: <LoadingPage/> }) (boot/KernelRoot.tsx); root is the kernel's a priori single slot (create-ui-kernel.ts), and arbitration (§4.4) picks one occupant. In the product that occupant is ui-shell's ShellRoot (priority 0, §5.1): it renders shell.main, whose occupant LayoutHost mounts kernel-ui's LayoutRoot, and LayoutRoot mounts every pane of the layout tree through SlotMount with the pane kind's winning component (PaneHost → usePaneMount → kindChainHead, layout/LayoutRoot.tsx; an unknown kind renders MissingKindPane). So a plugin page in the product is a pane kind plus a route (hello-slot: kind hello, path /hello), and the shell draws it. At e1efe72a3 LayoutRoot is exported from @get-bb/plugin-sdk/app (table below), so a plugin's own root occupant can host the pane tree by rendering <LayoutRoot /> inside what it returns; before that commit it was not exported and a plugin root rendered only what it returned. A root registration at priority: -1000 mounts only when nothing outranks it — createAppHarness without shell, or a composition without ui-shell — and then the routed pane appears only if that occupant mounts LayoutRoot; hello-slot, written before the export, renders the same panel from both places instead (HelloPanel is both the root occupant and the hello pane component). createAppHarness({ shell: { panes: true } }) mounts LayoutRoot under the test shell's root (§9.3). ui-shell/pages (§5.14) with the page pane kind (§5.6, route /plugins/:pluginId/:pageId/*) is the shell's registry for a page with shell chrome; the slice has no registrants.

Member Signature (abridged) What it does Spec As built
app.routes.register <S>(route: RouteContribution<S>) => void; RouteContribution<S> = { id: "<pluginId>/<name>"; path: string; pane: { kind: string; toState(params, url: URL): S; toPath(state: S): string }; title(state: S): string } Path patterns take :name segments and a trailing * (captured as params["*"]); static segments beat params beat *. Same path by two plugins → RouteCollision for the later id; no route priority (a fork uses replaces). Unmatched URL → NoRoutePage. title sets document.title. 07 §3.1 router/router.ts. A malformed percent-escape is a non-match, not a throw.
app.panes.register <S>(kind: PaneKindInput<S>) => void; PaneKindInput<S> = { kind; schema: ZodType<S>; Component: SlotComponentInput<PaneProps<S>>; title(s): string; threadId(s): string | null; dock: { default(s): DockState; allowedTabKinds: string[] | null } | null; priority?: number (0); icon?: IconName | null (null); equals?(a, b): boolean (deepEqual); intents?: ZodType<JsonValue> | null (null); children?: Record<string, SlotDeclarationInput> } Registers a pane kind. Several plugins may register one kind; the kernel arbitrates with the pane:<kind> pin and the winner gets Original (PaneComponentProps<S> = PaneProps<S> & { Original: ComponentType<PaneProps<S>> }). threadId(state) sets the thread scope key for the pane subtree and selects the kernel-ui/dock.tabs row for persist: "thread" tabs. children declares the pane component's child slots (how ui-thread-page declares thread.*). kind is a non-empty string; the kernel applies no grammar and no namespace (contributions.ts copies it as given; core kinds are bare — thread, compose, page — and hello-slot uses hello). Two plugins on one kind are candidates of one chain, never a collision; only a route path collides (RouteCollision). 07 §4.2 contributions.ts fills the defaults; LayoutRoot.tsx usePaneMount/useKindChain (per-instance crash fallback, stable Original). An unregistered kind renders MissingKindPane naming the owner from the catalog. At e1efe72a3 the kind's children are live slot-tree declarations while this plugin wins the kind (kindLive), so another plugin's register into one of them passes the load-time check, and app.contributions.json records them under owner: "pane:<kind>". A new pane's dock opens only when dock.default(state).open is true and the client preference kernel-ui/dockOpenDefault is true (default false at e1efe72a3, true before): the preference closes a default-open dock and never opens a default-closed one (layout-store.ts buildPane).
app.tabs.register <S>(kind: TabKindInput<S>) => void; TabKindInput<S> = { kind; schema; Component: SlotComponentInput<TabProps<S>>; label(s): string; icon: IconName | ((s) => IconName); persist: "thread" | "tab" | ((s) => "thread" | "tab"); priority? (0); singleton? (false); launcher?: { label; order; create(): S | Promise<S> } | null (null); closable? (true) } Registers a dock tab kind. persist decides the store per tab from its state ("thread" → the thread's kernel-ui/dock.tabs annotation with CAS; "tab" → the tab-scoped tree); the catalog records a function as "dynamic". icon is required (the strip always shows a glyph). 07 §4.2, §4.3 layout-store.ts (persistOf, persistThreadTabs, one CAS retry); each tab mounts one TabOutlet per tabId for the tab's life.
PaneProps<S> { paneId; state: S; isFocused; isMaximized; isOnlyPane; index; hash: string; dockHost: "inline" | "workspace"; setState(next: S): void; consumeIntent(): JsonValue | null; dock: DockHandle | null; renderSlot: RenderSlot } What a pane component receives. setState replaces content in place (URL replace when focused); consumeIntent is one-shot (second call null); dock is DockHandle { toggle; open; close; setMode; addTab(tab, { activate }): string; removeTab; activate; reorder; setWidth } bound to this pane; renderSlot is narrowed to the kind's children. 07 §4.6, §4.4 LayoutRoot.tsx. Invalid setState is refused with a toast, never thrown. PaneProps exist even when no kind is live, so dock tabs can usePane().
usePane () => PaneProps For descendants of a pane (header actions, dock tabs inline or in the workspace column); throws outside a pane subtree. 07 §4.6 LayoutRoot.tsx PaneContext.
TabProps<S> { tabId; paneId; state: S; isActive; setState(next: S): void; close(): void } What a tab component receives, plus Original. 07 §4.2 LayoutRoot.tsx TabOutlet.
useNavigation () => Navigation; Navigation = { open(target: PaneTarget, opts?: Partial<OpenOptions>): string; openPath(path: string, opts?): string; current(): { path; paneId; navId }; subscribe(listener: (change: { entry; cause: "push" | "replace" | "pop" }) => void): () => void } The SDK facade fills OpenOptions = { where: OpenWhere; replace: boolean; intent: JsonValue | null } with { where: "focused", replace: false, intent: null }. PaneTarget = { kind: string; state: unknown }; OpenWhere = "focused" | "new-split" | { paneId }. Returns the pane id now showing the target (an equals match focuses the existing pane and delivers the intent there). 07 §3.2 hooks.ts navigationFacade; router.ts NavigationService. A state the kind's schema refuses toasts and returns the focused pane id. Focusing a different pane with a different URL pushes; setState replaces. Browser back/forward reports cause: "pop" at e1efe72a3 (before, the popstate listener took the event as its initial flag and reported replace).
useLayoutTree <T>(selector: (tree: LayoutTree) => T) => T Subscribe to the tab-scoped tree (LayoutTree = { version: 1; root: pane | split; focusedPaneId; maximizedPaneId }, ≤ 8 panes, ≤ 32 dock tabs). The frozen tree is the snapshot; the selector may return fresh values. 07 §4.4 KernelRoot.tsx.
kernel-ui/layout useService("kernel-ui/layout") The layout ops face: open, split, close, focus, move, maximize, spotlight, setSizes, setState, findPaneByThread, paneRects, dockOf(paneId): DockHandle. Browser-local, no server half. 07 §4.4, §7.9 Built as the LayoutStore instance (dock ops via dockOf(paneId), not a dock.* namespace).
DockOwnerProps / DropZoneOwnerProps { dock: DockState; tabs: { tabId; kind; label; icon; closable; Component }[]; ops: DockHandle; host: "inline" | "workspace" } / { panes: { paneId; rect; activeSide }[]; label; hoveredPaneId } Owner props of ui-shell's pane.dock and layout.dropZones children, passed through LayoutRoot's renderDock/renderOverlay callbacks. 07 §4.5 No paneId on DockOwnerProps (read it with usePane()); the overlay is called only during a drag session.
LayoutRoot (props: LayoutRootProps) => ReactNode; LayoutRootProps = { renderDock?(props: DockOwnerProps): ReactNode; renderOverlay?(props: DropZoneOwnerProps): ReactNode; dragSession?: DragSession | null } The layout tree as a component: a CSS-grid container over the tab-scoped LayoutTree, one PaneHost per pane (scope providers, PaneProps, crash boundary, the kind's winning component through SlotMount), dividers as setSizes calls, maximize, and the dock host placement (inline beside the pane, or the workspace column). It draws no chrome: tab strips come through renderDock, drop zones through renderOverlay, which is called only while dragSession is non-null (null and absent mean no drag). Mount it once, inside a root occupant (in the product ui-shell's shell.main does); it throws outside a UiKernel provider. 07 §3.1, §4.5 exported at e1efe72a3 (plugin-sdk/src/app/index.ts); layout/LayoutRoot.tsx. The grid uses minmax(0, 1fr) tracks and minHeight: 0 on pane bodies and dock hosts, so tall pane content scrolls inside its pane.
DragSession { label: string; hoveredPaneId: string | null; sides: Readonly<Record<string, SplitSide>> } A drag-to-split session (ui-shell/layout-drag runs it): the label the overlay shows, the pane under the pointer, and the edge under the pointer per pane id (absent = none). Pass it as LayoutRoot's dragSession; renderOverlay then receives DropZoneOwnerProps. 07 §4.5 exported at e1efe72a3; layout/LayoutRoot.tsx. SplitSide = "left" | "right" | "top" | "bottom" (layout/layout-tree.ts) is kernel-ui's and is not on the SDK face; name the strings.

Kernel-registered pane commands (bindable, when: { all: ["splitActive"], none: ["modalOpen"] }): pane.focus.1…8 (Mod+N desktop, Control+N web-mac, Mod+Shift+N web-other), pane.focus.previous, pane.focus.next, pane.close (Mod+Shift+X), pane.maximize.toggle (Mod+Shift+E). thread.open and pane.act are ui-shell's (07 §4.7, R10; layout-commands.ts).

4.6 Client commands and keybindings

Ids are flat and open (thread.new, pane.focus.*); one executor plus a before-interceptor stack per id (02 §6.3 on the browser side). The bus is kernel-ui's synchronous registry; the kernel does not mirror it onto the container bus (D17). Hub {t: "command"} frames reach commands.run directly (a server tier sends them with ctx.hub.command, §3.5). An executor that throws yields an error result, never a timeout.

Member Signature (abridged) What it does Spec As built
app.commands.register (c: CommandContributionInput) => void; CommandContributionInput = { id; title: string | ((index?) => string); defaultShortcut: Shortcut | readonly ShortcutVariant[] | null; family?: { count } (null); when?: When ({all: [], none: []}); bindable? (true); desktopOnly? (false); menu?: { path: string[]; order } | null (null) } Declares a command for the catalog, the bindings table and menus. family expands thread.jump.* to .1…count (the handler gets index). defaultShortcut is required; null = bindable with no default; a variant list carries per-surface when/desktopOnly. 07 §6.1 command-bus.ts normalizeCommand. A second plugin registering the same id is CommandConflict at commit: the later plugin's app tier fails to load and rolls back (07 §6.1 says degraded; built fails the commit).
app.commands.handle (id, handler: CommandHandler, opts?: { priority?: number; executor?: boolean }) => void at setup; CommandHandler = (inv: Invocation) => boolean | void; Invocation = { input: unknown; index: number | null; actor: { kind; id } | null } The first handle (or executor: true) is the executor; every other is a before-interceptor ordered by priority desc then registration desc. An interceptor returning true consumes; an executor returning false reports "not consumed". A second executor is CommandConflict. 07 §6.1, 02 §6.3 command-bus.ts handle/run.
useCommandHandler (id, handler: CommandHandler, deps: readonly unknown[], opts?: { priority? }) => void Hook form of handle; registers on mount under the ambient plugin id and disposes on unmount / when deps change. 07 §6.1 KernelRoot.tsx.
useCommands () => ClientCommands; ClientCommands = { run(id: string, input?: JsonValue | null): boolean; has(id: string): boolean } The caller side of the bus: run runs a command by id as a shortcut would (input defaults to null) and returns whether a handler consumed it; has tells whether id resolves. For a menu item, a probe, or a button that triggers another plugin's command. The returned object is not memoized; do not put it in a dependency list. 07 §6.1 added at e1efe72a3; hooks.ts over kernel-ui CommandBus.run/has.
useCommandContext (key: ContextKey, active: boolean) => void Asserts a context key while mounted and active; counted, so overlapping asserters compose. Kernel keys: modalOpen, editableFocus (transient, derived from the keydown target), layoutCompact, webSurface, desktopSurface, mobileSurface, macPlatform, splitActive. ContextKey = string; When = { all: ContextKey[]; none: ContextKey[] }. 07 §6.1 CommandBus.assert. commands.context.define(key, description) is not on the SDK face; a plugin asserts any key string.
useShortcut (id: string) => Shortcut | null; Shortcut = { key; mod; meta; control; alt; shift } The effective binding of id from the in-memory table: defaults from every contribution filtered by isBindingAvailable({ isDesktop, isMac }), then ui-keyboard's overrides (bindings.set; null disables). Two defaults on one chord: the earlier plugin by sorted id keeps it. Family defaults bind key: "<index>" per member. Stable snapshot between changes. 07 §6.2 command-bus.ts effective(); the kit's ShortcutHint takes the Shortcut object.
useIsCommandModifierHeld () => boolean true after Meta (mac) or Control is held 700 ms; clears on keyup/blur. For shortcut hints. 07 §6.1 KernelRoot.tsx.
useModalPresence (id: string, open: boolean) => void Acquires a counted modal presence while open; modalOpen derives from it, never from a DOM selector. Kit Dialog/PersistentDrawer acquire it themselves; a raw Radix dialog does not (dev build warns). 07 §6.1 modal-presence.ts; ModalPresence does not block window.shouldClose.
keydown dispatch window keydown capture listener First effective binding whose chord matches (today's key normalization) and whose when holds → commands.run(id); a consumed run calls preventDefault. Redelivered events are no-ops. A keydown inside an IME composition (event.isComposing, or the legacy keyCode 229) is never a chord (e1efe72a3). 07 §6.1 KernelRoot.tsx + dispatchKeydown(event, ["editableFocus"]?).
Commands, CommandInput<N>, CommandOutput<N> interface Commands {}; CommandInput<N> = Commands[N]["input"] | unknown kernel-core's declaration-merge hook for typed command names (declare module "@get-bb/plugin-sdk/contracts" { interface Commands { "thread.send": { input; output } } }). Re-exported here for app code that names commands. 02 §6.3 kernel-core/src/commands.ts.

4.7 Preferences

A browser facade over 02's kernel/preferences; there is no kernel-ui/preferences service. Keys are static <pluginId>/<name> (^[a-z0-9][a-z0-9-]*\/[a-zA-Z0-9][a-zA-Z0-9._-]*$, PreferencesFacade KEY_RE); per-instance values are maps under one key. Preference key rule (identical in §1.3 and §3.7): the key is the full <pluginId>/<name> string in every tier, with name in ^[a-zA-Z][a-zA-Z0-9_-]*$ (the app's regex is wider, the server tier's is not); each tier that reads the key defines it itself with the same schema and default (a server define does not make the key readable in the browser, nor the reverse — usePreference of a key this bundle did not define throws read before its define); contributes.settings[name] is optional for code — it adds the Settings form row and the required fast path, and nothing checks that its default equals the code default. hello-slot defines hello-slot/theme in both server.ts and app.tsx from one THEME_KEY and DEFAULT_THEME exported by contracts.ts.

Member Signature (abridged) What it does Spec As built
app.preferences.define <T>(key: string, schema: ZodType<T>, opts: { scope: PreferenceScope; default: T }) => PreferenceRef<T>; PreferenceScope = "profile" | "client" | "tab" | "thread"; PreferenceRef<T> = { key; scope; default: T } Declares a key and returns the typed ref usePreference reads. profile/thread live in core (kernel/preferences, CAS, kernel/preferences.changed); client in localStorage["bb.pref.<key>"]; tab in sessionStorage. The default is filled at define; a read before define throws. The build emits { key, scope, default, schema } into app.contributions.json (a schema or default without a JSON form is ContributionSchemaError), spec 07 §7.5 has core validate profile/thread writes against it and refuse client/tab as unsupported_scope; as built kernel-core's PreferencesService has no schema producer (services.ts), so a write is validated only by its writer (the hook setter, PreferenceHandle.set). 07 §7.5, §5.9 preferences.ts PreferencesFacade.define(key, schema, opts, owner): a key is owned by its plugin; a reload redefines it (held values re-validated), another owner, a scope change, or a key outside KEY_RE throws a plain Error at commit, which fails the plugin's load (rollback, §4.1). define in setup is the only place (hello-slot keeps the ref in module scope). At e1efe72a3 a bootstrap-snapshot or wire row for a key not yet defined is parked (thread rows keyed <key>@<threadId>) and applied when that key's define lands; before, the facade dropped it.
usePreference <T>(ref: PreferenceRef<T>, args?: ScopeArgs) => [T, (next: T) => Promise<void>]; also <T>(key: string, args?); ScopeArgs = { threadId?: string } Subscribe to one cell and write it. thread scope requires { threadId }. The setter validates with the schema and resolves when the write lands. 07 §7.5 hooks.ts → usePreferenceOf. A profile/thread cell reads its default until the first core read lands — unless the bootstrap snapshot already carried the row, which at e1efe72a3 is parked through an app tier's late define and applied then, so the first paint shows the stored value (before, the row was dropped and the cell showed the default until its own kernel/preferences.get answered); the hook's setter always sends expectedUpdatedAt: null (last-writer-wins; CAS is facade-internal, used by the dock-tab writer). A remote value the schema rejects reads as the default.

4.8 Data: defineQuery, defineMutation, defineRowStore, defineStore

Module-level definitions that bind to the kernel lazily from a component; every .use()/.useHandle() is a hook (D18). The kernel owns the cache, the realtime subscriptions and invalidation (A5). client in the callbacks is the BbClient (kernel-ui/src/data/client.ts): { serverUrl: string; query<T = JsonValue>(serviceId: string, method: string, input: JsonValue): Promise<T>; mutate<T = JsonValue>(serviceId, method, input: JsonValue): Promise<T> } — query = GET /api/v1/<service>/<method>?input=<json>, mutate = POST, X-BB-Client on every request, an error envelope → KernelError. T is a statement, not a parse: hello-slot parses the body with the contract's output schema. I is unconstrained; a method whose input is z.strictObject({}) takes {} (second example below).

Member Signature (abridged) What it does Spec As built
defineQuery <I, O>(def: QueryDefinition<I, O>) => UseQuery<I, O>; QueryDefinition = { key: string; fetch(input: I, client): Promise<O>; invalidateOn: InvalidateOn<I>[]; staleMs?: number; debounceMs?: { min; max } } Cache entries are keyed <pluginId>/<key> + JSON(input) (the plugin id is the ambient PluginContext, "kernel" outside a mount), so two components share one entry. On mount: refetch when dirty or older than staleMs (default 0). Invalidation: the kernel subscribes to each wire target while a component uses the entry, debounces 50 ms (200 ms ceiling), defers while document.hidden, refetches every mounted entry on reconnect, and evicts an entry 5 min after its last listener leaves. 03 §8.5, 07 §7.6 data/define-query.ts QueryClient.
InvalidateOn<I> { name: string; key: string | null | ((input: I) => string) } | { entity: KernelEntity; id?: (input: I) => string } A wire target. name must be <owner>/<name> (else invalid_contract at definition). key: null matches every frame of the name, keyed or not (define-query.ts); an event declared with wire.key: null (§2.4) is matched only by key: null. The entity sugar maps to kernel/<entity>.changed with key <entity>:<id>; KernelEntity = "thread" | "project" | "environment" | "host" | "thread-head" | "interaction" | "thread-annotation". 07 §7.6 (no entity sugar) Built has the sugar (invalidationTargets).
UseQuery<I, O> { use(input: I): QueryState<O> & { refetch(): Promise<void> }; useHandle(): QueryHandle<I, O> } QueryState<O> = { status: "loading" | "success" | "error"; data: O | undefined; error: KernelError | null; updatedAt: number | null; fetching: boolean }. QueryHandle = { key; use; fetch(input): Promise<O>; invalidate(input?): void; peek(input): QueryState | null; setData(input, update): void } for imperative use and optimistic patches. useHandle() is a hook: call during render, capture for handlers. 03 §8.5 hooks.ts. A failed background refetch lands in error and keeps stale data (status stays success).
defineMutation <I extends JsonValue, O>(def: MutationDefinition<I>) => UseMutation<I, O>; MutationDefinition = { ref: string; method: string; optimistic?(input: I): () => void; silent?: boolean } client.mutate(ref, method, input). optimistic runs before the call and returns the rollback that runs on error. Errors toast "<ref>.<method> failed" unless silent, then rethrow as KernelError. UseMutation = { use(): { mutate(input): Promise<O>; pending; error: KernelError | null }; useHandle(): MutationHandle }; MutationHandle = { run(input): Promise<O>; use() }. 03 §8.5 data/define-row-store.ts defineMutation.
defineRowStore <Row, Op>(def: RowStoreDefinition<Row, Op>) => UseRowStore<Row, Op>; RowStoreDefinition = { key; page({ threadId, before: number | null, limit }, client): Promise<{ rows; seqEnd }>; resume({ threadId, after }, client): Promise<{ ops: SeqOp<Op>[]; seqEnd }>; apply(state: RowState<Row>, ops: Op[], ctx: { threadId: string }): RowState<Row>; follow: { name; key(threadId): string; ops(payload): SeqOp<Op>[] | null }; pageSize? (200) } The generic keyed-event store: one first page per thread, one wire follow (kernel/store.rows keyed thread:<id>), ops applied in seq order. SeqOp<Op> = { seq; op }; an op at or below the store's seqEnd is skipped; ops arriving while a page/catch-up is in flight are buffered, then gated. A stale reconnect calls resume; a failed page/catch-up sits in RowState.error and retries on the next reconnect. Row payloads apply immediately, never debounced. RowState<Row> = { rows: Row[]; seqEnd: number | null; error: KernelError | null; exhausted: boolean }. At e1efe72a3: apply receives { threadId } as its third argument, for ops that name no thread (a children cache, an empty window); a two-argument apply still type-checks. exhausted is a required member, true once a page (the first page or a loadOlder) came back with fewer than pageSize rows — the "Load earlier" gate; an apply that returns a fresh RowState literal must carry it (spread the incoming state), and a resume catch-up leaves it as it was. For kernel rows, Row from /contracts (§2.11) is the row type. 07 §7.6, §7.10 define-row-store.ts (shape is the built one, D18; exhausted and ctx.threadId added at e1efe72a3); idle eviction 5 min.
UseRowStore<Row, Op> { use(threadId): RowState<Row> & { loading: boolean; loadOlder(before: number): Promise<void> }; useHandle(): RowStoreHandle<Row>; definition: RowStoreDefinition } loadOlder takes the oldest seq the plugin holds (the kernel knows no row shape); stop offering it once exhausted is true. RowStoreHandle = { use; peek(threadId): RowState | null }. definition is exposed for tests and a server-side groupRows caller. 03 §8.5 hooks.ts.
export const notesQuery = defineQuery<{ pinnedOnly: boolean }, Note[]>({
  key: "list", fetch: (input, client) => client.query("notes-fixture/notes", "list", input),
  invalidateOn: [{ name: "notes-fixture/note.changed", key: null }, { entity: "thread", id: () => threadId }],
});
const q = notesQuery.use({ pinnedOnly: false });          // { status, data, error, refetch, … }
const handle = notesQuery.useHandle();                     // a hook too; use `handle.invalidate()` in an event handler
// no input: the cache entry is `<pluginId>/total:{}`
export const totalQuery = defineQuery<Record<string, never>, Total>({
  key: "total", fetch: (input, client) => client.query("word-count/counter", "total", input),
  invalidateOn: [{ name: "word-count/counted", key: null }],
});
const total = totalQuery.use({});

4.9 useService and errors

Member Signature (abridged) What it does Spec As built
useService <C extends AnyContract>(contract: C) => HandleOf<C>; <T = unknown>(id: string) => T By contract object (imported from the plugin's own contracts module through the import map) the handle is typed: HandleOf<C> = ServiceDefHandle<C> for a defineService object (method(input, opts?), AsyncIterable for stream, HTTP pair for custom) or kernel-core's ServiceHandle for a bare contract (AnyContract = ServiceDef | ServiceContract). By id (kernel-ui/navigation, kernel-ui/toasts, kernel-ui/layout, or a uses service) the caller states the type. Suspends while the binding resolves; throws ServiceUnavailable (a KernelError, code service_unavailable) when nothing provides it; re-renders when the binding changes. 07 §7.7 hooks.ts + services/use-service.ts. Resolution: local kernel-ui/* objects → local container → core scope (RemoteContainer over one WebSocket at /api/v1/kernel/scope). The contract is registered with the resolver through Suspense on first use. Suspense lands in the occupant's own SlotMount (fallback = owner fallback), never the React root. A lost scope socket invalidates every handle, toasts once, reconnects with backoff, and re-injects (H1). Catch the unavailable error after your other hooks so hook order stays stable (hello-slot useGreeter).
KernelError class KernelError extends Error { code: string; message; issues: Issue[] | null; data: JsonValue | null; retryable: boolean; toJSON(): KernelErrorShape; withData(extra) } The one error shape. Codes: invalid_input, invalid_output, invalid_facts, invalid_contract, reserved_name, unknown_method, unknown_command, unknown_event, not_found, unauthenticated, forbidden, vetoed, conflict, precondition, service_unavailable, needs_configuration, stale_handle, scope_disposed, timeout, cancelled, dispatch_depth, activation_loop, plugin_error, internal. A handle rejects with stale_handle across a reload; HTTP envelopes map back to the same codes. 02 §8 kernel-core/src/errors.ts; QueryState.error, MutationHandle.use().error and RowState.error carry it.
useToasts / Toasts () => Toasts; Toasts = { show(input: ToastInput): string; dismiss(id): void }; ToastInput = { tone: ToastTone; title; description: string | null; action: { label; onClick } | null }; ToastTone = "message" | "success" | "warning" | "error" | "loading" kernel-ui/toasts: a queue the kit's sonner Toaster drains (kitToastSink; the record id is the sonner id). 07 §7.2 services/toasts.ts; raised before mount → queued.
Dialogs (type only) { open<P, R>(Component: DialogComponent<P, R>, props: P, opts: { size: "sm" | "md" | "lg"; dismissible: boolean }): DialogHandle<R>; confirm({ title; description; confirmLabel; destructive }): Promise<boolean> }; DialogComponent<P, R> = ComponentType<P & { close(result: R): void }>; DialogHandle<R> = { result: Promise<R | undefined>; close(): void } The contract of ui-shell/dialogs (useService<Dialogs>("ui-shell/dialogs")). The kernel ships the type; a composition without ui-shell has no provider. 07 §7.1 services/contracts.ts.
Attention, AttentionLevel, AttentionTarget (type only) { set(source: string, level: AttentionLevel, target: AttentionTarget): void; clear(source): void }; AttentionLevel = "none" | "info" | "attention" | "urgent"; AttentionTarget = { kind: "app" } | { kind: "thread"; threadId } | { kind: "pane"; paneId } | { kind: "nav"; itemId } The contract of ui-shell/attention. 07 §7.3 services/contracts.ts.
Events, EventDecl, BbServices, ServiceDef, ServiceDefHandle interface Events { "<owner>/<name>": EventDecl<Payload, "emit" | "waterfall" | "parallel", Result> }; interface BbServices {} Declaration-merge hooks and authoring types shared with /contracts, re-exported so app code can name wire events (invalidateOn, follow) and handle types without a second import path. 02 §5, §11 contracts/index.ts; kernel-core/src/events.ts declares the kernel events (kernel/preferences.changed, kernel/activation.changed, kernel/catalog.changed, …).
// hello-slot app.tsx: tolerate service_unavailable after every other hook has run, so the hook order stays stable
function useGreeter(): GreeterHandle | null {
  try { return useService(greeter); } catch (error) {
    if (error instanceof KernelError && error.code === "service_unavailable") return null;
    throw error;                                   // a Suspense promise or a real bug
  }
}

4.10 Every app export (29 values, 21 hooks at e1efe72a3)

Member Signature (abridged) What it does Spec As built
definePluginApp (def) => PluginApp §4.1. 07 §2.3 define-plugin-app.ts
defineQuery (def: QueryDefinition) => UseQuery §4.8. 03 §8.5 hooks.ts
defineMutation (def: MutationDefinition) => UseMutation §4.8. 03 §8.5 hooks.ts
defineRowStore (def: RowStoreDefinition) => UseRowStore §4.8. 03 §8.5 hooks.ts
defineStore ({ init, persist?, actions }) => StoreDefinition §4.2 store option. 07 §5.7.1 slots/store.ts
useService (contract | id) => handle §4.9. 07 §7.7 hooks.ts
usePreference (ref | key, args?) => [value, set] §4.7. 07 §7.5 hooks.ts
useNavigation () => Navigation §4.5; facade defaults filled. 07 §3.2 hooks.ts
useShellHost () => ShellHost The negotiated shell (§4.12); shell.has(id) gates every capability; never branch on hello.surface for a feature. 07 §9 hooks.ts → kernel useShell
useToasts () => Toasts §4.9. 07 §7.2 KernelRoot.tsx
useCommandContext (key, active) => void §4.6. 07 §6.1 KernelRoot.tsx
useCommandHandler (id, handler, deps, opts?) => void §4.6. 07 §6.1 KernelRoot.tsx
useCommands () => ClientCommands §4.6; run(id, input?) / has(id). 07 §6.1 hooks.ts (added at e1efe72a3)
useShortcut (id) => Shortcut | null §4.6. 07 §6.2 KernelRoot.tsx
useIsCommandModifierHeld () => boolean §4.6. 07 §6.1 KernelRoot.tsx
useModalPresence (id, open) => void §4.6. 07 §6.1 KernelRoot.tsx
useLayoutTree (selector) => T §4.5. 07 §4.4 KernelRoot.tsx
usePane () => PaneProps §4.5. 07 §4.6 LayoutRoot.tsx
LayoutRoot (props: LayoutRootProps) => ReactNode §4.5; the pane tree for a plugin-owned root. 07 §3.1, §4.5 LayoutRoot.tsx (exported at e1efe72a3)
useLayoutMode () => LayoutMode "compact" | "regular": bootstrap override → shell layoutModeHint → (max-width: 767px); a LayoutModeProvider pin wins in its subtree. 07 §8 @bb/ui (G6; kernel installs its source)
useIsCompact () => boolean useLayoutMode() === "compact". 07 §8 @bb/ui
usePointerCoarse () => boolean (pointer: coarse); orthogonal to layout mode. 07 §8 @bb/ui
usePortalScopeProps () => { "data-bb-portaled-overlay": ""; "data-bb-plugin"?: string } Spread on portaled overlay content so plugin CSS reaches it and the desktop shell routes pointer input; kit overlays do it themselves. Omits data-bb-plugin outside a plugin mount. 07 §5.8 @bb/ui over PortalScopeProvider (mounted by SlotMount)
useOwnedEffect (release: () => void) => void Registers imperative state (locks, drag sessions, text effects) with the crash boundary's release registry; runs on crash and on unmount. Pass a stable function. 07 §5.8 SlotMount.tsx
useCloseGuard (guard: () => boolean | Promise<boolean>) => void Answers window.shouldClose: a guard returning true consumed the close ({ close: false }). 07 §9.2 (200 ms) create-ui-kernel.ts: 150 ms deadline, a throwing guard counts as false.
useAppPluginStatus (pluginId) => { status: PluginStatus; appTier: TierState; error: string | null; hash; generation; loaded } | null The in-memory app-tier table (§4.12). 07 §2.3 step 6 plugin-frontends.ts AppTierTable
useCatalog () => Catalog | null The last kernel-ui/catalog.get answer { revision; slots[] { name, kind, scope, owner, occupant }; panes[]; tabs[] }; null before the first kernel/catalog.changed fetch. 07 §5.9 boot/catalog.ts (shape is kernel-ui's request to 03)
CrashBoundary class extends Component<{ onCrash(error) }> The error boundary SlotMount uses; renders nothing after a crash. Exported for a plugin that wants its own inner boundary. 07 §5.8 SlotMount.tsx
KernelError class §4.9. 02 §8 @bb/kernel-core

4.11 Components and the import map

bb plugin build marks every import-map specifier external; the browser resolves them through the map core inlines into index.html (hashed, immutable URLs). Bundles load with import("<jsUrl>?h=<hash>").

Member Signature (abridged) What it does Spec As built
IMPORT_MAP_SPECIFIERS react, react-dom, react-dom/client, react/jsx-runtime, @get-bb/plugin-sdk/app, @bb/ui, @bb/ui/ (prefix), @radix-ui/react-dialog, -dropdown-menu, -popover, -tooltip, -context-menu, -select, -alert-dialog, -hover-card, sonner, @pierre/diffs, @pierre/diffs/react, clsx, tailwind-merge, class-variance-authority One React, one Radix dismissable-layer world, one sonner, pierre's worker pool, the cn() pair. vaul, react-menubar, react-navigation-menu are bundled, not mapped. 07 §2.1 boot/import-map.ts. The build also leaves react/jsx-dev-runtime, @get-bb/plugin-sdk/contracts (so app.tsx may import its own ./contracts.js) and @bb/design-tokens bare (plugin-build/src/externals.ts, D18); zod is bundled into every tier.
LayoutMode "compact" | "regular" The kit's type; see useLayoutMode (§4.10). 07 §8 @bb/ui
@bb/ui exports see table below The kit: one copy per window, semver'd with its own major (peer: { "@bb/ui": "^1" } in the manifest), source-visible for bb plugin vendor. 07 §11, Q18 packages/ui/src/index.ts
@bb/ui group Names on the import map
Primitives Button, Input, Textarea, Label, Checkbox, RadioGroup/RadioGroupItem, Switch, Separator, Skeleton, Badge, Pill, Kbd, EmptyState/EmptyStatePanel, OptionDisplay, Tabs/TabsList/TabsTrigger/TabsContent, ScrollArea/ScrollBar, Tooltip*, Select*, Dialog*, Popover*, DropdownMenu*, ContextMenu*, CompactLongPressMenu, Command*, Icon (+ registerIcons, ICON_NAMES)
Composites CopyButton, CopyableInlineLabel, TruncateStart, TruncatedList, ExpandableLine, OverflowFade, ScrollToBottomButton, SettingsSection/SettingsRow/SettingsRowList/SettingsBadge/SettingsWithControl, DetailCard/DetailRow/DetailRowIconLabel, TabPill, SplitButton, ImageLightbox, BbLogo, BottomAnchoredScrollBody, PageShell, RouteLoadingSkeleton, HeightTransition, AutoHeightContainer, CollapsibleHeader, ExpandablePanel
Drawer, toast, shortcuts PersistentDrawer, ResponsiveDrawer, MobileTrigger, useResponsiveRoot, useDrawerRealization, stripRadixContentProps; Toaster, toast, ToastContent; ShortcutHint, formatShortcut, formatShortcutAria, presentShortcut
Hooks and tokens cn, useLayoutMode, useIsCompact, useMediaQuery, usePrefersReducedMotion, usePointerCoarse, useColorScheme, useHoverPopover, usePortalScopeProps/PortalScopeProvider, LayoutModeProvider, CONTROL_HOVER_TRANSITION, LIST_HOVER_TRANSITION, COARSE_POINTER_*, CHROME_*_CLASS, activity*Class, MenuHoverProvider, useClipboardCopy, createScrollAnchorRegistry, beginLayoutAnimation
@bb/ui/domain Markdown, Diff, SourceCode (facades resolved through ui-markdown/render, slot:diff.renderer, slot:source.renderer; Unavailable when unbound), ProviderIcon, ModelLabel, FileBytes, Unavailable; concrete renderers in @bb/ui/domain/impl for the providing plugins only

IconName (@bb/ui, packages/ui/src/primitives/icon/icon.tsx) is a core name or an extended name; PluginIconName is <pluginId>/<name>. The 46 core names (CORE_ICON_MAP, on the boot path): AlertCircle, AlertTriangle, Archive, Bug, Check, ChevronDown, ChevronLeft, ChevronRight, Circle, CircleCheck, CircleQuestion, CircleX, ClosePluginPane, CloseThreadPane, Code, ComputerTerminal01, Copy, Download, Edit, Folder, FolderExport, FolderGit, FolderPlus, Info, ListTodo, Loading, MessageQuestion, MessageCirclePlus, MessageSquarePlus, MessageSquare, MoreHorizontal, PanelLeft, Search, SectionAdd, Settings, SlidersHorizontal, Spinner, Target, Terminal, Toolbox, ToolCase, Trash2, UserRoundPlus, Workflow, X, Zap. The 95 extended names (EXTENDED_ICON_NAMES, icon-registry.ts) load with the first route that needs them; ICON_NAMES lists both. <Icon name> takes IconName | PluginIconName; plugin glyphs enter the namespace through registerIcons(pluginId, map), and no kernel-ui or app code in the slice calls it for contributes.icons.named, so a <pluginId>/<name> value (hello-slot's hello-slot/wave) has no renderer today — use a core or extended name. app.panes.register icon and app.tabs.register icon take the same IconName (layout-store.ts). Props: Button = Omit<ButtonHTMLAttributes<HTMLButtonElement>, "title"> & { variant?: "default" | "destructive" | "outline" | "secondary" | "ghost" | "link"; size?: "default" | "sm" | "lg" | "icon"; asChild?: boolean } (primitives/button.tsx); Input = React.ComponentProps<"input"> (primitives/input.tsx); both forward their ref, and every other primitive passes its HTML or Radix props through the same way (packages/ui/src/primitives/<name>.tsx is the prop reference).

CSS rules (07 §2.5, §5.8, R34; packages/plugin-build/src/scope-plugin-utilities.ts): the build rewrites the compiled utilities layer to :where([data-bb-plugin="<id>"]) .cls, :where([data-bb-plugin="<id>"]).cls (both arms; never @scope). Authored CSS (global.css/app.css) must root every selector at [data-bb-plugin-effect="<own id>"], including selectors nested in @media/@supports; anything else fails the build. Stamp data-bb-plugin-effect="<contributorId>" on DOM you paint for another plugin (ui-editor-tiptap decorations) so the contributor's sheet reaches it. Overlays portaled to body carry data-bb-plugin through usePortalScopeProps. @bb/ui/ui.css and the app's theme.css are the host sheets; a plugin never imports Tailwind itself. The plugin's own dist/app.css is served as the descriptor's cssUrl and activated at commit (§4.12); at dc07292bf it never loaded — core looked the digest up under dist/app.css while the build keys meta.digests by the path under dist/ (app.css), so cssUrl was always null — and e1efe72a3 fixes the key (core/src/app.ts appDescriptor). Only core changed: a plugin built before the fix needs no rebuild. Manifest contributes.tokens values must derive from var(--canvas|--ink|<theme color>) or a color-mix of those.

4.12 Bundle and load lifecycle

bb plugin build writes dist/app.mjs, dist/app.css, dist/app.contributions.json (and meta.json with sdkMajor: 1, uiMajor: 1, per-file digests). app.contributions.json has no optional field: declares{} (each child slot: kind, scope, JSON-Schema props, boot, overlay, fallback, owner — the declaring registration's slot name, pane:<kind> for a pane kind's children, or the slot an inject thunk's registration targets; the last two at e1efe72a3), registers[] (name, kind, scope, key, priority, order, hasStore), injects[], routes[], panes[] (kind, priority, hasDock), tabs[] (kind, priority, persist incl. "dynamic", singleton, closable), commands[] (id, defaultShortcut, desktopOnly, menu), preferences[] (key, scope, default, schema). It is the app tier's only claim; contributes.slots in the manifest is informational and cross-checked at build (D9).

Step What happens Spec As built
bootstrap GET /api/v1/kernel-ui/bootstrap → { generation, catalogRevision, plugins: AppPluginDescriptor[], preferences (profile snapshot), layoutMode }; cached in localStorage["bb.uiBootstrap"] so the next cold start imports before the network answers. Descriptor: { id, version, generation, replaces, jsUrl, cssUrl, hash, jsBytes, sdkMajor, uiMajor, contributions, status: PluginStatus, appTier: TierState }. cssUrl is non-null when meta.digests has app.css (at e1efe72a3; before, core tested dist/app.css and it was always null, §4.11). 07 §2 step 6, §2.3 boot/bootstrap.ts; replaces added for order(); core/src/app.ts appDescriptor.
loadable only when status ∈ running | degraded and appTier ∈ ready | stale. PluginStatus = missing | incompatible | needs-update | disabled | replaced | waiting | activating | running | degraded | needs-configuration | failed | disposing | disposed (01 §4.1, R13); TierState = absent | ready | needs-update | error | stale. 07 §2.3 isLoadable.
waves Wave 1 = plugins owning the current route, registering into or injecting into a critical slot (root + every boot: "critical" declaration), or declaring children of one; wave 2 = the rest, started at route paint + idle or 1,500 ms after bootstrap. Order: route owner, then smallest jsBytes; 3 concurrent imports; commits in sorted id order after the wave arrives. 07 §2.2 planWaves, runWave, WAVE2_DEADLINE_MS.
one plugin gate sdkMajor === 1 && uiMajor === 1 (else needs-update) → preload CSS → import(jsUrl) → brand check → setup(api) in the collector → claim check → atomic commit (prefs, routes, commands, kinds, then slots; rollback of all on failure) → CSS activate (new <link data-bb-plugin-css> beside the old, swap on load; on error keep the old and record appTier: "error") → table entry. One toast per (id, hash) failure. 07 §2.3, §2.5 prepareFrontend / commitFrontend. CSS activates at commit, not first mount.
reconcile kernel/activation.changed → refetch bootstrap → reload descriptors whose hash, generation or status changed; unload those gone or no longer loadable (slots, routes, commands, kinds, preference definitions, CSS dropped; occupants unmount because the generation is in the instance key; latches cleared). Concurrent passes coalesce; pageshow.persisted runs one. kernel/catalog.changed refetches the catalog only. 07 §2.4 reconcile, refresh().
ShellHost { hello: ShellHello; has(id, version?): boolean; require(id, version?): Capability; currentServer(): ServerEntry }: the negotiated shell (@bb/shell-contract), via useShellHost(). Absent capability → render the kit's unavailable state. The kernel itself uses window (chrome variables, shouldClose, setTitle), keyboard (--shell-keyboard-height) and servers. 07 §9 shell-contract/src/host.ts: has/require match an exact version (default 1).

4.13 Types index

Member Signature (abridged) What it does Spec As built
Actor { kind: "human" | "agent" | "plugin" | "system"; id: string } Who invoked a command (Invocation.actor); the tab runs as human:local. 02 §7 kernel-core/src/actor.ts
JsonValue / JsonObject JSON unions Pane state, intents, preference values, mutation inputs. 02 §1 @bb/kernel-core
SlotKind / SlotScope "single" | "list" | "keyed" | "chain" / "root" | "thread" | "pane" The open unions behind §4.2. 07 §5.1 slot-tree.ts
PluginStatus / TierState see §4.12 Re-exported from @bb/kernel-loader/status. 01 §4.1 kernel-loader/src/status.ts
Slot types (§4.2–4.3) RegisterOptions, SlotsApi, SlotMap, SlotEntry, SlotKindOf, SlotScopeOf, SlotPropsOf, SlotDeclarationInput, SlotComponent, SlotComponentInput, SlotComponentProps, ScopeProps, RenderSlot, RenderSlotOptions, StoreDefinition, StoreFace pure aliases / interfaces 07 §5 slots.ts
Layout types (§4.5) RouteContribution, PaneKindInput, TabKindInput, PaneProps, PaneComponentProps, TabProps, PaneTarget, OpenWhere, OpenOptions, Navigation, LayoutTree, DockOwnerProps, DropZoneOwnerProps, LayoutRootProps, DragSession (the last two at e1efe72a3) pure aliases / interfaces 07 §3–§4 router.ts, layout-*.ts, LayoutRoot.tsx
Command types (§4.6) CommandContributionInput, CommandHandler, Invocation, ContextKey, When, Shortcut, ClientCommands (at e1efe72a3), Commands, CommandInput, CommandOutput pure aliases / interfaces 07 §6, 02 §6.3 command-bus.ts, kernel-core
Preference and data types (§4.7–4.8) PreferenceScope, PreferenceRef, ScopeArgs, QueryDefinition, QueryHandle, QueryState, InvalidateOn, KernelEntity, UseQuery, MutationDefinition, MutationHandle, UseMutation, RowStoreDefinition, RowStoreHandle, RowState, SeqOp, UseRowStore pure aliases / interfaces 07 §7.5–7.6, 03 §8.5 preferences.ts, define-query.ts, define-row-store.ts, hooks.ts
Service types (§4.9) AnyContract, HandleOf, ServiceDef, ServiceDefHandle, BbServices, Events, EventDecl, Toasts, ToastInput, ToastTone, Dialogs, DialogComponent, DialogHandle, Attention, AttentionLevel, AttentionTarget pure aliases / interfaces 07 §7, 02 §5, 02 §11 contracts/handles.ts, services/*.ts
Shell and app types (§4.1, §4.10–4.12) PluginApp, PluginAppApi, ShellHost, LayoutMode see the cited subsection 07 §2.3, §8, §9 define-plugin-app.ts, @bb/shell-contract, @bb/ui

4.14 Typed slots and the generated catalog

The SlotMap machinery (§4.2) is built; the catalog around it is design. Guide 16 is the worked form. Normative rules:

Rule Statement Status
publication The declarer publishes its SlotMap merge and named owner-prop types in src/contracts.ts, so they ship in dist/types/contracts.d.ts under the ./contracts export (§2.1). A merge in app.tsx or an app helper types one file and publishes nothing. built (convention; tsconfig.types.json includes only src/contracts.ts)
ownership One merge per slot name, owned by the declarer. A consumer imports the declarer's contracts module; it hand-merges only when the declarer publishes nothing, naming only the fields it reads. Two incompatible merges in one program fail the compile — that failure is correct, not a bug to route around. built (TypeScript semantics)
generation source The generated catalog joins three sources, in precedence order: (1) runtime declarations — every installed artifact's app.contributions.json declares plus the kernel's a priori slots — authoritative for existence, kind, scope, owner; (2) the declarer's published merge, authoritative for props TypeScript; (3) the declaration's JSON-Schema props, generating structural fallback types (callbacks degrade to (...args: unknown[]) => unknown; the collector records functions as any, §4.12). design
generator bb sdk types --slots writes bb/slots.d.ts for the plugin workspace: an @get-bb/plugin-sdk/app SlotMap augmentation covering every installed declaration, re-exporting published merges by reference and synthesizing the rest from JSON Schema. Disagreement between a published merge and the runtime declaration (kind/scope), or between two published merges, fails generation and names both sources. design (rides bb sdk types, §8.3)
distribution Outside the workspace, types resolve from the installed artifact, never npm: bb sdk types materializes each installed plugin's dist/types/ into bb/types/<id>/ and emits a generated paths block mapping @get-bb/plugin-*/contracts (and /app, §4.16) onto it. Types and bytes share one digest-addressed artifact, so they cannot skew; rerun after install or update, the same habit as the service declarations. Publishing a types package to npm stays optional, never required. design (Guide 16 step 4)
dump bb plugin slots [<pluginId>] [--json] prints the joined catalog row per slot: name, kind, scope, declaring plugin, owner (<slot> | pane:<kind> | tab:<kind>), boot, overlay, fallback, props schema, whether a published merge exists, current occupants. --json is the machine-readable catalog; §5 is its prose rendering. design
exclusions Generation reads built artifacts, never source, so test-only slots (test.* in app.test.*) and app-only mirrors cannot enter the catalog. contributes.slots (a flat claim of registration/injection targets, §1.3) contributes nothing to generation. design

4.15 Cross-plugin slots: the contract

The mechanism is §4.2 (children, inject) and §4.4 (arbitration); Guide 17 is the worked form. The contract between a declarer and an injector:

Clause Statement Status
no dependency inject never creates a dependency. An injector whose target has no live declaration is healthy: the thunk stays armed, runs when any declaration with that name appears (registration children, or a pane/tab kind's children while that kind's plugin wins it), and its registrations drop when the declaration vanishes. A plugin that is useless without the declarer states requires (§1.3) instead. built (slot-tree.ts runInjects)
absence semantics Every declarer failure is silent for the injector: never installed / disabled → thunk armed, never run; unloaded at runtime → registrations dropped, thunk re-arms; redeclared → thunk re-runs; slot dropped in a new version → registrations dropped, thunk waits. The injector's status is unaffected in every row. The only injector-degrading event is its own thunk throwing: partial registrations dropped, degraded, one toast, no retry until the injector reloads (§4.2). built
name is the contract A slot name binds kind, scope, props, and owner-prop behavior. A compatible change (new optional prop) keeps the name; an incompatible change (removed/retyped prop, kind or scope change) requires a new name, because the kernel re-realizes thunks on name existence only — a declaration that changes shape under a realized injector does not re-run or re-validate it, and the stale registration renders wrong props, not an error. rule (the gap it guards is as built: runInjects checks the name only)
arbitration parity Injected registrations arbitrate identically to the declarer's own (replaces → pin → priority → sorted id); the declarer has no reserved rank in its own slot. An out-arbitrated declarer's declaration is parked, not removed: its registrations stay in the map, reachable through an Original chain (resolve checks declaration, not liveDeclaration — as built). Treat a parked slot as gone; the parked-reachability edge is not contract. built, with one as-built edge
discovery Build-time: the declarer's published merge, §5, or bb plugin slots (§4.14, design). Runtime: useCatalog() — flat {name, kind, scope, owner, occupant} claims from every installed app tier, no props, no live/parked distinction (§4.10). There is deliberately no runtime API that enumerates foreign declarations with props; props travel through types. built
worker rows Composition isolation applies to the server tier only (§1.10); app tiers always load in the browser, so a worker-isolated declarer or injector behaves identically here. A worker exit fails the row; the next reconcile unloads its app tier and the unload row above applies. built

4.16 App module exports

Status: design; the import path below is not built at f97fbe617 (Appendix B, H9). Components already cross the boundary by value (first row); what does not exist is sharing by import: the builder rejects sibling imports other than @get-bb/plugin-<id>/contracts (externals.ts), and the loader imports only the descriptor's jsUrl, ignoring named exports (the ./app package entries on ui-composer and git serve tests only). Guide 18 is the worked form; the surface:

Member Definition (design)
by-value sharing (as built) Three channels, none an import: (1) slot machinery — owner props and registration objects carry components (Original, markdown.extension's { name, Component } rows, composer.typeahead.source/composer.submit data-slot values, message.action occupants); (2) the @bb/ui/domain facades (Markdown, Diff, SourceCode), resolved through ui-markdown/render and the renderer slots (§4.11); (3) browser-local app services — the built UI plugins publish service objects, components included, through one window table keyed Symbol.for("bb.ui-shell.services") (each plugin carries a verbatim app-services.ts copy; consumers resolve the ids with useService, §4.9). The table is an interim mechanism: the SDK's missing piece is app.services.provide, and the copies are deleted when it lands. By-value sharing is asynchronous (value exists only after the provider commits; consumers render a fallback) and untyped beyond the service interface; the rows below add the synchronous, versioned, build-time path.
AppServices + app.services.provide The typed retirement of the window table. The SDK adds interface AppServices {} as a declaration-merge registry for browser-local services, app.services.provide(id, value): void on PluginAppApi (published at commit, withdrawn with the plugin's generation), and a useService overload keyed by the registry, so useService("ui-markdown/render") returns the merged type with no caller assertion. The provider merges AppServices in its contracts module; React types enter type-only (import type { ComponentType } from "react" erases, so contracts stay tier-neutral — ui-timeline-view/contracts.ts already does this). Absence keeps the service semantics: suspend while resolving, then service_unavailable (§4.9). Every copied app-services.ts is deleted, not edited, when this lands.
bb.appExports Manifest key naming the export entry (./src/app-exports.ts); requires bb.app. Built to dist/app-exports.mjs with the app tier's external rules (§4.11); named exports recorded in meta.json appExports: string[]; top-level statements other than declarations and re-exports fail the build (side-effect-free at import).
contributes.appImports Record<pluginId, major> on the consumer. Plan-time check: exporter absent/disabled or appExports surface absent → consumer app tier not loadable, degraded, detail missing_app_import; major mismatch → needs-update. An unclaimed @get-bb/plugin-<id>/app import stays a build error.
import path @get-bb/plugin-<id>/app, left bare by the builder when claimed; resolved in the browser through a per-exporter import-map entry core inlines into index.html: /plugins/app/<id>/app-exports.mjs?h=<digest> (digest-suffixed, immutable). Types resolve through the exporter package's ./app types condition (dist/types/app-exports.d.ts).
identity The exports bundle shares the map's externals, so React, @bb/ui, zod, and the SDK resolve to the single shared runtime modules; one module evaluation per page, shared by all consumers.
updates An import map cannot change after resolution starts: a new exporter's entry appears on the next page load; an exporter artifact update with a loaded claimant triggers toast + page reload (the H1 rule); with no loaded claimant, nothing reloads. Consumer updates reload per-plugin as usual.
CSS An exported component mounts in the consumer's data-bb-plugin subtree, so every exported component renders a root carrying the exporter's data-bb-plugin-effect via usePluginEffectProps() (reads the defining bundle's id, not the ambient mount). The exporter's utilities compile into its own app.css with a [data-bb-plugin-effect="<id>"] arm; that sheet rides the exporter's app tier, which the plan check keeps present.
importAppModule (pluginId: string) => Promise<Record<string, unknown> | null> — the soft path: resolves the map entry, null when absent. Static import = hard dependency (loader-gated); importAppModule = optional presence, injector-style (§4.15).
worker rows Unchanged: exports are browser code served like app.mjs; worker isolation covers server/background entries only. Marketplace review treats appExports as app-tier code that runs inside other plugins' subtrees.