No setup-alpine
I wanted a scratch VM to break things in, and everything on offer wanted more from me than the task was worth. libvirt brings a daemon, a permissions model, and XML. Vagrant brings Ruby and a box registry. Raw QEMU works and costs twenty minutes of flag archaeology every time I come back to it.
Alpine makes the gap obvious. It boots in about a second, which is exactly what a scratch VM should do, and then it drops you at a login prompt with no network and no sshd, waiting on setup-alpine. So stoat builds an apkovl overlay fresh at every start and hands it to the guest as a fake FAT disk over -virtfs/vvfat. That overlay bakes in stoat's own ed25519 keypair, so root@127.0.0.1:<port> works the moment sshd comes up, no password, no key copying, plus a host key that stays the same across rebuilds so your SSH client doesn't flag a changed fingerprint on every single boot.
╭────────────────────────────────────────────────────╮
│ │
│ ❯ ○ alpine-live live 2048M 2c - │
│ │
│ ○ ubuntu-dev cloud 4096M 4c - │
│ │
│ ✗ old-vm broken: toml: line 1: expected │
│ '.' or '=', but got 'i' instead │
│ │
╰────────────────────────────────────────────────────╯
↵ start/stop • →/l details • s ssh • p provision • / search • n new • q quitNo SSH until you install it
A VM is one of three modes, and the mode picks the provisioning path. live is the apkovl story above: diskless, rebuilt every start, gone on stop. cloud takes an Ubuntu, Debian, Fedora, or Arch image, makes a copy-on-write overlay over the shared base so the download only happens once, and drops a cloud-init seed that runs at first boot only. disk is a persistent qcow2 for anything else, and it is the one mode that starts with nothing on it: no OS, no sshd, no key.
That last part is the fact that trips people up more than everything else in stoat combined. Until you install the guest yourself at the console and add stoat's key by hand, there is nothing on the other end of an SSH connection. Press provision on a fresh disk VM and stoat checks this up front and tells you so, rather than making you wait out a full connect timeout to fail with a generic "not reachable." Once the OS is in, pressing i on the detail screen flips installed in vm.toml, which is also what changes the boot order: unset, the installer ISO stays forced first on every boot; set, the VM boots straight off disk.qcow2.
Recipes (XFCE, Docker, dev tools, Tailscale) ride on top of whichever path applies: pushed over ssh for live and disk, merged into the cloud-init seed for cloud. Provisioning a cloud VM again is a deliberate no-op, since the seed already ran once and rebuilding it would throw away real guest state.
QEMU outlives stoat
stoat launches QEMU with -pidfile and a unix-socket monitor for the graceful system_powerdown, and then gets out of the way. There is no supervising process: quit stoat and the VM you started keeps running, because nothing about it depends on stoat still being alive.
The same goes for state. Each VM is a directory under ~/.stoat holding one hand-editable vm.toml, and stoat re-reads it from disk on every operation. No database, no cache to invalidate. If a hand-edit leaves that file unparseable, the VM shows up in the list as a broken row instead of silently disappearing, and its recorded SSH port still gets reserved by a best-effort regex so a new VM can't collide with it.
One CLI, three ways in
The TUI is for browsing: start a VM, ssh in, watch a recipe stream past. The CLI (ls, up, down, ssh, provision, rm, recipe, logs, doctor) covers the same ground for scripts, with exit codes that mean something: 0 success, 1 runtime failure, 2 usage error.
$ stoat ls NAME MODE STATE CPUS RAM SSH alpine-live live stopped 2 2048 2200 ubuntu-dev cloud running 4 4096 2201
Both call the same internal Go package, so a fix in one shows up in both. The MCP server is a third door built the same way from the outside: a Python/fastmcp process that drives the stoat binary through its --json output and never links the Go package or reads ~/.stoat directly. The CLI's JSON contract is the only coupling, versioned so a mismatched pair refuses to start instead of failing three calls in.
Where it stands
Pre-1.0 and single-user: stoat assumes it is the only thing managing its ~/.stoat, and offers no sandboxing past what QEMU/KVM already give a guest. The Alpine live path is the most exercised mode; the cloud-init backends and disk-mode installs are newer. vm.toml and the CLI's flags may still move before 1.0. Licensed AGPL-3.0, so it cannot be forked into a proprietary product.