Skip to content

Take bb's public APIs where they exist - #18

Open
ariofrio wants to merge 13 commits into
mainfrom
ariofrio/public-apis-where-they-exist
Open

Take bb's public APIs where they exist#18
ariofrio wants to merge 13 commits into
mainfrom
ariofrio/public-apis-where-they-exist

Conversation

@ariofrio

@ariofrio ariofrio commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Eight commits that take bb's documented APIs everywhere these plugins had been reaching around them. No behavior changes — this is about how much of bb's insides three plugins hold onto while doing the same job.

This is the first phase of a coupling audit across all five plugins. The audit classified every call against bb's own builtin-skills/bb-plugin-authoring/SKILL.md: public (documented there), experimental (the experimental_-prefixed surface it documents but marks "Experimental: see docs/api_to_audit.md"), and private (real, undocumented, ours to lose). This PR is only the private→public moves that were available today.

Why this matters

The private surface is the part that breaks on a bb upgrade, silently and at a distance — a renamed data-testid, a localStorage key that grows a field, a route that moves. Three of these were worse than the average:

  • Icons was making an authorization decision by string comparison. Which project the user may not restyle was id === "proj_personal". That literal lives in bb's packages/domain/src/project.ts; no public API promises it.
  • Two plugins were parsing bb's keybinding table out of a route the plugin contract never points a plugin at, so they carried hand-rolled guards for a shape they don't own.
  • Thread stages was arranging bb's own state behind its back — writing bb.root-compose.project-id, dispatching a fake StorageEvent so bb's jotai atom would notice, then synthesizing bb's New thread keystroke. Three private steps to say "compose here."

What changed

commit private → public
Ask bb which project is personal "proj_personal"project.isPersonal from the sidebar SDK
Save our settings through the SDK, not the endpoint browser PUT /api/v1/plugins/…/settingsbb.sdk.plugins.updateSettings
Let bb call the Icons plugin for us browser fetch of a neighbour's rpc route → bb.sdk.plugins.callRpc
Let bb start the side chat for us same, for the builtin side-chat plugin's createSideChat
Read bb's keybindings the way bb offers them fetch("/api/v1/system/config") in both plugins → bb.sdk.system.config()
Ask bb to open the composer where the chord lands compose-key write + StorageEvent + synthetic keystroke → openNewThread({ projectId, focusPrompt })
Let bb say which project is personal Icons' fixed-icon rule → a project's kind, resolved once per run
Note the API move for the next release changeset, patch for all three

Two things a reviewer should push on

Every settings and cross-plugin call gained a server hop. useSettings() reads but never writes, and there is no cross-plugin call on the app side at all — plugins.updateSettings and plugins.callRpc exist only on bb.sdk. So each of these adds a thin RPC on our own server that makes the public call. That is a round trip we did not pay before, on user-initiated actions only (saving a setting, loading icons, starting a side chat). I think a documented call is worth a loopback hop; if you disagree about listProjectIcons in particular — it runs on every sidebar project-set change — that is the one to argue about.

The compose bridge has a fallback, and the fallback is still private. openNewThread is a hook; stage chords run in a content script, where hooks do not reach. The mounted sidebar list publishes the bound action into a module-level slot and the chord calls it. When the list is not mounted — someone switched bb.sidebar.threadListProvider back to bb's own list — the chord falls back to bb's New thread command, so the composer still opens, just wherever bb left it rather than in the personal project. The alternative was keeping the private path as the fallback, which preserves the old behavior exactly and preserves the coupling exactly. I chose the degradation, and that is now a decision rather than a discovery: it is documented in the plugin's README next to the sentence that promises where the chords take you, and in the changeset that ships it.

What stays private, deliberately

Missing keyboard shortcuts still writes bb.root-compose.project-id itself: it has no persistent React surface to borrow a hook from, and useBbNavigate().toCompose() takes no projectId. Also untouched, because bb offers no equivalent: thread sections have no reorder anywhere in the SDK, REST, or CLI; there is no way to invoke a bb command (only to read the table, which this PR makes public); PluginContentScriptContext hands a script no navigation handle; no slot targets the thread header's title or a sidebar group label; and useRealtime hard-filters on the calling plugin's id, so the cross-plugin bb.icons broadcast channel has no replacement.

Those are the intended contents of a shim library, and — more usefully — the intended contents of five upstream API requests.

Verification

  • npm run release:check in each touched plugin: thread-stages, missing-keyboard-shortcuts, icons — all exit 0 (lockfile verify, tests, typecheck, verify:types, build, verify:package).
  • Root npm test: 27 pass, 0 fail.
  • Thread stages: 228 tests, up from 208. Every commit here is red-green; setWorkflowStage gets its first behavioral test, covering the personal-project routing rule.

check:screenshots will be red

The lock hashes each plugin's src/, so any source change stales its shot even when no pixel can move. This branch stales thread-stages, icons, missing-keyboard-shortcuts, and collection. Recapturing needs macOS and the bb desktop app; happy to run npm run screenshots and push the result onto this branch before merge.


Review round (three blind reviewers + one runtime report)

