openstation

Guides

End to end

Configure two connectors, a roster, policies, and automation.

Every other guide covers one slice. This one goes from an empty directory to an agent serving Slack and Telegram as a long-lived service, with a people roster, an enforced gate, a watcher that files instead of speaking, and a second agent that verifies its work.

Read it once through to see the shape. Then use getting started for the five-minute version, your first agent for the authoring detail behind each file, and the openstation.yaml reference for every field.

Every command and output below was run against this version. Where something is refused, that is the real error text.

What you end up with

~/openstation/support/                 the workspace: a git repo
├── .openstation/                      the management overlay — what you configure
│   ├── openstation.yaml               agents, connections, policy, automation
│   ├── people.yaml                  which handle is which person, at which role
│   └── profiles/
│       ├── member.md                how it talks to an ordinary caller
│       ├── owner.md                 how it talks to you
│       └── watcher.md               how it behaves where it never speaks
├── .claude/                         Claude-native — what the agent *is*
│   ├── agents/support.md            the charter (system prompt)
│   ├── agents/verifier.md
│   ├── settings.support.json        THE GATE. Claude enforces this
│   ├── settings.support-admin.json  a wider gate, for your DM only
│   └── settings.verifier.json       read-only: it checks, it doesn't edit
├── okf/                             the agent's work. Committed
│   ├── issues/                      what it files
│   └── scratch/inbound/             inbound photos and uploads. Gitignored
├── var/                             sessions, events, logs. Never committed
└── .gitignore

Two planes, one rule: .openstation/ references, .claude/ describes. Restating a prompt, model, or tool list in the manifest is a load-time error, not a silent override — so the two can never drift.

1. Scaffold

bun packages/cli/src/index.ts create support -a support -r member
  created .openstation/openstation.yaml
  created .openstation/profiles/member.md
  created .claude/agents/support.md
  created .claude/settings.support.json
  created okf/index.md

OpenStation ready: ~/openstation/support/.openstation/openstation.yaml

It also writes .gitignore with var/, .env, .env.keys, okf/scratch/inbound/, repos/, and .openstation/openstation.local.yaml. That file is load-bearing: commit automation stages what git reports, so an entry missing there is a file committed. See workspaces and git.

create loads its own output back before returning, so a scaffold that cannot load is a bug in the scaffolder rather than your problem.

The repo matters. git init first: the agent's work becomes commits, and without a repo there is nothing to commit into.

2. Add the second agent

bun packages/cli/src/index.ts agents add verifier -d .
added agent "verifier" to ~/openstation/support/.openstation/openstation.yaml

add writes .claude/agents/verifier.md and .claude/settings.verifier.json alongside the manifest entry. A second agent is config only — no platform code changes, which is the property M2 exists to prove.

3. Write the manifest

This is the whole configuration surface. Every block below is optional except agents:.

# .openstation/openstation.yaml
connections:
  work-slack:
    type: slack
    botToken: ${WORK_SLACK_BOT_TOKEN}
    appToken: ${WORK_SLACK_APP_TOKEN}
  family-telegram:
    type: telegram
    token: ${FAMILY_TELEGRAM_TOKEN}

agents:
  support:
    identity: agent:support
    executor: claude-cli
    claudeSettings: support        # -> .claude/settings.support.json — THE GATE
    profile: member               # default voice
    default: true                 # answers anything no other agent claims
    trigger: mention              # fallback addressing for spaces with no entry
    budget: { maxUsd: 0.50 }      # per turn
    channels:
      - id: C_HELP                # the support channel
        connector: slack
        trigger: mention          # only when tagged
        roles: [member, owner]    # and only from these roles
      - type: dm                  # your DM, on any connector
        roles: [owner]            # nobody else gets one
        claudeSettings: support-admin   # a wider gate, here only
      - id: C_PRODUCT             # a channel it watches but never speaks in
        connector: slack
        trigger: every-message
        silent: true              # files issues instead of replying
        profile: watcher
        budget: { maxUsd: 0.10 }

  verifier:
    identity: agent:verifier
    executor: claude-cli
    claudeSettings: verifier      # read-only gate: it checks, it never edits
    trigger: none                 # never answers a human — triggers only

triggers:
  - on: workspace.FileChanged
    agent: verifier
    path: okf/issues
    ext: [".md"]
    prompt: An issue changed — verify its claims and write a verdict.

schedules:
  - id: nightly-sweep
    agent: support
    everyMs: 86400000
    prompt: Summarize what changed today.

bridge:
  attachments: { max: 30 }        # most files one reply may deliver

