How a plugin works
A plugin is an npm package with up to three entries, one for each of bb’s processes. The server entry is required and runs inside the server; the optional app entry runs in the app window, and the optional host entry runs on the machine where the agent works. The entries share no memory. They talk over RPC, a realtime push channel and host RPC, and all durable state lives with the server entry. bb loads every entry at full trust. If the three processes are new to you, read How bb works first.
Three entries
Section titled “Three entries”The manifest is the plugin’s own package.json. Its bb block names the entries:
"bb": { "name": "Hello", "server": "./server.ts", // required "app": "./app.tsx", // optional: UI in the app window "host": "./host.ts" // optional: code on the agent's machine}| Entry | Runs in | Use it for | Its shape |
|---|---|---|---|
server entry, bb.server | the bb server process | storage, RPC and HTTP routes, CLI commands, agent tools, providers, schedules | a default-exported factory (bb: BbPluginApi) => … |
app entry, bb.app | the app window, in the same JS realm as bb’s own UI | panels, controls and renderers in the window, composer additions, palette commands | definePluginApp((app) => …) |
host entry, bb.host | a worker process the host daemon starts on its machine | files, processes and the agent’s bridge on the machine where the agent runs | experimental_defineHostEntry({ contract, handlers }) |
The server entry is required even when a plugin only adds UI: plugins/scheduled-send/server.ts is a deliberate stub. The manifest fields in full are in Package anatomy.
Neither the server entry nor the app entry runs a main loop. The server entry is a function bb calls once per load, handing it a fresh bb object. The function registers what the plugin provides (an RPC method, a CLI command, a setting, an agent tool) and returns. The app entry does the same through app: it registers a panel, a slot component or a command, and bb mounts the component where that slot sits in the window. From then on bb calls the plugin’s code when a person, the agent or another process needs it. The registration calls are listed in Backend namespaces and Frontend slots.
How the entries talk
Section titled “How the entries talk”app.tsx
useRpc<typeof rpcContract>()
rpc.call("listIssues", { … })
import type — the backend is erased from the bundle
1↓ rpc.call(…) → POST /plugins/<id>/rpc/<method>
POST /plugins/<id>/rpc/<method>
input schema → handler → output schema
2↓ validation → server.ts — the factory, in the server process
server.ts — the factory, in the server process
bb.rpc.register(…)
bb.http.route(…)
bb.cli.register({ name, run })
bb.agents.registerTool(…)
bb.realtime.publish(…)
the result is strict JSON
3↓ bb.realtime.publish → WebSocket /ws — one shared socket (signal back)
8↓ host RPC → host.ts, on the agent’s machine
↓ read and write → state and the outside world
useRealtime("issues:changed", fn)
the same component refetches its data
WebSocket /ws — one shared socket
{ type, pluginId, channel, payload }
broadcast to all; the client filters
4↓ useRealtime → refetch → useRealtime("issues:changed", fn) (signal back)
bb CLI
bb <command> … · argv without the command name
5↓ POST /cli → POST /plugins/<id>/cli
POST /plugins/<id>/cli
run() executes on the server
↓ server.ts — the factory, in the server process
agent tool
the tool set is frozen at session start
6↓ execute(…) → called inside the server process
called inside the server process
errors come back as text
↓ server.ts — the factory, in the server process
external service · webhook
POST with the signature in the request body
7↓ signature in the body → ALL /plugins/<id>/http/<path>
ALL /plugins/<id>/http/<path>
your own route, exact path match
↓ server.ts — the factory, in the server process
state and the outside world
bb.storage.kv · bb.storage.database()
fetch · fs · child_process
host.ts, on the agent’s machine
called through bb.hosts.experimental_client(…)
signals back with experimental_emitSignal(…)
The server entry is the hub. Every other caller reaches the plugin through it, and each caller has one channel:
| Channel | From → to | Use it for | Reference |
|---|---|---|---|
| RPC | app entry → server entry | reading and changing data from the UI; the contract is defined once in the server entry, and the app entry imports only its type | bb.rpc |
| realtime | server entry → every open window | telling pages that data changed so they fetch it again; nothing is stored or replayed | bb.realtime |
| CLI | the bb CLI → server entry | a bb <command> for people and for the agent; the command runs on the server, not in the CLI | bb.cli |
| agent tool | the agent → server entry | a native tool in the agent’s session, called inside the server process | bb.agents |
| HTTP | an outside service → server entry | webhooks and other callers that are not bb | bb.http |
| host RPC | server entry ⇄ host entry | work on the agent’s machine, with signals coming back | bb.hosts |
Two channels are absent by design. The app entry cannot call the host entry: it calls the server entry over RPC, and the server entry calls the host entry. And no entry can reach another plugin directly.
A skill completes the picture for the agent. A plugin ships skills/<name>/SKILL.md, bb injects it into the agent’s threads, and the skill tells the agent which bb command to run. The agent then goes through the CLI channel like a person would, instead of touching the plugin’s storage. Your first plugin walks through a scaffold that uses RPC, realtime, the CLI and a skill against one store.
State lives with the server entry
Section titled “State lives with the server entry”The server entry owns every piece of durable state: values in bb.storage.kv, a SQLite database of its own from bb.storage.database(), and settings declared with bb.settings.define. The app entry keeps only what it renders. It fetches over RPC and fetches again when a realtime signal arrives, so every open window shows the same data. A host entry keeps its own files on its machine, in the directory bb gives it.
This is what keeps a plugin small as it grows. The page, the CLI command and the agent tool are three clients of the same functions in the server entry, so adding a surface to a finished plugin usually means one more call into code that already exists.
Load, reload and dispose
Section titled “Load, reload and dispose”bb runs the server entry’s factory every time it loads the plugin: when the server starts, after an install, on bb plugin reload, and after every save while bb plugin dev is watching. Four rules follow from that:
- The factory is time-boxed to 30 seconds. Work that takes longer, or runs forever, belongs in a background service registered with
bb.background.service. - The
bbobject is valid for one load. After a reload, a reference kept from the previous load throwsPluginContextStaleError, sobbnever goes into a module-level variable. - A reload loads the new instance first and disposes the old one afterwards. If the new factory throws, the old instance keeps running, and its status stays
runningwith the detail “reload failed: …”. - Cleanup goes into
bb.onDispose. The hooks run in reverse order of registration when the plugin reloads, is disabled or the server shuts down.
In the app window, bb runs the app entry’s setup again whenever the bundle changes. A slot component that throws is caught by its own error boundary: bb shows a fallback, the rest of the window keeps working, and the slot stays disabled until the plugin reloads. The exact load and dispose steps, the eight statuses and the hot-reload loop are in Runtime and lifecycle.
Full trust
Section titled “Full trust”bb does not sandbox plugins. The server entry runs inside the server process with unrestricted fs, net, child_process and fetch; the app entry is same-origin page code in bb’s own window; the host entry runs as the same OS user as the agent. The security boundary is the decision to install a plugin. Trust model lists what plugin code can reach and the design rules that follow from it.