openstation

Guides

Deploying

Operate OpenStation as a long-running service.

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 EnvironmentFile is. systemctl restart after 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. With Restart=always, that becomes a restart loop with the reason in journalctl on every pass.
  • openstation details reads 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 PreToolUse hook 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.

View Markdown source on GitHub ↗