Wheelhouse docs

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.

/config/wheelhouse/agent.yaml
service:
  dns:
    forwarding:
      cache-size: 10000
  ssh:
qos:
  interface:
    eth1:
      egress: WAN-OUT
console
$ 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.yaml

Where the file lives#

SurfacePath
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/reconcileno 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 deliberately 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 for anyone who could sign in.

Format sniffing#

First non-whitespace byteParsed as
{ or [JSON
anything elseYAML

The YAML path decodes through the node tree rather than into a Go map, so the text in the file is the value. That matters more than it sounds:

WrittenWithout the node walkWhat actually happens
rule: 10:a map keyed by the integer 10, taken for a leaf, planned as set … rule ''a tag node named 10
mode: 0755the integer 493the string 0755
version: 1.10the float 1.1the string 1.10
expires: 2026-09-02an RFC 3339 timestampthe 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 stateResult
Nothing thereA bare set service ssh, plus the warning service.ssh: planned as a bare set (a node with no value).
A node that already has childrenSatisfied. Nothing planned.
A node holding a valueRefused. 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).

That last one is the case no operation can express: the router cannot set a node that requires a value to nothing, so the file is broken rather than the router being wrong.

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:

yaml
system:
  name-server:
    - 192.0.2.53
    - 198.51.100.53

Each 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#

ModeFlagEmits
Partial (default)set operations only. Nodes the file does not mention are left alone.
Full--full, --reconcile-full, or "full": trueAlso 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, so a typed document does not produce a plan that re-sets what the router already satisfies and never converges.

Warnings#

Warnings are advisory, never refusals: the operations they accompany are still what would change.

WarningMeans
<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 somebody made over SSH — the thing drift detection exists to notice — would otherwise stay invisible for up to an hour.

plan exit codes#

CodeMeans
0The router already matches the file.
2It does not. There are operations to apply.
1Something 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:

bash
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:

json
{
  "file": "/config/wheelhouse/agent.yaml",
  "full": false,
  "ops": [{"op": "set", "path": ["service", "dns", "forwarding", "cache-size", "10000"]}],
  "count": 1,
  "warnings": []
}

Note the shape of 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#

SurfaceReadsMay commit
wheelhouse-agent planfilenever
wheelhouse-agent apply --agent-urlfileno — it stages into a running agent's Commit Bar
wheelhouse-agent apply --commit --confirm-minutes Nfileyes, behind the router's own commit-confirm
POST /api/reconcilerequest bodyno — apply: true stages, and the Commit Bar owns the commit
GET /api/reconcile/filea file in the data directorynever
The reconcile loop--reconcile-fileonly 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.

BehaviourDetail
First pass--reconcile-delay after start, 15 s by default, so boot can settle.
Interval--reconcile-interval, 60 s by default, clamped up to a 10 s floor.
Default modestage. Enabling the loop cannot move a router out from under somebody.
Commit mode--reconcile-mode commit, with --reconcile-confirm minutes, 2 by default.
LicenceRequired. Without one the loop records no usable licence: the reconcile loop is paused until one is entered and changes nothing.
IdempotenceOperations already in the working set are not re-staged. An operation a person deliberately un-staged is offered again on the next pass — the staging area is the source of truth.
AuditEvery 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 still answers, the change did not cut the agent off, so it confirms — the way an operator does when the page still loads. 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:

json
{"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#

Checked against#

agent/desired.go (parseDesiredDoc, decodeYAMLTree, yamlValue, validateBlank, loadDesiredFile, reconciler, runPlanApply, printOps), agent/reconcile.go (reconcile, flattenDesired, multiLeafOps, sameLeaf, currentConfig, handleReconcile, handleReconcileFile, allowedDesiredName, resolveDesiredFile, emptyValueHint), agent/desired_test.go, docs/deploy.md "Desired state", docs/adr/001-desired-state-file.md, PLAN.md §6.

Updated 2026-09-02 desired-state reconcile config-as-code yaml json