Skip to content

Wire schema

microvms-agentd wire protocol, generated from the daemon’s own serde types, via schemars. The same document is served at schema.json, and the daemon answers every response with the microvms-agentd-version header so a client can check which version it is talking to.

Field Value
$schema https://json-schema.org/draft/2020-12/schema
protocol_version 1
daemon_version 0.1.0
version_header microvms-agentd-version
hook_prefix /aws/lambda-microvms/runtime/v1

The daemon serves 20 routes. Each request and response body named below is one of the types in the next section.

Method Path Auth Summary
POST /aws/lambda-microvms/runtime/v1/ready none (platform hook) image-build readiness probe; answers 200 even before bootstrap, because the question is whether the daemon started. Every hook invocation is recorded and reported on /v1/health as hooks.
POST /aws/lambda-microvms/runtime/v1/validate none (platform hook) image-build validation probe; same reasoning as ready. Recorded on /v1/health’s hooks, which is the only trace this hook leaves — the platform writes no CloudWatch logs for it.
POST /aws/lambda-microvms/runtime/v1/run none (platform hook) one-shot token bootstrap, plus the optional launch environment. The platform wraps the caller’s string, so the payload’s own fields are one JSON parse deeper than the request body: {“runHookPayload”: “{"agent_token": "…", "env": {"KEY": "VALUE"}}”}. agent_token is required and non-empty; env is optional and becomes the BASE environment of every later exec, with each request’s own env overlaid on top of it. Values must be strings. Unknown keys are ignored, so a newer client can still bootstrap this daemon. The whole payload is capped by the platform at 4096 bytes inclusive, measured in UTF-8 bytes — the token and the env share that budget. env is installed only by the first successful bootstrap: a replay cannot edit it and a conflicting token cannot either.
POST /aws/lambda-microvms/runtime/v1/suspend none (platform hook) acknowledged and logged
POST /aws/lambda-microvms/runtime/v1/resume none (platform hook) acknowledged. Measured: the token, filesystem, exec records, and even backgrounded processes survive a suspend/resume cycle. What does not survive is the guest’s view of time, which jumps — so any timeout or lease held by a running command expires at once on resume.
POST /aws/lambda-microvms/runtime/v1/terminate none (platform hook) acknowledged; begins graceful shutdown with in-flight requests draining
POST /v1/exec/start bearer start a command under a caller-minted exec_id. Idempotent on that id: a retry returns success without spawning a second child.
GET /v1/exec/{id} bearer poll status and output. Read-only: polling never mutates the entry, and output survives until an explicit ack.
GET /v1/exec/{id}/stream bearer follow output as Server-Sent Events from a byte offset
POST /v1/exec/{id}/stdin bearer write to a child’s stdin, or signal EOF. A separate request from the output stream on purpose: a dropped attach must not cost the ability to feed the process. EOF is explicit rather than inferred, because a child reading stdin cannot exit until the daemon drops its own handle.
POST /v1/exec/{id}/ack bearer release output and enter TTL collection. Only acked entries are ever collected, so output nobody read is never destroyed.
POST /v1/exec/{id}/kill bearer SIGTERM then SIGKILL to the whole process group, not just the direct child — a shell that backgrounded a server leaves the interesting process outside the child pid
GET /v1/procs bearer process accounting: every registered exec with its process group’s live pids, read from /proc so the guest needs no ps. child_exited beside a non-empty pids is a command that finished while something it backgrounded did not — the shape /v1/health’s busy cannot show, and the exec_id beside it is the one to pass to /v1/exec/{id}/kill. reap echoes the start request’s reap_group_on_exit. Entries whose pgid was never captured list no pids. Read-only.
GET /v1/tcp bearer upgrade to a WebSocket relayed to 127.0.0.1:<port> in the guest, so a caller outside the VM can speak an arbitrary TCP protocol to a server inside it. Binary frames carry bytes in both directions and a frame is a byte range rather than a message — TCP has no message boundaries and neither does this. The dial is loopback-only and never resolves a name: a relay that could reach another host would be an open proxy inside the VM reachable with the agent token. One connection per WebSocket, deliberately: multiplexing would reimplement per-stream ids, flow control, and close handshakes that the platform already provides per connection. Outcomes arrive as close codes — 4502 nothing listening, 4400 port 0, 4500 a mid-relay failure — because a WebSocket route leaves HTTP behind after its 101. Rests on a measured platform property: binary frames survive a port-scoped token byte-exact (docs/PLATFORM.md, 2026-08-29). With identity=1 the relay first completes a Noise KK handshake against the per-VM key delivered in the launch payload, which proves the far end is this VM without trusting the endpoint proxy and makes every later frame ciphertext; 4401 means this VM was launched without a seed and 4403 means the handshake was refused. The handshake runs before the dial, so a refused caller never reaches a guest service.
GET /v1/fs/file bearer read one file, or a 1-based inclusive line range of it. ?start_line=&end_line= are the AI SDK harness’s readTextFile semantics: an end_line past the last line reads through EOF without error, and omitting both returns the whole file byte-identically. Still streamed — the range never buffers the file to slice it.
PUT /v1/fs/file bearer write one file. Deliberately not confined to a root: the same token authorizes exec, so a root prefix would add no security while breaking harnesses that write to home directories and /etc.
GET /v1/fs/tar bearer download a tree as tar. Symlinks are packed as symlinks, which is the producing half of what extraction accepts.
PUT /v1/fs/tar bearer upload and extract a tar under ?path=, confined to that root — the one confined write path, because member paths come from the archive rather than from the caller. Mirrors the CPython tarfile data filter: in-tree symlinks preserved, absolute link targets refused, relative targets resolved lexically so a symlink written earlier in the same archive cannot redirect a later member.
GET /v1/health none liveness, daemon version, whether bootstrap has completed, and whether any exec is still running. busy exists so an orchestrator OUTSIDE the VM can hold it alive: the platform measures idleness by inbound traffic through the endpoint proxy, which terminates outside the guest, so a request from inside the guest cannot reset the idle timer. Polling this from outside is both the traffic and the decision. hooks lists every lifecycle hook the daemon observed, oldest first, with fired_at epoch seconds on the daemon’s clock, and hooks_dropped counts invocations past the cap. Hook routes are unauthenticated and loopback-reachable from inside the guest, so a hostile workload can forge ADDITIONAL entries — never remove or alter real ones; the cap keeps the earliest, which are the platform’s.
GET /v1/schema none this document: every route, shape, status code, and operative limit

