On this page
Developing Claude Commander
This page is for whoever changes the code. Commander is deliberately small in shape and strict in rules: one Express server (server.js, ~7.9k lines), a lib/ of pure or narrowly-scoped modules, one single-file React SPA (public/index.html), a numbered migration set, and a test suite that runs on node:test with no framework. The rules that keep it that way are in CLAUDE.md (the Engineering Constitution) and docs/governance/CMD-DEV-STD-001.md (the development-governance standard). Read both before your first change; this page shows how to satisfy them in practice.
Tutorial: the local development loop
Commander runs two instances on one host, and they are not interchangeable.
| Dev | Prod | |
|---|---|---|
| Worktree | commander-dev | claude-commander |
| HTTP / WebSocket port | 3210 / 3211 | 3200 / 3201 |
| pm2 process | commander-dev | commander |
| Database | claude_commander_dev | claude_commander |
Never test against prod. Prod hosts live terminal sessions belonging to the operator; a restart there kills them and a bad write there has no undo. Every experiment, migration and live-drive belongs on dev.
- Orient before you edit.
docs/sprints/next-task.txtis the pointer-of-record: read it at the start of a session to learn what is in flight, what is blocked and on whom. Feature specs live intracking/features/FEAT-NNN.md, shortcuts and their remediation plans intracking/, releases intracking/releases/. If the pointer, the code, the database and the pm2 runtime disagree about something, that disagreement is the defect: stop and surface it rather than picking a winner.
- Work in the dev worktree. Both worktrees are checkouts of the same repository;
ecosystem.config.jsdescribes the prod process and a siblingecosystem.dev.config.js(in the dev worktree) describes the dev one. Secrets for both live in asecrets.envfile at mode0600outside every worktree, loaded by the process config — never in a tracked file.
- Know which change needs what. This distinction decides whether a change needs a restart at all:
| You changed | To take effect |
|---|---|
public/index.html, public/vendor/*, other static assets | browser reload only — the file is served statically |
server.js, lib/*.js, migrations/*.sql | a dev process restart (and for SQL, node migrate.js first) |
- Do not restart
commander-devwhile the operator is working on it. A pm2 restart drops every live WebSocket (RFC 6455) terminal on that instance, taking the operator's session with it. When people are connected, stage the change and verify it statically instead: transpile-verify the SPA block, run the suite, read the diff. Restart only in an agreed window. When you do restart dev, usepm2 restart commander-devand never--update-env— pm2 stamps the invoking shell's environment into its saved state, and the 2026-08-16 env-clobber incident is exactly that. Recovery from a clobber ispm2 delete commander-dev && pm2 start ecosystem.dev.config.js.
- Verify the SPA without a browser.
public/index.htmlis transpiled in the browser by the vendored Babel standalone build, so a JSX syntax error is a blank page rather than a build failure. Before you hand the file over, extract the<script type="text/babel">block and transpile it with Babel under Node (stubbingwindow/Reactas UMD requires); a clean transpile is the gate named in CMD-DEV-STD-001 §3.4.
- Live-drive when the flow changed. The committed
verifyskill (.claude/skills/verify/SKILL.md) drives headless Chromium against the dev instance with WS-frame capture and screenshots. Two constraints it records: the terminal socket is TLS-only on 3211, so drive the UI over the HTTPS hostname rather thanhttp://localhost:3210(anhttp:page derivesws:and the handshake dies); and a driver mints its own short-lived row incommander.auth_sessionsbecause the password is argon2id-hashed (RFC 9106) and not recoverable.
- Run the suite and record the work.
npm testbefore every commit (see Run the suite), then the governance artefacts: an Impact Record atwork/impact/<ITEM-ID>.jsonand, after a set of related tasks, a vet record atwork/vet/<SET-ID>.md.
- Commit the slice on dev. The promotion ladder's verify rung refuses a dirty tree, so finished slices are committed as you go. Promotion to prod is not yours: see Ladder Promote is operator-hand in best practices.
How-to
Add a setting
A setting is an operator-editable value that does not need live-reload semantics. The chain has one direction and three links: environment variable → commander.settings row → /api/client-config (that last link only for values the browser needs).
- Declare the named default as a
constat the top ofserver.js, sourced from the environment:const FOO_MS = parseInt(process.env.FOO_MS, 10) || 5000;. This literal is the only one allowed — a literal at the point of use is a constitution violation. - Read the settings override through
getSettings(), which caches the wholecommander.settingstable for 30 seconds:parseInt(s.foo_ms, 10) || FOO_MS. - If the browser needs it, add it to the object served by
GET /api/client-config(server.js~line 696) and consume it inapplyClientConfigin the SPA. The client-side literal you leave behind is a stale-server fallback only, never a point-of-use value. - If the value is credential-bearing, add the key to
SETTINGS_ENCRYPTED_KEYSinlib/settings-secrets.js(AES-256-GCM at rest, NIST SP 800-38D) or toSETTINGS_MASKED_KEYSinserver.jsif it merely must never be served back. Both paths makeGET /api/settingsserve the mask********, andPATCHtreats an echoed mask as "unchanged" — a contract you must not break, because the 2026-08-05 dev lockout was a masked key that served blank and was then saved back as empty. - If the key changes who can log in, it is already guarded by prefix or floor in
lib/auth-guard.js— and it is not yours to change (see best practices).
Add a live tunable
A tunable is a setting the operator can change and see take effect without a restart. Tunables are data, not code: one registry, one resolver, lib/tunables.js.
- Add one entry to
REGISTRYinlib/tunables.js:key(the settings row),env(the environment variable),type(int,float,bool,color,csv), the nameddefault, optionalmin/max/unit, agroupfromGROUPS, an operator-facinglabelandhelp, andlive: truewhen a save applies without a restart. Nothing else changes — no branch, no new route, no new UI field. - Read it at use time, never at boot. In
server.jsthe helper isT('your_key')(line ~922), a synchronous read over the settings cache that kicks a background refresh when stale and serves the last known value meanwhile. Inlib/modules, take the settings map from the caller and calltunables.value(key, settings);tunables.envDefault(key)gives the env→default resolution for a module with no settings map. - For a periodic job, use
scheduleLoop(name, fn, intervalKey, startupDelayMs, enabledKey)(line ~930) rather thansetInterval. It re-reads the interval (and the optional enable switch) from the registry on every tick, so a saved change applies at the next tick;setIntervalfreezes its period at boot. Every timer isunref'd — the HTTP listeners keep the process alive, never a timer. - Validation is automatic in both directions.
PATCH /api/settingsrunstunables.parse()on any registry key and returns400with the reason on a bad value; a blank value deletes the override and restores the env/default. On read, an invalid stored value warns once and falls through to the next rung rather than being honoured silently. - The UI needs nothing.
GET /api/tunables(authenticated) returnstunables.describe()— every descriptor plus itseffectivevalue and itssource(settings,envordefault) — and Settings ▸ Advanced renders that payload. v3.0.15 carries 29 tunables in 7 groups.
Restart-only values stay environment-only and are catalogued in tracking/features/FEAT-055.md; do not put one in the registry and imply it is live.
Add a migration
- Create
migrations/NNN_short_name.sql, numbered after the current highest (036 at this commit). Additive-first: add columns and tables, backfill, and leave the old shape readable by a stale server. - Apply it with
node migrate.jsonly.DATABASE_URLis required and has no default — the runner refuses to guess a database. Each file runs in its own transaction and is recorded inside that transaction incommander.schema_migrations(filename, SHA-256checksumper FIPS 180-4,applied_at). - Never hand-apply SQL, on any instance. Applied files are skipped by ledger lookup, and a file that changed after it was applied is a hard error (
MIGRATE_ALLOW_CHECKSUM_DRIFT=1overrides it only after you have reviewed the diff). A ledger that disagrees with the directory halts the migrate rung on the next promotion, and reconciling it is manual work. - The one sanctioned in-code DDL is the ledger table's own
CREATE SCHEMA … CREATE TABLE IF NOT EXISTS commander.schema_migrations, which lives inmigrate.jsso a fresh database can bootstrap. Any other schema-bootstrapping code in the server is an out-of-band schema change and a red gate cell. - A pre-ledger database (tables present, ledger empty) refuses to run until you record the current files once with
node migrate.js --baseline, so half-applied guessing cannot happen.
Add an API route
Every route handler in server.js is wrapped at registration time. The loop at server.js ~598 rebinds app.get/post/put/patch/delete so each handler function is wrapped in a try plus Promise.resolve(...).catch(next). Express 4 does not catch a rejected promise from an async handler, and one escaped rejection kills the process — and every live PTY with it. Because of the wrapper you write plain async (req, res) => … and let a throw land in the JSON error middleware at the bottom of the file, which maps Postgres 22xxx/23xxx codes to 400 Invalid request and everything else to 500 Internal server error (RFC 8259 bodies, no stack leaked to the client).
Compose the guards in this order:
| Guard | Use it when | Behaviour |
|---|---|---|
authCheck | always, unless the route is deliberately public (/api/auth/check, POST /api/auth, /api/client-config) | reads the commander_token cookie or x-auth-token header against commander.auth_sessions; 401 on failure. Named directly on 164 route registrations. |
validateUUID | the path has :id, :pid, :sid, :projectId or :sessionId | rejects a non-UUID with 400 before any query runs; 86 route registrations |
requireFeature('key') | the route is edition-gated | resolves entitlements and answers 403 with {feature, tier, upgrade}; an unknown feature key throws at boot, not at request time; 37 gated routes |
confine(path, roots, kind) from lib/fs-confine.js | the handler touches the filesystem with any operator-supplied path | realpaths the target (or its nearest existing ancestor for 'write') before the prefix check, with a path.sep boundary guard, and returns null when out of bounds. Deny by default: no roots means nothing resolves. |
Two rules that are not optional. The server is the enforcement — a UI gate is an advertisement, and a route shared with an ungated surface must re-check server-side. And never fail silent: an unparseable config warns and falls back to a named default, a refused write says which key and why.
Add a frontend component
The SPA is one file: public/index.html, ~9.3k lines, a <script type="text/babel"> block transpiled in the browser by public/vendor/babel-standalone-7.26.4.min.js, rendering React 18 from public/vendor/react-18.production.min.js into #root via ReactDOM.createRoot. Every runtime dependency is vendored with its version pinned in the filename — React, ReactDOM, Babel, xterm 5.3.0 and its fit and web-links addons. Do not add a CDN reference. ISSUE-001 was exactly that: CDN-loaded libraries with no fallback, so a CDN hiccup was a blank page.
Working rules:
- Read config, do not restate it. A component takes its cadences, thresholds, vocabularies and feature flags from
/api/client-configthroughapplyClientConfig(line ~918). The module-level literals next to it exist only so a new SPA against an older server still renders; treat them as fetch-race and stale-server defaults, never as the value. - Gate with
featOn(key), and expect the server to refuse anyway. An absent entitlements map means allow-everything, so a new SPA against an old server changes nothing. - Two failure nets already exist; keep them working.
ErrorBoundarywraps<App/>and renders a recovery screen (reload, or clearcommander_*localStoragefirst) instead of React 18's blank unmount. A plain-JS boot watchdog afterloadchecks forReact,ReactDOM,Babel,Terminal,FitAddon,WebLinksAddonand renders a diagnostic if the root is still empty. - Parity is a review lens. Server API ↔ SPA ↔
public/agent-launcher.js↔/api/client-config↔ entitlement gates: a capability present on the server but absent from the UI is a dark-capability finding; the reverse is a parity finding. Duplicated logic across those surfaces is a defect even when it works.
Write a test
The suite has three layers, all under test/, all on node:test with node:assert and no framework or mocking library.
- Layer 1 (unit). A pure test over a
lib/module, no DB and no network. This is whylib/modules are written to be pure or dependency-injected:lib/ws-hardening.js,lib/promote-autoclean.js,lib/cpu-util.jsandlib/fs-confine.jsare all pure so they unit-test over fixtures rather than sockets, git repos,/procor a filesystem. Prefer extracting the decision into a pure function over testing it through the server. - Layer 2 (API contract).
test/api.test.jsboots the realserver.jsas a subprocess on ports 39876/39877 against a disposableclaude_commander_testdatabase it creates, migrates, seeds and drops. It is opt-in: the suite is skipped unlessCOMMANDER_TEST_PGnames an admin connection string withCREATEDB. Add a test here when the thing under test is the HTTP contract — status codes, auth gating, masking, confinement boundaries. - Layer 3 (render smoke). Also in
test/api.test.js:GET /must return a substantial document with a doctype, thetype="text/babel"block and rendered app content. It is the committed guard against the ISSUE-001 blank page. The full headless UI drive stays in theverifyskill because it needs a live instance.
Write the assertion message, not just the assertion — every existing test names what the failure means, which is what makes a red suite readable by whoever did not write the code.
Run the suite
npm test # node --test test/*.test.js
COMMANDER_TEST_PG=<admin-url> npm test # same, with Layer 2 actually running
As this page was written: 43 test files; a plain npm test reports 346 passing tests, with the Layer 2 suite reported as a single SKIP. A suite that silently skipped Layer 2 is not a green gate. CMD-DEV-STD-001 §3.1 requires you to set COMMANDER_TEST_PG from the dev URL and confirm the TAP summary before calling the gate green; Layer 2 was dark from FEAT-024 to 2026-08, and that is why the confirmation is part of the rule rather than a suggestion.
Reference
Repository layout
| Path | Holds |
|---|---|
server.js | the single Express app: route registration, WebSocket and PTY wiring, the settings cache, the config chain's named defaults |
lib/*.js | one concern per module, pure or dependency-injected so it unit-tests without the server (table below) |
migrate.js, migrations/NNN_*.sql | the migration runner and the numbered, checksummed migration set |
public/index.html | the whole SPA; public/vendor/ its pinned, self-hosted runtime libraries |
public/cc-capability.descriptor.json, public/personas.factory.json | shipped descriptors the server reads |
test/*.test.js | the three test layers, node:test only |
scripts/ | operator and build CLIs: vault, backups, key rotation, secret import, manifest signing, the docs and OpenAPI builders |
deploy/ | Dockerfile, tenant compose, the version-manifest schema and example |
docs/ | architecture, runbooks, handoffs, federation registry, governance/, and this documentation tree under docs/site/ |
tracking/ | feature specs, issues, bugs, releases, roadmap, and the shortcut/debt ledger |
work/ | impact/ records and vet/ records — the governance evidence trail |
.claude/ | the verify skill, hooks, and the operator quickstart |
agent-launcher/ is a separate TypeScript package that has drifted from the served public/agent-launcher.js. It is a named standing violation of non-negotiable 3 being burned down, not a pattern to extend.
Generated reference, and the tools that generate it
Nothing in the machine-readable reference is maintained by hand:
| Command | Produces |
|---|---|
node scripts/route-inventory.mjs [--pretty] | JSON [{method, path, file, line, auth, tag}] by parsing server.js and lib/*.js; dependency-free, handles multi-line route signatures |
node scripts/build-openapi.mjs | docs/site/reference/openapi.yaml (OpenAPI 3.1.0) from that inventory merged with hand enrichments in openapi.overrides.json; the server URL is a template variable and the version comes from package.json, so the document is never pinned to an instance |
node scripts/build-docs.mjs | the static documentation site and agent skill subtree, the site-generator content JSON, and the docs lint — secret-shaped literals and host home paths are errors, unresolved in-tree links and unbacked superlatives are warnings |
If you add a route, regenerate the inventory rather than editing the OpenAPI document. If a route's middleware chain changes, the inventory's auth column changes with it, which is the point.
Module map: lib/
39 .js modules plus one generated .cjs. server.js owns HTTP shape and process wiring; these own the decisions.
| Module | Concern |
|---|---|
agent-manifest.js | Publishes the signed agent-definition manifest (JWS) Commander serves at a well-known path (FEAT-026). |
agent-sync.js | Consumes the curated official persona set from the Auth control plane (receive side, FEAT-024 P3a). |
artifacts.js | Extracts agent-published asset references from transcripts; reference kinds are descriptor entries, not branches (FEAT-039). |
auth-guard.js | The login-seam step-up guard: which settings keys are login-critical and what 422/428/403 mean (FEAT-044). |
auth-oidc.js | Governed-login adapter — Commander as a relying party of DataShield Auth (FEAT-028 A). |
auth.js | Local identity: argon2id hashing (RFC 9106) and opaque DB-backed sessions (FEAT-024 P0). |
blueprint-intake.js | POST /api/blueprint/intake — server-side structural and signature validation of a signed AppDefinition. |
blueprint-validate.cjs | Generated schema validator. Do not hand-edit; regenerate from the Blueprint repo. |
blueprint-wizard.js | Hosts the Blueprint wizard inside Commander behind authCheck. |
cc-agents.js | Claude Code sub-agent fleet: catalogue discovery, per-persona bindings, the --agents launch payload (FEAT-022). |
cc-capability.js | Reads public/cc-capability.descriptor.json, the single source for the Claude Code capability surface. |
cc-config.js | Merges scoped Claude Code config layers against the capability descriptor (FEAT-022). |
cc-daemon.js | Reads Claude Code daemon rosters to detect live background sessions (FEAT-048). |
cc-trust.js | Pre-trusts Commander-launched project directories so no session stalls on the trust dialog. |
cc-version.js | Detects the installed Claude Code version, which drives min_version gating. |
cc-watch.js | Output-watch registry: generalises one hardwired prompt-pattern set into registered patterns (FEAT-047 P3). |
claude-bin.js | Resolves the Claude CLI executable across user-scoped npm prefixes. |
claude-sessions.js | Transcript manager: scans the transcript corpus, extracts metadata, owns the soft-delete lifecycle, and holds MODEL_REGISTRY and the live /v1/models merge. |
cpu-util.js | Pure CPU-utilisation maths over two /proc/stat snapshots. |
crypto.js | AES-256-GCM helpers (NIST SP 800-38D), Node built-in crypto only. |
deploy-manifest.js | Signs the deployment version-lock manifest validated against its JSON Schema 2020-12 document (FEAT-030). |
embedded-console.js | Per-project embedded developer console, mounted before every /api route so the scope binds the whole surface. |
entitlements.js | The single licence→capability resolver; feature keys, tier maps, overrides, requireFeature (FEAT-024 P2). |
fs-confine.js | Filesystem root-allowlist confinement with realpath-before-check and boundary-aware containment. |
integration-providers.js | Descriptor registry for External Integrations: field vocabulary, storage, health probe, setup guidance (FEAT-051). |
lighthouse.js | Server-side client of the licence authority; contract-first (FEAT-027 D2). |
mcp-broker.js | Consumer-side client of the Auth Application Binder — Commander is not the credential authority (FEAT-016). |
mcp-routes.js | The MCP consumer HTTP surface; owns shape only, delegating credential work to the broker. |
oauth-token.js | Parses and writes long-lived claude setup-token credentials. |
personas.js | Persona registry: DB-authoritative with a code factory reset from public/personas.factory.json (FEAT-015). |
promote-autoclean.js | Pure decision function for verify-time autoclean of known-generated files. |
promote-override.js | Pure decision function for the audited operator override of a dirty-tree verify halt (FEAT-046). |
settings-secrets.js | Encryption-at-rest and mask semantics for credential-bearing commander.settings rows. |
skills.js | Skill-pack catalogue and filesystem discovery for persona bindings (FEAT-015 3d). |
systemd-service.js | systemd awareness for the process monitor, with a validated sudo argument list (FEAT-049). |
tracking-markdown.js | Markdown + YAML-frontmatter engine for the tracking/ files. |
tunables.js | The operator-tunable registry and its resolver: one descriptor list, one resolution order (FEAT-055). |
usage-stats.js | Usage and cost from transcript ground truth, per model and per session (FEAT-025 M1). |
vault.js | Password Vault: one row shape, one encryption, one set of SQL, shared by the routes and the CLI (FEAT-056). |
ws-hardening.js | Pure WS/PTY hardening helpers — origin checks, dimension clamps — unit-testable without sockets. |
Status-code conventions
The SPA and the agent skills both branch on these, so a new route uses the same vocabulary.
| Status | Meaning here | Set by |
|---|---|---|
400 | malformed input: bad UUID, bad commit hash, a tunable value outside its bounds, a Postgres 22xxx/23xxx code | validateUUID, tunables.parse, the error middleware |
401 | no session, or a session that no longer resolves | authCheck |
403 | path outside the allowed filesystem roots (with the roots hint), a credential-shaped filename refused, a feature not in the plan, or a wrong step-up proof | refuseOutsideRoots, requireFeature, lib/auth-guard.js |
409 | the request is coherent but the resource is not in a state that allows it (for example restarting the CLI in a session with no live process) | the handler |
415 | the file exists inside a root but is not a type the surface will serve | the handler |
422 | the write itself is incoherent — notably a login-seam change that would leave the instance with no working login path | lib/auth-guard.js |
428 | step-up required: re-send with x-current-password (RFC 6585, NIST SP 800-63B §5) | lib/auth-guard.js |
500 | unhandled — logged with a stack server-side, opaque to the client | the error middleware |
Test layers
| Layer | Where | Needs | Asserts |
|---|---|---|---|
| 1 — unit | test/*.test.js over lib/ | nothing | pure decisions over fixtures; no DB, no network |
| 2 — API contract | test/api.test.js | COMMANDER_TEST_PG (admin URL with CREATEDB) | the real server on a disposable DB: status codes, auth gate, masking, confinement |
| 3 — render smoke | test/api.test.js | same as Layer 2 | GET / serves real HTML with the Babel block and app content (ISSUE-001 guard) |
CMD-DEV-STD-001 in one table
docs/governance/CMD-DEV-STD-001.md v1.1, PROPOSED for operator lock; binding as working practice from drafting. It inherits the fleet development-governance instruments by reference and does not restate CLAUDE.md.
| Rule | What it requires | Where it lands |
|---|---|---|
| R1 — evidence | every work item commits an Impact Record; docs-only items included | work/impact/<ITEM-ID>.json |
| R2 — verdict gating | no successor starts while a predecessor's verdict is amend/reject/pending on the same track | delivery note + record |
| R3 — post-set revet | after each related set, an unprompted re-vet of ≥10 angles against the actual diff and the previous 3 series; every finding dispositioned, never dropped | work/vet/<SET-ID>.md |
| §3 gates | suite green with Layer 2 confirmed; config purity; migration-only schema change; SPA transpile-verify; a rotten gate is surfaced, never worked around | delivery note states which reload/restart was needed and done |
| §5A model floor | subagents run Opus (analysis/code) or Sonnet/Haiku (mass/mechanical) with model set explicitly — never Fable/Mythos, never inherited | backstopped by .claude/hooks/block-fable-subagent.sh (PreToolUse, exit 2 on an explicit Fable/Mythos spawn; it cannot see an inherited or in-script model) |
| §5 operator-hand | ladder Promote to prod, prod restarts and DB writes, destructive migrations, secrets rotation, protected infrastructure, and the login seam (app_password, auth_mode, auth_oidc_*, mcp_auth_base_url, lib/auth-guard.js, lib/auth*.js) | left PENDING; agents never approve their own work |
| §4 artefacts | feature specs in tracking/features/, releases in tracking/releases/, shortcuts recorded in tracking/ before they land, seam changes update docs/federation/INTEGRATION-REGISTRY.md in the same commit | the repository, diffable |
Explanation
The five non-negotiables, and what each one is protecting
CLAUDE.md states five rules and asks you to treat a violation as a failing build.
1 — No hardcoded values. Every tunable routes environment variable → commander.settings → /api/client-config; a literal is legal only as the declared, named default at the top of that chain. The reason is not tidiness: a value settable in three places with no defined order is three bugs waiting, and the same build has to serve a personal host, a team instance and a multi-tenant container without a code change.
2 — No technical debt. No "temporary" hack, no TODO-later workaround, no out-of-band schema change; every schema change ships as a migration. Where a shortcut is genuinely unavoidable it is recorded in tracking/ with a remediation plan before it lands. Debt that is written down gets burned down; debt that is only remembered becomes the next incident, which is why the constitution keeps a short list of standing violations by name instead of a vague intention.
3 — No code sprawl. Prefer deleting and consolidating over adding. One renderer, one registry, one source of truth per concern; duplicated logic across server.js, index.html and agent-launcher.js is a defect even when it works. The precedent is the triplicated model list, retired by FEAT-019 down to one live source (/api/models with the MODEL_REGISTRY fallback) consumed through one hook.
4 — Hardened, soft-coded, configuration-based, with clean seams. Descriptor, registry and adapter patterns, designed against 2026–2030 expectations. The server is authoritative; clients render from served config.
5 — Standards first. When a design leans on a standard, cite the document. In this codebase that is load-bearing rather than decorative: WebSocket framing and close codes follow RFC 6455 (an oversize frame closes with 1009; ping/pong keepalive per §5.5.2–.3), JSON bodies RFC 8259, step-up signalling RFC 6585 428 with NIST SP 800-63B §5 as the reauthentication model, password hashing RFC 9106 argon2id, secrets at rest AES-256-GCM per NIST SP 800-38D, migration checksums SHA-256 per FIPS 180-4, manifests JSON Schema 2020-12, versions SemVer 2.0, timestamps ISO 8601. A cited standard is a decision you do not have to re-argue, and a reviewer can check it without reading your mind.
Descriptor, registry, adapter
The pattern is the same everywhere and worth naming, because it is how a change becomes data instead of a branch.
- A descriptor is a declarative record of one thing's shape.
public/cc-capability.descriptor.jsonis the single source for what Claude Code can do — slash commands, categories,min_versiongates, the operator narrative — read bylib/cc-capability.js.deploy/version-manifest.schema.jsondescribes a release.public/personas.factory.jsonis the builtin persona set. - A registry is the one list of descriptors.
lib/tunables.jsis the clearest case: 29 entries, each with key, env var, type, bounds, group, help and live-ness, and one resolver used by every reader.lib/integration-providers.jsis the same shape for integration provider types — field vocabulary, storage, health probe, guidance — and it drives the editor form so the client codes nothing.MODEL_REGISTRYinlib/claude-sessions.jsis the curated model list that a live/v1/modelsfetch merges into when a credential resolves, and falls back to when one does not.entitlements.jsholds the one feature-key list, which throws at boot on an unknown key. - An adapter is the narrow code that binds a registry to a surface.
Settings ▸ Advancedis an adapter overGET /api/tunables: add a registry entry and the UI grows a validated field with its source and effective value, because the renderer reads descriptors rather than knowing keys.
The seam this is all aimed at is FEAT-013, the provider descriptor registry that would make agent CLIs beyond Claude Code data rather than code branches. It is status: proposed at this version — not shipped — which is exactly why new Anthropic-specific code should route through a descriptor now rather than add another branch to unpick later.
Why one file for the SPA, and pure modules behind it
These two look like opposite instincts and are the same one. public/index.html is a single file because the alternative here is not a nicer module graph — it is a build step, a bundler config, a lockfile for the front end, and a second place for versions to drift, on a project whose front end is served straight off disk and reloads without a restart. The cost is paid deliberately: the file is large, it is transpiled in the browser, and it has two independent nets (the error boundary and the boot watchdog) because a blank page was the failure mode that earned ISSUE-001 its number.
lib/ goes the other way for the same reason — to keep the thing that can be checked cheaply separate from the thing that cannot. Decisions are extracted into modules that take their inputs as arguments: ws-hardening decides without a socket, promote-autoclean and promote-override decide without a git repository, cpu-util without /proc, tunables without a database, fs-confine without a server. That is what makes Layer 1 the cheapest and largest layer of the suite, and it is why "add a branch in server.js" is usually the wrong shape for a change and "add a descriptor entry, or extract a pure decision" is usually the right one.
Why the server is authoritative
The browser is a rendering surface that any user can edit, replay or run at an old version. So the SPA hardcodes nothing it can be served, and every gate it draws is re-checked in the request handler: featOn() hides an affordance, requireFeature refuses the route. Two consequences follow. First, skew is a design input, not an accident — every server/client contract change states its stale-client and stale-server behaviour, which is why applyClientConfig guards each field and an absent entitlements map means allow-everything. Second, a UI-only capability and a server-only capability are both findings: the first is unenforced, the second is dark. Checking both across the five surfaces is a standing sweep lens, not an optional review nicety.
Related
- Best practices for the rules that are operator-hand, and what breaks when you route around one.
- DevOps for the promotion ladder that runs your migration and your test rung.
- Admin for the settings, secrets and auth surface you are extending.
- Application for personas,
cc_configlayers and MCP bindings. - Reference for every route, key, tunable and environment variable · Agents
You've seen the proof
Ready for a number? Scope your deployment and we'll price it against your own economics.
Get your quote →