--path [--path
…]
fleet station changeset status --station [--base []
fleet station changeset diff --station --side uncommitted|committed [--path ]]
fleet station fs write|mkdir|move|delete --station …
```
The same operations as the console’s panels, each gated on a capability the station declares. See [What you can do to a station](/use/panels/).
### Git identity
[Section titled “Git identity”](#git-identity)
```sh
fleet stations git-identity --station # what it can push to forge as
fleet stations grant-push --station # give it a forge push key
fleet stations revoke-push --station # take that key away
```
**Push access is granted per station and never by adopting one.** Most stations never touch git, and a forge key for every station is an account nobody uses and a key nobody revokes.
The keypair is generated **on the node** and the private half never leaves it — `grant-push` asks for the public half and registers that. The hub holds no secret.
### Forge API access
[Section titled “Forge API access”](#forge-api-access)
```sh
fleet stations forge-api list --station # state, mode, account, isolation
fleet stations forge-api grant --station --provider forgejo \
--instance https://forge.example --repo org/repo --preset issues # minted: no credential
pbpaste | fleet stations forge-api grant --station --provider github \
--instance https://github.com --repo org/repo --mode supplied --expect-login octo-bot
fleet stations forge-api verify|rotate|revoke|confirm-revoked --station --grant
fleet stations forge-api secret --station --grant # re-supply, from stdin
fleet stations forge-api policy --station --grant --preset issues --rule 'deny: DELETE /repos/**'
```
**Operator only, and no credential on the command line.** Every verb but `list` needs the operator seat and the control pair’s reach over the agent. A supplied token or an App’s fields are read from stdin, sealed on this machine to the station’s broker key, and only the envelope is sent; there is no `--token` flag. The modes, presets, rules and limits are in [Stations](/use/stations/#forge-api-access).
### Staffing
[Section titled “Staffing”](#staffing)
```sh
fleet staff options
fleet staff create --file |-
fleet staff assign --station --file |-
fleet staff unassign --station
```
Putting an agent in a station, or taking it out.
### Skills
[Section titled “Skills”](#skills)
```sh
fleet skills … # artifacts, releases, cohorts, canaries, native placement
```
Its own page: [Managed skills](/use/skills/).
[]()
### Plugins
[Section titled “Plugins”](#plugins)
```sh
fleet plugins inventory --station
fleet plugins plan --station --action enable|disable
fleet plugins show --station --operation
fleet plugins inspect --station --operation
fleet plugins apply --station --operation --plan-digest
fleet plugins history --station
```
The same plan → inspect → apply shape as skills: the node plans, you read the plan, and the apply sends only the digest you reviewed. **Nothing here restarts a station.** These are the harness plugins behind [voice replies](/use/voice/), live turn reporting, and [error reporting](/use/errors/).
### Authority
[Section titled “Authority”](#authority)
```sh
fleet principals list # who exists in this workspace
```
People, roles, bans, signup, grants and service principals are **managed by your workspace’s account service**. The `fleet users`, `fleet grants`, `fleet settings signup` and `fleet principals suspend|restore|add-service|add-credential|revoke-credential` verbs remain for hubs that predate it; a current hub answers them with `410` and the account service’s URL. See the [`fleet` reference](/reference/fleet/#fleet-principals) and [Dispatch and grants](/use/grants/).
### The board bridge
[Section titled “The board bridge”](#the-board-bridge)
```sh
fleet bridge list
fleet bridge add --key …
fleet bridge set …
fleet bridge rm
```
Which agent claims from which board, onto which station. See [Working a board](/use/boards/).
### Hub settings and substrate
[Section titled “Hub settings and substrate”](#hub-settings-and-substrate)
```sh
fleet settings show
fleet settings signup|transcription|speech …
fleet runtimes list|providers|create|start|stop|rm
```
`settings` is hub-wide configuration: the [voice](/use/voice/) defaults for transcription and speech. `runtimes` is the substrate nodes run on.
### Keeping it current
[Section titled “Keeping it current”](#keeping-it-current)
```sh
fleet update [--check]
fleet version
```
The binary a person invokes drifts more than the one a service runs, not less.
## Commands that need no enrolment
[Section titled “Commands that need no enrolment”](#commands-that-need-no-enrolment)
```sh
apn scan
apn acp --list
apn acp --station
```
`apn scan` checks this machine’s agent runtimes for exposure, and needs nothing at all — no hub, no token, no network. It runs from a downloaded binary on a machine that has never been near AgentPod. See [Checking for exposure](/use/scan/).
`apn acp` attaches a local ACP editor to a station on another machine. Like `fleet`, it takes a person’s token rather than this host’s credential, so a laptop can use `apn`’s `acp` command purely as a client without being enrolled. See [Attaching an editor](/use/acp/).
## Tokens on the command line
[Section titled “Tokens on the command line”](#tokens-on-the-command-line)
`fleet` takes its token from `$AGENTPOD_TOKEN` or from the file `fleet login` writes. There is no `--token` flag on it.
`apn acp` does accept `--token`, and you should generally not use it: a token in an argument lands in your shell history and in the process list. Prefer `$AGENTPOD_TOKEN`, which is what the flag defaults to anyway.
(`apn enroll --token` is a different thing — that is a single-use *enrollment* token for the machine, not a principal’s token.)
## Getting help
[Section titled “Getting help”](#getting-help)
```sh
apn help # apn's commands, grouped
apn -h # one apn command in detail
fleet help # fleet's commands
fleet -h # the same text
```
`apn`‘s help text is generated from a single table in the binary, so `apn help ` and `apn -h` cannot disagree with each other. `fleet`, `fleet help` and `fleet -h` all print the same block, and `fleet -h` prints that verb’s usage. Both binaries’ verb lists come from the same tables the [`apn`](/reference/apn/) and [`fleet`](/reference/fleet/) reference pages are generated from, and a test fails when a command or flag has no entry.
Help flags are checked **before** anything happens: `apn stop -h` prints help and does not stop the service, `apn service -h` cannot install or uninstall anything, and `fleet login -h` prints help without opening a browser.
# Declared harness settings
> Declaring what a harness setting should be, the three policies that govern how it may be reconciled, writing a declaration to a station through plan/inspect/apply, and the states a station can report back.
A harness — Hermes, OpenClaw, Claude Code, Codex, OpenCode, Pi — keeps its own configuration, in its own file, in its own shape. AgentPod does not own that file. What it can do is let the fleet **declare** what a registered setting should be, write that declaration to a station through a reviewed operation, and tell you where each station’s actual value stands against what was declared.
## A declaration is not a write
[Section titled “A declaration is not a write”](#a-declaration-is-not-a-write)
```sh
fleet config set hermes.approvals.mode --value strict
```
**This records a declaration. It does not touch a station.** Nothing on disk changes when this command returns — the hub remembers that the fleet *wants* `hermes.approvals.mode` to be `strict`, at whichever level you declared it. Writing a harness’s own config file is a separate, reviewed operation — `plan`, then `apply` — described below in [Writing a declaration](#writing-a-declaration-plan-inspect-apply).
The gap between declaring and applying is deliberate, not a missing feature. A harness persists an operator’s own decisions into the same file a declaration targets — see `additive-only` below — so `set` itself should never move a file unasked, and nothing reconciles on a timer either: a sweep running on a tick would race the harness or silently undo what an operator just changed through it. A user who treats `set` as something that changed a machine has been misled by it; treat it as writing a line into a policy document, not into the harness. Only `apply`, naming the exact plan a human reviewed, writes anything.
```sh
fleet config unset hermes.approvals.mode
```
removes a declaration. Undeclaring a level that has nothing declared on it is a no-op, not an error.
## Scopes and levels
[Section titled “Scopes and levels”](#scopes-and-levels)
A declaration targets exactly **one level**: a station, a node, or the whole fleet (both null). Levels resolve with station the most specific and fleet the least — a station-level declaration wins over a node-level one, which wins over a fleet-wide one, for the same setting.
```sh
fleet config set hermes.approvals.mode --value strict # fleet-wide
fleet config set hermes.approvals.mode --value strict --node nod_123 # one node
fleet config set hermes.approvals.mode --value strict --station st_abc # one station
```
A setting also carries its own **scope** — `profile`, `project`, or `user` — which says where it actually lives inside the harness’s document, independent of which level you declared it at.
### Declaring a value that is not a string
[Section titled “Declaring a value that is not a string”](#declaring-a-value-that-is-not-a-string)
A single `--value` is declared as a **string**. The declaration is stored as JSON and the node refuses a value whose shape its registry does not expect, so the shape has to be right here rather than at write time. For a list-valued setting — every `additive-only` setting is one — repeat `--value` once per entry, or use `--json` for the value exactly as JSON:
```sh
# a list, by repeating --value
fleet config set hermes.approvals.command_allowlist --value "git status" --value "ls"
# a ONE-entry list, a number, a boolean — the forms repeating cannot express
fleet config set hermes.approvals.command_allowlist --json '["git status"]'
fleet config set hermes.approvals.timeout --json 900
```
`--value` and `--json` are mutually exclusive, and `set` needs one of them. Declaring a list-valued setting with a single `--value` stores the string it is, and every later `plan` for that station is then refused `SHAPE_UNEXPECTED` — so if a plan says a declared value is not a list of strings, this is why.
### Why a station is not a config scope, except for Hermes
[Section titled “Why a station is not a config scope, except for Hermes”](#why-a-station-is-not-a-config-scope-except-for-hermes)
For five of the six harnesses, a station is a *project path*, while the harness’s configuration lives per *user*. Declaring a setting “for this station” would be declaring it for a document the station doesn’t actually own — the user’s config file is shared across every project that user opens, so a station-scoped declaration on, say, Claude Code or Codex has no document to land in.
Hermes is the exception: there, a station **is** a profile, and a profile is exactly the document Hermes’s settings live in. That is the only harness where declaring a setting at the station level corresponds to a real, single document.
`--station` is **accepted for any setting**, including one whose registered `scope` is not `profile`. The declaration is stored; nothing is refused at `set` time. The mismatch surfaces later, when the declaration is **read back** — every comparison for that station reports the setting as `out-of-scope`, with a reason naming the scope, instead of `matches`, `drifted` or `absent`. So a `--station` declaration of a `user`- or `project`-scoped setting is not an error you are told about when you make it; it is a state you see the next time you ask where things stand.
## The three policies
[Section titled “The three policies”](#the-three-policies)
Every registered setting carries a `policy`, which says what this system is permitted to do toward making a station’s value match the declaration:
| Policy | Meaning |
| --------------- | ------------------------------------------------------------------------------------------------------------- |
| `reconcilable` | The value can be fully reconciled to the declaration — whatever is there can be replaced outright. |
| `additive-only` | The declaration is a floor, never the whole value. Items can be added; nothing already there is ever removed. |
| `report-only` | The value is only ever compared and reported. Nothing here writes it. |
`additive-only` exists because a harness persists an operator’s own decisions into the same file a declaration targets. `hermes.approvals.command_allowlist` is the first example: an operator can extend that allowlist from Hermes’s own UI at any time, and that edit lands in the same document a fleet-wide declaration points at. Reconciling the list down to exactly what was declared — the `reconcilable` behaviour — would silently delete a grant the operator made minutes earlier through a surface this system never watched. A declared baseline must never be able to erase an operator’s own decision; `additive-only` is the policy that guarantees that, by only ever being allowed to add.
`apply` enforces exactly this at write time, not just at comparison time: a `reconcilable` write replaces the key outright; an `additive-only` write only appends items that are not already present, so re-applying the same declaration twice is always safe; and a `report-only` setting is planned as a no-op and is never sent to a node to write — by design, not because writing it hasn’t shipped yet.
## Seeing what the fleet can declare
[Section titled “Seeing what the fleet can declare”](#seeing-what-the-fleet-can-declare)
```sh
fleet config settings
```
lists every setting any reachable node currently manages — the live registry, read from the node, never a copy held in the hub. A node that cannot currently be asked contributes nothing to this list, and is named separately so its absence isn’t silent.
This registry currently covers seven settings — plus whatever else is live on the node you ask:
| Setting | Scope | Policy |
| ---------------------------------------- | --------- | --------------- |
| `hermes.approvals.timeout` | `profile` | `reconcilable` |
| `hermes.approvals.mode` | `profile` | `reconcilable` |
| `hermes.approvals.command_allowlist` | `profile` | `additive-only` |
| `hermes.plugins.enabled` | `profile` | `additive-only` |
| `hermes.plugins.stream_reasoning_deltas` | `profile` | `reconcilable` |
| `hermes.skills.external_dirs` | `profile` | `additive-only` |
| `openclaw.hooks.allowConversationAccess` | `user` | `reconcilable` |
The last four are the same four settings the `apn hermes-live`, `apn hermes-skills` and `apn openclaw-errors` verbs have always managed. Folding them into this registry did not replace those verbs, and does not retire them: **both paths call the exact same writer**, so a plan `fleet config` derives for one of these settings and the edit the matching `apn` command would make by hand are byte-identical, not merely equivalent. Run `apn hermes-live`, `apn hermes-skills`, or `apn openclaw-errors` directly whenever that is more convenient — nothing about declaring these through `fleet config` changes what those commands do.
### Two limits on the plugin settings
[Section titled “Two limits on the plugin settings”](#two-limits-on-the-plugin-settings)
`hermes.plugins.enabled` and `hermes.plugins.stream_reasoning_deltas` share one writer, and it is indivisible: Hermes only reports reasoning deltas from a plugin that also has `plugins.enabled` set, so declaring **either** setting enables the agentpod-live plugin as part of the same edit. Declaring `hermes.plugins.stream_reasoning_deltas` alone still touches `plugins.enabled` — both key paths are named in the plan’s entries, and the plan’s diff shows every line that moves, so this is visible to whoever reviews the plan before `apply` ever writes anything. It is not a silent side effect; it is one edit covering two keys, and an operator declaring one setting should expect the other key to move too.
Separately, `hermes.plugins.enabled` can only ever **add** that one plugin, `agentpod-live`. Declaring any other plugin name is refused by name (`SHAPE_UNEXPECTED`) rather than appended — the setting id reads as if it manages Hermes’ whole plugin list, but the writer behind it only knows how to turn on the one plugin this system ships.
## Seeing where things stand
[Section titled “Seeing where things stand”](#seeing-where-things-stand)
```sh
fleet config show # every declaration in the fleet, as stored
fleet config show --node nod_123 # one node's declarations, as stored
fleet config show --station st_abc # one station: declared vs observed, compared
fleet config drift # every station whose value differs from what's declared
```
**Only `--station` compares anything.** With no flag, and with `--node`, `show` returns the **declaration rows themselves** — what was declared, at which level, by whom — and no station is contacted: there is no `observed` value and no state in that answer. That is not a gap in the output, it is what the question means at those levels: a fleet-wide or node-wide declaration is one row that may apply to many stations, and comparing it requires naming which station you mean. `--station` names one, so that form asks its node for the current values and reports a state per setting. `fleet config drift` is the other compared form, across every station at once.
Each **comparison** — `show --station` and `drift` — reports, per setting, a **state**:
| State | Meaning |
| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `matches` | The observed value agrees with what’s declared. |
| `drifted` | Declared and observed disagree. |
| `absent` | Declared, but the key isn’t in the document at all. |
| `opted-out` | An operator explicitly opted this station out of the declaration — see [Opting a station out](#opting-a-station-out). |
| `awaiting-restart` | A value was written but the harness hasn’t picked it up yet — see [awaiting-restart](#awaiting-restart-written-but-not-yet-in-effect). |
| `unreadable` | The document couldn’t be read, or the key is there but holds a shape this release can’t read — a nested map (see below). Never reported as `matches` — an unreadable value is not evidence of agreement. |
| `out-of-scope` | A per-station declaration was made for a setting whose registered scope isn’t the station’s document (see above). |
Each comparison also names the **level** the winning declaration resolved from — `station`, `node`, or `fleet` — the same precedence [Scopes and levels](#scopes-and-levels) describes. A station with its own declaration, one inherited from its node, and one inherited from the fleet all report differently here even when the declared value happens to be the same, so you can tell which declaration is the one actually governing this station without re-deriving precedence by hand.
`fleet config drift` only reports states other than `matches`, plus the list of stations it could not reach at all — a station it couldn’t ask is never silently left out of the total.
That list is narrower than “every station that is down”, and deliberately so. It holds the stations that **had something declared for them** and whose node could not be asked. A station with **no** declaration resolving to it is never contacted in the first place, so it appears neither in the observations nor in the unreachable list, however offline it is; and a station that does not advertise `config.manage` at all is not a candidate for this command. Read the list as “declarations I could not check”, not as a fleet health report — `fleet nodes` is where you see what is up.
### Nested-map settings read as `unreadable`
[Section titled “Nested-map settings read as unreadable”](#nested-map-settings-read-as-unreadable)
This release reads **scalar** and **list** values. A registered setting whose key holds a nested map is reported `unreadable`, with a reason saying so — not `absent`, and not `drifted`.
`hermes.approvals.command_allowlist` is a list in every real document, and reads normally: a block list, an inline list, and an empty list all come back readable, with the list itself as the observed value. It stays in the registry even on the shape it can’t read — the alternative was to report a key that is plainly in the document as “not in the document at all”, which is a false sentence, or to compare an inline list’s raw text and report drift forever. An honest `unreadable` with a reason is the state that tells you what is actually true.
### Opting a station out
[Section titled “Opting a station out”](#opting-a-station-out)
`opted-out` names an operator’s explicit choice, and it outranks the ordinary comparison — an opted-out setting is reported `opted-out` even when the observed value differs from the declaration, never `drifted`, because `drifted` would invite the exact `apply` the opt-out exists to prevent.
The opt-out lives in a **hub-side register** (`harness_config_opt_out`) — **not** as a key inside the harness’s own config file. A harness like Hermes rewrites and migrates its own config document on its own schedule; a key this system invented there would be a key whose survival this system does not control, so the opt-out is kept somewhere this system does control instead.
#### A harness’s own opt-out is a second, distinct source of `opted-out`
[Section titled “A harness’s own opt-out is a second, distinct source of opted-out”](#a-harnesss-own-opt-out-is-a-second-distinct-source-of-opted-out)
A harness can also carry **its own** record that an operator turned a setting off, made through the harness’s own UI rather than through agentpod at all:
* **Hermes:** the plugin named in `plugins.disabled`.
* **OpenClaw:** `plugins.entries.agentpod-errors.enabled` explicitly `false`.
Either one reports `opted-out`, with a reason naming the harness as the source — distinct from the hub-register reason, so you can tell “I exempted this through `fleet config`” from “I disabled this inside the harness itself” by reading the reason text alone. And either one now refuses a **write**, not only a comparison: declaring `hermes.plugins.enabled` while `plugins.disabled` names `agentpod-live` is refused `OPTED_OUT`, the same as an operator opt-out recorded in the hub’s own register.
**One asymmetry, stated plainly rather than papered over.** For Hermes, the check lives inside the one writer both `apn hermes-live` and the registry call, so both paths are protected — running `apn hermes-live enable` by hand against a profile where the plugin is listed in `plugins.disabled` refuses exactly as a `fleet config apply` would. For OpenClaw, the check lives only in the registry’s own plan derivation, not inside the writer `apn openclaw-errors` and the registry share. That means `apn openclaw-errors enable --apply`, run by hand, does **not** check OpenClaw’s own opt-out and can still re-enable a plugin an operator disabled through OpenClaw’s own UI. This is pre-existing behaviour, not something this feature introduced, and it is a known follow-up rather than something closed here.
`compare()`, adopt-time reconcile, and `plan`/`apply` all honor a row in this register the moment one exists — there is no path left that writes an opted-out setting silently. `plan` excludes an opted-out setting from what it asks the node to plan and names it in a `refused` list alongside whatever it *did* plan (a mixed request — some settings opted out, the rest not — refuses only the opted-out ones, never the whole plan: `fleet config plan --station ID` plans every setting currently declared for a station with no way to narrow it, so a whole-plan refusal would let one opt-out block writing anything else to that station). `apply` re-checks the register too, independently of `plan` — an operator can opt a station out *after* a plan was reviewed and before someone applies it, and that window is exactly where the check matters most. Because one `apply` call writes one journaled plan as a single atomic operation, a setting opted out after its plan was made refuses the **whole** apply, naming the setting; nothing in that plan is written, and a fresh plan (which will exclude the now-opted-out setting) is the remedy.
**An opt-out stops this system *writing* a setting from here on. It does not revert what is already in the file.** A station that was already drifted, or that already carries a value written before the exemption existed, stays exactly as it is — opting out changes what `compare()` reports and what `plan`/`apply` will do next, never what is already on disk.
#### Two levels, no third
[Section titled “Two levels, no third”](#two-levels-no-third)
An exemption targets exactly one of two levels — a **station**, or a **node** (every station on it, unless a station overrides it; see below). There is deliberately no fleet-wide exemption: `fleet config unset` already means “nothing is declared for this setting, anywhere” — a fleet-level opt-out row would only ever be a second way to say something `unset` already says, and a second way to say one thing is how a refusal becomes ambiguous. A row naming neither a station nor a node, or naming both, is refused before it is stored: the CLI requires exactly one of `--station`/`--node`, the route validates the same rule, and the table’s own CHECK constraint keeps it true for any other caller too.
A **station-level row always beats a node-level one**, for the same setting: when the station has its own row — whichever way it reads — that row decides outright and the node row is never consulted. Only when the station has **no row at all** does the node’s row apply.
#### `opt-in` versus `--clear`
[Section titled “opt-in versus --clear”](#opt-in-versus---clear)
This is the subtle half of the feature, and the two are not interchangeable:
```sh
fleet config opt-in hermes.approvals.mode --station st_abc
```
records a row for `st_abc` with `optedOut: false` — **“this station is explicitly NOT exempt.”** That row is not the absence of a row, and it stops the search right there: a station row decides outright, true or false, without the node row ever being read. So `opt-in` at the station overrides `opt-out` at the node — if `nod_123` is opted out of a setting and `st_abc` is one of its stations, `fleet config opt-in ... --station st_abc` pins `st_abc` back under ordinary reconciliation while every *other* station on `nod_123` stays exempt.
```sh
fleet config opt-out hermes.approvals.mode --station st_abc --clear
```
does something different: it **forgets the row entirely**, rather than recording a `false` one. With no station row left at all, the search falls through to the node — so if `nod_123` is still opted out of that setting, `st_abc` becomes exempt again too, the moment its own row is gone. `--clear` works at the node level the same way: a cleared node row leaves any station with no row of its own under no exemption at all, since there is nothing left to fall through to.
Use `opt-in` to pin one station in despite its node. Use `--clear` to stop having an opinion at that level and let whatever the other level says take over.
#### `--station` is a stationKey, not a row id
[Section titled “--station is a stationKey, not a row id”](#--station-is-a-stationkey-not-a-row-id)
Unlike `set`, `show`, `plan`, `inspect` and `apply` — whose `--station` is the station’s database row id — `opt-out` and `opt-in` take `--station` as the station’s **stationKey**. That is deliberate: a stationKey survives unadopt and re-adopt (re-adopting a station gives it a new row). An exemption recorded against a stationKey is still there, and still honored, after the station it names is unadopted and re-adopted; one recorded against a row id would silently stop applying the moment that happened.
#### A worked example
[Section titled “A worked example”](#a-worked-example)
```sh
# exempt every station on a node while a new default is piloted
$ fleet config opt-out hermes.approvals.mode --node nod_123 --reason "piloting a new default"
# pin one station on that node back in, despite the node-wide exemption
$ fleet config opt-in hermes.approvals.mode --station st_abc
# see the register, narrowed to that station
$ fleet config opt-out --station st_abc
[
{
"id": "cfgoo_9f1c3a7b",
"stationKey": "st_abc",
"nodeId": null,
"settingId": "hermes.approvals.mode",
"optedOut": false,
"reason": null,
"optedOutBy": "usr_4b2e",
"createdAt": "2026-10-05T09:00:00Z",
"updatedAt": "2026-10-05T09:00:00Z"
}
]
# change your mind: let st_abc fall back to following its node again
$ fleet config opt-out hermes.approvals.mode --station st_abc --clear
{"cleared": true}
```
(Illustrative, not literal output; `tenantId` is present on every row but omitted above for brevity.) After the final `--clear`, `st_abc` has no row of its own, so it inherits `nod_123`’s exemption again — it is now opted out for the same reason `nod_123` is, not because of anything recorded against `st_abc` itself.
With no `SETTING_ID`, `fleet config opt-out` lists the whole register, unresolved — every row, at either level, with its `reason` and who set it; `--station KEY` or `--node ID` (never both) narrows that listing. That is the register **as stored**, the same sense in which `fleet config show` with no `--station` returns declaration rows rather than a comparison. To see what a specific station is exempt from right now, after station-beats-node resolution, read its `opted-out` states from `fleet config show --station ID` or `fleet config drift` instead.
The register is visible on a station’s own page in the console (see [Seeing and applying it from the console](#seeing-and-applying-it-from-the-console)) — but `fleet config opt-out` / `opt-in` remains the only surface that **writes** it.
## Writing a declaration: plan, inspect, apply
[Section titled “Writing a declaration: plan, inspect, apply”](#writing-a-declaration-plan-inspect-apply)
```sh
fleet config plan --station ID the edit that would be made, and its digest
fleet config inspect --station ID --operation ID a plan already made, as it was reviewed
fleet config apply --station ID --operation ID --plan-digest SHA256
```
`plan` asks the hub to derive the exact edit against the station’s current document and returns a `ConfigPlan`: the before/after diff, which action each setting takes (`create`/`modify`/`append`/`noop`), whether a restart is needed, and a `planDigest` — a SHA-256 over everything in the plan except the timestamp and operation id, so two plans of an unchanged document always digest the same and a plan drawn against a changed document never does. `inspect` reads back exactly the plan or receipt a station’s own node journaled for that operation — it never re-derives anything. `apply` is the only verb that writes, and it refuses to run without `--plan-digest`: that digest must be the one `plan` printed for this operation, so a human reviewed the exact edit being written rather than whatever the current plan happens to be by the time `apply` runs.
`create` covers a key the document does not have yet, including the case where its whole top-level section is absent — the ordinary state of a freshly adopted station whose operator has customised nothing. That section is **appended at the end of the document**, holding only the declared key, so no line already there is moved, reindented or reflowed. A section that *is* there but holds something this editor will not extend — a scalar, a bare key with nothing under it, a list, or an inline `{...}` mapping — is refused `SHAPE_UNEXPECTED` and named, never rewritten. Only one level of section is ever created.
`fleet config plan --station ID` plans **every** setting currently declared for that station, at whatever level resolves to it — there is no flag to narrow the plan to one setting. A setting the station is opted out of is left out of that plan and named in `refused` instead (see [Opting a station out](#opting-a-station-out)) — the rest of the declared settings still get planned.
A worked example, declaring and then writing `hermes.approvals.mode` for one station:
```sh
$ fleet config set hermes.approvals.mode --value strict --station st_abc
$ fleet config plan --station st_abc
{
"schemaVersion": 1,
"operationId": "cfgop_3f9a2c7b1e6d48a09c21",
"stationKey": "st_abc",
"entries": [
{
"settingId": "hermes.approvals.mode",
"file": "/home/agentpod/.hermes/profiles/st_abc/config.yaml",
"keyPath": "approvals.mode",
"policy": "reconcilable",
"current": "standard",
"intended": "strict",
"action": "modify",
"restartToTakeEffect": true
}
],
"beforeSha256": "1b80374ee731c363440504e674caf2d26d72ee818a2482571e6beff6de397d5a",
"diff": "- mode: standard\n+ mode: strict\n",
"diffTruncated": false,
"noOp": false,
"restartRequired": true,
"createdAt": "2026-10-05T09:12:03Z",
"planDigest": "c68e0c65d2a58aa481e8fdf83422dbb4712a8b97e41fb907bdb3109adc503077"
}
$ fleet config inspect --station st_abc --operation cfgop_3f9a2c7b1e6d48a09c21
{ "plan": { ... as above ... }, "phase": "planned", "updatedAt": "2026-10-05T09:12:03Z", "written": [] }
$ fleet config apply --station st_abc --operation cfgop_3f9a2c7b1e6d48a09c21 \
--plan-digest c68e0c65d2a58aa481e8fdf83422dbb4712a8b97e41fb907bdb3109adc503077
{ "plan": { ... }, "phase": "applied", "written": [{"settingId": "hermes.approvals.mode", "action": "modify", "wrote": "strict"}],
"afterSha256": "318e20b3a675cd8701af3cab0b019abbab63ca2e1183b77fa39a7e538ffb50ab", "updatedAt": "2026-10-05T09:12:41Z" }
```
(Operation ids and digests above are illustrative, not literal output.) On the node, the plan and its receipt are journaled in `.agentpod/config-operations.json` — a sibling directory next to the harness’s own config, not inside it, because the harness rewrites and migrates its own config document and a receipt that must survive that rewrite cannot live anywhere under it.
Re-deriving the plan at `apply` time, rather than trusting the one `plan` printed earlier, is what catches a document that changed in between — see [refusals](#every-refusal-and-what-distinguishes-it) below for `PLAN_STALE` and `PLAN_DIGEST_MISMATCH`.
### A lost race preserves both edits
[Section titled “A lost race preserves both edits”](#a-lost-race-preserves-both-edits)
`PLAN_STALE` catches a document that changed **before** the write. A narrower window remains **after** it: the write itself lands, and then, in the moment between that write and the read-back that confirms it, something else changes the same file again — an operator’s own “Allow always” through Hermes’ UI, say, landing a heartbeat after `apply` wrote its own edit.
When that happens, this system does not pick a winner. The document on disk is left **exactly as the read-back found it** — whatever landed there in that window is not overwritten, and the edit `apply` itself intended is not thrown away either. It is written instead to a sidecar file beside the original, named by appending `.agentpod-rejected` to the document’s own filename — `config.yaml` gets `config.yaml.agentpod-rejected`, `openclaw.json` gets `openclaw.json.agentpod-rejected`. The operation’s receipt (`fleet config inspect`) records `"phase": "conflict"` with an `error` naming both paths and stating plainly that neither edit was lost.
That sidecar is bookkeeping for a human, not for this system: a later lost race against the same document overwrites whatever an earlier one left there, and nothing here ever reads a sidecar back — not on a retry, not on the next `plan`. Reconciling the two files, if they need reconciling at all, is a manual step. A fresh `fleet config plan` against the document as it now stands is the way forward; it has no reason to know the sidecar exists.
### `awaiting-restart`: written, but not yet in effect
[Section titled “awaiting-restart: written, but not yet in effect”](#awaiting-restart-written-but-not-yet-in-effect)
A setting whose policy requires a restart to take effect — every registered Hermes and OpenClaw setting today — is reported `awaiting-restart` right after `apply` writes it, until the harness’s gateway actually restarts. **AgentPod will not restart the harness for you.** Nothing in this system issues a restart; an operator restarts the gateway, from wherever they already do that, and the state changes on its own once observed.
The decision is made by comparing a gateway process id, not by a timer. Hermes and OpenClaw both multiplex one gateway process across every profile or subagent on a host, so that process’s pid is exactly the one that re-reads config. `apply` reads the station’s health right after a successful write and records that pid. The next comparison reads the station’s **current** gateway pid and reports `awaiting-restart` whenever that pid is unchanged from the one recorded at write time — or when either pid cannot be confirmed at all: the current one (health degraded, harness stopped) or the one that was supposed to be recorded at write time, which is left unset whenever that post-write health read timed out or came back in an unexpected shape. An unconfirmable pid on either side deliberately stays `awaiting-restart` rather than resolving to `matches`: claiming a restart is still needed when it already happened costs one needless restart; claiming a restart already happened when it hasn’t produces false agreement, which is the worse mistake.
Whether `hermes.approvals.*` truly needs a restart to take effect is itself unverified — Hermes’s own documentation lists hot-reloadable settings and settings needing a restart, and `approvals.*` is in neither list. The registry assumes `true` deliberately: an unnecessary restart is cheaper than a running gateway that silently disagrees with its own config file. See `docs/superpowers/notes/2026-10-05-hermes-approvals-restart.md` for what was checked and why it stayed inconclusive. `hermes.plugins.*` and `hermes.skills.external_dirs` are not the same kind of guess: Hermes reads a profile’s plugin and skill configuration when its gateway starts, which is what `apn hermes-skills` itself already tells an operator after writing, so `RestartToTakeEffect` for those three is a documented fact rather than an assumed one.
## Seeing and applying it from the console
[Section titled “Seeing and applying it from the console”](#seeing-and-applying-it-from-the-console)
Everything above has a CLI-free path too, on a station’s own page — its **Configuration** tab, which also holds that station’s plugins and its skills, because on a Hermes station all three write the same configuration document. A **declared configuration** panel sits at the top of that tab whenever the station advertises the `config.manage` capability; a station that does not advertise it shows nothing in its place, not an empty panel, and a station with nothing configurable at all has no Configuration tab.
A link to the older `?tab=skills` still works — it lands on the Configuration tab.
The panel lists one row per registered setting with something declared for that station: the declared value and the level it came from (station, node, or fleet), the observed value, and the state — all seven: `matches`, `drifted`, `absent`, `unreadable`, `out-of-scope`, `opted-out`, `awaiting-restart`, each with its reason shown alongside it. It is read-only for exactly as long as there is nothing to review: a “Review changes” button asks the hub for a plan over this station’s declared settings, the same `plan` a `fleet config plan` call would get back — the file, the key path, current → intended, the action, and the diff, plus the plan’s own digest. Nothing is written at that point. “Apply reviewed plan” sends that operation id and **that exact digest**, never a digest re-fetched or re-derived at apply time — the console goes through the identical reviewed-plan-then-digest discipline the CLI’s `plan`/`apply` pair enforces, against the same routes, because the digest is the record that a human saw the edit before it was written. A plan that refuses some of the requested settings (an `OPTED_OUT` entry mixed in with plannable ones, say) names each refused setting, its code and its message, beside whatever the plan did manage to derive — a partial refusal never quietly disappears the rest of the plan.
**The console never restarts anything**, here or anywhere else. A row reading `awaiting-restart` says so plainly, and if a restart is needed to apply what you are about to review, the panel says that too, before the apply button, not after. Use the station’s own restart control (reached from this same panel when the page wires one up) once you are ready.
**Exemptions are visible here, not editable.** An `opted-out` row shows which level the exemption came from, who recorded it and why, when it rode in through agentpod’s own register — and it reads differently again when the harness’s own record is the source (Hermes’ `plugins.disabled`, OpenClaw’s `plugins.entries.agentpod-errors.enabled: false`), never conflating the two. Recording or clearing an exemption is still only `fleet config opt-out` / `fleet config opt-in` — the console does not write this register, by design: reading it is what made the `opted-out` state legible, and giving it its own write surface here was ruled out rather than added silently.
None of this replaces the raw config editor reached from the file browser’s own “Edit” action on an open file. That editor is unchanged, and it is still the only way to touch anything in a harness’s config file that is not a registered setting — this panel sits beside it, for exactly the settings the registry knows about, and absorbs none of its job.
## Adopting a station: one reconcile, not a sweep
[Section titled “Adopting a station: one reconcile, not a sweep”](#adopting-a-station-one-reconcile-not-a-sweep)
When a station is adopted, every setting already declared for it — at station, node, or fleet level — is reconciled exactly once, right after the station’s row is written. A setting is skipped, not written, when its policy is `report-only`, when it’s opted out, or when it already matches; otherwise it’s planned and, if the plan isn’t a no-op, applied with that plan’s own digest — the same `plan`-then-`apply` path described above, run on the station’s behalf.
**A write failure here never fails the adoption.** Every setting on every station is isolated: a plan refusal, an apply refusal, an unreachable node, or an unreadable document is recorded against that one station (visible as its config reason) and never thrown. A station adopted with one setting unwritten is better than one not adopted at all.
**And it never holds the adoption open for long.** Stations are reconciled concurrently rather than one after another, and the whole pass is capped: past the cap the adoption answers, and the stations still in progress keep going and keep recording their own config reason. That matters because a node can be *connected but wedged*, where every round trip pays its full timeout instead of failing fast — adopting many stations onto such a node used to leave the request running long after the rows were committed, so a client that gave up reported a failed adoption of stations that were adopted.
The cap is one round trip’s worth of time, while a station’s pass makes several of them in sequence, so a merely *slow* node reaches the cap too — that is the ordinary case, not the exceptional one. A station still in progress when the cap fires is reported as **pending**, never as failed: nothing has gone wrong, the pass simply has not finished, and it records its own outcome against the station when it does. Reconcile also re-checks that a station is still adopted before it writes anything, so unadopting a station right after the adoption answers cannot leave a write landing on it afterwards.
This reconcile runs exactly once, at the moment of adoption — never on a timer and never swept across already-adopted stations. A station that drifts afterward stays drifted until someone asks for it (`fleet config drift`) or declares and applies again; running this on a schedule would race the harness’s own rewrites of the same file, or silently undo a change an operator just made through it.
## Every refusal, and what distinguishes it
[Section titled “Every refusal, and what distinguishes it”](#every-refusal-and-what-distinguishes-it)
A refusal that cannot be told from a pass is the failure this whole area keeps hitting, so every one of these is distinct and carries its own message. There are ten:
| Refusal | What distinguishes it |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `UNKNOWN_SETTING` | The setting id isn’t in any reachable registry — or no node could be asked to confirm either way; the message, not the code, tells you which. |
| `NOTHING_DECLARED` | The setting id IS in the station’s live registry, but nothing is declared for that station at any level and no value was given to plan against. Remedy: `fleet config set`. Distinct from `UNKNOWN_SETTING` because the id is real and the remedy is a different command. |
| `NODE_UNREACHABLE` | The node could not be asked at all (offline, timed out, disconnected, or answered in an unexpected shape). Remedy: retry once it is back. Never used for a refusal the node itself named — a node that answers with a refusal is a node that was reached. |
| `OUT_OF_SCOPE` | The declaration’s own target has no document to land in at all (for example, declaring against Hermes’s composite root, which has no profile-scoped document of its own) — a plan-time refusal, distinct from the `out-of-scope` *comparison state* reported for a per-station declaration of a non-`profile`-scoped setting. |
| `SHAPE_UNEXPECTED` | The declared value doesn’t fit the setting’s registered shape (an `additive-only` value that isn’t a list of strings, say), or the derived edit would change something outside the keys this plan claims to touch. |
| `PLAN_STALE` | The document changed after this plan was reviewed. Remedy: re-plan and re-review. |
| `PLAN_DIGEST_MISMATCH` | The digest `apply` was given does not match the one this station’s journal recorded for that operation. Remedy: re-read the plan that was actually reviewed (`inspect`), not re-plan — the document itself hasn’t necessarily changed. |
| `OPTED_OUT` | An explicit operator opt-out (see [Opting a station out](#opting-a-station-out)). `plan` produces it per setting — refusing just the opted-out entries of a mixed request, or the whole request when every setting named was opted out — before `config.plan` is ever dispatched for one of them. `apply` produces it for the whole operation when a setting the plan covers was opted out after the plan was made, before `config.apply` is ever dispatched; nothing in that plan is written. |
| `UNREADABLE` | The document could not be read or parsed at all; nothing about its contents is inferred. |
| `CREDENTIAL_PATH` | The setting’s target resolves to a credential file (`auth.json`, `.env`, or a path under a `credentials` directory) and is refused before it is ever opened, let alone read or edited. |
`PLAN_DIGEST_MISMATCH` was added during implementation, after the seven above were specified, because collapsing it into `PLAN_STALE` would make two conditions with different remedies indistinguishable from their refusal code alone. `NOTHING_DECLARED` and `NODE_UNREACHABLE` are the two the hub produces on its own: no node can produce them, because only the hub knows what is declared and only the hub knows it could not get an answer.
**A refused plan is a refusal, not a plan.** `fleet config plan` exits non-zero on one, and the HTTP route answers 400 when re-sending the same request could never work (`UNKNOWN_SETTING`, `NOTHING_DECLARED`, `OUT_OF_SCOPE`, `SHAPE_UNEXPECTED`, `OPTED_OUT`, `CREDENTIAL_PATH` — the remedy is a different request, usually `fleet config set`) and 409 when the request was well-formed and lost to the state of the document or the station (`UNREADABLE`, `PLAN_STALE`, `PLAN_DIGEST_MISMATCH` — the remedy is to re-read and re-send) — never 200 with a refusal in the body, which no script could tell from a plan. `SHAPE_UNEXPECTED` is a 400 because neither of its causes resolves itself: a declared value that does not fit the setting has to be re-declared, and a derived edit that would disturb the document never succeeds on a retry of the same request. `OPTED_OUT` is a 400 whether the hub or the node is the one that noticed. The code and the sentence are the node’s own wherever the node is the one that refused, and at adopt time they are what gets recorded against the station, so a recorded reason names the refusal rather than blaming the connection.
## A setting that can’t be found
[Section titled “A setting that can’t be found”](#a-setting-that-cant-be-found)
Declaring a setting the hub doesn’t recognize is refused with `UNKNOWN_SETTING`. The refusal’s reason is read verbatim from the hub — it may mean the setting genuinely doesn’t exist in any reachable harness’s registry, or it may mean no node was reachable to confirm either way. Those are two different problems with the same refusal code, and only the reason text tells you which one you have.
# When a turn fails
> One error shape whichever harness failed, the card a client can draw, and the plugins that recover what ACP drops.
A model provider runs out of quota. A credential expires. A request is malformed. What should reach the room is the provider’s own sentence.
What used to reach it depended entirely on which harness the agent ran. Six harnesses fail six different ways over ACP: some reject the prompt with real text in the message, some emit the error as an ordinary message chunk and resolve the turn normally — so the error arrives **posing as the agent’s answer** — and some drop the error entirely and resolve the turn, so the room says “the agent completed without a reply” while the real reason sits in a log on a machine you are not looking at.
AgentPod standardises this itself rather than waiting for six upstreams.
## One error shape
[Section titled “One error shape”](#one-error-shape)
Every failed turn is normalised into one shape, whichever harness produced it: a **kind**, the provider’s or harness’s **own words untrimmed**, which harness it was, and — when they are knowable — the provider, the model, and each attempt that was made.
The kinds are a closed vocabulary: `quota`, `rate_limit`, `auth`, `bad_request`, `context_exhausted`, `timeout`, `provider_unavailable`, `refusal`, `max_tokens`, `cancelled`, `node_offline`, `harness_exited`, and `unknown`.
`unknown` is there deliberately. A failure that does not fit is reported as not fitting, rather than squeezed into the nearest kind and misread later.
## The card in the room
[Section titled “The card in the room”](#the-card-in-the-room)
A failure is posted as a notice carrying a **card a client can draw** — the kind, the sentence, the harness, and the attempts if there were several. A client that does not understand the card shows the prose; a client that does draws the card.
Four rules keep it honest:
* **One error per failed turn**, led by the model that was actually asked for. A turn that tried three models does not produce three cards; it produces one card listing three attempts.
* **The error is held until the turn ends, and dropped if the agent answers.** An error mid-turn that the harness then recovers from is not a failure, and reporting it would teach you to ignore the cards.
* **A recovered fallback is no error.** If the first provider failed and the second answered, you got an answer.
* **A run that ended well after its failure was reported is no error either.**
Errors reach encrypted rooms like anything else.
## Recovering what ACP drops
[Section titled “Recovering what ACP drops”](#recovering-what-acp-drops)
Two harnesses dropped their error before it ever reached the hub, so no amount of care at the hub could recover it. For those, AgentPod ships a small plugin that runs inside the harness and reports what the harness knows and the protocol discards:
| harness | what it ships | installed with |
| -------- | ------------------------------- | ------------------------------------ |
| OpenClaw | the `agentpod-errors` plugin | `apn openclaw-errors enable --apply` |
| Pi | the `agentpod-errors` extension | `apn pi-errors enable --apply` |
They report the failure; the hub normalises it into the shape above.
Both are installed **on the node, with `apn`** — they are not reachable through the console or `fleet plugins`, which manage one plugin on one harness (see below). Without `--apply` each command prints the exact change and writes nothing. Neither restarts anything you did not ask it to: the OpenClaw gateway is yours to restart, and each new Pi session picks its extension up by itself.
### The third plugin, and the one the console manages
[Section titled “The third plugin, and the one the console manages”](#the-third-plugin-and-the-one-the-console-manages)
`agentpod-live` reports a turn’s *progress* rather than its failure — that the agent is thinking, that an answer has begun streaming, and when the turn ended. A harness-mode Hermes profile streams into AgentPod clients only with it.
It is the **only** plugin the console and `fleet plugins` can install, enable or disable, and only on Hermes. That path is off by default and a node opts in with `apn plugin-management enable`; after that each change is planned on the node, reviewed, and applied only if the profile still matches the review. Nothing there restarts a gateway.
`apn hermes-live` does the same thing locally, per profile.
## Why not just fix the harnesses
[Section titled “Why not just fix the harnesses”](#why-not-just-fix-the-harnesses)
The upstream issues are reported. Nothing here waits on them being accepted, because a fleet running six harnesses cannot have its error reporting gated on six other projects’ release schedules — and a harness you pinned for a different reason would keep the old behaviour anyway.
## Next
[Section titled “Next”](#next)
* [Talking to an agent in a room](/use/rooms/) — where the card arrives
* [apn and fleet](/use/cli/) — installing and reviewing plugins
# Dispatch and grants
> Who may send an agent work — read from the signed token, never from the request, and never read as "everyone".
Sending an agent a message spends its time. So does approving a tool call for it, and so does putting a card in front of it. All three are **dispatch**, and all three ask the same question: may this principal dispatch that one?
## The answer is in the token
[Section titled “The answer is in the token”](#the-answer-is-in-the-token)
```plaintext
GET /api/fleet/dispatchable
```
Returns the agents the token’s holder may dispatch. The answer is read from a `mayDispatch` claim the hub **signed into the token** — not from a query parameter, not from a header, and not derived from anything else the caller sent. There is nothing in the request that can change it.
**A missing or non-array claim is read as *permitted nothing*.** Never as *permitted everything*. An absent claim means the issuer does not speak that control, and reading that as “all” is the one mistake that cannot be walked back.
An agent’s token is refused at this endpoint regardless of what it may dispatch. An agent enumerating its siblings is an agent doing reconnaissance.
## A grant is a document
[Section titled “A grant is a document”](#a-grant-is-a-document)
```sh
fleet grants list
fleet grants show
fleet grants set --file grant.json # or --file - for stdin
fleet grants rm
```
`set` takes a **file**, not flags. A grant is a document rather than a field: the hub validates its whole shape, and flattening it into flags would mean the CLI modelling a schema the hub owns and then disagreeing with it the first time that schema moves. `-` reads stdin, so a grant can be piped from whatever produced it.
## Where a grant is checked
[Section titled “Where a grant is checked”](#where-a-grant-is-checked)
| act | checked |
| ----------------------------------------------------- | ------------------------------------------------------------- |
| messaging an agent in its room | ✓ |
| approving a permission question from a room | ✓ — approving is dispatching by another name |
| claiming a card for an agent through the board bridge | ✓, on the board’s side, against what the queuer was permitted |
| reading a station’s logs or files | no — that is operating a machine, not dispatching an agent |
The distinction is worth holding onto. Operating the ground an agent stands on is a different authority from spending the agent’s time, and AgentPod keeps them apart.
## Principals, and switching one off
[Section titled “Principals, and switching one off”](#principals-and-switching-one-off)
```sh
fleet principals list
fleet principals suspend
fleet principals restore
```
Suspending a principal stops it acting without deleting what it did. The record of its past work stays answerable, which is the point of having principals at all.
For people rather than principals:
```sh
fleet users list
fleet users show
fleet users role …
fleet users ban
fleet users unban
```
## A node’s credential is not an authority
[Section titled “A node’s credential is not an authority”](#a-nodes-credential-is-not-an-authority)
A node’s secret says **“I am this host”**. It is not permission to operate the fleet, and AgentPod deliberately never lets it become one — `fleet` commands never fall back to a node’s credential.
This is why there are two binaries. See [apn and fleet](/use/cli/).
## Next
[Section titled “Next”](#next)
* [Authentication](/build/auth/) — where a token comes from, and where it may be spent
* [Talking to an agent in a room](/use/rooms/) — the most common way a grant is spent
# Nodes
> Running the node agent day to day — service control, logs, status, updates, and removing a node from the fleet.
A node runs one background service. These are the commands that manage it.
Every command on this page is a **node verb**: it acts on *this machine*. Each also has an explicit spelling — `apn status` and `apn node status` are the same command. The bare forms are kept because existing runbooks name them.
## Is it working?
[Section titled “Is it working?”](#is-it-working)
```sh
apn status
```
Two blocks. **Local**: whether the service is installed, enabled and running, with its PID, the binary version and the config path. **Hub**: whether the hub is reachable and the stored credential is still valid.
The exit code is `0` only when the service is running *and* the credential is valid, so this drops straight into a health check. Add `--json` for scripts.
## Starting and stopping
[Section titled “Starting and stopping”](#starting-and-stopping)
```sh
apn start # enable and start
apn stop # stop and disable
apn restart # restart in place
```
`apn stop` is **sticky**: it disables the service as well as stopping it, so it will not come back on its own after a reboot or a fresh login. `apn start` is the exact inverse — it re-enables and starts. This is deliberate; a “stop” that silently undoes itself overnight is worse than no stop at all.
Underneath, macOS uses `launchctl` and Linux uses `systemctl`.
## Logs
[Section titled “Logs”](#logs)
```sh
apn logs # recent
apn logs -f # follow
apn logs -n 200 # last 200 lines
```
On macOS this reads `~/Library/Logs/agentpod-node.log`. On Linux it hands off to `journalctl`.
## Installing and removing the service
[Section titled “Installing and removing the service”](#installing-and-removing-the-service)
```sh
apn service install
apn service uninstall
```
`install` writes a launchd plist or a systemd unit from a template embedded in the binary, then enables and starts it. It is idempotent — running it again replaces the file and restarts.
Where it installs depends on who you are. Non-root Linux gets a `--user` unit; root Linux gets a system unit; macOS always gets a LaunchAgent and **refuses to run as root**.
`uninstall` stops, disables and removes the unit. It is idempotent too, and it leaves your config and enrollment alone — uninstalling the service does not un-enroll the machine.
## Running in the foreground
[Section titled “Running in the foreground”](#running-in-the-foreground)
```sh
apn run
```
This is what the service runs under the hood. Run it directly when you are debugging and want the output in front of you.
## Updating
[Section titled “Updating”](#updating)
```sh
apn update --check # report current and latest, change nothing
apn update # update
apn update --force # update even if already latest
```
On success the service restarts automatically. If that restart fails, the binary has already been swapped — the command tells you so and prints how to restart by hand, rather than leaving you to guess which half happened.
## Enrolling again
[Section titled “Enrolling again”](#enrolling-again)
```sh
apn enroll --hub https://hub.example.com --token
```
Falls back to `$AGENTPOD_HUB_URL` and `$AGENTPOD_ENROLL_TOKEN` when the flags are omitted.
Running it on a machine that is already enrolled is a no-op, unless the stored credential has stopped being valid or you pass `--force`.
## Removing a node from the fleet
[Section titled “Removing a node from the fleet”](#removing-a-node-from-the-fleet)
A machine you retire does not leave the fleet on its own; uninstalling the service only stops it. To take it off the list, remove it from the hub — from the node’s page in the Console (**Remove node**, confirmed by typing its name), or with `fleet`:
```sh
fleet nodes rm build-01 # a node that is offline
fleet nodes rm build-01 --force # a node that is still connected: disconnect it too
```
Removal unregisters every station on the node, the same way **Remove station** does, and revokes the node’s credential. If the machine dials back, the hub refuses it, and `apn enroll` with its old token fails too: to join again it needs a fresh token from `fleet invite`. Nothing on the machine itself is touched — workspace files stay, and the node agent keeps running until you `apn service uninstall` it.
Two kinds of node are refused, each with the command that does apply:
* **A provisioned runtime’s node** goes with its runtime: `fleet runtimes rm ` destroys the container and removes the node.
* **A node where a bridge agent runs** keeps its roster row, which holds a credential, until you remove it on purpose with `fleet bridge rm `.
The hub records each removal in its admin audit log.
## Next
[Section titled “Next”](#next)
* [Stations](/use/stations/) — what lives on the node
* [apn and fleet](/use/cli/) — the `fleet` client, which acts as *you* rather than as the machine
# What you can do to a station
> The panels — files, logs, terminal, config, health, lifecycle, cleanup, changesets, skills, voice and activity — and what each one requires.
Open an adopted station in the console and you get a set of panels. Which ones appear depends on what the station declares it can do.
## Capability gating
[Section titled “Capability gating”](#capability-gating)
Several operations are gated on a capability the station declares:
| Capability | Gates |
| ----------- | ------------------------------------ |
| `fs.write` | Writing to the filesystem |
| `terminal` | The interactive shell |
| `lifecycle` | Start, stop, restart |
| `cleanup` | Planning and applying a disk reclaim |
| `changeset` | Reading the working-tree diff |
A request for a capability the station has not declared is refused **at the hub, without contacting the node**. Reading is not gated this way — the read paths are available wherever the station supports them.
## Files
[Section titled “Files”](#files)
Browse the tree, preview a file, jump straight to a path with quick-open, and edit where `fs.write` is declared.
Under the hood these are the node’s own verbs — `fs.list`, `fs.read`, `fs.write`, `fs.mkdir`, `fs.move`, `fs.delete` — executed on the host by the local agent, never by the hub reaching in.
## Logs
[Section titled “Logs”](#logs)
Tail the harness’s logs live. The node knows where each harness keeps them, because the descriptor for that harness knows.
## Terminal
[Section titled “Terminal”](#terminal)
A real interactive shell on the host, brokered through the hub. Requires the `terminal` capability.
## Config
[Section titled “Config”](#config)
Read and edit the harness’s own configuration file, in place and in its own format — AgentPod does not convert it to something else and hand it back.
## Health
[Section titled “Health”](#health)
What the station reports about itself, asked live. The console shows this next to the node’s own online/offline state, which is a different question: a node can be perfectly online while a station on it is not.
## Lifecycle
[Section titled “Lifecycle”](#lifecycle)
`start`, `stop`, `restart`. Requires the `lifecycle` capability.
Every lifecycle action is written to the audit trail **before** it is dispatched, not after. An action that is attempted and fails still leaves a record — an audit log that only lists successes is one that hides exactly the events you most need.
The response is the station’s health *after* the action, so you can see the result rather than assume it.
## Cleanup
[Section titled “Cleanup”](#cleanup)
Two steps, deliberately:
```plaintext
POST /api/stations/:id/cleanup/plan what would be removed, and how much it frees
POST /api/stations/:id/cleanup/apply do it
```
The plan is shown to you first. Nothing on disk is touched until you apply it. Requires the `cleanup` capability.
## Changesets
[Section titled “Changesets”](#changesets)
What the agent has actually changed in its working tree — `status` for the summary, `diff` for the detail. Requires the `changeset` capability.
A node advertises `changeset` only for a station whose workspace is a **git repository**. A newly provisioned runtime’s `/workspace` is an empty directory, so it has no Changes panel, and `fleet station changeset` is refused with 403 and “its workspace is not a git repository”. That is the design, not a fault: clone or `git init` into the workspace, and the hub picks the capability up the next time the station is adopted or its node reconnects.
This is the panel for the question “what has this thing been doing”, answered from the files rather than from what the agent says about itself.
## Activity
[Section titled “Activity”](#activity)
The audit trail for the station: what was done, when, and by which principal.
## Chat
[Section titled “Chat”](#chat)
Where a station has a chat identity, talking to the agent is messaging it. See [Stations](/use/stations/#matrix-identity) for the two identity modes, and [Talking to an agent in a room](/use/rooms/) for what else arrives there.
## Skills
[Section titled “Skills”](#skills)
What this station actually has, and the review screen for installing something. Present, eligible and loaded are reported as separate observations rather than inferred from each other — see [Managed skills](/use/skills/).
## Plugins
[Section titled “Plugins”](#plugins)
Harness plugins on this station, and reviewed changes to them: the node plans, you read the plan, and the apply sends the digest you reviewed. Nothing here restarts the station.
## Voice notes and voice replies
[Section titled “Voice notes and voice replies”](#voice-notes-and-voice-replies)
Two sections, each offering *inherit*, *off*, or settings of its own, over the hub-wide defaults in **Admin → Transcription** and **Admin → Speech**. The voice-replies section also shows which voice this station was assigned, and lets the owner pick another. See [Voice notes](/use/voice/).
## Git identity
[Section titled “Git identity”](#git-identity)
What this station can push to a forge as, if anything. Push access is granted per station and never by adopting one — most stations never touch git, and a key for every station is an account nobody uses and a key nobody revokes.
The keypair is generated **on the node**; the private half never leaves it and the hub holds no secret. Granted and revoked with `fleet stations grant-push` / `revoke-push`.
## Posture
[Section titled “Posture”](#posture)
A banner surfaces exposure problems found on the host — the same checks [`apn scan`](/use/scan/) runs locally.
## Next
[Section titled “Next”](#next)
* [Attaching an editor](/use/acp/) — your own editor against a remote station
* [Managed skills](/use/skills/) — the review model behind the Skills panel
* [Talking to an agent in a room](/use/rooms/) — the other half of a station
* [Checking for exposure](/use/scan/)
# Talking to an agent in a room
> The Matrix bridge — every station gets an identity and a room, so an agent whose harness never spoke Matrix can be reached from a phone.
A station is a place an agent works. It is also, if you turn the bridge on, **somebody you can message**.
Every adopted station gets a Matrix identity and a room of its own. You send a message, the hub hands it to the harness as a turn, and the agent’s reply comes back as that agent — in a client you already have, on a phone you already carry. The harness itself never learns Matrix exists.
This is the half of AgentPod that is not a console. The console is for operating the ground an agent stands on; the room is for working with the agent.
## What you get per station
[Section titled “What you get per station”](#what-you-get-per-station)
| | |
| --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **an identity** | the station appears as a user, named for its node and station key. The member list shows the readable form. |
| **a room** | one per station, created when the station is adopted |
| **a space** | rooms hang under a space named for the machine they run on. A flat roster is fine at thirty agents and unusable at two hundred; clients that read the hierarchy group the list for free. |
Rooms are **encrypted at creation**. Any client that can read an encrypted room can read these; there is nothing extra to turn on.
## What reaches the room
[Section titled “What reaches the room”](#what-reaches-the-room)
* **The agent’s replies**, posted as the agent, streaming as the turn progresses
* **Voice notes**, in both directions — see [Voice notes](/use/voice/)
* **Permission questions**, when the agent wants to run a tool and its mode says to ask
* **A card when a turn fails**, saying what failed rather than going silent — see [When a turn fails](/use/errors/)
* **Approval gates and questions from a board**, if the workspace is linked to a [SuperPipeline](https://docs.superpipeline.dev) board — see [Working a board](/use/boards/)
* **Images** you send, passed to the agent rather than just their file name
## Answering a permission question
[Section titled “Answering a permission question”](#answering-a-permission-question)
An agent in `ask` mode parks when it wants to run a tool, and the question is posted where you are:
```plaintext
Permission needed: Write src/main.ts
1. Allow once
2. Allow always
3. Reject
Reply with the number, or the option's name.
```
Reply `1`, or `Allow once`. **Nothing else counts.** A reply that is not plainly one of the options approves nothing and shows the list again.
That pedantry is deliberate. Against options named *Allow once* and *Allow always*, a bare “yes” does not say which — and approving a tool call you did not mean to approve is the one failure worth being awkward about.
A question **stops standing** the moment the turn moves on: answered in the console, cancelled, or failed. A room can never approve something that was already decided.
**It expires after 30 minutes.** A question nobody answers in that time is withdrawn: the agent is told it was not given permission (it is not left waiting for ever), and the room says so. A reply that arrives after a question closed — expired, or answered in the console a moment earlier — is refused in the room. It is **never** passed to the agent as a message, so a late `1` cannot turn into an instruction. Once you have talked to the agent again, a number is an ordinary message.
**Updated apps show what you are allowing.** The same message also carries the request in full for apps that understand it: the tool, its command, each file change and the files it touches, with buttons that say what each option does (*allow once*, *always*, *reject*). Such an answer is bound to exactly the request you saw — if the agent asked something else since, it is refused. When the card could not show everything (a very long change, a terminal), nothing in the room can allow it — not a button, and not a typed number either; answer in the console. Whoever answers, by button, by number or in the console, is recorded with the answer, and the card closes wherever it was answered.
**A board room carries more than gates and questions.** A card waiting on a person (blocked, failing, not authorised) and a SuperToolkit approval request arrive there too — see [Boards](/use/boards/#a-card-waiting-on-you). Answers to them are never passed to an agent as a message. A decision nobody answers in time is reminded and then escalated in the same room, as a reply to its card — see [When nobody answers](/use/boards/#when-nobody-answers).
## Messaging an agent is dispatching it
[Section titled “Messaging an agent is dispatching it”](#messaging-an-agent-is-dispatching-it)
Sending a message spends the agent’s time, so it needs the same authority as dispatching it any other way — and **approving a permission question is dispatching by another name**, so it needs that authority too.
Who may message which agent is read from a `mayDispatch` claim the hub signed into your token. It is not a header, not a parameter, and not derived from anything sent alongside. See [Dispatch and grants](/use/grants/).
## Mid-turn messages are held, not refused
[Section titled “Mid-turn messages are held, not refused”](#mid-turn-messages-are-held-not-refused)
A message that arrives while the agent is mid-turn is **held and delivered when the turn ends**, rather than refused. The room says so as a notice. Refusing would make the room a worse place to talk than a terminal, which is the opposite of the point.
## Notifications on a phone
[Section titled “Notifications on a phone”](#notifications-on-a-phone)
The hub is the **push gateway**. A client registers a pusher with the homeserver, the homeserver calls the hub for every event that should reach a phone, and the hub turns each call into one push per device.
A **message** push carries the room, the event id and an unread count — not the message. The device fetches and decrypts the event itself and words the notification locally. A push that carried the text would put an encrypted room’s contents through a service that has no business seeing them.
One addition, and it is counts rather than text: the push for an agent’s answer that ended a turn with tool calls also carries how many steps ran and how many failed, which is what a client’s widget recap reads.
### The one exception: the fleet Live Activity
[Section titled “The one exception: the fleet Live Activity”](#the-one-exception-the-fleet-live-activity)
By operator decision of 2026-09-29, the Lock Screen fleet card is **pushed by the hub**, and those pushes carry **agent names, the current step’s title, and a pending decision’s question and option labels in plaintext** — readable by Apple in transit.
That is a deliberate widening of the gateway’s otherwise strict line, taken so the card can say something useful rather than “an agent needs you”. It is the only place it applies: message pushes still carry ids only, and nothing else changed.
A gate that offers a choice of versions never offers an approval on the Lock Screen — the card cannot show the versions — only a rejection; you choose in an updated app or on the board.
A permission request or a gate the hub posted is also tagged on the push as time-sensitive, so it reaches a person through Focus — the tag, not the text.
Pushes are configured per deployment and are optional: a hub with no push configuration answers that route with a `404` and still boots. Pushes are not worth taking a control plane down for.
## Turning it on
[Section titled “Turning it on”](#turning-it-on)
The bridge is off unless the hub is configured for it: a homeserver URL, a server name, and the application-service tokens. Deployment detail lives in the operator guide rather than here, because it is a property of your hub and not of the product.
With the bridge off, everything else in AgentPod works exactly as it does now. Nothing in the console depends on it.
## Next
[Section titled “Next”](#next)
* [Voice notes](/use/voice/) — speaking to an agent, and being answered
* [When a turn fails](/use/errors/) — the error card, and the harness plugins behind it
* [Working a board](/use/boards/) — agents that take work off a SuperPipeline board
* [Dispatch and grants](/use/grants/) — who may talk to whom
# Checking for exposure
> apn scan — a local security check for agent runtimes that needs no hub, no account and no network.
```sh
apn scan
```
`apn scan` checks the agent runtimes on this host for the two ways they get taken over:
* **a listener bound to every network interface** — an agent’s API reachable from outside the machine
* **credential files other users can read** — the keys the agent works with, sitting where anyone on the box can take them
It needs **no hub, no account and no network**. It works on a machine that has never heard of AgentPod, which is the point.
## Running it without installing AgentPod
[Section titled “Running it without installing AgentPod”](#running-it-without-installing-agentpod)
The installer in [Your first node](/start/first-node/) enrolls the machine, so it wants a hub and a token. `apn scan` wants neither — download the released binary on its own and run it where it lands:
```sh
curl -fsSLO https://github.com/SuperJackfruitLabs/agentpod/releases/latest/download/agentpod-node-linux-amd64
curl -fsSLO https://github.com/SuperJackfruitLabs/agentpod/releases/latest/download/SHA256SUMS
sha256sum --ignore-missing -c SHA256SUMS # macOS: shasum -a 256 --ignore-missing -c SHA256SUMS
chmod +x agentpod-node-linux-amd64
./agentpod-node-linux-amd64 scan
```
Each release publishes `agentpod-node-linux-amd64`, `agentpod-node-linux-arm64`, `agentpod-node-darwin-amd64` and `agentpod-node-darwin-arm64`. `apn` is only the installer’s name for this binary; downloaded directly, it keeps the release name. Nothing is written to the machine besides the file you downloaded.
## Exit codes
[Section titled “Exit codes”](#exit-codes)
| Code | Means |
| ---- | -------- |
| `0` | Clean |
| `1` | Warnings |
| `2` | Critical |
That makes it usable directly in cron or CI.
```sh
apn scan --json # machine-readable
apn scan --no-color # plain output for logs
```
## It does not guess
[Section titled “It does not guess”](#it-does-not-guess)
A check that cannot determine an answer reports **unknown**, and says why. It is never reported as a pass.
This is a deliberate design rule, and it costs something: you will sometimes see “unknown” where a more confident tool would show a green tick. That is the trade being made. A scanner that cries wolf gets ignored, and a scanner that quietly calls an unknown a pass is worse than one that says nothing.
There is also **no CVE feed**. A version-to-CVE database rots the moment it ships, and it would turn a static binary into something that has to phone home to stay honest. Every check here is a property of the machine as it is right now.
## In the console
[Section titled “In the console”](#in-the-console)
The same checks surface as a posture banner on nodes and stations, so an exposure found on a host you have not logged into lately is still in front of you.
## Next
[Section titled “Next”](#next)
* [apn and fleet](/use/cli/) — the rest of the surface
# Managed skills
> Knowing what skills a station actually has, installing one under review, and proving a session loaded it.
A skill is a package an agent can use. Copying one onto a machine is easy; **establishing that a running session loaded it** is the hard part, and it is the part that matters — an agent with a skill it did not load behaves exactly like an agent without it.
AgentPod treats that as the whole problem. Nothing here grants a product permission: a skill is a capability of an agent’s environment, not an authority.
## Inventory: what is actually there
[Section titled “Inventory: what is actually there”](#inventory-what-is-actually-there)
```sh
fleet skills station verify --station --profile
```
Inventory asks a station what skills it has and reports each one’s name, where it was observed, its scope, the digest of its entrypoint, its declared dependencies, and whether anything shadows it.
The design rule is that **nothing is inferred from anything else**. These are separate observations:
| observation | means |
| ----------- | ------------------------------------- |
| catalogued | the catalog knows about it |
| present | it is on disk where it should be |
| eligible | the harness would consider loading it |
| loaded | a session has it |
| exercised | it has actually run |
Present does not imply eligible. Eligible does not imply loaded. An empty compatibility list proves nothing. A known observation carries a timestamp; an **unknown observation carries a reason** — never a silent `false`.
The same applies to coverage. Every response says which roots it read and which it could not, so an unread root is reported as unobserved rather than treated as empty. Unreadable roots, malformed entries and bounds reached all stay visible.
**Inventory never imports code**, executes hooks, starts a model, probes credentials, refreshes a session or restarts a station. Skill bodies and credentials never leave the node.
A request carries only a station key. A caller cannot supply a filesystem root, another profile, a command, or an authentication path — the node resolves the station it has actually detected and scans only the roots that station’s descriptor selects.
## Plan, inspect, apply
[Section titled “Plan, inspect, apply”](#plan-inspect-apply)
Every change is a reviewed change, in three steps:
```sh
fleet skills station plan --station --artifact
fleet skills station inspect --station --operation
fleet skills station apply --station --operation --plan-digest
```
The apply names the **digest of the plan it is applying**. If the plan changed between your reading it and your applying it, the apply does not match and does not proceed. This CLI never turns a plan into an implicit apply: every mutation returns the hub’s reviewed record, and you are expected to read it.
Rolling back is a plan of its own:
```sh
fleet skills station rollback-plan --station --profile
```
## Artifacts and releases
[Section titled “Artifacts and releases”](#artifacts-and-releases)
```sh
fleet skills upload --harness --profile archive.tgz
fleet skills artifacts
fleet skills artifact delete --id
fleet skills release import release.json
fleet skills releases
```
An uploaded archive is **validated at admission** rather than at install time, and an artifact is immutable once admitted. A release names a set of artifacts, and a release is a record you import rather than a thing you edit — so what a cohort was given stays answerable after the fact.
## Cohorts and canaries
[Section titled “Cohorts and canaries”](#cohorts-and-canaries)
A release reaches a fleet gradually:
```sh
fleet skills cohort create --release --digest --station
fleet skills cohorts
fleet skills canary plan --cohort --release --digest --station
fleet skills canary inspect --cohort --release --digest --station --operation
fleet skills canary apply --cohort --release --digest --station --operation --plan-digest
```
A cohort is **immutable and bound to a release**: you cannot quietly change which stations got what. A canary applies that release to one station and verifies the result, so a bad skill is found on a station you chose rather than across the fleet.
## Native placement
[Section titled “Native placement”](#native-placement)
Some harnesses load skills as native plugins at a discovery root rather than as files in a workspace. Those have their own verbs, because activating one is a different act from copying it:
```sh
fleet skills native plan --station --profile --action activate|deactivate|rollback
fleet skills native verify --station --profile
fleet skills native apply --station --operation --plan-digest
```
Native placement is **fail-closed** and gated on an operator setting: a station does not start loading native skills because a package arrived. Verification opens a fresh session and checks the skill is loaded there, because a package manifest is content evidence and not proof of registration or execution.
Duplicate names across harnesses are **reported, not resolved**. AgentPod does not invent a cross-harness precedence rule it would then have to defend.
## From the console
[Section titled “From the console”](#from-the-console)
The console carries the same surface: a station’s skill inventory, the review screen for an installation, which harness an upload is for, trusted releases, and canary controls. The CLI exists so the same operations can be scripted and so a release can be rolled without a browser.
## Next
[Section titled “Next”](#next)
* [What you can do to a station](/use/panels/) — the rest of a station’s surface
* [apn and fleet](/use/cli/) — the two binaries, and which one acts as you
# Stations
> Detecting, adopting and organising the runtimes on your nodes.
A **station** is one runtime instance on a node — a single place where an agent works.
## Detection versus adoption
[Section titled “Detection versus adoption”](#detection-versus-adoption)
These are two different things, and the distinction is the point.
**Detection** is the node agent looking at the host and reporting what it finds. It happens without you asking, and it changes nothing.
```sh
apn detect
```
Prints what this host has, as JSON. No hub connection, no account required.
**Adoption** is you deciding a station should be managed. Only an adopted station is in the registry, and only an adopted station has panels.
The gap between them is deliberate. A machine may run agents that are none of AgentPod’s business, and detecting one is not consent to manage it.
## The tree
[Section titled “The tree”](#the-tree)
Stations nest. A harness that manages sub-runtimes appears as a parent with children beneath it, which is why the console shows a tree under each node rather than a flat list:
```plaintext
node: workhorse
└── hermes
├── coder-kai
└── hanuman
```
Deleting a parent removes its children with it — a child station cannot outlive the station it runs inside.
## Capabilities
[Section titled “Capabilities”](#capabilities)
Each station declares which operations it supports. A request for a capability a station has not declared is refused **at the hub**, with no call to the machine at all.
That ordering matters for a reason worth stating: a refusal that never leaves the hub cannot be turned into a way to probe what is on the host.
## Purpose
[Section titled “Purpose”](#purpose)
A station’s **purpose** is what it is for — `personal`, `work`, or any label you pick. It is not where it runs.
Set it on the station. A node’s purpose is only the default applied at adoption to a station that has none of its own; the station’s is the one anything actually reads. A station nobody has labelled is filed under no Matrix space at all.
## Matrix identity
[Section titled “Matrix identity”](#matrix-identity)
A station can have a Matrix identity, so that talking to an agent is just messaging. Two modes, and never both at once:
| Mode | Means |
| --------- | ---------------------------------------------- |
| `bridge` | The Application Service speaks for the station |
| `harness` | The station runs its own Matrix client |
In `bridge` mode the identity is minted by the Application Service and stored separately from whatever the harness reports about itself. Nothing on the host can report that identity, so nothing on the host can erase it.
The station’s room is created with it, and **created encrypted** — there is nothing extra to turn on, and no window in which it existed in the clear. Rooms hang under a space named for the machine they run on, so the roster groups itself in any client that reads the hierarchy.
An agent can be given an **avatar**: preview a workspace image and set it as the station’s picture. A roster of thirty identical default avatars is a roster you read by name only.
What else arrives in that room — voice notes, permission questions, a card when a turn fails, a board’s approval gates — is [Talking to an agent in a room](/use/rooms/).
## Git identity
[Section titled “Git identity”](#git-identity)
A station can be given a key to push to a forge with:
```sh
fleet stations git-identity --station
fleet stations grant-push --station
fleet stations revoke-push --station
```
**Granted per station, never by adopting one.** Most stations never touch git, and a forge key for every station is an account nobody uses and a key nobody revokes.
The keypair is generated **on the node** and the private half never leaves it: `grant-push` asks for the public half and registers that. The hub holds no secret, so a hub compromise does not hand over commit access.
**Commits carry the agent, not the host.** With the key, the hub sends the station’s commit author: the agent’s display name in readable form (a handle like `fixture-agent` — or a station key like `harness:fixture-agent` — becomes `Fixture Agent`; without a display name, the forge account’s full name minus its `(agent)` suffix), and the forge account’s email exactly as forge stores it. That email is synthetic — an agent has no mailbox — and it is what the forge links a commit to the account by, so the name carries no “(agent)” suffix: the email’s domain already says it. The node records the author beside the key and starts the station’s harness and terminal with `GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `GIT_COMMITTER_NAME` and `GIT_COMMITTER_EMAIL` — environment, never git config, so nothing is written to any repository or to the host. A station with no identity gets none of these and keeps the host’s git config. `git-identity` shows the author as `authorName`/`authorEmail`. An identity provisioned before authors existed gets one the next time its node connects (a node update reconnects it), with no key regenerated. A harness or terminal already running keeps the environment it started with.
## Forge API access
[Section titled “Forge API access”](#forge-api-access)
A station’s agent can be given scoped access to a forge’s **API** (issues, pull requests, comments, reviews) for named repositories. This is separate from [git identity](#git-identity): SSH keeps carrying git, and neither grant ever creates the other.
```sh
fleet stations forge-api list --station
fleet stations forge-api grant --station --provider forgejo \
--instance https://forge.example --repo org/repo --preset issues
fleet stations forge-api verify --station --grant
fleet stations forge-api rotate --station --grant
fleet stations forge-api policy --station --grant --preset issues \
--rule 'deny: DELETE /repos/**'
fleet stations forge-api revoke --station --grant
```
**The agent never holds the token.** The station’s broker (`stk`) holds the credential and adds it to the requests its policy allows, whichever client the agent uses (`tea`, `gh`, `curl`, an MCP server). The hub delivers the credential **sealed** to the station’s broker key. For a supplied or app grant the hub relays ciphertext it cannot open; for a minted grant it holds the token briefly between minting and sealing (see Limits).
**Operator only.** Every verb except `list` needs the hub operator seat **and** the control pair’s reach over the station’s agent; owning the station is not enough (403). An agent must not, and cannot, grant itself access. The forge account is never taken from the request: a minted grant is for the station’s occupying agent, a supplied or app grant for the `--expect-login` you declare. A minted grant on a station with no occupant is refused (409); a supplied or app grant does not need one.
**Three credential modes.**
| Mode | What it is | Where the credential comes from |
| ------------------ | --------------------------------------------------------------- | ---------------------------------------------------------------------------------- |
| `minted` (default) | The agent’s own forge account | The hub mints a token, limited to the listed repositories, on its configured forge |
| `supplied` | A machine account you made by hand (a GitHub PAT, for instance) | You, on stdin |
| `app` | A GitHub App installation, acting as the bot | You, on stdin: `app_id=…`, `installation_id=…`, `private_key=…` lines |
Today the hub accepts Forgejo `minted` (only on its configured forge) and `supplied`, and GitHub `app` and `supplied`. Gitea and Bitbucket are refused until their adapters exist.
A supplied or app credential is **never a flag** (there is no `--token`). It is read from stdin, sealed on your machine to the station’s broker key (fetched once from the hub, before anything is created), and only the sealed envelope is sent:
```sh
pbpaste | fleet stations forge-api grant --station --provider github \
--instance https://github.com --repo org/repo --mode supplied --expect-login octo-bot
```
If the grant is created but the envelope does not arrive, the CLI names the grant: re-send with `fleet stations forge-api secret --station --grant ` (add `--mode app` for an App’s fields). A sealed credential the station does not acknowledge within 24 hours is discarded.
**Policy.** A grant names its repositories and a preset: `read`, `issues`, `pull-requests` (no merge), `maintain`, or `none` (start from nothing). `--rule 'allow: METHOD /path'` and `--rule 'deny: METHOD /path'` add to it, repeatable; a rule with a body condition goes in `--rules-file`, a JSON array such as `[{"allow": "PATCH /repos/org/repo/issues/*", "when": {"body.state": "absent"}}]`. Rules narrow the token and never widen it; a `deny` wins; `DELETE` is denied by every preset. `policy` **replaces** the preset, rules and GraphQL setting (GraphQL is off unless `--allow-graphql`, so omitting the flag turns it off), and needs no new credential. A minted token’s provider-side scopes follow a changed preset only from the next `rotate`; until then the broker’s rules are what apply.
**Status.** `list` prints each grant’s state, mode, account, isolation, provider, instance, preset and repositories, and never a token. States run `authorised → provisioned → delivered → verified`, plus `failed`, `revoking` and `revoked`. `verified` means the broker made a real read call as the expected account **and** a node that reports bypass evidence showed the broker carries the station’s traffic. How `verify` reads that evidence:
* A client that **bypassed** the broker fails the grant (`failed`, reason `bypassed: `), even one that was `verified` before.
* **No path routed** through the broker (`not-wired: `) is inconclusive: the state is kept, so a first verify stays `delivered`; the reason is written only if it would not replace another lifecycle reason.
* The identity matched but the node could not show the traffic is brokered: the grant stays (or goes back to) `delivered` with `coverage-incomplete: `.
* Brokered, but not every client was measured: `verified` with the note `coverage: partial (untested: )`. The harnesses’ own clients have no safe probe today, so they report `untested` and **most grants verify with partial coverage**: the generic clients were seen going through the broker, the harness’s own client was not checked.
* A node too old to send bypass evidence verifies as before (identity only).
A verify can take up to 90 seconds, because the node probes its clients first. Isolation is `separate-user` (the broker runs as its own OS user) or `same-user` (degraded, see below).
**Rotation and revocation.** `rotate` (minted) mints a new token, delivers and verifies it, then revokes the old one; if verification fails the old token stays live. For a supplied or app grant, rotate by sending a new credential with `secret`. `revoke` first wipes the station’s copy (best effort; queued if the node is offline, which cuts the station’s access at once), then revokes a minted token at the forge, retrying until it succeeds. Reassigning, unassigning or deleting a station (or removing its node) marks its grants `revoking` in the same change and never waits on the forge; the reconcile sweep (every 5 minutes, and when a node reconnects) finishes the job. A new occupant starts with nothing.
**Needs a node that supports it.** A station’s node must advertise the `forge.api` capability; an older node answers `capability-missing` rather than doing nothing.
**One broker per station.** Each station gets its own broker on the node, with its own OS user, store, policy and proxy key, so one station’s credential is never in another’s store, and two stations can hold grants for the same forge host. The node creates it the first time it is needed and must run as root on Linux with systemd to do so; otherwise the node does not offer forge API access at all.
**What that does not do.** The node starts agent sessions and terminals as itself, not as a user of the station’s own, so the agents it starts run as the node’s user (root, on a node with brokers). An agent that is root, or that shares a user with another station, can read what the broker protects, so such a station’s grants report isolation `same-user` (`isolationDetail`: `node-uid` or `shared-uid`) and the node refuses a second station on a node (`station-uid-shared`) unless the operator acknowledges that the stations are not isolated from each other. Separate brokers keep credentials apart, not agents. An instance is never removed automatically: `apn forge-api prune --station --yes` does it, by hand. A grant’s `present` in `list` means the node holds a policy entry for it, not that the credential was delivered or works; `verify` says that. An operator can also list services every station needs in a *shared base policy* file, which the node copies into each station’s own policy (`/etc/agentpod/stk-shared.yaml` by default; its credentials are not delivered for you). The proxy port range and the file locations are settings in the node config; the operating guide lists them.
### Limits
[Section titled “Limits”](#limits)
* **No protection against root.** An agent or user with root on the station can read the broker’s store.
* **Same-user mode is weak, and today it is every station; write grants need your acknowledgement.** When the broker runs as the agent’s own user, only file permissions separate them, and the agent can read the broker’s store. Agents spawned by the node run as the node’s uid, so every station currently reports isolation `same-user` (`isolationDetail` `node-uid`, or `shared-uid` when acknowledged on the node). A **write grant** is any preset except `read` (`issues`, `pull-requests`, `maintain` and `none` all count), or `read` with an `allow` rule for a method other than GET, HEAD or OPTIONS. The hub refuses a write grant (409 `same-user-acknowledgement-required`) unless the operator passes `--acknowledge-same-user` (`acknowledgeSameUser: true`), on `grant` and on a `policy` that moves a grant to write. It assumes same-user unless the node has shown `separate-user` for the station’s grants, so a supplied or app write grant always needs it today. If a node’s delivery reports `same-user` for an unacknowledged write grant anyway, the grant is not delivered: the node’s copy is wiped, a minted token is revoked, and the grant is `failed` with that reason. The acknowledgement is an operator decision: the hub records who gave it and when (from the signed-in account, never the request body), the audit row records the request, `list` shows only that it was given, and a rotation keeps it. Read-only grants never need it. Agents must never pass the flag.
* **A node preparing its broker answers later, not never.** The first grant on a station can make the node install its broker instance; the hub waits up to 120 s for that (`forge.api.status`, `forge.api.deliver`). If an install is already running the grant answers 503 `station-preparing` with `Retry-After`, and nothing is created; a delivery that meets it is retried with the same sealed blob. A node that refuses the station answers 409 with its code: `station-not-hosted`, `station-key-changed`, `station-uid-shared` or `stk-unavailable`.
* **The hub sees a minted token for an instant**, between minting and sealing it. It stores only the sealed envelope.
* **Every forge-api route needs the hub’s Forgejo admin configuration.** Without `FORGE_URL` and `FORGE_ADMIN_TOKEN` on the hub, every `forge-api` route answers 503, even for a GitHub-only (`app` or `supplied`) grant, and the reconcile sweep does not run.
* **agentpod cannot revoke a supplied token at its provider.** `revoke` removes it from the station; revoke it at the provider yourself, then run `fleet stations forge-api confirm-revoked`.
* **A client that ignores proxy settings bypasses the broker.** It sends a placeholder and gets a plausible 401; detection is by test, per harness, not by prevention. `verify` runs stk’s probe through the station’s own instance (curl, python3, node and a Go client against a local stand-in at a host that cannot resolve) and reports each as `honoured`, `bypassed`, `refused-ca`, `untested` or `failed` in the ack’s `bypass.clients`. A `bypassed` client makes the verify `ok: false` with a reason starting `bypassed:`. The station’s own harness client is `untested` unless a safe probe command is configured (none ships), and is never reported `honoured` without one. `bypass.paths` says which harness paths the node routed through the broker (`spawn`, and `gateway` for hermes/openclaw) and `bypass.wiringBlocked` why a path is not. `bypass.brokered` is wiring AND measurement: true only when every path is wired, the probe ran, and no client is `bypassed`, `failed` or `refused-ca`. `bypass.measured` is `complete` only when every client row (including the harness’s own) is `honoured`, else `partial`; an untested client is never evidence. The probe takes up to 45 s, is skipped when stk’s own verify already failed, and a clean result is reused for 60 s per station (never a failure; any deliver, policy, revoke or wiring change drops it). Harness probes are refused on a root node. A station with no wired path at all is `ok: false` with `not-wired:`. The hub reads all of this as described under **Status**: `bypassed` fails the grant (a verified one too), `not-wired` is inconclusive (state kept), `brokered: false` keeps or puts the grant at `delivered` with a `coverage-incomplete` note, and `brokered: true` with `measured: partial` verifies with a `coverage: partial` note. A rotation still finishes on a station that cannot show brokered traffic: once the node acknowledged the new token and its identity matched, the old token is revoked; the grant stays `delivered` with the coverage note. The reconcile sweep does not re-verify a not-brokered grant within 30 minutes; `fleet stations forge-api verify` always runs.
* **Body-level rules cover JSON REST only.** A rule that would have to read a GraphQL or non-JSON body cannot be enforced.
* **Forgejo’s token listing and repository confinement are to be verified live** against the real forge before relying on them for orphan cleanup and repository restriction.
## Staffing
[Section titled “Staffing”](#staffing)
Putting an agent in a station, or taking it out:
```sh
fleet staff options
fleet staff assign --station --file |-
fleet staff unassign --station
```
## Cleaning up
[Section titled “Cleaning up”](#cleaning-up)
Removing a station from the registry is not the same as deleting anything on the machine. For reclaiming actual disk, see the cleanup panel in [What you can do to a station](/use/panels/) — which plans first, shows you the plan, and only then applies it.
## Next
[Section titled “Next”](#next)
* [What you can do to a station](/use/panels/)
* [Talking to an agent in a room](/use/rooms/)
* [Managed skills](/use/skills/)
* [Attaching an editor](/use/acp/)
# Voice notes
> Speaking to an agent, and being answered out loud — transcription in, synthesis out, and what each falls back to.
An agent in a bridged room can be sent a voice note, and can answer with one. Both directions are optional, both are configured hub-wide and overridable per station, and neither invents anything when it is not configured.
## Sending a voice note
[Section titled “Sending a voice note”](#sending-a-voice-note)
A voice note is **transcribed before the agent sees it**. The transcript is posted in the room as a reply to the note, and the agent receives the text marked as what it was:
```plaintext
[Voice note, 0:42, transcribed]
```
So the agent works from text, you keep the audio, and anybody reading the room later can see both.
**Configure** it in the console under **Admin → Transcription**: the provider, its URL, the model, the API key, and the longest note to accept. *Test connection* sends one second of silence rather than making you record something. Per station, the station page’s **Voice notes** section offers *inherit*, *off*, or its own settings.
Anything that speaks OpenAI’s `/v1/audio/transcriptions` works, self-hosted or hosted.
**With nothing configured, a voice note reaches the agent as a note saying it could not be heard.** It is not silently dropped and not silently passed through as an empty message — the agent is told there was something it could not hear, which is a thing it can respond to.
API keys are stored encrypted and never shown again.
## Being answered out loud
[Section titled “Being answered out loud”](#being-answered-out-loud)
A bridge-mode agent — any harness whose messages the hub posts — can follow its text with a voice note.
| setting | meaning |
| -------------------- | ------------------------------------- |
| `voice_in` (default) | answer a voice note with a voice note |
| `always` | speak every turn that ended with text |
| `off` | never |
`voice_in` is the default because it mirrors you: speak to the agent and it speaks back; type and it types.
### Which voice
[Section titled “Which voice”](#which-voice)
Most specific first: the station’s own voice, else the hub’s default, else **one assigned from the station’s id**. The assignment is a hash over a curated list, so it is stable across restarts and different agents mostly sound different without anybody choosing.
The station page shows it as *Assigned: …*, and the owner may pick another — or type a blend of two.
### What is spoken, and what is not
[Section titled “What is spoken, and what is not”](#what-is-spoken-and-what-is-not)
The whole turn’s text as posted. A turn that flushed in parts — around a permission question, say — is joined, so you hear one answer rather than fragments.
Over the length limit, it is spoken up to the last sentence end that fits, and **nothing is added**. No “truncated” announcement: the text above has the rest, and a voice note that editorialises about itself is worse than one that simply stops at a sentence.
Never spoken: an error turn, a silent turn, an empty turn. One voice reply per turn.
### Text first, always
[Section titled “Text first, always”](#text-first-always)
The text is posted first and is **never delayed by speech**. Synthesis starts after the turn ends and is not awaited.
If the speech service is down, busy, slow, or refuses the upload, the hub logs one warning and posts nothing — **no error card**. The answer already arrived; a second message saying the answer could not be read aloud is noise about a cosmetic failure.
Logs carry duration, character count, voice and latency. Never the text.
## In an encrypted room
[Section titled “In an encrypted room”](#in-an-encrypted-room)
A voice note is encrypted like any other attachment — the audio is encrypted, the ciphertext uploaded as opaque bytes, and the event itself encrypted like every other message from that agent. Clients that understand voice notes draw the waveform; clients that do not show an audio file.
A voice reply is **not** sent as a reply-to of its own text. Every client that does not know the key for “this speaks that message” would quote the whole answer again above the voice note, so the link is carried in a field instead.
It is also marked as a quiet event, so your phone does not buzz twice for one answer.
## Next
[Section titled “Next”](#next)
* [Talking to an agent in a room](/use/rooms/) — the bridge these ride on
* [What you can do to a station](/use/panels/) — where per-station settings live