21 types under $defs, one subsection each, in the order the schema declares them. A type column that names another type links to its subsection.

The disk half of Health.

An object with 3 properties, 3 of them required.

Property Type Required Description
available_bytes integer (uint64) yes Bytes available to an unprivileged writer, from statvfs f_bavail.
reserve_bytes integer (uint64) yes Bytes that must stay free before a write is refused. Zero means the guard is disabled.
under_pressure boolean yes Whether a write would be refused right now. Precomputed rather than left to the client, so every consumer applies the same comparison the write path does.

The terminal exit SSE event. Emitted before the stream ends, so a client that sees the body close without one knows the connection failed rather than the command finishing.

An object with 5 properties, 5 of them required.

Property Type Required Description
exit_code integer or null (int32) yes
offset integer (uint64) yes Total bytes published, so a client can assert it saw all of them.
signal integer or null (int32) yes
truncated boolean yes
writers_may_be_alive boolean yes

Query string for GET /v1/fs/file, which is FsQuery plus an optional line range.

A separate type rather than two more fields on FsQuery, because a line range means nothing on PUT /v1/fs/file or on either tar route and a shared type would publish it on all four. mode is absent here for the same reason pointing the other way: it is a property of a write.

The semantics are the AI SDK harness contract’s, verbatim, because that is the consumer: 1-based and inclusive on both ends, and an end_line past the file’s last line reads through EOF without an error. A caller asking for lines 1..1000 of a 12-line file gets the 12 lines and a 200, not a 416.

An object with 3 properties, 1 of them required.

Property Type Required Description
end_line integer or null (uint64) no Last line to return, 1-based inclusive. Absent means through EOF. Past the last line is not an error: the read returns through EOF. A range ending before start_line is refused with 400, because it can only be a caller who computed one of the two wrong — there is no file for which it is the right question.
path string yes
start_line integer or null (uint64) no First line to return, 1-based inclusive. Absent means 1. Zero is refused with 400 rather than treated as 1. A caller who sends 0 is working from a 0-based mental model, and silently reinterpreting it would hand back a window one line off from the one they will compute offsets against.

Query string for every route in this module.

path is required. A request missing it is 400, never 404: clients map 404 onto FileNotFoundError, so answering 404 for a protocol typo made a missing query key look like an absent artifact — that is how one defect hid for a full review round.

An object with 2 properties, 1 of them required.

Property Type Required Description
mode string or null no Octal mode for a written file, carried as a string so 0644 and 644 both parse and neither is read as decimal 644.
path string yes

