The roster, at .openstation/people.yaml. It maps a channel handle — a Telegram user id, a Slack
member id, an email address — to a person, their role, and optionally a profile.
The two do different jobs, and keeping them apart is deliberate:
rolesays who they are. It decides admission — achannels:entry withroles: [admin]admits only those — and nothing else.profilesays how the agent behaves for that person — charter, tool ceiling, model. It carries no authority; that isclaudeSettings, which resolves separately. Assigning one is an explicit act, and it then follows them into every space.
A role never selects a profile. If it did, anyone holding a privileged role would carry its capabilities into every room they are in.
The file is optional. Without it, identity is passthrough: every caller in the process is the
one role the daemon started with (--as, default member). With it, each caller gets their
own.
The scaffold doesn't write this file. openstation members add <id> creates it on first use and
appends to it after — see members add below.
Shape
defaultRole: member # optional; omit to refuse contacts not listed
members: # optional, default []
- id: leon # required: internal, channel-independent id
name: Leon # optional: display name
role: owner # required: admission only
profile: owner # optional: -> .openstation/profiles/owner.md, follows them everywhere
claudeSettings: owner-gate # optional: -> .claude/settings.owner-gate.json, also follows them
contacts: # optional, default {}; empty until a handle is added
telegram: "123456789"
slack: "U01234567"
- id: dad
role: member
contacts:
telegram: ["456", "789"] # one person, several handles on a channel
- id: alice # add member's own output: no contacts yet
role: owner
Fields
| Field | Required | Meaning |
|---|---|---|
defaultRole |
no | Role for a contact not in members. Omit it and unknown contacts are refused — the agent answers "You're not authorized to use this agent." and no turn runs |
members[].id |
yes | Internal id, stable across channels. What audit attribution carries |
members[].name |
no | Display name, passed through as the principal's displayName |
members[].role |
yes | Who they are. Drives admission (channels[].roles:) and the advisory grant. Must match ^[A-Za-z0-9][A-Za-z0-9_-]*$, and — once the workspace has a roles.yaml — must be declared there with scope: person or both |
members[].profile |
no | Profile they always run, overriding the space's. Omit and they get the space's profile, or the agent's default |
members[].claudeSettings |
no | Settings label they always run under, overriding the space's. Its own axis — a person can be given a wider gate without a different voice |
members[].contacts |
no | channel: handle or channel: [handle, …]. Defaults to {} — a member with none simply resolves nothing until you add one by hand. Handles are matched verbatim — never trimmed, never lowercased |
Channel keys are the connector names: telegram, slack, email, repl.
Creating and extending the file: members add
bun packages/cli/src/index.ts members add alice -d . --name Alice --role owner
added member "alice" to .openstation/people.yaml
First call writes the stub — the header comment and commented example above, plus
defaultRole: member — then adds the entry; later calls append to the existing file, comments
and formatting intact. --role defaults to the roster's own defaultRole. Either way the role
needs a .openstation/profiles/<role>.md; members add scaffolds one if it's missing, the same
rule workspace create follows for -r/--role. A duplicate id, or an id/role that isn't a safe
filename, is refused before anything is written.
The member it adds has no contacts: — wire up a channel handle by hand afterward, the same as
hand-editing the file directly.
A named profile must exist
A profile: with no .openstation/profiles/<name>.md is a refusal, not a downgrade — the
caller sees "This agent isn't configured for your role yet." The same holds for a profile a
channels: entry names, and for the agent's own default. See
permissions.
Adding role: owner needs no file: a role is not a profile.
Load-time errors
The file is validated at boot; an invalid roster stops the daemon rather than quietly granting the wrong role.
Two members claiming one handle — resolution would depend on file order:
openstation people <workspace>/.openstation/people.yaml is invalid: members.1.contacts.telegram:
handle "123" on channel "telegram" is already claimed by member "leon"
A duplicate id and a role that isn't a safe filename are refused the same way. A member
with no contacts: is valid — it just resolves nothing until one is added. A missing file is
not an error — it means no roster is declared.
A role absent from roles.yaml is refused too, in a workspace that has one. Until
2026-09-02 a role here was checked for name pattern only, so role: admni loaded cleanly and
then silently dropped that person to defaultRole, admitting them nowhere.
What this is not
Not a tool allow-list here: this file assigns roles, it does not define them. What a role
grants lives in roles.yaml, and the enforced gate is still the agent's
.claude/settings.<label>.json — now compiled from that roster rather than hand-authored. Not identity federation either — one person's
handles are linked here by hand, per workspace; there is no cross-channel directory.