Data and settings

A plugin owns four places to keep state: a key-value store, a SQLite database, a log, and secrets by reference. It also owns its preferences, which are the settings a user edits in Settings and an agent reads through the CLI. Nothing is shared between plugins except through services. This page builds one plugin that uses all of them, and shows where each value lives on disk, which tier can read it, and how it changes.

Use this when

  • Keep an API token out of my code. You hold a reference; the value never passes through your logs or your database.
  • Remember a per-thread preference. A sort order the user sets on one thread stays on that thread.
  • Own a table with migrations. Your rows live in your own SQLite file, and the schema evolves with the plugin.
  • Expose a setting to Settings and to agents. The user edits it in Settings, agents read it through the CLI, and the plugin says "I need configuration" instead of failing.

What you build

A server-tier plugin bookmarks with one service, bookmarks/bookmarks. It keeps bookmarks per thread in its own data.db, reads a profile setting (maxPerThread), a thread-scoped preference (pinnedFirst), a boolean setting (syncEnabled), and a secret (apiToken). When sync is on and the token is missing, the plugin shows needs-configuration and keeps serving. An app component reads the thread preference with the same key.

Steps

1. Declare the manifest

database: true is the permission for storage.database(). Each contributes.settings entry is the profile preference bookmarks/<key> and one row in the Settings form. The descriptor types are string, text, number, boolean, select, list, project, host, and secret (Reference §1.3).

{
  "name": "@bb-local/bookmarks",
  "version": "0.1.0",
  "type": "module",
  "bb": {
    "id": "bookmarks",
    "name": "Bookmarks",
    "description": "Bookmark URLs on a thread; agents can add them as a tool.",
    "category": "productivity",
    "server": "./src/server.ts",
    "app": "./src/app.tsx",
    "contracts": "./src/contracts.ts",
    "provides": { "bookmarks/bookmarks": { "version": "1.0.0" } },
    "contributes": {
      "database": true,
      "cli": { "commands": ["bookmarks"] },
      "settings": {
        "maxPerThread": { "type": "number", "label": "Bookmarks per thread", "default": 20, "min": 1, "max": 500 },
        "syncEnabled": { "type": "boolean", "label": "Sync to the bookmark service", "default": false },
        "apiToken": { "type": "secret", "label": "Bookmark service API token", "required": false }
      }
    }
  },
  "dependencies": { "@get-bb/plugin-sdk": "1.0.0-next.0", "@bb/ui": "*", "zod": "4.3.6" },
  "peerDependencies": { "react": "^19.0.0" }
}

2. Export the keys and the service from src/contracts.ts

Export the preference keys here so both tiers define the same key with the same schema and default. The config query is what an agent runs as bb bookmarks config --json.

import { defineService, method } from "@get-bb/plugin-sdk/contracts";
import { z } from "zod";

export const MAX_KEY = "bookmarks/maxPerThread";
export const SYNC_KEY = "bookmarks/syncEnabled";
export const PINNED_FIRST_KEY = "bookmarks/pinnedFirst";
export const TOKEN_SECRET = "apiToken"; // ctx.secrets.reference("apiToken") → "plugin:bookmarks/apiToken"
export const pinnedFirstSchema = z.boolean();

export const bookmarkSchema = z.strictObject({
  id: z.number().int(),
  threadId: z.string(),
  url: z.string().url(),
  note: z.string(),
  pinned: z.boolean(),
  createdAt: z.number().int(),
});
export type Bookmark = z.infer<typeof bookmarkSchema>;
const threadId = z.string().min(1).describe("Thread id");