One gap SSE event: the byte range a lagging or late subscriber lost.

An object with 2 properties, 2 of them required.

Property Type Required Description
from integer (uint64) yes
to integer (uint64) yes

GET /v1/health response.

An object with 9 properties, 9 of them required.

Property Type Required Description
bootstrapped boolean yes
busy boolean yes Whether any exec is still running right now. Here so that an orchestrator outside the VM can decide whether to keep the VM alive, and the “outside” is the whole design. The platform measures idleness by inbound traffic through the endpoint proxy, and that proxy terminates outside the guest and forwards over loopback (measured; docs/PLATFORM.md, “The platform’s own hook arrives over loopback”). A request a guest process sends to the daemon’s own port never reaches the proxy, so no amount of in-guest traffic can reset the idle timer. A route that promised otherwise would be a keepalive that does not keep anything alive, discovered when a multi-hour run auto-suspends mid-work. What does reset it is a poll from outside, and this field is what makes such a poll informed rather than unconditional: the orchestrator polls, which is itself the inbound traffic, and reads whether the workload is busy to decide whether to keep polling. The assertion is therefore repeated and explicitly the caller’s, which is what the daemon self-keepaliving would not be — a hung process would then bill to the 8-hour ceiling with nobody asking. Computed from the exec registry rather than remembered: true iff at least one registered exec has not yet published a result. An exec that exited and is waiting to be acked is not busy — its output is being held, not produced — so an orchestrator does not keep a VM alive for a command that finished. #[serde(default)], unlike every field above it, and the asymmetry is not an oversight. The daemon is baked into an image while the client is installed separately, so a current client routinely talks to a daemon from whenever that image was built — and a required field would make health() fail outright against a daemon that predates it, turning a missing signal into an unreachable VM. False is also the right absence: a daemon that cannot say whether it is busy has not asserted that it is.
disk DiskHealth or null yes Free space on the daemon’s working filesystem, and the reserve it is judged against. Reported so disk pressure is something an orchestrator watches rather than something it discovers from a failed write. anthropics/claude-code#59856 filled two 10 GB disks to 100% with never-collected session directories and the first symptom was useradd: No space left on device — by which point every writer in the sandbox was already broken. A number on a health endpoint is what makes that curve visible while there is still time to act. None when free space could not be measured, which is deliberately distinct from zero: unmeasurable is not full, and a monitor that conflated them would page on a missing statvfs.
execs integer (uint) yes How many execs are registered, in any phase. Alongside busy because the two answer different questions and a monitor wants both: busy: false, execs: 0 is a fresh or drained VM, while busy: false, execs: 7 is a VM holding seven unacked results that somebody still has to collect. Terminating the second loses output nobody read. Defaulted for the same reason as busy: a client routinely talks to a daemon baked into an older image, and zero is the honest reading of a daemon that does not report a count.
hooks array of HookObservation yes Every lifecycle-hook invocation the daemon observed, oldest first. The platform writes no CloudWatch logs for the validate hook, so “did my validate hook even run?” has no answer anywhere else. Each entry is the daemon’s own observation — the hook path that was posted and the daemon’s clock when it arrived — never anything the guest printed. One trust caveat travels with this field and is stated rather than implied: the hook routes are unauthenticated and reachable over loopback from inside the guest, so a hostile workload can forge additional entries by posting the hook paths itself. It cannot remove or alter real ones — the daemon records before it responds and the log is append-capped, keeping the earliest entries — so the platform’s real firings are the front of the list and later spam is what the cap drops. Defaulted for busy’s reason: an older daemon omits the field, and an empty list is the honest reading of a daemon that reports no observations.
hooks_dropped integer (uint64) yes How many hook invocations were dropped once the log reached its cap. Non-zero means the list above is the earliest invocations only — the cap keeps first-N so a guest spamming the unauthenticated hook routes cannot grow daemon memory or push the platform’s real firings out of the record. Defaulted like hooks: an older daemon omits it, and zero is the honest reading of a daemon that dropped nothing it could tell us about.
identity_degraded boolean yes Whether any startup identity repair step failed. True means the VM is serving with a value from the shared image still in place — a duplicate machine-id or boot_id — which is a security-relevant condition an operator may want to drain the VM over, but is never a reason for the daemon to refuse to serve.
identity_repaired boolean yes False when identity repair was switched off by config. Distinguished from a repair that ran and found nothing so a monitor can tell “opted out” from “nothing to do”.
version string yes

