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.json

The 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.

  1. The contracts export can contain tokens, schemas, constants, and types.
  2. An app export can import React and other app exports.
  3. A server export can import server-safe dependencies.
  4. A host export can import host-safe dependencies.
  5. A tier cannot import an export for another tier.
  6. 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.