feat(desktop): Settings › API keys, the account's own Anthropic and OpenAI keys (BYOK 3/3)
What
BYOK part 3 of 3, the desktop half. #2078 added the sealed key store and endpoints, and #2079 made agents run on the account's key. This adds a new page, Settings › API keys, with one row per provider.
| No key yet | Keys saved (one verified, one out of quota) |
|---|---|
![]() | ![]() |
(The screenshots come from a Linux Chrome harness that mounts the real ApiKeysSection with the app's built styles.css and a mocked bridge. The sidebar around it is static markup. This is not the Mac app.)
No key saved:
- a password field with autocomplete off, and Save ("Checking…" while the service verifies the key with the provider)
- the note "Usage on your key is billed by Anthropic/OpenAI"
- if a save is refused, the reason shows under the row: rejected by the provider, looks like the other provider's key, provider unreachable, or too many tries
Key saved:
- the hint the service answers (
sk-ant-…x7Qa), the date it was added, and its status:- Verified (with a check)
- "Anthropic rejected this key. Replace it."
- "Out of quota or credit at OpenAI."
- "This key can no longer be read. Replace it."
- Replace, which reopens the field
- Remove, which asks first in place ("Agents on Anthropic models will run on Luke's key.")
Settings › Coding agents: the Default model line says "…, on your own Anthropic key." when the default model's provider has a key of the account's.
How the key travels (the security design from #2078)
- The key exists in the renderer only in the field: it is read once on Save and cleared from the field as soon as the service keeps it. No React state, settings file, or localStorage ever holds it. No answer carries a key back, only hints.
- The chain copies the existing coding-agent default write:
providerKeys.read/save/removeacts (panel only; the voice window is refused, tested)- → gateway methods → host composer
- →
HostedCodingAgentClient - →
PUT/GET/DELETE /api/account/provider-keysover the bearer-authenticated HTTPS account call
- Nothing on the way logs or traces act payloads. The host's invalid-params refusal names the shape, never the value (tested).
- The page
<section>and the field carryph-no-capture, so session replay leaves them out entirely, including the field's character count. - New analytics settings view
api_keys(allowlisted). No key-related property is counted.
Bundle budget (re-recorded, approved by Dean)
apps/desktop/bundle-budget.json: this PR re-records the renderer.js baseline with LUKE_UPDATE_BUNDLE_BUDGET=1, and Dean approved it. whiteboard.js and the 5% slack are unchanged.
| renderer/renderer.js (gzip -9) | bytes |
|---|---|
| Baseline before (recorded in #1962) | 698,487 |
| Ceiling before (+5%) | 733,411 |
origin/main without this PR (CI build) | ≈731,600 (4.7% over the baseline) |
| This PR (CI and local build) | 733,811 (400 over the ceiling) |
| New baseline | 733,811 (new ceiling 770,501) |
Why: this PR adds about 2.2 KB gzipped: the Settings › API keys page, its three acts and their schemas, two settings-search rows, and the Coding agents note. Feature growth since #1962 (side panel tabs, agent tabs, the agent to-do list, …) had already used up the slack. Trimming copy would have saved about 100 bytes and dropping the note 140, so the feature couldn't fit without cuts. This is growth, not drift.
Tests
api-keys-page.test.ts(jsdom):- the pasted key is sent once from a password field with autocomplete off, is gone from the page once kept, and the row shows the hint alone
- the page and its fields carry
ph-no-capture - a refused save shows its words and keeps the field
- the trouble statuses each ask for action
- Replace and Cancel work, and Remove asks before removing
- a failed read offers Try again, and a signed-out user sees the sign-in note
coding-agents-page.test.ts: the "own key" note.coding-agent-acts.test.ts: a panel's key reaches the host once; the voice window can't read, save, or remove one.compose-coding-agents.test.ts: the key passes through to the client, answers carry hints only, and the refusal holds no key.coding-agent-client.test.ts:- read, save (key in the PUT body once), and remove (provider in the DELETE query)
- a key refused by shape travels nowhere
- Wire goldens are recorded for the three new view schemas. The settings search and the settings page list include the new page; I updated the existing assertion that no row offers an "OpenAI API key". The voice key it guarded is still never offered: the only key rows are the coding agents', on API keys, for a signed-in account.
./scripts/check.sh exits 0. CI could not verify the Mac UI, and ./scripts/verify.sh was not run (Linux VM); LUKE-159 is the manual Mac pass.
🤖 Generated with Claude Code