One lifecycle-hook invocation, as the daemon observed it.

A daemon-reported fact in the same trust class as an exec’s exit code: the daemon’s word about a request it served, carrying nothing the guest printed.

An object with 2 properties, 2 of them required.

Property Type Required Description
fired_at integer (uint64) yes Seconds since the epoch on the daemon’s clock when the invocation arrived. The daemon’s clock rather than any caller’s, and recorded before the handler does any work — a run hook that was invoked and refused still fired.
hook string yes Which hook, spelled the way the platform’s routes spell it: ready, validate, run, suspend, resume, or terminate.

POST /v1/exec/{id}/kill response.

A named type rather than the serde_json::json! literal this used to be: an ad-hoc Value has no schema to derive, so the one route whose body a client most needs to branch on — killed distinguishes “signalled” from “the group was already gone”, and both are 200 — would have been the one route the published document could not describe.

An object with 2 properties, 2 of them required.

Property Type Required Description
exec_id string yes
killed boolean yes Whether a signal was actually delivered. false with a 200 means the process group had already exited, which is the outcome a kill wanted.

One output SSE event.

An object with 3 properties, 3 of them required.

Property Type Required Description
offset integer (uint64) yes
output string yes
stream StreamKind yes

Where an exec sits in its lifecycle. Mirrors ExecPhase in the model crate.

JsonSchema rides along with Serialize on every type from here down that crosses the wire. schemars reads the same #[serde(...)] attributes serde does, so the published schema describes what the daemon actually emits — the rename_all below is the reason this matters rather than a formality.

A string with 3 values:

Value Meaning
running Child spawned, still running (or its pipes still held by a grandchild).
exited Child exited and output is buffered and readable.
acked Caller acked; output has been released and the entry awaits collection.

Captured output and exit status of a finished exec.

An object with 8 properties, 2 of them required.

Property Type Required Description
exec_id string yes
exit_code integer or null (int32) no Exit code, or None when the child died to a signal.
phase Phase yes
signal integer or null (int32) no Signal number that killed the child, when one did.
stderr string no
stdout string no
truncated boolean no Set when either stream hit max_output_bytes and was cut. An explicit flag rather than a sentinel string in the output: a marker inside the bytes is indistinguishable from output that happens to contain it.
writers_may_be_alive boolean no Set when the post-exit linger deadline expired with the pipes still open, meaning some grandchild is alive and may write more that nobody will see. Reported rather than hidden, because a harness that sees empty output from a command it knows produced some needs to be able to tell why.

One entry of GET /v1/procs: an exec’s process group as the daemon sees it.

The exec registry knows the group (pgid) and whether the direct child has exited; /proc knows which pids are still in that group. Both halves are here because the interesting case is their disagreement — child_exited: true with a non-empty pids is a command that finished while something it started did not, which is exactly what a caller asking “did this step leave anything behind” needs to see and could not before.

An object with 6 properties, 6 of them required.

Property Type Required Description
child_exited boolean yes Whether the exec’s own child has exited. Read from the terminal marker an ack cannot take, so an acked exec still reads as exited here.
exec_id string yes
pgid integer or null (uint32) yes The process group id captured at spawn. None when the child was reaped before it could be read, in which case pids is empty because there is no group to scan for.
pids array of integer (uint32) yes Live pids whose process group is pgid, read from /proc. Zombies are not live and are not listed. Empty once the group is gone, and empty for an entry whose pgid is None.
reap boolean yes Whether the exec was started with reap_group_on_exit.
started_at integer (uint64) yes Seconds since the epoch on the daemon’s clock when the child was spawned. The same convention as a hook observation’s fired_at.

GET /v1/procs response.

An object with 1 properties, 1 of them required.

Property Type Required Description
procs array of ProcGroup yes Every registered exec, in registry order, whether or not anything in its group is still alive. An exec that exited cleanly with nothing left behind is listed with child_exited: true and pids: [].

The envelope the platform posts to the run hook.

The runHookPayload string given to RunMicrovm is not delivered as the request body: the platform wraps it, so the body is {"runHookPayload": "<the caller's string>"} and the caller’s own JSON is one serde_json parse deeper. Measured 2026-08-05 — a daemon that reads agent_token from the top level answers 400, and the platform then terminates the VM with “Run lifecycle hook returned HTTP status 400” before any traffic is forwarded, so the mistake is invisible from the outside.

An object with 1 properties, 0 of them required.

Property Type Required Description
runHookPayload string or null no

