Agent protocol

Four routes behind a bearer token. Small enough to read in one sitting, and implemented twice so it cannot drift.

The agent is a small daemon installed on the machine that runs the VMs. It exists for one thing: letting the phone power a VM on before connecting to it, then learning which port its console landed on. Everything else wisq does works without it.

Transport

HTTP/1.1, port 7442 by default, every route under /v1. Authentication is a bearer token, compared in constant time — it is a credential on a network the daemon does not control, and a byte-at-a-time comparison leaks its prefix to anyone patient enough to measure.

Authorization: Bearer <token>

The token is generated from the operating system's random source on first start and kept in ~/.wisq-agent/token with owner-only permissions. There is no account and no password: one token, revoked by deleting the file.

Routes

GET /v1/vms

Every machine the backend can see
[
  {
    "id": "debian-13",
    "name": "Debian 13",
    "state": "running",
    "consoleProtocol": "vnc",
    "consolePort": 5901,
    "guestOS": "linux"
  }
]

state is one of running, paused, stopped, starting or unknown. consolePort and consoleProtocol are absent until the console exists — which is exactly what the client polls for.

GET /v1/vms/{id}

The same object for one machine. This is the route the client polls during a boot, and a 404 with a readable message when the identifier is unknown.

POST /v1/vms/{id}/start

Starts the machine and answers immediately with state starting. Booting a guest takes tens of seconds, and an HTTP request held open that long does not survive a phone moving between cells.

POST /v1/vms/{id}/stop

{ "force": false }

false sends an ACPI shutdown, true pulls the power cord. An empty body means false.

Errors

Anything outside 2xx carries a JSON body. The message is shown to the person using the app, so it has to read like a sentence.

{ "error": "VM introuvable : debian-13" }

Pairing

On start the daemon prints one link per reachable address. Opened on the iPhone, the link lands on the import screen with the address and token filled in and the query already running.

wisq://agent?host=nas&port=7442&token=…&name=nas

Loopback addresses are never offered: a link to 127.0.0.1 is useless from a phone. The daemon also advertises itself over Bonjour as _wisq-agent._tcp, at best effort — avahi-publish-service on Linux, dns-sd on macOS, and silently nothing otherwise. A convenience that is missing must never stop the daemon serving.

Implementation

The daemon is Rust, the client is Swift. They do not have the same constraints: a program with no interface and no platform framework has no reason to carry a language runtime. Statically linked against Swift's it was a 58 MB download to serve four routes; it is now 582 KB, one static binary that runs on any Linux including Alpine.

Zero dependencies, deliberately. This is a program people install with a piped shell script, so its dependencies become theirs — and the protocol above is small enough that a hand-written HTTP/1.1 server and JSON writer are less code than the glue a framework would need.

The wire format is guarded by a test that crosses both languages: the Swift suite launches the real Rust binary on an ephemeral port and queries it with the same client the app embeds, and parses the daemon's pairing links with the app's own parser. That is the only place a divergence between the two halves can show up.