Desired state#
A desired-state file says what the router should look like. The agent reads it, reads the
router, and works out the set and delete operations that would close the gap. Those
operations travel the same road as anything typed into a form: they are staged, shown as
commands, and committed by a person — unless you deliberately start a loop in commit mode,
which commits behind the router's own commit-confirm window. This is
not a second control plane. It is a different way of filling the same
working set.
The file mirrors the configuration tree#
The document is the shape of the config tree, nested. Keys are node names, values are leaf values.
service:
dns:
forwarding:
cache-size: 10000
ssh:
qos:
interface:
eth1:
egress: WAN-OUTThat plans exactly what it reads as:
set service dns forwarding cache-size 10000
set service ssh
set qos interface eth1 egress WAN-OUTBoth formats parse, chosen by content. A document starting with { or [ is read as
JSON; anything else is read as YAML. So agent.yaml holds YAML, an inline document in an
API body stays JSON, and a mislabelled file fails on its contents instead of being parsed
into the wrong shape.
Values keep the spelling you wrote. The router stores text, so values are carried
through unconverted: 0755 decoded normally becomes 493,
1.10 becomes 1.1, a date becomes a timestamp, and a mapping under a numeric key such as
rule: 10: becomes something the engine would read as a leaf and plan as set … rule ''.
A key with no value#
ssh: in YAML, {} in JSON, means this node exists — the shape the config tree uses for
a switch-on node. What it plans depends on what the router already holds, so the engine
reads it against live state:
| On the router | What the engine does |
|---|---|
| Nothing there | Plans a bare set service ssh, and the plan carries a warning saying so. |
| A node that already has children | Satisfied. Nothing planned. |
| A node holding a value | A contradiction, and the plan refuses to run: system.host-name: the router holds "edge" here but the file gives it no value. |
An explicit empty string is different, and is refused where the document is read, before
any diffing, with the hint an empty value is not something VyOS can set — give it a value,
or drop the key. Warnings come back in the API's warnings field; the loop logs a set of
warnings once, not once per pass.
Partial by default, full on request#
By default the engine emits set operations for differences only. It never deletes
something the file does not mention, so a partial file — "these six things must be true" —
is safe next to configuration that nothing manages.
--full (on the command line), full: true (in an API body) or --reconcile-full (on the
loop) changes that: what the file does not declare is deleted. It is the right mode for a
file that is meant to be the whole router, and the wrong one for a fragment.
Four ways to use it#
| Surface | Default behaviour | Escape hatch |
|---|---|---|
| The Reconcile page | Stages into the Commit Bar | — |
POST /api/reconcile | Returns the plan; apply: true stages it | — |
GET /api/reconcile/file | Diffs a file inside the agent's data directory against live | — |
wheelhouse-agent plan | Prints the commands, changes nothing, exits 2 when it found work | --json, --full |
wheelhouse-agent apply | Stages through a running agent (--agent-url) | --commit --confirm-minutes N |
--reconcile-file (the loop) | Stages (--reconcile-mode stage) | --reconcile-mode commit, window from --reconcile-confirm |
plan exits 2 when the router differs from the file, the way diff -q does, so it drops
into a shell test or a CI gate without anyone parsing text.
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 exit 2
# Stage into a running agent's Commit Bar; a person still commits:
wheelhouse-agent apply --file /config/wheelhouse/agent.yaml \
--agent-url https://127.0.0.1:8443
# Or commit from cron, behind the router's own commit-confirm:
wheelhouse-agent apply --file /config/wheelhouse/agent.yaml --commit --confirm-minutes 2Two refusals:
applywithout--agent-urlrefuses to stage. The working set is the running agent's memory. A one-shot process filling its own copy would report success and lose the work on exit, so staging is aPOST /api/stageto the agent, authenticated with the break-glass token.--commitwithout a confirm window refuses. Automation may commit, never silently.
GET /api/reconcile/file takes a name inside the agent's data directory, never a path.
An absolute path is accepted only when it resolves inside the data directory, and the name
must end in .yaml, .yml or .json. The default name is agent.yaml.
The loop#
Off unless asked for. --reconcile-file <path> starts it; it waits --reconcile-delay (15
seconds by default, so boot can settle) and then re-diffs every --reconcile-interval (60
seconds by default, clamped to a 10-second floor).
- In stage mode it fills the Commit Bar and stops. The Commit Bar stays the thing that commits.
- In commit mode it commits with a
--reconcile-confirmminute window (2 by default) and confirms only once the router still answers, so a change that cut the agent off is left to roll back on its own.
It will not stage the same operation twice. Operations it has already put in the working set are not offered again until somebody commits or un-stages them. An operation deliberately un-staged is offered again on the next pass; the working set is the source of truth, not the loop's memory.
Its writes are attributed to it. Everything the loop does is audited as
actor: reconcile-loop, role: system, so the record never blurs a machine write with a
human one — see The audit log.
Reading the drift without a shell#
curl -sk -H "Authorization: Bearer $T" "$R/api/drift"
# {"managed":true,"file":"/config/wheelhouse/agent.yaml","mode":"stage",
# "drifted":true,"ops":2,"checked_at":"2026-09-02T10:27:07Z"}managed: false and mode: "off" mean no file is being managed, which is the default. The
metrics wheelhouse_desired_managed, wheelhouse_desired_drift and
wheelhouse_desired_pending_ops exist only while a file is being managed.
What it is not#
- It is not the agent's state file.
state.jsonis the agent's own store — accounts, tokens, sessions, settings, the licence key. - It is not templated. One file, one router, no per-host substitution. Templating and file-watch triggers are not built.
- It is not a second source of truth. The router's configuration tree is still the truth. The file is a claim about what the tree should contain.
See also#
- Staging: the working set — where the loop's operations land.
- State, intent and drift — the other thing called drift.
- Commit-confirm — what commit mode arms.
- Desired-state reference — the file format, key by key.
- Agent flags — every
--reconcile-*flag.