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 seedINSERTis legal. The pending statements and their ledger rows go through onedb.batch(...), oneBEGIN IMMEDIATE … COMMIT, so a failing statement rolls the whole pending set back.openDatabaseis the two calls in one (D13). - Handle.
run,get,alltake(sql, params: SqlParam[]);paramsis a required array,[]when there are none.batchtakes[{ sql, params }, …].runfillsrowsforRETURNING,SELECT, andPRAGMA, pluschangesandlastInsertRowid.batchis 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 inplugin_kvofstore.db. A key is at most 256 bytes and a value at most 256 KiB of JSON; over either isinvalid_input.bb plugin remove bookmarkskeeps kv, preferences,data.db, and logs;--purgedeletes them. - Log.
ctx.log.<level>(message, fields?)appends JSONL lines withtime,level,pluginId,generationto~/.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.
definereturns a handle;getreads throughkernel/preferences.get { key, scope, threadId }and answers the default when unset; a stored value the schema rejects isinvalid_output.setvalidates, thenkernel/preferences.setwithexpectedUpdatedAt(nullwhen omitted: last writer wins;0: the key must be unset; a stamp: must match, elseconflict). Core checks the owner: a plugin actor writes onlybookmarks/…keys. Every write emitskernel/preferences.changed, whichwatchfilters by key and scope and which the app'susePreferencefollows.profileandthreadlive in core;client(localStorage) andtab(sessionStorage) never reach it. A Settings save is the samekernel/preferences.setby a human actor; it never re-runs the plugin. The manifestdefaultfeeds only therequiredfast path at load;preferences.definein neither tier consults it, and nothing checks the two defaults agree. - Secrets. A
secretsetting holds the referenceplugin:bookmarks/apiToken;ctx.secrets.reference("apiToken")builds the same string.resolvegoes through the activekernel/secrets.backend;not_foundwhen unset,service_unavailablewhen no backend is bound. Writes arekernel/secrets.put,actors: ["human"]: the Settings form andbb secret put, never plugin code. A write emitskernel/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 toneeds-configurationand keeps every effect, solistandaddkeep answering;ctx.status.ok()returns it torunning. ThrowingNeedsConfigurationErrorfromactivatedoes the same at activation time.
Pitfalls
- Write
CREATE TABLE, notCREATE TABLE IF NOT EXISTS, and never edit an applied statement: the ledger compares the hash and answersconflict(D13). Add a new statement at the end to change a table.contributes.database: trueis required; without itdatabase()andopenDatabase()answerprecondition. - A
SqlResultRowvalue is aSqlParam:number,string,null, a JSON value, orUint8Arrayfor a BLOB. Narrow by hand as hello-slot does, or parse with zod as above. SQLite has no boolean; store0/1.allover 10,000 rows or 1 MiB answersprecondition { reason: "payload_too_large" }; page withLIMITand a cursor. - The handle is per generation. After a reload a leaked promise from the old generation fails with
scope_disposed(everystorage,secrets, andhostClientmember checksctx.signalfirst) and a closed handle answersstale_handle. Do not keepdbin module scope. - A preference key is the full
<pluginId>/<name>;namematches^[a-zA-Z][a-zA-Z0-9_-]*$. A key outside the grammar throwsinvalid_contractfromdefine, synchronously. Thread scope withoutthreadIdisinvalid_input.usePreferenceof a key the app bundle did notdefinethrows "read before its define"; define insetup, then read by key or by the returned ref (Reference §4.7). The app's setter always sendsexpectedUpdatedAt: null; compare-and-set is a server-side tool. - As built, a
needs-configurationrow re-runs on a change to one of its owncontributes.settingskeys (D8). The example handles the change itself throughwatchandkernel/secrets.changed, which also covers a token set bybb secret put. - The
bb preferencesgroup is the kernel's own face overkernel/preferences.get/set/clear/list; a plugin cannot claim that word. Expose what agents need as a query on your service, asconfigdoes.
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/secretscontracts), §3.6 (storage, kv, database, log), §3.7 (preferences and secrets), §3.1 (ActivationStatusApi,NeedsConfigurationError), §3.11 (kernel/preferencesandkernel/secretsmethods), §4.7 (app preferences). - Reference Appendix A: D8, D13.
- Guide 4 reads these preferences from an interceptor; Guide 6 publishes changes to the browser.