Hem
building

stoat貂

stoat manages local QEMU VMs on Linux through a TUI and CLI. I built it for scratch environments I could start, SSH into, and throw away without reconstructing the command line each time.

stoat — ~/.stoat
➜ .stoat stoat ls
NAME MODE STATE CPUS RAM SSH
alpine-live live running 2 2048 2200
ubuntu-dev cloud running 4 4096 2201
arch-scratch disk stopped 2 4096 2202
➜ .stoat stoat ssh alpine-live
Warning: Permanently added '[127.0.0.1]:2200' (ED25519) to known hosts.
Welcome to Alpine!
localhost:~#

I wanted a scratch VM I could break and throw away. I kept looking up the same QEMU flags in shell history, so I built a way to save those choices and manage the VMs together.

stoat is a Go binary that launches QEMU. It saves VM settings in TOML files and provides a TUI and CLI for managing them.

With an Alpine live image, I also wanted to avoid setting up networking and SSH through setup-alpine by hand.

I skip that step. stoat builds an apkovl overlay at every boot and hands it to the guest as a fake FAT disk over -virtfs/vvfat. The overlay carries stoat's own ed25519 key, so root@127.0.0.1 answers the moment sshd comes up. No password, no key copying. The host key survives rebuilds, so your SSH client never complains about a changed fingerprint.

The overlay is rebuilt on every start. Changes inside the live guest are discarded when it stops.

stoat — ~/.stoat
➜ .stoat stoat ls
NAME MODE STATE SSH
alpine-live live running 2200
ubuntu-dev cloud running 2201
arch-box disk stopped 2202
old-vm — broken toml: line 1: expected '='

A VM is one of three kinds, and the kind decides how it gets provisioned. A live VM is the Alpine trick above: diskless, rebuilt every start, gone when you stop it. A cloud VM takes an Ubuntu, Debian, Fedora, or Arch image and lays a copy-on-write overlay over one shared base, so ten Ubuntu VMs share a single download and a few megabytes of delta each. It also drops a cloud-init seed that runs once, on first boot. A disk VM is a plain persistent qcow2 for everything else.

A new disk VM starts empty. Until you install the guest yourself and add stoat's key, there is nothing on the far end of an SSH connection. Rather than let you sit through a connect timeout, stoat checks first and tells you. Once the OS is in you press i, it flips installed in the vm.toml, and the VM boots straight off the disk instead of the installer.

Recipes for XFCE, Docker, dev tools, or Tailscale ride on top of whichever path applies. They get pushed over ssh for live and disk VMs, and merged into the cloud-init seed for cloud ones. Provisioning a cloud VM twice is a deliberate no-op, because the seed already ran and rebuilding it would throw away real guest state.

stoat — provision
➜ .stoat stoat provision ubuntu-dev --recipe docker
==> pushing recipe: docker
+ apt-get update
+ install docker-ce docker-compose-plugin
+ systemctl enable --now docker
✓ docker active (running)
done in 41s

stoat launches QEMU with a -pidfile and a unix-socket monitor for a graceful shutdown, then lets go. There is no supervising process. Quit stoat and the VM you started keeps running, because nothing about it depends on stoat being alive.

State is just files. Each VM is a directory under ~/.stoat holding one hand-editable vm.toml, and stoat re-reads it on every operation. No database, no cache to invalidate. Botch an edit and the VM shows up as a broken row instead of vanishing. Even then its recorded port stays reserved, grabbed by a best-effort regex over the unparseable file, so the next VM can't land on top of it.

Day to day I use the TUI. It lists each VM with its mode and state. ↵ starts or stops the highlighted VM, s opens SSH, p provisions, → opens details, / filters the list, n builds a new one.

stoat
stoat · 4 vms · ↑↓ to move
❯ ● alpine-live live running 2c 2048M
● ubuntu-dev cloud running 4c 4096M
○ arch-box disk stopped 2c 4096M
✗ old-vm broken toml: line 1
↵ start/stop · → details · s ssh · p provision · / filter · n new · q quit

The CLI covers the same ground for scripts. ls, up, down, ssh, provision, rm, recipe, logs, and doctor return 0 for success, 1 for a runtime failure, 2 for a usage mistake. That is enough to drop stoat into a Makefile or a CI job without scraping its output.

stoat — doctor
➜ .stoat stoat doctor
✓ /dev/kvm accessible
✓ qemu-system-x86_64 8.2.1
✓ 4 vms tracked · 1 broken
exit 0

The third door is an MCP server, so an agent can drive VMs the way I do. It is a small Python process that shells out to the stoat binary and reads its --json output. It never links the Go package or touches ~/.stoat directly, and the JSON contract is versioned, so a mismatched pair refuses to start instead of failing three calls deep.

It is pre-1.0 and single-user. It assumes it owns its ~/.stoat, and it gives you nothing past what QEMU and KVM already sandbox. The Alpine live path is the most exercised; the cloud-init backends and disk installs are newer, and the vm.toml layout may still move before 1.0. It is AGPL-3.0, so nobody folds it into a closed product.