A space is a place a conversation happens: a Slack channel, a Telegram group, a DM, an email
thread. A connector is the transport that carries it. Configuring an agent's reach means
saying which spaces it answers in, and under what rules — the whole of it lives in one
channels: list.
A DM is a space type, not a special case
The most useful thing to internalize: OpenStation has no separate concept of a direct message. A
DM is a space whose type is dm, borrowed from buzz.
channels:
- type: dm # every DM, on every connector
roles: [admin]
This matters because the alternative — a dm: block beside channels: — needs answers to
awkward questions: which agent owns it when there are several, how it interacts with a channel
list, what to do when only Slack DMs should be restricted. Treating a DM as a type dissolves all
three. One list describes every space, and nothing in it lacks an id to key on.
Selectors
Each entry says which spaces it applies to. A field you omit is not a constraint, so the fewer you name, the wider it reaches:
| Selector | Matches | Specificity |
|---|---|---|
id |
one exact space | 4 (highest) |
connector + type |
Slack DMs but not Telegram DMs | 3 |
type |
dm or channel, anywhere |
2 |
connector |
everything arriving over one connector | 1 |
The list is sorted most-specific-first at load, and the first match wins — so you write entries in whatever order reads best and the loader orders them. Two entries with the same selector are a load error, because nothing could choose between them.
A bare string is shorthand for id:, so the two forms mix freely:
channels:
- C_GENERAL # same as { id: C_GENERAL }
- id: C0BKYEJ6P7T
name: auto-support-test # a label, not a selector
- type: dm
roles: [admin]
name: is there because a Slack id is unreadable, and an unreadable id is a file nobody can
review. It is a comment with a place to live: not in the table above, not part of matching, and
free to go stale when the channel is renamed. Matching a name would be worse than useless —
no connector reports one per message, so the entry would match nothing at all.
To see the whole list as configured — every entry grouped under the connection that carries it,
labels included — run show. show --live compares it
against what the bot can actually see: a channel it was removed from, one nobody declared, or a
label that went stale after a rename.
What the connector can tell you
Policy can only act on what the transport reports, and the transports differ:
| Connector | Reports a DM? | Reports a mention? |
|---|---|---|
| Slack | yes (D… channel ids) |
yes, once it knows its own user id |
| Telegram | yes (private chats) | yes, given the bot's @username |
| no | no | |
REPL (dev) |
yes | yes — one person, one conversation |
Email is the honest outlier. There is no tagging, and "private" isn't decidable from the To:
header alone, since cc and bcc never reach you. So an email agent cannot use trigger: mention —
it would answer nothing at all — which is exactly why trigger: defaults to every-message.
The same reasoning applies to a Slack bot whose transport hasn't resolved its own user id: it cannot tell its mention from anyone's, so it treats every message as unaddressed rather than guessing. Absence of evidence is never read as evidence in this direction.
Six questions, six fields
An entry is a selector plus the policy for whatever it matches. The fields are deliberately independent — none of them implies another:
| Field | Question |
|---|---|
trigger |
does a turn happen at all? |
roles |
may this caller cause one here? |
profile |
how does the agent behave — charter, tool ceiling, model? |
claudeSettings |
what is it permitted to do? |
budget |
what may that turn spend? |
silent |
does anything it says reach the channel? |
And one question with no field at all: is there anything worth saying? That one belongs to
the agent's charter. There is no respond: when-relevant setting that answers it on the
charter's behalf, because whether a message deserves a reply is a judgement, and judgement is
what the agent is for. When a turn runs on a message nobody addressed, the platform states the
fact — [not addressed directly] — and leaves the conclusion open.
Full field reference: openstation.yaml.
Why they're separate
It's tempting to collapse these. Most of them look like "how privileged is this conversation". They came apart because collapsing them leaks capability.
The concrete case, from a real support bot: admins may reconfigure the agent over DM. Attach
that power to the person and it follows them into #general, where a stray message now runs
under admin authority. Attach it to the space and it cannot — the DM is where the power lives,
and the same admin in a public channel gets the ordinary gate.
That is why profile (how it behaves) and claudeSettings (what it may do) are two fields
rather than one. A space can widen authority without changing how the agent sounds; a person can
be given a different voice without a wider gate. When those were a single setting, moving either
moved both, and the leak was one config edit away.
For the same reason a role never selects behaviour or authority. A role decides admission — who may cause a turn here — and stops there.
Membership is not routing, yet
channels: is validated, stored, and used for policy. It does not currently route messages
to different agents: agent selection resolves once at startup, so one process serves one agent
no matter what the manifest claims. Two agents in one manifest means two serve processes
today.
Policy still works exactly as written — the entries are matched per message against the space it
arrived in. It is only the which agent answers step that is fixed at boot. Tracked in
internal/roadmap.md under "Known gaps to v1".
Where to go next
openstation.yamlreference — every field and every load-time error- Permissions — which of these axes is a security boundary (one)
- Connectors — putting an agent on a real transport