SynVirt Guest Agent Protocol
Synced read-only from
/home/synnet/mirrors/synvirt-product/docs/GUEST_AGENT_PROTOCOL.md. Edit at the source, not here.
SynVirt Guest Agent Protocol
Section titled “SynVirt Guest Agent Protocol”Version: v1 (Phase 1 of the Guest Tools Suite)
This document specifies the wire protocol used by the SynVirt host-side
guest-tools module (crates/synvirt-guest-tools) and the in-guest agent
(crates/synvirt-guest-agent, Phase 2). It is the single source of
truth: both ends read this spec rather than each other.
1. Transport
Section titled “1. Transport”- Bus: libvirt virtio-serial
<channel>. - Channel name:
com.synnet.synvirt.0. - Host side: Unix socket at
/var/run/synvirt/agent/{uuid}.sock, where{uuid}is the VM’s libvirt UUID. The domain XML uses<source mode='bind' path='…'/>, which makes QEMU bind and own the socket: it appears when the domain starts, disappears when it stops, and its owner is whateverqemu.confconfigures (typicallylibvirt-qemu:kvm). The daemon never creates this file — itUnixStream::connects to it on a retry loop. - Directory owner:
/var/run/synvirt/agent/is staged by systemd (RuntimeDirectory=synvirt/agenton the daemon unit) so it exists with the daemon user and group before QEMU starts. The sockets inside are owned by QEMU. - Guest side: character device
/dev/virtio-ports/com.synnet.synvirt.0on Linux/BSD, named pipe\\.\Global\com.synnet.synvirt.0on Windows. - Direction: bi-directional, full-duplex.
- Framing: JSON Lines — one JSON object per
line, LF-terminated (
\n). No length prefix. No CRLF. - Encoding: UTF-8, no BOM.
- Message size: soft cap 64 KiB per line. Either side MAY reject a
longer line with
E_PROTO_MALFORMEDand close the connection.
The channel is inherently trustworthy at the kernel/libvirt layer — it cannot be reached from outside the host-guest boundary — but both sides still parse JSON strictly and reject unknown fields where required below.
2. Envelope
Section titled “2. Envelope”Every message is a JSON object with this shape:
{ "v": 1, "id": "550e8400-e29b-41d4-a716-446655440000", "type": "hello", "ts": "2026-04-23T17:05:00Z", "payload": { ... }}| Field | Type | Required | Notes |
|---|---|---|---|
v |
integer | yes | Protocol version. Always 1 in this document. |
id |
string | yes | UUID v4. For ack/error responses this is a NEW id; the original request id is carried in the payload. |
type |
string | yes | One of hello, heartbeat, report, command, ack, error. |
ts |
string | yes | ISO-8601 / RFC-3339 UTC timestamp with Z suffix. |
payload |
object | yes | Type-specific body. Always an object, never null. |
Unknown top-level fields MUST be rejected with E_PROTO_MALFORMED.
Version handling: a receiver that does not understand v responds with
error carrying E_PROTO_VERSION_UNSUPPORTED and closes the connection.
3. Message types
Section titled “3. Message types”3.1 hello — guest → host
Section titled “3.1 hello — guest → host”The first message on a fresh connection. Carries the agent’s identity
so the host can update VmToolsState. The host will NOT process any
other message until it has received a well-formed hello.
Payload:
| Field | Type | Required | Notes |
|---|---|---|---|
agent_version |
string | yes | Semver of the guest agent build. |
os_family |
string | yes | One of windows, linux, bsd. |
os_name |
string | yes | Human-readable, e.g. "Debian GNU/Linux 13" or "Windows 11 Pro". |
os_version |
string | yes | Build/release identifier, e.g. "13.0" or "10.0.22631". |
arch |
string | yes | x86_64, i686, aarch64, etc. |
hostname |
string | yes | Guest’s reported hostname. |
Example:
{ "v": 1, "id": "c8b1a8f0-6d9e-4e82-9f31-1b2e8f3d7a55", "type": "hello", "ts": "2026-04-23T17:05:00Z", "payload": { "agent_version": "1.0.0", "os_family": "linux", "os_name": "Debian GNU/Linux 13", "os_version": "13.0", "arch": "x86_64", "hostname": "winxp-01" }}3.2 heartbeat — guest → host
Section titled “3.2 heartbeat — guest → host”Liveness ping, sent every 30 seconds. The host marks the agent offline after 90 seconds without any message (heartbeat, report, or ack).
Payload: empty object {}.
Example:
{ "v": 1, "id": "f51c9a11-2f0d-4c8c-82cd-0c0fe5f7b6e1", "type": "heartbeat", "ts": "2026-04-23T17:05:30Z", "payload": {}}3.3 report — guest → host
Section titled “3.3 report — guest → host”Unsolicited event. The host persists relevant fields to sled and/or broadcasts a daemon event.
Payload:
| Field | Type | Required | Notes |
|---|---|---|---|
event |
string | yes | tools_installed, tools_uninstalled, shutdown_initiated, reboot_initiated, clipboard_changed, display_resized. |
details |
object | no | Event-specific context. clipboard_changed carries {"text": string}; display_resized carries {"width": u32, "height": u32}. |
Example:
{ "v": 1, "id": "90f7d3e0-7d2e-4c91-a3de-2c8a0f3e7b14", "type": "report", "ts": "2026-04-23T17:10:00Z", "payload": { "event": "tools_installed", "details": { "version": "1.0.0" } }}3.4 command — host → guest
Section titled “3.4 command — host → guest”Request for the agent to perform an action. The agent MUST respond with
either an ack or an error, carrying request_id = command.id.
Payload:
| Field | Type | Required | Notes |
|---|---|---|---|
action |
string | yes | See catalogue below. |
args |
object | no | Action-specific arguments. |
Default command timeout: 30 seconds. Host-side callers MAY override per call.
3.4.1 Command catalogue (v1)
Section titled “3.4.1 Command catalogue (v1)”| Action | Args | Ack result shape |
Notes |
|---|---|---|---|
shutdown |
{ "delay_seconds": 0 } |
{} |
Graceful shutdown via OS API. |
reboot |
{ "delay_seconds": 0 } |
{} |
Graceful reboot via OS API. |
get_info |
{} |
{ "os_name": string, "os_version": string, "hostname": string, "uptime_seconds": u64, "ips": [string] } |
Fresh snapshot. Not cached. |
set_hostname |
{ "hostname": string } |
{ "hostname": string } (echoes resolved) |
May require guest restart. |
freeze_fs |
{} |
{ "frozen": bool } |
For snapshot quiesce. |
thaw_fs |
{} |
{ "thawed": bool } |
Pair with freeze_fs. |
clipboard_get |
{ "format": "text"|"html" } |
{ "format": string, "data": string } |
Linux/BSD via wl-paste/xclip; Windows via OpenClipboard+GetClipboardData. |
clipboard_set |
{ "format": "text"|"html", "data": string } |
{} |
Linux/BSD via wl-copy/xclip; Windows via OpenClipboard+SetClipboardData. Agent MAY refuse by policy. |
resize_display |
{ "width": u32, "height": u32 } |
{ "width": u32, "height": u32 } |
Linux/BSD via xrandr; Windows via ChangeDisplaySettingsExW. Echoes the resolution actually applied. |
Agents that do not implement an action respond with
E_GUEST_COMMAND_UNSUPPORTED.
Example (get_info):
{ "v": 1, "id": "aa5a1f8d-1c2e-4567-a3be-4c7d0a9a1f23", "type": "command", "ts": "2026-04-23T17:15:00Z", "payload": { "action": "get_info", "args": {} }}3.5 ack — guest → host
Section titled “3.5 ack — guest → host”Positive response to a command.
Payload:
| Field | Type | Required | Notes |
|---|---|---|---|
request_id |
string | yes | The id of the command this acks. |
result |
object | yes | Action-specific result (may be {}). |
Example (response to the get_info above):
{ "v": 1, "id": "d62a7c0c-5f33-4c8a-97d1-8f8b0c6f1f9b", "type": "ack", "ts": "2026-04-23T17:15:00Z", "payload": { "request_id": "aa5a1f8d-1c2e-4567-a3be-4c7d0a9a1f23", "result": { "os_name": "Windows 11 Pro", "os_version": "10.0.22631", "hostname": "winxp-01", "uptime_seconds": 18523, "ips": ["172.16.11.102"] } }}3.6 error — both directions
Section titled “3.6 error — both directions”Negative response (to a command) or unilateral protocol error.
Payload:
| Field | Type | Required | Notes |
|---|---|---|---|
request_id |
string or null | no | Present when the error is a response to a command. |
code |
string | yes | One of the codes in §4. |
message |
string | yes | Human-readable detail, English. |
Example:
{ "v": 1, "id": "7bde4e55-b4e9-47dc-a5ce-bd0f60c14dcb", "type": "error", "ts": "2026-04-23T17:15:01Z", "payload": { "request_id": "aa5a1f8d-1c2e-4567-a3be-4c7d0a9a1f23", "code": "E_GUEST_COMMAND_UNSUPPORTED", "message": "action get_info is not implemented by this agent" }}4. Error codes
Section titled “4. Error codes”| Code | Meaning |
|---|---|
E_PROTO_VERSION_UNSUPPORTED |
v is missing or not understood. |
E_PROTO_MALFORMED |
JSON parse failure, unknown type, missing required field, oversize line. |
E_PROTO_TIMEOUT |
Command exceeded its timeout before an ack or error arrived. |
E_GUEST_COMMAND_UNSUPPORTED |
Action name is not in this agent’s catalogue. |
E_GUEST_COMMAND_FAILED |
Action understood but failed to complete (OS API error, etc.). |
Additional codes MAY be added in future protocol revisions; receivers
MUST treat unknown E_* values as fatal for the current command and
non-fatal for the connection.
5. Lifecycle
Section titled “5. Lifecycle”5.1 Connection open
Section titled “5.1 Connection open”- libvirt’s virtio-serial channel comes up when QEMU opens the host socket (VM start).
- Host’s
AgentListeneraccepts the connection and starts a 10 s timer. - Guest writes a
hello. Host validates, updatesVmToolsState.{agent_connected, agent_version, os_*, last_hello}, and broadcastsAgentConnected. - Host cancels the 10 s timer and arms a 90 s idle timeout.
If the 10 s timer elapses without a valid hello, the host closes the socket and discards the session.
5.2 Steady state
Section titled “5.2 Steady state”- Guest sends
heartbeatevery 30 s. - Guest sends
reportfor noteworthy events. - Host sends
commandon demand; guest replies withackorerror. - Every received message (any type) resets the 90 s idle timer.
5.3 Connection close
Section titled “5.3 Connection close”- Guest shutdown / reboot typically closes the channel cleanly.
- Idle timeout (90 s) triggers host-side close,
VmToolsState.agent_connected := false,AgentDisconnectedbroadcast. - Any parse error or oversize line also closes the socket after
emitting an
errorwith the appropriate code.
6. Security notes
Section titled “6. Security notes”- The virtio-serial channel is local to the hypervisor and flows through a libvirt-mediated Unix socket. Network attackers cannot reach it.
- Guests are nevertheless treated as UNTRUSTED: the host strictly parses every field, enforces size limits, and never executes guest-supplied code or paths.
clipboard_setandset_hostnamepayloads are echoed back to the operator via the dashboard. The daemon escapes all guest-sourced strings before rendering.- Timing: every command has a bounded timeout (
E_PROTO_TIMEOUT). Every connection has a bounded idle window. A hung or malicious guest cannot pin host resources indefinitely.
7. Versioning policy
Section titled “7. Versioning policy”- v1 (this document): the baseline shipped in SynVirt 0.8.2.
- Minor additions (new optional fields, new
reportevents, new command actions) do NOT bumpv. Consumers ignore unknown optional fields except where the spec explicitly marks “reject unknown fields”. - Incompatible changes (field type change, removed required field,
breaking rename) bump
vto 2+. Both agent and host must support the new version; otherwise they fall back to the highest mutually supportedv(Phase 2 material).
7.1 Adding new message types in v2
Section titled “7.1 Adding new message types in v2”When v2 is drafted: the guest’s hello carries an optional
"supported": [1, 2] field that v1 hosts ignore but a v2 host
acknowledges with the chosen v. A v2 agent that never sees such a
reply infers “v1 only” after a grace window and downgrades. Both
sides then keep the agreed version for the rest of the connection;
it is never renegotiated per message. Mixed-version fleets keep
working without a flag day.
8. State machine
Section titled “8. State machine”States: Disconnected → Connecting → Connected → Disconnected.
The supervisor dials when QEMU binds the per-VM socket on VM start
and arms a 10 s hello deadline; once a valid hello arrives the
connection moves to Connected, exchanging command/ack and
heartbeat/report until any of EOF, parse error, oversize line,
idle > 90 s, an explicit terminating error, or supervisor shutdown
returns it to Disconnected. The connector then loops back to the
retry-connect loop without manual intervention. The deeper state
diagram (with branches) lives in
docs/modules/guest-tools/ARCHITECTURE.md.
Timers, at a glance:
| Timer | Duration | Scope |
|---|---|---|
| Hello deadline | 10 s | From TCP/Unix connect to first hello. |
| Heartbeat cadence | 30 s | Guest MUST emit one every 30 s. |
| Idle deadline (host) | 90 s | Any inbound message resets it. |
| Command timeout | 30 s | Host MAY override per call; guest MUST enforce an absolute cap the spec does not pin. |
9. Telemetry side-channel (vsock)
Section titled “9. Telemetry side-channel (vsock)”Sections 1-8 describe the JSON-Lines control plane on virtio-serial. Bulk telemetry rides a separate virtio-vsock stream so a chatty sample loop can never queue behind a clipboard, shutdown, or reboot control message.
- Bus: virtio-vsock.
- Host endpoint:
VMADDR_CID_HOST(kernel-defined2), portVSOCK_TELEMETRY_PORT=7777. - Guest CID: kernel-allocated. The daemon emits
<vsock model='virtio'><cid auto='yes'/></vsock>in the libvirt domain XML and reads back the resolved address fromvirsh dumpxmlafter the domain starts. - Cadence: one [
TelemetrySample] perTELEMETRY_INTERVAL_SECS=5seconds. - Framing: JSON-Lines — one
TelemetrySampleper\n-terminated line, UTF-8, no envelope,deny_unknown_fields.
9.1 TelemetrySample wire format
Section titled “9.1 TelemetrySample wire format”{ "timestamp_unix_ms": 1714693512345, "cpu_percent": 12.5, "mem_used_bytes": 268435456, "mem_total_bytes": 1073741824, "disk_io": { "read_bytes_per_sec": 1234, "write_bytes_per_sec": 5678 }, "net_io": { "rx_bytes_per_sec": 90, "tx_bytes_per_sec": 80 }}| Field | Type | Required | Notes |
|---|---|---|---|
timestamp_unix_ms |
u64 | yes | Wall-clock at sample time, Unix epoch milliseconds. The host stamps its own received_at for UI freshness so guest-clock drift is harmless. |
cpu_percent |
f32 | yes | Aggregate CPU utilisation across every vCPU, range [0.0, 100.0]. NaN reserved for “could not measure”. |
mem_used_bytes |
u64 | yes | Resident memory in use by the guest OS. |
mem_total_bytes |
u64 | yes | Total memory the guest sees (matches <memory> in the libvirt domain XML modulo balloon adjustments). |
disk_io |
{ read_bytes_per_sec: u64, write_bytes_per_sec: u64 } |
no | Aggregate block-device throughput, averaged over the last TELEMETRY_INTERVAL_SECS window. |
net_io |
{ rx_bytes_per_sec: u64, tx_bytes_per_sec: u64 } |
no | Aggregate non-loopback throughput over the same window. |
9.2 Why disk_io and net_io are Option
Section titled “9.2 Why disk_io and net_io are Option”Older agents that ship without IO sampling omit both keys entirely.
The schema still validates with deny_unknown_fields, and the host
listener treats None as “not measured” — the dashboard renders an
em-dash on the IO cards rather than a misleading zero. Newer agents
that do sample IO emit both blocks and the chart fills in.
9.3 v1 handshake compatibility
Section titled “9.3 v1 handshake compatibility”Everything in this section is additive with respect to the v1 control plane:
TelemetrySamplelives on a separate transport (vsock) and never touches the JSON-Lines envelope, so a v1 host that has not been rebuilt with the side-channel listener simply never opens that port — the agent’s connect attempts fail and it backs off, which is harmless.ResizeDisplay(CommandAction) andClipboardChanged/DisplayResized(ReportEvent) are new variants of existing enums. Per §7, “minor additions … do NOT bumpv.” A host that predates these variants responds to an unknown command withE_GUEST_COMMAND_UNSUPPORTEDand forwards an unknown report throughAgentEvent::Unknownwithout crashing.disk_io/net_ioonTelemetrySampleareOption, so an agent that does not measure IO still produces a payload that validates against the strictly-checked host schema.
Existing agents on protocol v1 keep working unchanged.
9.2 Telemetry extensions (v0.16.0): top-K processes + inventory
Section titled “9.2 Telemetry extensions (v0.16.0): top-K processes + inventory”The base TelemetrySample from §9.1 stays the source of truth. v0.16.0
adds two optional sub-blocks that ride inside the same envelope on
the same vsock stream — no new transport, no new port, no new envelope.
{ "timestamp_unix_ms": 1714693512345, "cpu_percent": 12.5, "mem_used_bytes": 268435456, "mem_total_bytes": 1073741824, "disk_io": { "read_bytes_per_sec": 1234, "write_bytes_per_sec": 5678 }, "net_io": { "rx_bytes_per_sec": 90, "tx_bytes_per_sec": 80 }, "top_procs": { "by_cpu": [ { "pid": 1234, "name": "nginx", "user": "www-data", "cpu_percent": 12.25, "rss_bytes": 33554432, "read_bytes_per_sec": 0, "write_bytes_per_sec": 0, "rx_bytes_per_sec": 0, "tx_bytes_per_sec": 0 } ], "by_mem": [], "by_disk": [], "by_net": [] }, "inventory": { "hostname": "vm-test", "distro": "Debian GNU/Linux 13", "kernel": "6.1.0-21-amd64", "arch": "x86_64", "agent_version": "0.16.0", "timezone": "America/Mazatlan", "boot_time_unix_s": 1700000000, "nics": [ { "name": "eth0", "mac": "52:54:00:11:22:33", "ips_v4": ["10.10.26.65"], "ips_v6": ["fe80::1"], "mtu": 1500 } ] }}9.2.1 Cadence
Section titled “9.2.1 Cadence”| Block | Cadence | Constant |
|---|---|---|
| Base sample | 5 s | TELEMETRY_INTERVAL_SECS |
top_procs |
30 s | TELEMETRY_TOPK_INTERVAL_SECS |
inventory |
300 s | TELEMETRY_INVENTORY_INTERVAL_SECS |
The agent emits one base sample every 5 s on the same vsock stream as
v0.15. Every sixth sample additionally fills top_procs; every
sixtieth additionally fills inventory. The agent MAY also emit an
inventory block off-cadence on a detected change (hostname, IP
list, kernel version), so consumers MUST treat the field as
“latest-wins” and not assume a fixed period.
top_procs.by_* is capped at TELEMETRY_TOPK_LIMIT = 20 entries per
ranking. Bumped from 10 in v0.16 after the Telemetry tab landed and
operators asked for more long-tail visibility per axis. The wire is
backwards compatible — a v0.15 host that decoded the first 10 still
parses the new payload (no schema bump), it just sees more entries.
9.2.2 Wire format
Section titled “9.2.2 Wire format”Each new block carries #[serde(deny_unknown_fields)] exactly like the
base sample. The envelope is unchanged — both blocks live as optional
keys directly on TelemetrySample, omitted via skip_serializing_if
when the agent has nothing to report on that tick.
Field-by-field documentation lives on the Rust types in
crates/synvirt-guest-proto/src/lib.rs: TopProcsBlock,
ProcEntry, InventoryBlock, NicInventory.
9.2.3 Compatibility
Section titled “9.2.3 Compatibility”Forward (new agent, old host): the agent emits top_procs /
inventory keys only when it has data. A v0.15 host that consumed
TelemetrySample with deny_unknown_fields would have rejected the
extra keys; the v0.16 host adds them as Option fields and accepts
both shapes. Operators rolling out v0.16 SHOULD upgrade hosts before
agents to avoid that asymmetry; the agent’s vsock backoff makes the
window harmless either way.
Backward (old agent, new host): a v0.15 agent never emits the new
keys. The v0.16 host’s serde(default) defaults both fields to
None, so the legacy line parses unchanged and the dashboard simply
shows blank top-K and inventory panels for that VM.
Top-K via the existing CommandAction::TopProcessesByMemory request
on the virtio-serial control plane (§3.4.1) stays available for
operators who want a one-shot snapshot off-cadence.