openstation

Core Concepts

Spaces and channels

Choose where an agent answers and which policies apply.

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
Email 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

View Markdown source on GitHub ↗