Migrating from 0.4

This page ports a 0.4 plugin to the 1.0 API one pattern at a time. The before code comes from the real 0.4 plugins (custom-instructions, keep-awake, docs, github, provider-retry, side-chat, ask-user-question); the after code uses only members in the reference. You migrate in the order the build gates enforce: manifest and contracts first, then the server tier, then the app tier, then the host tier, then delete what has no successor.

Use this when

  • Port custom-instructions. The smallest 0.4 plugin: one RPC, one CLI word, one instructions contribution, one settings section.
  • Port keep-awake. A plugin with a host tier: a host entry with one command, a server reconciler, a settings section.
  • Port a plugin with a nav panel. Decide whether to wait for the page slots or ship on a built slot today.
  • Know what is deleted outright. Which 0.4 members have no successor, and what to remove from docs/api_to_audit.md.

What you build

package.json        # bb block: id, entries, provides, contributes (no branding, no engines.bb ">=0.0")
src/contracts.ts    # defineService / defineEvent / defineHostCommands; the only module siblings import
src/server.ts       # definePlugin({ activate(ctx) })
src/app.tsx         # definePluginApp({ setup(app) })
src/host.ts         # defineHostEntry({ roles })
src/*.test.ts       # the four harnesses (guide page 13)

Gates, in order: bb plugin build . passes (manifest rules, provides matches the exported ids, contributes.slots matches the collector, no sibling import other than /contracts); expectDerivedSurfaces passes; createTestPlugin passes in both placements; createAppHarness passes; every slot you target is built in §5, not spec.

Steps

1. The manifest

// before (plugins/custom-instructions/package.json)
{ "name": "bb-plugin-custom-instructions", "engines": { "bb": ">=0.0" },
  "bb": { "name": "Custom instructions", "description": "…", "branding": { "icon": "EditFile" }, "server": "./server.ts", "app": "./app.tsx" } }
// after
{ "name": "@get-bb/plugin-custom-instructions", "version": "1.0.0", "type": "module", "files": ["dist", "src"],
  "exports": { "./contracts": { "source": "./src/contracts.ts", "default": "./dist/contracts.mjs" } },
  "bb": { "id": "custom-instructions", "name": "Custom instructions", "description": "…",
          "server": "./src/server.ts", "app": "./src/app.tsx", "contracts": "./src/contracts.ts",
          "provides": { "custom-instructions/instructions": { "version": "1.0.0" } }, "uses": { "threads/agent-config": "^1.0.0" },
          "contributes": { "cli": { "commands": ["instructions"] }, "icons": { "plugin": "./assets/icon.svg" },
                           "settings": { "text": { "type": "text", "label": "Custom instructions", "default": "" } } } } }

id is explicit and namespaces everything. engines.bb defaults to *; the 1.0 range is open (§0.6). branding.icon becomes contributes.icons.plugin. The parse is strict: an unknown key fails with its path.

2. bb.rpc.register + useRpcdefineService + useService

// before (custom-instructions/server.ts, app.tsx)
export const customInstructionsRpcContract = defineRpcContract({
  getInstructions: { input: z.null(), output: instructionsResponseSchema },
  saveInstructions: { input: instructionsInputSchema, output: instructionsResponseSchema } });
bb.rpc.register(customInstructionsRpcContract, { getInstructions() { … }, async saveInstructions({ instructions }) { … } });
const rpc = useRpc<typeof customInstructionsRpcContract>();  rpc.call("saveInstructions", { instructions });
// after (src/contracts.ts)
export const TEXT_KEY = "custom-instructions/text";  export const MAX_LENGTH = 4096;
const view = z.strictObject({ instructions: z.string(), maxLength: z.number().int().positive() });
export const instructions = defineService({
  id: "custom-instructions/instructions", version: "1.0.0", summary: "Persistent instructions injected into agents",
  cli: { group: ["instructions"] },
  methods: {
    get: method({ summary: "Print the current custom instructions", input: z.strictObject({}), output: view, renderText: (v) => v.instructions }),
    set: method({ kind: "mutation", summary: "Replace the custom instructions", input: z.strictObject({ text: z.string().max(MAX_LENGTH) }),
      output: view, cli: { fields: { text: { positional: 0 } } }, renderText: () => "Custom instructions updated" }),
  },
});
const svc = useService(instructions);   await svc.set({ text });     // after (src/app.tsx): typed; one HTTP request per call

Inputs are z.strictObject; a z.null() input becomes z.strictObject({}). A mutation runs through the command bus with an actor. The hand-written parseInstructionsInput goes away; the container validates.

3. bb.cli.register → the derived CLI

0.4 registered bb.cli.register({ name: "instructions", summary: "…", commands: [{ name: "get", summary: "…", usage: "bb instructions get [--json]" }, …], async run(argv) { … } }): sixty lines of argv parsing. After: nothing. cli.group: ["instructions"] plus the method names yield bb instructions get and bb instructions set <text>, with --json, help, and exit codes (§8.4). contributes.cli.commands lists the top-level word for the plan-time collision check. clear is bb instructions set ""; add a method if you want the word.

4. bb.agents.contributeInstructionsctx.agents.contributeInstructions

// before: a synchronous per-thread function
bb.agents.contributeInstructions(() => customInstructions.trim().length > 0 ? customInstructions : null);
// after (src/server.ts): one live contribution, replaced when the preference changes
export default definePlugin({
  async activate(ctx) {
    const text = ctx.preferences.define(TEXT_KEY, z.string().max(MAX_LENGTH), { scope: "profile", default: "" });
    let stop: Disposer | null = null;
    const apply = async (value: string) => {
      await stop?.(); stop = value.trim().length > 0 ? await ctx.agents.contributeInstructions(value) : null;
    };
    await apply(await text.get());
    await text.watch((value) => void apply(value));
    await ctx.provide(instructions, withDefaults(instructions, {
      get: async () => ({ instructions: await text.get(), maxLength: MAX_LENGTH }),
      set: async ({ text: next }) => { await text.set(next); return { instructions: next, maxLength: MAX_LENGTH }; },
    }));
  },
});

Declare uses: { "threads/agent-config": "^1.0.0" }. The contribution is watch-held: it lands when threads is bound and survives a threads reload; the disposer revokes it. Per-project text is contributeInstructions(text, { kind: "project", projectId }) or a ResolverRef on your own service (§3.8). The 0.4 kv row is gone: the setting is the value, and bb preferences reaches it.

5. bb.agents.registerToolexpose: { tool: true }

// before (ask-user-question/src/server.ts)
bb.agents.registerTool({ name: "AskUserQuestion", description, parameters: toolInputSchema,
  experimental_presentation: { label: { pending: "Asking a question", completed: "Asked a question" }, icon: { glyph: "MessageQuestion" }, suppress: true },
  async execute(input, ctx) { … } });
// after: a mutation on the service `ask-user-question/questions`; the tool is ask_user_question_questions_ask
ask: method({ kind: "mutation", summary: "Ask the user a question", input: z.strictObject(toolInputSchema.shape), output: answerSchema, expose: { tool: true },
  tool: { presentation: { label: { pending: "Asking a question", completed: "Asked a question" }, icon: "MessageQuestion", intent: "generic", suppress: true } } }),

0.4 parameters were z.object; 1.0 inputs must be z.strictObject (D4), so wrap the old schema's .shape or method() throws invalid_contract. The handler runs as actor { kind: "agent", id: threadId }. There is no second tool registry; ctx.agents.configure({ tools: { include, exclude } }) selects over these names. bb.ui.requestInput becomes the interaction.request command with the kind declared in contributes.interactionKinds (§10.1).

6. bb.http.route → a kind: "custom" method

// before (docs/server.ts): mounted at /api/v1/plugins/docs/http/list
bb.http.route("POST", "/list", async (context) => {
  const input = await readHttpInput(context, listNotesInput); if (!input.ok) return input.response;
  return context.json(await handlers.listNotes(input.value));
}, { auth: "token" });
// after: mounted at /api/v1/docs/files/raw; the only kind that owns sub-paths
raw: method({ kind: "custom", summary: "Serve a vault file", input: z.any(), output: z.any(), auth: "operator" }),
raw: { http: async (req, ctx) => new Response(await readVault(new URL(req.url).pathname), { status: 200 }) },   // handler

Most bb.http.route calls were RPC in disguise; make those query/mutation methods and the CLI and tool come free. 0.4 auth: "local" | "token" | "none" maps to operator | machine | none (§8.2). A custom method cannot be a tool and is refused in a worker (D7).

7. bb.settings.define + useSettingscontributes.settings + preferences

// before (provider-retry/server.ts)
const settings = bb.settings.define({ maximumWait: { type: "select", label: "Maximum automatic wait", options: [...], default: "6 hours" } });
settings.onChange((next) => service.setMaximumWaitMs(maximumWaitMs(next.maximumWait)));     // app: useSettings().values
// after: the same descriptor under contributes.settings.maximumWait, then one key in every tier that reads it
export const WAIT_KEY = "provider-retry/maximumWait";                                                    // contracts.ts
const wait = ctx.preferences.define(WAIT_KEY, waitSchema, { scope: "profile", default: "6 hours" });   // server
await wait.watch((next) => service.setMaximumWaitMs(maximumWaitMs(next)));
const waitRef = app.preferences.define(WAIT_KEY, waitSchema, { scope: "profile", default: "6 hours" });  // app setup
const [value, setValue] = usePreference(waitRef);                                                      // component

Each tier defines the key itself; the manifest entry adds the Settings row and the required fast path (§3.7, §4.7). A secret setting holds plugin:<id>/<key>; read it with ctx.secrets.resolve({ name: ctx.secrets.reference(key) }).

8. bb.realtime.publish + useRealtimedefineEvent + defineQuery

// before (github): bb.realtime.publish("data-changed", {});   useRealtime("data-changed", refetch);
// after (contracts.ts)
export const dataChanged = defineEvent({ name: "github/data-changed", mode: "emit", payload: z.strictObject({}), scope: "server", wire: { to: ["client"], key: null } });
declare module "@get-bb/plugin-sdk/contracts" { interface Events { "github/data-changed": EventDecl<Record<string, never>, "emit"> } }
// after (app): the query hook is the consumer
const itemsQuery = defineQuery<Record<string, never>, Item[]>({ key: "items",
  fetch: async (input, client) => itemsSchema.parse(await client.query("github/github", "list", input)),
  invalidateOn: [{ name: "github/data-changed", key: null }] });

On the server, await ctx.realtime.declare(dataChanged) before ctx.provide, then ctx.realtime.publish("github/data-changed", {}); publishing an undeclared name throws unknown_event. Per-key targeting is data: wire: { key: { path: "vaultId", prefix: "vault" } } and invalidateOn: [{ name, key: (input) => \vault:${input.vaultId}` }]`.

9. bb.events.on("thread.idle") → commands and kernel events

// before (automations): bb.events.on("thread.idle", ({ thread }) => close(thread.id)); bb.events.on("thread.deleted", …);
// after
await ctx.commands.after("thread.archive", (cmd, outcome) => { if (outcome.ok) void onArchived(cmd.input.threadId); });
await ctx.commands.after("thread.delete", (cmd, outcome) => { if (outcome.ok) void onDeleted(cmd.input.threadId); });
await ctx.events.on("kernel/thread-head.changed", (payload) => void onHeadChanged(payload));   // idle/active/failed live on the head

thread.created/archived/deleted split into commands.after on thread.create/thread.archive/thread.delete; status transitions ride kernel/thread-head.changed (D12), and the head is read through threads/threads.show. commands.before is new: you can veto or rewrite thread.send (§3.4), which 0.4 could not.

10. bb.background.schedule → a loop in start(signal)

// before (side-chat): bb.background.schedule("empty-fork-cleanup", "13 * * * *", async () => { … });
// after: contributes.background: ["empty-fork-cleanup"]
background: { "empty-fork-cleanup": defineBackgroundService(async (_scope, signal) => {
  while (!signal.aborted) { await sweep(); await sleep(60 * 60_000, signal); } }) },

There is no kernel schedules table (D10); a durable schedule is the automations plugin's. start receives a bare Scope, not a PluginContext: capture ctx.storage and ctx.log in activate and pass them in. A non-empty contributes.background starts a separate worker.

11. app.slots.*app.slots.register

0.4 1.0 target (§10.4) Status in §5
settingsSection settings.plugin:<pluginId> (keyed, root); contributes.settings alone yields the form spec
navPanel a pane kind + route (app.panes.register, app.routes.register) + a sidebar.nav row; ui-shell/pages has no registrants; page.header.center:<pageId> panes, routes, sidebar.nav built; pages and header spec
threadPanelAction app.tabs.register for the tab; panel.launcher for the row tabs API built, no kind in the slice; launcher spec
sidebarFooterAction sidebar.footer (list, root) built
pendingInteraction interaction.renderer:<pluginId>/<name> (keyed, thread) built
homepageSection compose.sections (list, pane) built
messageAction / messageDirective message.action (list, thread) / markdown.extension (list, root) built
fileOpener files.opener:<ext> (keyed, pane); pin slot:files.opener:<ext> spec
// before (custom-instructions/app.tsx)
export default definePluginApp((app) => { app.slots.settingsSection({ id: "custom-instructions", component: CustomInstructionsSettings }); });
// after: the slot is spec today, so ship the declarative form (contributes.settings) and hold the registration
export default definePluginApp({ setup(app) {
  textRef = app.preferences.define(TEXT_KEY, z.string().max(MAX_LENGTH), { scope: "profile", default: "" });
  // once ui-settings declares it: app.slots.register({ name: "settings.plugin", kind: "keyed", scope: "root", key: "custom-instructions" }, CustomInstructionsSettings);
} });

A registration into a slot no live occupant declares fails the plugin's load (§4.2). For a nav panel today, follow hello-slot: a pane kind, a route, and the same component as a root fallback at priority: -1000, plus a sidebar.nav occupant for the row. useBbNavigate() becomes useNavigation().open(target); useBbContext() becomes usePane() and the scope props (threadId, paneId). @bb/shared-ui/* imports become @bb/ui.

12. experimental_defineHostEntrydefineHostEntry

// before (keep-awake/host.ts, server.ts)
return experimental_defineHostEntry({ contract: keepAwakeHostContract,
  handlers: { setEnabled(input, context) { bindLifecycle(context.lifecycle.signal); workerLease ??= context.experimental_retainWorker(); … } },
  dispose() { disposeState(); } });
const host = bb.hosts.experimental_client({ contract: keepAwakeHostContract });
host.experimental_onWorkerExit(({ hostId }) => requestRetry());   await host.call("setEnabled", { enabled }, { hostId, signal });
// after (contracts.ts): kebab-case names; contributes.hostRoles: [{ role: "rpc", name: "caffeinate" }], hostCommands: ["set-enabled"]
export const keepAwakeHost = defineHostCommands({ id: "keep-awake",
  commands: { "set-enabled": { input: z.strictObject({ enabled: z.boolean() }), output: z.strictObject({ enabled: z.boolean(), supported: z.boolean() }) } } });
// after (host.ts)
export default defineHostEntry({
  roles: [defineHostRole({ role: "rpc", name: "caffeinate", contract: keepAwakeHost,
    commands: { "set-enabled": async ({ enabled }, ctx) => { bindLifecycle(ctx.lifecycle.signal); lease ??= ctx.retain(); return setEnabled(enabled); } } })],
  dispose() { disposeState(); },
});
// after (server.ts)
const host = ctx.hostClient(keepAwakeHost, "caffeinate");
host.onExit(({ roleId }) => requestRetry());   await host.call("set-enabled", { enabled }, { hostId, timeoutMs: null });

bb.sdk.subscribe({ event: "host:changed" }) becomes ctx.events.on("kernel/host.changed", …) (D12); bb.sdk.hosts.list() is kernel/hosts.list (§3.11), injected by declaring your own defineService copy of the id. ctx.retain() is a no-op in the child; leases are held host-side (D14). experimental_createHostEntryHarness becomes fakeRoleContext (guide page 13). The supervisor never restarts a role; the next call starts it again (§6.7).

13. Delete

  • globalThis.__bbPluginRuntime and the specifier shims 0.4's bb plugin build wrote: the browser serves one React, @bb/ui, and @get-bb/plugin-sdk/app through the native import map (§4.11).
  • Every experimental_ name and its entry in docs/api_to_audit.md: the 1.0 surface is locked in api.lock.json under final names (§0.5). Remove the "Host plugin foundation", "Fixed-tab navigation", registerTool({ experimental_* }), and provider-maintenance entries as you stop using them.
  • defineRpcContract, bb.cli.register, bb.onDispose (registrations dispose themselves; ctx.effect for the rest), bb.server.loopbackBaseUrl (ctx.server.baseUrl), bb.sdk (ctx.inject), acpLaunchSpec and the ACP tier (bridgeOptions.launch), jsdom and @testing-library/react (the app harness runs happy-dom).

What happens at runtime

bb plugin build . runs setup in the headless collector and writes dist/app.contributions.json; the browser runs setup again at load and fails with contributions_mismatch on any difference (D9). The server tier loads in-process for bundled and path: rows and in a worker for npm:/url: rows (D7). activate has 30 s; registrations publish in composition order; a uses rebind re-runs activate inside one registry batch (§3.1, §3.3).

Pitfalls

  • settings.plugin, panel.launcher, files.opener, page.header.center, and the plugin-panel tab kind are spec at dc07292bf. Target root, sidebar.*, thread.*, compose.*, timeline.*, message.action, interaction.renderer today (§10.4).
  • Host command names are kebab-case in defineHostCommands even though the loader regex admits camelCase (§1.3); no build step cross-checks hostCommands against the entry (§6.3).
  • ctx.hostClient answered service_unavailable at 6f3d4592f (D15) and is wired at dc07292bf; keep an in-process fallback for an offline host (§3.9). target: "host" methods answer 503 (D5).
  • A plugin may write only its own <pluginId>/… preference keys and may not call kernel/plugins or kernel/composition mutations (forbidden, §1.14; own preference keys §3.7).
  • The cli tier (defineOfflineHandlers, cli.offline) is not built (Appendix B). A 0.4 command that worked without a server needs the server in 1.0.

Checklist

Before you call the port done:

  • [ ] package.json#bb has id, contracts, provides, uses, contributes; no branding, no engines.bb: ">=0.0".
  • [ ] src/contracts.ts exports every defineService, defineEvent, defineHostCommands, and the declare module merges; no handlers.
  • [ ] Every bb.rpc.register / bb.http.route is a method; every bb.cli.register and registerTool is deleted in favor of derivation.
  • [ ] Every setting is defined in each tier that reads it, from one shared key constant.
  • [ ] Every realtime.publish has a defineEvent, a declare before provide, and a defineQuery consumer; every events.on maps to commands.after or a kernel/* event; every schedule is a start(signal) loop.
  • [ ] Every slot target is marked built in §5, or the registration waits.
  • [ ] bb plugin build ., expectDerivedSurfaces, createTestPlugin (inproc and worker), createAppHarness pass; no experimental_ name remains and docs/api_to_audit.md is trimmed.

See also

  • Reference §10 (the full old → new map), §1.2–1.3 (manifest keys), §3 (every ctx member), §4 (setup and the hooks), §5 (slot status), §6 (host tier); guide pages 13 (tests) and 14 (install the port as path:, fork what you need); the 0.4 sources under packages/plugin-sdk/src/ and plugins/{custom-instructions,keep-awake}/.