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 (27 values, 82 types). 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. 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, or a live occupant's children) or the load fails (07 §5.1; slot-tree.ts validate).
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).
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. 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. 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 → CrashBoundarySuspense (fallback = the owner fallback) → <div data-bb-plugin-root data-bb-plugin="<id>" class="contents">. React key = instance key ${name}|${key ?? ""}|${pluginId}|${generation}|${scopeKey} (+ |<mode>), so a new generation or a mode change remounts (07 §5.8; 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 (PaneHostusePaneMountkindChainHead, 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. LayoutRoot is not exported from @get-bb/plugin-sdk/app, so a plugin's own root occupant cannot host panes: it renders only what it returns. 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 does not appear; render the same panel from both places, as hello-slot does (HelloPanel is both the root occupant and the hello pane component). 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.
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.
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.

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. 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.
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. 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).
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.tsusePreferenceOf. A profile/thread cell reads its default until the first core read lands; 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[]): 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 }. 07 §7.6, §7.10 define-row-store.ts (shape is the built one, D18); 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). 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 (27 values, 20 hooks)

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
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
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. 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), 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 }. 07 §2 step 6, §2.3 boot/bootstrap.ts; replaces added for order().
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 pure aliases / interfaces 07 §3–§4 router.ts, layout-*.ts, LayoutRoot.tsx
Command types (§4.6) CommandContributionInput, CommandHandler, Invocation, ContextKey, When, Shortcut, 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