Three reviewers read this branch without my rationale, and a fourth thread hit the same defect from the other end while investigating why npm run screenshots was hanging. All four landed on one bug.

e33e14f dropped a call. The old createSideChat made two round trips — fork the thread through the Side chat plugin, then persist the panel tab through this plugin's ensureSideChatTab. The rewrite kept only the first, so the tab was written to localStorage and nowhere else. bb rebuilds a thread's panel from its own server-side tab list (reconcileFixedPanelTabsState), and it commits Info/Git-diff tabs the first time any thread is opened — so the local list never wins and the side chat's tab was dropped on every thread, sometimes before its composer was focused. It also blocked the screenshot harness at shot 5 of 6, which is why no full capture existed on this branch.

Fixed by moving the persist inside the server's createSideChat, with a regression test on the handler. The orphaned ensureSideChatTab is gone, and validateSideChat's cleanup prunes rows that exist again.

Four more findings fixed in the same pass, none of them broken today:

finding fix
A chord mutated before it could fail — a failed project lookup left the thread filed and the caller told it failed routing resolves before the move; test asserts the stage is unchanged and no signal fires
Keybinding mirrors validated bb's table as one array; one changed row would silently revert the shortcut to a hardcoded default rows are read one at a time, matching the delegate that consumes them
The mirrored Icons schema pinned fields that plugin owns; a grown row cost every icon per-row filter behind a loose envelope
Drawing icons became a call that can fail; listIcons could reject outright the read path degrades to "personal project unknown", the write path still refuses
updateSettings accepted 2 of the plugin's 6 settings all six, plus a test holding the RPC's keys to the plugin's own

engines.bb checked, not bumped. Every API this branch newly uses — isPersonal, openNewThread, projects.list({includePersonal}), plugins.callRpc, plugins.updateSettings, system.config — exists at desktop-v0.35.1, the oldest tag at or below both declared floors.

Known and deliberately left: while the icons load, the personal project renders as an ordinary editable row and its picker's first pick is refused server-side. It self-corrects on the first response.

Written by Claude

@ariofrio
ariofrio force-pushed the ariofrio/public-apis-where-they-exist branch from 4e7623f to 85f399c Compare August 22, 2026 19:00
ariofrio and others added 8 commits August 22, 2026 15:06
The sidebar's project icons picked the bubble glyph by comparing a row's id
to `proj_personal`, a literal that lives in bb's own domain package and that
no public API promises to keep. The same decision is made two files away in
ThreadFilter, and there it reads `project.isPersonal` — the flag the SDK
puts on every sidebar project.

`buildProjectIconMap` now takes the personal project's id and the caller
reads it from bb, so the plugin holds no opinion about what that id is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The options menu wrote its two toggles by PUTting `/api/v1/plugins/
thread-stages/settings` from the browser. That route exists, but the plugin
contract never mentions it, so the plugin also carried its own status-code
handling and a fallback for a body that turns out not to be JSON.

`useSettings()` reads and does not write, which puts the write on the server,
where `bb.sdk.plugins.updateSettings` is the documented call. The menu now
goes through an `updateSettings` RPC method whose input schema turns an
unknown key into `invalid_input` before bb ever sees it, and the hand-rolled
fetch leaves with `sidebar-settings.ts`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sidebar read its project icons by POSTing the Icons plugin's own rpc
route from the browser. The route shape is documented, but reaching into a
neighbour with it is not, and it put another plugin's id and method in the
frontend along with hand-parsed envelopes.

`bb.sdk.plugins.callRpc` is the call bb ships for this, so `listProjectIcons`
now makes it on the server and mirrors the neighbour's response in a schema
that is deliberately not strict — the Icons plugin may grow fields, and an
extra key is not this sidebar's business.

`fetchProjectIcons` takes a loader instead of doing its own transport, so
what happens when the neighbour is absent is now a test rather than a
comment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ctrl+Shift+S started a side chat by POSTing the Side chat plugin's own rpc
route from the browser, with that plugin's id, method, and input shape spelled
out in the frontend — including an `anchorText: ""` whose meaning belongs to
the neighbour, not to a keyboard shortcut.

bb ships `bb.sdk.plugins.callRpc` for one plugin to call another, so the call
moves to the server behind this plugin's own `createSideChat`, which takes
the one thing the shortcut actually knows: the thread the user is on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both plugins replay bb's own New thread command by synthesizing the keystroke
bb listens for, which means reading bb's keybinding table first. They read it
by fetching `/api/v1/system/config` from the browser — a route the plugin
contract never points a plugin at, and one whose whole shape they then had to
guard against by hand.

`bb.sdk.system.config()` is the documented read, so each plugin now asks for
it over its own `listAppKeybindings`, whose schema keeps the three fields a
delegate uses and leaves bb's `when` rule to bb. The delegates themselves are
untouched: they still take a `fetchConfig` returning `unknown` and still
parse defensively, because a keybinding table is bb's to change.

