openstation

Reference

openstation.yaml

The complete workspace manifest schema.

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>.jsonthe 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: true and 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 filterspath + 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-files block 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's typing is not per-message and reads correctly for the whole burst.
  • An emoji named none is 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 please is an ordinary message. That also means a Telegram group's /reset@yourbot and a @yourbot /reset mention do not match — see the addressing limit under inbound:.
  • 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-files block. 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.jpg can 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.

View Markdown source on GitHub ↗