<!-- nornir:class: design -->
# dwarves Claude pane — the token path
The dwarves GUI mounts a **Claude** tab (`dwarves-gui/src/tabbed.rs` →
`ClaudeTab`) that hosts the shared `facett_claude::ClaudePane` (a pure
facett-Elm leaf). The pane emits a `ClaudeEffect::Send` (data); a **host**
(`ClaudeTab`) pumps that effect through an injected
`facett_claude::ClaudeTransport` and feeds the reply back. That transport is the
shared **`nornir-claude`** edda leaf (the ONE constellation Anthropic client),
adapted onto the facett seam by the `EddaBackend` newtype (guide §10). This doc
pins down **where the Claude credential comes from**.
## Current state — offline by default, live behind a feature
`ClaudeTab::offline()` (the demo default) injects the leaf's
`nornir_claude::OfflineTransport` through `EddaBackend`: it makes **no live
Anthropic call** — a labelled `(offline demo — no live Claude wired)…` reply.
The demo is robot-tested end-to-end (`robot_claude_pane_drives_over_the_seam`
plus the L5 guard `claude_tab_drives_the_shared_nornir_claude_leaf`) with zero
network and zero credential, so the send→reply loop is proven in code (no AI, no
key), AND that the pane genuinely drives the shared leaf transport.
`ClaudeTab::live()` (behind dwarves-gui's `claude-http` feature, which maps to
the leaf's `http` feature) injects `nornir_claude::HttpTransport` through the
SAME `EddaBackend` — a live blocking POST to `/v1/messages`, credential resolved
at send time. The `ClaudeTab::new(transport)` seam is unchanged; only the boxed
transport differs. Build/run it with `cargo run -p dwarves-gui --features claude-http`.
## The live token path — `nornir_claude::resolve_credential`
The leaf resolves its credential exactly the way the Anthropic SDKs and the
`ant` CLI do — **first match wins** (`nornir_claude::resolve_credential(session)`
/ the pure `resolve_credential_from(api_key, auth_token, session)`); an unset
`ANTHROPIC_API_KEY` does NOT mean "no credential":
1. `ANTHROPIC_API_KEY` (env) — sent as the `x-api-key` header
(`Credential::ApiKey`).
2. `ANTHROPIC_AUTH_TOKEN` (env) — sent as `Authorization: Bearer …` **plus**
the `anthropic-beta: oauth-2025-04-20` header (`Credential::Bearer`; OAuth
tokens go on `Authorization`, never `x-api-key`).
3. Per-session token — threaded in as the `session_key` arg and used as `x-api-key`
when neither env source is set. In dwarves this rung is filled from the **vault**
(the key you configure in Settings) — see below.
### The vault rung — WHERE YOU CONFIGURE IT (dwarves, `claude-token`)
Rung 3 used to be unwired in dwarves, so the two env vars were the ONLY way to give
the app a credential and there was **nowhere in the UI to configure one**. That is
closed:
* **Stored** by `dwarves::claude_token` as ONE named secret — vault entry
**`anthropic`**, kind `anthropic-api-key` — through the **SAME**
`VaultTokenStore` the OIDC login uses (moved to `dwarves::token_store` so both
consumers share it). No second secret path, no second sealing code: it lands in
`~/.dwarves/vault.bin` under Argon2id + AES-256-GCM with every other credential.
* **Configured** in **Settings → "Claude (Anthropic) API key"** — a masked field +
Save/Remove, next to the existing vault-unlock control. The pane renders in EVERY
build: without `claude-token` it still reports the env rungs honestly and says what
it would take to store a key, so the setting is always discoverable.
* **Consumed** by `funnel_action::VaultKeyed<T>`, a `ClaudeTransport` wrapper that
fills `req.session_key` from the vault before delegating — so precedence is
unchanged (`resolve_credential` still ranks env first) and the leaf is untouched.
An explicit per-request session key (a UI token-login paste) still outranks the
stored one; the wrapper only fills an empty slot.
* **Reported** as a SOURCE label — `env:ANTHROPIC_API_KEY` / `env:ANTHROPIC_AUTH_TOKEN`
/ `vault:anthropic` / `none` — via `claude_token::status()`. The label rides
`state_json` (`claude_source`, `claude_configured`, `claude_in_vault`,
`claude_vault_readable`); **the key never does**. A locked vault is reported as
`vault_readable: false`, distinct from "nothing stored", so the UI says *unlock*
rather than *paste a key*.
The funnel pane takes the same label over `{"claude_source": "…"}` and shows
*"no Claude credential — configure it in Settings to expand ideas"* when it is `none`,
so a funnel that cannot expand ideas says so instead of silently producing nothing.
Coverage: `claude_token` unit tests (round-trip through a REAL temp vault + a
not-plaintext-on-disk assert, the blank-key refusal, the pure ladder's precedence, the
locked-vault degradation, a no-secret-material assert), `VaultKeyed` transport tests
(the key reaches `req.session_key`; a blank is not a credential; an explicit key is not
overwritten), and two robot-UI scenarios + matrix cells
(`claude_token_control_discoverable`, `claude_token_stored_in_vault_from_settings` —
the latter proving the key never enters `state_json` and is encrypted at rest).
**Not yet in the leaf (deferred, edda D1):** the `ant auth login` on-disk profile
(`~/.config/anthropic/`) and Workload Identity Federation. Those need profile
selection + short-lived-token refresh / a JWT exchange (heavier than a dep-light
leaf carries); `resolve_credential`'s `session_key` arg is the single insertion
point when a consumer wants to thread them in. Until then the live path is the
two env sources + the per-session paste.
A set-but-empty `ANTHROPIC_API_KEY=""` is treated as blank and falls through
(the leaf trims + skips empty), so it does NOT wedge out a real token. If both
`ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` are set the first (x-api-key)
wins by precedence — unset it to rely on the bearer token.
- **Endpoint:** `POST https://api.anthropic.com/v1/messages`, header
`anthropic-version: 2023-06-01`.
- **Model:** `claude-opus-4-8` (the constellation default; adaptive thinking,
`output_config.effort` for depth). Never a date-suffixed variant.
- **Never hardcode a key** in source or a commit — read it from the env/profile
at transport-construction time. The offline stub carries no secret, so the
demo + robot tests stay credential-free.
## L5 (reuse) — RESOLVED: consumes the shared `nornir-claude` leaf
The standing L5 debt is closed. dwarves no longer hand-rolls a Claude client:
the bespoke `OfflineClaudeTransport` twin was **retired**, and `ClaudeTab` now
CONSUMES the shared **`nornir-claude`** edda leaf (crate
`edda/crates/nornir-claude`) — the ONE primitive carrying the offline stub, the
live HTTP transport, and the credential-resolution chain. The only dwarves-side
glue is `EddaBackend` (the ~3-line newtype in `tabbed.rs`) mapping
`facett_claude::WireMsg` → `nornir_claude::WireMsg` and forwarding to the leaf's
byte-identical `send_wire`. nornir's `viz::claude::HttpTransport` repoints at the
same leaf (its adopting agent's task), so both brains share one client. See the
shared claude-api reference for the exact SDK/HTTP shapes.
## Observability
The pane folds its state under `__dwarves_state()["claude"]`
(`kind:"claude"`, `turns`, `sending`, `last_assistant`). The robot scenario
drives `set_tab → prompt_changed → send` over the JSON seam and asserts the
reply landed (offline transport, in code) — the same channel a live transport
would answer on, so promoting to a live client needs no test rewrite, only a
credential + a network-gated arm.