workspace:
  external:
    - ~/photos                    # a root outside the repo it may send from

Loaded, this resolves to:

agents: support, verifier
connections: work-slack=slack, family-telegram=telegram
support spaces: C_HELP@slack:4 > C_PRODUCT@slack:4 > dm:2
attachmentsMax: 30
externalRoots: /Users/you/photos
triggers: 1 | schedules: 1

Note the ordering: spaces are sorted most specific first, so the first match wins and nothing downstream needs ordering logic. An exact id outranks a bare type.

The five axes, and why they're separate

Field Question Resolution
trigger does a turn happen at all? space > agent
roles may this caller cause one here? space only
profile how does it talk here? person > space > agent
claudeSettings what is it permitted to do? person > space > agent
budget what may one turn spend? space > agent

profile and claudeSettings resolve independently. Widening the gate in your DM does not change how the agent sounds there, and giving someone a different voice does not hand them authority. That split is the whole design — see spaces and channels.

A role decides admission, never capability. To scope a power to one person, give them a space only they are admitted to — which is what the type: dm + roles: [owner] entry above is.

4. Fill in the referenced files

The manifest above references six files. The loader verifies every one exists and refuses to start otherwise, naming the missing path — so this step cannot be half-done.

Charters (.claude/agents/<name>.md) — Claude-native frontmatter, comma-separated tools:

---
name: verifier
description: Checks issue claims against the workspace and writes a verdict.
tools: Read, Glob, Grep
---
You verify. You never edit an issue; you write `okf/issues/<id>.verdict.md` beside it.

Profiles (.openstation/profiles/<name>.md) — the platform's dialect, YAML-array tools. This is a per-role addendum layered on the charter, not a replacement:

---
tools: ["Read", "Glob", "Grep"]
---
You watch and file. You do not chat.

A role or profile with no file is a refusal, not a fallback. The loader catches it at boot rather than leaving you a refusal at 3am nobody can explain.

Settings (.claude/settings.<label>.json) — the enforced gate. The scaffold grants reads plus writes scoped to okf/, the platform's default zone — the whole bundle, not one subfolder:

{
  "permissions": {
    "allow": ["Read", "Glob", "Grep", "Skill", "Edit(okf/**)", "Write(okf/**)"],
    "deny": [
      "Read(.env)", "Read(**/.env)", "Read(.env.keys)", "Read(**/.env.keys)",
      "Read(**/secrets/**)", "Bash(rm:*)",
      "Read(var/**)", "Bash(cat var/*)", "Read(logs/**)", "Bash(cat logs/*)"
    ]
  }
}

