openstation init initialize the installation: the home + its catalog README
openstation create <workspace> scaffold, git init, commit the scaffold
openstation list every workspace under the home, and whether it would boot
openstation [workspace] show what this workspace is wired to run (alias: details)
openstation [workspace] triggers every way a turn can start here, as one list
openstation [workspace] disable serve refuses it
openstation [workspace] enable serve boots it again
openstation [workspace] remove delete the directory (guarded)
openstation [workspace] agents add <name> append an agent
openstation [workspace] agents [list] the agents this workspace declares
openstation [workspace] members add <id> add a person to people.yaml
openstation [workspace] members [list] the roster
openstation [workspace] loops add scaffold the eval → learn → improve loop
openstation [workspace] loops [list] the loop this workspace runs
openstation [workspace] dev local REPL, no daemon
openstation [workspace] serve start connectors, long-running
openstation [workspace] status what agents are doing now — daemon, turns, queues
openstation [workspace] events tail var/events/
openstation [workspace] logs tail var/logs/
The CLI isn't published to npm, so invoke it from a clone of the repo:
bun packages/cli/src/index.ts <command> [options]
The workspace comes first
The workspace precedes the verb. The only bare verbs are the three that do not target an existing workspace:
init,create,list.
Everything else names its workspace one of five ways, in this order:
| You pass | Workspace root |
|---|---|
-d, --dir <dir> |
exactly that path |
a leading name — openstation notes dev |
$OPENSTATION_HOME/notes, default ~/openstation/notes |
| neither, standing in a workspace | the current directory |
| neither, standing under one | the nearest directory above you holding a manifest |
| neither, standing anywhere else | $OPENSTATION_WORKSPACE — a bare name under the home, or a path |
| nothing resolves | refused, naming every way out |
Passing both a name and -d is an error, not a ranking — a silent winner is the one outcome
nobody can debug. -w, --workspace <name> is the same thing spelled as a flag; the leading name
is rewritten into it, so use whichever reads better in a script.
Standing anywhere under a workspace is enough
The search climbs: the current directory, then each parent, until one holds
.openstation/openstation.yaml. First hit wins, so the whole tree works without naming
anything.
cd ~/openstation/notes/okf/issues
openstation show # -> ~/openstation/notes
openstation logs -n 5 # the same workspace, from anywhere inside it
The climb stops before $OPENSTATION_HOME, before $HOME, and before the filesystem root.
Git walks to the root; this doesn't, because a manifest left in ~ by one stray create would
otherwise become the silent default for every command run anywhere beneath it — the same hazard
the workspaces home avoids by refusing to be a git repo. The bound is on the logical path, so a
$HOME reached through a symlink is not recognized as that boundary.
create is exempt: it scaffolds into the directory you are standing in, which is by
definition not a workspace yet. Without that exemption, create run inside notes/okf would
target notes.
When nothing resolves, the CLI refuses rather than failing later on a manifest path you never typed:
openstation: no workspace at /Users/leonid/projects/thing or above it.
name one openstation <workspace> show
point at a path -d <dir>
set a default export OPENSTATION_WORKSPACE=<name>
what's available openstation list
$OPENSTATION_WORKSPACE — a default for callers outside a workspace
Set it and every command works from anywhere:
export OPENSTATION_WORKSPACE=antfarm # -> $OPENSTATION_HOME/antfarm
openstation automations # from /tmp, from a cron unit, from anywhere
It takes a bare name or a path, told apart by the same SAFE_NAME rule as everything else: a
name admits no separator, so the two forms are disjoint and neither is guessed at.
The workspace you are standing in — or under — outranks it. It reads backwards until you look
at what it prevents: a turn and an inbound stage both run with the workspace root as their cwd,
the executor copies the parent environment into every child, and one daemon may serve several
workspaces. If the variable won, a value exported once in an operator's shell would follow every
turn of every workspace and quietly point its CLI at a different one — including
automations disable. Ordering it below the search makes that impossible rather than merely
unlikely, and costs little: the variable is for callers standing outside every workspace, which
is exactly when the search has nothing to say.
The search changes one case this ordering used to leave to the variable. A tool that cds
within its own workspace is now answered by the search, which agrees with the stamp. A tool
that cds clean out of its workspace and into a different one's tree resolves the tree it is
standing in, rather than the turn it belongs to — the more defensible of two answers for an
already anomalous place to be. Turns and stages get the variable stamped per turn (never
inherited), so wherever the search finds nothing it still names their own workspace.
show marks the row when this variable decided — and equally when the search climbed to
get there. -d and a leading name are on the command line, and the current directory is under
your feet; these two are the rules you cannot see for yourself:
WORKSPACE
root /Users/leonid/openstation/antfarm (from $OPENSTATION_WORKSPACE)
root /Users/leonid/openstation/notes (found above the working directory)
Names become path segments, so they must match ^[A-Za-z0-9][A-Za-z0-9_-]*$. Anything else
(../foo, a/b) is refused; use -d for a path outside the home. A name that collides with a
command — list, show, dev — is refused by create, because the leading slot
would read it as the verb it shadows.
The old word order is refused, not reinterpreted. openstation dev notes worked until
2026-08-04; it now fails naming openstation notes dev. Accepting both would leave two orderings
in the wild and nothing to teach. See
plans/2026-08-04-workspace-lifecycle-design.md.
OPENSTATION_HOME overrides the home directory — absolute, or relative to the process cwd. A
shell expands ~; this doesn't, so OPENSTATION_HOME=~/spaces in a config file won't work.
Why one home directory. One place holding one git repo per agent project, matching the
one-daemon-per-OpenStation topology. The home itself is deliberately not a git repo: commit
automation runs git with cwd set to the workspace root, so a workspace that forgets
git init would otherwise resolve upward and quietly commit into the parent's history.
See workspaces and git.
init
Initializes the installation: the workspaces home, plus a README.md to catalog what you keep
in it. It creates no workspace — that is create.
Options:
--home <dir> workspaces home to initialize
(default: $OPENSTATION_HOME, else ~/openstation)
bun packages/cli/src/index.ts init
created ~/openstation
created ~/openstation/README.md
OpenStation home: ~/openstation
Create your first workspace: openstation create <name>
Idempotent: a second run reports what is already there — including how many workspaces it found — and changes nothing. The README is seeded once and never rewritten, because the catalog is yours, not the platform's: it is prose about intent, which no scan can produce.
It writes no registry. list scans the home for directories holding
.openstation/openstation.yaml, so a workspace you scaffolded by hand is as enrolled as one
create made, and there is no host file that can disagree with what is on disk.
init <name> and init -d <dir> are refused, naming the command that replaced them. Both
scaffolded a workspace until 2026-08-04, so reinterpreting them would quietly create something
other than what the caller means.
create
openstation create [workspace]
Options:
-d, --dir <dir> scaffold into a directory you already have (default: cwd)
-a, --agent <name> agent name (default: the workspace name, else "assistant")
-r, --role <role> role whose profile is scaffolded (default: "member")
-e, --executor <kind> executor the scaffolded agent runs on
(choices: "claude-cli", "claude-sdk", "pi-sdk", default: "claude-cli")
--no-trust leave the runtime's config alone — the scaffolded gate is then dropped
every turn
claude-cli is the default deliberately: it is the path with a 15-minute wall-clock limit, and
the one this repo has run live. Pick claude-sdk when you want budget.maxTurns, which the CLI
cannot enforce. pi-sdk is experimental: it also supports maxTurns, requires a provider API key in the resolved workspace environment, treats maxUsd as a post-request spend threshold, refuses MCP, and is not equivalent to Claude-native enforcement.
bun packages/cli/src/index.ts create support
created .openstation/openstation.yaml
created .openstation/profiles/member.md
created .claude/agents/support.md
created .claude/settings.support.json
created okf/index.md
created .env
workspace trusted in /Users/you/.claude.json — its gate stays in force
git repository initialized, scaffold committed as 6f2a1c8
OpenStation ready: ~/openstation/support/.openstation/openstation.yaml
A named workspace names its agent too — create support gets an agent called support, not
assistant, since v1 runs one agent per workspace. Override with -a.
Five announced files plus a .gitignore it writes without mentioning (it ignores var/, the
platform's own state; .env, this workspace's secrets; okf/scratch/inbound/, inbound uploads;
and repos/, an external checkout). The manifest declares the agent; the charter holds its
behavior; the settings file enforces what it may do; the profile covers the role from -r. The
okf/index.md stub is the bundle root — the seed for every folder the agent grows underneath,
already watched, committed, and writable with no further declaration. The scaffolded
agent is declared default: true — without that, agent selection finds nothing for the REPL's
channel and dev fails to boot.
The repository and its first commit. A workspace wants a git history — that is how a turn
becomes reversible — so create runs git init and commits the scaffold, unsigned and skipping
hooks. It commits only into a repository it created: pointed at a directory that is already a
repo (-d, the adopt-a-repo path), it scaffolds, says so, and leaves that history alone. If git
cannot run at all — not installed, no committer identity — the scaffold still stands and the
failure is reported, because a workspace without a repo is degraded, not broken.
.env is seeded commented-out and gitignored: a place to put a token, not an opinion about which.
The trust flag. The scaffold writes a gate, and Claude drops an untrusted workspace's whole
permissions.allow list — plus additionalDirectories — from every turn, with a warning on the
child's stderr and no error. So create marks the workspace trusted in the config file the child
will read (~/.claude.json, or wherever CLAUDE_CONFIG_DIR points), preserving everything else
in it. This is the only thing create writes outside the workspace; --no-trust skips it and
says what that costs. Skipped on its own when ANTHROPIC_API_KEY or CLAUDE_CODE_OAUTH_TOKEN is
set: there the platform owns var/claude/ at boot and grants trust in that file instead. A
failure here is reported and the scaffold still stands — and every dev, serve and show
re-asks the question as the the workspace trusted where the runtime reads trust check. See
permissions.
Agent names become path segments — .claude/agents/<name>.md — so they must match
^[A-Za-z0-9][A-Za-z0-9_-]*$. See openstation.yaml.
list
Options:
--home <dir> workspaces home to scan (default: $OPENSTATION_HOME, else ~/openstation)
--json machine-readable output
bun packages/cli/src/index.ts list
WORKSPACES /Users/you/openstation
hn-support enrolled git support (claude-cli)*, triage (claude-sdk)
notes enrolled git notes (claude-cli)*
openbook disabled git photo (claude-cli)*
pilot enrolled no-repo assistant (claude-cli)*
legacy enrolled git unloadable: openstation config …: unknown executor "patient"
5 workspaces, 1 disabled, 1 unloadable
A workspace is a directory under the home holding .openstation/openstation.yaml; anything else
in there is skipped rather than listed as broken. * marks the agent declared default: true.
no-repo is worth reading as a warning — a workspace with no git history cannot revert a turn.
An unloadable workspace keeps its row, carrying the loader's own reason. A workspace you cannot boot is exactly the row you opened this command for; dropping it would report the home as healthier than it is.
It never reaches the network — every field comes from the manifest plus the filesystem. There is
no list --live: details --live costs one round of API calls per workspace, and multiplying
that across a home needs evidence first. --json is the contract; the table is not.
disable / enable
openstation [workspace] disable
openstation [workspace] enable
Options:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
disable writes disabled: true into the workspace's own manifest — there is no host file to
put it in — and serve then refuses to boot it, naming the command that reverses it. dev
warns and runs anyway: a workspace you have withdrawn from its channels is exactly one you may
still want to debug locally.
Both are idempotent and say which they were. enable removes the flag and the comment disable
left rather than setting it to false, so an enrolled manifest looks like one that was never
disabled. Either way the file is loaded back before the command returns, and an edit that would
break it is rolled back instead of saved.
remove
openstation [workspace] remove
Options:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
--yes actually delete it
--force delete even when the workspace holds work that exists nowhere else
Deletion, and only deletion — disable is how you stop serving a workspace you want to keep. Two
verbs for two outcomes beats one verb whose destructiveness depends on a flag.
Without --yes it deletes nothing, printing what it would remove. Because invariant #7 puts
customer work in the workspace's git history, remove first asks git what would be lost and
refuses when the answer isn't "nothing":
| Finding | Without --force |
|---|---|
| not a git repository | refused — nothing there is recoverable |
| uncommitted changes | refused |
| commits that are on no remote | refused — that history exists only here |
| git could not be asked | refused, with the git error |
A directory holding no .openstation/openstation.yaml is refused outright, --force or not, so a
mistyped --dir cannot turn this into rm -rf on something else.
agents
The agents this workspace declares. add appends one by config only — no platform code changes.
openstation [workspace] agents add <name>
openstation [workspace] agents [list]
Options, both:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
agents add:
-c, --channel <channel...> channels this agent answers on
--default make this the fallback agent for unclaimed channels
agents list:
--json machine-readable output
bun packages/cli/src/index.ts support agents add reviewer --channel review
-c is variadic: --channel review triage claims both. Two agents can't claim the same
channel, and only one agent may be --default — both are load-time errors, not last-wins.
bun packages/cli/src/index.ts support agents list
support claude-cli trigger=every-message default
gate /Users/you/openstation/support/.claude/settings.support.json
reviewer claude-cli trigger=every-message channels=review
gate /Users/you/openstation/support/.claude/settings.reviewer.json
The gate gets its own line because it is the file that decides what the agent may do, and a path
appended to the facts row is the part that scrolls off. list reads through the ordinary loader,
so a manifest that would fail to boot fails here the same way.
channels: doesn't route messages yet. Agent selection resolves once at startup, so one
process serves one agent regardless of what channels the manifest claims. The field is
validated and stored; it isn't consulted per message. See
internal/roadmap.md under "Known gaps to v1".
Neither group has remove: dropping an agent means editing the manifest and deciding what
happens to its charter, its gate and its sessions, which is its own design.
members
The people the workspace's roster admits. The scaffold doesn't write
.openstation/people.yaml — it's an optional feature, absent by default — and members add
creates it on first use.
openstation [workspace] members add <id>
openstation [workspace] members [list]
Options, both:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
members add:
-n, --name <name> display name
-r, --role <role> role admitting them (default: the roster's defaultRole)
members list:
--json machine-readable output
bun packages/cli/src/index.ts support members add alice --name Alice --role owner
added member "alice" to ~/openstation/support/.openstation/people.yaml
A fresh file gets the stub header and its commented example alongside the new entry. --role
defaults to the roster's own defaultRole (a fresh file's is always member); either way the
role must have a .openstation/profiles/<role>.md — members add scaffolds one if missing, the
same rule create follows. A duplicate id, or an id/role that isn't a safe filename, is refused
before anything is written. The new member's contacts: is empty — wire up a channel handle by
hand afterward. See people.yaml.
bun packages/cli/src/index.ts support members list
alice Alice role=owner slack:U01234567
bob — role=member no contacts
unlisted contacts are admitted as "member"
no contacts is the shape members add writes, and the row says so rather than implying the
person is reachable. The last line is the roster's answer for everyone not in it: a
defaultRole admits strangers under that role, and no defaultRole refuses them. With no
people.yaml at all, list says so and every contact is admitted — which is what the Bridge
does when the file is absent.
loops
The eval → learn → improve loop this workspace runs. add scaffolds one: charters, operator
skills, two store indexes, a PreToolUse hook, two agents and the three trigger rules that fire
them. Everything is copied out of a template bundle that ships with the CLI, never referenced
in place — an instantiated loop is the workspace's own content from its first commit, and a
platform upgrade never changes a deployed agent's behavior.
Not the automations: loops. Those are markdown files that run on a clock
(automations). This one is an event-driven pipeline: an approved improvement
artifact fires an agent that applies it.
openstation [workspace] loops add
openstation [workspace] loops [list]
Options, both:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
loops add:
--evals-dir <path> sealed zone outside the workspace (default: a `-evals` sibling)
--clone-root <path> where the code agent's clones live (default: a `-code` sibling)
--code-clone <path> the clone a platform code task is implemented in
(default: <clone-root>/openstation)
--dry-run print the files and manifest entries this would write, and
write nothing
loops list:
--json machine-readable output
bun packages/cli/src/index.ts support loops add
wrote:
.claude/agents/code-improver.md
.claude/agents/improver.md
…
.claude/hooks/deny_evals_access.py
.claude/settings.improver.json
.claude/settings.code-improver.json
okf/code-tasks/index.md
okf/improvements/index.md
appended to ~/openstation/support/.openstation/openstation.yaml:
improver: …
fill in each charter's `## Workspace knowledge` section, and implement the harness at
tools/loop — the bundle is not usable until both are done
The two agents it registers are improver (applies an approved improvement) and code-improver
(implements one code task in a clone, behind a PR). Both are trigger: none and silent: true:
no channel reaches them, and the three rules are their only callers. The bundle also ships
eval-collector and eval-learner charters and three operator skills, deliberately not
registered — they are subagents an operator spawns, and a manifest entry is what would make one
reachable by a trigger or a channel.
The gates are generated, not copied. Their control-plane and secret denies come from the same
permissions/grants.ts the runtime enforces, so they cannot drift; on top of that both carry the
sealed-zone denies and the copied PreToolUse hook, because a path deny binds Read/Edit/
Write and not Bash, Grep or Glob — and an agent that can read the ground truth its own
work is scored against makes the scores meaningless.
It writes nothing unless every target is clear. A manifest that already declares improver
or code-improver, or any file the bundle would land on, refuses the whole run with the list.
If the appended manifest then fails to load, the manifest is restored and every file and
directory the run created is removed.
bun packages/cli/src/index.ts support loops
improver trigger=none silent
gate ~/openstation/support/.claude/settings.improver.json
code-improver trigger=none silent
gate ~/openstation/support/.claude/settings.code-improver.json
fired by:
created okf/improvements → improver
created okf/code-tasks → code-improver
modified okf/code-tasks → improver
list is the default, so the bare noun works, and a workspace with no loop says so rather than
printing nothing. A loop whose agents are declared and whose rules are missing reports
fired by: nothing — with trigger: none there is no other way in.
The scaffolded loop is not runnable as it stands. Two things are the instantiating
workspace's own: each charter's ## Workspace knowledge section (the agent under evaluation, its
artifact schema, the repos a code task may name and where each is reviewed, the test commands),
and the harness CLI the skills invoke. See the bundle's own
packages/cli/src/templates/loop/README.md and
internal/plans/2026-08-31-loops-add-design.md.
automations
What runs on a clock here: the markdown loops an automations: binding admits, and the
schedules: entries the manifest declares. Both arm the same job store, so one command reports
both. Event-driven triggers: are not here — they have no cadence; triggers lists
every way a turn can start, cadence or not.
openstation [workspace] automations [list]
openstation [workspace] automations show <id>
openstation [workspace] automations runs [id]
openstation [workspace] automations run <id>
openstation [workspace] automations enable <id>
openstation [workspace] automations disable <id>
Options, all:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
list / show / runs:
--json machine-readable output
show:
-n, --runs <n> how many recent runs (default: 10)
runs:
-n, --limit <n> how many runs (default: 20)
--failed only runs that failed
list is the default, so the bare noun works:
bun packages/cli/src/index.ts antfarm automations
BINDINGS
okf/loops type=Loop agents=base-improver max 12/h
SCHEDULED
ID AGENT EVERY STATUS LAST RUN RUNS DELIVER
base-improvements base-improver 5m next in 4m24s 12:10 ok 16s 21 telegram:-5561319701
sui-improvements base-improver 5m running for 1m22s 12:04 ok 6m13s 1 telegram:-5561319701
WARNINGS
! base-improvements: mean run is 10m16s but every: is 5m — this automation is effectively continuous
Everything is read off the disk — the manifest and the loop files for the declaration,
var/jobs.db for state, var/events/ for history — so it answers before a boot as well as
during one. A workspace nothing has ever served reports not armed rather than a next-fire, and
no database is created by reading.
The STATUS column switches on state because the useful number does: a running job wants how long it has been running, an idle one wants when it fires next. The warning above is why — a loop whose runs outlast its interval has a next-fire permanently in the past, which would otherwise read as a fault instead of a cadence mistake.
Files that declare nothing runnable
A loop that never fires used to be silent. list reports it, split by whether it is a decision or
a defect:
DISABLED
okf/loops/paused.md
SKIPPED
okf/loops/hot.md every: 30s is faster than the binding's ceiling of 12/hour
okf/loops/draft.md agent "ant-master" is not in the binding's allowlist
show
bun packages/cli/src/index.ts antfarm automations show base-improvements -n 6
FILE
path okf/loops/base-improvements.md
declares agent base-improver · every 5m · session fresh · enabled true
deliver telegram:-5561319701
binding okf/loops (type=Loop, agents=base-improver, max 12/h)
STATE
scope loop:base-improvements
status next in 4m10s
next 2026-08-08 12:15:52
runs 21 completed · last ok at 2026-08-08 12:10:52
RUNS var/events/, newest first
2026-08-08 12:10:36 ok 16s
2026-08-08 11:48:36 ok 16m40s
mean 10m16s · p95 15m53s · 21 ok / 2 failed since 2026-08-06 17:48:06
runs 21 completed counts complete() calls, so it is legitimately lower than the RUNS tally
below it: a firing the TurnRunner never admitted is released without one. Timestamps are UTC —
the event log is UTC and a workspace declares no timezone.
A skipped file can still be inspected; show names the reason it is not running.
runs
Recent firings across every automation, newest first — the "what failed overnight" view.
bun packages/cli/src/index.ts antfarm automations runs --failed -n 5
2026-08-08 10:01:36 base-improvements FAILED (execution) 12m
2026-08-08 07:11:05 base-improvements FAILED (execution) 11m46s
run, enable, disable
run arms; it does not execute. The CLI is not the daemon, and running the turn here would
bypass the TurnRunner's global cap and per-conversation lock. It brings the next fire forward and
says so; the serving process picks it up on its next tick, and with nothing serving it fires when
something next does. It refuses a job that is already running, and one with no job row yet.
enable/disable edit the loop file's enabled: frontmatter, not the job store. The file is
the declaration — for cadence, for the instruction, and so for this too. A pause held in the store
would disagree with the file forever, because the scheduler re-declares from its 30s rescan and
never reconciles status. Only the frontmatter block is rewritten: a body that mentions enabled:
in prose is left alone. A schedules: entry is refused with a pointer to the manifest.
For agents
Read verbs are separate words rather than flags so a gate can grant them one at a time — the
prefix Bash(openstation automations list:*) does not also grant run or disable:
// .claude/settings.<agent>.json
"allow": [
"Bash(openstation automations list:*)",
"Bash(openstation automations show:*)"
],
"deny": [
"Bash(openstation automations run:*)",
"Bash(openstation automations enable:*)",
"Bash(openstation automations disable:*)"
]
A turn's cwd is the workspace root, so no -w is needed and the line stays workspace-independent.
This is the only route an agent has to its own schedule: a gate that denies cat, grep and
tail — as the reference gates do — leaves no other way to read var/jobs.db or the event log.
Pair it with --json so the agent parses rather than scrapes.
Deny the mutating verbs explicitly. Deny-by-default already refuses them, so the block is
redundant today — it earns its place by surviving the one edit that would otherwise open the hole:
simplifying the allow to Bash(openstation automations:*), which looks like tidying and hands over
run. The verb matters because the caller's permissions bound the command; the loop's agent
bounds the run. An armed turn executes as the loop's own agent under that agent's settings
file, so an agent holding run can cause work its own gate forbids — which is why run prints the
agent it will run as. enable is the same hazard delayed by one interval, and disable is a
denial of service on the operator's own schedule.
triggers
Every way a turn can start here, as one list. Six things declare one — an agent's own trigger:,
its channels: entries, default:, triggers:, schedules:, and the loop files an
automations: binding admits — spread across four manifest blocks and a folder.
show reports those blocks separately; this joins them into one row per edge.
openstation [workspace] triggers [list]
openstation [workspace] triggers topology
openstation [workspace] triggers fire <rule> --file <path>
Options, list + topology:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
--live reconcile channel edges against what each connection can see
--observed annotate rule and clock edges with firings from var/events/
--json machine-readable output — both verbs return the same report
list:
-a, --agent <name> only this agent's triggers
-s, --source <kind> channel | agent-trigger | default | rule | schedule | automation
fire:
--file <path> workspace-relative file the rule should process (required)
--reason <text> why — recorded on the request row and the TriggerFired event
--dry-run evaluate the rule's filters against the file and report; fire nothing
--wait block until the fired turn finishes; non-zero exit on a failed turn
--timeout <sec> how long --wait watches before exiting 2 (default 900)
list and topology are reads, safe to grant an agent asking what wakes it. fire is not —
it starts another agent's turn, the same escalation automations run carries —
which is why it is a separate word a gate can withhold. list is the default, so the bare noun
works.
fire starts a rule's turn now on a named file. A rule's address is the id= its list row
leads with — a declared id: on the triggers[] entry, or <agent>@<path> derived. The
serving daemon runs the turn, never the CLI: fire arms a row in var/jobs.db that the daemon
claims within seconds and that expires unrun if nothing is serving (never queued for a later
boot). The rule's content filters — path, ext, artifactType — still apply against the
real file, and a mismatch is refused with the failing filter named; the motion guards —
change:, skipUnchangedContent, first-match shadowing — are deliberately bypassed, because
an operator's fire is not filesystem motion. The firing is stamped manual: true (with who and
why) in var/events/, and the turn runs under the rule's agent's own gate: fire changes when a
turn starts, never what it may do. --dry-run is the read-only half — the same checks,
reported, nothing armed.
topology is the graph: who reaches whom, through what.
bun packages/cli/src/index.ts hn-support triggers topology
INBOUND who can start a turn
mention id=C0BKYEJ6P7T connector=slack #test-auto-support ━▶ support profile=member gate=support $5/turn
mention connector=slack type=dm ━▶ support roles=admin profile=admin gate=support-admin
mention any space no channels entry matches ━▶ support profile=member gate=support $5/turn
unclaimed any space no agent claims ━▶ support profile=member gate=support $5/turn
ARTIFACTS what agents reach each other through
okf/issues
created artifactType=Issue retry=1 ━▶ triage ▶ origin conversation
modified artifactType=Issue skipUnchangedContent ━▶ verifier no reply (no deliver:)
↺ verifier's own write re-matches this rule — guarded by skipUnchangedContent
· which agent writes here is declared nowhere, and the change event carries no
actor — these edges are what fires, never who caused it
AGENTS
support 5 in out ▶ conversation gate=support $5/turn
triage 1 in out ▶ origin conversation gate=triage $3/turn
verifier 1 in out ▶ nothing is delivered anywhere gate=verifier $2/turn
REACHABILITY
by a person support
by a rule triage, verifier
by a clock —
by nothing —
REACHABILITY is the section to read first. It partitions the roster by what can start each
agent, and by a person is the answer to "who can be talked to" — a question that otherwise
means reading two blocks and knowing that trigger: on an agent and triggers: at the root are
different axes.
Artifacts are nodes, not agent→agent arrows. Nothing declares which agent writes where, and
workspace.FileChanged carries no actor, so the rule is drawn on the path it watches. Above,
that triage wrote the file the verifier's rule then matched is true and is not config — the
command will not claim it.
A self-edge is marked, not hidden. A rule matching modified (or declaring no change:) can
be re-matched by a write from the very agent it fires. The guard prints beside it; an unguarded
one prints NOTHING GUARDS IT and raises a warning, because the symptom is a workspace that
never goes quiet. A created rule cannot loop this way and is not marked.
--observed annotates only what the event log can key — rules (trigger.TriggerFired) and clock
jobs (a turn.TurnFinished whose scope names the id). Inbound edges get no count: a turn's scope
is the conversation, not the channels: entry that admitted it, and two entries can admit the
same conversation.
created artifactType=ResearchQuery ━▶ hive-runner ▶ origin conversation fired 4×, last 2026-08-05T17:19:46.565Z
--live reconciles declared channel ids the way show --live does, and reuses that
command's reconciliation rather than repeating it. It matters more here than there: an agent's
own trigger: covers every channel it was invited to, so the manifest is a floor and Slack
membership is the real edge list.
gates
What each agent may do, compiled from .openstation/roles.yaml, and whether
the file that enforces it still agrees. Daemon-free like triggers: everything
resolves from the manifest plus the filesystem, so it answers before a boot.
openstation [workspace] gates [list]
openstation [workspace] gates check
openstation [workspace] gates init
openstation [workspace] gates write
Options, all:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
list, check:
--json machine-readable output — both return the same report
| Verb | What it does |
|---|---|
list |
one block per agent: the roles it holds, any exemptions with their reasons, and the rules they compile to |
check |
the same report, and exit 1 if any gate file has drifted. Reads only — safe in CI, and safe to grant an agent asking what it may do |
init |
writes the roles.yaml stub. The on-ramp; refuses to overwrite an existing roster |
write |
regenerates every .claude/settings.<label>.json from the roster |
The stub init writes declares no roles on purpose: one that declared a required role
would start refusing boots the moment it was created.
write is operator-run by construction, not by convention: Claude Code write-protects
.claude/** from headless agents regardless of permission rules, so an agent invoking it fails
on the write itself. It is also idempotent — an unchanged file is not rewritten, so running it
twice leaves mtimes alone and produces an empty git diff.
Drift is compared by rule set, not bytes, so a reordered emit or an edited _comment_* is
not drift. Boot refuses on drift and never regenerates: silent regeneration would discard a
hand-edited security fix, and silently honouring a hand edit would make the roster a lie.
A workspace with no roles.yaml reports every gate as hand-authored, with no drift possible
and a warning saying so. Nothing about this command changes what Claude enforces — the gate
file is still the boundary; this is how it gets written.
dev
--asvs--role.create --rolenames the profile to scaffold;dev --asandserve --assay who the caller is treated as. Two questions, two flags — the single-r/--rolethat answered both is what made the two senses of "role" hard to keep apart.
Runs the agent locally over the REPL connector — no daemon, no credentials beyond Claude auth.
openstation [workspace] dev
Options:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
-a, --agent <name> agent to run
--as <role> role to treat the local caller as (default: "member")
bun packages/cli/src/index.ts support dev
Type a message, get a reply. -r decides which profile is layered onto the charter; a role
with no profile file is refused rather than silently downgraded, so --as admin needs
.openstation/profiles/admin.md to exist.
dev commits the agent's work like serve does — a turn that changes the artifact zone
becomes a commit you can git revert.
serve
Starts connectors whose credentials are in the environment. Long-running.
openstation [workspace] serve
Options:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
-a, --agent <name> agent to run
--as <role> role to treat callers as, when no people.yaml resolves them (default: "member")
SLACK_BOT_TOKEN=xoxb-... SLACK_APP_TOKEN=xapp-... \
bun packages/cli/src/index.ts support serve
Which connectors start is decided entirely by which credential sets are present — there's no
flag for it. With none present, serve exits and tells you so. See
environment variables for each connector's required set, and
deploying for running it as a service.
status
What the workspace's agents are doing right now — the daemon-side complement of
show, which reports what a boot would run. Read-only: it creates nothing,
so it answers before the first boot rather than erroring.
openstation [workspace] status
Options:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
-a, --agent <name> only this agent's rows
-n, --limit <n> recent turns per agent (default: 5)
--json machine-readable output
bun packages/cli/src/index.ts support status
Four blocks:
- DAEMON — every heartbeat record under
var/serve/, one row per serving process:runningon a fresh beat,stoppedwhen the record carries its shutdown stamp, ornot running … likely crashedon a stale beat with no stamp. - NOW — in-flight turns inferred from the event log: a
turn.TurnStartedwith no later finish on its scope is in flight while some daemon heartbeat is fresh, and orphaned otherwise — the daemon that carried it is gone. A dangling start older than two days ages out of the block instead of haunting it, and every roster agent with nothing in flight readsidle. - QUEUE — work waiting to happen: pending
triggers firerequests still inside their pickup window, turns held for a human approval, and each schedule's next fire. - RECENT — the last
-nfinished turns per agent (outcome, duration, age), newest first, plus aworkspacerow of the last-ncommits — a vetoed commit prints its veto reason in place of a sha.
-a filters like events -a does — only that agent's NOW and RECENT rows —
and also drops the workspace commits row, which no single agent owns. Empty blocks print
their calm forms (not running, idle, none) and the command exits 0, so status
before the first boot is an answer, not an error. --json prints the gathered report
unrendered — the machine contract behind every block. A manifest that won't load raises
the same named error as every other command, with a non-zero exit.
events
Tails the workspace event log (var/events/).
openstation [workspace] events
Options:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
-n, --limit <n> how many recent events (default: 20)
-t, --type <type> only events of this type
-a, --agent <name> only events naming this agent
bun packages/cli/src/index.ts support events -n 50 -t turn.TurnFinished
Prints no events recorded yet on an empty log. -t takes an exact event type — see
events for the eight types and their fields. -a keeps only events that name
the agent (turns and triggers do; workspace and approval events are workspace-scoped).
logs
Shows the workspace application log (var/logs/openstation.log) — what the process did,
as opposed to events, which records what happened to the workspace.
openstation [workspace] logs
Options:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
-n, --limit <n> how many recent lines (default: 40)
-l, --level <level> only this level or above
-g, --grep <text> only records containing this text (a run id, a tool, a channel)
-a, --agent <name> this agent's own log (var/logs/agents/<name>.log)
bun packages/cli/src/index.ts support logs -n 100 -a support-bot
Every line a turn logs for an agent is mirrored to that agent's own rotating file under
var/logs/agents/, so -a reads one agent's full history without grepping the shared log.
show
One command, two names: same options, same report, same --json contract. show reads as a
verb beside list; details is what every existing script and skill already calls.
Shows what the workspace is wired to run, per plane — gateway, bridge, execution — plus the
agents, channel policy, triggers, and schedules the manifest declares. Read-only, and it needs
no running daemon: everything comes from the manifest plus the filesystem, so it answers
before a boot rather than after one. --live is the one exception, and it is opt-in.
openstation [workspace] show # or: openstation [workspace] details
Options:
-w, --workspace <name> workspace name under the workspaces home
-d, --dir <dir> workspace root (default: the workspace at or above the cwd)
--json machine-readable output
--live reconcile declared channels against what each connection can see
bun packages/cli/src/index.ts support show
WORKSPACE
root /Users/you/openstation/hn-support
external /Users/you/hn-repos
state sessions, jobs, approvals(absent), events, serve
env aws-secrets openstation/hn-support (6 applied)
GATEWAY
source declared
slack slack enabled SLACK_BOT_TOKEN, SLACK_APP_TOKEN ack=eyes (default)
id=C0BKYEJ6P7T auto-support-test → support trigger=mention
connector=slack type=dm → support roles=admin gate=support-admin
BRIDGE
identity people.yaml
approval off
attachments max 30 (default)
pipeline ↓ normalize
↓ identity (people.yaml)
↓ policy (trigger=mention, 3 space rule(s))
↓ select agent (resolved once at boot — not per message)
↓ ...
EXECUTION
executor main
declared capped (claude-sdk, claude resources) → triage
declared main (claude-cli, claude resources) → support
declared patient (claude-cli, claude resources, timeout 1800s) → verifier
tool surfaces none (no manifest surface yet)
artifact zone okf/**
runner concurrency 3, 1 reserved for live turns
AGENTS
support claude-sdk default trigger=mention profile=member $5/turn
gate /Users/you/openstation/hn-support/.claude/settings.support.json
id=C0BKYEJ6P7T connector=slack trigger=mention
It reports the platform's own compromises rather than hiding them. tool surfaces none (no manifest surface yet) and select agent (resolved once at boot — not per message) are facts
about what this version does, not decoration — a listing that omitted them would read as a
workspace with no gaps.
The env row sits above GATEWAY on purpose: it says how much to trust every =unset
marker below it. It reads the declared provider — an aws-secrets workspace makes this command
reach the network, bounded at 5s — and degrades to (unavailable: <reason>) rather than
failing the report. See environment variables.
--json is the contract; the human layout is not. Script against the JSON.
Channels, under the connection that carries them
A channel exists only via a connector, so channels: entries are listed under their connection
rather than in a section of their own. Each row is a rule, not an agent: → support, watcher
means both agents declare that rule, and the arrow says declares, never routes — agent
selection still resolves once at boot.
Which connection a row belongs under follows what the manifest can actually say. A connector:
selector names a type, so a type shared by two connections lists the row under both. A row
naming no connector — a bare id, or type: dm — is attributed only when there is exactly one
connection it could have arrived over; otherwise it lands under any connector, because nothing
in the file decides between them. The manifest's name: label is printed here, which is the only
place an unreadable id like C0BKYEJ6P7T gets a readable one.
--live
Reconciles those rows against what each connection can actually see. It reaches the network, which is why it is opt-in.
GATEWAY
source declared
slack slack enabled SLACK_BOT_TOKEN, SLACK_APP_TOKEN ack=eyes (default)
id=C0BKYEJ6P7T #auto-support-test → support trigger=mention
id=C0BHRF256H4 ABSENT → support trigger=mention
id=C_RANDOM #random UNDECLARED
connector=slack type=dm → support roles=admin
Four findings a manifest read cannot produce: ABSENT (declared, and the bot is not in it —
the agent is silently dead there, whether the id is wrong or the invite never happened),
ARCHIVED, UNDECLARED (the bot is in it and no entry claims it, so it answers under the
agent's own trigger: or not at all), and a stale name: label, reported against the channel's
current name. A row naming a class of spaces — type: dm — gets no status: no listing can
annotate a rule that matches many spaces.
--live also settles attribution the manifest could not: an unclaimed id that exactly one
connection reports seeing moves under that connection.
| Connector | Reach |
|---|---|
| Slack | full listing, via users.conversations. Needs channels:read and groups:read on the bot token — a token that can post still cannot list |
| Telegram | declared ids only (live: declared ids only). The Bot API cannot enumerate a bot's chats, so an undeclared chat is undiscoverable |
| nothing — no notion of membership |
It never turns a working report into a failed one. A disabled connection, an unset
credential, a missing scope, or an API error leaves that connection's rows carrying their
declared facts, marks it live unavailable: <reason>, and adds a warning. The exit code does not
change — which matters, because a rejected token is exactly when you want the rest of the report.
Warnings
A WARNINGS section appears when something is wrong before a turn ever runs — a declared
executor nobody binds to, a workspace whose agents give a boot no default to select, or a
resource the bound runtime does not honour:
WARNINGS
! executor "patient" is declared but no agent binds to it
What details warns about, serve and dev may refuse. The preflight now separates the two:
a resource a runtime does not honour leaves the agent narrower and warns; one it cannot
enforce would leave the workspace running wider than it declares, and both commands refuse to
start rather than log it and continue. details never throws — it lists refusals first, so the
report shows what a boot would stop on:
WARNINGS
! executor "main" (claude-cli) needs "/opt/bin/claude" on the child's PATH and cannot find
it — every turn agent "support" runs would fail to start
Credential variables for enabled connections are checked by name —
a value never enters the output — along with unreadable declared roots and the preflight
checks serve already runs.
Commands that don't exist
openstation eject appears in older design docs (internal/architecture/11) and in the
pre-rewrite getting-started guide. It was never implemented — there's no mechanism for
copying a built-in module's source into your project.
openstation doctor was listed here too. Its useful half — reporting what is wired and what is
wrong with it — is now show, which reports warnings and takes --json. There
is still no command that reports drift between a workspace and the version of OpenStation that
scaffolded it.
openstation list was listed here as deliberately absent, on the grounds that the home README is
the catalog and the platform shouldn't own it. Half of that survives: the README is still yours and
still unread by anything, because intent is not something a scan can recover. But "nothing lists
your workspaces" stopped being a decision once more than one could be disabled, unloadable, or
missing a repository — list reports that, and nothing else.