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.

  1. 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 by sort_order, with the emoji, role and shortcut key each carries.
  2. Pick a persona to make your own. Developer (claude-fable-5) and Tester (claude-sonnet-5) are the usual starting points. Open its editor.
  3. 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 to commander.persona_prompt_history — version max + 1, with your note and author operator. Nothing is overwritten, ever.
  4. Choose the model and the runtime defaults. model is free text on the persona row, filled from the live list at GET /api/models. The three tri-state defaults — default_sandbox, default_dangerous, default_polling — are the launch toggles the persona pre-sets; NULL means "no persona opinion, ask the launcher".
  5. Save. The editor sends PATCH /api/personas/<id> with expectedUpdatedAt. If someone else changed the row since you loaded it you get 409 and the message "Persona changed since you loaded it — reload and reapply." Reload and reapply; do not retry blind. Your save also stamps operator_modified = true, which is what stops a later factory refresh from reverting your work.
  6. Attach what it knows. PUT /api/personas/<id>/skills binds skill packs; PUT /api/personas/<id>/mcp-bindings binds MCP servers. Both are full replacements of the set, not merges.
  7. 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.
  8. Watch the breadcrumb. The session header shows the persona, the model actually acting (pushed over the socket as a model frame 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.

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.

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.

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 kindProduces
flaga CLI flag — --permission-mode, --effort, --allowedTools (joined), --add-dir (repeatable)
settingsa 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_freeformlines 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:

ColumnMeaning
subagent_eligiblethis persona is offered as a sub-agent, projected into the catalogue as persona:<id>
can_use_subagentsthis 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.

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 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:

TypeLabelCategoryProbe
openrelayOpenRelayaiGET /models, counts models; 402 means auth-ok-but-gated
generic_openaiOpenAI-compatibleaiGET {base_url}/models
hostingerHostinger APIhostingGET …/virtual-machines, counts VPS
datashield_authDataShield Auth (admin API)identityGET {base_url}/apps with X-API-Key, counts apps
githubGitHubsource-controlGET /user
slackSlack appmessagingPOST /auth.test, honours the ok field
webhookWebhook targetgenericHEAD {base_url}
generic_bearerGeneric bearer APIgenericGET {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).

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>]

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.

Reference

Persona fields

commander.personas, 31 columns. Writable means accepted by PATCH /api/personas/:id.

ColumnTypeWritableMeaning
idtext (PK)nostable identifier; comes from the factory file or <namespace>--<slug> for a governed persona
name, role, descriptiontextyesdisplay name, role line, longer description. Not unique — there is no constraint on name
emoji, color, bg_colortextyeslauncher presentation; colours validated on render
modeltextyesmodel id; free text, no enum, filled from GET /api/models
task_ownertextyestask-id pattern this persona owns (e.g. TEST_.*)
shortcuttextyessingle-key accelerator in the launcher
sort_orderintegernolist order
enabledbooleanyeslisted and launchable
is_builtinbooleannoseeded from the factory file
current_prompttextvia historythe boot prompt; a change appends a version
updated_at, updated_bytimestamptz, textnoconcurrency token and last author
default_sandbox, default_dangerous, default_pollingboolean, nullableyeslaunch toggles the persona pre-sets; NULL = no opinion
skill_tagsjsonbyesfree-form capability tags (array)
factory_versionintegernofactory revision this row was seeded or refreshed from
operator_modifiedbooleannoset by your first edit; blocks factory refresh
publish_to_registrybooleannoinclude in the signed agent manifest. No writer exists at this version
sourcetextnolocal or governed (CHECK-constrained)
governed_key, governed_version, governed_classification, governed_synced_at, governed_hashtext / timestamptznocontrol-plane provenance; governed_key is unique where present
subagent_eligiblebooleanyesoffered as a sub-agent, projected as persona:<id>
can_use_subagentsbooleanyesmay 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.

OrderLayerStored inTunables permitted
1 (lowest)instancecommander.settings row cc_configpermission_mode, effort, co_authored_by
2accountcommander.account_cc_config.configpermission_mode, effort, output_style, co_authored_by
3personacommander.persona_cc_config.configall seven
4 (highest)sessioncommander.sessions.cc_configpermission_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.

StateWritten when
idlethe column default, and on PTY exit
runninga PTY was spawned for the session
closedthe session was closed deliberately
detacheda 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.

ArtefactLocationSet by
Primary transcript<root>/<encoded-cwd>/<uuid>.jsonlCLAUDE_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>.jsonlsame roots
Trashed transcript~/.claude/commander-trash/… with a .trashmeta.json sidecar
Cold (archived) transcript~/.claude/commander-archive/… with a .archmeta.json sidecarTRANSCRIPT_ARCHIVE_DIR
Handoff brief~/.claude/commander-briefs/<id>.md
Transcript metadata index (a cache, mode 0600)~/.claude/commander-index/meta-index.jsonTRANSCRIPT_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 /screenshotsSCREENSHOT_DIR
Uploadsserved at /uploadsUPLOADS_DIR
Per-session runtime files: <id>.mcp.json, <id>.cc-settings.json, <id>.cc-agents.json (all 0600 in a 0700 directory)runtime dirMCP_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.

You've seen the proof

Ready for a number? Scope your deployment and we'll price it against your own economics.

Get your quote →