--json provides machine-readable output for named VM commands. This document
defines the output contract. Project fan-out currently has one exception:
no-name up, down, and apply can write progress prose before their terminal
JSON result. Use a named VM invocation when the complete stdout stream must be
JSON.
This document is the contract. The human-facing CLI is documented in cli.md.
stoat mcp exposes the same operations from inside the same binary and reuses
the wire DTO package for tool results. MCP does not use the CLI's JSON-lines
envelope, and some tool result DTOs differ from the corresponding CLI payload
(for example, MCP wait returns healthy, while CLI wait --json returns
reached and waited_ms). See the MCP reference for the tool
behavior and schemas.
stoat --json ls
{"v":3,"type":"result","cmd":"ls","ok":true,"data":{"vms":[...]}}
proc = subprocess.run(argv, stdout=PIPE, stderr=DEVNULL)
result = None
for line in proc.stdout.splitlines():
obj = json.loads(line)
if obj.get("type") == "result":
result = obj
if result is None:
raise StoatCrashed(proc.returncode) # died before answering
if not result["ok"]:
raise StoatError(result["error"]["code"], result["error"]["message"])
return result["data"]The parser uses the process exit code only when no result line was received.
Four rules make it work for a named VM command:
- Every line of stdout is one JSON object. Nothing else is ever written
to stdout in
--jsonmode, including a recipe's own output, which is wrapped aslogevents rather than passed through. - Exactly one line has
"type":"result", and it is last. Read to EOF and take it. - Errors are on stdout too, in that same result line. stderr carries only
things that are not part of this contract: Go panic traces, and stray
output from
qemu-imgorsshthat escaped capture. Do not parse stderr. - Either you get a result line, or the process died. The second case is detectable as "exit code is nonzero and no result line was seen".
Project fan-out is a current limitation. With a project stoat.toml and no
VM name, up, down, and apply force their internal fan-out path and may
write human progress lines before the final ProjectRun result. A parser that
must use fan-out should capture the final result line only after handling this
known defect; prefer stoat <command> <key> --json for a clean stream. A
single-VM down result can also report state: "running" while QEMU is still
exiting; follow it with stoat wait <key> --until stopped --json when
termination must be confirmed.
Rule 3 prevents consumers from merging stdout and stderr to reconstruct a result. Sequential reads from both pipes can deadlock when either buffer fills. The result envelope carries the command error on stdout.
Every line:
| Field | Type | Present |
|---|---|---|
v |
int | always |
type |
string | always |
cmd |
string | always |
ok |
bool | result lines only |
data |
object | on success, and on non-terminal events |
error |
object | on failure only |
data and error are mutually exclusive on a result line. ok is carried
separately rather than derived from which one is set, so that a consumer
branching on ok and one branching on "error" in obj can never disagree.
type |
Terminal | Meaning |
|---|---|---|
result |
yes | the answer; exactly one per invocation, always last |
progress |
no | a download's byte count (pull) |
stage |
no | a recipe boundary during apply, or during up when it applies at boot |
log |
no | one line of guest output during apply, or during up when it applies at boot |
A consumer MUST ignore any type it does not recognize. New non-terminal
types can be added without a version bump.
| Field | Type | Present |
|---|---|---|
code |
string | always |
message |
string | always, human-readable, not machine-parseable |
subject |
string | reserved, see below |
kind |
string | reserved, see below |
Branch on code. Never parse message.
subject and kind are defined in the envelope but no command emits them
today, so a consumer will not see them. They exist for a later change that
names what an error is about (subject:"work", kind:"vm") without putting
it in the prose. Because they are additive and optional, that change will not
bump the contract version. Do not write code that requires them.
| Code | Meaning |
|---|---|
not_found |
no such VM |
broken |
the VM's vm.toml will not parse |
name_taken |
a VM by that name already exists |
invalid_spec |
the request itself is malformed |
image_not_downloaded |
the image exists in the catalog but not on disk |
recipe_not_applicable |
a named recipe cannot run on this VM |
in_use |
a recipe is still listed by one or more VMs |
git_required |
a recipe operation needs Git on PATH |
not_running |
the operation needs a running VM |
already_running |
the operation needs a stopped VM |
no_disk |
the VM has no qcow2 (a live VM has none) |
immutable_field |
update was asked to change a field that cannot change |
disk_shrink |
a disk can only grow |
cannot_reach |
wait was asked for a state this VM can never reach |
unknown_log |
bad --which |
qemu_missing |
qemu-system-x86_64 is not on PATH |
kvm_unusable |
/dev/kvm cannot be opened; the user is usually not in the kvm group |
qemu_start_failed |
qemu ran and refused to start the VM |
monitor_unreachable |
the VM's qemu monitor socket does not answer |
monitor_rejected |
qemu answered the monitor command with an error |
no_console_password |
the VM has no console password to type |
share_invalid |
the configured share is not a directory |
no_xattr |
the share's filesystem cannot store user.* extended attributes |
screenshot_failed |
qemu refused the screendump |
download_failed |
a mirror or a checksum file did not answer with the image |
download_stalled |
the download stopped producing bytes |
checksum_mismatch |
the downloaded bytes do not match the published digest |
no_such_image |
the requested image is not in the index |
timeout |
the deadline expired |
canceled |
the context was cancelled |
usage |
a bad flag, a missing argument, an unknown subcommand |
confirmation_required |
a destructive command was run without -y |
access_denied |
MCP guest access was refused because the VM's agent_access level is too low |
rate_limited |
MCP refused a tool call because its per-tool or shared rate bucket was exhausted |
lock_out_of_date |
a project declaration is not pinned in stoat.lock |
host_unsupported |
this host has no qualified native VM runtime yet |
internal |
anything unanticipated; the escape hatch |
Codes are only ever added. Never renamed, never repurposed, never removed.
A consumer MUST treat an unrecognized code as a generic failure rather than
crashing on it, because that is what makes adding one a non-breaking change.
The list above is wire.Codes(), which returns every declared code, sorted.
0 success, 1 runtime failure, 2 usage error. Nothing else, and no code
per error kind: the envelope carries the error with more precision than a byte
can, and a new error kind must not need a new number.
Two commands deliberately differ under --json:
execexits 0 whenever the guest command RAN, whatever the guest returned. The guest's status is indata.exit_code. Exit 1 means stoat could not run it at all. Without--json,execpasses the guest's status through like ssh does, which is right for a human runningstoat exec vm make test && deployand fatal for a machine, since a guest exiting 2 would be indistinguishable from a stoat usage error.doctorexits 0 withhealthy:false. It succeeded at checking; the host is unhealthy, which is the answer rather than a failure to produce one.
- Non-interactive, always.
rmwithout-yfails withconfirmation_requiredrather than prompting. A boundary that can be crossed by a process that answers a prompt is not a boundary. - No progress prose. Anything worth saying is a field.
- No ANSI colour, regardless of terminal detection.
-qbecomes meaningless and is accepted as a no-op.
--json is scanned out of argv before parsing, so a usage error can still
answer in the contract. It is recognized anywhere EXCEPT after exec's VM
name, because everything there belongs to the guest:
stoat --json exec work ls -la # stoat's
stoat exec --json work ls -la # stoat's
stoat exec work ls --json # the guest's, untouched
Repeated across commands. Every list-valued field is [] when empty, never
null.
VM {"name":"work","os":"alpine","mode":"cloud","backend":"cloudinit",
"state":"stopped","cpus":4,"ram_mb":4096,"disk":"8G",
"share":"/home/u/src","recipes":["xfce"],
"ssh_port":2200,"ssh_user":"stoat","installed":false,
"forwards":[{"host_port":8080,"guest_port":80}],
"allow_exec":false,"agent_access":"manage","display":"vnc",
"error":"only on a broken VM",
"project":"/home/u/myrepo","key":"dev","project_missing":false}
VMStatus {"name":"work",...VM fields...,"health":"ok","recipes_detail":[
{"name":"xfce","applied":true,"version":"1.2","at":"...",
"health":"unknown","params":{},"outputs":{}}]}
Image {"id":"alpine-virt","os":"alpine","variant":"virt",
"backend":"apkovl","file":"alpine-virt-3.24.1-x86_64.iso",
"downloaded":true,"bytes":62914560,"bytes_exact":true,"byo":false}
Snapshot {"tag":"clean","vm_state":true,"size_display":"203 MiB",
"created_display":"2026-08-04 12:00:00"}
Check {"name":"qemu-img","ok":false,"detail":"not found",
"fix":["sudo","pacman","-S","qemu-img"],"optional":false}
PruneItem {"class":"orphaned_image","path":"/home/u/.stoat/isos/old.iso"}
Recipe {"name":"xfce","description":"XFCE desktop over SSH or at boot",
"schema":2,"runtime":"sh","reboot":false,"depends":[],
"params":[],"outputs":[],"health":null}
RecipeSchema {"name":"docker","description":"Docker engine and the compose plugin",
"schema":3,"runtime":"sh","reboot":false,"depends":[],
"params":[RecipeParam,...],"outputs":[RecipeOutput,...],
"health":{"check":"docker info","timeout":"30s"}}
RecipeParam {"name":"channel","type":"enum","required":false,
"default":"stable","values":["stable","test"],"help":"..."}
RecipeParam {"name":"user","type":"string","required":false,
"default":"","default_from":"ssh_user","values":[],"help":"..."}
RecipeOutput {"name":"socket","help":"path of the socket"}
RecipeHealth {"check":"docker info","timeout":"30s"}
RecipeEntry {"name":"tailscale","description":"join a tailnet on boot",
"scope":"global","source":"https://github.com/x/stoat-tailscale",
"ref":"v1.2","commit":"9f3c1e2"}
RecipeRoot {"path":"/home/u/.stoat/recipes","scope":"global"}
RecipeAdded {"name":"tailscale","source":"https://github.com/x/stoat-tailscale",
"ref":"v1.2","commit":"9f3c1e2d4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d",
"scope":"global"}
RecipeRemoved {"name":"tailscale","scope":"global"}
RecipeRefreshed {"path":"python-dev/install-alpine.sh","status":"backed_up"}
IndexEntry {"name":"tailscale","source":"https://github.com/x/stoat-tailscale",
"description":"join a tailnet on boot","os":["alpine"]}
RecipeIssue {"name":"xfce","reason":"xfce is not offered to fedora/cloudinit"}
ApplyPlan {"name":"xfce","action":"run","reason":"never applied",
"version":"1.2"}
Guest {"name":"fedora","init":"systemd","shell":"/bin/bash",
"installer":"","default_backend":"cloudinit",
"default_ssh_user":"stoat","escalate":["sudo","-n"],
"capabilities":["dnf","systemd"],"aliases":["rpm-family"],
"filename_hints":["fedora"],"seed_packages":[],
"pkg":{"setup":"","install":["dnf","install","-y"],
"env":{},"runtime_packages":{"python3":"python3"}},
"svc":{"enable":"systemctl enable {name}", ...},
"cmd":{},"backend":{"cloudinit":{"skip_9p":false}},
"source":"bundled"}
MCPClient {"client":"cursor","path":"/home/u/.cursor/mcp.json",
"installed":true,"command":"/home/u/.local/bin/stoat",
"current":true}
InitResult {"path":"/home/u/myrepo/stoat.toml","project":"myrepo",
"gitignore_updated":true}
Drift {"field":"cpus","from":"2","to":"4","needs_restart":true}
ProjectStatusVM {"key":"dev","name":"myrepo-dev","state":"running",
"health":"ok","drift":[Drift,...],
"error":"only on an immutable-field mismatch"}
ProjectStatus {"project":"myrepo","dir":"/home/u/myrepo",
"vms":[ProjectStatusVM,...]}
ProjectRunVM {"key":"dev","name":"myrepo-dev","status":"ok",
"error":"only when status is error"}
ProjectRun {"project":"myrepo","vms":[ProjectRunVM,...]}MCPClient.current is false when the client's entry names a different
binary than the running one, which is the stale entry mcp doctor reports.
RecipeParam.default_from is present only on a param that declares it.
"ssh_user" fills that param from the VM's configured SSH account at apply
time when the caller sets no value. "ssh_venv_dir" fills it with .venv
under that account's home directory. A value the caller sets still wins. A
param declares default or default_from, never both.
VM.project is the absolute directory of the stoat.toml that declared this
VM, and VM.key is the declaration key, both empty for a VM stoat create
made outside a project. VM.project_missing is true when that directory no
longer exists; the VM still lists and runs.
ProjectStatusVM.state is missing for a declared VM that does not exist
yet, otherwise a VM.state value. drift is empty when error is set: an
immutable-field mismatch (image or disk) stops the comparison before the
rest of the fields are checked.
ProjectRunVM.status is ok, error or skipped. skipped means a VM
earlier in declaration order failed and this one was never attempted;
ProjectRun.vms always lists every declared VM, in declaration order, so a
caller can see what did not run as plainly as what did.
RecipeEntry has name, description, scope, source, ref, and
commit. scope is one of bundled, local, global, or project; only
global and project entries carry source, ref, and the seven-character
commit prefix. RecipeRoot identifies each search root with path and
scope. RecipeAdded uses the same remote pin fields for add, lock, sync, and
update results, with the full resolved commit. RecipeRemoved contains only
the name and scope. RecipeRefreshed.status is current, refreshed,
backed_up, or orphan; a .bak file is written before overwrite for every
status except current and refreshed.
state is one of stopped, running, broken. error appears only on a
broken VM.
os and backend can be empty strings on a VM created before those
fields were recorded in vm.toml. The VM is otherwise usable, but nothing can
answer what guest OS it runs, so treat an empty os as unknown rather than as
a value.
allow_exec is true for every VM that predates the field, not Go's zero
value: an absent allow_exec key in vm.toml is read as true, so a caller
does not need to special-case an old VM. It is a recorded fact, not an
enforced one: stoat exec/cp do not check it, so a consumer that must
refuse exec on a VM with allow_exec:false (the MCP server) has to check it
itself before calling.
agent_access supersedes allow_exec with four levels: none, observe,
manage, and exec. Each level includes the operations allowed by the levels
below it. An explicitly stored allow_exec = true maps to exec. An explicit
false value or an absent key maps to manage. For compatibility, an absent
key still appears as allow_exec:true in the VM DTO. MCP permissions must use
agent_access; direct stoat exec and stoat cp commands do not enforce it.
Snapshot.size_display and created_display are named that way because they
are qemu's own formatted table output. They are opaque. Do not parse them.
Recipe.reboot says the guest needs a restart before that recipe's effect is
visible. A caller that runs apply and then waits for reachable can see the
sshd that is about to go down, so it must account for the reboot itself.
Recipe.depends names recipes that run first. apply orders the run on its
own, so a caller reads depends to report the order, never to sort by it.
ApplyPlan.action is run or skip, and reason is human text whose wording
is not part of the contract. version is the recipe version already applied,
absent when the recipe never ran.
Image.file is a bare filename under the data root's isos/, never an
absolute path. That is a guarantee, not an accident.
Image.byo is explicit. Consumers must not derive it from an empty id.
VM.display is "window" or "vnc", and "" on a broken VM. "window"
means that the VM uses a QEMU window. "vnc" means that its screen is provided
by a VNC server on a Unix socket.
Consumers must not derive display from mode and installed. The value also
depends on the host. Without a graphical session, an uninstalled disk VM uses
VNC so that its installer remains accessible.
Stoat detects the host display from DISPLAY, WAYLAND_DISPLAY, and
$XDG_RUNTIME_DIR/wayland-0. STOAT_GRAPHICAL=0 or STOAT_GRAPHICAL=1
overrides that detection. Host display availability is not a separate VM
field.
display identifies the surface, not its location. The payload does not
include the VNC socket path or an attach command. Run stoat get <name>
without --json to print the socket and a suitable viewer command.
Guest.source is "bundled", "user", or "bundled+user" for a user file
merged over a bundled one. Guest.svc and Guest.cmd are template strings,
not commands to run directly: {name} renders to the service/argument, see
docs/reference/guest.md. Guest.backend passes each [backend.<name>]
table through opaque; only the backend package that owns <name> defines its
keys.
- Host paths.
core.VMcarries six absolute host paths (its disk, console log, monitor socket, and so on). None reach the wire. The DTO constructor does not read that field at all, so no future JSON tag can leak it. This includes the VNC socket: seedisplayabove. console_password. A console password is useless unless shown to a human at a console, and it must never reach a wire format.isoandbase.baseis an absolute host path. Both are omitted until a caller has a concrete need.
These are pinned by a test that runs the real CLI and greps its whole output, so a leak fails the build rather than shipping.
cmd |
data |
|---|---|
init |
InitResult |
status |
ProjectStatus |
ls |
{"vms":[VM,...]} |
get |
{"vm":VMStatus} |
create |
{"vm":VM} |
update |
{"vm":VM,"changed":["ram"],"applies_at":"now"} |
up (one VM) |
{"vm":VM} (re-read after start, so state is authoritative) |
up, down, apply, wait, rm (no VM, project scope) |
ProjectRun |
down (one VM) |
{"vm":VM} |
wait |
{"vm":"work","until":"reachable","reached":true,"waited_ms":4210} |
rm |
{"name":"scratch","deleted":true} |
clone |
{"vm":VM,"source":"work","forwards_copied":false} |
exec |
{"vm":"work","exit_code":1,"stdout":"...","stderr":"..."} |
ssh-command |
{"argv":["ssh","-p","2200",...]} |
cp |
{"vm":"work","direction":"to_guest","local":"/home/u/f","remote":"/tmp/f"} (local is always resolved to an absolute path, even if given relative or ~-prefixed) |
forward (show) |
{"vm":"work","forwards":[...],"active":true} |
forward (set/clear) |
{"vm":"work","forwards":[...],"active":false,"applies_at":"next_start"} |
images |
{"images":[Image,...]} |
pull |
{"id":"alpine-virt","downloaded":true,"verified":true,"checksum_available":true} |
snapshot (list) |
{"vm":"work","snapshots":[Snapshot,...]} |
snapshot (act) |
{"vm":"work","tag":"clean","action":"restore"} |
prune |
{"dry_run":true,"items":[PruneItem,...]} |
apply |
{"vm":"work","applied":["xfce"],"skipped_reason":""} |
apply --dry-run |
{"vm":"work","dry_run":true,"plan":[ApplyPlan,...]} |
recipes |
{"recipes":[Recipe,...]} |
check-recipes |
{"applicable":false,"issues":[RecipeIssue,...]} |
guest ls |
{"guests":[Guest,...]} |
guest show |
{"guest":Guest} |
recipe list |
{"roots":[RecipeRoot,...],"recipes":[RecipeEntry,...]} |
recipe show |
{"recipe":RecipeSchema} |
recipe new |
{"path":"/home/u/.stoat/recipes/foo/"} |
recipe add |
{"name":"tailscale","source":"...","ref":"v1.2","commit":"9f3c1e2d4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d","scope":"global"} |
recipe lock |
{"recipes":[RecipeAdded,...]} |
recipe sync |
{"recipes":[RecipeAdded,...]} |
recipe update |
{"recipes":[RecipeAdded,...]} |
recipe rm |
{"name":"tailscale","scope":"global"} |
recipe refresh |
{"files":[RecipeRefreshed,...]} |
recipe search |
{"recipes":[IndexEntry,...]} |
screenshot |
{"vm":"work","path":"/home/u/.stoat/work/screenshots/2026-09-05T140302Z.png","bytes":48213,"width":1280,"height":800} |
logs (no VM) |
{"lines":[...]} (stoat's own log) |
logs <vm> |
{"vm":"work","which":"console","lines":[...]} |
doctor |
{"healthy":false,"checks":[Check,...]} |
mcp doctor |
{"contract":3,"version":"1.2.3","transport":"stdio","binary":"/home/u/.local/bin/stoat","clients":[MCPClient,...]} |
mcp install |
{"client":"cursor","path":"/home/u/.cursor/mcp.json","json":"{...}"} |
version |
{"version":"1.2.3","contract":3} |
help |
{"usage":"..."} |
ssh |
refused, see below |
The table above is for the CLI's --json results. MCP uses the same DTO
package but has tool-specific payloads: for example, MCP copy_to and
copy_from return CopyResult (vm, local, remote, to_remote), MCP
forward returns ForwardList (forwards), and MCP apply_recipes returns
ApplyResult (vm, recipes_detail). Consult the MCP tool schema for those
fields rather than assuming the CLI row applies.
get returns {"vm":VMStatus}: VMStatus embeds the VM fields directly;
only the outer get result has the vm member. recipes remains the compatible
string list, while recipes_detail adds stored per-recipe state. health is the stored aggregate
(ok, failed, or unknown); it is not a live SSH check. Every detail's
params and outputs is an object, even when empty. Secret parameters are
<set> or <unset> and are never emitted as their value.
recipe show and recipes use the same RecipeSchema projection. Parameters
and outputs are named arrays sorted by name. A recipe without a health check
has health:null; all list fields are [], never null.
All recipe subcommands report "cmd":"recipe", not the full subcommand
path, and all guest subcommands report "cmd":"guest". Distinguish them by
which fields data carries.
recipe list reports every valid manifest in shadow order. Each row names its
scope (bundled, local, global, or project); only remote global and
project rows carry source, ref, and the seven-character commit prefix. The
roots list gives the search order and the scope label for each root.
up, down, apply, wait and rm report ProjectRun only when they run
at project scope with no VM argument; given a VM, each keeps its one-VM shape
from the row above. stoat.toml's [project] fan-out is the only thing that
changes data's shape; the command's own cmd name does not.
Fields worth knowing about:
update.changednames the fields that actually changed, in wire naming (ssh_port, notSSHPort). A flag you did not pass does not appear, and the field it names is untouched.applies_atisnowornext_start, and appears onupdateand on aforwardthat changed the configuration. A forward saved on a running VM is not active until the next start. Aforwardthat only displays the current configuration reportsactivewithoutapplies_at.check-recipes.applicableis emitted explicitly even though it equalsissues == []. Consumers must use this field instead of deriving the value.apply.skipped_reasondistinguishes "recipes ran" from "there was nothing to run" without reading English. It is""when the apply ran. A cloud VM's recipes ran at first boot, and a second run already holding the VM's lock is the other case; both come back withapplied: []and a reason.appliedis always a list, never a bool.provisionis a hidden alias ofapplyand reports"cmd":"apply". It has nodatashape of its own.doctor.healthyreports host readiness. The enclosing result envelope usesokfor command success.
A guest's stdout can contain arbitrary bytes. Go's JSON encoder replaces invalid UTF-8 with U+FFFD and loses the original bytes. Stoat therefore uses base64 for a stream that is not valid UTF-8:
{"stdout_base64":"...","stdout_encoding":"base64","exit_code":0}The plain field is omitted when its base64 counterpart is present, and vice
versa. Encoding is reported per stream, because stdout and stderr can
independently be invalid. An absent *_encoding means UTF-8.
stoat --json ssh <vm> is a usage error. ssh replaces the stoat process
image, so there is no "after" in which to write the terminal result line, and
faking one would break the exactly-one-result guarantee everywhere. Use
exec for a command, or ssh-command for the argv to run yourself.
pull, apply and up emit non-terminal events before their result. up
emits the stage and log events of the apply it runs after boot, with
"cmd":"up", and its result comes after that apply has finished. apply --dry-run emits none: it computes the plan host-side and runs nothing.
{"v":3,"type":"progress","cmd":"pull","data":{"id":"alpine-virt","done":41943040,"total":62914560,"percent":66}}
{"v":3,"type":"progress","cmd":"pull","data":{"id":"alpine-virt","done":62914560,"total":62914560,"percent":100}}
{"v":3,"type":"result","cmd":"pull","ok":true,"data":{"id":"alpine-virt","downloaded":true,"verified":true,"checksum_available":true}}
progress fires only when the percentage changes, not per read.
apply wraps each appended line of the recipe log as a log event, and emits
a stage event at each recipe boundary:
{"v":3,"type":"stage","cmd":"apply","data":{"recipe":"xfce"}}
{"v":3,"type":"log","cmd":"apply","data":{"line":"+ apk add xfce4"}}
The stage boundaries are real, read out of the markers the provisioner already writes, rather than a percentage nobody can compute.
Lines are flushed as they are produced, so a buffered stdout does not turn streaming into "silent until exit".
"v" is an integer contract version, not the build version. It is 3.
It bumps only for a removal or a meaning change: a field deleted, a unit
changed, an error code split or repurposed, or result ceasing to be last.
Additions never bump it.
v2. The recipe system moved from flat files whose target OS was encoded in
the filename (xfce.alpine.sh) to directories with a recipe.toml manifest.
Recipe lost label, target_os and shared, and gained description;
recipe names are now bare (xfce, not xfce.alpine.sh), so every name a
consumer passes to create --recipes, apply --only or check-recipes
changed shape too.
There is no v1 compatibility path. Stoat does not read or serve the old format. A v1 consumer refuses a v2 binary, and a v2 consumer refuses a v1 binary. The startup version check reports both contract versions.
A data root still holding v1 recipe files is not migrated. The v2 installer writes its directories alongside them and ignores the rest, so the stale files are inert but visible. Moving them aside is a one-line manual step.
A consumer may rely on:
v,type,cmdon every line- exactly one
resultline, last []for an empty list, nevernull- an unrecognized
typebeing safe to skip - an unrecognized
codebeing a generic failure
A consumer may not rely on: field order, the exact text of message, the
contents of any *_display field, or the absence of fields it does not know.
v3. recipe list changed shape for remote recipes. dir became roots,
a list of {path, scope} in search order, and recipes became a list of
RecipeEntry objects rather than names. A consumer that read
data.recipes[] as strings reads data.recipes[].name instead.
The same version also adds agent_access to VM, additive alongside
allow_exec, and moves the MCP server from a separate Python process into
stoat mcp in this binary. Neither change removes or repurposes a field, so
neither bumped the version on its own; they are noted here only because they
landed in the same branch as the recipe list change.
The project-file release added init and status commands, --project on
ls, and a no-argument fan-out on up, down, apply, wait and rm at
project scope. It also added project, key and project_missing on VM,
and five MCP tools (project_status, project_up, project_down,
project_apply, project_wait) alongside start, stop, apply_recipes
and wait, which keep their existing inputs and outputs. All additions; the
contract stays 3.
The default_from addition to RecipeParam is the same kind of change: a
new optional field on an existing object, absent unless a param declares it.
The contract stays 3.
stoat capabilities [VM] --json returns a report with schema 1 in the usual result envelope. The command reads host checks and one VM's stored metadata. It does not start, connect to, or mutate a VM. Omit VM for host and project scope; a target adds its directory name, stored mode, and normalized agent_access values.
The report has these fields:
| Field | Meaning |
|---|---|
| schema | Capability report schema, currently 1. |
| stoat_version | Build version supplied by Stoat. |
| host | os, arch, and project_state (available, absent, or unknown). |
| target | Optional stored VM snapshot with name, mode, and agent_access. |
| access_policy | mcp_agent_access_enforced, cli_agent_access_enforced, and cli_commands (exec, cp). |
| profiles | Implemented runtime profiles and their host or guest requirements. |
| capabilities | Implemented current surfaces and their requirements, limits, and evidence. |
| unavailable | Explicitly unavailable surfaces. |
Each profile or capability has name, status, scope, requirements, limits, optional reason, and evidence. Status is supported, limited, unsupported, or unknown. supported means the implementation is available and its required observations are available. Discovery does not establish VM readiness. limited carries a limit code; unsupported and unknown carry a reason code. Every list is [] when empty, never null.
The stable reason codes are not_implemented, host_probe_unavailable, agent_access_unknown, target_mode_unknown, and project_state_unknown. The stable limit codes are agent_access_required, target_required, disk_required, project_file_required, and host_requirement_missing. MCP enforces agent_access; direct CLI exec and cp do not. A live target limits vm.snapshot with disk_required. runtime.fork and runtime.continuation always appear under unavailable with unsupported/not_implemented.
{
"schema": 1,
"stoat_version": "dev",
"host": {"os": "linux", "arch": "amd64", "project_state": "absent"},
"access_policy": {
"mcp_agent_access_enforced": true,
"cli_agent_access_enforced": false,
"cli_commands": ["exec", "cp"]
},
"profiles": [],
"capabilities": [],
"unavailable": [
{"name": "runtime.fork", "status": "unsupported", "scope": "runtime", "requirements": [], "limits": [], "reason": {"code": "not_implemented"}, "evidence": [{"kind": "implementation", "source": "runtime.fork", "result": "not_implemented"}]},
{"name": "runtime.continuation", "status": "unsupported", "scope": "runtime", "requirements": [], "limits": [], "reason": {"code": "not_implemented"}, "evidence": [{"kind": "implementation", "source": "runtime.continuation", "result": "not_implemented"}]}
]
}The example abbreviates profiles and capabilities; a real report always contains the implemented entries. The proposal evidence identifies the report entry, while its status and reason state that the runtime surface is unavailable.