export const bookmarks = defineService({
  id: "bookmarks/bookmarks",
  version: "1.0.0",
  summary: "Bookmarks per thread",
  cli: { group: ["bookmarks"] },
  methods: {
    add: method({
      kind: "mutation",
      summary: "Bookmark a URL on a thread",
      input: z.strictObject({ threadId, url: z.string().url(), note: z.string().max(500).default("") }),
      output: bookmarkSchema,
      expose: { tool: true },
      cli: { fields: { url: { positional: 0 }, threadId: { flag: "thread", ambient: "threadId" } } },
      tool: { presentation: { label: { pending: "Bookmarking…", completed: "Bookmarked" }, icon: "Check", intent: "generic" } },
      errors: { "bookmarks/limit": { status: 409, summary: "The thread has reached its bookmark limit" } },
      renderText: (b) => `${b.id}  ${b.url}`,
    }),
    list: method({
      summary: "The bookmarks of a thread",
      input: z.strictObject({ threadId }),
      output: z.array(bookmarkSchema),
      cli: { fields: { threadId: { flag: "thread", ambient: "threadId" } } },
      renderText: (rows) => rows.map((b) => `${b.pinned ? "*" : " "} ${b.id}  ${b.url}  ${b.note}`).join("\n") || "(none)",
    }),
    togglePinnedFirst: method({
      kind: "mutation",
      summary: "Flip the per-thread 'pinned first' sort",
      input: z.strictObject({ threadId }),
      output: z.strictObject({ pinnedFirst: z.boolean() }),
      cli: { name: "pinned-first", fields: { threadId: { flag: "thread", ambient: "threadId" } } },
      renderText: (r) => (r.pinnedFirst ? "pinned first" : "newest first"),
    }),
    config: method({
      summary: "The effective settings",
      input: z.strictObject({}),
      output: z.strictObject({ maxPerThread: z.number().int(), syncEnabled: z.boolean(), tokenConfigured: z.boolean() }),
      renderText: (c) => `max ${c.maxPerThread}  sync ${c.syncEnabled ? "on" : "off"}  token ${c.tokenConfigured ? "set" : "missing"}`,
    }),
  },
});

3. Provide the handlers in src/server.ts

openDatabase(migrations) opens the plugin's own file and runs the statements it has not run yet. Rows come back as SqlResultRow (Record<string, SqlParam>); parse them at that boundary.

import { definePlugin, withDefaults, KernelError, type SqlResultRow } from "@get-bb/plugin-sdk/server";
import { z } from "zod";
import { bookmarks, MAX_KEY, PINNED_FIRST_KEY, pinnedFirstSchema, SYNC_KEY, TOKEN_SECRET, type Bookmark } from "./contracts.js";

export const MIGRATIONS = [
  "CREATE TABLE bookmarks (id INTEGER PRIMARY KEY AUTOINCREMENT, thread_id TEXT NOT NULL, url TEXT NOT NULL, note TEXT NOT NULL DEFAULT '', pinned INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL)",
  "CREATE INDEX bookmarks_thread ON bookmarks (thread_id, created_at)",
];

const rowSchema = z.object({ id: z.number().int(), thread_id: z.string(), url: z.string(), note: z.string(), pinned: z.number().int(), created_at: z.number().int() });
const toBookmark = (row: SqlResultRow): Bookmark => {
  const r = rowSchema.parse(row);
  return { id: r.id, threadId: r.thread_id, url: r.url, note: r.note, pinned: r.pinned === 1, createdAt: r.created_at };
};

export default definePlugin({
  async activate(ctx) {
    const db = await ctx.storage.openDatabase(MIGRATIONS);
    const maxPerThread = ctx.preferences.define(MAX_KEY, z.number().int().min(1).max(500), { scope: "profile", default: 20 });
    const syncEnabled = ctx.preferences.define(SYNC_KEY, z.boolean(), { scope: "profile", default: false });
    const pinnedFirst = ctx.preferences.define(PINNED_FIRST_KEY, pinnedFirstSchema, { scope: "thread", default: false });

    const token = async (): Promise<string | null> => {
      try {
        return (await ctx.secrets.resolve({ name: ctx.secrets.reference(TOKEN_SECRET) })).value;
      } catch (error) {
        if (error instanceof KernelError && error.code === "not_found") return null;
        throw error;
      }
    };
    const checkConfiguration = async (): Promise<void> => {
      if ((await syncEnabled.get()) && (await token()) === null)
        await ctx.status.needsConfiguration("Set the API token in Settings → Bookmarks, or turn sync off");
      else await ctx.status.ok();
    };
    const recheck = (): void => void checkConfiguration().catch((e: unknown) => ctx.log.warn("status check failed", { error: String(e) }));
    await syncEnabled.watch(recheck);
    await ctx.events.on("kernel/secrets.changed", ({ name }) => { if (name === ctx.secrets.reference(TOKEN_SECRET)) recheck(); });

    await ctx.provide(bookmarks, withDefaults(bookmarks, {
      add: async ({ threadId, url, note }) => {
        const count = await db.get("SELECT COUNT(*) AS n FROM bookmarks WHERE thread_id = ?", [threadId]);
        const n = count?.["n"];
        const max = await maxPerThread.get();
        if (typeof n === "number" && n >= max)
          throw new KernelError({ code: "bookmarks/limit", message: `thread has ${n} bookmarks`, data: { threadId, max } });
        const inserted = await db.run("INSERT INTO bookmarks (thread_id, url, note, pinned, created_at) VALUES (?, ?, ?, 0, ?) RETURNING *", [threadId, url, note, Date.now()]);
        const row = inserted.rows[0];
        if (row === undefined) throw new KernelError({ code: "internal", message: "insert returned no row" });
        const bookmark = toBookmark(row);
        ctx.log.info("bookmark added", { threadId, id: bookmark.id });
        await ctx.storage.kv.set("lastAdded", { threadId, at: bookmark.createdAt });   // a small cursor: kv, not a table
        return bookmark;
      },
      list: async ({ threadId }) => {
        const order = (await pinnedFirst.get({ threadId })) ? "pinned DESC, created_at DESC" : "created_at DESC";
        const rows = await db.all(`SELECT * FROM bookmarks WHERE thread_id = ? ORDER BY ${order}`, [threadId]);
        return rows.map(toBookmark);
      },
      togglePinnedFirst: async ({ threadId }) => {
        const stamp = await pinnedFirst.updatedAt({ threadId });          // null when unset
        const next = !(await pinnedFirst.get({ threadId }));
        await pinnedFirst.set(next, { threadId, expectedUpdatedAt: stamp }); // `conflict {key, updatedAt}` on a race
        return { pinnedFirst: next };
      },
      config: async () => ({
        maxPerThread: await maxPerThread.get(),
        syncEnabled: await syncEnabled.get(),
        tokenConfigured: (await token()) !== null,
      }),
    }));
    await checkConfiguration();
  },
});

