Surfaces and kinds
A surface is a typed UI contract. A plugin claims a surface and provides its implementation through the app factory.
The surface kind controls how bb combines all claims. The four kinds are single, list, keyed, and chain.
Use this when
- Replace one UI region. Claim a
singlesurface such asbb.layout.sidebar. - Add one item beside other items. Claim a
listsurface such asbb.thread-ui.sidePanels. - Handle one data type. Claim one key of a
keyedsurface such asbb.thread-ui.timeline.item. - Wrap one operation. Claim a
chainsurface such asbb.thread-ui.composer.send. - Let the user select an implementation. Claim a replaceable surface and let the global picker control the winner.
What you build
You will build one acme.review app plugin. It uses one claim of each kind.
The plugin adds a sidebar notice and a review panel. It also renders review items and checks review sends.
The surface graph
The kernel declares only bb.app.root. The active root winner declares its child surfaces.
Each active winner can declare more child surfaces. This rule creates a graph that follows the current winners.
For example, the default thread view declares bb.thread-ui.sidePanels. That surface exists only while an active view declares it.
A replacement view can keep that child surface. It can also omit it.
The picker shows a claim as inactive when its parent surface does not exist. The plugin does not fail for this reason.
Contract IDs use dots. First-party IDs start with bb., and plugin-owned IDs start with the plugin ID.
Examples include bb.thread-ui.composer and acme.review.summary. Do not use a slash in a contract ID.
Claims are static
Put every surface claim in bb.plugin.jsonc. The host reads claims before it runs plugin code.
This information lets the host find conflicts and prepare the winner picker. The build also generates contract.json.
// bb.plugin.jsonc
{
"id": "acme.review",
"version": "2.0.0",
"claims": [
{ "surface": "bb.layout.sidebar" },
{ "surface": "bb.thread-ui.sidePanels" },
{
"surface": "bb.thread-ui.timeline.item",
"key": "acme.review/review"
},
{ "surface": "bb.thread-ui.composer.send" }
],
"artifacts": {
"app": "./dist/app.js"
}
}acme.review/review is a timeline item key. It is not a contract ID.
A claim says that the plugin can provide a contract. The app factory supplies the behavior for that claim.
Choose a kind
The contract owner selects the kind when it declares the surface. A claimant must follow that kind.
| Kind | Active result | User control | Typical surface |
|---|---|---|---|
single |
One implementation | Select one winner when replacement is allowed | bb.layout.sidebar |
list |
All items in a stable order | Hide or reorder items | bb.thread-ui.sidePanels |
keyed |
One implementation for each key | Select one winner for each replaceable key | bb.thread-ui.timeline.item |
chain |
Ordered wrappers around one base | Inspect and reorder wrappers | bb.thread-ui.composer.send |
The app artifact imports definePlugin from @get-bb/plugin/app. Its factory receives a typed api object.
The runtime removes each api registration when the plugin unloads. You do not need a cleanup function for these registrations.
The next four sections form one src/app.tsx file.
single: wrap the sidebar
A single surface has one active implementation. The owner can mark the surface as replaceable.
The active winner receives props and a typed Original component. Render Original to include the first-party default.
// src/app.tsx
import type { ComponentType } from "react";
import { definePlugin } from "@get-bb/plugin/app";
import type { LayoutSidebarProps } from "@bb/layout/contracts";
import type {
ComposerSendMiddleware,
ThreadSidePanelProps,
TimelineItemProps,
} from "@bb/thread-ui/contracts";
function ReviewSidebar(input: {
Original: ComponentType<LayoutSidebarProps>;
props: LayoutSidebarProps;
}) {
const { Original, props } = input;
return (
<>
<div role="status">Review tools are active.</div>
<Original {...props} />
</>
);
}This wrapper keeps the default sidebar. It adds a small notice before it.
Original always means the default implementation. It never means the previous claimant or the next claimant.
Only a single app surface receives Original. A keyed winner does not receive it.
list: add a side panel
A list surface activates every valid claim. The owner renders the items in a deterministic order.
Each item has an identity inside its plugin. The owner contract can add fields such as title, placement, and component.
function ReviewPanel(props: ThreadSidePanelProps) {
return (
<section>
<h2>Review</h2>
<p>Check the current change before you send it.</p>
<button type="button" onClick={props.close}>Close</button>
</section>
);
}
const reviewPanel = {
id: "review-panel",
title: "Review",
placement: "thread" as const,
component: ReviewPanel,
};A list does not have one winner. A new list claim does not replace an installed item.
The user can hide an item or change its order. The picker shows these list controls without a winner conflict.
keyed: render one timeline type
A keyed surface has a separate selection for each key. The timeline uses the exact item type as its key.
Plugin-owned timeline types use <pluginId>/<name>. This key format does not change the dotted surface ID.
function ReviewItem(props: TimelineItemProps) {
return (
<article>
<strong>Review request</strong>
<p>Timeline type: {props.item.type}</p>
</article>
);
}
const reviewItem = {
key: "acme.review/review",
component: ReviewItem,
};The kernel stores one winner for bb.thread-ui.timeline.item plus this key. Other keys keep their own winners.
The first-party * claim handles an unknown extension type. A failed keyed winner returns to its declared default claimant.
chain: check a send
A chain surface puts ordered wrappers around a base operation. Each wrapper can continue, change, or stop the operation.
The composer freezes the chain for one send. A middleware calls next() at most once.
const requireReviewSummary: ComposerSendMiddleware = {
id: "require-summary",
order: 550,
when: ({ draft }) => draft.text.startsWith("/review"),
async run(context, next) {
if (context.draft.text.trim() === "/review") {
return {
status: "blocked",
code: "acme.review.summary",
reason: "Add a review summary.",
};
}
return next();
},
};The user can inspect and reorder chain members. A chain does not select one winner and discard the other members.
Keep a chain member small. Use a service when several plugins need shared state or long operations.
Provide all four claims
Complete src/app.tsx with one factory. Each call matches one claim from the manifest.
export default definePlugin((api) => {
api.surfaces.provide("bb.layout.sidebar", ReviewSidebar);
api.surfaces.provide(
"bb.thread-ui.sidePanels",
reviewPanel,
);
api.surfaces.provide(
"bb.thread-ui.timeline.item",
reviewItem,
);
api.surfaces.provide(
"bb.thread-ui.composer.send",
requireReviewSummary,
);
});The loader stages these registrations as one plugin generation. A factory error leaves the current generation active.
Winners and the picker
The kernel uses one global winner for each replaceable single contract. It also uses one winner for each replaceable keyed key.
A fresh install selects the first-party claimant or the declared default. The user does not need to make an initial choice.
A second claimant does not replace the current winner. The picker shows both claimants and waits for a user choice.
The picker groups a fork with its parent. The fork still has its own plugin ID and its own claim.
A winner change remounts the surface. The plugin owns its state, so local component state can reset.
The winner applies to every instance of the contract. A plugin cannot select a different winner for one thread or one panel.
A second claim for a non-replaceable single contract fails at load. The same rule applies to a non-replaceable keyed key.
Error boundaries and fallback
The host puts an error boundary around each surface. A component error cannot remove the recovery UI.
When the current surface winner fails, the host marks its claim as failed. The host also shows a visible notice.
A failed single winner falls back to Original or the declared default claimant. A failed keyed winner uses the default claimant for that key.
The fallback does not select another replacement claimant. Original remains the first-party default.
Safe mode disables all overrides and starts only verified defaults. The kernel keeps plugin management and recovery available.
Pitfalls
- Do not put a surface implementation in code without a static claim for it.
- Do not use a slash in a contract ID. A domain key can use a slash when its owner contract requires one.
- Do not expect
Originalonlist,keyed, orchainsurfaces. - Do not assume a child surface always exists. Its active parent winner controls its lifetime.
- Do not use plugin load order to resolve a conflict. Use the picker for winners and chain order.
- Do not keep durable data only in component state. A winner change remounts the surface.
See also
- Guide 2, Services — use the same four kinds for backend contracts.
- Guide 9, Timeline and composer — define timeline data and composer behavior.
design/03-bb-layout.md— read the layout surface contracts.design/05-bb-thread-ui.md— read the thread UI surface contracts.