Authoring a plugin
A plugin declares a static contract graph and supplies behavior through one factory for each runtime tier.
Package shape
A plugin uses one npm package. The package can contain app, server, and host artifacts. Each tier stays in its own runtime plane. A contracts module contains only types, tokens, and contract declarations.
acme-github/
├── package.json
├── bb.plugin.jsonc
├── src/
│ ├── contracts.ts
│ ├── app.tsx
│ ├── server.ts
│ ├── host.ts
│ ├── components/
│ │ └── index.ts
│ └── server-utils/
│ └── index.ts
├── skills/
│ └── github-review/SKILL.md
├── assets/
│ └── icon.svg
└── dist/
├── app.mjs
├── app.css
├── server.mjs
├── host.mjs
├── contracts.mjs
├── contract.json
└── artifact.jsonThe source package has these author-visible files.
| File | Rule |
|---|---|
package.json |
It defines npm identity, dependencies, scripts, and package export conditions. |
bb.plugin.jsonc |
It defines plugin identity, claims, declarations, edges, exports, artifacts, and lineage. |
src/contracts.ts |
It contains data-only contract definitions. It can run on all tiers. |
src/app.tsx |
It contains the optional browser factory. |
src/server.ts |
It contains the optional server factory. |
src/host.ts |
It contains the optional host factory. |
skills/ and assets/ |
They contain declared plugin resources. |
dist/ |
The build writes verified runtime artifacts. Authors do not edit these files. |
At least one runtime artifact must exist. A server and its host artifact form one plugin commit. The kernel loads all artifacts from the same plugin version and generation.
The manifest
The manifest supplies the complete static graph. The kernel reads it without plugin execution.
The build validates the manifest and generated contract.json as one unit.
// bb.plugin.jsonc
{
"$schema": "https://getbb.app/schemas/plugin-v2.schema.json",
"schemaVersion": 2,
"id": "acme.github",
"version": "2.0.0",
"name": "GitHub",
"description": "Issues, pull requests, and review tools.",
"category": "integrations",
"engines": { "bb": "^2.0.0", "sdk": "^2.0.0" },
// A fork uses a new ID and records its source revision.
// "fork": { "parent": "bb.github", "parentRevision": "1.4.2" },
"claims": [
{ "surface": "bb.layout.sidebar", "version": "^1.0.0" },
{ "surface": "bb.thread-ui.sidePanels", "version": "^1.0.0", "order": 40 },
{ "surface": "bb.thread-ui.timeline.item", "version": "^1.0.0", "key": "acme.github.pr" },
{ "service": "bb.agents.tool", "key": "github_search_issues", "version": "^1" },
{ "service": "acme.github.issueCache", "version": "^1" }
],
"surfaces": [
{
"id": "acme.github.prView.actions",
"version": "1.0.0",
"kind": "list",
"replaceable": false,
"props": "./src/contracts.ts#PullRequestActionProps",
"stability": "experimental"
}
],
"services": [
{
"id": "acme.github.issueCache",
"kind": "single",
"version": "1.2.0",
"contract": "./src/contracts.ts#issueCache",
"replaceable": true,
"defaultProvider": "acme.github",
"stability": "stable"
}
],
"requires": [
{ "service": "bb.threads", "range": "^1" },
{ "service": "bb.agents.tool", "range": "^1" }
],
"optional": [
{ "service": "bb.workspace", "range": "^1" }
],
"watched": [
{ "service": "bb.providers", "range": "^1" }
],
"hostRoles": [
{
"name": "git",
"contract": "./src/contracts.ts#gitRole",
"scope": "host",
"launch": { "kind": "module", "export": "git" }
}
],
"exports": {
"./contracts": "./dist/contracts.mjs",
"./components": "./dist/components.mjs",
"./server-utils": "./dist/server-utils.mjs"
},
"artifacts": {
"app": "./dist/app.mjs",
"server": "./dist/server.mjs",
"host": "./dist/host.mjs"
}
}Manifest records
| Record | Required fields | Notes |
|---|---|---|
| Surface declaration | id, version, kind, props, stability |
The owner can set replaceable. A replaceable surface names defaultClaimant. |
| Service declaration | id, kind, version, contract, stability |
A replaceable service also names its default provider. |
| Surface claim | surface and an optional key |
A keyed claim must name its key. A list or chain claim has claimant identity. |
| Service claim | service, version, and an optional key |
The build checks the implementation against contract.json. |
| Edge | service and range |
The manifest puts each edge in requires, optional, or watched. |
| Host role | id, contract, and required |
The role stays private to the same plugin package. |
| Export | a subpath and a source module | The build emits tier-safe package exports. |
| Fork | parent and parentRevision |
A fork always has its own plugin ID and storage. |
Contract IDs use bb.<plugin>.<contract> for first-party contracts. Other owners use <pluginId>.<contract>.
The loader matches service contracts by the ID and a semver range. It never uses JavaScript object identity.
A winning implementation can render its declared child surfaces. Those children exist only under the active winner.
The kernel supplies only bb.app.root without a plugin.
Build artifacts
The build writes all outputs into a staging directory. It swaps the directory only after every check succeeds.
| Artifact | Contents | Runtime consumer |
|---|---|---|
app.mjs |
One browser ESM bundle and the app factory default export. | The app loader. |
app.css |
Plugin styles for the browser artifact. | The app loader. |
server.mjs |
One server ESM bundle and the server factory default export. | The server loader. |
host.mjs |
One host ESM bundle and the host factory default export. | The host daemon. |
contracts.mjs |
Data-only tokens, schemas, and types with runtime values. | Authors and compatibility tools. |
contract.json |
Full contract schemas, versions, kinds, keys, stability, and host-role methods. | The loader, inspector, and compatibility checks. |
Exported *.mjs |
Exact app, server, host, or data-only modules. | Direct imports from other plugins. |
artifact.json |
Digests, source maps, SDK version, and build facts for all artifacts. | Verification and diagnostics. |
The build rejects a runtime claim that has no static manifest claim. It also rejects a missing runtime claim. The build rejects imports from a wrong tier. The contracts module cannot import app, server, or host code.
One factory for each tier
Each artifact exports one factory result as its default export. The factory receives one concrete api object.
The loader stages all registrations during the factory call. It commits them as one atomic plugin generation.
export type Disposer = () => void | Promise<void>;
export interface PluginIdentity {
readonly id: string;
readonly version: string;
readonly generation: number;
readonly signal: AbortSignal;
}
export type PluginFactory<Api> = (
api: Api,
) => void | Disposer | Promise<void | Disposer>;Every API registration has automatic cleanup. A factory can return one disposer for resources outside the API. If a factory throws, the loader discards all staged registrations. The current generation stays active.
The three factory modules use explicit tier names.
| Tier | Import | Factory | API type |
|---|---|---|---|
| App | @get-bb/plugin/app |
definePlugin(factory) |
AppPluginApi |
| Server | @get-bb/plugin/server |
defineServerPlugin(factory) |
ServerPluginApi |
| Host | @get-bb/plugin/host |
defineHostPlugin(factory) |
HostPluginApi |
The factory helpers preserve types and attach no hidden behavior. The loader owns identity and lifecycle.
App API
The app artifact shares the page, React, and the DOM. It can provide surfaces and app-plane services. It can also register behavior with owner plugins through their typed helpers.
export interface AppPluginApi {
readonly plugin: PluginIdentity;
readonly surfaces: AppSurfaceApi;
readonly services: AppServiceApi;
readonly commands: AppCommandApi;
readonly settings: AppSettingsApi;
readonly storage: AppStorageApi;
readonly log: PluginLog;
onDispose(disposer: Disposer): void;
}
export interface AppSurfaceApi {
provide<P>(id: string, component: SingleSurfaceComponent<P>): void;
provide<P>(id: string, item: ListSurfaceItem<P>): void;
provide<P>(id: string, item: KeyedSurfaceItem<P>): void;
provide<P>(id: string, member: SurfaceChainMember<P>): void;
}
export interface AppServiceApi {
provide<T>(token: ServiceToken<T>, implementation: T): void;
use<T>(token: ServiceToken<T>): Promise<T>;
optional<T>(token: ServiceToken<T>): OptionalService<T>;
watch<T>(token: ServiceToken<T>, listener: ServiceBindingListener<T>): Disposer;
}
export interface OptionalService<T> {
current(): T | undefined;
whenAvailable(): Promise<T>;
}use accepts only a declared required edge. optional and watch accept their matching declared edge.
App services are in-process app objects. A server capability needs an owner-provided app client or an explicit RPC contract.
| App member | Signature | Purpose |
|---|---|---|
plugin |
PluginIdentity |
It supplies identity, generation, and the lifecycle signal. |
surfaces.provide |
Four overloads for single, list, keyed, and chain |
It fulfills one static surface claim. |
services.provide |
provide(token, implementation): void |
It fulfills one app-service claim. |
services.use |
use(token): Promise<T> |
It resolves one required app-service edge. |
services.optional |
optional(token): OptionalService<T> |
It tracks one optional app-service edge without a restart. |
services.watch |
watch(token, listener): Disposer |
It observes one watched app-service edge. |
commands.add |
add(command: AppCommand): void |
It claims one bb.commands.command key through the Commands plugin. |
settings.get |
get<T>(key: SettingKey<T>): T |
It reads a preference declared through the Settings plugin. |
settings.watch |
watch<T>(key, listener): Disposer |
It observes preference changes. |
storage.get |
get<T extends JsonValue>(key): Promise<T | undefined> |
It reads plugin-owned browser state. |
storage.set |
set(key, value): Promise<void> |
It writes plugin-owned browser state. |
storage.delete |
delete(key): Promise<void> |
It deletes plugin-owned browser state. |
log |
debug, info, warn, and error |
It writes attributed plugin diagnostics. |
onDispose |
onDispose(disposer): void |
It adds cleanup for a manual resource. |
Full app example
// src/app.tsx
import { definePlugin } from "@get-bb/plugin/app";
import { bbThreads } from "@bb/threads/contracts";
import { GitHubPanel } from "./components/GitHubPanel.js";
import { GitHubSummary } from "./components/GitHubSummary.js";
export default definePlugin(async (api) => {
const threads = await api.services.use(bbThreads.app);
api.surfaces.provide("bb.thread-ui.sidePanels", {
key: "acme.github",
title: "GitHub",
component: ({ threadId }) => (
<GitHubPanel threadId={threadId} threads={threads} />
),
});
api.surfaces.provide("bb.layout.sidebar", ({ Original, props }) => (
<Original {...props} footer={<GitHubSummary />} />
));
});The loader remounts each provided surface after a winner change. A surface error boundary uses the declared default.
The Original component exists only for a single surface and only in the app plane.
Server API
The server artifact runs in the bb server process. A service handle is the provider's real in-process object. The API also exposes kernel ports and typed helpers from first-party owner plugins.
export interface ServerPluginApi {
readonly plugin: PluginIdentity;
readonly services: ServerServiceApi;
readonly storage: PluginStorage;
readonly secrets: SecretsPort;
readonly realtime: RealtimePort;
readonly http: HttpPort;
readonly rpc: RpcPort;
readonly host: ServerHostApi;
readonly log: PluginLog;
onDispose(disposer: Disposer): void;
}
export interface ServerServiceApi {
provide<T>(token: ServiceToken<T>, implementation: T): void;
provide<T>(token: ListServiceToken<T>, item: { key: string; implementation: T }): void;
provide<K extends string, T>(token: KeyedServiceToken<K, T>, item: { key: K; implementation: T }): void;
provide<T>(token: ChainServiceToken<T>, member: ServiceChainMember<T>): void;
use<T>(token: ServiceToken<T>): Promise<T>;
optional<T>(token: ServiceToken<T>): OptionalService<T>;
watch<T>(token: ServiceToken<T>, listener: ServiceBindingListener<T>): Disposer;
}
export interface ServerHostApi {
use<C extends HostRoleContract>(
name: string,
contract: C,
target: HostTarget,
): Promise<HostRoleClient<C>>;
}The storage, secrets, realtime, HTTP, and RPC members are stable kernel ports. Their detailed contracts belong to the kernel page.
Domain helpers can extend this API through modules. For example, @bb/agents/server supplies typed tool registration.
| Server member | Signature | Purpose |
|---|---|---|
plugin |
PluginIdentity |
It supplies identity, generation, and the lifecycle signal. |
services.provide |
Four overloads for single, list, keyed, and chain |
It fulfills one static service claim. |
services.use |
use(token): Promise<T> |
It resolves one required edge to a real in-process object. |
services.optional |
optional(token): OptionalService<T> |
It tracks the current optional provider without a restart. |
services.watch |
watch(token, listener): Disposer |
It observes watched provider changes. |
storage |
PluginStorage |
It supplies plugin-owned key-value, files, and database state. |
secrets |
SecretsPort |
It resolves plugin secret references. |
realtime |
RealtimePort |
It publishes and subscribes to declared realtime events. |
http |
HttpPort |
It registers explicit server routes. |
rpc |
RpcPort |
It registers typed app-to-server calls. |
host.use |
use(name, contract, target): Promise<HostRoleClient> |
It opens the plugin's private typed host role. |
log |
debug, info, warn, and error |
It writes attributed plugin diagnostics. |
onDispose |
onDispose(disposer): void |
It adds cleanup for a manual resource. |
Full server example
// src/server.ts
import { defineServerPlugin } from "@get-bb/plugin/server";
import { bbThreads } from "@bb/threads/contracts";
import { bbWorkspace } from "@bb/workspace/contracts";
import { bbProviders } from "@bb/providers/contracts";
import { bbAgentTools } from "@bb/agents/server";
import { gitRole, issueCache } from "./contracts.js";
import { createIssueCache } from "./server-utils/index.js";
export default defineServerPlugin(async (api) => {
const threads = await api.services.use(bbThreads.server);
const workspace = api.services.optional(bbWorkspace.server);
const cache = createIssueCache({
storage: api.storage,
threads,
workspace,
readRepository: async (input, options) => {
const git = await api.host.use("git", gitRole, {
kind: "environment",
environmentId: input.environmentId,
});
return git.call("readRepository", input, options);
},
});
await cache.ready();
api.services.provide(issueCache, cache);
bbAgentTools(api).provide({
key: "github_search_issues",
title: "Search GitHub issues",
input: issueCache.methods.search.input,
run: (input) => cache.search(input),
});
api.services.watch(bbProviders.server, (change) => {
if (change.kind === "available") cache.setProviders(change.service);
if (change.kind === "unavailable") cache.setProviders(undefined);
});
return () => cache.close();
});The loader waits for this factory before commit. A failed ready() call cannot affect the current provider.
The server has no Original service handle. Arbitration returns to the declared default provider after a failure.
Host API
The host artifact runs in the daemon on the user's machine. It fulfills private roles for its server artifact. A host role uses typed verbs for every server-to-host operation.
export interface HostPluginApi {
readonly plugin: PluginIdentity;
readonly roles: HostRoleApi;
readonly log: PluginLog;
onDispose(disposer: Disposer): void;
}
export interface HostRoleApi {
provide<C extends HostRoleContract>(
name: string,
contract: C,
handlers: HostRoleHandlers<C>,
): void;
}
export interface HostRoleContext<C extends HostRoleContract> {
readonly signal: AbortSignal;
readonly target: HostTarget;
readonly paths: HostPaths;
readonly fs: HostFileSystem;
readonly exec: HostExec;
readonly watch: HostWatch;
emitSignal<N extends keyof C["signals"]>(name: N, payload: SignalOutput<C, N>): void;
retain(reason: string): Disposer;
}| Host member | Signature | Purpose |
|---|---|---|
plugin |
PluginIdentity |
It supplies identity, generation, and the lifecycle signal. |
roles.provide |
provide(name, contract, handlers): void |
It fulfills one declared private host role. |
log |
debug, info, warn, and error |
It writes attributed host diagnostics. |
onDispose |
onDispose(disposer): void |
It adds cleanup for a manual resource. |
Each handler receives HostRoleContext. The context supplies paths, machine ports, signals, and retention.
Full host example
// src/host.ts
import { defineHostPlugin } from "@get-bb/plugin/host";
import { gitRole } from "./contracts.js";
export default defineHostPlugin((api) => {
api.roles.provide("git", gitRole, {
async readRepository(input, context) {
const result = await context.exec.run(
["git", "-C", input.path, "status", "--short"],
{ signal: context.signal, cwd: input.path },
);
return { stdout: result.stdout, exitCode: result.exitCode };
},
async watchRepository(input, context) {
const release = context.retain(`watch:${input.path}`);
const stop = await context.watch.path(input.path, () => {
context.emitSignal("repositoryChanged", { path: input.path });
});
return async () => {
await stop();
await release();
};
},
});
});The public service token stays on the server. Consumers do not depend on the private host role. The inspector joins the public service, the server provider, and the host role into one status record.
The four contract kinds
One kind system defines conflict behavior on the app and server planes.
| Kind | Runtime shape | Selection rule | App example | Server example |
|---|---|---|---|---|
single |
One active implementation. | The user selects one winner when replacement is allowed. | bb.thread-ui.composer |
acme.github.issueCache |
list |
All items form one ordered list. | The user can hide or reorder items. | bb.thread-ui.sidePanels |
Telemetry sinks. |
keyed |
One active implementation for each key. | The user selects a winner for each replaceable key. | Timeline renderer by item type. | Agent tool by tool name. |
chain |
Ordered wrappers surround one base. | The user can see and reorder members. | Composer send interceptors. | Command interceptors. |
These TypeScript shapes show the four app forms.
type SingleSurfaceComponent<P> = React.ComponentType<{
props: P;
Original: React.ComponentType<P>;
}>;
interface ListSurfaceItem<P> {
key: string;
title?: string;
component: React.ComponentType<P>;
}
interface KeyedSurfaceItem<P> {
key: string;
component: React.ComponentType<P>;
}
interface SurfaceChainMember<P> {
key: string;
wrap(next: React.ComponentType<P>): React.ComponentType<P>;
}A non-replaceable single or keyed collision fails at load. A list claim never creates a winner conflict.
A chain always has a visible order. The kernel does not use plugin load order as policy.
The winner store has global scope. A contract or keyed contract key has one current winner for all users.
Sharing rule
Authors use one of three mechanisms. Each mechanism has one meaning.
| Mechanism | Meaning | Effect of a winner change |
|---|---|---|
| Module import | Use this exact implementation as code. | The import does not change. |
| Surface | Render the implementation the user selected. | The surface remounts with the new winner. |
| Service | Call the current live provider of a contract. | Required consumers restart. Watched consumers receive a change. |
Use a module import for a concrete building block.
import { Composer } from "@bb/thread-ui/components";
export const FixedComposer = () => <Composer threadId="thr_1" />;Use a surface for the user's current app choice.
import { Surface } from "@get-bb/plugin/app";
export const SelectedComposer = () => (
<Surface id="bb.thread-ui.composer" props={{ threadId: "thr_1" }} />
);Use a service for the user's current server choice.
import { bbThreads } from "@bb/threads/contracts";
export default defineServerPlugin(async (api) => {
const threads = await api.services.use(bbThreads.server);
await threads.get("thr_1");
});The rule is: use a contract for a replaceable thing, and import a module for exact code.
Service edges
Every service use must have one static edge. The loader rejects undeclared access.
| Edge | Activation rule | Runtime access | Provider replacement or loss |
|---|---|---|---|
required |
The provider must be ready before activation. | await api.services.use(token) |
The loader restarts the consumer and all required dependents in graph order. |
optional |
The consumer can start without a provider. | api.services.optional(token).current() |
The consumer stays active. The reference tracks the current provider. |
watched |
The consumer can start without a provider. | api.services.watch(token, listener) |
The listener receives unavailable and then available. |
export type ServiceBindingChange<T> =
| { kind: "available"; service: T; providerPluginId: string; generation: number }
| { kind: "unavailable"; previousProviderPluginId?: string };
export type ServiceBindingListener<T> = (
change: ServiceBindingChange<T>,
) => void | Promise<void>;A required edge returns a real in-process object. The consumer must not keep it after its generation stops. The loader blocks new calls during cutover. It drains current calls to a bounded deadline and cancels the remainder.
Forks and lineage
A fork is a normal plugin with a new ID. It records the parent ID and the copied parent revision.
{
"id": "sawyer.github",
"version": "1.0.0",
"fork": {
"parent": "bb.github",
"parentRevision": "1.4.2"
},
"claims": [
{ "surface": "bb.thread-ui.sidePanels" },
{ "service": "bb.github.issueCache", "version": "^1" }
]
}The fork can claim parent-owned contract IDs that it still implements. The build checks these claims against the parent contract.json.
The fork receives fresh storage. It can share data only through an exported service.
The winner picker groups the fork with its parent. Installation does not change the active winner without a user choice. The recorded revision gives an agent the merge base for a later parent update.
Module exports do not follow lineage. Importing @bb/thread-ui/components still imports the parent package's exact module.
Consumers that must follow the selected fork use a surface or a service.
Stability tiers
Every declared contract has one stability tier.
| Tier | Declaration | Compatibility rule |
|---|---|---|
| Stable | "stability": "stable" |
The owner follows semver and supplies a deprecation window. |
| Experimental | "stability": "experimental" |
The owner can change the contract in any release. |
An experimental runtime API member starts with experimental_. An experimental contract also carries the manifest marker.
The generated contract file repeats this stability value. Inspectors and build errors show it.
A stable claimant must satisfy the declared semver range. A fork cannot weaken the owner's stable contract. An experimental consumer must accept that a parent update can require source changes.
Module exports
Module exports share exact code. They do not participate in arbitration.
The manifest and package.json map each public subpath to a built module.
// package.json
{
"name": "@acme/bb-github",
"type": "module",
"exports": {
"./contracts": {
"types": "./dist/types/contracts.d.ts",
"default": "./dist/contracts.js"
},
"./components": {
"types": "./dist/types/components.d.ts",
"browser": "./dist/components.js"
},
"./server-utils": {
"types": "./dist/types/server-utils.d.ts",
"node": "./dist/server-utils.js"
}
}
}The build applies these rules.
- The contracts export can contain tokens, schemas, constants, and types.
- An app export can import React and other app exports.
- A server export can import server-safe dependencies.
- A host export can import host-safe dependencies.
- A tier cannot import an export for another tier.
- A plugin cannot register behavior from an imported module at module load time.
First-party packages use short names such as @bb/thread-ui/components and @bb/threads/contracts.
External packages use their npm package name. The plugin inspector shows each direct module dependency.
contract.json never replaces the contracts module. The JSON file supports validation and inspection.
The module supplies TypeScript inference and runtime token values.
Testing entry
The 2.0 SDK provides one Node-only test root at @get-bb/plugin/testing.
Tier subpaths keep browser and host dependencies small.
| Import | Main exports |
|---|---|
@get-bb/plugin/testing |
Common assertions, import scan, deferred values, and aggregate types. |
@get-bb/plugin/testing/server |
createServerHarness, service doubles, lifecycle drivers, and inspections. |
@get-bb/plugin/testing/app |
loadAppPlugin, renderSurface, installTestAppRuntime, and app drivers. |
@get-bb/plugin/testing/host |
createHostHarness and host role drivers. |
@get-bb/plugin/testing/process |
Shell argument and setup-marker helpers for process tests. |
@bb/threads/testing |
Thread fixtures. |
@bb/workspace/testing |
Workspace status fixtures. |
@bb/providers/testing |
Test-model selection and provider corpus fixtures. |
The product build rejects every import from a /testing subpath.
Server harness
The server harness runs the real loader graph with controlled ports. It stages and commits registrations like production.
export interface ServerHarnessOptions {
plugin: {
manifest: PluginManifestSource;
contract: ContractJson;
server: ServerPluginDefinition;
host?: HostPluginDefinition;
};
providers?: TestPluginSpec[];
ports?: Partial<KernelTestPorts>;
placement?: "inProcess" | "worker";
}
export interface ServerHarness {
readonly api: ServerPluginApi;
readonly services: {
use<T>(token: ServiceToken<T>): Promise<T>;
stub<T>(token: ServiceToken<T>, implementation: T): ServiceDouble<T>;
};
readonly drivers: {
reload(next?: ServerPluginDefinition): Promise<void>;
flipWinner(contractId: string, pluginId: string): Promise<void>;
setSettings(values: Record<string, JsonValue | null>): Promise<void>;
callRpc(method: string, input?: JsonValue): Promise<JsonValue>;
runCli(argv: string[]): Promise<CliResult>;
fetchHttp(method: string, path: string, init?: RequestInit): Promise<Response>;
runService(name: string): RunningService;
runSchedule(name: string): Promise<void>;
emitThreadEvent(event: ThreadEvent): Promise<{ errors: unknown[] }>;
callAgentTool(name: string, input: unknown): Promise<AgentToolResult>;
resolveAgentConfiguration(input: AgentConfigurationContext): Promise<AgentConfiguration>;
};
readonly inspection: {
calls: readonly TestCall[];
callsTo(path: string): readonly TestCall[];
status(): PluginRuntimeStatus;
registrations(): readonly ContractRegistration[];
};
dispose(): Promise<void>;
}
export function createServerHarness(
options: ServerHarnessOptions,
): Promise<ServerHarness>;Service doubles replace the old broad fake SDK. Each double uses a named token and records typed calls. Reload makes prior required service handles stale. Tests can check restart order and winner cutover.
const harness = await createServerHarness({
plugin: { manifest, contract, server },
placement: "inProcess",
});
const cache = await harness.services.use(issueCache);
await cache.search({ query: "is:open" });
await harness.drivers.reload();
await expect(cache.search({ query: "is:open" })).rejects.toMatchObject({
code: "stale_handle",
});App harness
The app harness loads the real app factory and renders one declared surface. It supplies service doubles, routing, settings, and surface owner props.
export interface RenderSurfaceOptions<P> {
manifest: PluginManifestSource;
app: AppPluginDefinition;
contractId: string;
key?: string;
props: P;
services?: readonly AppServiceDouble[];
settings?: Record<string, JsonValue>;
route?: string;
}
export interface RenderedSurface {
readonly element: HTMLElement;
readonly behavior: {
emitRealtime(event: RealtimeEvent): Promise<void>;
setService<T>(token: ServiceToken<T>, service: T | undefined): Promise<void>;
flipWinner(contractId: string, pluginId: string): Promise<void>;
};
readonly inspection: {
rpcCalls: readonly TestCall[];
navigationCalls: readonly TestCall[];
serviceCalls: readonly TestCall[];
remountCount: number;
};
rerender(props: unknown): Promise<void>;
unmount(): Promise<void>;
}
export function loadAppPlugin(
source: AppPluginDefinition | string,
): Promise<CapturedAppPlugin>;
export function renderSurface<P>(
options: RenderSurfaceOptions<P>,
): Promise<RenderedSurface>;
export function installTestAppRuntime(): Disposer;renderSurface replaces the old slot-specific renderer. It applies single, list, keyed, and chain rules.
It can supply a typed Original for a replaceable single surface.
Content-script test mounts do not exist in 2.0. A test renders the surface that owns the behavior.
App factory cleanup uses unmount() and the plugin generation signal.
Host harness
The host harness runs role handlers through schema, JSON, signal, cancellation, and lifecycle boundaries.
export interface HostHarnessOptions {
paths?: { dataDir: string; tempDir: string };
ports?: Partial<Pick<HostPluginApi, "fs" | "exec" | "watch">>;
}
export interface HostHarness<C extends HostRoleContract> {
call<N extends keyof C["methods"]>(
method: N,
input: HostMethodInput<C, N>,
options?: { signal?: AbortSignal },
): Promise<HostMethodOutput<C, N>>;
signals(): readonly HostSignalRecord<C>[];
retainedLeaseCount(): number;
readonly lifecycleSignal: AbortSignal;
dispose(): Promise<void>;
}
export function createHostHarness<C extends HostRoleContract>(
entry: HostPluginDefinition,
role: C,
options?: HostHarnessOptions,
): HostHarness<C>;Shared and domain test exports
The common entry keeps only general plugin author helpers.
export interface Deferred<T> {
readonly promise: Promise<T>;
resolve(value: T | PromiseLike<T>): void;
reject(reason?: unknown): void;
}
export function deferred<T>(): Deferred<T>;
export interface PublicImportViolation {
file: string;
specifier: string;
reason: "private-package" | "outside-allowlist" | "outside-package" | "dynamic-specifier";
}
export function scanPublicImports(
packageRoot: string,
options?: { allow?: readonly RegExp[] },
): { files: string[]; violations: PublicImportViolation[]; privateDependencies: string[] };
export function assertContract(
contract: ContractDeclaration,
samples?: ContractSamples,
): void;Domain fixtures move to the plugin that owns their data types.
| Module | Exports |
|---|---|
@bb/threads/testing |
threadFixture(overrides) and event-row decoders. |
@bb/workspace/testing |
workingTreeFixture, mergeBaseFixture, and workspaceStatusFixture. |
@bb/providers/testing |
preferredTestModels, resolveTestModel, providerCorpusAvailable, listProviderCorpus, and loadProviderCorpus. |
Process helpers stay in a separate subpath. App, server, and host bundles cannot import this subpath.
import {
shellQuote,
waitForMarkerCount,
} from "@get-bb/plugin/testing/process";Content scripts and the old app entry
The 0.x app API used a builder object and trusted content scripts. The 2.0 API removes both concepts.
A content script had no contract owner. It could mutate any app state or DOM region. This made replacement, failure fallback, winner inspection, and fork compatibility unclear.
In 2.0, the plugin claims the narrowest owner-declared surface or service.
| Old use | 2.0 replacement |
|---|---|
| Mount UI into app chrome. | Claim the owner surface and call api.surfaces.provide. |
| Add a thread row status. | Claim a child surface that the active bb.thread-ui.list winner declares. |
| Observe thread state. | Declare an edge to the bb.threads app service. |
| Patch a send operation. | Claim the bb.thread-ui.composer.send chain. |
| Run global setup and cleanup. | Use the app factory and api.onDispose. |
The old definePluginApp(setup) entry becomes definePlugin(factory) from @get-bb/plugin/app.
The factory receives AppPluginApi, not PluginAppBuilder or a runtime namespace object.
// Old
export default definePluginApp((app) => {
app.contentScripts.register({ id: "status", mount: installStatus });
});
// 2.0
export default definePlugin((api) => {
api.surfaces.provide("bb.thread-ui.timeline.item", {
key: "acme.github.status",
component: GitHubStatus,
});
});Covers
| Old item ID | New contract or verb | Note |
|---|---|---|
app.module |
@get-bb/plugin/app module |
Replaced by the tier-specific app entry. |
app.definePluginApp |
definePlugin(factory) |
Replaced by the app factory. |
app.contentScripts |
dropped | Replaced by owner-declared surface claims and app services. |
app.contentScripts.register |
dropped | Replaced by api.surfaces.provide() or a typed service edge. |
app.PluginAppSetup |
AppPluginFactory |
Replaced by the typed factory callback. |
app.PluginSdkApp |
AppPluginApi |
Replaced by the concrete app API object. |
testing.package.root |
@get-bb/plugin/testing |
The Node-only common test entry. |
testing.package.app |
@get-bb/plugin/testing/app |
The app harness entry. |
testing.package.host |
@get-bb/plugin/testing/host |
The host harness entry. |
testing.createFakePluginHost |
createServerHarness() |
The server harness runs the real loader graph. |
testing.fakeHost.options |
ServerHarnessOptions |
Named services and test ports replace broad SDK options. |
testing.fakeHost.bb |
ServerHarness.api |
The harness exposes the concrete server API. |
testing.fakeHost.harness |
ServerHarness |
Drivers and inspection share one typed harness. |
testing.fakeHost.staleError |
KernelError with stale_handle |
The common kernel error replaces a test-only class. |
testing.fakeHarness.reload |
ServerHarness.drivers.reload() |
It tests generation replacement. |
testing.fakeHarness.dispose |
ServerHarness.dispose() |
It tests automatic cleanup and abort. |
testing.fakeHarness.setSettings |
ServerHarness.drivers.setSettings() |
It changes test settings and sends change events. |
testing.fakeHarness.callRpc |
ServerHarness.drivers.callRpc() |
It calls the explicit RPC port. |
testing.fakeHarness.runCli |
ServerHarness.drivers.runCli() |
It runs a contributed command verb. |
testing.fakeHarness.fetchHttp |
ServerHarness.drivers.fetchHttp() |
It calls the HTTP port. |
testing.fakeHarness.runService |
ServerHarness.drivers.runService() |
It starts a declared background service. |
testing.fakeHarness.runSchedule |
ServerHarness.drivers.runSchedule() |
It runs a declared schedule. |
testing.fakeHarness.emitThreadEvent |
ServerHarness.drivers.emitThreadEvent() |
It sends an event through bb.threads. |
testing.fakeHarness.callAgentTool |
ServerHarness.drivers.callAgentTool() |
It calls a keyed bb.agents.tool claim. |
testing.fakeHarness.resolveAgentConfiguration |
ServerHarness.drivers.resolveAgentConfiguration() |
It resolves configuration through bb.agents. |
testing.createFakeSdk |
ServerHarness.services.stub() |
Named service doubles replace the broad bb.sdk fake. |
testing.fakeSdk.calls |
ServerHarness.inspection.calls |
It records typed service and port calls. |
testing.fakeSdk.callsTo |
ServerHarness.inspection.callsTo() |
It filters recorded calls by a contract path. |
testing.fakeSdk.stub |
ServerHarness.services.stub() |
It stubs one named service token. |
testing.makeThreadResponse |
@bb/threads/testing: threadFixture() |
The thread owner supplies its fixture. |
testing.scanPublicSdkOnly |
scanPublicImports() |
The stable scan checks public package boundaries. |
testing.publicScan.options |
PublicImportScanOptions |
It keeps explicit allow rules. |
testing.publicScan.violation |
PublicImportViolation |
It keeps the four violation reasons. |
testing.publicScan.result |
PublicImportScan |
It reports files, violations, and private dependencies. |
testing.loadPluginApp |
loadAppPlugin() |
It loads the 2.0 app factory. |
testing.renderSlot |
renderSurface() |
A general surface renderer replaces slot rendering. |
testing.renderSlot.options |
RenderSurfaceOptions |
Contract ID, key, props, services, and route replace slot state. |
testing.renderSlot.behavior |
RenderedSurface.behavior |
It drives realtime, service bindings, and winner changes. |
testing.renderSlot.inspection |
RenderedSurface.inspection |
It records RPC, navigation, service calls, and remounts. |
testing.renderSlot.lifecycle |
RenderedSurface.rerender() and unmount() |
It controls surface and plugin cleanup. |
testing.installRuntime |
installTestAppRuntime() |
It installs the 2.0 app test runtime. |
testing.mountContentScripts |
dropped | renderSurface() tests the owner surface that replaces the content script. |
testing.contentScript.options |
dropped | RenderSurfaceOptions supplies the replacement surface context. |
testing.contentScript.mounted |
dropped | RenderedSurface replaces the mount result and inspects cleanup. |
testing.hostHarness.create |
createHostHarness() |
The stable host harness replaces the experimental entry. |
testing.hostHarness.options |
HostHarnessOptions |
It supplies paths and host ports. |
testing.hostHarness.call |
HostHarness.call() |
It validates input, output, JSON, and cancellation. |
testing.hostHarness.signals |
HostHarness.signals() |
It returns validated signals in order. |
testing.hostHarness.leases |
HostHarness.retainedLeaseCount() |
It reports current host retention leases. |
testing.hostHarness.lifecycleSignal |
HostHarness.lifecycleSignal |
It exposes the role lifecycle signal. |
testing.hostHarness.dispose |
HostHarness.dispose() |
It aborts calls and disposes the role. |
testing.testHelpers.deferred |
deferred() |
The common test entry keeps a small promise controller. |
testing.testHelpers.deferredType |
Deferred<T> |
It defines the promise controller type. |
testing.testHelpers.optionalPaths |
assertContract() |
Contract conformance owns optional-path checks. |
testing.testHelpers.models |
@bb/providers/testing: preferredTestModels() |
The provider owner supplies test model order. |
testing.testHelpers.resolveModel |
@bb/providers/testing: resolveTestModel() |
The provider owner selects an available model. |
testing.testHelpers.workspaceTree |
@bb/workspace/testing: workingTreeFixture() |
The workspace owner supplies its fixture. |
testing.testHelpers.mergeBase |
@bb/workspace/testing: mergeBaseFixture() |
The workspace owner supplies its fixture. |
testing.testHelpers.workspaceStatus |
@bb/workspace/testing: workspaceStatusFixture() |
The workspace owner supplies its fixture. |
testing.testHelpers.shellQuote |
@get-bb/plugin/testing/process: shellQuote() |
The process test subpath supplies shell quotation. |
testing.testHelpers.waitMarker |
@get-bb/plugin/testing/process: waitForMarkerCount() |
The process test subpath supplies marker waits. |
testing.testHelpers.corpusEnv |
@bb/providers/testing: PROVIDER_CORPUS_DIR_ENV |
The provider test module owns the corpus location. |
testing.testHelpers.corpusAvailable |
@bb/providers/testing: providerCorpusAvailable() |
The provider test module checks corpus availability. |
testing.testHelpers.listCorpus |
@bb/providers/testing: listProviderCorpus() |
The provider test module lists corpus records. |
testing.testHelpers.loadCorpus |
@bb/providers/testing: loadProviderCorpus() |
The provider test module loads one record. |
testing.testHelpers.decodeCorpus |
@bb/threads/testing: decodeStoredEventRow() |
The thread owner decodes stored event rows. |
testing.testHelpers.resolveCorpusDir |
@bb/providers/testing: resolveProviderCorpusDir() |
The provider test module resolves the corpus directory. |
app.PluginAppBuilder |
AppPluginApi |
The app factory receives the concrete API object. |
app.PluginAppBuilder.composer |
bb.threads composer contracts |
The Threads owner replaces builder composer registration. |
app.PluginAppBuilder.contentScripts |
dropped |
Use an owner-declared surface or app service. |
app.PluginAppBuilder.slots |
AppPluginApi.surfaces |
Typed owner surfaces replace the slot registry. |
app.PluginAppDefinition |
AppPluginFactory |
The app artifact exports a tier factory. |
app.PluginAppDefinition.__bbPluginApp |
dropped |
The loader verifies the artifact and does not use a branded value. |
app.PluginAppDefinition.setup |
definePlugin(factory) |
The default app export is the factory result. |
app.contracts.PluginContentScriptContext |
dropped |
Owner surfaces and services provide typed context. |
app.contracts.PluginContentScriptContext.experimental_setThreadRowStatus |
bb.thread-ui.list child surface |
A declared child surface replaces global row mutation. |
app.contracts.PluginContentScriptDisposer |
Disposer |
The app factory and registrations own cleanup. |
app.contracts.PluginContentScriptRegistration |
dropped |
The 2.0 app tier has no trusted content-script registration. |
app.contracts.PluginContentScriptRegistration.mount |
dropped |
Use a factory or an owner-declared surface implementation. |
app.slots |
AppPluginApi.surfaces |
Owner-declared contracts replace the open slot namespace. |
server.contract |
ServerPluginApi |
The server factory receives the concrete tier API. |
server.factory |
defineServerPlugin(factory) |
The server artifact exports one typed factory. |
server.sdk.contract |
typed service edges and kernel ports |
Named contracts replace the broad SDK facade. |
server.ui |
domain-owned surfaces and services |
Threads and Mentions own the former server UI operations. |