Data and settings
A plugin can keep private data through three fixed kernel ports. Use bb.storage for plugin data and bb.secrets for secret values. Use bb.preferences for values that a user can change.
The bb.settings plugin presents preference forms and custom settings pages. The kernel still owns the schema, values, defaults, and access rules.
Use this when
- Keep a small private value. Use a key-value store for a cursor, a count, or cached JSON.
- Own tables and migrations. Use a private SQLite database for records and queries.
- Use an API token. Read a declared secret through
bb.secrets, and never store or log its value. - Add user settings. Declare a schema and let
bb.settingsmake a form. - Add a custom settings page. Claim the
bb.settings.pageslist surface.
What you build
This page builds acme.bookmarks. The plugin keeps a start count in key-value storage and creates a SQLite table for bookmarks.
The plugin also declares three profile settings. They set the bookmark limit, sync state, and API token.
The kernel creates a standard settings form from the schema. The plugin also adds a read-only summary page through bb.settings.pages.
Steps
1. Declare the ports, settings, and page claim
Write bb.plugin.jsonc at the package root. A claim names a contract that another plugin owns.
The three requires rows declare the fixed kernel ports that the plugin uses. The page claim adds one ordered item to a list surface.
{
"$schema": "https://getbb.app/schemas/plugin-v2.schema.json",
"schemaVersion": 2,
"id": "acme.bookmarks",
"version": "2.0.0",
"name": "Bookmarks",
"description": "Keep bookmarks and optional sync settings.",
"category": "productivity",
"engines": {
"bb": "^2.0.0",
"sdk": "^2.0.0"
},
"artifacts": {
"app": "./dist/app.js",
"server": "./dist/server.js"
},
"claims": [
{
"surface": "bb.settings.pages",
"version": "^1.0.0",
"order": 300
}
],
"requires": [
{ "service": "bb.storage", "range": "^1.0.0" },
{ "service": "bb.secrets", "range": "^1.0.0" },
{ "service": "bb.preferences", "range": "^1.0.0" }
],
"settings": {
"schema": "./settings.schema.json",
"fields": {
"maxPerThread": {
"label": "Bookmarks per thread",
"required": true
},
"syncEnabled": {
"label": "Sync bookmarks",
"required": true
},
"apiToken": {
"label": "Bookmark API token",
"secret": true,
"required": false
}
}
}
}bb plugin build validates this file. It also generates contract.json for the loader and inspection tools.
The build checks the claim against the declaration for bb.settings.pages. Do not write contract.json by hand.
2. Define the settings schema
Create settings.schema.json. The kernel validates the schema during install and validates each value before storage.
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"properties": {
"maxPerThread": {
"type": "integer",
"minimum": 1,
"maximum": 500,
"default": 20
},
"syncEnabled": {
"type": "boolean",
"default": false
},
"apiToken": {
"type": "string",
"minLength": 1
}
},
"required": ["maxPerThread", "syncEnabled"]
}The property names form preference keys under the plugin ID. For example, maxPerThread becomes acme.bookmarks/maxPerThread.
This slash separates a preference owner from a preference name. It does not form a contract ID.
The manifest marks apiToken as secret. The kernel does not put that value in bb.preferences.
The standard bb.settings form writes the token through the human-only secret flow. The form only shows its state.
3. Use all three ports in the server factory
Create src/server.ts. The factory receives a typed api object with handles for the declared ports.
import { defineServerPlugin } from "@get-bb/plugin/server";
const MAX_PER_THREAD = {
key: "acme.bookmarks/maxPerThread",
scope: "profile",
} as const;
const SYNC_ENABLED = {
key: "acme.bookmarks/syncEnabled",
scope: "profile",
} as const;
const migrations = [
{
id: "001-bookmarks",
checksum:
"c90cf81c370c73d50b89c476e288ee98238075da5cb0892f86b43809218546e8",
statements: [
"CREATE TABLE bookmarks (id INTEGER PRIMARY KEY, url TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', created_at INTEGER NOT NULL)",
"CREATE INDEX bookmarks_created_at ON bookmarks (created_at)",
],
},
] as const;
async function sendBookmarks(_token: string): Promise<void> {
// Send a request here. Do not log or store _token.
}
export default defineServerPlugin(async (api) => {
const state = api.storage.kv("runtime");
const starts = (await state.get<number>("starts")) ?? 0;
await state.set("starts", starts + 1);
const db = await api.storage.database("bookmarks");
await api.storage.migrate(db, migrations);
api.onDispose(() => db.close());
const row = await db.get<{ count: number }>(
"SELECT COUNT(*) AS count FROM bookmarks",
);
const limit = await api.preferences.get<number>(MAX_PER_THREAD);
api.log.info("bookmark data is ready", {
starts: starts + 1,
bookmarks: row?.count ?? 0,
maxPerThread: limit.value,
source: limit.source,
});
const sync = async (): Promise<void> => {
const enabled = await api.preferences.get<boolean>(SYNC_ENABLED);
if (!enabled.value) return;
const token = await api.secrets.read(api.secrets.ref("apiToken"), {
signal: api.plugin.signal,
});
if (token === undefined) {
api.log.warn("bookmark sync needs an API token");
return;
}
await token.expose((value) => sendBookmarks(value));
};
const requestSync = (): void => {
void sync().catch(() => {
api.log.warn("bookmark sync failed");
});
};
api.onDispose(
api.preferences.watch(
{ ownerPluginId: api.plugin.id },
requestSync,
),
);
api.onDispose(
api.secrets.watch("apiToken", requestSync),
);
await sync();
});kv("runtime") creates a private namespace. Another plugin cannot read it.
database("bookmarks") opens a private SQLite database. The kernel keeps its name inside the plugin data root.
migrate() applies each new migration once. Add a new migration when the schema changes.
preferences.get() returns a cell. The cell includes the value, its source, its revision, and its update time.
SecretValue.expose() limits direct access to the secret text. Kernel logs, errors, traces, and RPC data redact the wrapper.
The two watchers return cleanup functions. api.onDispose() runs them when this plugin generation stops.
4. Add a custom settings page
The manifest schema already gives this plugin a generated settings page. A custom claim adds another page and does not replace it.
Create src/app.tsx. This page uses usePluginSettings() to read all non-secret settings for the plugin.
import { definePlugin } from "@get-bb/plugin/app";
import {
SettingsPage,
SettingsSection,
} from "@bb/settings/components";
import { usePluginSettings } from "@bb/settings/hooks";
function BookmarkSummary() {
const settings = usePluginSettings("acme.bookmarks");
return (
<SettingsPage title="Bookmark status">
<SettingsSection title="Current values">
{settings.isLoading ? <p>Loading...</p> : null}
{settings.error ? <p>{settings.error.message}</p> : null}
{settings.values ? (
<dl>
<dt>Bookmarks per thread</dt>
<dd>{String(settings.values.maxPerThread)}</dd>
<dt>Sync</dt>
<dd>{settings.values.syncEnabled ? "On" : "Off"}</dd>
</dl>
) : null}
</SettingsSection>
</SettingsPage>
);
}
export default definePlugin((api) => {
api.surfaces.provide("bb.settings.pages", {
id: "status",
label: "Bookmark status",
description: "Show the active bookmark settings.",
order: 300,
component: BookmarkSummary,
});
});The bb.settings.pages contract has the list kind. Every claimant adds one page.
The user can hide or reorder pages. The runtime uses the user order before the claim order.
usePluginSettings() omits apiToken. Use the standard secret field or the generated form to change that value.
The factory does not return a cleanup function. The loader removes its surface item automatically.
5. Build and inspect the plugin
Run the standard plugin commands from the package root.
bb plugin build .
bb plugin dev .
bb plugin config acme.bookmarks
bb plugin doctor acme.bookmarks
bb plugin logs acme.bookmarksbb plugin config reads and changes the declared settings through bb.preferences. It uses the kernel secret flow for apiToken.
The Settings application shows the generated form and the custom summary page. The server follows later preference and secret changes.
Choose a data store
Use the smallest store that fits the data.
| Need | Port | Reason |
|---|---|---|
| A small JSON value | bb.storage.kv() |
It gives simple get, set, delete, list, compare, and watch operations. |
| Rows and queries | bb.storage.database() |
It gives private SQLite tables, batches, and transactions. |
| A user choice | bb.preferences |
It validates profile or thread values against the manifest schema. |
| A password or token | bb.secrets |
It controls access and redacts secret wrappers. |
Do not use a preference as a general data store. A user, a CLI command, or a settings page can change it.
Do not put a secret in key-value storage, SQLite, a preference, a log field, or an error message.
What happens at runtime
- Storage scope. The kernel binds each storage handle to the plugin ID and generation.
- Fork data. A fork has a new plugin ID, so its storage starts empty.
- Database life. The database handle rejects work after its lifecycle scope ends.
- Preference validation. The kernel validates stored values and schema defaults before it returns a cell.
- Preference writes. A plugin can write only keys in its own namespace.
- Thread settings. A thread address includes
scope: "thread"and athreadId. - Compare and set. Pass
expectedRevisiontoset()orclear()when a stale write must fail. - Secret writes. Only a human or an approved kernel flow can set or remove a secret.
- Settings form.
bb.settingsreads the validated schema throughbb.pluginsand writes through kernel ports. - Reload. A preference change does not restart a healthy plugin. A watcher receives the committed change.
Pitfalls
- Keep each migration ID and checksum stable after release. Add another migration for each later schema change.
- Close each database handle during disposal. Do not keep a handle in module state.
- Declare every preference and secret in the manifest schema. The ports reject unknown keys.
- Use the full preference key in code. The key has the form
<pluginId>/<name>. - Include a
threadIdwith each thread-scoped address. - Check
sourcebefore you assume that a preference has a stored row. A default also produces an effective value. - Keep secret work inside
SecretValue.expose(). Never return the secret from its callback. - Do not register a second runtime settings schema. The kernel manifest has the only schema.
- Do not expect a custom settings page to replace the generated page. It adds one list item.
See also
- Kernel ports defines
bb.storage,bb.secrets, andbb.preferences. - bb.settings defines the generated form and the
bb.settings.pagessurface. - Guide 4 can expose data changes as commands.
- Guide 6 can publish data changes to the app.