The directory holds two kinds of thing and they must not mix. The definition — CLAUDE.md, parameters, procedures, scripts — is what travels. The instance state — what it has handled, what it spawned, its loop's pid — must never, or a recipient's commander starts up believing it already handled somebody else's recordings and silently does nothing. Structural from the first commit rather than sorted out later, for the same reason plugin capabilities are read in place: a directory boundary beats a convention, because a convention is what a tired person violates. Parameters separate from procedure, too. With a path baked into a script, a fork is a debugging session; declared in one place, a fork is a conversation — the new owner is asked where their memos land and the commander rewrites its own file. Phase 2, recorded and NOT being built: a commander turns itself into a git repository when asked, in conversation. No button — it git inits, ignores state/ and commits, which is the same shape as everything else here. What it needs to know goes in the commander SKILL rather than an affordance, because an affordance's body is charged to every turn of every commander forever and this earns that approximately never. Two rules that belong in the design now because both are awkward later. A credential is definition-shaped — it looks like configuration — so gitignoring state/ does not protect it; commanders name what must be configured and the platform holds the value. And a shared commander declares what it requires, or it lands on an install without a Whisper server, arms cleanly and fails silently at 3am, which is the worst failure this system has. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
9.4 KiB
A commander is a directory, not a session
Status: designed, not built.
What is wrong now
A commander's identity is borrowed. command_hq_sessions.session_id is Claude's transcript id, minted
by the CLI as the first turn begins — verified: the id on the live row has a transcript file under
~/.claude/projects/. Everything about a commander hangs off that one borrowed value, and three problems
fall out of it.
An ordering problem. POST /command-hq/commanders takes a sessionId the caller already holds and
validates the transcript exists, so a chat session is created first and filed as a commander second. But
the working directory is fixed when the session spawns — before the id exists. There is no point in the
current flow where a commander can be started in a directory named after itself.
A first-turn interlock that is a side effect. commanderBlock compiles the paths a commander must
record itself at into every turn, and on the first turn they cannot be resolved, so the block is empty.
The commander skill turns that into a rule: "if you have not been given paths, you are on your first
turn." It works, and it is a signal derived from a failure rather than something anybody said. That
block's own comment explains the awkwardness — it rides effectivePrompt rather than the session
preamble, because the preamble is built once and the first turn has no id to build it from.
Losing the conversation loses the commander. State lives in the transcript and in a second tree,
commander-state/<transcript id>/. A session that dies takes its commander with it.
The design
The directory IS the commander. The session is how you talk to it.
$DATA_PATH/<email>/commanders/<commander id>/
CLAUDE.md written at creation, extended by the commander as it learns the job
spawned session ids of the agents its loop started
<whatever it writes> scripts, procedures, its record of what it has handled
The id is one the platform owns, generated when the commander is created. Nothing waits on the CLI.
The flow becomes linear:
- The user gives a name and a reference path, and clicks Start.
- The platform creates the row and the directory.
- It writes
CLAUDE.mdinto the directory: the name, the reference path, and the standing guidancecommanderBlockcurrently injects. - It spawns the session with
cwdset to that directory. - The transcript id arrives later and is recorded on the row, as a pointer to the current conversation.
The API does not change. Same endpoints, same shape from the client's side. What changes is the server being able to do things in an order that works.
What this buys
CLAUDE.md needs no mechanism. Claude Code reads one from the working directory at session start. So
the living document is free: the commander edits its own file, and the next turn has it. No injection, no
per-turn cost, no block riding effectivePrompt.
A commander survives its conversation. Everything it knows is on disk. A session that dies can be replaced by a fresh one in the same directory, and the commander carries on with its scripts, its procedures and its record of what it has already handled. Today that is a total loss.
One directory instead of two. commander-state/<id>/ collapses in. Deleting a commander becomes
deleting a directory, which is also the only way to be sure nothing is left behind.
The first turn can be stated rather than inferred. With the file written before the session exists, the platform can simply say which turn it is on. The interlock stops being a trick.
The reference path
What was the working directory becomes a reference — what the commander operates ON, not where it lives. One is collected in the start dialog, because a person can name one thing and asking for a list up front gets a worse answer than asking later.
More can be added in conversation. A voice-memos commander watches one folder; a life manager might
watch memos, scans and a mailbox. So this is a list on the row, seeded with one, and the commander writes
additions into its own CLAUDE.md as they are agreed.
What the directory holds: definition versus state
Two kinds of thing live in there and they must not mix, because one of them travels and the other must never.
$DATA_PATH/<email>/commanders/<commander id>/
CLAUDE.md DEFINITION — the living document, extended as the job is agreed
parameters.* DEFINITION — the things a fork has to change. Paths, cadences, thresholds
procedures/ DEFINITION — what a spawned agent reads at fire time
scripts/ DEFINITION — the loops
state/ INSTANCE — what it has handled, what it spawned, its loop's pid
Separate from the first commit, not sorted out later. A shareable bundle is "everything except
state/" only if that boundary is structural; mixed together, extracting one means picking files apart by
hand and getting it wrong once. Same reasoning as plugin capabilities being read in place rather than
copied — a directory boundary beats a convention, because a convention is what a tired person violates.
The failure it prevents is specific: a recipient's commander starting up believing it has already handled your recordings, and silently doing nothing.
Parameters are separate from procedure, or forking means editing code. With ~/Documents/VoiceMemos
baked into a script, a fork is a debugging session. Declared in one place, a fork is a conversation — the
new owner is asked where their memos land and the commander rewrites its own file. That difference is the
whole point of the thing being an agent rather than a template.
Phase 2 — a commander becomes a git repository
Not now. Recorded because the phase 1 layout has to leave room for it, and it costs nothing today to leave that room.
There is no button. The owner asks the commander, in conversation, to make itself shareable, and it
does — git init, a .gitignore that excludes state/, a first commit. That is the same shape as
everything else here: the commander maintains its own directory, and the platform does not reach in.
What it needs to know goes in the commander SKILL, not an affordance. The layout, what is tracked,
what is ignored and how credentials are handled is reference material — read on the one day somebody asks
for it. An affordance's body is charged to every turn of every commander forever, and this would earn that
cost approximately never. capabilities/skills/commander/SKILL.md gains a section.
Credentials are named, never valued. This is the rule that matters, because the natural place to put a
Whisper URL is the config file that is about to become public. A commander declares what must be
configured — "an STT endpoint" — and the platform holds the value. Gitignoring state/ does not cover
this: a credential is definition-shaped, it looks like configuration, and it would be committed and pushed
and stay published.
And a shared commander declares what it requires. Yours assumes a transcription plugin, a Whisper
server actually configured, and a folder that exists. Landing on an install without them, it arms cleanly
and fails silently at 3am — the worst failure this system has. Same problem osDependencies solves for a
plugin, wanting the same answer: the receiving platform refuses rather than pretends.
The forking story then reads: clone, open a conversation, answer where your memos land and what you want done with them, and the commander rewrites its own parameters and arms.
The consequence for the skill
capabilities/skills/commander/SKILL.md says turn one is orientation — "read where you actually are
before you ask anything" — and that assumes cwd is the thing worth exploring. Under this design the
commander's own directory is nearly empty on turn one and the interesting thing is somewhere else.
That guidance has to change with the code, or every new commander will orient on an empty folder and ask a worse question than it does today.
The schema change, and why there is no migration
session_id stops being the commander's identity and becomes a nullable pointer to its current
conversation. The row gains its own id, its directory, and its reference paths.
Nothing is migrated. The one commander on this machine is deleted and re-armed from scratch on the new system — the owner's call, made on 2026-08-24, and the right one: it is unarmed and has never fired, so migrating it would mean writing and testing a path that runs exactly once and then never again. Re-arming also exercises the flow this design exists to fix, which a migration would route around.
That decision has a shelf life. It is free while one commander exists and unarmed; it stops being free the moment somebody is relying on one. Build this before that happens, or write the migration.
What this does not decide
Whether a commander's loops are scripted or agentic. Both, in practice — a watcher that transcribes does not need judgement and should not spend a turn per file, while a job that reads transcripts and decides what to do with each of them is agentic by nature. The directory holds the procedure either way.
How a commander learns what the platform can do. It reads the generated platform skill and, for a plugin, that plugin's own skill. That is a separate gap: a plugin must ship a skill documenting its routes or a commander can see it is installed and still not know how to call it.