4. Read the thread preference in the browser

The app tier defines the same key itself; a server define does not make it readable here. usePreference reads by key, or by the ref define returns, and needs { threadId } for scope: "thread".

import { definePluginApp, usePreference } from "@get-bb/plugin-sdk/app";
import { Button } from "@bb/ui";
import { PINNED_FIRST_KEY, pinnedFirstSchema } from "./contracts.js";

export function PinnedFirstToggle({ threadId }: { threadId: string }) {
  const [pinnedFirst, setPinnedFirst] = usePreference<boolean>(PINNED_FIRST_KEY, { threadId });
  return (
    <Button variant="outline" size="sm" onClick={() => void setPinnedFirst(!pinnedFirst)}>
      {pinnedFirst ? "Pinned first" : "Newest first"}
    </Button>
  );
}

export default definePluginApp({
  setup(app) {
    app.preferences.define(PINNED_FIRST_KEY, pinnedFirstSchema, { scope: "thread", default: false }); // before the first read
    // register PinnedFirstToggle into a thread-scoped slot that passes `threadId` (Reference §5)
  },
});

5. Use it from every surface

bb plugin dev . installs the directory; the other lines touch the database, the settings, the secret store, and the log.

bb plugin dev .
bb bookmarks add https://example.com --thread thr_1 --json     # {"id":1,"threadId":"thr_1",...}
bb bookmarks config --json                                       # {"maxPerThread":20,"syncEnabled":false,"tokenConfigured":false}
bb secret put plugin:bookmarks/apiToken                          # prompts; a human actor only
bb plugin logs bookmarks --since 0                               # {"time":...,"level":"info","message":"bookmark added",...}

An agent gets the tool bookmarks_bookmarks_add and reads the settings with bb bookmarks config --json; a script reads them as GET /api/v1/bookmarks/bookmarks/config.

