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 where —
roles: [owner]on the DM entry resolves through this. - Who is speaking —
nameis 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 fromPATH, 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 theverifierabove runs from triggers, not from a channel of its own. Two agents answering two channels means two processes with different-avalues. - An untagged follow-up in a thread gets silence under
trigger: mention. There is no "engaged" setting betweenmentionandevery-message; the conversation record that would add one is a recorded, accepted regression for v1.