openstation

Reference

CLI

Commands, flags, inspection, and automation controls.

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>.mdmembers 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

--as vs --role. create --role names the profile to scaffold; dev --as and serve --as say who the caller is treated as. Two questions, two flags — the single -r/--role that 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: running on a fresh beat, stopped when the record carries its shutdown stamp, or not running … likely crashed on a stale beat with no stamp.
  • NOW — in-flight turns inferred from the event log: a turn.TurnStarted with 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 reads idle.
  • QUEUE — work waiting to happen: pending triggers fire requests still inside their pickup window, turns held for a human approval, and each schedule's next fire.
  • RECENT — the last -n finished turns per agent (outcome, duration, age), newest first, plus a workspace row of the last -n commits — 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
Email 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.

View Markdown source on GitHub ↗