Synthesizing the keystroke remains private. This halves what that costs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Filing the last Idle thread leaves nowhere to go, so the chord opens a new
one. It did that by writing bb's own `bb.root-compose.project-id`, faking the
storage event that makes bb notice, and then synthesizing bb's New thread
keystroke — three private steps to say "compose here".

`openNewThread({ projectId, focusPrompt })` is one public call that does all
three, and it is what bb's own sidebar calls. It is a hook, and the chords run
in a content script, so the mounted list lends it down: while the sidebar
draws, the chord uses bb's action, and without it the old keystroke still
opens the composer, now without arranging bb's state behind its back.

Routing the other destination needed to know which project is personal,
because that one routes without a project segment and the project path would
land on the composer instead. The server knows this publicly — a project's
`kind` — so it now answers with a null project rather than an id the frontend
had to recognize, and the chord's first behavioral test covers it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The icons the personal project gets — a chat bubble, and no picker at all —
were decided by comparing an id to `proj_personal`. That literal lives in bb's
own domain package, no public API promises it, and the plugin was using it for
an authorization decision: which project the user may not restyle.

A project's `kind` is public, so the server reads it once per run and answers
`listIcons` with the id bb reports. `setIcon` refuses that project rather than
that string, the sidebar's content script — which has no hooks to ask with —
takes the id from the same response, and the thread header asks the SDK
directly, where `isPersonal` was already sitting on every sidebar project.

`defaultIcon` and `isEditable` now take the personal project instead of
recognizing it, so a project whose id merely looks personal keeps its icon,
which is what the two new tests hold them to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ariofrio
ariofrio force-pushed the ariofrio/public-apis-where-they-exist branch from 85f399c to 21c47d5 Compare August 22, 2026 19:12
ariofrio and others added 5 commits August 22, 2026 15:22
The chords run from a content script, so they fire wherever you are, but the
composer step goes through bb's own "new thread in this project" action, and
this plugin can only reach that while it is drawing the sidebar list. Set
Settings → Sidebar to bb's built-in list and the difference shows up in one
place: emptying Idle opens the composer on the project you last used instead
of on none.

That is a small enough gap to live with and a confusing enough one to hit
unexplained, so it goes next to the sentence that promises the behavior, and
into the release note that ships the change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Moving the side-chat call to the server dropped a step. The old frontend
routine made two round trips — fork the thread, then persist its panel tab
through this plugin's `ensureSideChatTab` — and the rewrite kept only the
first. What was left wrote the tab to this client's storage and nowhere else.

bb treats its own tab list as authoritative: `reconcileFixedPanelTabsState`
rebuilds a thread's panel from the server's tabs whenever the two differ, and
a `plugin-panel` tab is one it persists. Opening a thread at all commits bb's
own Info and Git diff tabs to the server, so there is no fresh-thread case
where the local list wins: the side chat's tab was dropped on every thread,
sometimes before its composer was ever focused, leaving a hidden fork with
nothing pointing at it. The next ⇧⌘L would fork another.

The persist now happens inside `createSideChat`, next to the call it belongs
to, which also drops the second round trip the frontend used to make.
`ensureSideChatTab` had no caller left and goes with it, and `validateSideChat`
goes back to pruning rows that exist.

Four reviewers found this independently, one with a runtime trace; none of the
tests did, because nothing covered what the shortcut leaves behind. One does
now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three more findings from the same reviews, none of them breakage today, all
of them a way this branch could break quietly later.

A chord moved the thread before it worked out where to send you, so a failed
project lookup left the thread filed and the caller told the move had failed.
The routing now resolves first: either the chord happens and the destination
is right, or nothing happens at all.

Both keybinding passthroughs validated bb's table as one array, so a single
row bb changed would have cost the whole table and silently reverted the
shortcut to its hardcoded default. They now drop the row they cannot read, the
way the delegate reading them always has.

Drawing icons used to be a local read that could not fail; asking bb which
project is personal made it a call that can. A failed ask now costs the bubble
on one row rather than every icon in the sidebar, while writing an icon still
refuses rather than guessing.

And `updateSettings` accepted the two settings that existed when it was
written, of six. A control wired to any of the other four would have failed
validation with a toast; a test now holds the RPC's keys to the plugin's own.

The README's caveat said "with bb's built-in list selected", which is narrower
than the truth — the composer step also falls back in the moment before the
sidebar has loaded. Both it and the changeset now say so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The mirrored Icons schema said it was lenient because it allowed extra keys.
That is the wrong axis: the fields it pins are the ones that plugin owns and
may change. A third owner kind, or a color that stops being a string, and the
whole answer fails to parse — costing every project icon in the sidebar,
where before the diff the sidebar simply ignored a row it did not recognize.

The envelope is now loose enough to reach a per-row filter, and the rows are
read one at a time, which is what the consumer does with them anyway.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sidebar list lends the chords bb's "new thread here" action through a
module-level slot, and cleared it unconditionally on unmount. bb mounts one
thread list, so today that is the same instance either way — but a second
mount followed by the first one's teardown would clear a slot still being
used, and the chord would quietly stop preselecting for the rest of the
session.

Clearing only what is still ours costs a comparison.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant