Skip to content

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

Merged
ariofrio merged 18 commits into
mainfrom
ariofrio/public-apis-where-they-exist
Aug 25, 2026
Merged

Take bb's public APIs where they exist#18
ariofrio merged 18 commits into
mainfrom
ariofrio/public-apis-where-they-exist

Conversation

@ariofrio

@ariofrio ariofrio commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Summary

Three plugins reached around bb's documented APIs — browser fetches of bb's and neighbouring plugins' routes, writes into bb's localStorage that a fake StorageEvent then announced. Each now takes the documented call.

Purpose

The private surface is what breaks silently on a bb upgrade, and none of it was load-bearing enough to keep: each call here has a documented equivalent that does the same work.

Changes

was now
PUT /api/v1/plugins/…/settings from the browser bb.sdk.plugins.updateSettings
fetch of a neighbour's rpc route (Icons, side-chat) bb.sdk.plugins.callRpc
fetch("/api/v1/system/config") bb.sdk.system.config()
compose-key write + synthetic StorageEvent + fake keystroke openNewThread({ projectId, focusPrompt })

Schemas mirroring bb's keybinding table and Icons' rows drop a row they cannot read rather than failing the whole payload, matching the consumers that were already tolerant.

Evidence

npm run release:check green for all three plugins; CI green on all five plugin jobs.

check:screenshots is red: the lock digests each plugin's src/, so any source change stales its shot. Needs npm run screenshots on macOS to clear.

Caveats

One behavior change. openNewThread is a hook and stage chords run in a content script, so the mounted sidebar list lends the action through a module-level slot. Without it — bb's built-in list selected — emptying Idle still files the thread and opens a composer, but on the project you last used rather than on none. Keeping the old private write as a fallback would preserve the behavior and the coupling both; Thread stages' README documents the difference instead.

A loopback hop per settings write and per cross-plugin call: both exist only on bb.sdk, so each gains a thin RPC on our own server. User-initiated except listProjectIcons, which runs whenever the sidebar's project set changes.

engines.bb unchanged. Every API used here exists at desktop-v0.35.1, below both declared floors (>=0.36, >=0.35).

proj_personal stays a literal. It is reserved in bb's domain package and bb's own isPersonal is derived from it, so asking bb for it added state and failure modes without removing a risk.

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 10 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>
The changeset, the README paragraph, and a run of comments had grown past
their jobs: a claimed payoff bb does not produce, a closing line about how
much of bb's insides these plugins hold onto, and comments restating the code
underneath them.

Same facts, fewer words. Two comments went entirely: the code they sat on
already said it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment-level pass over the same prose: a docblock that explained whose
opinion an argument represents rather than what it is, and two filler words.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Section icons (#17) landed on the same functions this branch moved onto bb's
public APIs, so the two had to be reconciled rather than picked between.

`fetchIcons` keeps main's three maps and takes this branch's loader, so the
sidebar still asks bb to call the Icons plugin. `buildProjectIconMap` keeps
the personal project as an argument.

The Icons server had grown its own read of bb's projects for the names beside
each row. Which project is personal now rides along with that list instead of
costing a second call: one read, one cache, and the failure handling main
already wrote. A write that arrives before the first read waits for it, so
refusing the personal project's icon still needs bb's answer rather than a
guess.

`sidebar-dom.ts` keeps the literal id. It collects anchors from the DOM before
any state exists, so there is nothing there to ask; the comment says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The service that reads bb's projects also subscribes to project changes, so
running it in a harness that stubs only the read leaves an unhandled
rejection: every test passes and the run still fails.

Same stub main's own project tests use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`proj_personal` is reserved in bb's domain package, bb's own `isPersonal` is
computed from it, and generated ids cannot collide with it. Asking bb which
project is personal bought nothing against that, and cost: a field on the
Icons wire, a cache beside bb's project list, a write that waited for a read,
a memoized lookup in the chord path, and an argument threaded through six
functions in five files.

All of it goes back to a comparison against a constant. The Icons plugin's
sources are now identical to main; the other three keep a `const` with a line
saying what it is.

Two things fall out. The composer bridge no longer waits for the sidebar's
projects, so it is lent on mount rather than after a load — the cold-start
window the README had to describe is gone. And a chord can no longer file a
thread and then fail resolving its route, because there is nothing left on
that path to fail; that fix and its test go with it.

Kept: the first behavioral test of `setWorkflowStage`, now holding it to what
it actually answers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@ariofrio
ariofrio merged commit 77f4e11 into main Aug 25, 2026
11 checks passed
@ariofrio
ariofrio deleted the ariofrio/public-apis-where-they-exist branch August 25, 2026 05:30
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