This workspace files under okf/issues/ — the trigger above watches it, and the verifier writes okf/issues/<id>.verdict.md beside what it checks — and the scaffolded gate already covers it: no hand-edit needed. Narrow it further, to exactly Edit(okf/issues/**), only if you want this agent unable to touch anything else the bundle might grow (okf/scratch/, a future okf/playbooks/). settings.verifier.json drops Edit/Write entirely; settings.support-admin.json widens beyond okf/. This file is the only real gate — the platform's own ceiling is an advisory pre-filter. Permissions explains what that means and how to check it.

5. The people roster

The scaffold doesn't write .openstation/people.yaml — build it by hand, as below, or grow it incrementally with openstation members add you -d . --name Leon --role owner, which creates the file on first use:

# .openstation/people.yaml
defaultRole: member
members:
  - id: you
    name: Leon
    role: owner            # needs .openstation/profiles/owner.md
    contacts:
      slack: "U01234567"
      telegram: "123456789"

members add leaves contacts: empty — add the handles above by hand either way.

Two things this decides:

  • Who is admitted whereroles: [owner] on the DM entry resolves through this.
  • Who is speakingname is what the agent sees. Every non-DM prompt now opens with [from Leon]; without a roster entry it falls back to the raw handle. A DM prompt stays bare, since there is exactly one other person in it.

Remove defaultRole to refuse strangers outright. Keep it and an unlisted contact is a member. See people.yaml.

6. Run it locally first

bun packages/cli/src/index.ts dev -d .
openstation dev — agent "support"
logging to ~/openstation/support/var/logs/openstation.log — add --verbose to mirror it here
openstation repl — Ctrl-D to exit, /new to reset the session
you>

dev needs the default: true agent and Claude auth, and runs no automation — no triggers, no schedules, no session sweep. It is the fastest way to find a broken charter or a gate that denies something it shouldn't, before any credential exists.

--as owner treats you as that role, which is how you exercise the admin path locally.

7. Wire the channels

Per-channel app setup — scopes, tokens, what each connector can and cannot do — lives in Slack, Telegram and Email. Read the one you need; the credentials land in the environment either way.

export WORK_SLACK_BOT_TOKEN=xoxb-...
export WORK_SLACK_APP_TOKEN=xapp-...
export FAMILY_TELEGRAM_TOKEN=123:AA...
bun packages/cli/src/index.ts serve -d .

Credentials are referenced, never written. ${VAR} in the manifest names an environment variable; a literal token there is a load-time error, because the manifest is a tracked file.

With a variable unset, serve says exactly which:

openstation serve: every declared connection is unusable — connection "work-slack" is declared
but WORK_SLACK_BOT_TOKEN, WORK_SLACK_APP_TOKEN is not set; connection "family-telegram" is
declared but FAMILY_TELEGRAM_TOKEN is not set

One usable connection is enough to start; the others become named boot warnings, since silence would look like a connector that started. Nothing usable at all is an error, because a daemon listening to nothing looks healthy.

Without a connections: block

Omit it and connectors come from credential presence in the environment — SLACK_BOT_TOKEN + SLACK_APP_TOKEN, TELEGRAM_BOT_TOKEN, the five EMAIL_* variables — one instance per type. That is what every manifest predating the block does, and it still works.

Declare the block when you need two bots of one type (a family bot and a pilot bot; a work Slack and a customer Slack). That used to require two processes, since detection keyed off one well-known variable per type.

One caveat on connection names

A message reports its connector as its type (slack, telegram), not the connection name, so a connector: selector matches the type — connector: slack covers both Slack connections. Naming one instance in a selector needs per-message routing, which isn't wired. Tracked in internal/roadmap.md under "Known gaps to v1".

8. Deploy it

Deploying has the systemd unit, the shutdown semantics, and the operational gaps. What the daemon does for you beyond serving:

  • Reconnects. A connector that stops without a drain is re-served with a widening backoff (1s → 30s, reset once a connection has been up a minute). A clean drain is never restarted.
  • Warns at boot. A half-configured credential set, a Bash(<bin> …) grant whose binary is missing from PATH, an unreadable declared root, no connector at all — by name, never by value.
  • Sweeps sessions. Conversations idle over 7 days lose their session ref, at boot and daily. Fixed, not a knob: the cost of dropping one is a fresh session.
  • Runs the automation. Triggers and schedules fire on the background lane, which cannot take the permit reserved for a live conversation.

Restart=always is still worth having — it covers a crash of the process itself, which supervision inside the process cannot.

9. Check the four things worth believing

The work is a commit.

git log --oneline
git show --stat HEAD

Everything git tracks is committed except var/ and logs/. git revert HEAD undoes a turn.

Every turn left a trace.

bun packages/cli/src/index.ts events -d . -n 20
bun packages/cli/src/index.ts events -d . -t turn.TurnFinished

Chat turns publish turn.TurnStarted / turn.TurnFinished — including the silent watcher, whose whole point is that it says nothing. A refused turn is reported finished with the refusal as its stoppedReason, so "no event" means only "never became a turn". See events.

The gate held. Ask the agent to read .env in a space where it is denied. A denial is Claude's, from the settings file — the only place it can be proven.

bun packages/cli/src/index.ts logs -d . -g "Read" -n 40

The verifier is genuinely independent. Edit a file under okf/issues/, and the trigger fires the verifier under its own charter, gate and ceiling — not the charter of the agent that wrote the issue. That is what makes the check worth having, and it is the thing a single-agent process cannot give you.

Optional: the approval gate

Hold destructive or expensive turns for a human:

bridge:
  approval: { enabled: true, ttlMs: 86400000 }

A flagged turn is held, and the caller gets the reason plus approve/deny buttons — or types approve:<id> on a channel without buttons.

It cannot coexist with a silent: space. Declaring both is refused at boot:

silent: true cannot be combined with the approval gate — a held turn asks the channel for a
decision that a silent space can never deliver. Drop silent: for that space, or run this agent
without approval

So the manifest in step 3 has no approval: block: it has a silent watcher. Choose one per workspace, or split them across two. Details and the built-in predicate list: permissions.

What this does not give you

Read before you build on this for the full list. The two that shape a deployment:

  • One agent serves one process. channels: is validated and stored, but agent selection resolves once at startup — so the verifier above runs from triggers, not from a channel of its own. Two agents answering two channels means two processes with different -a values.
  • An untagged follow-up in a thread gets silence under trigger: mention. There is no "engaged" setting between mention and every-message; the conversation record that would add one is a recorded, accepted regression for v1.
View Markdown source on GitHub ↗