Wheelhouse docs

Adding an endpoint#

An endpoint in the agent is one line in routes() and one handler function. The line says who may call it and whether it needs a licence; the handler does the work. There is no router library, no middleware stack to configure and no code generation — the standard library's http.ServeMux with method-and-path patterns is the whole of it. What a new endpoint really has to get right is the wrapper it is registered with, the status codes it returns, and whether it writes an audit entry.

The five-minute version#

  1. Write the handler in the file whose subject it belongs to — a firewall read goes in main.go beside the other firewall reads; a new app operation goes in agent/apps.go.
  2. Register it in routes() in agent/main.go, inside the block that matches its plane, wrapped in the role it needs.
  3. If it changes anything, record an audit entry.
  4. Add it to the typed client in ui/src/lib/api.ts.
  5. Add it to the endpoint table in docs/deploy.md.
  6. Write a test.

Choosing the wrapper#

This is the decision that matters, and it is made once, in the registration line:

go
// Read plane: any authenticated principal.
mux.HandleFunc("GET /api/firewall/groups", readOnly(handleFirewallGroups))

// Write plane: operator, and a licence.
licensed := func(h http.HandlerFunc) http.HandlerFunc { return writeable(requireLicense(h)) }
mux.HandleFunc("POST /api/stage", licensed(handleStage))

// Admin plane.
mux.HandleFunc("PUT /api/admin/settings", adminOnly(handleUpdateSettings))

// A licensed feature, not merely a licence.
mux.HandleFunc("POST /api/fleet/{id}/configure", writeable(requireFeature("fleet", handleFleetConfigure)))

// A read that would otherwise hand secrets to a viewer.
mux.HandleFunc("GET /api/wireguard", readOnly(redactSecrets(RoleAdmin, handleWireGuard)))
QuestionAnswer
Does it change router configuration or agent state?writeable(requireLicense(…)) — use the licensed helper that is already in scope
Does it change accounts, tokens, settings, the licence, power or images?adminOnly
Does it only read?readOnly
Could its response contain a private key, a pre-shared secret or a password hash?Wrap it in redactSecrets(RoleAdmin, …) as well
Is it part of a plan feature rather than the base product?requireFeature("<name>", …)
Must it answer before anyone has signed in?Leave it unwrapped, and be certain — today only /health, /api/auth/status, /api/auth/login and the three OIDC handshake routes are

What a handler looks like#

The conventions are consistent across all 108 registrations. A read:

go
func handleFirewallGroups(w http.ResponseWriter, r *http.Request) {
    res, err := vyos.ShowConfig([]string{"firewall", "group"})
    if err != nil {
        writeError(w, 502, "config retrieve failed: %v", err)
        return
    }
    if res.Error != nil {
        writeError(w, 400, "VyOS: %s", *res.Error)
        return
    }
    writeJSON(w, res.Data)
}

Three helpers, all in agent/main.go:

HelperUse
writeJSON(w, v)A 200 with a JSON body
writeError(w, code, format, args…)A status with {"error": "…"}
writeStatus(w, code, v)A status with an arbitrary JSON body — how 402 carries the licence state alongside the message

Status codes, and what they mean here#

CodeWhen
400The request was wrong, or the router refused the command for a reason the caller should read
401No principal, or a session that has expired
402No valid licence (or the plan lacks the feature). The body names the fix
403Authenticated, but the role is not enough
409The state does not permit it — a restart of an app that is stopped, for instance
501Genuinely not implemented, with the manual command to run instead
502The router could not be reached, or answered in a way the agent could not use

The 400/502 split is load-bearing and is asserted by agent/test.sh: a set VyOS rejects has to come back with the router's own words, not a generic message. That test exists because the failure once arrived as a 500 from a nil dereference inside a handler, and the operator never saw why the router said no.

Validate before you stage#

handleStage is the model for input checking. It refuses an operation that is not set or delete, an operation with no path, and any path element that is blank — each with a message naming the offending index:

go
if op.Op != "set" && op.Op != "delete" {
    writeError(w, 400, "op %d is %q, not set or delete", i, op.Op)
    return
}

The comment above that block says why: the Commit Bar renders exactly set and delete, so anything else would render dishonestly, and a valueless path only fails later at commit with an error that no longer names this request. Refuse while the request can still say why.

Audit what you changed#

Any endpoint that changes something writes an audit entry, attributed to the principal that made the request. Three helpers, in agent/main.go:

go
recordAudit(r, "operation-name", success, errMsg)
recordAuditPath(r, "operation-name", path, success, errMsg)
recordAuditFull(r, "operation-name", path, commands, success, errMsg)

They fill in the actor, the role and the client IP from the request. Record the failure too — an audit log that only holds successes cannot answer "who tried".

Body size, path parameters and redaction#

  • Body size is capped by limitBodies before the mux picks a handler. The default is 1 MiB; the routes that legitimately carry a whole router configuration are named one by one in bodyLimit and get 16 MiB — agent/security.go. A new endpoint that needs the larger cap gets an entry there, not a bigger global cap. The comment above limitBodies records why it sits in front of the mux: one anonymous POST of a 400 MB body took the agent from 85 MB to 2.2 GB resident, and a second one the login limiter refused buffered just the same, because the decode ran before the refusal.
  • Path parameters use Go's own patterns: DELETE /api/admin/tokens/{id} and r.PathValue("id"). Where a parameter is interpolated into something the router will parse, wrap the handler in validPathValue(name, re, …) so a bad value is refused at the edge.
  • Redaction is a wrapper rather than a line in each handler because several endpoints reach the same secrets by different code paths. The list of secret leaf names is secretLeaves in agent/security.go; a new kind of secret in the configuration tree belongs in that map, not in a handler.

Then the other four places#

An endpoint is not finished when it answers.

PlaceWhat to add
ui/src/lib/api.tsA typed function on the api object, and its wire types. The client is written by hand; there is no generator
docs/deploy.mdA row in the endpoint table. That table is the API contract — there is no OpenAPI document
agent/endpoints_test.goA behaviour test against a stand-in router. TestRoutesRegisterWithoutConflict in agent/main_test.go already catches a pattern that collides with an existing one
agent/test.shA live check, if the endpoint is one an operator would notice breaking

Re-derive the current list any time:

bash
grep -o 'mux.HandleFunc("[A-Z]* [^"]*"' agent/main.go | sed 's/mux.HandleFunc("//;s/"$//' | sort

See also#

Checked against#

agent/main.go · agent/security.go · agent/authhttp.go · agent/license.go · agent/apps.go · agent/endpoints_test.go · agent/test.sh · ui/src/lib/api.ts · docs/deploy.md · README.md

Updated 2026-09-02 development agent api endpoint