Typed slots and the generated catalog
Every slot registration is typed through one interface: SlotMap in @get-bb/plugin-sdk/app (§4.2). A declarer merges an entry into it, and every consumer gets a narrowed register and typed owner props with no value import. The mechanism is built and works; the problem is publication. Today most merges live in files that never ship types, so a consumer's registration degrades to Record<string, unknown>. This page states the publication rules, then specifies how types travel to consumers: distribution from the installed artifact, generation for what nobody publishes, and the catalog dump. The tooling (step 4's distribution, bb sdk types --slots, bb plugin slots) is design at f97fbe617; everything in steps 1–3 is built.
Use this when
- Type your own child slots for other plugins. Publish the
SlotMapmerge in your contracts module. - Register into a foreign slot with real types. Import the declarer's
/contractsentry. - The declarer publishes no merge. Generate the types with
bb sdk types --slotsinstead of hand-merging. - Type against a plugin bb installed.
bb sdk typesmaterializes the artifact's.d.tsfiles; npm is never involved. - Find every slot on this install, with props.
bb plugin slotsdumps the joined catalog. - Stop a duplicate-augmentation conflict. Keep one merge per slot name, in one published module.
What you build
No new plugin. This page reworks the deploy plugin from Guide 7: its deploy.panel.actions merge moves from app.tsx into contracts.ts so it ships in dist/types/contracts.d.ts, its foreign registrations import @get-bb/plugin-ui-sidebar/contracts instead of a hand-written slot-types.ts, and one generated bb/slots.d.ts covers the slots whose declarers publish nothing.
Steps
1. Know what the types do — and when they vanish
SlotMap is an empty interface with declaration-merge semantics (§4.2). SlotKindOf<N>, SlotScopeOf<N>, and SlotPropsOf<N> select the merged entry for N. For a merged name, register narrows kind and scope to literals and the component receives typed owner props. For an unmerged name, kind and scope stay open unions and props become Record<string, unknown> (packages/plugin-sdk/src/app/slots.ts). Nothing fails at build or load: the registration still validates against the runtime declaration (§4.2), so a wrong hand-merge compiles cleanly and lies. Treat an unmerged foreign owner's props as a boundary (Guide 7, Pitfalls).
2. Publish your merge in the contracts module
Only src/contracts.ts ships types. The type build (tsconfig.types.json) emits dist/types/contracts.d.ts from that one file, and the package's ./contracts export points at it. A merge in app.tsx types your own file and no one else's. The rule: declare the runtime child in app.tsx; declare the type in contracts.ts; export the props interface too.
// contracts.ts (published as @bb-local/plugin-deploy/contracts)
export interface DeployPanelActionsOwner { releaseId: string }
declare module "@get-bb/plugin-sdk/app" {
interface SlotMap { "deploy.panel.actions": { kind: "list"; scope: "pane"; props: DeployPanelActionsOwner } }
}The built UI plugins follow this split: ui-sidebar, ui-thread-list, ui-thread-page, ui-timeline-view, ui-composer, ui-settings, and the panels all publish their owner types in plugins/<id>/src/contracts.ts. The runtime children schemas stay in app.tsx and are often looser (ui-composer's runtime table uses props: any); the published merge is the precise contract.
3. Consume a published merge
Import the declarer's contracts entry for its side effect and its named types:
import type { SidebarFooterOwner } from "@get-bb/plugin-ui-sidebar/contracts";
import "@get-bb/plugin-ui-sidebar/contracts"; // loads the SlotMap merge@get-bb/plugin-<id>/contracts resolves to dist/types/contracts.d.ts for TypeScript and dist/contracts.mjs at runtime (§2.1). A type-only import is enough for the merge, but the bare side-effect import keeps the merge alive when your bundler drops unused type imports. Do not hand-copy the shape into your own slot-types.ts unless the declarer publishes nothing — a copied merge goes stale silently, and two incompatible merges for one name fail the consumer's compile.
4. Resolve the types outside the workspace
(Design; not built at f97fbe617.) Inside the bb workspace, @get-bb/plugin-<id>/contracts resolves through pnpm. A third-party plugin has no such path: its declarer was installed by bb, not npm, so node_modules carries nothing to resolve the specifier against. The rule that closes this: the installed artifact is the type source. bb sdk types — which already writes the service declarations from installed JSON Schemas (§8.3) — also materializes every installed plugin's dist/types/ into bb/types/<id>/ and emits a generated paths block the plugin's tsconfig.json extends:
// bb/tsconfig.paths.json (generated)
{ "compilerOptions": { "paths": {
"@get-bb/plugin-*/contracts": ["./bb/types/*/contracts.d.ts"],
"@get-bb/plugin-*/app": ["./bb/types/*/app-exports.d.ts"] // Guide 18
} } }Types and bytes come from the same digest-addressed artifact, so they cannot skew the way an npm types package can. Rerun after bb plugin install or an update — the same habit the service side already requires. Publishing a types package to npm stays possible for declarers who want it; it is never required.
5. Generate what is not published — bb sdk types --slots
(Design; the flag is not built at f97fbe617.) bb sdk types already generates BbServices merges from installed service schemas (§8.3). The --slots extension generates the slot side into bb/slots.d.ts in the plugin workspace:
bb sdk types --slots # writes bb/bb-services.d.ts and bb/slots.d.tsWhat gets generated, and from where — in precedence order:
- Existence, kind, scope, owner: the runtime declarations. The generator reads every installed plugin's
app.contributions.jsondeclaresmap (plus the kernel's a prioriroot,diff.renderer,source.renderer). This is the same claim the browser enforces at load (§4.12), so the generated names can never drift from whatregisteraccepts. Test-only slots never appear: generation reads built artifacts, not source. - Props types: the declarer's published
dist/types/contracts.d.ts, when it merges the name. The generator re-exports that merge by reference (animport "..."line), so TypeScript names, aliases, and callback signatures survive. - Props types, fallback: the declaration's JSON-Schema props. When no published merge covers a name, the generator emits a structural type from the schema in
declares. JSON Schema cannot carry functions — the collector records them asany(§4.12) — so a callback field generates as(...args: unknown[]) => unknownwith a/** generated from JSON Schema; import the declarer's contracts for the real signature */note.
Conflicts are errors, not merges: when a published merge and the runtime declaration disagree on kind or scope, or two installed plugins publish incompatible merges for one name, the generator fails and names both sources. That failure is the catalog's lint: today the same conflict surfaces as an unexplained compile error in whichever consumer imports both.
6. Inspect the live catalog — bb plugin slots
(Design; not built at f97fbe617.) The runtime hook useCatalog() returns a flat list — name, kind, scope, owner, single-slot occupant — with no props and no parent authority (§4.10). The CLI form joins the three sources the generator uses and prints the full row:
bb plugin slots # every declared slot on this install
bb plugin slots ui-composer # one declarer's slots
bb plugin slots --json # the joined catalog as JSONEach row: name, kind, scope, declaring plugin, owner (<slot>, pane:<kind>, or tab:<kind>), boot, overlay, fallback, the props schema, whether a published type merge exists, and the current occupants. The --json form is the machine-readable catalog; reference §5 is its prose rendering and stops being hand-maintained once this exists.
What happens at runtime
Nothing new. The catalog is a build-time and type-time surface. register and inject validate against the live declaration exactly as before (§4.2); generated types change what compiles, never what loads. A stale bb/slots.d.ts after a plugin update compiles against old shapes and fails, if at all, in the declarer's own zod validation or in your component — regenerate after bb plugin install the way you rerun bb sdk types after a service change.
Pitfalls
- A merge in
app.tsxpublishes nothing:tsconfig.types.jsonincludes onlysrc/contracts.ts. Fourteen built plugins carry app-only mirrors for exactly this reason — they type one consumer, not the catalog. contributes.slotsin the manifest is a flat name list of what you register or inject into (§1.3). It carries no kinds, no props, and no declarations; nothing generates from it.- Two published merges for one name must be identical, or every program that sees both fails to compile. The declarer owns the merge; consumers import it.
- The JSON-Schema fallback cannot restore callback signatures or type aliases. When the generated type shows
unknowncallbacks, ask the declarer to publish (step 2) rather than hand-writing the signature. - The kernel's a priori slots (
root,diff.renderer,source.renderer) are merged by the SDK itself; do not redeclare them.
See also
- Guide 7, UI slots — the four kinds,
register,inject, and declaringchildren. - Guide 17, Cross-plugin slots — the contract across the plugin boundary, and what absence means.
- Guide 18, Exporting frontend code — the same package-carries-types split applied to
/appexports and typed browser-local services (AppServices, §4.16). - Reference §4.2 (
SlotMap, the conditional types), §4.12 (app.contributions.json), §4.14 (this design, normative), §5 (the catalog as prose).