One daemon per OpenStation: a single long-lived serve process owning one workspace, one agent, and
whichever connectors its environment configures.
The shape
systemd unit / container
│
▼
openstation serve -d /srv/my-space
│
├── connectors (from env) Slack · Telegram · Email
├── workspace /srv/my-space — a git repo
├── var/ sessions.db · events/ · jobs.db
└── claude spawned per turn
The process needs: Bun, an authenticated Claude Code CLI on its PATH, a writable workspace that
is a git repo, and its credentials reachable by whatever the workspace's env: block declares —
by default, the unit's own environment plus a .env in the workspace root.
systemd
[Unit]
Description=OpenStation — my-space
After=network-online.target
[Service]
Type=simple
User=openstation
WorkingDirectory=/opt/openstation
ExecStart=/usr/local/bin/bun packages/cli/src/index.ts serve -d /srv/my-space
EnvironmentFile=/etc/openstation/my-space.env
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
/etc/openstation/my-space.env holds the credentials, 0600 and owned by root:
ANTHROPIC_API_KEY=...
SLACK_BOT_TOKEN=xoxb-...
SLACK_APP_TOKEN=xapp-...
Two details that matter more than they look:
Restart=always is still worth having, but the process now supervises its own connectors:
a connector that drops is re-served with a widening backoff (1s → 30s, reset once a connection
has been up a minute), and each attempt is logged. Restart=always covers what that cannot —
a crash of the process itself.
The runtime a tool needs must be on the unit's PATH. The executor hands the child the
workspace's resolved environment, which starts as a copy of this process's — so a Python venv is
available to the agent only if it's on the PATH of this process, or the workspace's own
provider sets PATH. Set it explicitly in the unit rather than relying on a login shell.
Secrets from AWS Secrets Manager
The unit above is the host environment, and a workspace with no env: block gets exactly
that plus its own .env. A workspace can instead declare where its variables come from — one
provider, read once at boot:
Declaring aws-secrets means the workspace's .env stops being read. One provider, not a
chain: nothing falls back to the file. Move what it held into the secret before you switch, or
those variables simply stop arriving — a .env left behind is inert, and the boot banner says
env: aws-secrets <secretId>, never naming the file. The unit's own environment is unaffected:
it is still the base the secret layers over.
# .openstation/openstation.yaml
env:
provider: aws-secrets
secretId: openstation/my-space
region: us-east-1 # optional; else AWS_REGION / AWS_DEFAULT_REGION in the unit
The secret is one flat JSON object for the whole workspace:
{
"ANTHROPIC_API_KEY": "sk-ant-…",
"SLACK_BOT_TOKEN": "xoxb-…",
"SLACK_APP_TOKEN": "xapp-…"
}
One secret rather than one per variable, which makes it one API call at boot, one atomic rotation, and one IAM statement on the task or instance role:
{ "Effect": "Allow", "Action": "secretsmanager:GetSecretValue",
"Resource": "arn:aws:secretsmanager:us-east-1:123456789012:secret:openstation/my-space-*" }
Four operational facts to plan around:
- Credentials come from the default AWS chain — task role, instance profile, SSO profile, environment. Nothing about AWS auth goes in the manifest, so a unit needs no AWS variables beyond a region if you don't declare one.
- Rotation means a restart. The secret is read once at boot, exactly as an
EnvironmentFileis.systemctl restartafter a rotation, or the daemon keeps the values it started with. - Boot fails closed. A denied
GetSecretValue, a missing region, a secret that isn't a flat JSON object — each aborts the boot naming the origin, never a value. A daemon that booted credential-less would instead start connectors that fail at connect, which is much harder to diagnose. WithRestart=always, that becomes a restart loop with the reason injournalctlon every pass. openstation detailsreads the secret too, unflagged, bounded at 5s so a wedged credential chain still leaves a readable report. It needs the same IAM as the daemon. See environment variables for what that bound does and does not cover.
The variables the secret holds are the same ones the unit would have exported — the environment variables reference lists them.
Shutdown
SIGINT and SIGTERM drain once: connectors stop accepting, in-flight turns finish, then the
process exits printing openstation serve — drained. A second signal can't start a second drain.
Give it a generous TimeoutStopSec — a turn can legitimately run for minutes, and claude-cli
allows up to 15 (claude-sdk has no limit at all).
What lives where
| Path | Contents | Back up? |
|---|---|---|
| the workspace repo | the agent's work and its history | yes — push to a remote |
var/sessions.db |
conversation → Claude session ids | no; losing it just resets continuity |
var/events/YYYY-MM-DD.jsonl |
the append-only event log | if you want the audit trail |
var/jobs.db |
scheduler state | no |
var/ is platform state and gitignored by the scaffold. Nothing rotates the event log — one file
per UTC day, growing forever. Rotate or prune it yourself.
The workspace has no special git requirements. Nothing pushes for you; add a remote and push on a timer if you want the history off-box.
Operational gaps to plan around
These are real and current. None has a workaround inside OpenStation.
Never run as a long-lived daemon in anger. Reconnect supervision now exists — a connector
that stops without a drain is re-served with backoff, and a clean drain is the one exit that is
never restarted — but no deployment has yet put weeks on it. Treat the first one as the test, and
keep Restart=always for a crash of the process itself.
No spend ceiling. No token budget, no cost cap, and nothing reporting per-tool spend. Bound it outside: provider-side limits on the API key, and a dedicated key per deployment so you can see and cap it independently.
One agent per process. serve resolves the agent once at boot and passes no channel, so the
default: true agent answers everything. Multiple agents means multiple units, each with -a and
its own credentials.
Triggers and schedules don't run from the CLI. serve starts the file watcher, so
workspace.FileChanged events are published and visible in openstation events — but it registers
no trigger rules (the manifest's triggers: block is unparsed), and there's no command to create a
scheduled job. Both are reachable only by embedding OpenStation as a library.
The approval gate isn't wired into serve. Also library-only. See
permissions.
Hardening
The permission gate is enforced inside Claude's decision loop, which makes it a strong default rather than a hard boundary. For a deployment where something must never happen, put a real boundary underneath it:
- run as a dedicated unprivileged user owning only the workspace
- mount everything else read-only; a container makes this easy
- add a
PreToolUsehook for specific irreversible operations - restrict egress if exfiltration matters more than deletion
Keep .env, **/secrets/**, and var/** denied in the settings file — the scaffold does this,
and var/** matters because it's how the agent would read the platform's own state about itself.
Checking on it
bun packages/cli/src/index.ts events -d /srv/my-space -n 50
git -C /srv/my-space log --oneline -20
journalctl -u openstation-my-space -f
Remember that chat turns publish no events — the log shows commits, watcher activity, and approvals, not conversations. For what a turn actually did, read the commit.