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 + useRpc → defineService + 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 callInputs 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.contributeInstructions → ctx.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.registerTool → expose: { 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 }) }, // handlerMost 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 + useSettings → contributes.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); // componentEach 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 + useRealtime → defineEvent + 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 headthread.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_defineHostEntry → defineHostEntry
// 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.__bbPluginRuntimeand the specifier shims 0.4'sbb plugin buildwrote: the browser serves one React,@bb/ui, and@get-bb/plugin-sdk/appthrough the native import map (§4.11).- Every
experimental_name and its entry indocs/api_to_audit.md: the 1.0 surface is locked inapi.lock.jsonunder 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.effectfor the rest),bb.server.loopbackBaseUrl(ctx.server.baseUrl),bb.sdk(ctx.inject),acpLaunchSpecand the ACP tier (bridgeOptions.launch),jsdomand@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 theplugin-paneltab kind are spec atdc07292bf. Targetroot,sidebar.*,thread.*,compose.*,timeline.*,message.action,interaction.renderertoday (§10.4).- Host command names are kebab-case in
defineHostCommandseven though the loader regex admits camelCase (§1.3); no build step cross-checkshostCommandsagainst the entry (§6.3). ctx.hostClientansweredservice_unavailableat6f3d4592f(D15) and is wired atdc07292bf; 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 callkernel/pluginsorkernel/compositionmutations (forbidden, §1.14; own preference keys §3.7). - The
clitier (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#bbhasid,contracts,provides,uses,contributes; nobranding, noengines.bb: ">=0.0". - [ ]
src/contracts.tsexports everydefineService,defineEvent,defineHostCommands, and thedeclare modulemerges; no handlers. - [ ] Every
bb.rpc.register/bb.http.routeis a method; everybb.cli.registerandregisterToolis deleted in favor of derivation. - [ ] Every setting is defined in each tier that reads it, from one shared key constant.
- [ ] Every
realtime.publishhas adefineEvent, adeclarebeforeprovide, and adefineQueryconsumer; everyevents.onmaps tocommands.afteror akernel/*event; every schedule is astart(signal)loop. - [ ] Every slot target is marked built in §5, or the registration waits.
- [ ]
bb plugin build .,expectDerivedSurfaces,createTestPlugin(inproc and worker),createAppHarnesspass; noexperimental_name remains anddocs/api_to_audit.mdis trimmed.
See also
- Reference §10 (the full old → new map), §1.2–1.3 (manifest keys), §3 (every
ctxmember), §4 (setupand the hooks), §5 (slot status), §6 (host tier); guide pages 13 (tests) and 14 (install the port aspath:, fork what you need); the 0.4 sources underpackages/plugin-sdk/src/andplugins/{custom-instructions,keep-awake}/.