The management overlay, at .openstation/openstation.yaml. It declares which agents exist and
references the Claude-native files that hold their behavior. It never restates a prompt,
model, or tool list — attempting to is a load-time error, not a silent override.
.openstation/ and .claude/ are siblings at the workspace root. A manifest inside
.openstation/ anchors one level up, and every .claude/ path resolves against that root.
Shape
agents:
<name>:
identity: agent:<name> # required
executor: claude-cli # required: an `executors:` name, or a bare kind
claudeSettings: <label> # required: -> .claude/settings.<label>.json
channels: [] # optional, default []; a bare id or a selector entry
trigger: every-message # optional, default every-message: mention|every-message|none
silent: false # optional, default false: true never posts anything
budget: # optional; no ceiling unless declared
maxUsd: 0.50
profile: <name> # optional: default behaviour, -> .openstation/profiles/<name>.md
roles: [] # optional, default []: -> .openstation/roles.yaml
exempt: {} # optional: required roles this agent lacks, each with a reason
allow: [] # optional: one-off rules for this agent alone
deny: [] # optional
default: false # optional, default false
triggers: # optional, default []
- on: workspace.FileChanged # required: event type, group ("workspace"), "workspace.*", or "*"
agent: <name> # required: must be declared above
path: okf/scratch/inbox # optional: only paths at or under this prefix
ext: [".jpg", ".png"] # optional: only these extensions
artifactType: Issue # optional: only artifacts declaring this frontmatter type:
change: created # optional: only this change kind: created|modified|deleted
skipUnchangedContent: false # optional, default false
retry: { max: 2 } # optional: extra attempts after a failed firing
prompt: Scan the new photos # optional: else the charter says what a trigger means
deliver: # optional: post the outcome to the artifact's origin
to: conversation
agent: <name> # optional: a composer writes the post; omit for verbatim
schedules: # optional, default []
- id: nightly-scan # required: unique; also the job's session scope
agent: <name> # required: must be declared above
everyMs: 86400000 # required: positive interval, in milliseconds
prompt: Run the daily scan # optional
deliver: # optional: post the run's reply to a conversation
to: conversation
conversation: "telegram:-1001234567890" # required here — nothing to infer it from
automations: # optional, default []
- path: okf/notes/loops # required: workspace-relative folder, no absolute/".."
type: Loop # required: only files declaring this frontmatter type load
agents: [improver] # required, min 1: allowlist, each must be declared above
maxPerHour: 12 # optional: ceiling — a file's every: may be slower, never faster
approval: # optional; omit for no gate
enabled: true # optional, default true — declaring the block is the opt-in
ttlMs: 86400000 # optional, default 24h
connections: # optional; omit for env detection
<name>:
type: telegram # required: slack | telegram | email
token: ${VAR} # required per type: credentials by reference only
enabled: true # optional, default true
ack: typing # optional, per type: how receipt is acknowledged
env: # optional; omit for dotenv over <workspaceRoot>/.env
provider: dotenv # required: dotenv | dotenvx | aws-secrets
path: .env # dotenv/dotenvx only, optional: workspace-relative or absolute
bridge: # optional
attachments:
max: 30 # optional: most files one reply may deliver
reset: "/reset" # optional; omit and no reset command exists
coalesce: # optional; omit and every message is its own turn
windowMs: 2000 # required: quiet period that ends a burst
inbound: # optional, default []: deterministic stages, in order
- type: command # required: the only kind
run: openbook ingest --stage # required: tokenized without a shell
on: attachments # optional, default message: message|attachments
roles: [member, owner] # optional: only these roles
prefix: ["/", "ob:"] # optional: only text starting with one of these
timeoutMs: 120000 # optional, default 30000
approval: { enabled: true } # optional: the same gate as root-level `approval:`
workspace: # optional
external: # roots outside the repo the agent may read and send from
- ~/openbook/store
disabled: false # optional, default false: true means `serve` refuses this workspace
At least one agent is required. triggers:, schedules:, and automations: are read and wired
by serve; dev runs no automation.
Six things here can start a turn, and they are not in one place: an agent's own trigger:,
its channels: entries, default:, plus the triggers:, schedules: and automations: blocks.
openstation <workspace> triggers joins all six into one list, and triggers topology draws them
as a graph — see the CLI reference. Reach for it before reading these blocks
against each other by hand; "which agents can a person reach" in particular is a question this
file cannot answer in one place and that command answers in one line.
A complete, live-verified manifest — from examples/notes-agent:
agents:
notes:
identity: agent:notes
executor: claude-cli
claudeSettings: notes # -> .claude/settings.notes.json (the ENFORCED tool gate)
channels: [] # add a channel name to route it there
default: true # answers anything no other agent claims
Fields
| Field | Required | Default | Meaning |
|---|---|---|---|
identity |
yes | — | Actor string for audit attribution and autonomous (trigger) runs. Any non-empty string; agent:<name> by convention |
executor |
yes | — | Agent runtime: an instance declared in executors:, or a bare kind (claude-cli, claude-sdk, or experimental pi-sdk) |
claudeSettings |
yes | — | Label resolving to .claude/settings.<label>.json — the enforced tool gate. Must match ^[A-Za-z0-9][A-Za-z0-9_-]*$ |
channels |
no | [] |
Channel membership, each entry a bare id or a policy entry. Validated and stored; see the routing caveat below |
trigger |
no | every-message |
What makes a message start a turn, for a space with no entry of its own |
silent |
no | false |
true means nothing this agent says reaches any channel |
budget |
no | — | Per-turn limit: maxTurns on SDK executors; maxUsd is a Claude-native ceiling and a post-request Pi threshold |
profile |
no | — | Default profile for spaces naming none. Omit and the daemon's --as role names it |
roles |
no | [] |
Roles composing this agent's gate. Names index roles.yaml; each must be declared there with scope: agent or both |
exempt |
no | — | { <role>: "<reason>" } — required roles this agent legitimately lacks. A reason is mandatory |
allow / deny |
no | [] |
One-off rules for this agent alone, composed after every role's |
default |
no | false |
The fallback agent when no channel matches. At most one per file |
Agent names become path segments (.claude/agents/<name>.md), so they obey the same
pattern as settings labels: ^[A-Za-z0-9][A-Za-z0-9_-]*$.
roles: names rules; it does not enforce them. What an agent may do is still decided by
the settings file claudeSettings: names, which Claude Code itself enforces. What changed is
where that file comes from: openstation <ws> gates write compiles it from
roles.yaml, and gates check refuses a boot whose gate file has drifted
from the roster. The platform's own ceiling-intersect-grant remains an advisory pre-filter,
never the gate.
Every role named must exist in the roster and be scoped for its slot, every required: true
role must be held or exempted with a reason, and an exemption must name a role that really is
required — a typo there otherwise reads as protection while granting nothing. See
roles.yaml for the full list of load-time refusals.
permissions: was removed on 2026-09-02. It was documentation only and never a gate, so a
manifest declaring it no longer loads — the schema is strict. Delete the key; use roles:.
channels: doesn't route messages yet. Agent selection resolves once at startup and no
channel is passed per message, so one process serves one agent no matter what the manifest
claims. The field is parsed, validated for conflicts, and stored — it just isn't consulted per
turn. Tracked in internal/roadmap.md under "Known gaps to v1".
Conversation policy — five axes
The narrative version of this section, with the reasoning behind the split, is spaces and channels.
A channels: entry is a selector plus the policy for whatever it matches. A bare string is
shorthand for id:, so both forms can appear in one list:
channels:
- C_GENERAL # bare id: answer here under the agent's own defaults
- id: slack:C_PRODUCT
name: product # optional label — for the reader, never matched
trigger: every-message # run a turn on everything said here
silent: true # and never post — this agent files issues instead
profile: watcher
budget: { maxUsd: 0.20 }
- id: slack:C_HELP
trigger: mention # only when tagged
roles: [member, admin] # and only from these roles
claudeSettings: support-ro # under a different gate than the agent's default
- type: dm # every DM, on any connector
roles: [admin]
claudeSettings: support-admin
- connector: telegram # everything arriving over one connector
trigger: none
Selecting spaces
| Selector | Matches | Specificity |
|---|---|---|
id |
one exact space | 4 (highest) |
connector + type |
e.g. Slack DMs but not Telegram DMs | 3 |
type |
dm or channel, on any connector |
2 |
connector |
everything on one connector | 1 |
| (no entry matches) | falls through to the agent's own fields | — |
A DM is a space type, not a special block. That's borrowed from
buzz, where a DM is a channel whose type is dm — which is what
lets one list describe every space with no entry that has no id to key on.
The first matching entry wins, most specific first. Two entries with the same selector are a load
error, since nothing would choose between them. Only an id: entry is a routing claim — so
two agents may both describe DM policy without conflicting, while two agents claiming one id
cannot.
name: is not in that table on purpose. An entry may carry one to say which room C0BKYEJ6P7T is,
but it is a label for whoever reads the file: no connector reports a channel name per message, and a
channel can be renamed without this file hearing about it. So it never affects matching, never
counts toward specificity, and two entries differing only by name are still the same selector.
An entry with a name and no selector is a load error rather than a rule that quietly matches
nothing.
connector is the name the inbound message reports: slack, telegram, email, repl. Ids are
not namespaced automatically, so wherever two connectors could produce the same id, either qualify
it in the string (slack:C_HELP) or pair id: with connector: — one id on two connectors is two
selectors, not a duplicate.
connector: names the connector type, never a connection name — and a wrong value there
matches nothing, silently. connector: telegram is a selector; connector: family-telegram,
naming a connections: entry, is accepted by the loader
and can never match, because every normalizer sets the message's connector to its type. The
value is a free string with no closed set to validate against, so nothing warns. Two bots of
one type therefore share every selector, every conversation key and every contact — see
what a connection name is not, yet.
The five axes
Five separate questions, one field each. None of them implies another — that is the whole design.
| Field | Question | Decided by | Resolution |
|---|---|---|---|
trigger |
does a turn happen at all? | the platform, before Claude runs | space > agent |
roles |
may this caller cause one here? | the platform | space only |
profile |
how does the agent behave — charter, tool ceiling, model? | you, in .openstation/profiles/<name>.md |
person > space > agent |
claudeSettings |
what is it permitted to do? | Claude, enforcing the settings file | person > space > agent |
budget |
what may the turn spend? | the executor | space > agent |
| (none) | is there anything worth saying? | the agent, in its charter | — |
silent |
does anything it says reach the channel? | the platform, after the turn | space > agent |
The row with no field is the important one. Whether a message deserves a reply is a judgement,
and judgement belongs to the agent — so there is no respond: when-relevant mode that decides it
on the charter's behalf.
trigger: values
| Value | A turn runs | Cost when it doesn't |
|---|---|---|
mention |
when the agent is tagged, or in a DM | nothing |
every-message |
always | — pays a Claude run per message |
none |
never | nothing |
mention covers DMs because nobody tags anyone in a DM; requiring a tag there would mean never
answering one.
A connector that cannot report addressing. Email has no tagging, and "private" isn't decidable
from to: alone, so it reports neither. mention therefore never fires on email — which is why
the default is every-message rather than the cheaper mention. Set mention where the
connector supports it (Slack, Telegram), and scope every-message with a budget:.
What an unaddressed turn is told. When a turn runs on a message that did not address the agent, the prompt is prefixed with a bare marker:
[not addressed directly]
lunch is at 1 today, same place as last week
A fact, not an instruction. What to conclude from it is the charter's job — one agent should stay
quiet, another should file an issue, and a prompt that decided for them would remove the judgement
every-message was chosen to buy. A message that does address the agent carries no marker.
silent: — never speaking
silent: true runs the turn and posts nothing: no text, no attachments, and no refusal copy
either. It is enforced by the platform, so a charter that ignores its instructions still cannot
post. Use it for an agent whose output is workspace state — an observer that reads a channel and
files issues.
Two consequences worth knowing:
- A silent space cannot refuse out loud. A role refusal is a reply, so it is suppressed too.
silent: trueand the approval gate are refused together, at startup. A held turn asks the channel for a decision that a silent space could never deliver, so the turn would wait forever.
budget: — what a turn may spend
budget:
maxUsd: 0.50 # Claude: native ceiling; Pi: observed post-request threshold
maxTurns: 8 # claude-sdk and pi-sdk; refused by claude-cli
At least one is required; an empty budget: {} is a load error, because it reads as a limit and
is none. Exceeding either ends the turn and the caller is told it hit a cost limit — not that
something went wrong, which would send them to retry the same expensive request.
maxTurns on a claude-cli executor fails at load. The claude CLI has --max-budget-usd
and no turn cap, so honouring it is impossible; a ceiling the operator believes applies but
doesn't is worse than none. The check reads the kind behind the name, so binding to an
instance does not slip a cap past it:
agents.triage.budget.maxTurns cannot be enforced by executor "patient" (claude-cli)
maxUsd is softer on executor: pi-sdk. Pi reports cost after each model request. The
adapter accumulates that observed cost and, once the threshold is reached, prevents the next
request in a tool-use loop. The request that crosses the threshold can overshoot it; a final answer
that crosses it is returned because there is no next request to stop. Use maxTurns or a provider
proxy when a hard bound matters.
There is no default — an unbudgeted agent runs under whatever the executor's own limits are, and
The scaffold doesn't write one; see getting started for a
starting maxUsd: 0.50 / maxTurns: 8 to add by hand. For scale: measured turns in a small
workspace ran $0.02–$0.18 each, and a turn that reads a channel and decides to say nothing is
barely cheaper than one that answers — most of the cost is the system prompt, not the work.
Which profile a turn runs under
A profile is behaviour only: charter, tool ceiling, model. Selection, most specific first:
| Chosen from | Meaning |
|---|---|
the person's profile: in people.yaml |
assigned to them deliberately, so it follows them everywhere |
the matching channels: entry's profile: |
everyone admitted to that space runs it |
the agent's profile: |
the default for spaces naming none |
A profile carries no authority. Writing claudeSettings: in a profile's frontmatter is a load
error, naming where authority does go. The two axes resolve independently so that a space can widen
what may be done without changing how the agent sounds, or vice versa.
A role never selects a profile. That is the point of the split: a role says who you are and
decides admission (roles:), while a profile says how the agent behaves. If a role picked the
profile, anyone holding a privileged role would carry its behaviour into every room they happen to
be in — which is exactly what hn-agent forbids. So an admin-only power goes on the DM space,
and an owner-only power that genuinely should travel goes on the person.
Which gate a turn runs under
claudeSettings: resolves separately from the profile, with the same shape:
| Chosen from | Meaning |
|---|---|
the person's claudeSettings: in people.yaml |
assigned to them deliberately |
the matching channels: entry's claudeSettings: |
everyone admitted to that space runs under it |
the agent's claudeSettings: |
the default, and the one field that is required |
Why it's called claudeSettings
Because that is what it points at. .claude/settings.<label>.json carries permissions and
hooks and env — a PreToolUse hook in there is a stronger boundary than any allow list — so
naming the field gate: or permissions: would misdescribe what pointing at it grants. The
label is a pointer to a Claude-native file, and the name says so.
channels:
- type: dm
roles: [admin] # authorization: only admins are in this space
claudeSettings: support-admin # authority: what happens in it, for anyone who is
Those two lines together are how "admins can configure the agent" is expressed. The escalation
can't follow that person into #general, because it is attached to the DM and not to them —
which is the rule hn-agent states outright: "Channel mentions never get admin settings even
for admins." Want a power scoped to a person instead? Give them a space only they are admitted
to, or put the label on them in people.yaml if it genuinely should travel.
Restricting DMs to one connector
Because type and connector compose, "admins only in Slack DMs, and ignore Telegram entirely"
is two entries:
channels:
- connector: slack
type: dm
roles: [admin] # only admins may DM on Slack
claudeSettings: support-admin # and those DMs run under a more privileged gate
- connector: telegram
trigger: none
The Slack DM entry needs no trigger: — the agent's own mention already covers a DM.
What a refusal looks like
The two kinds of refusal are answered differently, on purpose:
| Refused because | The caller gets |
|---|---|
addressing — trigger: none, or mention with no tag and no DM |
nothing at all. No message is posted |
authorization — their role isn't in roles: |
"I can't take requests from you in this conversation." |
Silence is the right answer to "this wasn't for me": a watched channel that replied to every
message would be noisier than one that just answered. A role refusal is the opposite — the
message was for the agent and it's declining, which is worth saying. In a silent: true space
both are silent, since a refusal is a reply.
Either way no turn runs, so a refusal costs nothing. every-message is the exception: the turn
runs whatever happens, which is what budget: is for.
Design and remaining work:
plans/2026-07-25-conversation-policy-design.md.
triggers: — run on an event
| Field | Required | Default | Meaning |
|---|---|---|---|
on |
yes | — | Event match: exact type (workspace.FileChanged), a group (workspace), workspace.*, or * |
agent |
yes | — | Agent that runs. Must be declared in agents:, or the manifest fails to load |
path |
no | — | Workspace-relative prefix; only paths at or under it fire. A pathless event never matches |
ext |
no | — | Extensions that fire, matched case-insensitively |
artifactType |
no | — | Artifact kinds that fire — the type: in the file's frontmatter. One value bare or a list; matched case-insensitively. An artifact with no declared type never matches |
change |
no | — | The kind of file change that fires: created, modified, or deleted. Omit to fire on any |
skipUnchangedContent |
no | false |
Loop guard: skip when the changed file's content hash is what it was at the last fire |
retry |
no | — | { max: N } — re-run a failed firing up to N extra times |
prompt |
no | — | The instruction the fired run carries. Without it the run gets a generic "something matched, act per your charter" |
deliver |
no | — | Post the run's outcome back to the conversation named by the artifact's origin: frontmatter. to: conversation, plus an optional composer agent: |
Only the first matching rule fires per event, and fired runs use the background lane, so they can never take the permit reserved for a live conversation.
artifactType: — fire on what changed, not only where
A path + ext pair says "a markdown file under okf/issues/", which is also true of the
store's own index.md and log.md. Every turn that files an issue writes all three, so a rule scoped
that way fires three times for one piece of work.
artifactType: scopes on the artifact's own declaration instead:
triggers:
- on: workspace.FileChanged
agent: verifier
path: okf/issues
artifactType: Issue # or a list: [Issue, Incident]
skipUnchangedContent: true
prompt: An issue changed — verify its claims.
The value is compared against the type: field of the file's YAML frontmatter, which
OKF requires on every artifact and
which an index or log file deliberately does not have. A file with no declared type never
matches — the filter fails closed, exactly like a pathless event against path:. So the
bookkeeping files stay out with nothing to exclude by name.
Filters compose: every one named must hold. artifactType needs no ext, since only markdown
can declare a type in the first place.
Two limits worth knowing. A deletion carries no type — nothing is left to read — so a rule
naming one never fires on a delete; watch for deletions with a path-only rule. And the watcher
reads a capped head of each changed markdown file (4 KB), so a type: buried below that window
is not seen. Both are recorded on the event itself.
change: — fire on one kind of change
An inbox rule wants creations, not the edits the fired run itself makes. change: created
(or modified, deleted) restricts the rule to that kind of file event. Like every filter it
fails closed: an event carrying no change kind never matches.
A worked set of these three filters — path + artifactType + change, with
skipUnchangedContent on the one rule that fires on modified — is what
openstation <ws> loops add writes: two created rules that make an artifact
store an inbox, and a modified rule for the reply half of an agent-to-agent handoff. Read the
generated block for the convention, including why each store's index.md carries no
frontmatter.
retry: — re-run a failed firing
retry: { max: 2 } # up to 3 attempts in total
A failed firing re-runs up to max extra times — immediately, with no backoff. A permission
denial never retries: it is deterministic, so a retry would re-run the same denied action.
Whatever the last attempt returned is what deliver: (below) reports.
Every attempt — first or retried — writes a job record row to trigger_runs in var/jobs.db
(rule, agent, path, attempt, status running|failed|done, error). Records are observability,
not control flow: a broken store is logged and never blocks a turn.
deliver: — post the outcome back
deliver:
to: conversation # required: the only target today
agent: <name> # optional: a composer writes the post; omit for verbatim
Closes the loop on backend work: when the fired run finishes, its outcome is posted to the
conversation named by the artifact's frontmatter. The contract is origin: — whoever
captures the artifact writes the conversation key into it:
---
type: Issue
origin: slack:C0BKYEJ6P7T:1722100000.000100
status: new
---
The key is whatever the connector minted, verbatim — slack:<channel>:<thread-ts>,
telegram:<chat-id>[:<thread-id>], email:<root-message-id>. It is opaque to the platform
except for the prefix, which is what picks the connector to post through.
- No
agent:— the run's own reply is posted verbatim, so the fired agent's charter should end its turn with a user-facing summary. agent: <name>— a composer turn writes the post, with a job record of its own (rule<on>:deliver). If it fails or returns nothing, the run's reply is posted verbatim instead — delivery is never lost to polish.- A run that failed after retries posts a failure note, so the reporter isn't left waiting.
- No
origin:in the frontmatter — a logged skip, not an error: an artifact created by hand or by a schedule has no conversation to answer.
Delivery runs after the turn's own events, and a delivery failure is logged and swallowed — it can never fail the turn.
Which connector a delivery goes out on. Posting needs a connector that can speak
unprompted: Slack, Telegram, and the REPL. With one such connector running it takes every key.
With more than one, the key's prefix picks the connector that minted it — a
telegram: key goes to Telegram even when Slack is also live. A key matching no live connector
is logged and skipped; it is never posted through whichever connector happens to be first,
which would put a family group's report into a work Slack. With none running, deliver: logs
and skips at fire time.
Two limits. A key of the pre-prefix Slack shape (<channel>:<thread-ts>, written into an
origin: before the prefix existed) matches no connector once a second one is live. And two
connections of one type share a name, so a key cannot pick between them: the first is used
and the ambiguity is logged at boot. That waits on per-message routing.
schedules: — run on an interval
| Field | Required | Default | Meaning |
|---|---|---|---|
id |
yes | — | Unique job id; also its session scope, so a job resumes its own conversation. Same name pattern as agents |
agent |
yes | — | Agent that runs. Must be declared in agents: |
everyMs |
yes | — | Positive interval in milliseconds. Interval-only — there is no cron syntax |
prompt |
no | — | The instruction each run carries |
deliver |
no | — | Post a successful run's reply to a conversation: to: conversation plus a required conversation: key |
Jobs live in var/jobs.db and survive restarts: run counts and the pending fire time are kept,
and a job left running by a crash becomes due again. First fire is one interval after startup,
never immediately. Editing everyMs (or the agent) re-arms the job on the next boot and logs
schedule "<id>" updated; an unchanged declaration is silent. prompt: and deliver: are read
from the manifest on every boot rather than stored with the job, so editing either needs no
re-arming.
deliver: — tell someone what the run found
schedules:
- id: nightly-scan
agent: openbook
everyMs: 86400000
prompt: Scan the library and report what is new.
deliver:
to: conversation
conversation: "telegram:-1001234567890"
conversation: is required, unlike a trigger's deliver:. A trigger reads its target from
the artifact's origin: frontmatter; a scheduled run has no artifact, so there is nothing to
infer one from — and a job that ran nightly and delivered nowhere is exactly the silence this
field exists to prevent. Omitting it is a load error. The value is a connector's own
conversation key, opaque to the platform except for the prefix that
routes it.
The delivery rules are the trigger dispatcher's, not new ones: post only on success with non-blank output, log every skip, and never let a delivery failure change the run's outcome or its next fire time. Three things to know:
- A failed run posts nothing. A trigger answers whoever reported the artifact, so it says the automated run failed; a nightly job answers nobody, and a 3 a.m. failure announced into a family group is noise the job record and the turn events already carry. Read those instead.
- Text only. A
```send-filesblock in a scheduled reply is delivered as literal text: lifting it needs the Bridge's confinement policy, and a scheduled run carries no roots to confine against. - Nothing warns at boot when a
deliver:is declared and no live connector can post. The scheduler says so loudly at the first fire — which for a nightly job is a day later.
No schedule runs under openstation dev. The automation half — watcher, trigger
dispatcher, scheduler — is wired only from serve, so a declared deliver: is not
exercisable in the REPL. Test it with serve against a real connector, or by driving the
scheduler directly.
automations: — markdown files as scheduled jobs
A schedules: job's instruction is a prompt string in this file, edited by the operator and
invisible to the workspace's own knowledge. automations: moves the instruction into a
markdown file — versioned, agent-readable, agent-authorable — and keeps this file to what it
should hold: policy. The manifest binds a folder and policy; each .md file inside it that
declares the binding's type: becomes one interval job, cadence and instruction included.
automations:
- path: okf/notes/loops # workspace-relative folder; any folder
type: Loop # only files declaring this frontmatter type load
agents: [improver] # allowlist: a file naming another agent is a skipped file, not a run
maxPerHour: 12 # optional ceiling: a file's `every:` may be slower, never faster
| Field | Required | Default | Meaning |
|---|---|---|---|
path |
yes | — | Workspace-relative folder scanned for loop files. No absolute path, no .. segment. Trimmed |
type |
yes | — | The frontmatter type: a file must declare to be considered by this binding — trimmed, matched exactly. A file declaring anything else (or nothing) is silently someone else's artifact |
agents |
yes | — | Allowlist of agents a loop file may name in agent:. At least one entry; each must be declared in agents:, or the manifest fails to load |
maxPerHour |
no | — | Ceiling on any one file's frequency, a positive integer. A file's every: may be slower than the ceiling implies, never faster |
One word, two things. A "loop" here is a markdown file that runs on an interval. The
eval → learn → improve loop openstation <ws> loops add scaffolds is event-driven
— agents: entries plus triggers: rules, no cadence anywhere — and shares nothing with this
block but the noun.
The binding is policy the files cannot touch: the allowlist and the ceiling are operator-owned, and a file can only narrow them, never widen. Every violation is a logged skip, never a run and never a crashed boot — these files are artifacts, possibly agent-authored, and an artifact error must not take the daemon down.
The file — one automation, one markdown file
---
type: Loop
agent: improver
every: 15m
session: fresh
enabled: true
deliver: "telegram:-1004327808608"
---
Read okf/notes/improvements/. Pick the highest-priority file with `status: open`, do the work, set
`status: done`, and append what changed. If none are open, reply "nothing open" and stop.
| Field | Required | Default | Meaning |
|---|---|---|---|
type |
yes | — | Must equal the binding's type: exactly, or this file isn't this binding's to begin with |
agent |
yes | — | Must be in the binding's agents: allowlist. Missing or disallowed is a logged skip |
every |
yes | — | A number plus one unit letter: s | m | h | d — e.g. 90s, 30m, 6h, 1d. No cron syntax, and the unit is required: a bare number is rejected rather than guessed as milliseconds or minutes |
session |
no | resume |
resume keeps resuming this job's own session, like a schedules: job. fresh drops the session ref (TurnRunner.forget) before every firing — for a job whose cost creeps turn over turn rather than one that tracks state |
enabled |
no | true |
false skips the file, logged as "disabled" — pause without deleting |
deliver |
no | — | A bare conversation key, e.g. "telegram:-1004327808608" — a successful run's reply is posted there. Unlike a schedules: job's deliver:, this is a scalar, not { to, conversation }: frontmatter here is read as flat scalars only, never with a YAML parser |
Frontmatter is read with the same flat-scalar reader the file watcher already uses for
artifactType: — deliberately no YAML parser, which is why deliver: here is a bare string
rather than the nested object schedules: uses.
The fired prompt is a pointer, not the body. Every firing carries exactly:
Scheduled run for
<path>. Read that file and execute the instructions in its body.
The body — the actual instruction — is read by the agent at run time, the same "Claude materializes context" rule as everywhere else. An edit to the body takes effect on the next firing with no re-arm and no drift between a loaded copy and the file on disk.
The job id is the filename. okf/notes/loops/base-improvements.md declares job id
base-improvements — the basename minus .md, matched against the same pattern as an agent or
settings-label name (^[A-Za-z0-9][A-Za-z0-9_-]*$). Its session scope is loop:<id>, disjoint
from a schedules: job's schedule:<id> scope. A loop id colliding with a schedules: id is
refused — logged once, not on every rescan — and the yaml job keeps the id; rename the file to
resolve it. Two loop files sharing one basename across different bound folders collide the same
way with each other: the first scanned wins the id and the rest are skipped, logged as a
duplicate.
Loading: scanned, not watched
Bound folders are scanned directly — at boot, and again every scheduler tick (tickMs, 30s by
default) — rather than riding the file watcher's hardcoded scan roots. That is what makes any
folder bindable, including a top-level one the watcher never walks, and what makes a rescan
notice a deletion (a vanished file is a vanished job). Only the first 4096 bytes of each file are
ever read, the same capped head the watcher uses for artifactType:; frontmatter left
unterminated within that window is a skip, not a misparse of a body line as a field.
Declaring or editing a file re-arms it on the very next tick — no reboot. This is the one
place automations: behaves differently from schedules:, whose everyMs/agent edits wait
for the next boot: a loop file is scanned continuously, so creating, editing, or deleting one
takes effect within tickMs. Each change is logged once:
openstation automation — loop "base-improvements" created
openstation automation — loop "base-improvements" updated
openstation automation — loop "base-improvements" removed
Every skip names a reason, once per file per reason — logged again only if the reason changes or the file relapses after recovering:
openstation automation — loop file okf/notes/loops/base-improvements.md skipped — session: must be fresh or resume, not "sometimes"
A file whose frontmatter type: doesn't match the binding's is the one exception: it is silently
someone else's artifact, never logged. Every other failure logs its reason — a bad filename, a
missing or disallowed agent:, a missing or unparseable every:, an every: faster than the
binding's maxPerHour, a bad session: value, unterminated frontmatter, or a folder/file that
can't be read.
Those lines are logged once, at the moment they change — so don't rely on catching them.
openstation <ws> automations reports the current answer instead: every
declared automation with its next run and recent record, and every skipped file with its reason,
whether or not anyone was watching the log when it was first read.
Shared machinery, and what's different
automations: rides the same infrastructure schedules: does: jobs live in the same
var/jobs.db, run on the same SchedulerRunner in the shared
background lane (never the permit a live conversation holds), and deliver: posts through the
same connector-picking rules — text only, logged and skipped
with no live connector, never blocking the run's own outcome. A run's spend is bounded the same
way too: the agent's own budget:, not a per-loop override.
What differs is everything about where the job comes from: the id is the filename instead of a
declared id:, the prompt is always the same pointer instead of an operator-written string, hot
reload happens on the next tick instead of the next boot, and session: fresh exists as a
per-file choice schedules: has no equivalent for.
Only serve runs automations — dev does not, same as schedules: and triggers:.
Ownership: who may write where
What scopes an agent to okf/ is the write gate — the scaffolded .claude/settings.<label>.json
grants Edit/Write on the whole bundle (WRITABLE_ENTRIES), and Claude enforces it. It is not
the after-turn commit: the commit stages everything git status reports minus the system plane
(var/, logs/), so a change under any other folder — .claude/**, a stray root file, a
top-level loops/ — is committed too, however it got there. So an operator-owned loop folder
can live anywhere in the workspace — top-level loops/ works, and an edit made there (by the
operator, or by any process with filesystem access outside the gate) would be swept into the next
turn's commit like anything else. An agent-authorable loop folder — one an agent is meant to
create or update its own jobs under, from inside a turn — has to live under okf/ (e.g.
okf/loops/), because that is the bundle the write gate grants; the folder being committed was
never the constraint. The binding's allowlist and maxPerHour ceiling are what keep an
agent-authored schedule from being a privilege escalation.
An earlier version of the write gate scoped only notes/, one name among several the artifact
plane recognized, so a declared folder outside it needed the gate widened by hand — filed as an
improvement rather than built. Collapsing the writable zone to the single okf/ bundle closed
that: any folder the agent grows underneath is already granted, with nothing left to widen. See
plans/2026-08-06-okf-bundle-root-design.md.
Not a channel. A bound folder is channel-shaped — something arrives, an agent acts, a reply
may go out — but deliberately rides the background lane rather than the Bridge: nobody waits on a
loop firing, and routing it through the Bridge would let background work starve a live
conversation. A files connector (a drop folder where each new file is a message from its
origin:) is a related but distinct, still-deferred idea — see
architecture/09-extensions.md. Full design and
reasoning: plans/2026-08-05-markdown-automations-design.md.
approval: — hold a turn for a human
Workspace-wide, not per-agent: one held-turn store, one predicate list. A flagged turn is
held instead of run, and the caller gets the reason plus approve/deny buttons. The decision
comes back as an ordinary inbound message, so it works on a connector with no buttons too —
the user types approve:<id>.
approval:
enabled: true
| Field | Required | Default | Meaning |
|---|---|---|---|
enabled |
no | true |
Declaring the block is the opt-in; false is an off switch that keeps the block as documentation |
ttlMs |
no | 24h | How long a held turn stays decidable. After that it expires and cannot authorize its turn |
Held turns live in var/approvals.db. While one is pending for a conversation, further
messages in it get "That's still waiting on approval — decide on it before sending more."
What fires it. A built-in predicate list — Terraform, Ansible, SSH, rm , cleanup,
clear, .env, train, scan — matched against the prompt text and the turn's granted
tools. You cannot declare your own predicates in YAML. They are functions over a
resolved turn, and a dialect that could express them would be a policy DSL; a custom list
still means embedding OpenStation and calling wire({ approval: { predicates } }). Writing
predicates: in the manifest is a load error rather than a silently ignored key.
Two limits worth knowing. The Bridge resolves a turn before Claude picks a tool, so a
predicate sees only the text the user sent and the static grant — it catches an explicit
destructive ask, not the specific command Claude is about to run. For that, use a
PreToolUse hook in the settings file. And approval: with silent: true anywhere is
refused at boot: a held turn asks the channel a question a silent space could never deliver.
connections: — connectors by name
Names the connector instances this space serves, with credentials by reference. Two bots of one type are two entries, in one process — which used to require two processes.
connections:
work-slack:
type: slack
botToken: ${WORK_SLACK_BOT_TOKEN}
appToken: ${WORK_SLACK_APP_TOKEN}
family-telegram:
type: telegram
token: ${FAMILY_TELEGRAM_TOKEN}
openbook-pilot:
type: telegram
token: ${PILOT_TELEGRAM_TOKEN}
ack: typing # native "typing…" for as long as the turn runs
enabled: false # declared, switched off
| Field | Required | Default | Meaning |
|---|---|---|---|
type |
yes | — | slack | telegram | email. Unknown types are a load error |
enabled |
no | true |
false keeps the entry as documentation without starting it |
ack |
no | per type | How receipt is acknowledged — see below. Slack: an emoji name, default eyes. Telegram: typing | none, default none. Email: not accepted |
| credentials | yes | — | Per type: Slack botToken + appToken; Telegram token; Email imapHost, smtpHost, user, pass, from (+ optional imapPort, smtpPort) |
ack: is the receipt indicator, shown the moment the Bridge admits a message as a turn —
so a user knows the agent heard them before it has anything to say. It is declared per
connection rather than per agent because acknowledging receipt is a transport affordance, not
policy: a connection is one transport instance.
Its legal values differ by type, because the transports differ:
| Type | Values | Default | Notes |
|---|---|---|---|
slack |
an emoji name, or none |
eyes |
Bare name, no colons (eyes, not :eyes:). Needs the reactions:write scope |
telegram |
typing | none |
none |
Telegram expires a chat action after ~5s, so the indicator is re-sent every 4s until the turn resolves |
email |
— | — | Declaring ack on an email connection is a load error: it has neither reactions nor typing |
A refused message is never acked — no reaction, no typing — because the indicator means "I will answer this", and a message the platform declines must leave no trace. An ack that fails is logged and the turn continues: an emoji is never allowed to decide whether an answer gets sent.
Two caveats worth knowing before you pick one:
- With
bridge.coalesce, a burst is answered on its last message, so a Slack reaction lands on that one message rather than on each. Telegram'stypingis not per-message and reads correctly for the whole burst. - An emoji named
noneis unreachable on Slack, the one cost of a flat field.
Every credential must be a ${VAR} reference. A literal is a load error: this file is
tracked, and a token in it is a leak. The loader resolves variable names only — presence is
checked at boot, so a manifest with a connection whose variable is unset still loads.
No block means today's behaviour, unchanged: connectors come from credential presence in
the environment (SLACK_BOT_TOKEN + SLACK_APP_TOKEN, TELEGRAM_BOT_TOKEN, the five EMAIL_*
variables), each instance named after its type. Declare the block and it is the whole truth —
ambient credentials add nothing.
Missing credentials warn; nothing usable is a boot error. A declared connection whose
variable is unset is named in a boot warning, since silence would look like a connector that
started. If no declared connection can start, serve refuses rather than sitting there
listening to nothing.
What a connection name is not, yet. A message still reports its connector as its type
(slack, telegram), not the connection name, so a connector: policy selector matches the
type. Naming a single instance in a selector needs per-message routing — tracked in
internal/roadmap.md under "Known gaps to v1".
executors: — runtimes by name
Names the executor instances agents bind to. Two instances of one kind that differ — a verifier on a long timeout beside a support agent on the default — is what the block is for; a kind with no knobs is still worth naming, because the name is what an agent binds to.
executors:
main:
type: claude-cli
patient:
type: claude-cli
timeoutMs: 1800000 # 30 minutes, for turns that read a lot
claudeBin: /opt/homebrew/bin/claude
capped:
type: claude-sdk # the kind that can enforce budget.maxTurns
agents:
support:
executor: main
triage:
executor: capped
budget: { maxTurns: 8 }
verifier:
executor: patient
| Field | Required | Default | Meaning |
|---|---|---|---|
type |
yes | — | claude-cli | claude-sdk. Unknown types are a load error |
resources |
no | claude |
Which agent files this instance reads. claude is the only supported value today |
timeoutMs |
no | 900000 |
claude-cli only: wall-clock ceiling for one run |
claudeBin |
no | claude on PATH |
claude-cli only: which binary to spawn |
resources: names the files, type: names the runtime. The runtime is what executes a turn;
the resource set is whose conventions the agent's files follow — the charter at
.claude/agents/<name>.md, .claude/skills/, and the settings that gate the turn. Two axes,
because one runtime can read another's files by translating them.
Only claude is supported, and an unsupported value is a load error rather than a silent
default. A second set is not a manifest string but a set of answers — where its files live, what
plays the part of the charter, and above all whether it carries a declarative deny-by-default
gate the platform can point at rather than compile. Offering the value before those are answered
would let this file claim support that does not exist.
What an instance reads is checked before boot. serve and dev cross each agent's declared
resources against what its bound instance can actually honour, and refuse rather than start when a
runtime cannot enforce something the files declare — running wider than declared is not a warning.
A resource it merely does not honour is a warning: the agent is narrower for it. See
internal/plans/2026-08-01-agent-resources-design.md.
executor: resolves name-first. A value declared here is that instance; otherwise it must
be a built-in kind, which resolves to an implicit single instance of it. So a manifest with no
executors: block keeps working and keeps meaning what it meant. An instance may not be named
after a kind — that would make the two rules disagree about one string.
No enabled:, unlike connections:. An executor is reachable only through an agent
reference, so switching one off either does nothing or fails boot. Delete the entry instead. A
declared instance nobody binds to is a details warning, not an error.
No credentials here. Auth is resolved once per workspace, from env: — every instance
shares one decision about where Claude's config dir and API key come from. Two instances billing
to different accounts is not expressible yet.
Every declared instance is built at boot, not on first use: a bad claudeBin should fail
the boot, not the one trigger that names it at 3am.
env: — where this workspace's variables come from
connections: names the variables; this names the place they are read from. One provider,
resolved at boot into a bag belonging to this workspace — never written into process.env, so
two workspaces in one process cannot see each other's credentials.
env:
provider: dotenv
path: .env # optional; relative to the workspace root, or absolute
env:
provider: dotenvx
path: .env # optional; the committed, encrypted file
env:
provider: aws-secrets
secretId: openstation/notes
region: us-east-1 # optional; else AWS_REGION / AWS_DEFAULT_REGION
| Field | Required | Default | Meaning |
|---|---|---|---|
provider |
yes | — | dotenv | dotenvx | aws-secrets. The discriminator: each provider accepts only its own fields |
path |
no | .env |
dotenv and dotenvx. Relative to the workspace root, or absolute. A leading ~ is a load error |
secretId |
yes | — | aws-secrets only. The secret's name or ARN — a reference, never its contents |
region |
no | — | aws-secrets only. Falls back to AWS_REGION, then AWS_DEFAULT_REGION, in the host environment |
No block means dotenv over <workspaceRoot>/.env — what every manifest predating this
block already did. Nothing existing changes by staying silent.
Exactly one provider, not a chain. One unambiguous origin per variable is worth more than the convenience of layering, and the host environment is already the base underneath whichever provider is named: the provider wins where both set a name, and the boot banner names what it overrode. Shimming one variable locally is what a shell export is for.
Why a block rather than a flag or a guess. Inferring the provider from which credentials
happen to be present is the mistake connections: exists to undo. Declaring it means
openstation details can report it — provider, origin, and whether the read worked — which is
what makes every =unset marker in that report interpretable.
A dotenvx file is a committed, encrypted .env — a DOTENV_PUBLIC_KEY header plus
encrypted: values, safe to track because the values are ciphertext. The private key never
enters the repo: it is read from the resolved base environment first (DOTENV_PRIVATE_KEY;
DOTENV_PRIVATE_KEY_<NAME> for a .env.<name> file), then from a gitignored .env.keys
beside the file. Two failures are boot errors rather than degradations: a missing file
(the declaration says the file is committed, so absence means a broken clone — unlike
dotenv, where a missing file is a no-op), and encrypted values with no key, aborting
with the variable to set and the keys path tried — errors name keys, never values. Full
behaviour — warnings, what is stripped, key rotation —
is in environment variables; the migration recipe is in
sharing secrets with dotenvx.
An aws-secrets secret is one flat JSON object of variable name to string value:
{"SLACK_BOT_TOKEN": "xoxb-…"}. One call at boot, atomic rotation, one IAM statement. A
rotated secret means a restart. Full behaviour — IAM, the AWS credential chain, the failure
modes — is in environment variables.
Boot fails closed. A provider that cannot be read aborts dev and serve, naming the
origin and never a value.
bridge: — turn-path knobs
bridge:
attachments:
max: 30
reset: "/reset"
inbound:
- type: command
run: openbook ingest --stage
on: attachments
approval:
enabled: true
ttlMs: 86400000
| Field | Required | Default | Meaning |
|---|---|---|---|
attachments.max |
no | 30 |
Most files one reply may deliver. Overflow is dropped and logged, never sent. Must be a positive integer |
reset |
no | — | Text that drops the conversation's session ref instead of running a turn. Omit and no reset command exists |
inbound |
no | [] |
Deterministic stages run on every inbound message before policy, in list order |
approval |
no | — | The human-approval gate, in the block it belongs to. Identical to the root-level approval:, which stays an accepted alias |
Declaring approval: at the root and under bridge: is a load error rather than a silent
winner — they are the same gate, and a precedence rule nobody can see is worse than a refusal.
coalesce: — one answer for a burst
bridge:
coalesce:
windowMs: 2000 # absent ⇒ every message is answered on its own
Someone sending thirty photos sends thirty messages. Without this, that is thirty turns and
thirty replies — and where an inbound: stage runs per message, thirty subprocesses. Coalesced,
the burst arrives as one message carrying thirty attachment paths, so the conversation gets
one answer and a stage sees the whole dump in one invocation. That second effect is the reason to
turn it on for a tool with a rate limiter or a warm client: per-message subprocesses cannot hold
either across a burst.
The window is reset by every arrival, so a steady stream stays one burst. The merged message keeps the last message's identity — its id, what it replied to, whether it addressed the agent — because that is the message being answered; the earlier ones are its lead-up. Text is joined in arrival order, attachments concatenate in arrival order.
What it costs. Every reply waits for the window to go quiet, so a single message is answered
windowMs later than it would have been. That is why it is opt-in rather than a default.
Where it does not apply. openstation dev — the REPL has no bursts — and a caller embedding
the platform and driving the Bridge directly, since coalescing lives in serveChannel.
reset: — forget this conversation
bridge:
reset: "/reset" # absent ⇒ no reset command exists
The declared text drops this conversation's session ref, so the next message starts a new Claude session instead of resuming. It spends no turn, and the caller is told:
Starting fresh — I've forgotten what we were talking about.
Nothing in the workspace changes — a session ref is a pointer, and dropping it forgets the conversation, not the work. See sessions and turns.
Opting in is the only way a message becomes something other than a turn. With no reset:
declared, /reset is an ordinary message that reaches the agent, which is what every manifest
predating the field does.
Four rules, each load-bearing:
- Matched whole against the trimmed text.
/reset pleaseis an ordinary message. That also means a Telegram group's/reset@yourbotand a@yourbot /resetmention do not match — see the addressing limit underinbound:. - It runs after policy, so a caller who may not use a space cannot reset its session, and in a space that refused them nothing is dropped and nothing is said.
- It runs after the approval gate's decision prefixes, so declaring
reset: "approve:"cannot disarm a held turn — the decision is read first. - A conversation with a turn in flight is refused, with "Still working on your previous message". The drop takes the same per-conversation lock a turn's session read and save take; unlocked, the in-flight turn would save its ref afterwards and silently undo the reset.
reset: "/new" is silently dead under openstation dev. The REPL intercepts /new itself
— it mints a fresh conversation key, prints (session reset), and never calls the Bridge — so
the declared reset appears to work while doing nothing, and the two resets have different
semantics (a new key versus a dropped ref). Pick any other text.
inbound: — deterministic stages before the agent
Work the platform runs on a message itself, with no Claude turn and no model cost: a photo ingested, a slash command answered, a button press handled. Each stage is a subprocess of the workspace, so it can be written in any language.
bridge:
inbound:
- type: command
run: openbook ingest --stage # tokenized without a shell
on: attachments # only messages carrying files
roles: [member, owner]
timeoutMs: 120000
- type: command
run: openbook gateway-command --stage
prefix: ["/", "ob:"] # slash commands and button ids
| Field | Required | Default | Meaning |
|---|---|---|---|
type |
yes | — | command — the only kind. A long-lived stage would arrive as an MCP surface, or not at all |
run |
yes | — | The command, tokenized without a shell. The message's attachment paths follow positionally |
on |
no | message |
message runs it for every message; attachments only for one carrying materialized files |
roles |
no | — | Only run for these roles. Each must have a profile, or the manifest fails to load |
prefix |
no | — | Only run when the trimmed text starts with one of these |
timeoutMs |
no | 30000 |
Wall-clock limit for one invocation, then the child is killed |
The list is workspace-wide and nothing scopes it to an agent: a captionless photo addresses nobody, so a per-agent list would be empty exactly when a stage matters.
Where it runs: after identity, before policy. That placement is the whole point. A
captionless photo in a mention-gated group is ingested, because stages run before
addressing — and a stranger's photo is not, because they run after identity and never see a
caller the roster does not know.
The contract. Argv is run: tokenized, then the message's attachment paths, positionally,
in the order they arrived. Cwd is the workspace root, also passed as OPENSTATION_WORKSPACE.
The environment is the workspace's own resolved env:
bag, not the daemon's — which is what gives a stage its own credentials. On stdin, one JSON
object:
{
"connector": "telegram",
"conversationKey": "telegram:-1001234567890",
"spaceId": "telegram:-1001234567890",
"messageId": "8412",
"isDm": false,
"text": "who is in these?",
"attachments": ["/ws/okf/scratch/inbound/photo-1.jpg"],
"principal": { "id": "leon", "role": "owner", "displayName": "Leon" },
"contact": { "channel": "telegram", "externalId": "4815162342" }
}
contact sits beside principal because principal.id is a people.yaml
id, not a channel handle — a tool asked to look up its own caller cannot get there from the id.
On stdout, one JSON object. Every field is optional, and unknown keys are ignored so a tool's stage output may grow:
| Field | Effect |
|---|---|
note |
Pointer text prepended verbatim to the turn's prompt — a path and a summary, never a payload |
reply |
An answer for the channel. Ends the message: no turn, and no later stage runs |
stop |
true ends the message with no reply at all |
A reply goes through the same lifting an agent's reply does, so a ```send-files or
```send-buttons block in it works and is confined the same way — a stage gains nothing an
agent does not already have. **A reply implies a stop**: one inbound message produces one
outgoing reply, so a stage that answers has ended the conversation's turn whatever it said
about stop. In a silent: true space a stage's reply is dropped by the same rule that drops
a refusal.
Blank stdout is a stage that said nothing, not a failure — that is the quiet ingest case.
A stage that fails is skipped, and the message continues as if it had not been declared.
Dropping the message would make a broken stage look like an agent ignoring people. Four
failures, each logged and published as
bridge.StageFailed: a binary that cannot be spawned
(spawn), stdout that is not a JSON object (unparseable), a timeoutMs overrun
(timeout), and a non-zero exit (exit).
A non-zero exit is advisory: the stage is reported and whatever it printed is honoured. A tool that ingested 29 photos of 30 exits non-zero and its stdout still names the 29; discarding that would lose them to the corrupt one.
Nothing in a message can reach a shell. run: is tokenized by whitespace with single and
double quotes grouping, and nothing else is interpreted — no expansion, no operators, no
escapes. $(rm -rf /) is one literal argument. A tool that wants --file X per path gets a
two-line wrapper script, not a template language in the manifest.
prefix: — commands and button presses that cost nothing
Without a prefix:, a stage is offered every message. With one, the platform spawns nothing
for ordinary chat, so two things become free:
- A whole slash-command surface is one stage.
prefix: ["/"]sends every/…message to one command handler. - A button press is deterministic. A press comes back as an ordinary message whose text is
the button id, so ids sharing a prefix (
ob:) are answered by a stage instead of by a Claude turn. See authoring buttons.
Every filter is an AND: a stage declaring on: attachments, roles: and prefix: runs only
where all three hold.
A prefix: does not fire on @yourbot /find or /find@yourbot in a Telegram group. The
prefix is matched against the trimmed text exactly as it arrived, and stripping a bot's own
mention and the @botname command suffix is the connector's job — which is not done yet. In a
group, a mention-triggered space needs the tag to admit the message at all, and Telegram
appends @botname to a command sent to a group; neither form starts with /find. Until the
connector strips them, a prefix stage is reliable in DMs and in every-message spaces where
people type the bare command.
The platform's own reserved text is never offered to a stage — the declared reset: and
the approval gate's approve:/deny: prefixes. Stages run before policy and therefore before
those, so a stage with a broad prefix: could otherwise swallow the decision a held turn was
waiting on and leave a turn nobody can approve.
openstation details names each declared stage with what selects it, and both details and
serve warn when a stage's binary is not on the workspace's PATH — the likeliest failure of
all, since a missing binary otherwise reports only as a spawn failure in the log. dev runs
no preflight, so it warns about neither.
workspace: — roots outside the repo
The one thing .gitignore cannot express: directories outside the workspace the agent may
read and deliver files from. A photo store, a shared drive, a checkout that belongs to another
tool — data that should not have to move into a git workspace to be usable.
workspace:
external:
- ~/openbook/store # ~ expands to the operator's home
- ../shared-photos # relative resolves against the workspace root
- /mnt/archive # absolute is taken as written
| Field | Required | Default | Meaning |
|---|---|---|---|
external |
no | [] |
Roots outside the workspace a reply may deliver files from. An empty string is a load error |
What a declared root changes, and what it does not.
- A file under one can be delivered in a
```send-filesblock. Everything outside the workspace root and every declared root is still refused. - A path under one classifies as read-only input: readable, never committed. It is not yours to keep history for.
- A relative path in a send-files block always resolves against the workspace root, never
against a declared root —
kids.jpgcan never mean someone else's store. Name a declared root's files absolutely. - A symlink out of a declared root is still refused: confinement resolves both sides.
- It does not grant the agent read access. That is the settings file —
Read(...)there, as always. This widens what may be delivered, not what may be read. - Existence is not checked at load: a volume that isn't mounted yet is a boot warning, not a failure.
disabled: — withdrawn from service
disabled: true
serve refuses to boot a workspace whose manifest says this, naming the command that reverses it.
dev warns and runs it anyway: a workspace withdrawn from its channels is exactly one you may
still want to debug locally.
It lives here, in the workspace's own manifest, rather than in a file at the workspaces home —
there is no host manifest, and adding one to hold a single boolean would be a second config to
load, validate, and keep in sync with what is on disk. openstation <ws> disable and
enable are how you set it; list reports it per workspace.
Two files must exist for every agent
The loader verifies both and refuses to start if either is missing. It checks that they exist; it never reads them — Claude Code is their only reader.
| Path | Holds |
|---|---|
.claude/agents/<name>.md |
the charter: prompt, model, tools (Claude-native) |
.claude/settings.<label>.json |
the enforced permission gate |
Load-time errors
Every failure names the file and the failing field path, and stops the process. Real messages, with the workspace path elided:
Restating something Claude-native — the schema is strict, so model, tools, or prompt
in the manifest fails rather than drifting from the charter:
openstation config <workspace>/.openstation/openstation.yaml is invalid:
agents.a: Unrecognized key(s) in object: 'model'
No agents declared:
agents: agents must declare at least one agent
Two agents claiming one channel id — only an id: entry is a routing claim, and two claims
on one id have no rule to break the tie:
agents.b.channels.0: channel "help" is already claimed by agent "a"
Two entries with the same selector — two policies for one set of spaces, with nothing to choose between them:
agents.support.channels.1: agent "support" already declares a channels entry for type=dm
An entry that selects nothing:
agents.support.channels.0: a channels entry needs at least one of id, connector, or type —
an entry matching every space is what the agent's own trigger: already says
A name: with no selector beside it — the label names a channel, so it looks like it selects
one:
agents.support.channels.0: a channels entry needs a selector: name: is a label for the reader,
never matched against a space — add the id, connector, or type it names
A space claudeSettings: label with no file — same check as the agent-level label, naming the
selector so you know which entry to fix:
agents.support.channels.id=C_HELP.settings "nope" has no file at <workspace>/.claude/settings.nope.json
A roles: entry with no profile — a role without one is refused at run time, so admitting
only that role would make the space unreachable:
agents.support.channels.type=dm.roles names "admin", which has no profile at
<workspace>/.openstation/profiles/admin.md — a role without one is refused, so nobody
could ever be admitted
An inbound stage whose run: cannot be tokenized — caught at load, so a typo names the
manifest instead of failing the first time a message arrives. Same for a run: that quotes
away to nothing (bridge.inbound[0].run "''" names no command):
openstation config <workspace>/.openstation/openstation.yaml: bridge.inbound[0].run has an
unterminated " quote
An inbound stage's roles: entry with no profile — the same rule a channels: entry
follows, since a role without a profile is refused at run time:
openstation config <workspace>/.openstation/openstation.yaml: bridge.inbound[0].roles names
"owner", which has no profile at <workspace>/.openstation/profiles/owner.md — a role without
one is refused, so it could never take effect
An unknown stage kind, on: value, or extra key — the block is strict, so a misspelled
field is refused rather than ignored:
openstation config <workspace>/.openstation/openstation.yaml is invalid:
bridge.inbound.0.on: Invalid enum value. Expected 'message' | 'attachments', received 'photos'
A schedule declaring deliver: with no target — a trigger infers one from the artifact's
origin:; a scheduled run has no artifact:
openstation config <workspace>/.openstation/openstation.yaml is invalid:
schedules.0.deliver.conversation: Required
Two default agents — ambiguous, so it fails instead of resolving last-wins:
agents.b.default: agent "b" cannot be default: true — "a" is already the default
Unknown executor — the declared instances and the built-in kinds, in one list:
agents.a.executor: unknown executor "gpt" — declare it under executors:, or name one of: patient, claude-cli, claude-sdk, pi-sdk
An unknown env provider — the discriminator names the closed set:
openstation config <workspace>/.openstation/openstation.yaml is invalid:
env.provider: Invalid discriminator value. Expected 'dotenv' | 'dotenvx' | 'aws-secrets'
A ~ in env.path — refused rather than resolved under the workspace root, where it
would find nothing and boot with every credential missing. Silent, for the one field whose
whole job is credentials:
openstation config <workspace>/.openstation/openstation.yaml: env.path "~/secrets/notes.env"
starts with ~, which is not expanded here — write the absolute path instead
Missing charter:
openstation config <workspace>/.openstation/openstation.yaml: agents.ghost has no matching
Claude-native definition at <workspace>/.claude/agents/ghost.md
Missing settings file:
openstation config <workspace>/.openstation/openstation.yaml: agents.a.settings "nope" has no
file at <workspace>/.claude/settings.nope.json
Malformed YAML — reported with line and column:
openstation config <workspace>/.openstation/openstation.yaml has invalid YAML:
Missing closing "quote at line 4, column 1
What isn't in this file
By design, secrets are never here — connector credentials come from the environment
(environment variables), and env: names the place they are read from, never
a value. Behavior isn't here either: the charter, model, tool
list, and skills are Claude-native. See
agents and profiles for which file holds what.
Who is on the other end of a channel isn't here either: that's the sibling
.openstation/people.yaml, which maps channel handles to roles.