diff --git a/docs-site/.dockerignore b/docs-site/.dockerignore
new file mode 100644
index 0000000..f64e7ca
--- /dev/null
+++ b/docs-site/.dockerignore
@@ -0,0 +1,19 @@
+node_modules/
+dist/
+.astro/
+
+.env
+.env.local
+.env.production
+
+.git/
+.gitignore
+
+*.log
+.DS_Store
+.vscode/
+.idea/
+
+# don't pull in repo-root artifacts
+../artifacts/
+README.md
diff --git a/docs-site/.env.example b/docs-site/.env.example
new file mode 100644
index 0000000..9eb88d6
--- /dev/null
+++ b/docs-site/.env.example
@@ -0,0 +1,13 @@
+# mcqemu docs-site — environment template.
+# Copy to `.env` and adjust before `make prod` / `make dev`.
+
+# Keeps this stack's containers, networks and volumes distinct from the
+# sibling docs sites on the same host.
+COMPOSE_PROJECT_NAME=mcqemu-docs
+
+# Domain served. caddy-docker-proxy reads this from the labels and obtains
+# a certificate over ACME DNS-01.
+#
+# Production: mcqemu.warehack.ing
+# Local dev: mcqemu.l.warehack.ing
+DOMAIN=mcqemu.warehack.ing
diff --git a/docs-site/.gitignore b/docs-site/.gitignore
new file mode 100644
index 0000000..d9f74c3
--- /dev/null
+++ b/docs-site/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+dist/
+.astro/
+.env
+.env.local
+.env.production
+*.log
+package-lock.json
diff --git a/docs-site/Caddyfile b/docs-site/Caddyfile
new file mode 100644
index 0000000..590a6ee
--- /dev/null
+++ b/docs-site/Caddyfile
@@ -0,0 +1,18 @@
+:80 {
+ root * /srv/docs
+ encode zstd gzip
+ try_files {path} {path}/
+ file_server
+
+ # Serve the 404 page with a REAL 404 status. The older
+ # `try_files ... /404.html` form returned HTTP 200 for every missing
+ # path, which silently broke uptime checks, smoke tests, and Search
+ # Console (they all saw a "live" page).
+ handle_errors {
+ @404 expression {err.status_code} == 404
+ handle @404 {
+ rewrite * /404.html
+ file_server
+ }
+ }
+}
diff --git a/docs-site/Dockerfile b/docs-site/Dockerfile
new file mode 100644
index 0000000..0ac987d
--- /dev/null
+++ b/docs-site/Dockerfile
@@ -0,0 +1,47 @@
+# Multi-stage build for the mcqemu docs site.
+#
+# Stages:
+# - base : Node, pnpm/npm tooling, deps installed
+# - dev : runs `astro dev` with HMR for local development
+# - builder : produces the static `dist/`
+# - prod : caddy:alpine that serves `dist/` (no Node at runtime)
+#
+# `docker compose --profile dev up` → dev target
+# `docker compose up` (no profile) → prod target
+
+# Pinned through the mirror.gcr.io pass-through to dodge intermittent
+# Docker Hub TLS hiccups during builds. Same content, more reliable
+# fetch path. The `docker pull` resolves identically against either.
+FROM mirror.gcr.io/library/node:22-alpine AS base
+WORKDIR /app
+COPY package.json ./
+RUN --mount=type=cache,target=/root/.npm \
+ npm install --no-audit --no-fund
+
+
+# ----- dev: astro dev server with HMR -----
+FROM base AS dev
+# Astro's binary is in node_modules/.bin — package.json's `dev` script
+# already binds to 0.0.0.0 for HMR-behind-Caddy.
+COPY . .
+ENV ASTRO_TELEMETRY_DISABLED=1
+EXPOSE 4321
+CMD ["npm", "run", "dev"]
+
+
+# ----- builder: produce dist/ -----
+FROM base AS builder
+COPY . .
+ENV ASTRO_TELEMETRY_DISABLED=1
+RUN npm run build
+
+
+# ----- prod: caddy serves the static build -----
+FROM mirror.gcr.io/library/caddy:2-alpine AS prod
+# Caddyfile is intentionally minimal — caddy-docker-proxy on the host
+# handles TLS, routing, and the public-facing reverse proxy. This
+# container just serves files locally; the proxy points at it.
+RUN mkdir -p /srv/docs
+COPY --from=builder /app/dist /srv/docs
+COPY Caddyfile /etc/caddy/Caddyfile
+EXPOSE 80
diff --git a/docs-site/Makefile b/docs-site/Makefile
new file mode 100644
index 0000000..467d8d0
--- /dev/null
+++ b/docs-site/Makefile
@@ -0,0 +1,50 @@
+# mcqemu docs — targets follow the warehacking cookie-cutter.
+
+SHELL := /usr/bin/env bash
+.SHELLFLAGS := -eu -o pipefail -c
+.DEFAULT_GOAL := help
+
+.PHONY: help
+help: ## Show this help
+ @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
+
+.PHONY: prod
+prod: ## Build + run the production docs container (Caddy serves dist/)
+ docker compose up -d --build docs
+
+.PHONY: dev
+dev: ## Run the Astro dev server with HMR (--profile dev)
+ docker compose --profile dev up --build docs-dev
+
+.PHONY: down
+down: ## Stop and remove the docs containers
+ docker compose --profile dev down
+ docker compose down
+
+.PHONY: logs
+logs: ## Tail logs (works for whichever profile is up)
+ docker compose logs -f --tail=100
+
+.PHONY: build
+build: ## Build the static site without bringing up Caddy (CI gate)
+ docker compose build docs
+
+# ---- Production deploy --------------------------------------------------
+#
+# Pulls origin/main on the warehack.ing prod host and rebuilds the docs
+# container. Agent forwarding (-A) lets the remote git pull use the
+# operator's key for Gitea, so nothing persistent is provisioned there.
+#
+# Use docker-2.supportedsystems.net, not warehack.ing: the latter resolves
+# to an IPv4 whose port 22 is unreachable from some networks.
+
+DEPLOY_HOST ?= warehack-ing@docker-2.supportedsystems.net
+DEPLOY_PATH ?= ~/mcqemu
+
+.PHONY: deploy
+deploy: ## Pull main + rebuild the docs container on the prod host
+ @echo "==> deploying $(DEPLOY_HOST):$(DEPLOY_PATH)"
+ ssh -A $(DEPLOY_HOST) "cd $(DEPLOY_PATH) && git fetch origin main && git reset --hard origin/main && cd docs-site && make prod"
+ @echo "==> verifying by content (these sites return 200 for every path)"
+ @curl -sS https://mcqemu.warehack.ing/ | grep -oE "
[^<]*" || echo " homepage title not found"
+ @curl -sS https://mcqemu.warehack.ing/definitely-not-a-page-xyz/ | grep -oE "[^<]*" || echo " (bogus path returned no title)"
diff --git a/docs-site/astro.config.mjs b/docs-site/astro.config.mjs
new file mode 100644
index 0000000..8fa9083
--- /dev/null
+++ b/docs-site/astro.config.mjs
@@ -0,0 +1,92 @@
+// mcqemu docs — Starlight with a diataxis-shaped sidebar.
+//
+// Telemetry and devToolbar are off per project convention. The HMR block
+// matters when the dev server runs behind Caddy: Vite's WebSocket needs an
+// explicit host, protocol and clientPort or HMR drops every few seconds
+// with "server connection lost".
+//
+// DOMAIN drives both the canonical site URL and the HMR host, so the same
+// image serves mcqemu.warehack.ing (prod) and mcqemu.l.warehack.ing (dev).
+
+import mdx from "@astrojs/mdx";
+import sitemap from "@astrojs/sitemap";
+import starlight from "@astrojs/starlight";
+import { defineConfig } from "astro/config";
+import remarkGfm from "remark-gfm";
+import starlightLinksValidator from "starlight-links-validator";
+
+const domain = process.env.DOMAIN ?? "mcqemu.warehack.ing";
+
+export default defineConfig({
+ site: `https://${domain}`,
+ telemetry: false,
+ devToolbar: { enabled: false },
+
+ vite: {
+ server: {
+ host: "0.0.0.0",
+ hmr: {
+ host: domain,
+ protocol: "wss",
+ clientPort: 443,
+ },
+ },
+ },
+
+ integrations: [
+ starlight({
+ title: "mcqemu",
+ description:
+ "MCP server for QEMU: VM lifecycle, disposable sandboxes, live snapshots, guest-agent access, and screenshot-driven control.",
+ favicon: "/favicon.svg",
+ customCss: ["./src/styles/theme.css"],
+
+ social: [
+ {
+ icon: "seti:git",
+ label: "Source",
+ href: "https://git.supported.systems/warehack.ing/mcqemu",
+ },
+ ],
+
+ // Diataxis order: orientation, then learning, then doing, then
+ // looking up, then understanding.
+ // Starlight v0.39+ needs {label, items:[{autogenerate}]}; the inline
+ // {label, autogenerate} shorthand was removed.
+ sidebar: [
+ {
+ label: "Start here",
+ items: [{ label: "What is mcqemu?", slug: "overview" }],
+ },
+ {
+ label: "Tutorial",
+ items: [{ autogenerate: { directory: "tutorial" } }],
+ },
+ {
+ label: "How-to",
+ items: [{ autogenerate: { directory: "how-to" } }],
+ },
+ {
+ label: "Reference",
+ items: [{ autogenerate: { directory: "reference" } }],
+ },
+ {
+ label: "Explanation",
+ items: [{ autogenerate: { directory: "explanation" } }],
+ },
+ ],
+
+ plugins: [
+ // Broken internal links fail the build instead of shipping.
+ starlightLinksValidator({ errorOnRelativeLinks: false }),
+ ],
+
+ pagination: true,
+ lastUpdated: true,
+ }),
+ // Astro enables GFM for .md, but the MDX pipeline does not inherit it,
+ // so tables render as run-together paragraphs without this.
+ mdx({ remarkPlugins: [remarkGfm] }),
+ sitemap(),
+ ],
+});
diff --git a/docs-site/docker-compose.yml b/docs-site/docker-compose.yml
new file mode 100644
index 0000000..a026dd1
--- /dev/null
+++ b/docs-site/docker-compose.yml
@@ -0,0 +1,68 @@
+# mcqemu docs site — two profiles.
+#
+# Default (no --profile flag):
+# production — Caddy serves the built dist/. This is what runs at
+# mcqemu.warehack.ing.
+#
+# --profile dev:
+# Astro dev server with HMR, src/ bind-mounted for live reload. The Vite
+# HMR WebSocket is configured in astro.config.mjs to survive the
+# TLS-terminating proxy; the caddy.reverse_proxy.* labels below keep the
+# connection from being closed as idle.
+#
+# Both services join the external `caddy` network and advertise themselves
+# to caddy-docker-proxy through labels. DOMAIN in .env switches between the
+# production and local-dev tiers.
+
+services:
+ docs:
+ profiles: ["prod", ""]
+ build:
+ context: .
+ target: prod
+ image: mcqemu-docs:prod
+ container_name: mcqemu-docs
+ restart: unless-stopped
+ networks:
+ - caddy
+ labels:
+ caddy: ${DOMAIN:-mcqemu.warehack.ing}
+ caddy.reverse_proxy: "{{upstreams 80}}"
+
+ docs-dev:
+ profiles: ["dev"]
+ build:
+ context: .
+ target: dev
+ image: mcqemu-docs:dev
+ container_name: mcqemu-docs-dev
+ restart: unless-stopped
+ environment:
+ - DOMAIN=${DOMAIN:-mcqemu.l.warehack.ing}
+ - ASTRO_TELEMETRY_DISABLED=1
+ volumes:
+ # node_modules stays inside the container so host/platform mismatches
+ # cannot break native deps.
+ - ./astro.config.mjs:/app/astro.config.mjs:ro
+ - ./tsconfig.json:/app/tsconfig.json:ro
+ - ./src:/app/src
+ - ./public:/app/public
+ networks:
+ - caddy
+ labels:
+ caddy: ${DOMAIN:-mcqemu.l.warehack.ing}
+ caddy.reverse_proxy: "{{upstreams 4321}}"
+ # Vite HMR sends no application-level pings, so Caddy's default idle
+ # timeouts would close the socket every 10-15s. Needs Caddy 2.10+.
+ caddy.reverse_proxy.flush_interval: "-1"
+ caddy.reverse_proxy.transport: "http"
+ caddy.reverse_proxy.transport.read_timeout: "0"
+ caddy.reverse_proxy.transport.write_timeout: "0"
+ caddy.reverse_proxy.transport.keepalive: "5m"
+ caddy.reverse_proxy.transport.keepalive_idle_conns: "10"
+ caddy.reverse_proxy.stream_timeout: "24h"
+ caddy.reverse_proxy.stream_close_delay: "5s"
+
+networks:
+ caddy:
+ external: true
diff --git a/docs-site/package.json b/docs-site/package.json
new file mode 100644
index 0000000..851f1fd
--- /dev/null
+++ b/docs-site/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "mcqemu-docs",
+ "type": "module",
+ "version": "0.1.0",
+ "private": true,
+ "scripts": {
+ "dev": "astro dev --host 0.0.0.0",
+ "build": "astro build",
+ "preview": "astro preview --host 0.0.0.0",
+ "astro": "astro"
+ },
+ "dependencies": {
+ "@astrojs/mdx": "^5.0.4",
+ "@astrojs/sitemap": "^3.7.2",
+ "@astrojs/starlight": "^0.39.2",
+ "astro": "^6.3.1",
+ "remark-gfm": "^4.0.1",
+ "sharp": "^0.34.0",
+ "starlight-links-validator": "^0.24.0"
+ }
+}
diff --git a/docs-site/public/favicon.svg b/docs-site/public/favicon.svg
new file mode 100644
index 0000000..97b2a73
--- /dev/null
+++ b/docs-site/public/favicon.svg
@@ -0,0 +1,7 @@
+
diff --git a/docs-site/src/content.config.ts b/docs-site/src/content.config.ts
new file mode 100644
index 0000000..56a1009
--- /dev/null
+++ b/docs-site/src/content.config.ts
@@ -0,0 +1,8 @@
+// Starlight content collection — required for content.config.ts in Astro 6.x.
+import { docsLoader } from "@astrojs/starlight/loaders";
+import { docsSchema } from "@astrojs/starlight/schema";
+import { defineCollection } from "astro:content";
+
+export const collections = {
+ docs: defineCollection({ loader: docsLoader(), schema: docsSchema() }),
+};
diff --git a/docs-site/src/content/docs/explanation/architecture.mdx b/docs-site/src/content/docs/explanation/architecture.mdx
new file mode 100644
index 0000000..4a79e3a
--- /dev/null
+++ b/docs-site/src/content/docs/explanation/architecture.mdx
@@ -0,0 +1,170 @@
+---
+title: Architecture
+description: How the MCP server, the QEMU processes, the QMP and guest-agent sockets, qemu-img and the JSON registry fit together, and why VMs are daemonized and monitor sessions are short-lived.
+---
+
+mcqemu is a thin control plane. It does not emulate anything, it does not proxy
+guest traffic, and it does not keep VMs alive. Everything it does is arranged so
+that the interesting state lives in the operating system (processes, sockets,
+files) rather than in the server's memory, because the server is the part most
+likely to be restarted at an awkward moment.
+
+## The pieces
+
+| Piece | What it is | Lifetime |
+| --- | --- | --- |
+| The MCP server | A Python process speaking MCP to your client (usually over stdio) | As long as your client keeps it |
+| A VM | A `qemu-system-*` process, daemonized, with its own pidfile | Until something stops it |
+| The QMP socket | A unix socket per VM, QEMU's machine monitor | The VM's lifetime |
+| The guest-agent socket | A second unix socket per VM, backing a virtio-serial port | The VM's lifetime |
+| `qemu-img` | A subprocess, spawned per image operation | One command |
+| The registry | A JSON file plus a lock file on disk | Persistent |
+
+The server owns none of the VMs in any operating-system sense. It is not their
+parent process, it does not hold their sockets open, and if you kill it nothing
+happens to them. The registry is a notebook, not an authority. When a tool needs
+to know whether a VM is running, it does not consult the notebook, it looks at
+`/proc` and at the socket.
+
+The two decisions that shape everything else are the process model and the
+session model.
+
+## Decision one: VMs outlive the server
+
+VMs are launched with `-daemonize` and `-pidfile`. QEMU forks, the foreground
+process exits, and the VM continues as a session leader unattached to anything
+mcqemu owns. Restarting your editor, upgrading mcqemu, or crashing the server
+leaves a Debian install halfway through its partitioner exactly where it was.
+
+This matters more than it might sound. An agent driving a VM will typically be
+in the middle of a long operation (an OS installation, a package build, a boot
+that takes two minutes under TCG emulation) and the MCP server is attached to an
+editor session that gets reloaded for unrelated reasons. A model where the VM
+dies with its parent would make those operations quietly unreliable in a way
+that is very hard to attribute.
+
+`-daemonize` also buys a clean launch verdict. QEMU's foreground process exits
+zero only after the VM is fully initialized and the QMP socket is listening, so
+awaiting that process gives a synchronous answer with real stderr on failure,
+rather than the usual "spawn it and poll for a socket" dance. When QEMU fails
+because a disk is locked or a machine type does not exist, the launch call
+returns that message directly. Daemonized QEMU routes later errors to its `-D`
+log file instead of stderr, so if the exit status is non-zero but stderr is
+empty, the launcher reads the last line of the log.
+
+### Liveness, and why the pidfile is not enough
+
+Reading a PID from a pidfile and calling `kill(pid, 0)` is the obvious liveness
+check and it is wrong. PIDs are recycled. A pidfile written days ago, on a host
+that has since rebooted or simply wrapped its PID space, can name a process that
+is very much alive and has nothing to do with your VM. Acting on that (sending a
+signal, reporting the VM as running) is a bug with real consequences.
+
+So liveness is two checks. First, `/proc//comm` must read back as something
+starting with `qemu-system`; a recycled PID belonging to a shell or a browser
+fails immediately. Second, for VMs mcqemu launched, `/proc//cmdline` is
+parsed for the `-name` argument and compared with the registry key. That second
+check is deliberately generous: it returns "matches" whenever it cannot prove
+otherwise (unreadable cmdline, no `-name` present), because its job is to veto a
+positive identification, not to manufacture a negative one.
+
+Attached VMs are judged differently. mcqemu did not choose their `-name` and may
+not know their PID at all, so when no PID was supplied their liveness comes from
+connecting to the QMP socket. A stat is not enough there either: a SIGKILLed
+QEMU leaves its socket file on disk, so the check opens a connection and sees
+whether anything accepts.
+
+## Decision two: QMP sessions are opened per call
+
+QEMU's QMP unix socket accepts exactly one client at a time. That single fact
+drives the session model. If mcqemu connected once at launch and held the
+connection for the VM's lifetime, it would own the monitor exclusively and
+everything else would be locked out: no `qmp-shell` for a human looking over the
+agent's shoulder, no second tool, no debugging. It would also leave connection
+state to reconcile whenever either side restarted, which for a server whose VMs
+outlive it is a recurring problem rather than an edge case.
+
+Instead every tool call opens a session, runs its commands, and disconnects. In
+between calls the socket is free. You can attach `qmp-shell` to a VM the agent is
+driving, poke at it, and detach, and the next tool call simply connects.
+
+The cost is that concurrent calls against the same VM would collide, and they
+would collide badly: the second connect would sit there until its five second
+timeout, then report the VM unreachable, which reads exactly like a VM that has
+exited. So sessions are serialized with a lock keyed by VM name. It is a mutex
+table over resources rather than shared mutable state, which means two calls
+against two different VMs still run in parallel, and only same-VM calls queue.
+
+That lock exists inside one server process. It cannot serialize a second mcqemu
+instance or your `qmp-shell`, and that is exactly why the connect-failure message
+is careful (see [Reliability](/explanation/reliability/)): when a connect fails
+but the process is demonstrably alive, the error says another client may hold the
+monitor and explicitly tells the caller not to relaunch.
+
+## The guest agent is a separate channel
+
+Every launched VM gets a second socket wired to a virtio-serial port named
+`org.qemu.guest_agent.0`. `guest_exec`, `guest_file_read`, `guest_file_write`,
+`guest_ping` and `guest_info` all speak to that socket, never to QMP.
+
+Keeping the channels separate is worth the extra socket. Guest-agent calls depend
+on software inside the guest and are therefore allowed to hang, time out, or
+never work at all; monitor calls control the machine from outside and must keep
+working when the guest is wedged. Sharing one channel would let a frozen guest
+interfere with the tools you use to deal with a frozen guest.
+
+The guest agent happens to speak the QMP wire format, so the same client library
+works with greeting and capability negotiation switched off. The protocol's
+mandatory `guest-sync` handshake, which echoes a random token, doubles as the
+probe for "is there actually an agent in there": if the token does not come back
+within three seconds, the guest does not have a working `qemu-guest-agent` and
+the tools say so rather than timing out on the real command later. See
+[Install the guest agent](/how-to/install-the-guest-agent/) for the guest side.
+
+## Images are `qemu-img` subprocesses
+
+Image work shells out to `qemu-img` and parses its JSON output. There is no
+library binding and no attempt to open qcow2 files directly.
+
+The reason is safety rather than convenience. `qemu-img` participates in QEMU's
+image locking protocol, so an attempt to convert or resize a disk that a running
+VM has open fails with a lock error instead of corrupting the image. mcqemu
+recognises that error and rewrites it into a sentence that names the actual
+problem. The registry's own view of which disks are busy is advisory only (it
+does not know an attached VM's disks and does not walk backing chains), so the
+image lock is treated as the authority and the registry view as a hint.
+
+## The registry is bookkeeping, not truth
+
+The registry is a JSON file under the state directory holding one record per VM:
+socket paths, pidfile, PID, the exact argv used, the launch config, log paths.
+It exists so that a freshly started server can find VMs that a previous one
+launched.
+
+Two properties matter more than speed. It must never take the server down, and
+it must tolerate more than one writer.
+
+More than one writer is not hypothetical. A second MCP client, a stray
+`uvx mcqemu`, or two clients configured against the same state directory all
+share one file. So every write is a read-modify-write under an exclusive
+`flock`, and the temp file used for the atomic rename carries the writing
+process's PID in its name. Without the lock the last writer's snapshot silently
+erases the other's VMs; with a shared temp name two writers could interleave and
+publish the mixture.
+
+Never taking the server down is the other half, and it is covered in
+[Reliability](/explanation/reliability/): an unreadable registry is quarantined
+and the server starts empty, reporting the damage through `list_vms` rather than
+refusing to start.
+
+## What this means in practice
+
+VMs survive server restarts, so `list_vms` after a restart shows what is really
+running rather than an empty list. The monitor socket is available to other
+tooling between calls. Two agents can share a host without clobbering each
+other's records, though they cannot serialize each other's monitor access, so
+same-VM contention shows up as a retryable error rather than a wedge.
+
+Paths for the state and runtime directories are documented in
+[Configuration](/reference/configuration/), and every tool named here is listed
+with its parameters in the [Tool reference](/reference/tools/).
diff --git a/docs-site/src/content/docs/explanation/reliability.mdx b/docs-site/src/content/docs/explanation/reliability.mdx
new file mode 100644
index 0000000..b51679d
--- /dev/null
+++ b/docs-site/src/content/docs/explanation/reliability.mdx
@@ -0,0 +1,191 @@
+---
+title: How mcqemu handles failure
+description: The four rules behind mcqemu's error handling (verify before destroying, degrade instead of dying, never guess an unchecked cause, bound every wait) and what each of them costs.
+---
+
+An MCP server that manages VMs is in an unusual position. Its callers are often
+models, which are good at acting on a plausible-sounding sentence and less good
+at doubting one. Its subjects are long-lived processes holding gigabytes of state
+that no one wants to lose. And it works on a host that is doing other things at
+the same time.
+
+Four rules follow from that. None of them is clever; all of them are the boring
+option, chosen deliberately.
+
+## Never delete without verifying what you waited for
+
+Waiting for something and then assuming it happened is the standard way to
+destroy data. `sandbox_destroy` is the only tool in mcqemu that deletes files, so
+it is where this rule is enforced hardest.
+
+It sends `quit` over the monitor, then polls until the PID is actually gone,
+giving it ten seconds. If the process is still there it escalates to `SIGKILL`
+and polls again for five. If the process is *still* alive after that, it stops
+and fails, and nothing is deleted. The error names the PID and says the overlay
+is still open by that process.
+
+That last branch is the point. Unlinking a disk image that QEMU still has open
+does not free the space (the kernel keeps the inode alive for the open file
+descriptor) and it does not stop the guest writing, so you end up with a running
+VM writing into a file that no longer has a name, consuming disk you cannot
+account for. Refusing to delete leaves you with a mess you can see, which is
+strictly better than a mess you cannot.
+
+The same instinct shows up in the surrounding checks. `sandbox_destroy` refuses
+outright to touch a VM that was not created by `sandbox_vm`, because `stop_vm`
+and `forget_vm` never delete disks and users reasonably assume the same of
+everything else. It re-validates the VM name it was handed rather than trusting
+the registry key it came from, since a corrupted record is exactly the input that
+turns a delete into a disaster. It resolves the directories it is about to remove
+and confirms they are genuinely inside the state and runtime roots, and are not
+the roots themselves. Cleanup errors are collected rather than swallowed, so the
+result distinguishes "destroyed" from "mostly destroyed, here is what is left".
+
+## Degrade instead of dying
+
+If the registry file is unreadable, mcqemu does not refuse to start. It moves the
+damaged file aside to a timestamped quarantine name, starts with an empty
+registry, and records what happened. `list_vms` returns that record in
+`registry_warnings` alongside the (now empty) VM list. Individual records that
+fail to parse are skipped one at a time with their own warning, rather than
+taking the whole file down with them.
+
+The reasoning is a direct consequence of the process model described in
+[Architecture](/explanation/architecture/). VMs outlive this server. A server
+that will not start because its notebook is corrupt is a server that cannot stop
+the runaway VM eating the host's memory, and the notebook has no bearing on
+whether that VM exists. Bookkeeping failures must not disarm the controls.
+
+The warning matters as much as the degradation. A quietly empty list would be
+read as "no VMs are running", which in this situation is exactly wrong: VMs may
+well be running untracked, and their QMP sockets are still sitting under the
+runtime directory where `attach_vm` can pick them up. So the tool's contract makes
+the warning list impossible to miss, and its own description tells the caller to
+report it rather than treat the list as complete.
+
+A registry written by a newer mcqemu is handled the other way: if the schema
+version is higher than this build understands, it is not loaded and not
+quarantined, because reading it optimistically and writing it back would damage
+records the newer build owns. Refusing and saying so is the safe direction.
+
+The same preference for a usable degraded state appears at launch. If QEMU exits
+zero (which means the VM is up and its monitor is listening) but the pidfile
+cannot be read, the launcher does not raise. Raising there would leave a running,
+unregistered VM that no tool could find or stop, which is the worst outcome
+available. It returns a record without a PID, and only fails when neither a
+pidfile nor a live QMP socket can be found, at which point it prints the `pgrep`
+command to locate the process by hand.
+
+## Do not guess a cause you have not checked
+
+The most consequential error message in mcqemu is the one for a failed QMP
+connect, and it has two forms.
+
+If the process is not alive, the message says the VM has likely exited and points
+at the QEMU log. If the process *is* alive, it says something quite different:
+that QEMU's monitor accepts one client at a time, that another tool call or an
+external `qmp-shell` may be holding it, and, explicitly, not to relaunch the VM.
+
+The difference is not politeness. A caller told "the VM has exited" will do the
+sensible thing and start it again, and if the VM was merely busy that means two
+QEMU processes fighting over one disk image, or a launch that fails confusingly
+on a locked image, or in the worst case a second guest writing to a filesystem
+the first one is already mounting. The wrong diagnosis produces a wrong action
+that produces real damage. So the liveness check runs before the message is
+written, and the message only claims what was checked.
+
+The same principle appears in a few other places:
+
+Waiting for a shutdown polls the process, not just the event queue. The client
+library's event queue never wakes on a dropped connection, so a VM that crashes
+mid-wait would burn the caller's entire timeout and then be reported as a guest
+that ignores ACPI, sending them to `force=True` for a machine that already died.
+Polling liveness once a second turns that into "process exited during shutdown",
+which is what happened.
+
+The identity check on a PID is deliberately one-sided. It compares the `-name` in
+`/proc//cmdline` against the registry key, and returns "this is not a
+different VM" whenever it cannot prove otherwise: unreadable cmdline, no `-name`
+present, a truncated argument list. Its job is to veto a mistaken identification,
+so an inconclusive read must not be allowed to contradict a positive liveness
+check.
+
+Liveness for an attached VM connects to its socket rather than stat-ing it,
+because a SIGKILLed QEMU leaves the socket file behind and the file's existence
+proves nothing. The same check protects launch: a leftover socket file under a
+name you are reusing is deleted, but only after confirming that nothing is
+listening on it, since an unregistered but running QEMU would otherwise lose its
+monitor and become permanently unreachable.
+
+And when a tool cannot tell, it says so. Timeouts report the events actually seen
+while waiting. Guest-agent failures name the specific thing that is missing
+(`qemu-guest-agent` installed and running in the guest) rather than reporting a
+generic connection error, and a guest agent that refuses a command on a
+RHEL-family guest gets the specific advice about `BLOCK_RPCS` in
+`/etc/sysconfig/qemu-ga`.
+
+## Bound every wait
+
+Every operation that waits has a deadline. An unbounded wait in a server managing
+multiple VMs does not just hang one call, it holds that VM's session lock and
+blocks every subsequent call against the same machine, so one wedged guest takes
+out an entire VM's tooling.
+
+| Wait | Bound | Why that number |
+| --- | --- | --- |
+| QMP connect | 5s | Either the socket is there or it is not |
+| QMP command | 30s | Ordinary monitor commands are fast; 30s means wedged |
+| Guest-agent handshake | 3s | Doubles as the "is there an agent?" probe |
+| Guest-agent call | 10s | The handshake succeeded, so the agent was alive a moment ago |
+| `guest_exec` | Caller's `timeout`, plus an outer bound | The poll loop checks the clock between awaits, so a single hung call needs its own ceiling |
+| `stop_vm` (graceful) | 30s, configurable | Long enough for a normal shutdown, short enough to notice ACPI being ignored |
+| `savevm` / `loadvm` | 900s | Writing or reading all of guest RAM legitimately takes minutes |
+| `sandbox_vm` agent wait | 90s, configurable | A cold boot plus the agent's own startup |
+
+`savevm` is the interesting one. Snapshotting a large VM writes its entire RAM
+into the qcow2 file and can honestly take minutes, so a 30 second bound would
+turn a working operation into a spurious failure. The response is a much longer
+bound rather than no bound at all, because "this can take a while" is not the same
+statement as "this can take forever", and a wedged snapshot still needs to
+surface eventually.
+
+`sandbox_vm` shows the other half of bounding a wait well: it does not simply
+sleep until its deadline. While waiting for the guest agent it also checks
+whether the VM is still alive, so a guest that dies during boot is reported as
+having exited, with the tail of the QEMU log, at the moment it happens, instead
+of being blamed ninety seconds later on a missing guest agent.
+
+## Concurrency is a failure mode too
+
+Two mcqemu instances can share one registry file, and nothing prevents it, so
+every write is a read-modify-write under an exclusive file lock and the atomic
+rename uses a temp name carrying the writer's PID. Without the lock the second
+writer's snapshot silently erases the first writer's VMs, and those VMs keep
+running with nobody tracking them. `list_vms` re-reads the file before answering
+so records another instance created are visible.
+
+Within one process, calls against the same VM are serialized by a per-VM lock
+(the monitor socket takes one client at a time), and a launch reserves its name
+for the duration, because launching involves awaits between checking that a name
+is free and registering it, and two concurrent launches would otherwise both pass
+the check and race over the same sockets and pidfile.
+
+## What all of this costs
+
+More calls fail than would otherwise. A tool that refuses to delete, refuses to
+guess, and gives up after a timeout will return errors in situations where a more
+optimistic implementation would have carried on and usually been fine. That is
+the trade being made, and it is made on purpose: the failures it avoids are the
+ones that lose data or leave two VMs on one disk, and those are not recoverable
+by retrying.
+
+A bounded wait can also fire on a slow but perfectly healthy machine, which is
+why the bounds most likely to be wrong for your host are parameters rather than
+constants. `stop_vm` takes a `timeout`, `guest_exec` takes a `timeout`, and
+`sandbox_vm` takes `wait_agent_s`. If you are running a guest under TCG emulation
+on a busy laptop, raise them rather than fighting the defaults.
+
+The errors are written to be acted on, which is worth knowing if you are reading
+them as a model: they usually name the next tool to call, and when the safe move
+is to do nothing they say that explicitly. Full parameter details for every tool
+mentioned here are in the [Tool reference](/reference/tools/).
diff --git a/docs-site/src/content/docs/explanation/sandboxing.mdx b/docs-site/src/content/docs/explanation/sandboxing.mdx
new file mode 100644
index 0000000..5efc2a3
--- /dev/null
+++ b/docs-site/src/content/docs/explanation/sandboxing.mdx
@@ -0,0 +1,154 @@
+---
+title: What sandboxing does and does not give you
+description: The isolation a mcqemu sandbox actually provides (a separate guest kernel, a copy-on-write overlay, blocked outbound networking) and an honest account of where that boundary ends.
+---
+
+A sandbox in mcqemu is a QEMU virtual machine with two defaults changed: its disk
+is a copy-on-write overlay over a base image, and its outbound networking is
+switched off. `sandbox_vm` does the overlay, the launch, and the wait for the
+guest agent in one call; `sandbox_destroy` tears the whole thing down. That is
+the entire mechanism. Everything below is about what those two defaults buy you
+and where they stop.
+
+## A separate kernel is the real boundary
+
+The isolation that matters here is not something mcqemu implements. It is the
+fact that a guest runs its own kernel on emulated hardware. Guest processes make
+syscalls into the guest kernel, which drives virtual devices, which QEMU
+implements in userspace on the host. Nothing in the guest addresses host memory,
+host processes, or host files, because there is no path from guest userspace to
+those things that does not go through the device model.
+
+That is a much stronger position than a container, where the guest and the host
+share one kernel and isolation is a matter of namespaces and seccomp filters
+being configured correctly. If you are running software you have reason to
+distrust, the kernel boundary is the reason to reach for a VM.
+
+The cost is that you pay for it. A VM boots a whole operating system, wants its
+own memory, and (when the guest architecture does not match the host) runs under
+TCG emulation, which is correct but slow.
+
+## The overlay: the base image is never written
+
+`sandbox_vm` creates a qcow2 overlay whose backing file is your base image, and
+gives the VM only the overlay. Every write the guest makes lands in the overlay.
+The base image is opened read-only through the backing chain and comes out
+byte-identical no matter what happens inside.
+
+This is what makes sandboxes cheap enough to be disposable. Creating one is
+writing a small file with a pointer to a big one, so a fresh sandbox from a
+20 GB base costs kilobytes and a second or two. It is also what makes
+`sandbox_destroy` safe: it deletes the overlay and the VM's state and runtime
+directories, and reports the base image path back under `base_image_untouched`
+so the answer is visible rather than assumed.
+
+Two consequences worth knowing. The overlay is recreated fresh on every
+`sandbox_vm` call for a given name, so a stale overlay from a dead sandbox never
+resurrects old state. And because the base is genuinely in use while a sandbox
+runs, mcqemu counts it among the disks in use, so an attempt to modify the base
+underneath a running sandbox is refused.
+
+If you want state to survive, take a snapshot (see [Snapshots](/how-to/snapshots/))
+or launch a normal VM against a real disk with `launch_vm` instead. Sandboxes
+are for work you intend to throw away. [Disposable sandboxes](/tutorial/disposable-sandboxes/)
+walks through the loop.
+
+## Outbound networking is off by default
+
+`sandbox_vm` launches with QEMU's user-mode networking (SLIRP) in restricted
+mode. Guest-initiated traffic is dropped. Inbound port forwards keep working, so
+the default forward from a free host port to guest port 22 is still how you get
+in, and `guest_exec` over the agent socket is unaffected because it never touches
+the network at all.
+
+The reason this is the default is specific, and it is worth understanding rather
+than taking on faith. SLIRP synthesizes a small private network for the guest,
+and in that network the address `10.0.2.2` is the host's loopback interface. A
+guest with unrestricted user-mode networking can therefore open connections to
+services listening on the host's `127.0.0.1`: a development database that never
+bothered with authentication because it only listens locally, an SSH agent
+forwarding socket, an unauthenticated Ollama or Redis, a `docker` API on a TCP
+port. The mental model of "it is only on localhost, so it is private" is exactly
+the model SLIRP breaks. `restrict=on` closes that path along with internet
+access.
+
+When a guest legitimately needs to fetch packages, pass `allow_network=True`, and
+be aware you are re-opening the host loopback path at the same time. There is no
+setting that gives internet access while blocking `10.0.2.2`, because SLIRP does
+not offer that distinction. If you need it, give the VM a real bridged or TAP
+interface and enforce the policy in your host firewall, which means starting
+QEMU yourself and registering it with
+[attach_vm](/how-to/attach-existing-vm/).
+
+`launch_vm` takes the same `restrict_net` flag, plus `no_net=True` to remove the
+network card entirely. A VM with no NIC has no forwards either, so it is only
+reachable through the guest agent and the display.
+
+## Where the boundary ends
+
+Everything above is real. None of it makes this a hardened security boundary
+against a determined attacker.
+
+**QEMU escapes exist.** The device model is a large body of C code parsing input
+that a hostile guest controls, and it has had exploitable bugs, some of them
+serious. mcqemu changes nothing about that surface. If your threat model
+includes an adversary willing to burn a QEMU escape on you, a mcqemu sandbox is
+not the control you want on its own. Real defense in depth for that case means
+running QEMU as a dedicated unprivileged user, on a host that holds nothing
+valuable, behind whatever seccomp and MAC policy your distribution offers, and
+not on your workstation.
+
+**The QEMU process runs as you.** It has your file permissions, your network
+access, and your ability to write to your own home directory. The boundary being
+defended is "the guest cannot reach the host", and it holds against the guest
+playing by the rules of the device model. It does not hold against an escape,
+because the thing that escapes lands with your privileges.
+
+**`extra_args` can weaken isolation.** `launch_vm` accepts raw QEMU flags,
+because there will always be a machine type or device you need and no wrapper
+anticipated. That escape hatch is an operator tool. Values you wrote yourself are
+fine; values derived from anything untrusted are not, because a few flags turn a
+VM into a hole straight through to the host. Those are rejected outright:
+
+| Rejected | Why |
+| --- | --- |
+| `-fsdev`, `-virtfs` | Host filesystem passthrough into the guest |
+| `-drive` / `-blockdev` naming `/dev/...` | Attaches a host block device |
+| `-chardev` with `spawn` | Runs a host command |
+| `-runas` | Changes the user the QEMU process runs as |
+| `-monitor` | Exposes the human monitor outside the managed socket |
+| `-qmp`, `-pidfile`, `-daemonize` | Collide with the sockets and process model mcqemu manages |
+
+Read that list as what it is: a guardrail against an agent constructing arguments
+from untrusted input, not a security perimeter. It is a fixed list of known-bad
+flags, not a proof that everything else is safe, and QEMU has a very large flag
+surface. An operator who genuinely wants host passthrough can start QEMU by hand
+and manage it with `attach_vm`, and nothing stops them, which is correct. The
+point of the check is that the path to weakening isolation should require a
+person deciding to do it.
+
+**The guest agent is root inside the guest, by design.** `guest_exec` runs
+commands as the agent's user, which is normally root, and `guest_file_write` will
+write anywhere the agent can reach. That is not a leak, it is the feature: you
+already control the VM's power button, its disks and its display from the host,
+so declining to give you a shell would be theatre. What it does mean is that
+installing the guest agent is a decision about the guest, not a neutral
+convenience. Do not install it in a VM whose contents you are supposed to be
+analysing at arm's length, and remember that a sandbox with the agent running has
+no meaningful defense against the host, only against the guest reaching out.
+
+## A summary you can act on
+
+Protected: your host filesystem from guest processes, your host services on
+loopback (with the default restricted networking), your base images from any
+guest writes, and your host kernel from guest syscalls.
+
+Not protected: anything, if QEMU itself is exploited; anything, if you pass
+isolation-weakening `extra_args`; the guest's own contents from you, since the
+agent and the monitor give you full control; and the host loopback path, if you
+pass `allow_network=True`.
+
+Use a sandbox to run software you have not read, to test an installer, to try a
+package that wants to touch your whole system, or to give an agent somewhere to
+make a mess. Do not use one as the only thing standing between you and code
+written specifically to attack you.
diff --git a/docs-site/src/content/docs/explanation/see-and-drive.mdx b/docs-site/src/content/docs/explanation/see-and-drive.mdx
new file mode 100644
index 0000000..7618e88
--- /dev/null
+++ b/docs-site/src/content/docs/explanation/see-and-drive.mdx
@@ -0,0 +1,148 @@
+---
+title: Seeing and driving a VM
+description: Why screenshots and scancode-level input let an agent operate a machine that has no OS yet, how absolute and relative pointers differ, and why the look-act-look loop beats a blind sequence of keystrokes.
+---
+
+Most tooling that automates a computer needs the computer's cooperation. SSH
+needs a running sshd, and a network. A guest agent needs a package installed and
+a service started. Configuration management needs an OS that already booted.
+Every one of those requires that somebody already got the machine into a working
+state, which is precisely the part that is tedious to do by hand.
+
+mcqemu can see and drive a VM without any of it. Screenshots come out of QEMU's
+framebuffer and keystrokes go into QEMU's emulated keyboard controller, so both
+work at a BIOS setup screen, a GRUB menu, a partitioner, a login prompt, or a
+kernel panic. That is the capability the display tools exist for.
+
+## Screenshots come from the framebuffer, not the guest
+
+`vm_screenshot` issues QMP's `screendump`, which asks QEMU to render whatever its
+emulated display device currently holds and write it to a file on the host as a
+PNG. The server reads that file, returns the image, and deletes it. The guest is
+not asked, not interrupted, and does not need to be capable of anything.
+
+Each call writes to a filename unique to that call. A shared name would let a
+concurrent screenshot swap the frame between capture and use (which matters for
+`vm_click`, described below), and would leave a picture of the guest's screen
+sitting on disk afterwards.
+
+Two things follow from "this is the framebuffer, not a rendering of guest state".
+A blank image is a real answer, not a failure: consoles blank, guests switch to
+a mode with nothing drawn yet, and a VM sitting at a black screen may be fine.
+And the resolution is whatever the guest last programmed, so it changes when an
+installer switches from text mode to a graphical mode, or when a display manager
+starts. Coordinates from an old screenshot do not survive a mode change.
+
+For text-mode guests there is a cheaper channel. If the guest writes to its
+serial port (a kernel booted with `console=ttyS0`, or a text installer),
+`vm_serial_read` returns the tail of the serial log as text, which is far easier
+to read than pixels and keeps scrollback that a screenshot cannot. It returns
+nothing useful for a guest that only draws to the screen.
+
+## Keyboard input is synthesized at the scancode level
+
+`vm_send_keys` uses QMP's `send-key`, which injects key events into the emulated
+keyboard controller as QEMU's `qcode` key identifiers. The guest sees exactly
+what it would see from a physical keyboard on the emulated hardware. Nothing
+above the hardware layer is involved, which is why this works before an OS
+exists.
+
+Each entry in the `keys` list is one press. A single key is `"ret"`, `"esc"`,
+`"f2"`, `"a"`; a chord is written with hyphens (`"ctrl-alt-f2"`, `"ctrl-c"`) and
+its keys are held together. Friendly aliases are accepted, so `"enter"`,
+`"space"` and `"escape"` resolve to `ret`, `spc` and `esc`. Between entries there
+is a configurable delay, and each press has a hold time, because firmware menus
+and some bootloaders sample the keyboard on a timer and simply miss a press that
+is too brief.
+
+`vm_type_text` is the same mechanism with a translation layer: each character is
+mapped through a US layout to a qcode plus an optional shift, and sent as its own
+press. This has consequences you should expect rather than discover. The layout
+is fixed, so a guest configured for a non-US keyboard will produce different
+characters than you typed, most visibly for symbols and for anything involving
+AltGr. And every character is a separate round trip over the monitor, so typing
+is slow and is capped at 4096 characters. Writing a config file by typing it into
+an editor works and is sometimes the only option; when the guest agent is
+available, `guest_file_write` is the right tool for anything longer than a
+command line.
+
+## Pointer input has two modes, and they behave differently
+
+**Absolute (the default).** VMs launched by mcqemu get a `virtio-tablet-pci`
+device, which reports positions rather than movements, exactly like a touchscreen
+or a drawing tablet. `vm_click` takes pixel coordinates that match what
+`vm_screenshot` showed you. It takes a screenshot first, both to prove a display
+exists and to read the resolution, checks that your coordinates are inside it,
+scales them into QEMU's absolute coordinate space (0 to 32767 on each axis), then
+sends the position and the button events. There is no cursor to chase: the
+pointer arrives where you said.
+
+The catch is that the guest needs a driver for that device. Modern Linux, recent
+Windows with virtio drivers, and many live images have one. A guest from before
+about 2010, or a minimal install without the driver, does not, and `vm_click`
+will appear to do nothing at all. That silent non-effect is the symptom to
+recognise.
+
+**Relative (the fallback).** `vm_mouse_move` drives the emulated PS/2 mouse
+through the human monitor, which every guest with any mouse support understands.
+The trade is that you are no longer specifying a position, you are specifying
+movement, and the guest decides what to do with it. Pointer acceleration means a
+50 pixel delta may move the cursor 50 pixels, or 90, depending on the guest's
+settings. Large deltas also desync some guests outright, which is why motion is
+sent in small packets (32 pixels by default, never more than 120, because PS/2
+deltas are small signed values).
+
+Because you cannot address a position directly, the reliable technique is to
+manufacture one. Pass `home="bottom-right"` (or any corner) and mcqemu pushes the
+cursor hard against that corner, overshooting deliberately, which pins it to a
+known location regardless of where it was. From there you move toward the target
+in small steps, take a screenshot to see where the cursor actually landed,
+correct with further small moves, and only then click. The tool's own response
+says as much, because getting this wrong is the default outcome and a click in
+the wrong place in an installer is expensive.
+
+## The look-act-look loop
+
+The single most useful habit when driving a VM is: take a screenshot, perform one
+action, take another screenshot. It feels wasteful and it is not.
+
+Blind sequences of keystrokes go wrong for reasons that have nothing to do with
+the sequence being incorrect. Guests take unpredictable time to react, and a VM
+under TCG emulation can be an order of magnitude slower than the same guest with
+KVM, so timing tuned once does not transfer. Input sent while the guest is
+probing devices or switching video modes is dropped on the floor with no error
+anywhere; the keystroke simply never happened. Installers reorder or insert
+screens depending on what hardware they find, what mirror they reach, and whether
+a disk already has a partition table. Focus moves on its own when a dialog
+appears. And the failure mode compounds: once one keystroke lands on the wrong
+screen, every subsequent keystroke in the sequence is being interpreted by a
+program you did not intend, and the machine can end up in a state that is worse
+than where it started (a partitioner that has silently selected the wrong disk is
+the memorable example).
+
+None of this is detectable from the return value of a key-send. The keystroke was
+delivered to the emulated keyboard successfully; that is all the success of that
+call means. The only evidence about what actually happened is the next
+screenshot.
+
+So the loop is: look at where you are, decide one action, take it, look again to
+confirm it did what you expected. When a step is slow (a package installation, a
+reboot, a filesystem being written) look repeatedly rather than sleeping for a
+guess. When something unexpected appears, you find out one action after it
+happened instead of twenty. [Drive an installer](/how-to/drive-an-installer/)
+applies this to a concrete case, and [your first sandbox](/tutorial/first-sandbox/)
+is a gentler place to get a feel for it.
+
+## What this does not give you
+
+The model is reading pixels. There is no accessibility tree, no DOM, no list of
+widgets, and no text extraction: a button is a shape with letters drawn on it, so
+low-contrast themes, unusual fonts, small text and scaled displays all degrade
+the read. Coordinates are display-space and go stale on a mode change. There is
+no way to ask what has focus, so a screenshot has to be interpreted for it.
+
+The compensation is that all of this works on absolutely any guest, including one
+whose OS does not exist yet. Once a guest is booted and has the agent installed,
+`guest_exec` is faster, more precise and much easier to check for success, and it
+is the right tool for anything reachable that way. Screenshots and scancodes are
+for getting to that point, and for the times when the guest cannot help you.
diff --git a/docs-site/src/content/docs/how-to/attach-existing-vm.mdx b/docs-site/src/content/docs/how-to/attach-existing-vm.mdx
new file mode 100644
index 0000000..1adbe0a
--- /dev/null
+++ b/docs-site/src/content/docs/how-to/attach-existing-vm.mdx
@@ -0,0 +1,149 @@
+---
+title: Manage a VM you started yourself
+description: Register an externally launched QEMU process with attach_vm, know which tools work on it, and understand what forget_vm does.
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+`launch_vm` builds its own QEMU command line, which is convenient until you
+need something it does not offer: a device model it never adds, a bridged
+network, a specific machine type, or a VM that libvirt or a script already
+starts for you.
+
+`attach_vm` covers that case. You start QEMU however you like, and mcqemu
+manages it through its QMP control socket. It never rewrites your command line
+and never launches the process; it only connects to what is already there.
+
+## Give QEMU the sockets
+
+The one hard requirement is a QMP unix socket, added with
+`-qmp unix:/path,server=on,wait=off`. The `server=on` part makes QEMU create
+and listen on the socket, and `wait=off` stops it from blocking at startup
+until a client connects.
+
+Two optional additions are worth including, because retrofitting them means
+restarting the guest:
+
+- A virtio-serial channel named `org.qemu.guest_agent.0` for the `guest_*`
+ tools.
+- `virtio-tablet-pci` for absolute pointer positioning, which is what
+ `vm_click` needs.
+
+A complete example:
+
+```bash
+qemu-system-x86_64 \
+ -name legacy \
+ -machine q35,accel=kvm -cpu host -m 4096 -smp 4 \
+ -drive file=$HOME/vms/legacy.qcow2,if=virtio,format=qcow2 \
+ -display none \
+ -qmp unix:$XDG_RUNTIME_DIR/legacy-qmp.sock,server=on,wait=off \
+ -chardev socket,id=qga0,path=$XDG_RUNTIME_DIR/legacy-qga.sock,server=on,wait=off \
+ -device virtio-serial \
+ -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0 \
+ -device virtio-tablet-pci \
+ -daemonize -pidfile $XDG_RUNTIME_DIR/legacy.pid
+```
+
+If the VM is already running without a QMP socket, there is no way to add one
+without restarting it. QMP is the only channel these tools have.
+
+## Attach it
+
+
+
+1. Register the process:
+
+ ```json
+ attach_vm(name="legacy",
+ qmp_socket="/run/user/1000/legacy-qmp.sock",
+ qga_socket="/run/user/1000/legacy-qga.sock",
+ pid=48213)
+ ```
+
+ Only `name` and `qmp_socket` are required. `qga_socket` enables the
+ `guest_*` tools; `pid` lets liveness checks tell "stopped" apart from
+ "unreachable" without probing the socket.
+
+2. The call verifies the socket exists, is really a unix socket, and answers a
+ QMP status query before registering anything, then returns the guest's
+ current status. A failure here means QEMU is not listening where you said.
+
+3. Confirm it shows up alongside everything else:
+
+ ```json
+ list_vms()
+ ```
+
+ The entry has `source: "attached"`, which is how the other tools know it was
+ not spawned here.
+
+
+
+Names must be unique across the registry. If the name is taken, either pick
+another or `forget_vm` the old entry first.
+
+## What works, and what does not
+
+Once attached, almost everything behaves as it does for a launched VM:
+
+- `vm_info`, `list_vms`, `pause_vm`, `resume_vm` all work over QMP.
+- `vm_screenshot`, `vm_send_keys` and `vm_type_text` work if the VM has a
+ display device.
+- `vm_click` needs the tablet device; without it, use `vm_mouse_move` (see
+ [Drive an installer](/how-to/drive-an-installer/)).
+- `guest_*` tools work if you passed `qga_socket` and the guest is running
+ `qemu-guest-agent`.
+- `vm_snapshot_*` works if the VM's writable disks are qcow2.
+- `stop_vm` sends an ACPI power button press over QMP, and with `force=true`
+ tells QEMU to quit outright.
+
+The exception is `vm_serial_read`. mcqemu reads the serial log file it set up
+at launch, and an attached VM's serial output goes wherever your own command
+line sent it. The tool reports that rather than guessing. Read your own log
+file directly, or use `vm_screenshot`.
+
+`sandbox_destroy` also refuses attached VMs, along with any VM that
+`sandbox_vm` did not create. It deletes disks, and it will only delete overlays
+it made itself.
+
+
+
+## What forget_vm does
+
+`forget_vm` removes the registry entry. That is all it does.
+
+```json
+forget_vm(name="legacy")
+```
+
+It does not stop the VM, does not touch the QEMU process, and never deletes a
+disk. Afterwards the VM keeps running exactly as before, and mcqemu simply has
+no record of it. The result reports `process_left_running` so you know whether
+you have just stopped tracking a live process.
+
+Use it to hand a VM back to whatever else owns it, to clear a stale entry for a
+VM that died elsewhere, or to free up a name.
+
+For a VM that this server launched, `forget_vm` refuses while the process is
+still alive unless you pass `force=true`. Forgetting a running spawned VM
+orphans it: the process stays up holding its disks and ports, and no tool can
+find it any more. The refusal exists so that only happens on purpose. If you do
+orphan one, find it with `pgrep -af qemu-system` and either kill it or attach
+it again by its socket path.
+
+Re-attaching later is just `attach_vm` with the same socket paths. Nothing
+about the running VM changed while it was unregistered.
+
+## Related
+
+- [Install and connect](/how-to/install-and-connect/) for host requirements.
+- [Architecture](/explanation/architecture/) for how the registry tracks VMs
+ and why they outlive the server.
+- [Tool reference](/reference/tools/) for parameters and defaults.
diff --git a/docs-site/src/content/docs/how-to/drive-an-installer.mdx b/docs-site/src/content/docs/how-to/drive-an-installer.mdx
new file mode 100644
index 0000000..645a46e
--- /dev/null
+++ b/docs-site/src/content/docs/how-to/drive-an-installer.mdx
@@ -0,0 +1,214 @@
+---
+title: Drive an installer
+description: Use the screenshot, act, screenshot loop to get through a bootloader, a text installer or a graphical one, including when to use vm_click versus vm_mouse_move.
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+Installers run before there is anything inside the guest to talk to. No SSH, no
+guest agent, no shell. The tools in this page work at the level QEMU emulates
+hardware: a framebuffer you can photograph, a keyboard that emits scancodes,
+and a mouse. That means they work in BIOS menus, bootloaders, partitioners and
+desktop environments alike, on any operating system, with nothing installed in
+the guest.
+
+This page assumes a VM is already running (see
+[Your first virtual machine](/tutorial/first-sandbox/) if not).
+
+## The loop
+
+Every interaction is the same three beats: look, act, look again.
+
+
+
+1. `vm_screenshot(name="vm")` returns the display as a PNG. Read what is
+ actually on screen.
+
+2. Send exactly one meaningful action: a keystroke, a line of text, a click.
+
+3. Screenshot again to see what it did.
+
+
+
+The second screenshot is not optional bookkeeping, it is the whole method. A
+guest takes time to react, and an installer's next question is rarely the one
+you predicted. Chaining three blind actions and screenshotting at the end
+usually means finding out that action one landed somewhere unexpected and
+actions two and three went into a dialog you did not know was there.
+
+
+
+Guests need time. After pressing Enter on a step that formats a filesystem or
+copies packages, wait before screenshotting again, and if the screen has not
+changed, wait longer rather than sending the key again. Repeated keystrokes
+queue up and fire all at once when the guest catches up.
+
+## Keyboard
+
+`vm_send_keys` presses keys and chords. Each entry in `keys` is one press:
+
+```json
+vm_send_keys(name="vm", keys=["down", "down", "ret"])
+vm_send_keys(name="vm", keys=["ctrl-alt-f2"])
+vm_send_keys(name="vm", keys=["esc"])
+```
+
+A chord is written with hyphens and is pressed together, so `"ctrl-c"` is one
+entry, not two. Aliases like `enter`, `space` and `escape` work alongside the
+QEMU names `ret`, `spc`, `esc`. Function keys are `f1` through `f12`.
+
+Two timing knobs matter when a guest is slow or an installer eats keys during
+a redraw: `hold_ms` (default 100) is how long each key is held down, and
+`delay_ms` (default 50) is the gap between entries. Raising `delay_ms` to 200
+or so is the usual fix for a menu that seems to miss presses.
+
+`vm_type_text` types a string character by character, which is what you want
+for hostnames, passwords and shell commands:
+
+```json
+vm_type_text(name="vm", text="setup-alpine", enter=true)
+```
+
+`enter=true` presses Enter at the end. The layout is US ASCII: printable
+characters plus tab and newline. There is a 4096 character limit, because every
+character is a separate round trip to the guest. If you need to put a large
+file into a guest, wait until the guest agent is available and use
+`guest_file_write` instead of typing it.
+
+## Text installers: read the serial console
+
+Many text-mode installers and Linux kernels booted with `console=ttyS0` write
+to the serial port, and mcqemu logs that to a file for every VM it launches:
+
+```json
+vm_serial_read(name="vm", tail_lines=100)
+```
+
+Text is much cheaper to read than a screenshot and it carries scrollback, so
+prefer it when the guest is producing it. Guests that only paint a graphical
+screen log nothing here, and VMs registered with `attach_vm` manage their own
+serial output, so `vm_serial_read` has nothing to read for those.
+
+## Mouse: which tool
+
+Two tools move the pointer, and picking the wrong one wastes a lot of time
+because the failure is silent. Nothing errors, the click just does not land.
+
+**`vm_click` is the one to try first.** It takes pixel coordinates that match
+what `vm_screenshot` showed you, and drives an absolute-position tablet device
+that every VM launched by this server has:
+
+```json
+vm_click(name="vm", x=412, y=337)
+vm_click(name="vm", x=88, y=120, button="right")
+vm_click(name="vm", x=200, y=150, double=true)
+```
+
+Coordinates are validated against the current display size, so a click outside
+the screen is rejected rather than silently dropped.
+
+This works when the guest has a driver for the tablet device. Modern Linux,
+Windows 7 and later, and current BSDs do. Screenshot after the click: if the
+pointer did not move to where you clicked, the guest has no absolute pointer
+driver and you need the other tool.
+
+**`vm_mouse_move` is for guests without tablet drivers**, which in practice
+means most operating systems older than about 2010, and some installers before
+their drivers load. It drives the emulated PS/2 mouse, which only understands
+relative motion: "three pixels left", never "go to 412, 337". The guest may
+also apply its own pointer acceleration, so a request to move 200 pixels can
+land 260 pixels away.
+
+The reliable pattern is corner, step, verify:
+
+
+
+1. Home the cursor against a corner so you know where it is. Motion is
+ deliberately overshot toward the edge, so the cursor pins there whatever its
+ previous position was.
+
+ ```json
+ vm_mouse_move(name="vm", home="top-left")
+ ```
+
+ Valid corners are `top-left`, `top-right`, `bottom-left`, `bottom-right`.
+
+2. Move toward the target with `dx` and `dy` relative to that corner:
+
+ ```json
+ vm_mouse_move(name="vm", home="top-left", dx=412, dy=337)
+ ```
+
+ Motion is sent in packets no larger than `step` pixels (default 32) because
+ guests commonly desync or over-accelerate on large deltas. Lower `step` if a
+ guest behaves erratically; the cost is more round trips.
+
+3. Screenshot and find the cursor. Acceleration means it is often not where you
+ asked.
+
+4. Correct with small relative moves, no `home` this time, until the cursor is
+ on the target:
+
+ ```json
+ vm_mouse_move(name="vm", dx=-14, dy=6)
+ ```
+
+5. Screenshot to confirm, then click:
+
+ ```json
+ vm_mouse_move(name="vm", click="left")
+ ```
+
+ You can also move and click in one call by passing `click` alongside `dx`
+ and `dy`, but only once you trust that guest's scaling. `double=true`
+ double-clicks.
+
+
+
+Homing costs a screenshot and a burst of motion packets, so for a sequence of
+clicks in the same area, home once and then work relatively from where you
+know the cursor is, re-homing whenever you lose track.
+
+## Worked example: a graphical installer
+
+
+
+1. Screenshot. The bootloader menu is showing.
+
+2. `vm_send_keys(name="vm", keys=["ret"])` to take the default entry.
+
+3. Wait, then screenshot. Repeat until the installer's first screen appears;
+ graphical installers can take a minute to start under emulation.
+
+4. Screenshot, find the "Next" button, and `vm_click` on its centre.
+
+5. Screenshot. If the button highlighted or the page advanced, the tablet works
+ and you can use `vm_click` for the rest of the installation. If nothing
+ moved, switch to `vm_mouse_move` with the corner, step, verify pattern.
+
+6. For text fields, click into the field, screenshot to confirm the caret is
+ there, then `vm_type_text`. Do not assume focus; installers move it around
+ between pages.
+
+7. At the partitioning step, screenshot before and after every action, and read
+ the confirmation dialog before answering it.
+
+
+
+## After the install
+
+When the guest is up, install `qemu-guest-agent` inside it (see
+[Install the guest agent](/how-to/install-the-guest-agent/)). Screenshots and
+keystrokes keep working afterwards, and stay the right tool for boot menus and
+crash screens, but for anything the guest can do itself, `guest_exec` gives you
+exit codes and text instead of pixels.
+
+## Related
+
+- [See and drive](/explanation/see-and-drive/) for how input injection and
+ screenshots work underneath.
+- [Tool reference](/reference/tools/) for the full parameter list.
diff --git a/docs-site/src/content/docs/how-to/install-and-connect.mdx b/docs-site/src/content/docs/how-to/install-and-connect.mdx
new file mode 100644
index 0000000..65ac1b7
--- /dev/null
+++ b/docs-site/src/content/docs/how-to/install-and-connect.mdx
@@ -0,0 +1,172 @@
+---
+title: Install and connect
+description: Install mcqemu with uv, register it with Claude Code at project or user scope, and confirm the host can actually run VMs.
+---
+
+import { Steps, Aside, Tabs, TabItem } from '@astrojs/starlight/components';
+
+## Requirements
+
+mcqemu drives local QEMU processes, so it runs where QEMU runs:
+
+- Linux, with `qemu-system-x86_64` and `qemu-img` on your PATH. Other guest
+ architectures need their own binary (`qemu-system-aarch64` and so on).
+- Python 3.11 or newer, managed with [uv](https://docs.astral.sh/uv/).
+- Read and write access to `/dev/kvm` for hardware acceleration. This is
+ optional: without it QEMU falls back to TCG software emulation, which works
+ and is considerably slower.
+
+Check the host before blaming the server:
+
+```bash
+qemu-system-x86_64 --version
+qemu-img --version
+ls -l /dev/kvm && test -w /dev/kvm && echo "kvm writable"
+```
+
+If `/dev/kvm` exists but is not writable, add yourself to the group that owns
+it (usually `kvm`) and log back in.
+
+## Install
+
+
+
+
+`uvx` downloads and runs the published package, with no install step to
+maintain:
+
+```bash
+uvx mcqemu
+```
+
+It prints a version banner to stderr and then waits for an MCP client on
+stdin. That is a healthy server; press Ctrl-C.
+
+
+
+
+Working on mcqemu itself, or pinning to a local revision:
+
+```bash
+git clone https://git.supported.systems/warehack.ing/mcqemu
+cd mcqemu
+uv sync
+uv run mcqemu
+```
+
+
+
+
+## Register with Claude Code
+
+Pick the scope by asking who should get these tools.
+
+
+
+
+Project scope writes `.mcp.json` in the repository, so anyone who checks it out
+gets the same server. Use this when VMs are part of the project's workflow,
+such as a repository whose tests need a throwaway guest.
+
+```bash
+claude mcp add --scope project mcqemu -- uvx mcqemu
+```
+
+The resulting file, which you can also write by hand:
+
+```json title=".mcp.json"
+{
+ "mcpServers": {
+ "mcqemu": {
+ "type": "stdio",
+ "command": "uvx",
+ "args": ["mcqemu"],
+ "env": {}
+ }
+ }
+}
+```
+
+From a checkout, use `"command": "uv"` with
+`"args": ["run", "--directory", "/path/to/mcqemu", "mcqemu"]` so the server
+runs regardless of which directory Claude Code starts in.
+
+
+
+
+User scope makes the tools available in every project you open. Use this when
+VMs are part of how you work rather than part of one repository.
+
+```bash
+claude mcp add --scope user mcqemu -- uvx mcqemu
+```
+
+
+
+
+
+
+## Confirm it works
+
+
+
+1. Run `/mcp` in Claude Code. `mcqemu` should be listed as connected. If it is
+ listed as failed, run the exact command from your config in a terminal; the
+ error is usually a missing `uv` on PATH or a wrong `--directory`.
+
+2. Ask the agent to call `list_vms`. A fresh install answers with an empty list
+ and empty `registry_warnings`.
+
+3. Launch something disposable to prove QEMU itself works. Any ISO will do, and
+ it does not need to boot anywhere useful:
+
+ ```json
+ launch_vm(name="smoketest", iso="~/isos/alpine-virt.iso")
+ ```
+
+ The result reports `accel: "kvm"` or `accel: "tcg"`, which tells you whether
+ acceleration is active.
+
+4. Take a screenshot with `vm_screenshot(name="smoketest")`. If you get a PNG of
+ a bootloader, every layer is working: the server, QEMU, and the QMP control
+ channel.
+
+5. Clean up: `stop_vm(name="smoketest", force=true)`. Force is appropriate here
+ because no operating system is running to answer a power button press.
+
+
+
+## When something fails
+
+**`qemu-system-x86_64 not found on PATH`.** QEMU is not installed, or not for
+that architecture. On Arch, `pacman -S qemu-full`; on Debian and Ubuntu,
+`apt install qemu-system-x86`.
+
+**The launch result says `accel: "tcg"` and you expected KVM.** Either
+`/dev/kvm` is not writable by your user, or the guest architecture does not
+match the host. KVM only applies when the two match; an aarch64 guest on an
+x86_64 host is always emulated.
+
+**A VM is running but no tool can see it.** VMs survive restarts of the MCP
+server because they are daemonized QEMU processes, and the registry on disk is
+what connects them. If `list_vms` returns a non-empty `registry_warnings`, that
+bookkeeping was damaged and VMs may be running untracked. Find them with
+`pgrep -af qemu-system` before launching anything with the same name.
+
+**You want the state kept somewhere else.** `MCQEMU_STATE_DIR` and
+`MCQEMU_RUNTIME_DIR` move the registry, logs and sockets;
+`MCQEMU_LOG_LEVEL=DEBUG` makes the server chattier on stderr. See
+[Configuration](/reference/configuration/).
+
+## Next
+
+- [Your first virtual machine](/tutorial/first-sandbox/) for a guided
+ install from an ISO.
+- [Tool reference](/reference/tools/) for every tool, parameter and default.
+- [Architecture](/explanation/architecture/) for what the server keeps on disk
+ and why VMs outlive it.
diff --git a/docs-site/src/content/docs/how-to/install-the-guest-agent.mdx b/docs-site/src/content/docs/how-to/install-the-guest-agent.mdx
new file mode 100644
index 0000000..001f071
--- /dev/null
+++ b/docs-site/src/content/docs/how-to/install-the-guest-agent.mdx
@@ -0,0 +1,208 @@
+---
+title: Install the guest agent
+description: Get qemu-guest-agent into a guest, either from inside a running VM or by injecting it into a stopped disk image with qemu-nbd and chroot.
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+`guest_exec`, `guest_file_read` and `guest_file_write` talk to
+`qemu-guest-agent` running inside the guest operating system. mcqemu wires the
+host side of that channel (a virtio-serial port named
+`org.qemu.guest_agent.0`) into every VM it launches, so the only missing piece
+is the package inside the guest.
+
+Check what you have:
+
+```json
+guest_ping(name="vm")
+```
+
+Success means you are done. Failure means the agent is not installed, not
+running, or the guest is paused or frozen. `guest_info` then reports the guest
+OS, the agent version, and which agent commands are enabled.
+
+## Option 1: from inside a running guest
+
+Use this when the guest is up and you can reach a console (with
+`vm_type_text`, see [Drive an installer](/how-to/drive-an-installer/)) or an
+SSH session through a forwarded port.
+
+The guest needs package downloads to work, so if this VM was created with
+`sandbox_vm`, it has outbound networking blocked by default; recreate it with
+`allow_network=true` for the install.
+
+Run the pair of commands for the distribution, as root:
+
+```bash
+# Debian, Ubuntu
+apt-get update && apt-get install -y qemu-guest-agent
+systemctl enable --now qemu-guest-agent
+
+# Fedora, RHEL, CentOS, Rocky, Alma
+dnf install -y qemu-guest-agent
+systemctl enable --now qemu-guest-agent
+
+# Arch
+pacman -S --noconfirm qemu-guest-agent
+systemctl enable --now qemu-guest-agent
+
+# Alpine
+apk add qemu-guest-agent
+rc-update add qemu-guest-agent default && rc-service qemu-guest-agent start
+
+# openSUSE
+zypper install -y qemu-guest-agent
+systemctl enable --now qemu-guest-agent
+```
+
+Windows guests get the agent from the
+[virtio-win](https://github.com/virtio-win/virtio-win-pkg-scripts) ISO: attach
+it with `launch_vm(..., iso="/path/to/virtio-win.iso")` and run
+`guest-agent\qemu-ga-x86_64.msi` from inside the guest.
+
+Then confirm from the host:
+
+```json
+guest_ping(name="vm")
+guest_exec(name="vm", command="uname", args=["-a"])
+```
+
+
+
+Enabling the service matters as much as installing the package. An agent that
+is running now but not enabled at boot will be missing the next time you launch
+that image, and every sandbox built on it.
+
+## Option 2: inject it into a stopped disk image
+
+Use this when you cannot get a console: a downloaded cloud image with no
+password set, an image whose network never comes up, or when you are preparing
+a base image and would rather not boot it at all.
+
+The technique exposes the disk image to the host as a block device with
+`qemu-nbd`, mounts the guest's root filesystem, and installs the package into
+it through `chroot`.
+
+
+
+
+
+1. Load the network block device module and connect the image:
+
+ ```bash
+ sudo modprobe nbd max_part=8
+ sudo qemu-nbd --connect=/dev/nbd0 ~/vms/debian.qcow2
+ ```
+
+2. Find the root partition. Do not guess: images vary, and picking a boot or
+ EFI partition wastes time.
+
+ ```bash
+ lsblk /dev/nbd0
+ sudo blkid /dev/nbd0p*
+ ```
+
+ The root filesystem is usually the largest ext4, xfs or btrfs partition.
+ If `lsblk` shows LVM physical volumes instead of a plain filesystem, run
+ `sudo vgchange -ay` and mount the resulting device under `/dev/mapper/`.
+
+3. Mount it, plus the pseudo-filesystems the package manager needs:
+
+ ```bash
+ sudo mkdir -p /mnt/guest
+ sudo mount /dev/nbd0p1 /mnt/guest
+ sudo mount --bind /dev /mnt/guest/dev
+ sudo mount --bind /proc /mnt/guest/proc
+ sudo mount --bind /sys /mnt/guest/sys
+ ```
+
+ If the image has a separate `/boot` or EFI partition, mount those inside
+ `/mnt/guest` too. Installing the agent does not need them, but a package
+ manager that decides to regenerate an initramfs will fail without them.
+
+4. Give the chroot working DNS, keeping the original so you can put it back:
+
+ ```bash
+ sudo cp /mnt/guest/etc/resolv.conf /mnt/guest/etc/resolv.conf.orig
+ sudo cp /etc/resolv.conf /mnt/guest/etc/resolv.conf
+ ```
+
+5. Install the agent inside the image:
+
+ ```bash
+ sudo chroot /mnt/guest apt-get update
+ sudo chroot /mnt/guest apt-get install -y qemu-guest-agent
+ sudo chroot /mnt/guest systemctl enable qemu-guest-agent
+ ```
+
+ `systemctl enable` works in a chroot because it only creates symlinks; it
+ does not need systemd to be running. `systemctl start` would fail, which is
+ fine, because the guest starts the service on its next boot. For dnf-based
+ images substitute `dnf install -y qemu-guest-agent`, and for Alpine
+ `apk add qemu-guest-agent` with `rc-update add qemu-guest-agent default`.
+
+6. Restore the guest's own resolver:
+
+ ```bash
+ sudo mv /mnt/guest/etc/resolv.conf.orig /mnt/guest/etc/resolv.conf
+ ```
+
+7. Tear down in reverse order, and check each step succeeded:
+
+ ```bash
+ sudo umount -R /mnt/guest
+ sudo qemu-nbd --disconnect /dev/nbd0
+ ```
+
+ If `umount` reports the target is busy, find what is holding it with
+ `sudo lsof +D /mnt/guest` and stop that before retrying. Disconnecting the
+ nbd device while a filesystem is still mounted loses writes.
+
+8. Boot it and check:
+
+ ```json
+ launch_vm(name="debian", disks=["~/vms/debian.qcow2"])
+ guest_ping(name="debian")
+ ```
+
+
+
+
+
+## Making it a base image
+
+Once `guest_ping` answers, that image is ready to be the base for disposable
+clones. Shut the VM down cleanly with `stop_vm` so the filesystem is
+consistent, then never launch the base directly again; build sandboxes on it
+instead, as in [Disposable sandboxes](/tutorial/disposable-sandboxes/).
+
+While you are in there, two things are worth doing to the image because every
+future sandbox inherits them: install the packages you always want, and make
+sure the SSH server is enabled if you plan to use the forwarded port that
+`sandbox_vm` sets up.
+
+## Related
+
+- [Disposable sandboxes](/tutorial/disposable-sandboxes/) for using the
+ finished base image.
+- [Snapshots](/how-to/snapshots/) for checkpointing an image before you modify
+ it.
+- [Tool reference](/reference/tools/) for the `guest_*` tool parameters.
diff --git a/docs-site/src/content/docs/how-to/snapshots.mdx b/docs-site/src/content/docs/how-to/snapshots.mdx
new file mode 100644
index 0000000..71fcdb5
--- /dev/null
+++ b/docs-site/src/content/docs/how-to/snapshots.mdx
@@ -0,0 +1,132 @@
+---
+title: Take and restore snapshots
+description: Live snapshots of running VMs capture RAM and restore instantly; offline image snapshots work on stopped disks. When to use each.
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+There are two kinds of snapshot, and choosing between them comes down to one
+question: is the VM running right now?
+
+| | Live snapshot | Offline image snapshot |
+|---|---|---|
+| Tools | `vm_snapshot_create` / `_restore` / `_delete` / `_list` | `image_snapshot_create` / `_apply` / `_delete` / `_list` |
+| Addressed by | VM name | disk image path |
+| VM state | must be running | must be stopped |
+| Captures | RAM, devices and disk | disk contents only |
+| Restoring gives you | the exact moment you snapshotted, applications still open | the disk as it was, which then boots from cold |
+| Requires | writable disks in qcow2 format | a qcow2 image |
+
+Both store their data inside the qcow2 file itself, so snapshots travel with
+the image when you copy it, and they cost disk space in the same file.
+
+## Live snapshots of a running VM
+
+Use these for checkpoints in the middle of work: before applying an update,
+before a change you expect to have to undo, before letting something untrusted
+run.
+
+
+
+1. Create the checkpoint. The VM pauses briefly while its RAM is written into
+ the disk image, then continues.
+
+ ```json
+ vm_snapshot_create(name="dev", tag="before-upgrade")
+ ```
+
+ Tags use letters, digits, dots, underscores and hyphens, start with a letter
+ or digit, and are at most 64 characters.
+
+2. Do the risky thing.
+
+3. List what you have if you have lost track:
+
+ ```json
+ vm_snapshot_list(name="dev")
+ ```
+
+4. Roll back. RAM, devices and disk all revert together, so the guest comes
+ back mid-sentence rather than booting.
+
+ ```json
+ vm_snapshot_restore(name="dev", tag="before-upgrade")
+ ```
+
+5. Drop a checkpoint you no longer need. The VM keeps running; only the saved
+ state is removed.
+
+ ```json
+ vm_snapshot_delete(name="dev", tag="before-upgrade")
+ ```
+
+
+
+
+
+How long a snapshot takes scales with how much RAM the VM has, because that RAM
+is being written to disk. A 2 GB VM is a few seconds; a 32 GB VM is a coffee
+break. Restoring is the same work in reverse and is still far quicker than a
+boot plus getting an application back to the state it was in.
+
+Live snapshots need every writable disk to be qcow2. If a VM has a raw disk
+attached, `vm_snapshot_create` fails, and the fix is to convert the disk while
+the VM is stopped:
+
+```json
+image_convert(source="~/vms/data.img", dest="~/vms/data.qcow2", format="qcow2")
+```
+
+## Offline snapshots of a stopped image
+
+Use these when the VM is not running: marking a known-good state of a base
+image, or checkpointing a disk before you modify it from the host (for example
+before injecting the guest agent, see
+[Install the guest agent](/how-to/install-the-guest-agent/)).
+
+```json
+image_snapshot_create(path="~/vms/base.qcow2", tag="clean-install")
+image_snapshot_list(path="~/vms/base.qcow2")
+image_snapshot_apply(path="~/vms/base.qcow2", tag="clean-install")
+image_snapshot_delete(path="~/vms/base.qcow2", tag="clean-install")
+```
+
+These tools refuse to touch a disk that is attached to a running VM, and say
+which VM is holding it. Stop that VM first. The refusal is deliberate: editing
+a disk underneath a live guest corrupts it, and the guest would not notice
+until much later.
+
+Only disk contents are stored, so `image_snapshot_apply` gives you an image
+that boots from cold. That is exactly what you want for a base image, and not
+what you want mid-session, which is why the live tools exist.
+
+## Choosing
+
+**Working inside a VM and about to do something reversible.** Live snapshot.
+The restore puts you back in the room you were standing in.
+
+**Preparing a base image, or about to edit a disk from the host.** Offline
+snapshot, with the VM stopped.
+
+**You want a clean throwaway VM every time, not a checkpoint.** Neither. Use
+`sandbox_vm` with an overlay, which is faster to create and cheaper to discard
+than any snapshot; see [Disposable sandboxes](/tutorial/disposable-sandboxes/).
+Snapshots are for going back inside one VM's history; overlays are for never
+changing the original in the first place.
+
+**You need a copy you can move to another machine or keep for months.**
+Neither. `image_convert` writes a standalone image, flattening any backing
+chain, which is easier to reason about than a snapshot living inside a file
+that also holds other states.
+
+## Related
+
+- [Tool reference](/reference/tools/) for exact parameters.
+- [Architecture](/explanation/architecture/) for how snapshot state is stored
+ in qcow2.
diff --git a/docs-site/src/content/docs/index.mdx b/docs-site/src/content/docs/index.mdx
new file mode 100644
index 0000000..e79bfbe
--- /dev/null
+++ b/docs-site/src/content/docs/index.mdx
@@ -0,0 +1,52 @@
+---
+title: mcqemu
+description: An MCP server for QEMU. Launch and drive virtual machines, run disposable sandboxes, take live snapshots, and reach inside guests, all as tools an agent can call.
+template: splash
+hero:
+ tagline: Give an agent a hypervisor. Launch VMs, watch their screens, type into them, snapshot them mid-flight, and throw them away.
+ actions:
+ - text: Start the tutorial
+ link: /tutorial/first-sandbox/
+ icon: right-arrow
+ variant: primary
+ - text: Tool reference
+ link: /reference/tools/
+ icon: open-book
+ variant: minimal
+---
+
+import { Card, CardGrid } from '@astrojs/starlight/components';
+
+
+
+ One call gives you a copy-on-write clone of a base image, booted, with the
+ guest agent answering. One more call destroys it. The base image is never
+ written to, and outbound networking is off unless you ask for it.
+
+
+ Screenshots come from the framebuffer, so they work at a BIOS menu or a
+ bootloader with no guest cooperation. Keys are injected as scancodes, which
+ is why they work before an OS exists.
+
+
+ Save RAM, devices and disks at a moment in time, keep working, then roll the
+ whole machine back. Restore is instant because the memory came with it.
+
+
+ With qemu-guest-agent installed, run commands and read or write files over
+ virtio-serial. No SSH, no network, no credentials.
+
+
+
+## Install
+
+```bash
+uvx mcqemu # run it
+claude mcp add mcqemu -- uvx mcqemu # add it to Claude Code
+```
+
+Requires Linux with QEMU on the PATH. `/dev/kvm` access is optional; without it
+guests run under software emulation, which works but is much slower.
+
+New here? [What is mcqemu?](/overview/) explains the shape of it in a couple of
+minutes, then the [tutorial](/tutorial/first-sandbox/) gets you to a running VM.
diff --git a/docs-site/src/content/docs/overview.mdx b/docs-site/src/content/docs/overview.mdx
new file mode 100644
index 0000000..336786e
--- /dev/null
+++ b/docs-site/src/content/docs/overview.mdx
@@ -0,0 +1,59 @@
+---
+title: What is mcqemu?
+description: "The shape of the project in two minutes: what it manages, what an agent can do with it, and what it deliberately does not try to be."
+---
+
+mcqemu is an MCP server that turns QEMU into tools an agent can call. It manages
+virtual machines on the machine it runs on: creating disks, booting them,
+watching their screens, typing into them, snapshotting them, running commands
+inside them, and tearing them down.
+
+QEMU already speaks a machine-friendly protocol. QMP is JSON over a unix socket,
+and the guest agent speaks the same framing over a virtio-serial channel. Most of
+mcqemu is a careful mapping from that surface onto tools with names and
+descriptions written for the thing calling them.
+
+## What you can do with it
+
+Boot an operating system from an installer ISO and drive the installation by
+looking at screenshots and sending keystrokes, without a display attached.
+Keep a base image around with the guest agent installed, then spin up disposable
+clones of it in seconds and destroy them when you are done. Freeze a running
+machine, try something destructive, and roll back to the exact moment before,
+memory included. Reach into a guest to run a command or drop in a file when there
+is no network and no credentials.
+
+## The pieces
+
+A VM launched by mcqemu is a daemonized QEMU process with two unix sockets: one
+for QMP, one for the guest agent. A small JSON registry records what exists.
+Because the QEMU processes are daemonized, they outlive the MCP server, so
+restarting your editor does not take down your machines. See
+[architecture](/explanation/architecture/) for why that shape was chosen and what
+it costs.
+
+Disk images are managed through `qemu-img`. Sandboxes are copy-on-write overlays
+on top of a base image, which is what makes them cheap to create and safe to
+throw away.
+
+## What it is not
+
+It is not a cluster manager. There is no scheduler, no migration, no multi-host
+awareness; mcqemu manages VMs on one machine and says so.
+
+It is not a hardened security boundary. A sandbox is a real VM with a separate
+kernel and no outbound network by default, which is a meaningful barrier against
+software that misbehaves, but it is not a claim that a determined attacker with a
+QEMU escape cannot get out. [Sandboxing](/explanation/sandboxing/) is explicit
+about where the line sits.
+
+It does not require anything of a guest in order to watch or drive it. Screens
+and keystrokes work at the firmware level. Only the `guest_*` tools need
+cooperation, in the form of `qemu-guest-agent` running inside.
+
+## Where to go next
+
+The [tutorial](/tutorial/first-sandbox/) starts from nothing and ends with a
+working VM you can talk to. The [tool reference](/reference/tools/) lists every
+tool with its parameters. If you would rather understand the machinery first,
+start with [architecture](/explanation/architecture/).
diff --git a/docs-site/src/content/docs/reference/configuration.mdx b/docs-site/src/content/docs/reference/configuration.mdx
new file mode 100644
index 0000000..d4d47e7
--- /dev/null
+++ b/docs-site/src/content/docs/reference/configuration.mdx
@@ -0,0 +1,93 @@
+---
+title: Configuration and file layout
+description: Environment variables, where state and sockets live, launch defaults, and how port forwarding and firmware are chosen.
+sidebar:
+ order: 2
+---
+
+## Environment variables
+
+| Variable | Default | What it does |
+| --- | --- | --- |
+| `MCQEMU_STATE_DIR` | `$XDG_DATA_HOME/mcqemu`, else `~/.local/share/mcqemu` | Registry, per-VM logs, sandbox overlays, UEFI variable stores |
+| `MCQEMU_RUNTIME_DIR` | `$XDG_RUNTIME_DIR/mcqemu`, else `/tmp/mcqemu-$UID` | QMP sockets, guest-agent sockets, pidfiles |
+| `MCQEMU_LOG_LEVEL` | `INFO` | Log level for the server's own stderr logging |
+
+Both directory variables are useful for tests and for running more than one
+independent instance. Sockets live under the runtime directory partly because
+unix socket paths are limited to about 107 bytes, and a short path leaves room
+for long VM names.
+
+## What lives where
+
+```
+$MCQEMU_STATE_DIR/
+ vms.json registry (schema-versioned, atomically written)
+ vms.json.lock advisory lock held during read-modify-write
+ vms//
+ qemu.log QEMU's own log, written after daemonizing
+ serial.log the guest's serial console
+ overlay.qcow2 sandbox overlay disk (sandboxes only)
+ uefi-vars.fd per-VM UEFI variable store (firmware="uefi")
+
+$MCQEMU_RUNTIME_DIR//
+ qmp.sock QMP control socket
+ qga.sock guest-agent channel
+ pid pidfile written by QEMU
+```
+
+The registry is rewritten under an exclusive lock, re-reading first, so a second
+mcqemu instance sharing the same state directory merges rather than overwrites.
+If the file is ever unreadable it is quarantined as `vms.corrupt..json`
+and the server starts with an empty registry, reporting the damage in the
+`registry_warnings` field of `list_vms`. Running VMs are unaffected by that; they
+can be re-registered with `attach_vm` using the sockets above.
+
+## Launch defaults
+
+`launch_vm` aims for a sensible modern machine and lets you override any of it.
+
+| Aspect | Default |
+| --- | --- |
+| Machine type | `q35` on x86, `virt` on aarch64 and riscv64, QEMU's own default elsewhere |
+| Acceleration | KVM with `-cpu host` when the guest architecture matches the host and `/dev/kvm` is writable, otherwise TCG with `-cpu max` |
+| Memory and CPUs | 2048 MB, 2 vCPUs |
+| Disks | virtio, with the format probed by `qemu-img` rather than guessed |
+| Display | none, with a display device still present so screenshots work |
+| Serial | written to `serial.log` |
+| Pointer | virtio tablet, so `vm_click` can target exact pixels |
+| Guest agent | virtio-serial channel wired on every launch |
+| Networking | user-mode with virtio-net; `restrict=on` when `restrict_net` is set |
+
+The result of `launch_vm` reports which accelerator was used, so a VM that
+quietly fell back to software emulation is visible rather than merely slow.
+
+## Port forwarding
+
+`port_forwards` accepts three spellings:
+
+| Form | Meaning |
+| --- | --- |
+| `"2222:22"` | Host port 2222 to guest port 22. The host port is bind-tested first, so a collision fails immediately with a clear message. |
+| `"auto:22"` | A free host port is chosen. The launch result reports which one. |
+| `"22"` | Shorthand for `auto:22`. |
+
+Duplicate host ports within one list are rejected rather than left for QEMU to
+fail on later.
+
+## Firmware
+
+`firmware="bios"` is the default. `firmware="uefi"` uses the edk2 images
+installed on the host: on x86_64 that is a read-only `OVMF_CODE` pflash plus a
+writable per-VM copy of `OVMF_VARS`, so UEFI variables persist per VM without
+touching the shared firmware. On aarch64 and riscv64 the firmware is supplied
+with `-bios` instead.
+
+## Escape hatch
+
+`extra_args` appends raw flags to the QEMU command line. It exists for tuning
+the tool surface does not cover, and it is for values you wrote yourself, never
+for values derived from untrusted input. Flags that would breach the isolation a
+VM is supposed to provide are rejected: host filesystem passthrough (`-fsdev`,
+`-virtfs`), host block devices, spawning chardevs, `-runas`, and flags that would
+collide with the sockets and pidfile mcqemu manages.
diff --git a/docs-site/src/content/docs/reference/tools.mdx b/docs-site/src/content/docs/reference/tools.mdx
new file mode 100644
index 0000000..1dda333
--- /dev/null
+++ b/docs-site/src/content/docs/reference/tools.mdx
@@ -0,0 +1,448 @@
+---
+title: Tool reference
+description: "Every mcqemu tool, its parameters and defaults, grouped by what it does."
+sidebar:
+ order: 1
+---
+
+The full tool surface, generated from the running server so it matches the code
+exactly. Descriptions are the text the calling model sees. Parameters marked
+`required` have no default; everything else may be omitted.
+
+## Sandboxes
+
+One call to a working disposable VM, and one to remove every trace of it.
+
+### `sandbox_vm`
+
+Spin up a disposable sandbox VM from a base disk image, in one call: creates a copy-on-
+write overlay (the base image is never modified), launches the VM, and waits for the
+guest agent to come up so guest_exec / guest_file_* are immediately usable. By default a
+free host port is forwarded to guest port 22 (pass port_forwards=[] to disable, or your
+own list). If no `name` is given, sandbox / sandbox-2 / ... is chosen. Tear everything
+down later with sandbox_destroy. Outbound networking is OFF by default: the guest cannot
+reach the internet or any service on the host, which is what makes it a sandbox. Inbound
+port forwards still work. Pass allow_network=True when the guest legitimately needs to
+fetch packages. Note that the guest agent answers well before the guest finishes
+booting, so this returns while services like networking are still starting. If a command
+depends on one, wait for it inside the guest (e.g. poll 'systemctl is-active
+NetworkManager') rather than assuming it is up.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `base_image` | string | required |
+| `name` | string | `null` |
+| `memory_mb` | integer | `2048` |
+| `cpus` | integer | `2` |
+| `port_forwards` | array | `null` |
+| `allow_network` | boolean | `false` |
+| `wait_agent_s` | integer | `90` |
+
+### `sandbox_destroy`
+
+Destroy a sandbox created with sandbox_vm: force-stop the VM, remove it from the
+registry, and delete its overlay disk and logs. The base image is untouched. Refuses to
+operate on VMs that were not created by sandbox_vm — use stop_vm / forget_vm for those
+(they never delete disks). If the VM cannot be killed, nothing is deleted and the call
+fails.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+
+## Lifecycle
+
+Starting, stopping and tracking virtual machines.
+
+### `launch_vm`
+
+Launch a new QEMU virtual machine and register it for management. Use `disks` for
+existing image files (create them first with image_create); use `iso` to boot an
+installer or live CD. With both, the VM boots the ISO once then the disk afterwards.
+`port_forwards` maps host ports to guest ports (user-mode networking): "2222:22" is
+explicit, "auto:22" (or just "22") picks a free host port — the result reports what was
+chosen. KVM acceleration is used automatically when the guest arch matches the host. The
+VM keeps running even if this MCP server restarts; stop it with stop_vm.
+restrict_net=True drops all guest-initiated traffic (no internet, and no reaching
+services on the host) while keeping port_forwards working inbound — use it when running
+untrusted software. no_net=True removes the NIC entirely. extra_args passes raw flags to
+qemu-system-* and is an operator escape hatch: only use values you wrote yourself, never
+values derived from untrusted input. Flags that would breach VM isolation (host
+filesystem passthrough, host block devices, spawning chardevs) are rejected.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `arch` | string | `"x86_64"` |
+| `disks` | array | `null` |
+| `iso` | string | `null` |
+| `memory_mb` | integer | `2048` |
+| `cpus` | integer | `2` |
+| `machine` | string | `null` |
+| `firmware` | string | `"bios"` |
+| `port_forwards` | array | `null` |
+| `no_net` | boolean | `false` |
+| `restrict_net` | boolean | `false` |
+| `extra_args` | array | `null` |
+
+### `stop_vm`
+
+Stop a VM. By default sends a graceful ACPI power-button press and waits for the guest
+to shut down; if the guest ignores it (no OS booted, or OS without ACPI handling), the
+call fails with advice to retry with force=True, which terminates QEMU immediately (like
+pulling the power cord).
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `force` | boolean | `false` |
+| `timeout` | integer | `30` |
+
+### `pause_vm`
+
+Pause (freeze) a running VM's virtual CPUs. The VM stays in memory; resume it with
+resume_vm.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+
+### `resume_vm`
+
+Resume a VM previously frozen with pause_vm.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+
+### `attach_vm`
+
+Register an externally launched QEMU process so the other tools can manage it. Point
+qmp_socket at its QMP unix socket (the QEMU process must have been started with e.g.
+-qmp unix:/path,server=on,wait=off). Optionally provide qga_socket for guest-agent tools
+and pid for liveness tracking.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `qmp_socket` | string | required |
+| `qga_socket` | string | `null` |
+| `pid` | integer | `null` |
+
+### `forget_vm`
+
+Remove a VM from the registry WITHOUT stopping it — the QEMU process is left untouched.
+Refuses to forget a running VM this server spawned unless force=True (to avoid orphaning
+processes by accident).
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `force` | boolean | `false` |
+
+## Inspection
+
+What exists and what state it is in.
+
+### `list_vms`
+
+List every registered VM with its live status (running, paused, shutdown, stopped, or
+unreachable). Includes both VMs launched by this server and externally attached ones.
+Returns \{"vms": [...], "registry_warnings": [...]\}. A non-empty registry_warnings
+means bookkeeping was damaged and some VMs may be running but untracked — report it
+rather than assuming the list is complete.
+
+### `vm_info`
+
+Detailed information about one VM: its launch configuration, log file paths, and — when
+running — live QMP state (status, vCPUs, block devices).
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+
+## See and drive
+
+Watching a VM's display and injecting input, with no guest software required.
+
+### `vm_screenshot`
+
+Capture the VM's current display as a PNG image. Works on any running VM with a display
+device (the default for launched x86 VMs) — no guest software needed. Use this to watch
+installers, read console output, or verify GUI state before sending keys with
+vm_send_keys / vm_type_text.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+
+### `vm_send_keys`
+
+Press keys or key combos in the VM. Each list entry is one press: a single key ("ret",
+"esc", "f2", "a") or a chord pressed together ("ctrl-alt-f2", "ctrl-c"). Entries are
+sent in order with delay_ms between them. Aliases like "enter", "space", "escape" work.
+To type prose, use vm_type_text instead.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `keys` | array | required |
+| `hold_ms` | integer | `100` |
+| `delay_ms` | integer | `50` |
+
+### `vm_type_text`
+
+Type a string into the VM, character by character (US keyboard layout; printable ASCII
+plus tab and newline). Set enter=True to press Enter at the end — handy for shell
+commands at a console login or terminal.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `text` | string | required |
+| `enter` | boolean | `false` |
+| `delay_ms` | integer | `30` |
+
+### `vm_click`
+
+Click at pixel coordinates (x, y) on the VM display — coordinates match what
+vm_screenshot shows. Requires the VM's tablet device for absolute positioning (present
+on VMs launched by this server). button: left, right, or middle.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `x` | integer | required |
+| `y` | integer | required |
+| `button` | string | `"left"` |
+| `double` | boolean | `false` |
+
+### `vm_mouse_move`
+
+Relative mouse control for guests WITHOUT absolute-pointer (tablet) drivers — most
+pre-2010 OSes. Use this when vm_click has no visible effect. Motion goes to the emulated
+PS/2 mouse in small steps (\<= `step` px per packet) because guests often desync or
+apply acceleration on large deltas. Recommended pattern: pass home="bottom-right" (or
+another corner) to pin the cursor to a known position first, then dx/dy toward the
+target, then vm_screenshot to verify where the cursor actually landed (guest
+acceleration may scale motion), correct with further small moves, and finally click.
+`click` presses that button after moving; double=True double-clicks.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `dx` | integer | `0` |
+| `dy` | integer | `0` |
+| `home` | string | `null` |
+| `click` | string | `null` |
+| `double` | boolean | `false` |
+| `step` | integer | `32` |
+
+### `vm_serial_read`
+
+Read the last lines of the VM's serial console log. Only useful when the guest writes to
+its serial port (kernel console=ttyS0, or text-mode installers); GUI-only guests log
+nothing here — use vm_screenshot for those.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `tail_lines` | integer | `50` |
+
+## Live snapshots
+
+Point-in-time state of a running VM, including RAM. Requires qcow2 disks.
+
+### `vm_snapshot_create`
+
+Save a live internal snapshot (RAM + device + disk state) of a running VM under `tag`.
+Requires the VM's writable disks to be qcow2. The VM pauses briefly while state is
+written. Restore later with vm_snapshot_restore.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `tag` | string | required |
+
+### `vm_snapshot_restore`
+
+Roll a running VM back to the internal snapshot `tag` (RAM, devices and disks all
+revert). Anything that happened after the snapshot is lost.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `tag` | string | required |
+
+### `vm_snapshot_delete`
+
+Delete the internal snapshot `tag` from a running VM's disks. The VM keeps running; only
+the saved snapshot is removed.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `tag` | string | required |
+
+### `vm_snapshot_list`
+
+List internal snapshots visible to a running VM. For stopped VMs use image_snapshot_list
+on the disk file instead.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+
+## Disk images
+
+qemu-img operations on images that are not currently attached to a running VM.
+
+### `image_create`
+
+Create a new disk image. `size` uses qemu-img suffixes, e.g. "20G". qcow2 grows on
+demand, so a large virtual size costs almost nothing up front. With `backing_file`, the
+new image becomes a copy-on-write overlay — great for cheap disposable clones of a base
+image. Refuses to replace an existing file unless overwrite=True.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `path` | string | required |
+| `size` | string | required |
+| `format` | string | `"qcow2"` |
+| `backing_file` | string | `null` |
+| `overwrite` | boolean | `false` |
+
+### `image_info`
+
+Inspect a disk image: format, virtual and on-disk size, internal snapshots, and (with
+backing_chain=True) the full copy-on-write chain.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `path` | string | required |
+| `backing_chain` | boolean | `false` |
+
+### `image_convert`
+
+Convert a disk image to another format (e.g. raw -> qcow2, vmdk -> qcow2). Conversion
+flattens any backing chain into a standalone image. compress=True enables qcow2
+compression (smaller, slower).
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `source` | string | required |
+| `dest` | string | required |
+| `format` | string | `"qcow2"` |
+| `compress` | boolean | `false` |
+| `overwrite` | boolean | `false` |
+
+### `image_resize`
+
+Resize a disk image's virtual size (e.g. size="30G", or "+10G" to grow relatively).
+Growing is safe; shrinking DESTROYS data beyond the new size and requires shrink=True as
+explicit confirmation (shrink the guest filesystem first!).
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `path` | string | required |
+| `size` | string | required |
+| `shrink` | boolean | `false` |
+
+### `image_snapshot_create`
+
+Create an internal disk-only snapshot in an offline qcow2 image. For running VMs use
+vm_snapshot_create instead (it also captures RAM).
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `path` | string | required |
+| `tag` | string | required |
+
+### `image_snapshot_apply`
+
+Revert an offline qcow2 image to internal snapshot `tag`. Data written after the
+snapshot is lost.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `path` | string | required |
+| `tag` | string | required |
+
+### `image_snapshot_delete`
+
+Delete internal snapshot `tag` from an offline qcow2 image.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `path` | string | required |
+| `tag` | string | required |
+
+### `image_snapshot_list`
+
+List internal snapshots stored in a (not currently running) qcow2 image.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `path` | string | required |
+
+## Guest agent
+
+Reaching inside a guest. Needs qemu-guest-agent installed and running in the guest OS.
+
+### `guest_ping`
+
+Check whether the qemu-guest-agent inside the VM is alive and responding. A failure
+means the guest OS doesn't have the agent installed or running — the other guest_* tools
+won't work until it does.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+
+### `guest_info`
+
+Report the guest OS details (name, version, kernel) and the guest agent's version and
+supported commands.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+
+### `guest_exec`
+
+Run a command inside the guest OS and return its stdout, stderr, and exit code.
+`command` is the executable path or name; pass arguments separately in `args` (this is
+exec, not a shell — for shell features use command="/bin/sh", args=["-c", "your |
+pipeline"]). Requires qemu-guest-agent in the guest.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `command` | string | required |
+| `args` | array | `null` |
+| `stdin` | string | `null` |
+| `timeout` | integer | `30` |
+
+### `guest_file_read`
+
+Read a text file from inside the guest (up to max_bytes, default 1 MiB, 8 MiB ceiling).
+Requires qemu-guest-agent in the guest.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `path` | string | required |
+| `max_bytes` | integer | `1048576` |
+
+### `guest_file_write`
+
+Write a text file inside the guest (mode 'w' truncates, append=True appends). Requires
+qemu-guest-agent in the guest.
+
+| Parameter | Type | Default |
+| --- | --- | --- |
+| `name` | string | required |
+| `path` | string | required |
+| `content` | string | required |
+| `append` | boolean | `false` |
+
diff --git a/docs-site/src/content/docs/tutorial/disposable-sandboxes.mdx b/docs-site/src/content/docs/tutorial/disposable-sandboxes.mdx
new file mode 100644
index 0000000..c93e323
--- /dev/null
+++ b/docs-site/src/content/docs/tutorial/disposable-sandboxes.mdx
@@ -0,0 +1,206 @@
+---
+title: Disposable sandboxes
+description: Turn a base image into throwaway VMs that boot in seconds, run something in one, then delete it without touching the base.
+sidebar:
+ order: 2
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+In [Your first virtual machine](/tutorial/first-sandbox/) we spent twenty
+minutes installing an operating system. In this tutorial we get a working VM
+from that same image in a few seconds, run a command in it, deliberately make a
+mess inside it, and throw it away. Then we do it again and watch the mess be
+gone.
+
+This is the loop worth internalising: build a base image once, then treat
+individual VMs as cheap and disposable.
+
+## What you need
+
+The base image from the first tutorial, `~/vms/alpine-agent.qcow2`, with
+`qemu-guest-agent` installed inside it, and the VM built on it shut down.
+
+
+
+## Create a sandbox
+
+
+
+1. Ask the agent for a sandbox:
+
+ ```json
+ sandbox_vm(base_image="~/vms/alpine-agent.qcow2")
+ ```
+
+ One call does four things: it creates a copy-on-write overlay disk, launches
+ a VM on that overlay, forwards a free host port to guest port 22, and waits
+ for the guest agent to answer before returning.
+
+2. Read the result closely, because it tells us what we got:
+
+ ```json
+ {
+ "name": "sandbox",
+ "status": "running",
+ "sandbox": true,
+ "overlay": "~/.local/share/mcqemu/vms/sandbox/overlay.qcow2",
+ "base_image": "/home/you/vms/alpine-agent.qcow2",
+ "network": "outbound blocked",
+ "guest_agent": "responding",
+ "port_forwards": ["39221:22"]
+ }
+ ```
+
+ We did not pass a name, so it picked `sandbox` (the next one would be
+ `sandbox-2`). `guest_agent: responding` means `guest_exec` will work right
+ now, with no waiting and no login.
+
+3. Prove the guest is real:
+
+ ```json
+ guest_exec(name="sandbox", command="uname", args=["-a"])
+ ```
+
+
+
+
+
+## The base image is never modified
+
+The sandbox writes to an overlay file, and the overlay refers back to the base
+for anything it has not changed. Let us see it.
+
+
+
+1. Write a file inside the guest:
+
+ ```json
+ guest_file_write(name="sandbox", path="/root/evidence.txt",
+ content="this sandbox was here\n")
+ ```
+
+2. Read it back to confirm it landed:
+
+ ```json
+ guest_file_read(name="sandbox", path="/root/evidence.txt")
+ ```
+
+3. Now do some real damage, the kind you would never risk on a machine you
+ cared about:
+
+ ```json
+ guest_exec(name="sandbox", command="/bin/sh", args=["-c", "rm -rf /etc/apk"])
+ ```
+
+4. Look at the relationship between the two disks:
+
+ ```json
+ image_info(path="~/.local/share/mcqemu/vms/sandbox/overlay.qcow2", backing_chain=true)
+ ```
+
+ The chain shows the overlay on top and `~/vms/alpine-agent.qcow2` below it
+ as the backing file. Check the base image's modification time on the host:
+ it has not changed. Every write the guest made went into the overlay.
+
+
+
+## Outbound networking is off by default
+
+Try to reach the internet from inside the sandbox:
+
+```json
+guest_exec(name="sandbox", command="/bin/sh", args=["-c", "apk update"])
+```
+
+It fails. That is the point of the default: traffic the guest starts is
+dropped, so it cannot reach the internet or any service listening on your host.
+Inbound port forwards still work, which is why the forwarded SSH port is
+useful even with networking restricted.
+
+When a sandbox legitimately needs to fetch packages, ask for it explicitly with
+`allow_network=true`, and know that you have opened the door on purpose.
+
+## Throw it away
+
+
+
+1. Destroy the sandbox:
+
+ ```json
+ sandbox_destroy(name="sandbox")
+ ```
+
+ The result confirms `overlay_deleted: true` and names the base image it left
+ alone. The VM is force-stopped, removed from the registry, and its overlay
+ and logs are deleted.
+
+2. Create a fresh one from the same base:
+
+ ```json
+ sandbox_vm(base_image="~/vms/alpine-agent.qcow2")
+ ```
+
+3. Look for the damage:
+
+ ```json
+ guest_file_read(name="sandbox", path="/root/evidence.txt")
+ ```
+
+ The file does not exist, and `/etc/apk` is back. The new sandbox started
+ from the base image exactly as it was.
+
+4. Clean up:
+
+ ```json
+ sandbox_destroy(name="sandbox")
+ ```
+
+
+
+
+
+## What we learned
+
+A base image plus an overlay gives us VMs that cost seconds instead of minutes,
+because the expensive part (installing an operating system) already happened
+and is shared by every sandbox built on it.
+
+Overlays make throwing a VM away the normal outcome rather than a loss.
+`sandbox_destroy` then `sandbox_vm` is a full reset back to a known state, and
+the base image is safe from anything the guest does because the guest can only
+write to the overlay.
+
+Restricting outbound traffic by default is what makes it a sandbox rather than
+just a fast VM. Software running inside it cannot phone anywhere or poke at
+services on your host unless you decide otherwise.
+
+## Next
+
+- [Snapshots](/how-to/snapshots/) if you want checkpoints inside a
+ longer-lived VM instead of a clean slate every time.
+- [Install the guest agent](/how-to/install-the-guest-agent/) to turn another
+ operating system into a base image, including injecting the agent into a
+ downloaded cloud image offline.
+- [Sandboxing](/explanation/sandboxing/) for what these isolation boundaries do
+ and do not promise.
diff --git a/docs-site/src/content/docs/tutorial/first-sandbox.mdx b/docs-site/src/content/docs/tutorial/first-sandbox.mdx
new file mode 100644
index 0000000..7bf620a
--- /dev/null
+++ b/docs-site/src/content/docs/tutorial/first-sandbox.mdx
@@ -0,0 +1,283 @@
+---
+title: Your first virtual machine
+description: Install mcqemu, create a disk, install an operating system from an ISO, and end with a VM your agent can run commands inside.
+sidebar:
+ order: 1
+---
+
+import { Steps, Aside } from '@astrojs/starlight/components';
+
+In this tutorial we install mcqemu, create a blank disk, install Alpine Linux
+onto it from an installer ISO while watching the screen, and finish with a VM
+whose guest agent answers, so the agent can run commands inside it.
+
+We will drive everything through an LLM agent (the examples assume Claude Code).
+Each step shows the tool call to ask for, and what the result should look like,
+so you can tell at a glance whether the step worked.
+
+Expect this to take around twenty minutes, most of which is the operating
+system installer doing its own thing.
+
+## What you need
+
+A Linux host with QEMU installed (`qemu-system-x86_64` and `qemu-img` on your
+PATH), [uv](https://docs.astral.sh/uv/), and about 2 GB of free disk space.
+Access to `/dev/kvm` makes everything much faster, but the tutorial works
+without it under software emulation.
+
+Download the Alpine "virt" ISO before starting, from
+[alpinelinux.org/downloads](https://alpinelinux.org/downloads/). It is around
+60 MB. We will assume it is at `~/isos/alpine-virt.iso`, and we will keep our
+VM disk at `~/vms/alpine-agent.qcow2`, so create those directories now:
+
+```bash
+mkdir -p ~/isos ~/vms
+```
+
+## Install and connect the server
+
+
+
+1. Check that the server starts. `uvx` fetches mcqemu and runs it; the version
+ banner appears on stderr and then it waits for a client, which is what an
+ MCP server is supposed to do.
+
+ ```bash
+ uvx mcqemu
+ ```
+
+ Press Ctrl-C to stop it. Seeing `mcqemu v...` is all we needed.
+
+2. Register it with Claude Code:
+
+ ```bash
+ claude mcp add mcqemu -- uvx mcqemu
+ ```
+
+3. Start Claude Code in any directory and run `/mcp`. The list should include
+ `mcqemu` as connected. If it does not, [Install and connect](/how-to/install-and-connect/)
+ covers the usual causes.
+
+4. Ask the agent to call `list_vms`. On a fresh install it answers with an
+ empty list:
+
+ ```json
+ {"vms": [], "registry_warnings": []}
+ ```
+
+ An empty `registry_warnings` means the bookkeeping is healthy. We now have a
+ working server with nothing to manage yet.
+
+
+
+## Create a disk and start the installer
+
+
+
+1. Ask the agent to create the disk:
+
+ ```json
+ image_create(path="~/vms/alpine-agent.qcow2", size="8G")
+ ```
+
+ The result reports `format: qcow2` and a virtual size of 8 GB. Check the
+ file on the host and you will see it occupies only a couple of hundred
+ kilobytes. qcow2 images grow as the guest writes, so a generous virtual size
+ costs almost nothing up front.
+
+2. Launch the VM with the installer ISO attached:
+
+ ```json
+ launch_vm(name="alpine", disks=["~/vms/alpine-agent.qcow2"],
+ iso="~/isos/alpine-virt.iso", memory_mb=2048)
+ ```
+
+ The result tells us the VM is running, which acceleration it got (`kvm` or
+ `tcg`), the process ID, and paths to its logs. When a VM has both a disk and
+ an ISO, it boots the ISO this once and the disk on every later boot, so we
+ will not have to detach anything by hand.
+
+3. Give it fifteen seconds, then look at the screen:
+
+ ```json
+ vm_screenshot(name="alpine")
+ ```
+
+ The agent gets a PNG of the guest display back. You should see Alpine's
+ boot messages or a `localhost login:` prompt. Nothing is installed inside
+ the guest to make this work; QEMU is handing over the framebuffer directly,
+ which is why screenshots work in bootloaders and BIOS menus too.
+
+ If the screen still shows a bootloader, wait and take another screenshot.
+ Watching, rather than assuming, is the habit this whole workflow is built
+ on.
+
+
+
+## Install Alpine onto the disk
+
+The installer is an interactive console session, so we type into it the same
+way a person at a keyboard would.
+
+
+
+1. Log in as root (Alpine's live image has no root password):
+
+ ```json
+ vm_type_text(name="alpine", text="root", enter=true)
+ ```
+
+ Take a screenshot to confirm we landed at a shell prompt before continuing.
+
+2. Start the installer:
+
+ ```json
+ vm_type_text(name="alpine", text="setup-alpine", enter=true)
+ ```
+
+3. Work through the questions with `vm_type_text`, taking a screenshot after
+ each answer to see what is being asked next. Most answers can be the
+ default (press Enter with `vm_send_keys(name="alpine", keys=["ret"])`).
+ The three that matter:
+
+ - Set a root password when asked, and remember it. We need it to log in later.
+ - When asked which disk to use, answer `vda`. Virtio disks appear under that
+ name inside the guest.
+ - When asked how to use it, answer `sys`. That installs to the disk rather
+ than running from RAM.
+
+ Alpine asks for a final `y` to erase the disk. Nothing else on your machine
+ is at risk; the only disk the VM can see is the image file we created.
+
+4. When the installer reports it is done, shut the guest down cleanly:
+
+ ```json
+ stop_vm(name="alpine")
+ ```
+
+ `stop_vm` presses the virtual power button and waits for the guest to
+ shut itself down, so filesystems get flushed properly.
+
+
+
+
+
+## Boot from disk
+
+
+
+1. Launch again, this time with no ISO, and forward a host port to the guest's
+ SSH port so we have a way in later:
+
+ ```json
+ launch_vm(name="alpine", disks=["~/vms/alpine-agent.qcow2"],
+ port_forwards=["auto:22"])
+ ```
+
+ `auto:22` means "pick any free host port"; the result reports which one it
+ chose, for example `port_forwards: ["43617:22"]`. You can also ask for a
+ specific one with `"2222:22"`, and mcqemu checks up front that the port is
+ free instead of letting QEMU fail obscurely.
+
+2. Screenshot after a few seconds. This time the login prompt comes from the
+ installed system on the disk, not from the ISO.
+
+3. Log in with `root` and the password you set:
+
+ ```json
+ vm_type_text(name="alpine", text="root", enter=true)
+ vm_type_text(name="alpine", text="", enter=true)
+ ```
+
+
+
+## Install the guest agent
+
+Everything so far worked through the screen and keyboard, with no cooperation
+from the guest operating system. The `guest_*` tools are different: they need
+`qemu-guest-agent` running inside the guest. mcqemu wires up the host side of
+that channel on every launch, so installing the package is the only step left.
+
+
+
+1. Confirm the agent is genuinely missing, so the difference is visible:
+
+ ```json
+ guest_ping(name="alpine")
+ ```
+
+ This fails, and the error says the guest does not have the agent installed
+ or running.
+
+2. Type these three commands into the guest console, one at a time, with
+ `vm_type_text(..., enter=true)`:
+
+ ```text
+ apk add qemu-guest-agent
+ rc-update add qemu-guest-agent default
+ rc-service qemu-guest-agent start
+ ```
+
+ The VM has outbound network access (we did not restrict it), so `apk` can
+ reach Alpine's mirrors. Take a screenshot after the first command to check
+ the download succeeded; if `apk` cannot find the package, run
+ `setup-apkrepos -c -1` to enable the community repository and try again.
+
+3. Ask again:
+
+ ```json
+ guest_ping(name="alpine")
+ ```
+
+ Now it answers `guest_agent: responding`.
+
+4. Run a command inside the guest without touching the keyboard:
+
+ ```json
+ guest_exec(name="alpine", command="uname", args=["-a"])
+ ```
+
+ The result carries the guest's stdout, stderr, and exit code. Note that
+ `command` is an executable and `args` are its arguments; this is exec, not a
+ shell. For pipelines, use `command="/bin/sh"` with
+ `args=["-c", "your | pipeline"]`.
+
+5. Shut the VM down. We want the image quiescent for the next tutorial.
+
+ ```json
+ stop_vm(name="alpine")
+ ```
+
+
+
+## What we built
+
+`~/vms/alpine-agent.qcow2` is now an installed Alpine system with a working
+guest agent. Along the way we saw the two ways to interact with a VM:
+
+- Through the display and keyboard (`vm_screenshot`, `vm_type_text`,
+ `vm_send_keys`), which works on any guest at any stage of boot, including
+ installers and bootloaders, because it operates below the operating system.
+- Through the guest agent (`guest_ping`, `guest_exec`), which is faster and
+ gives you structured output, but only after you have put the agent inside
+ the guest.
+
+We also saw that disks and VMs are separate things: `image_create` makes a
+disk, `launch_vm` runs a VM around it, and the same disk can be booted again
+later with different settings.
+
+## Next
+
+Keep that image. In [Disposable sandboxes](/tutorial/disposable-sandboxes/) we
+use it as a base to spin up throwaway clones in seconds, run untrusted things
+in them, and delete them without the base ever changing.
+
+If you want to install something bigger than Alpine next, read
+[Drive an installer](/how-to/drive-an-installer/) for the screenshot and input
+loop in detail, including mouse control for graphical installers.
diff --git a/docs-site/src/styles/theme.css b/docs-site/src/styles/theme.css
new file mode 100644
index 0000000..33069b1
--- /dev/null
+++ b/docs-site/src/styles/theme.css
@@ -0,0 +1,74 @@
+/* mcqemu — a restrained terminal palette.
+ *
+ * The accent is the amber of a monitor that has been on too long, against
+ * slate greys. Starlight derives most of its surface colours from these
+ * tokens, so overriding the scale is enough for both themes.
+ */
+
+:root {
+ --sl-color-accent-low: #3a2a08;
+ --sl-color-accent: #b5820f;
+ --sl-color-accent-high: #f0c674;
+
+ --sl-color-white: #f4f4f2;
+ --sl-color-gray-1: #e3e4e1;
+ --sl-color-gray-2: #c0c2be;
+ --sl-color-gray-3: #8b8e88;
+ --sl-color-gray-4: #55584f;
+ --sl-color-gray-5: #34362f;
+ --sl-color-gray-6: #24261f;
+ --sl-color-black: #16170f;
+
+ --sl-font: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
+ --sl-font-mono: "JetBrains Mono", "Fira Code", ui-monospace, SFMono-Regular, monospace;
+}
+
+:root[data-theme="light"] {
+ --sl-color-accent-low: #f5e6c0;
+ --sl-color-accent: #8a610a;
+ --sl-color-accent-high: #4a3405;
+
+ --sl-color-white: #1b1c16;
+ --sl-color-gray-1: #24261f;
+ --sl-color-gray-2: #3f423a;
+ --sl-color-gray-3: #6b6e66;
+ --sl-color-gray-4: #9a9d94;
+ --sl-color-gray-5: #d3d5cf;
+ --sl-color-gray-6: #eceee8;
+ --sl-color-gray-7: #f6f7f3;
+ --sl-color-black: #ffffff;
+}
+
+/* Starlight's staggered CardGrid rotates card icon backgrounds through a
+ * built-in palette that includes purple and magenta. Hold the whole grid on
+ * the accent so the page reads as one thing. */
+.card .icon {
+ background-color: var(--sl-color-accent-low) !important;
+ color: var(--sl-color-accent-high) !important;
+}
+
+/* Tool names appear constantly in the prose; give inline code enough
+ * contrast to scan without turning every paragraph into a rash. */
+:not(pre) > code {
+ border: 1px solid var(--sl-color-gray-5);
+ border-radius: 0.25rem;
+ padding: 0.1rem 0.35rem;
+ font-size: 0.9em;
+}
+
+/* Reference tables are dense and mostly two or three narrow columns. */
+.sl-markdown-content table {
+ display: table;
+ width: 100%;
+ font-size: 0.95em;
+}
+
+.sl-markdown-content th {
+ text-align: left;
+ font-weight: 600;
+}
+
+/* The splash hero reads better slightly narrower than the default. */
+.hero > .stack {
+ max-width: 46rem;
+}
diff --git a/docs-site/tsconfig.json b/docs-site/tsconfig.json
new file mode 100644
index 0000000..8bf91d3
--- /dev/null
+++ b/docs-site/tsconfig.json
@@ -0,0 +1,5 @@
+{
+ "extends": "astro/tsconfigs/strict",
+ "include": [".astro/types.d.ts", "**/*"],
+ "exclude": ["dist"]
+}
diff --git a/pyproject.toml b/pyproject.toml
index 887f854..dda074a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -27,7 +27,8 @@ dependencies = [
]
[project.urls]
-Repository = "https://git.supported.systems/MCP/mcqemu"
+Documentation = "https://mcqemu.warehack.ing"
+Repository = "https://git.supported.systems/warehack.ing/mcqemu"
[project.scripts]
mcqemu = "mcqemu.server:main"