On this page
Building on Claude Commander
This page is for the person who builds with Commander rather than operating it: you decide what the agents are, what they know, what they may touch, and what happens to the work they produce. The four Diátaxis modes follow in order — a tutorial, then task recipes, then the tables, then the reasons.
One idea underpins everything here. A persona is a stored launch recipe: a boot prompt, a model, runtime defaults, skill packs, MCP bindings, layered Claude Code configuration, and a sub-agent roster. The same row is read when you launch a terminal and when an orchestrating agent asks which sub-agents exist. Nothing about a launch is invented by the browser; the browser renders one command from server-resolved parts.
Tutorial: your first persona, and a session launched from it
The registry ships nine builtin personas, seeded into commander.personas from public/personas.factory.json about five seconds after boot. Making a persona yours means editing one of those — see the note at the end of this tutorial for why there is no create button at this version.
- Open the launcher. In a project, open a new session and choose Launch Agent. The picker lists every enabled persona from
GET /api/personas, ordered bysort_order, with the emoji, role and shortcut key each carries. - Pick a persona to make your own. Developer (
claude-fable-5) and Tester (claude-sonnet-5) are the usual starting points. Open its editor. - Write the boot prompt. This is the persona's standing role and guardrails, typed into the terminal at launch. The cap is
PERSONA_MAX_PROMPT_BYTES, 65536 bytes by default; over it the save is refused with a 400 naming the limit. Saving a changed prompt appends a new row tocommander.persona_prompt_history— versionmax + 1, with your note and authoroperator. Nothing is overwritten, ever. - Choose the model and the runtime defaults.
modelis free text on the persona row, filled from the live list atGET /api/models. The three tri-state defaults —default_sandbox,default_dangerous,default_polling— are the launch toggles the persona pre-sets;NULLmeans "no persona opinion, ask the launcher". - Save. The editor sends
PATCH /api/personas/<id>withexpectedUpdatedAt. If someone else changed the row since you loaded it you get409and the message "Persona changed since you loaded it — reload and reapply." Reload and reapply; do not retry blind. Your save also stampsoperator_modified = true, which is what stops a later factory refresh from reverting your work. - Attach what it knows.
PUT /api/personas/<id>/skillsbinds skill packs;PUT /api/personas/<id>/mcp-bindingsbinds MCP servers. Both are full replacements of the set, not merges. - Launch. Pick the persona in the launcher and start the session. Commander creates the session row (
POST /api/projects/<pid>/sessions), resolves the Claude Code launch configuration (POST /api/cc/sessions/<id>/launch-config), renders one command line, opens the WebSocket, waits for the readiness pattern, types any startup commands, then types the boot prompt and presses Enter. - Watch the breadcrumb. The session header shows the persona, the model actually acting (pushed over the socket as a
modelframe when the transcript tail changes), and the account the launch ran on.
The honest note on creation. At v3.0.15 the persona routes are read, patch, prompt-history and prompt-revert. There is no POST /api/personas and no DELETE /api/personas/:id. A genuinely new persona arrives one of two ways: a new entry in public/personas.factory.json with factory_version bumped (a code change, so it ships through the ladder), or an import from the Auth control plane by governed sync. Editing a builtin is the supported path for shaping your own.
How-to
Shape a persona
Only an allowlist of fields is writable, and the boot prompt is handled separately from the rest.
- Writable by
PATCH:name,role,emoji,color,bg_color,model,description,task_owner,shortcut,enabled,default_sandbox,default_dangerous,default_polling,skill_tags,subagent_eligible,can_use_subagents. Anything else in the body is ignored rather than rejected. - The boot prompt is
current_promptand travels in the samePATCH, but takes the history path: a change appends a version, andPOST /api/personas/<id>/prompt-revertwith{version}re-submits an old prompt as a new version notedrevert to v<n>. History is append-only, so a revert is itself auditable. - Concurrency is optimistic and explicit: pass
expectedUpdatedAt(RFC 7232If-Matchsemantics) and handle409. - Governed personas (
source = 'governed', imported from Auth) are read-only except for three local narrowings —enabled,subagent_eligible,can_use_subagents. Any other key returns403listing what was refused and what is locally editable. A patch touching only those three does not setoperator_modified, so governance keeps flowing. - Colours are validated on render, not just on save: the launcher's
safeColor()accepts only a hex triple/quad, a bare CSS colour name, or anrgb()/hsl()form, and persona text is HTML-escaped byesc().
The factory relationship is worth understanding before you edit. seedPersonas() refreshes a builtin only when operator_modified is false and the row's factory_version is behind the file's. Your first save opts that persona out of refresh permanently.
Bind skill packs
Skill packs are discovered from disk and catalogued, then bound to personas.
- Discovery is
lib/skills.jsoverSKILLS_DIR(aPATH-style list; default.claude/skillsunder the install). Only immediate children are scanned, and the entry type decides the shape: a directory issource_type: 'dir'(description = the first non-empty line of itsSKILL.md, elseREADME.md), a.zipis'zip', a.mdfile is'md'. Slugs come fromslugify(); the first duplicate wins. - Cataloguing is
seedSkillPacks(), upsertingcommander.skill_packsby slug about 5.2 s after boot. It is non-destructive: a pack that disappears from disk stays in the catalogue.GET /api/skill-packslists the enabled rows — the only skill-pack route. - Binding is
PUT /api/personas/<id>/skillswith{skill_packs: [{skill_pack_slug, load_mode, importance, sort_order}]}.load_modeispreloadoron_demand(anything else normalises toon_demand), enforced by a CHECK constraint. The write is transactional and replaces the whole set.
Two limits to know, because silence here would be a lie. Frontmatter in SKILL.md is not parsed — the description is literally the first non-empty line, so a pack that opens with a YAML fence gets --- as its description. And at v3.0.15 a bound skill pack does not yet reach a launch: nothing copies, symlinks or flags it, and load_mode/importance are stored but not read by the launch path. The binding is real, durable and published — lib/agent-manifest.js joins it into the signed agent manifest — but the launch consumer is still a declared seam. Plan around that rather than assuming a preload happens.
Bind MCP servers to a persona
MCP bindings (FEAT-016) say which tool servers a persona may reach, and nothing about a binding stores a credential.
- The catalogue is
GET /api/mcp/servers, reading the viewcommander.mcp_available_servers— organisation entitlements from the broker cache unioned with locally registered servers — with an honest freshness stamp.POST /api/mcp/syncrefreshes the entitlement side. - Register your own server with
POST /api/mcp/local:slug(alphanumeric plus-/_, 64 characters, because the slug names the connection in the generated config and rides into--allowedTools),name, a transport ofstdio,httporsse, thencommandfor stdio orendpointotherwise. Credentials travel as anauth_refpointing at an Auth vault connection — never an inline secret. Unspecified trust defaults tountrusted. - Bind with
PUT /api/mcp/bindings/<personaId>(orPUT /api/personas/<id>/mcp-bindings): per server atool_allowarray (nullmeans every entitled tool), an optionalrequire_approval,enabledandimportance.tool_allowcan only narrow what the entitlement already grants. - At launch
POST /api/mcp/sessions/<id>/launch-configresolves the bindings, mints a scoped token per server, writes<runtime>/<sessionId>.mcp.jsonat mode0600inside a0700directory, and returns the config path, the compiled--allowedTools, the strict-config flag andblocksDangerousSkip. Approval policy is the server's call: an explicitrequire_approvalwins, otherwise any destructive tool in scope or an untrusted-tier server forces approval — and a persona that needs approval cannot launch with--dangerously-skip-permissions. A binding whose server is no longer available, or whose token will not resolve, is reported as a warning rather than dropped in silence. - At teardown the minted tokens are revoked (RFC 7009) and the
.mcp.jsonis unlinked, so no credential outlives the session.
Tune Claude Code with the capability descriptor and layered config
FEAT-022 replaced scattered flag-building with one served descriptor and one resolver.
The descriptor is public/cc-capability.descriptor.json (descriptor_version: 2), served unauthenticated at GET /api/cc/capability and rendered by the Help panel's Claude Code tab. It carries narrative (three sections), 43 slash commands with categories — all marked interactive_only, six carrying a min_version — and 7 tunables. The served copy is decorated per command and per tunable with available, computed server-side from the detected CLI version, so the client does no version maths.
Version detection is lib/cc-version.js: claude --version run through <shell> -lic (login and interactive, so the interactive-only PATH lines in a profile apply and the detected binary matches the one a PTY would run), parsed with a \d+\.\d+\.\d+ match, cached for cc_version_ttl_ms (6 h default) per command string, single-flight, with a retry back-off. It fails open: a detection error keeps the last good version and is reported, never thrown. When no version is known, version gating is skipped entirely.
The four layers merge low to high, so the later layer wins:
instance ← account ← persona ← session
Each value passes three gates in lib/cc-config.js before it counts: the key must be a known tunable, the layer must be in that tunable's scopes, and the value must validate for its type. A value that fails any gate lands in dropped[] with a reason (unknown tunable, scope not permitted, invalid value, requires CC <v>+ (have <v>), not a known /command: <line>) and is returned to the caller. Nothing is silently discarded. min_version is a floor, checked per tunable and per command.
Resolution emits exactly three things, in descriptor order so the output is deterministic:
| Emit kind | Produces |
|---|---|
flag | a CLI flag — --permission-mode, --effort, --allowedTools (joined), --add-dir (repeatable) |
settings | a key in a per-session settings file, written mode 0600 as <runtime>/<sessionId>.cc-settings.json and passed as --settings. Path assignment refuses __proto__, constructor and prototype segments. |
boot_command_freeform | lines typed into the terminal before the boot prompt |
Boot commands are the startup_commands tunable (a list, persona and session scope only, marked dangerous). Every line must start with / and its first token must be one of the descriptor's 43 command names — checked both when you save the layer and again when the launch resolves, so a bad line never reaches a terminal. At launch each surviving line is typed, followed by Enter, with a settle delay between them (inject_settle_ms), and the boot prompt goes last. The overlay reports Applying startup command i/n.
Edit the account and persona layers through GET/PUT /api/cc/config/account/<id> and /api/cc/config/persona/<id>; both take {config, expectedUpdatedAt} and answer {config, updated_at, dropped}, with 409 on skew. The instance layer is the cc_config settings row, the session layer is commander.sessions.cc_config — written by the resolver so a later --resume re-emits identically. The whole mechanism is gated by cc_config_enabled, which is false by default; while it is off, the resolver answers {enabled: false, flags: [], settingsPath: null, bootCommands: [], dropped: []} and emits nothing.
Run a sub-agent fleet
FEAT-052 turns the persona registry into Claude Code's agent catalogue. Two boolean columns on commander.personas gate it, and they are independent:
| Column | Meaning |
|---|---|
subagent_eligible | this persona is offered as a sub-agent, projected into the catalogue as persona:<id> |
can_use_subagents | this persona may orchestrate; --agents is serialised only when it is true |
Both are local narrowings, so they are editable even on a governed persona.
Projection happens at read time. GET /api/cc/agents returns the file-sourced catalogue (commander.cc_agents, discovered from CC_AGENTS_DIR, default .claude/agents) concatenated with every enabled, eligible persona rendered by projectPersona(): slug persona:<id>, source_type: 'persona', and a definition carrying the persona's name, description, model, disallowedTools, and a wrapped boot prompt. Nothing is written to cc_agents — the projection is computed, so the catalogue cannot drift from the registry.
Projection is fail-closed and loud. A persona with no boot prompt or no model is skipped and logged (is subagent_eligible but has no model — not offered), because a sub-agent that does not name its own model would silently inherit the orchestrator's.
The sub-agent boot prompt wraps the persona's own prompt in a contract: the persona prompt is the standing role and guardrails, the delegating agent's first message is the task, the default mode is append, and a task prefixed [boot:replace] may supersede the role but never the guardrails or restrictions.
Nesting is refused by name. disallowedTools carries the spawn tool — Agent, overridable by CC_SPAWN_TOOL_NAME — added twice over: once when a persona is projected, and again by forbidNesting() for every fleet entry regardless of source. That function also strips the spawn tool out of an explicit tools allowlist, so the CLI is never handed an allowlist that contradicts the denylist, and it unions rather than trusts a per-persona override (['Bash'] becomes ['Bash','Agent']). The rule is enforced by tool name, which means a CLI rename would void it — verify on dev after any Claude Code upgrade.
Bind the fleet with PUT /api/personas/<id>/cc-agents ({agents: [...]}, full replace, stored in commander.persona_cc_agents with enabled, importance, sort_order and per-entry overrides). At launch the server filters out self-delegation, then writes <runtime>/<sessionId>.cc-agents.json mode 0600 and passes --agents. If the persona has bound sub-agents but can_use_subagents is false, the response sets agentsGated: true, the flag is withheld, and the reason is logged: the launcher toggle is a mirror, the server is the authority.
Launch, resume, attach, park and retire a session
A session is two things kept deliberately separate: a row in commander.sessions that Commander owns, and a Claude Code transcript — an append-only JSONL file under ~/.claude/projects (or a per-account root) that Claude Code owns. There is no transcript table. The only link is sessions.claude_session_id, and the file is the source of truth; the column is a cache.
- Launch.
POST /api/projects/<pid>/sessionscreates the row (title,env_name,context_file,model_override,provider_override,account_id). The terminal itself is not an HTTP route: the browser openswss://<host>:<wsPort>/ws?session=<id>&project=<id>and sends the rendered command as aninputframe. - Deterministic linkage (FEAT-018). Before launching, the client mints a UUID (RFC 4122,
crypto.randomUUID()),PATCHes it onto the session asclaude_session_id, and the command builder emits--session-id '<uuid>'. That is what makes a later resume exact instead of a guess.--resumeand--session-idare mutually exclusive;--resumewins. - Resume.
claude --resume '<id>', always prefixed withcd '<cwd>' &&because the CLI only finds a conversation from its own project directory. Four modes exist in the UI — full, compact, brief and attach; brief distils the conversation first throughPOST /api/claude-sessions/<sid>/brief. If the transcript is owned by a different account root,CLAUDE_CONFIG_DIRis pinned to the owning root so the resume finds it. - Rejoin in place. When the row says the session ran but no PTY is live, the socket sends
session_deadwith the saved scrollback and waits. The recovery popup answers with areviveframe; nothing spawns until it does. - Attach to a daemon session (FEAT-048). Claude Code ≥ 2.1.261 can hold a session in its own background daemon, and refuses
--resumewhile it does. Commander reads<root>/daemon/roster.json, confirms each worker's liveness by reading/proc/<pid>/cmdlinefor the host marker, and decorates the browse list withdaemon: {short, live, cwd, cliVersion, root}. Attaching emitscd '<cwd>' && unset ANTHROPIC_API_KEY && CLAUDE_CONFIG_DIR='<root>' claude attach '<short>'— and nothing else: model, MCP, settings and permission flags do not apply to an attach. The CLI's own refusal text is caught by an output watch (bg-session-refusal) and surfaced as acc-watchframe. - Park. With
session_park_enabledon, a session marked archived while idle has its PTY reaped by a 30-second flush loop once it has been quiet longer thansession_archive_park_idle_minutes(30 by default). The row and the transcript survive; only the process goes.session_close_default(archiveorask) decides what the close control does by default. - Archive and dismiss.
PATCH /api/sessions/<id>/archivetakes{archived, dismissed}. Dismissing hides a row from the archived drawer without deleting anything. Un-archiving is entitlement-gated onsession-rejoin. - Trash, restore, purge. These act on the transcript, not the row.
POST /api/claude-sessions/<sid>/trashmoves the file to a trash directory with a.trashmeta.jsonsidecar and refuses with409 ACTIVEif it was written in the last five minutes unless you passforce.restorebrings it back;DELETE /api/claude-sessions/<sid>purges it. A sweep purges pastclaude_trash_retention_days(7 by default) on thetrash_sweep_interval_mscadence; a separate archive sweep rolls cold transcripts out of the hot directory. - Delete the row.
DELETE /api/sessions/<id>kills the PTY tree, revokes any MCP tokens minted for it (RFC 7009), unlinks its.mcp.json,.cc-settings.jsonand.cc-agents.json, and deletes the row. The transcript is untouched.
Transcript kind. Every transcript is classified primary, subagent or unknown. A filename matching the UUID shape is primary, decided on the name alone; agent-<hex>.jsonl is a sub-agent, whose first record yields the parent sessionId and its agentId; anything else is unknown — a first-class outcome, not an error, because a rising unknown share is the early warning that the upstream layout changed. GET /api/transcripts/stats rolls the corpus up with unknownPct and a fan-out distribution, and transcript_unknown_alert_pct (5 %) is the alert threshold.
Search transcripts
GET /api/claude-sessions/<sid>/prompts streams the JSONL and returns up to 500 messages — user text truncated at 20 000 characters, assistant text at 2 000 — with a truncated flag. The matching happens in the browser (Ctrl+Shift+F, or the terminal's right-click menu): there is no SQL ILIKE and no full-text index behind it. Two consequences worth stating to anyone building on it: the search covers the logged conversation only and never the live screen, and beyond the 500-message window the UI says so rather than pretending completeness.
Work with artifacts
Artifacts (FEAT-039, FEAT-042) are the files and pages your agents produce, captured from transcripts and served back safely.
- Capture is an incremental scan.
lib/artifacts.jsis a pure extractor with four descriptor-driven kinds:artifact-local(a local path published by the agent),artifact-hosted(aclaude.aiartifact URL),image, andgeneric-url(opt-in, off by default). The reliable path is a structured pair — a local path and a hosted URL read from one authoritative record — because cross-record guessing invents links that were never made. - The scan is byte-incremental (commit
0a9279a). A cursor per file holds{mtimeMs, size, offset}and is persisted in the metadata index underarts; a grown file is read from its previous offset, a shrunk or unseen file is read whole, and a torn trailing line is left for the next pass. The old behaviour skipped any transcript over 25 MiB silently; nothing is skipped now. Memory is bounded byartifact_scan_batch_recordsand byartifact_scan_max_line_bytes, which drops (and logs) a pathological single line instead of buffering it. - Storage is content-addressed. A captured local file is hashed with SHA-256, stored write-once at
<store>/<first two hex>/<full digest>with no extension, and recorded on the row assha256withstored_at. Alocal_pathis only captured when both the path and itsrealpathsit inside the serving roots — a symlink is not a way in. Rows are idempotent on(claude_session_id, COALESCE(published_url, local_path)). - Serving resolves in order: the live local file if it is still in-root, then the durable store copy by digest, then a distinct refusal —
403outside roots,415not a regular file,413overartifact_serve_max_bytes,400no local source,410gone with no durable copy. Content types come from a fixed allowlist (HTML, text, Markdown, JSON, CSV, CSS, SVG, PNG, JPEG, GIF, WebP, AVIF, PDF); anything else is servedapplication/octet-streamand never sniffed. - The viewer is doubly sandboxed. The response carries
Content-Security-Policy: default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; font-src data:; media-src 'self' data:; sandbox, alongsideX-Content-Type-Options: nosniff, an inlineContent-Dispositionwith a sanitised filename,Cache-Control: private, no-storeandReferrer-Policy: no-referrer. The iframe that displays it carriessandbox=""— the empty attribute, which grants nothing: unique origin, no scripts. The same CSP is applied to the error page, so a refusal is as inert as a success. - Routes:
GET /api/artifacts(scopesession/project/all, images excluded unless asked),POST /api/artifacts/sync,POST /api/artifacts/prune(soft-deletes local-only rows with neither a live file nor a stored copy; hosted rows are kept),GET /api/artifacts/:id/raw. All four are entitlement-gated onsession-managerorsession-rejoin.
Capture and annotate screenshots
POST /api/screenshots takes a base64 image data URL plus project, session, note, context and source; dimensions are read from the PNG header, a SHA-256 is recorded, and an AI tagger runs asynchronously to fill ai_description, ai_tags and visible_text. The gallery searches a PostgreSQL full-text index over description, note, visible text and category, or matches a tag exactly, with the page size capped by screenshots_page_max.
Annotation is deliberately a flattened image, not an overlay document. POST /api/screenshots/:id/annotate accepts a PNG data URL only, copies the pristine file to a one-time <file>.orig sidecar, then overwrites the original in place so any path an agent already holds keeps working, and recomputes size, dimensions and digest. GET /api/screenshots/:id/base serves the .orig when it exists, which is what stops marks from compounding each time you re-annotate. Upload size is bounded by the global body limit (25 MB by default), not by a screenshot-specific cap.
Keep a prompt library
commander.prompts holds title, body, category, tags and a use_count, scoped to a project or global (project_id IS NULL). GET /api/prompts searches title, body and category through a full-text index, also matching tags exactly and the title by substring, and orders most-used first. Caps are enforced as 413 per RFC 9110 semantics — title 200, body 32768, category 64, 16 tags, 40 characters per tag — while shape errors are 400.
Two behaviours to build against. Insertion uses bracketed paste: the body is sent over the live session socket wrapped in ESC[200~ … ESC[201~ and Enter is deliberately not sent, so a multi-line prompt lands as text for the operator to review and submit. And there is no variable substitution — a prompt is literal text; the editor lets you tweak the body before inserting, and both copy and insert count as a use.
Connect an external service
External Integrations (FEAT-051) stores third-party credentials as descriptor-driven rows in commander.external_integrations. Eight provider types ship at this version:
| Type | Label | Category | Probe |
|---|---|---|---|
openrelay | OpenRelay | ai | GET /models, counts models; 402 means auth-ok-but-gated |
generic_openai | OpenAI-compatible | ai | GET {base_url}/models |
hostinger | Hostinger API | hosting | GET …/virtual-machines, counts VPS |
datashield_auth | DataShield Auth (admin API) | identity | GET {base_url}/apps with X-API-Key, counts apps |
github | GitHub | source-control | GET /user |
slack | Slack app | messaging | POST /auth.test, honours the ok field |
webhook | Webhook target | generic | HEAD {base_url} |
generic_bearer | Generic bearer API | generic | GET {base_url}{probe_path} |
Each descriptor declares its fields (name, kind, required, secret, store) and its probe, and that one declaration drives the editor form, where a value is stored, how it is masked and how it is tested. Only base_url and org_id land in plain columns; other non-secret fields go to the metadata JSONB; every secret field is encrypted together into one AES-256-GCM blob (secrets_enc). Two providers carry more than one secret (datashield_auth, slack).
- Masking is two-sided. Reads project each secret as
{set, last4}— never the value. Writes treat a value equal to the mask (or empty, or absent) as unchanged, so saving a form cannot blank a stored token. - Reveal is audited, not step-up gated.
POST /api/integrations/:id/revealreturns the plaintext and logs which fields were revealed. The source records this as a deliberate tradeoff: there is no step-up plane for it, so the session cookie is the guard. - The health probe answers with one of
ok,auth_ok,auth_error,unreachable,error,unknown, plus a human message and, where the provider allows counting, how many models, VPS or apps answered. It usesredirect: 'manual'and aborts atintegration_probe_timeout_ms(12 s). A dead token shows up here, before an agent finds it.
Store a credential in the Password Vault
Every password, token or key an agent creates belongs in the vault (FEAT-056) in the same step that creates it. Entries live in commander.vault_entries with a name unique case-insensitively — which is exactly what makes a retry idempotent — one of six categories (login, api_key, token, database, ssh, other), and plain metadata beside a single encrypted secret. Reveals are stamped on the row (last_revealed_at, reveal_count) and logged.
The CLI is the agent-facing contract:
node scripts/vault.js list --instance dev
node scripts/vault.js get --instance dev --name "<entry>" [--reveal]
node scripts/vault.js set --instance dev --name "<what it unlocks>" \
[--username <u>] [--url <https://…>] [--category <id>] [--notes "<text>"] \
[--tags <csv>] [--length <n>] [--by agent|operator] \
( --generate | --secret-stdin | --secret-env VAR )
node scripts/vault.js delete --instance dev --name "<entry>"
node scripts/vault.js generate [--length <n>]
--instance dev|prodis required for everything butgenerate; it selects which instance's database URL and encryption key are read, from the environment or from a secrets file outside the tree (--fileoverrides the location). No other value is accepted.setupserts on the lower-cased name. On an existing entry only the fields you pass are validated and written, so re-running the samesetis idempotent — except--generate, which rotates the secret every time and stampsrotated_at. A brand-new entry must bring a secret from somewhere:--generate,--secret-stdinor--secret-env.- A generated secret is printed to stdout exactly once; the human-readable confirmation goes to stderr, so piping captures the secret and nothing else. Generation is rejection-sampled from a 55-character alphabet that omits
0 O 1 l I, atvault_password_lengthcharacters by default. --bydefaults toagentin the CLI (the HTTP path defaults tooperator), so vault entries record which hand created them.- Exit codes:
0success,1a runtime failure such asno entry named '<name>',2a usage or configuration error — a missing--instance, a missing--name, an absent database URL or an encryption key that is not 64 hex characters.
generate never touches the database. Everything else needs migration 035 applied and COMMANDER_ENCRYPTION_KEY present; without either, the HTTP routes answer 400 saying exactly which is missing rather than failing obscurely.
Read usage and cost
lib/usage-stats.js folds transcripts into per-day, per-model and per-hour token counts, then prices them.
- Token buckets are input, output, cache read, and cache writes split by lifetime. Cache read costs a pinned
cacheReadrate where a model has one, otherwise 0.1× base input; a five-minute cache write costs 1.25× and a one-hour write 2× base input. A legacy undifferentiated cache-write total is priced at the cheaper five-minute rate and the result is marked approximate. - Pricing is a named default, not a fetch.
MODEL_PRICINGholds 13 model-id prefixes with$/MTokin and out, matched longest-prefix so dated suffixes strip themselves, andPRICING_AS_OF(2026-06-24) records when the table was last reconciled against the published price list — there is no pricing API to poll.pricing_stale_days(90) is when the report starts flagging it. An operator override lives inmodel_pricing_json, and it fails closed: a malformed entry throws rather than applying a silent partial table. - An unlisted model is never costed at zero. It is priced as the newest entry of the same family and reported in
estimatedModels, or, with no family match, listed inunpricedModels. - Counting is deduplicated. Claude Code writes one transcript line per content block, each repeating the same
message.usage; folding them all multiplied every number. Lines are deduped bymessage.id, and synthetic models are skipped. - Sub-agents fold two ways. Their turns count inside the totals (they cost real tokens) and are tallied separately so the report can show the share; and a sub-agent file's hourly spend is attributed to its parent session, because that is the session that paid for it.
- Window meters measure a rolling 5-hour window and a rolling 7-day window, counting input plus output only — what a subscription window actually meters. Anthropic publishes no budget to read, so a budget comes from
usage_window_budgets_json, keyed by account id or rate-limit tier; with none, the meter estimates from the heaviest window ever observed and marks itselfestimated.usage_window_warn_pct(75) andusage_window_crit_pct(90) colour it. - Routes:
GET /api/usage/stats(cached, single-flight,days1–3650),GET /api/usage/window,GET /api/usage/stats/progress(reports a running scan, never starts one) — all three behind thetoken-analyticsentitlement — plus the ungatedGET /api/sessions/:id/usagefor one session, which answers{available: false, reason}rather than zeros when the transcript is missing or unlinked.
Reference
Persona fields
commander.personas, 31 columns. Writable means accepted by PATCH /api/personas/:id.
| Column | Type | Writable | Meaning |
|---|---|---|---|
id | text (PK) | no | stable identifier; comes from the factory file or <namespace>--<slug> for a governed persona |
name, role, description | text | yes | display name, role line, longer description. Not unique — there is no constraint on name |
emoji, color, bg_color | text | yes | launcher presentation; colours validated on render |
model | text | yes | model id; free text, no enum, filled from GET /api/models |
task_owner | text | yes | task-id pattern this persona owns (e.g. TEST_.*) |
shortcut | text | yes | single-key accelerator in the launcher |
sort_order | integer | no | list order |
enabled | boolean | yes | listed and launchable |
is_builtin | boolean | no | seeded from the factory file |
current_prompt | text | via history | the boot prompt; a change appends a version |
updated_at, updated_by | timestamptz, text | no | concurrency token and last author |
default_sandbox, default_dangerous, default_polling | boolean, nullable | yes | launch toggles the persona pre-sets; NULL = no opinion |
skill_tags | jsonb | yes | free-form capability tags (array) |
factory_version | integer | no | factory revision this row was seeded or refreshed from |
operator_modified | boolean | no | set by your first edit; blocks factory refresh |
publish_to_registry | boolean | no | include in the signed agent manifest. No writer exists at this version |
source | text | no | local or governed (CHECK-constrained) |
governed_key, governed_version, governed_classification, governed_synced_at, governed_hash | text / timestamptz | no | control-plane provenance; governed_key is unique where present |
subagent_eligible | boolean | yes | offered as a sub-agent, projected as persona:<id> |
can_use_subagents | boolean | yes | may orchestrate; --agents is emitted only when true |
Related tables, all full-replace writes: persona_prompt_history (persona_id, version, prompt, note, author, created_at; unique on (persona_id, version)), persona_skill_packs (skill_pack_slug, load_mode, importance, sort_order), persona_mcp_bindings (server_slug, tool_allow, require_approval, enabled, importance), persona_cc_agents (agent_slug, enabled, importance, overrides, sort_order), persona_cc_config (config JSONB, updated_at, updated_by).
cc_config layer precedence
Later wins. A value is kept only if its key is a known tunable, the layer is in that tunable's scopes, and the value validates; otherwise it appears in dropped[] with a reason.
| Order | Layer | Stored in | Tunables permitted |
|---|---|---|---|
| 1 (lowest) | instance | commander.settings row cc_config | permission_mode, effort, co_authored_by |
| 2 | account | commander.account_cc_config.config | permission_mode, effort, output_style, co_authored_by |
| 3 | persona | commander.persona_cc_config.config | all seven |
| 4 (highest) | session | commander.sessions.cc_config | permission_mode, effort, allowed_tools, additional_dirs, startup_commands |
The seven tunables and how each is emitted: permission_mode (enum → --permission-mode), effort (enum → --effort), allowed_tools (list → --allowedTools, comma-joined), additional_dirs (list → repeated --add-dir), output_style (enum → settings outputStyle), co_authored_by (bool → settings includeCoAuthoredBy), startup_commands (list → boot commands typed into the terminal). Resolution is gated by cc_config_enabled, default off.
Session lifecycle
commander.sessions.status is text with no database enum; four values are written by the server.
| State | Written when |
|---|---|
idle | the column default, and on PTY exit |
running | a PTY was spawned for the session |
closed | the session was closed deliberately |
detached | a boot sweep found a row still claiming running with no process |
A fifth value, crashed, appears in a migration comment but no code writes or reads it. Three orthogonal flags carry the rest of the lifecycle: archived with archived_at (parked or closed), dismissed_at (hidden from the archived drawer without deletion), and pinned. claude_session_id links the row to its transcript; cc_config holds the session layer; resolved_account_id records which account the launch actually ran on.
Where a session's artefacts live
Commander owns rows; Claude Code owns transcripts; everything else is a file with a named directory at the top of its config chain. Nothing below is a literal in the code at the point of use.
| Artefact | Location | Set by |
|---|---|---|
| Primary transcript | <root>/<encoded-cwd>/<uuid>.jsonl | CLAUDE_PROJECTS_DIR, default ~/.claude/projects; plus one root per account oauth_dir |
| Sub-agent transcript | <slug>/agent-<hex>.jsonl, or <slug>/<uuid>/subagents/agent-<hex>.jsonl | same roots |
| Trashed transcript | ~/.claude/commander-trash/… with a .trashmeta.json sidecar | — |
| Cold (archived) transcript | ~/.claude/commander-archive/… with a .archmeta.json sidecar | TRANSCRIPT_ARCHIVE_DIR |
| Handoff brief | ~/.claude/commander-briefs/<id>.md | — |
Transcript metadata index (a cache, mode 0600) | ~/.claude/commander-index/meta-index.json | TRANSCRIPT_INDEX_FILE |
| Artifact store (content-addressed) | <store>/<first two hex>/<sha256> | ARTIFACT_STORE_DIR, default .artifact-store under the install |
Screenshots (plus one .orig per annotated file) | served at /screenshots | SCREENSHOT_DIR |
| Uploads | served at /uploads | UPLOADS_DIR |
Per-session runtime files: <id>.mcp.json, <id>.cc-settings.json, <id>.cc-agents.json (all 0600 in a 0700 directory) | runtime dir | MCP_RUNTIME_DIR, default a commander-mcp directory under the system temp dir |
The three per-session runtime files are unlinked when the session row is deleted. The transcript is not — deleting a session never deletes a conversation; trash and purge are separate, deliberate verbs.
Explanation
Why a persona is both the launch recipe and the sub-agent roster. These look like two features and would drift as two. A launch recipe answers what shall this terminal be; a sub-agent entry answers what may an agent delegate to. Both answers are the same facts — a prompt, a model, tools, guardrails — so Commander stores them once. Marking a persona subagent_eligible does not copy it anywhere: GET /api/cc/agents projects it on read as persona:<id>, computed from the current row. That is why a prompt edit reaches the fleet immediately, why a persona with no model is refused from the catalogue instead of quietly inheriting the orchestrator's, and why the no-nesting rule is applied in the projection rather than trusted from a definition. The alternative — a separate agent registry synced from personas — is a second source of truth, and a stale sub-agent roster is a security question, not a cosmetic one.
Why one command builder, in one place, with POSIX quoting. Every launch path — the launcher, the preflight, a resume in four modes, a daemon attach, the modal preview — calls the same function, buildClaudeCommand() in public/agent-launcher.js, and the preview you read is the string that runs. Arguments are wrapped with POSIX.1-2017 single-quote quoting ('\'' for an embedded quote), which is what makes a project path with a space, or a boot prompt with a backtick, inert rather than interesting. The parts are not invented by the client: flags come from the server-resolved capability descriptor, the settings and agents file paths are written server-side at mode 0600, and unset ANTHROPIC_API_KEY is unconditional so a stray environment variable cannot silently re-route billing. Keeping it in one function is also how the property is checkable — unset ANTHROPIC_API_KEY appears exactly once in the tree, so a second composer would be visible the moment it appeared.
Why the boot prompt is typed rather than passed as a flag. Claude Code's system-prompt flags are not the seam Commander uses; the persona prompt goes into the terminal as text after a readiness pattern matches, with startup /commands typed first. That keeps the transcript honest — what the agent was told is in the conversation, where an auditor and a resume can both see it — and it means a persona change needs no CLI capability to take effect. The cost is that delivery can fail if the terminal drops mid-injection, which is why the failure is reported as a toast naming what did not arrive rather than left as a silently role-less agent.
Why so much is declared before it is consumed. Skill-pack bindings, load_mode, importance and publish_to_registry are stored, validated and published today, while the launch path does not yet read them. That is a deliberate seam, recorded rather than hidden: the descriptor and the tables come first so the consumer is a small change in one place instead of a new schema later. When you build on Commander, read this page for what is wired and the reference for what a route actually returns — and treat a stored-but-unconsumed field as a promise about shape, not about behaviour.
Related
You've seen the proof
Ready for a number? Scope your deployment and we'll price it against your own economics.
Get your quote →