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.settings make a form.
  • Add a custom settings page. Claim the bb.settings.pages list 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.bookmarks

bb 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 a threadId.
  • Compare and set. Pass expectedRevision to set() or clear() 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.settings reads the validated schema through bb.plugins and 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 threadId with each thread-scoped address.
  • Check source before 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, and bb.preferences.
  • bb.settings defines the generated form and the bb.settings.pages surface.
  • Guide 4 can expose data changes as commands.
  • Guide 6 can publish data changes to the app.