A start request. command is either an argv array or, with shell: true, a single script string.

An object with 10 properties, 2 of them required.

Property Type Required Description
command array of string yes argv when shell is false, or the script when it is true.
cwd string or null no Omitted means inherit the daemon’s working directory. See the module docs.
env object no
exec_id string yes Caller-minted idempotency key. Harbor retries, and a retry must not produce a second child.
group integer or null (uint32) no
reap_group_on_exit boolean no Whether to signal the exec’s whole process group once its own child exits. Off by default, and the default is the contract this daemon has always had: a backgrounded grandchild that inherited the output pipe keeps running and keeps writing, and the linger deadline is what bounds how long the daemon waits on it. That is the “backgrounded server keeps logging” guarantee, and callers rely on it. Set to true, the daemon runs the same SIGTERM-then- SIGKILL escalation POST /v1/exec/{id}/kill uses against the group as soon as the child’s exit is observed, so nothing the command left behind outlives it and the linger sees EOF rather than writers_may_be_alive. A timed-out exec is already escalated and is not escalated twice.
shell boolean no
stdin boolean no Whether to give the child a writable stdin pipe. Defaults to false, which keeps Stdio::null(). Opt-in rather than always-on, and not only for tidiness: a child holding an open stdin pipe nobody will ever write to is a child that blocks forever the first time it reads. /bin/sh reading a script from stdin, git deciding it can prompt, any tool that probes for input — all of them behave differently against a pipe than against /dev/null. Every existing caller gets today’s behavior by not setting this.
timeout_sec number or null (double) no Wall-clock budget. Validated before the child spawns — the predecessor raised on a bad value inside the waiter thread, by which point the child was already running and became an orphan.
user integer or null (uint32) no Numeric uid to demote to. Optional; omitted means run as the daemon’s own user.

An object with 2 properties, 2 of them required.

Property Type Required Description
exec_id string yes
phase Phase yes

POST /v1/exec/{id}/stdin body.

Both fields optional and both meaningful together: a final chunk plus EOF in one request is the common case for feeding a prompt, and forcing two round trips would leave a window where the child has the bytes but not the EOF that tells it the input is complete.

An object with 2 properties, 0 of them required.

Property Type Required Description
data_b64 string or null no Base64 so arbitrary bytes survive JSON. A JSON string cannot carry non-UTF-8, and stdin is bytes.
signal string or null no "eof" closes the pipe after any data_b64 is written. Named rather than a bare boolean so the field has somewhere to grow.

POST /v1/exec/{id}/stdin response.

pub rather than private, like every other type in this module the schema route publishes: the generator names them by type, so a response shape that stays private is a shape a consumer cannot be told about.

An object with 3 properties, 3 of them required.

Property Type Required Description
eof boolean yes
exec_id string yes
written integer (uint) yes

Which pipe a streamed chunk came from. Both share one offset space, so a client holds one cursor rather than two that can disagree about ordering.

A string with 2 values: stdout, stderr.

GET /v1/exec/{id}/stream query.

An object with 1 properties, 0 of them required.

Property Type Required Description
offset integer or null (uint64) no Byte offset to resume from. Absent means 0, i.e. everything still in the replay window.

?port=<n>[&identity=true] — the guest port to relay to, and whether to prove identity.

An object with 2 properties, 1 of them required.

Property Type Required Description
identity boolean no Whether to run a Noise KK handshake before relaying any bytes. On this struct rather than in a second query type because one route has one query contract, and the schema is generated from exactly that: a separate type would need a second extractor and would not appear in docs/schema.json at all. Absent means false, which is what keeps every layer-2 client working unchanged. True against a VM launched without a seed is refused with [close::NO_IDENTITY] rather than silently downgraded — a caller who asked to verify identity and got an unverified tunnel would believe a proof it never received. See [super::identity] for what the handshake proves and its honest limit.
port integer (uint16) yes The guest port. Dialled on 127.0.0.1 only. 0 parses and is refused after the upgrade with [close::BAD_PORT], rather than rejected as a parse error: a caller who named it gets a reason naming the port instead of a 400 that could equally mean a missing parameter.

This page is generated from docs/schema.json, the daemon’s JSON schema, derived from its own serde types. This page reads the root fields, routes and $defs. site/scripts/gen-reference.mjs writes it into the site’s content directory on every pnpm run sync, so an edit made here is overwritten by the next run.

To change the page, change the source. Regenerate the source with mise run schema from the repository root; mise run schema:check fails when the committed file no longer matches what the binary emits.