Cross-plugin slots
The slot tree is dynamic: any occupant can declare new slots with children, and any plugin can fill a foreign slot with inject (Guide 7, steps 1 and 8). The kernel already runs all of it — declaration, injection, arbitration, and teardown. What it does not do is tell either side what to expect from the other. This page is that contract. It names the parties (the declarer, who owns a slot; the injector, who fills it), fixes what each may assume, and walks every failure mode: the declarer that is absent, disabled, out-arbitrated, updated, or crashed. The behavior described here is as built at f97fbe617 unless marked as a rule; reference §4.15 is the normative form.
Use this when
- Fill a slot another plugin declares.
injectis the only sanctioned form; know what happens when the declarer is gone. - Open your panel to other plugins. Declaring
childrenmakes you a declarer; this page states what you owe your injectors. - Decide whether a foreign slot is safe to depend on. The absence semantics decide whether you need
requires. - Change a slot you already published. The versioning rule tells you when a change needs a new name.
- Debug an injection that renders nothing. The failure table maps each cause to its observable effect.
What you build
No new plugin. The deploy plugin from Guide 7 plays both parts: as an injector into ui-sidebar's sidebar.footer, and as the declarer of deploy.panel.actions. Each step adds one clause of the contract to that code.
Steps
1. Injection is late binding, not a dependency
app.slots.inject(name, thunk) stores the thunk against the target name. The kernel runs it when any declaration with that name exists — from a live registration's children, or from a pane or tab kind's children while that kind's plugin wins the kind (§4.2). Order does not matter: an injector that commits before its declarer waits; when the declarer commits, the thunk runs synchronously after its declarations attach. When the declaration vanishes, the thunk's registrations are dropped and the thunk re-arms; it runs again on redeclaration.
The contract: injection never creates a dependency. An injector with no live declarer is healthy — loaded, not degraded, contributing nothing to that slot. Write the thunk as plain registration code that reads nothing from the live tree; the build runs it against a recorder to collect its children claims (§4.12), so a thunk that touches the DOM or a service fails the build's claim check before it can fail a user.
app.slots.inject("sidebar.footer", (slots) =>
slots.register({ name: "sidebar.footer", kind: "list", scope: "root", order: 50 }, DeployButton));If your plugin is useless without the declarer — not just quieter — declare requires in the manifest instead of relying on injection's tolerance (§1.3). Injection tolerance is for optional presence; requires is for real dependency.
2. What the declarer owes: the name is the contract
A declarer publishes three things under one slot name: the runtime declaration (kind, scope, zod props — in app.tsx), the type merge (SlotMap — in contracts.ts, Guide 16 step 2), and the behavior of the owner props it passes to renderSlot. Injectors bind to the name. So the rule is:
- Compatible change (a new optional owner prop, a widened callback): keep the name. Injectors keep working; new fields are invisible to old code.
- Incompatible change (a removed or retyped prop, a kind or scope change, different semantics): publish a new name and keep the old declaration through one major of your package, or remove it and accept that injectors go dark (step 4 covers how they go dark — silently and safely).
This rule exists because the kernel enforces none of it: runInjects re-realizes a thunk on name existence only. A declaration that changes shape under a realized injector does not re-run or re-validate that injector (as built); the stale registration sits beside the new declaration until the injector reloads. Do not rely on that gap — it renders wrong props, not an error.
3. Discover what exists
At build and review time, read the declarer's published merge and reference §5, or dump the joined catalog with bb plugin slots (Guide 16 step 6; design). At runtime, useCatalog() returns the flat claim table — every slot's name, kind, scope, owner, and the single-slot occupant — refreshed on kernel/catalog.changed (§4.10). It reports claims from every installed app tier, including plugins that are not currently loaded, and it carries no props; use it to decide whether to point a user somewhere, never to type a registration. There is no API that enumerates another plugin's declarations with props at runtime; that is deliberate — props travel through types (Guide 16), not through reflection.
4. The failure modes, one by one
What an injector observes, per declarer state:
| Declarer state | Declaration | Injected registrations | Injector status |
|---|---|---|---|
| never installed | absent | thunk armed, never run | healthy |
| installed, disabled | absent (descriptor not loadable) | thunk armed, never run | healthy |
| unloads at runtime (removed, failed reload survives via rollback — see below) | removed | dropped; thunk re-arms | healthy |
| redeclares (reload, re-enable) | back | thunk re-runs | healthy |
| loses exclusive arbitration | parked | kept in the registration map; the winner cannot render them | healthy |
| new version drops the slot | removed | dropped; thunk re-arms and waits forever | healthy |
| pane/tab kind loses the kind | parked while another plugin wins the kind | reachable only through the old kind component if it still mounts via Original |
healthy |
| crashes as an occupant | declaration stays (crash latches the occupant, not the declaration) | rendered by the next candidate or fallback | healthy |
Two asymmetries to hold on to. First, every declarer failure leaves the injector healthy — absence is silence, never an error. The only way an injector degrades is its own thunk throwing: the partial registrations are dropped, the injector is marked degraded with one toast, and the thunk is not retried until the injector reloads (§4.2). Second, teardown is not symmetric with arbitration: a declarer that unloads takes injected registrations down, but a declarer that merely loses arbitration parks its declaration and the registrations stay in the map. A candidate mounted through an Original chain can still render its own children from a parked declaration (as built — resolve checks declaration, not liveDeclaration). Do not design around that edge; treat a parked slot as gone.
A failed declarer reload is the subtle row: rollback restores the previous generation (§4.1), so the old declaration — and your injected content — stays live under the old shape. Combined with step 2's no-revalidation gap, this is why the name-is-the-contract rule is strict.
5. Worker isolation changes nothing here
Marketplace rows run their server tier in a worker (§1.10). The app tier always runs in the browser: the loader reads app.contributions.json from the artifact, core serves dist/app.mjs, and the browser loads it exactly as for an in-process plugin. A worker-isolated declarer or injector therefore behaves identically in the slot tree. The one interaction: when the worker exits, the loader fails the row, the next reconcile unloads the plugin's app tier, and the "unloads at runtime" row of the table applies.
What happens at runtime
- Commit wave: plugins commit in sorted id order (§4.4). After each commit,
runInjectsruns every armed thunk whose target now has a declaration (≤ 8 passes per commit). - A realized thunk's registrations join the slot's candidate set and arbitrate exactly like the declarer's own:
replaces→ pin →prioritydesc → sorted plugin id (§4.4). A declarer has no home-field advantage in its own slot. kernel/activation.changedreconciles. An unloaded declarer's declarations detach; dependent thunks un-realize and their registrations drop; nothing toasts.- The declarer returns; thunks re-run; injected content reappears. The injector's store state does not survive the round trip — the instance key changed with the declarer's generation (§4.3).
Pitfalls
injectinto a name you also declare is allowed but pointless; register directly into your own slots.- A thunk that throws punishes you, not the declarer: degraded status, one toast, no retry until reload. Keep thunks to bare
registercalls. - Do not poll
useCatalog()to decide whether to inject — the kernel already does that binding for you, and the catalog reports claims, not live declarations. - An injected
single/keyed/chainregistration can out-arbitrate the declarer's own occupant. That is a feature (forks, pins) — but if you only want to add, target alistslot or wrapOriginalinstead of replacing (Guide 7, step 4). - Two live occupants declaring the same child name is
SlotDeclarationConflictfor the later plugin id (§4.4). Namespace child slots under your plugin id (deploy.panel.actions), never generically (panel.actions). - Absence semantics mean your UI must read correctly with the slot empty. If an empty
renderSlotlooks broken, add afallback(§4.3) — do notrequiresa UI plugin just to fill a gap.
See also
- Guide 7, UI slots — registration, kinds,
children, arbitration. - Guide 16, Typed slots — publishing and consuming the types across this boundary.
- Reference §4.2 (
inject, declarations), §4.4 (arbitration, conflicts), §4.15 (this contract, normative), §5 (the catalog).