Declared harness settings
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”fleet config set hermes.approvals.mode --value strictThis 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.
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.
fleet config unset hermes.approvals.moderemoves 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”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.
fleet config set hermes.approvals.mode --value strict # fleet-widefleet config set hermes.approvals.mode --value strict --node nod_123 # one nodefleet config set hermes.approvals.mode --value strict --station st_abc # one stationA 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”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:
# a list, by repeating --valuefleet config set hermes.approvals.command_allowlist --value "git status" --value "ls"
# a ONE-entry list, a number, a boolean — the forms repeating cannot expressfleet 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”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”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”fleet config settingslists 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”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”fleet config show # every declaration in the fleet, as storedfleet config show --node nod_123 # one node's declarations, as storedfleet config show --station st_abc # one station: declared vs observed, comparedfleet config drift # every station whose value differs from what's declaredOnly --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. |
awaiting-restart |
A value was written but the harness hasn’t picked it up yet — see awaiting-restart. |
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 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”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”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 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.enabledexplicitlyfalse.
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”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”This is the subtle half of the feature, and the two are not interchangeable:
fleet config opt-in hermes.approvals.mode --station st_abcrecords 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.
fleet config opt-out hermes.approvals.mode --station st_abc --cleardoes 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”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”# 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) — 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”fleet config plan --station ID the edit that would be made, and its digestfleet config inspect --station ID --operation ID a plan already made, as it was reviewedfleet config apply --station ID --operation ID --plan-digest SHA256plan 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) — the rest of the
declared settings still get planned.
A worked example, declaring and then writing hermes.approvals.mode for one station:
$ 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
below for PLAN_STALE and PLAN_DIGEST_MISMATCH.
A lost race preserves both edits
Section titled “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”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”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”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”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). 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”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.