The desired-state file#
One file says what the router's configuration should look like; the agent reads the live
configuration, works out the set and delete commands that close the gap, and hands
them to the same staging area everything typed into the web UI goes through. The file
mirrors the router's configuration tree. There is no Wheelhouse-specific schema:
if you can write the node path in set, you can write it here as nesting.
service:
dns:
forwarding:
cache-size: 10000
ssh:
qos:
interface:
eth1:
egress: WAN-OUT$ wheelhouse-agent plan --file /config/wheelhouse/agent.yaml
set service dns forwarding cache-size 10000
set service ssh
warning: service.ssh: planned as a bare set (a node with no value)
2 change(s) needed in /config/wheelhouse/agent.yamlWhere the file lives#
| Surface | Path |
|---|---|
wheelhouse-agent plan / apply | --file, defaulting to <data-dir>/agent.yaml |
| The reconcile loop | --reconcile-file, a full path |
GET /api/reconcile/file?file= | a bare name inside the data directory, defaulting to agent.yaml |
POST /api/reconcile | no file at all: the document is the request body's desired_state |
agent.yaml is a convention, not a format promise — the parser sniffs the content, so a
.yaml file holding JSON works and a mislabelled file fails on its contents rather than
silently parsing into the wrong shape.
The API route is narrow: the name must end .yaml, .yml or .json, must not begin
state., and must resolve inside the data directory — no separators, no .., and an
absolute path only if it lands in the data directory anyway. Without those rules the
endpoint would render state.json — accounts, password hashes, TOTP secrets and raw
session ids — as a diff of set operations.
Format sniffing#
| First non-whitespace byte | Parsed as |
|---|---|
{ or [ | JSON |
| anything else | YAML |
The YAML path decodes through the node tree rather than into a typed map, so the text in the file is the value:
| Written | If it were decoded as typed data | What happens |
|---|---|---|
rule: 10: | a map keyed by the integer 10, taken for a leaf, planned as set … rule '' | a tag node named 10 |
mode: 0755 | the integer 493 | the string 0755 |
version: 1.10 | the float 1.1 | the string 1.10 |
expires: 2026-09-02 | an RFC 3339 timestamp | the string 2026-09-02 |
The router stores text. Anything that retypes a value on the way in produces a diff that never converges.
A key with no value#
A mapping key with nothing after the colon (ssh: in YAML, {} in JSON) means this
node exists — the shape the configuration tree uses for a node that is switched on
rather than set to something. What it does depends on what the router holds there, so
the engine decides against live state:
| Live state | Result |
|---|---|
| Nothing there | A bare set service ssh, plus the warning service.ssh: planned as a bare set (a node with no value). |
| A node that already has children | Satisfied. Nothing planned. |
| A node holding a value | Refused. system.host-name: the router holds "edge" here but the file gives it no value — give it a value, or drop the key (full mode deletes it). |
No operation can express that last case: the router cannot set a node that requires a value to nothing.
An explicit empty string is a different thing and is refused earlier, where the document is read, before any diffing:
service.dns.forwarding.cache-size: an empty value is not something VyOS can set — give
it a value, or drop the key (a key with nothing after the colon switches a node on)The check walks the whole document, so a blank in a subtree the diff never reaches is still reported, and the message names the file rather than the router.
Multi-valued leaves#
A node that holds several values — name-server, address, allow-from — is written
as a list and diffed value by value:
system:
name-server:
- 192.0.2.53
- 198.51.100.53Each missing value becomes its own set system name-server <value>; in full mode each
extra live value becomes its own delete. A single value may be written bare rather
than as a one-element list; the router reads back a single-valued node as a string and a
multi-valued one as an array, and both shapes compare the same way.
An empty element anywhere in a list is refused with the same message as an empty scalar.
Partial and full#
| Mode | Flag | Emits |
|---|---|---|
| Partial (default) | — | set operations only. Nodes the file does not mention are left alone. |
| Full | --full, --reconcile-full, or "full": true | Also a delete for every live node the file does not declare, at every level it walked. |
How a value is compared#
Every value in the configuration tree is text. A document may spell the same thing typed
— YAML cache-size: 10000, JSON true — while the router answers "10000". Both sides
are canonicalised to their string form before comparison.
Warnings#
Warnings are advisory, never refusals: the operations they accompany are still what would change.
| Warning | Means |
|---|---|
<path>: planned as a bare set (a node with no value) | The file switched a node on. Check that it is a switch and not a value you forgot to type. |
They come back in the warnings field of POST /api/reconcile and
GET /api/reconcile/file, on stderr from plan, and the reconcile loop logs a set of
warnings once per change rather than once per pass, so a steady file stays quiet.
Reading against the router, not the cache#
The diff always reads the router's configuration uncached. The agent's read cache is invalidated by the agent's own commits, so a change made over SSH would otherwise stay invisible for up to an hour.
plan exit codes#
| Code | Means |
|---|---|
| 0 | The router already matches the file. |
| 2 | It does not. There are operations to apply. |
| 1 | Something failed: the file will not parse, holds an empty value, contradicts the router, or the router did not answer. |
Exit 2 is the diff -q convention, so plan drops into a shell test or a CI gate
without parsing text:
if ! wheelhouse-agent plan --file /config/wheelhouse/agent.yaml >/dev/null; then
echo "router has drifted"
fi--json gives a machine-readable document instead of lines:
{
"file": "/config/wheelhouse/agent.yaml",
"full": false,
"ops": [{"op": "set", "path": ["service", "dns", "forwarding", "cache-size", "10000"]}],
"count": 1,
"warnings": []
}In an operation, for a set, the last path element is the value. That is the
router's own /configure convention, and it is why a path element may never be
empty.
The four surfaces, and what each may do#
| Surface | Reads | May commit |
|---|---|---|
wheelhouse-agent plan | file | never |
wheelhouse-agent apply --agent-url | file | no — it stages into a running agent's Commit Bar |
wheelhouse-agent apply --commit --confirm-minutes N | file | yes, behind the router's own commit-confirm |
POST /api/reconcile | request body | no — apply: true stages, and the Commit Bar owns the commit |
GET /api/reconcile/file | a file in the data directory | never |
| The reconcile loop | --reconcile-file | only in --reconcile-mode commit |
The reconcile loop#
Off unless --reconcile-file is set. One pass is: read the file, read the router, diff,
then either stage the difference or commit it.
| Behaviour | Detail |
|---|---|
| First pass | --reconcile-delay after start, 15 s by default. |
| Interval | --reconcile-interval, 60 s by default, clamped up to a 10 s floor. |
| Default mode | stage: the difference goes to the Commit Bar and waits for a person. |
| Commit mode | --reconcile-mode commit, with --reconcile-confirm minutes, 2 by default. |
| Licence | Required. Without one the loop records no usable licence: the reconcile loop is paused until one is entered and changes nothing. |
| Idempotence | Operations already in the working set are not re-staged. An operation a person un-staged is offered again on the next pass. |
| Audit | Every pass that acts writes an entry with actor reconcile-loop, role system, operation reconcile-stage or reconcile-commit, and the rendered commands. |
In commit mode with a confirm window, the loop commits, then re-reads the configuration. If the router answers, it confirms. If the router has stopped answering it does not confirm and says so, leaving the router to roll back on its own timer.
State is readable at GET /api/drift:
{"managed":true,"file":"/config/wheelhouse/agent.yaml","mode":"stage",
"drifted":true,"ops":2,"checked_at":"2026-09-02T22:27:07Z"}mode:"off" means no file is being managed. The three
wheelhouse_desired_* metrics are absent entirely in that case.
See also#
wheelhouse-agent—planandapply.- Every flag — the six
--reconcile-*flags. - Endpoint index —
/api/reconcile,/api/reconcile/file,/api/drift. - Exit codes
- Prometheus metrics — the drift gauges.
- Audit entries — what the loop records.
- Desired state — the idea.
- State against intent