What happens at runtime

  • Database. storage.database() opens ~/.bb/plugins/data/bookmarks/data.db (WAL), one guarded handle per generation, closed on dispose. storage.migrate(db, statements) keeps an append-only ledger _bb_migrations(idx, sha256, applied_at) keyed by index. Each string is one SQL statement run with no parameters; a seed INSERT is legal. The pending statements and their ledger rows go through one db.batch(...), one BEGIN IMMEDIATE … COMMIT, so a failing statement rolls the whole pending set back. openDatabase is the two calls in one (D13).
  • Handle. run, get, all take (sql, params: SqlParam[]); params is a required array, [] when there are none. batch takes [{ sql, params }, …]. run fills rows for RETURNING, SELECT, and PRAGMA, plus changes and lastInsertRowid. batch is the only transaction surface. Every call is async in every placement; in-process the writer is the store worker behind an envelope, in a worker it is a local driver.
  • kv. ctx.storage.kv.get/set/delete/list(prefix) are rows of your plugin in plugin_kv of store.db. A key is at most 256 bytes and a value at most 256 KiB of JSON; over either is invalid_input. bb plugin remove bookmarks keeps kv, preferences, data.db, and logs; --purge deletes them.
  • Log. ctx.log.<level>(message, fields?) appends JSONL lines with time, level, pluginId, generation to ~/.bb/plugins/data/bookmarks/logs/<yyyymmdd>[.<n>].jsonl, rotated at 8 MiB × 5. bb plugin logs bookmarks [--follow] [--since <ms>] streams them (polled each second by byte offset). After dispose the call is a no-op.
  • Preferences. define returns a handle; get reads through kernel/preferences.get { key, scope, threadId } and answers the default when unset; a stored value the schema rejects is invalid_output. set validates, then kernel/preferences.set with expectedUpdatedAt (null when omitted: last writer wins; 0: the key must be unset; a stamp: must match, else conflict). Core checks the owner: a plugin actor writes only bookmarks/… keys. Every write emits kernel/preferences.changed, which watch filters by key and scope and which the app's usePreference follows. profile and thread live in core; client (localStorage) and tab (sessionStorage) never reach it. A Settings save is the same kernel/preferences.set by a human actor; it never re-runs the plugin. The manifest default feeds only the required fast path at load; preferences.define in neither tier consults it, and nothing checks the two defaults agree.
  • Secrets. A secret setting holds the reference plugin:bookmarks/apiToken; ctx.secrets.reference("apiToken") builds the same string. resolve goes through the active kernel/secrets.backend; not_found when unset, service_unavailable when no backend is bound. Writes are kernel/secrets.put, actors: ["human"]: the Settings form and bb secret put, never plugin code. A write emits kernel/secrets.changed { name } on the server only; it is not wired to browsers. Never put the value in a log field, a preference, or a row.
  • Status. ctx.status.needsConfiguration(message) moves the generation to needs-configuration and keeps every effect, so list and add keep answering; ctx.status.ok() returns it to running. Throwing NeedsConfigurationError from activate does the same at activation time.

Pitfalls

  • Write CREATE TABLE, not CREATE TABLE IF NOT EXISTS, and never edit an applied statement: the ledger compares the hash and answers conflict (D13). Add a new statement at the end to change a table. contributes.database: true is required; without it database() and openDatabase() answer precondition.
  • A SqlResultRow value is a SqlParam: number, string, null, a JSON value, or Uint8Array for a BLOB. Narrow by hand as hello-slot does, or parse with zod as above. SQLite has no boolean; store 0/1. all over 10,000 rows or 1 MiB answers precondition { reason: "payload_too_large" }; page with LIMIT and a cursor.
  • The handle is per generation. After a reload a leaked promise from the old generation fails with scope_disposed (every storage, secrets, and hostClient member checks ctx.signal first) and a closed handle answers stale_handle. Do not keep db in module scope.
  • A preference key is the full <pluginId>/<name>; name matches ^[a-zA-Z][a-zA-Z0-9_-]*$. A key outside the grammar throws invalid_contract from define, synchronously. Thread scope without threadId is invalid_input. usePreference of a key the app bundle did not define throws "read before its define"; define in setup, then read by key or by the returned ref (Reference §4.7). The app's setter always sends expectedUpdatedAt: null; compare-and-set is a server-side tool.
  • As built, a needs-configuration row re-runs on a change to one of its own contributes.settings keys (D8). The example handles the change itself through watch and kernel/secrets.changed, which also covers a token set by bb secret put.
  • The bb preferences group is the kernel's own face over kernel/preferences.get/set/clear/list; a plugin cannot claim that word. Expose what agents need as a query on your service, as config does.

See also

  • Reference §1.3 (contributes.settings, the descriptor table), §1.13 (per-plugin storage paths), §1.14 (bb plugin logs, --purge).
  • Reference §2.7 (kernel/preferences, kernel/secrets contracts), §3.6 (storage, kv, database, log), §3.7 (preferences and secrets), §3.1 (ActivationStatusApi, NeedsConfigurationError), §3.11 (kernel/preferences and kernel/secrets methods), §4.7 (app preferences).
  • Reference Appendix A: D8, D13.
  • Guide 4 reads these preferences from an interceptor; Guide 6 publishes changes to the browser.