machine
rc run creates a fresh VM per job and destroys it at the end. For multi-step work (install, compile, test,
fix), use a machine: a named VM with its own disk that keeps existing between one command and the next and
across stop and start.
At a glanceβ
rc machine create --name dev --stack python # registers it and creates the disk (does not boot)
rc machine start --name dev # boots; the VM keeps running after the command exits
rc machine exec --name dev -- python3 -V # runs a command (streamed output)
rc machine shell --name dev # interactive terminal (Ctrl+C, vi, resize)
rc machine cp ./app.py dev:/workspace/app.py # uploads a file
rc machine exec --name dev -- python3 /workspace/app.py
rc machine cp dev:/workspace/result.json . # downloads a file
rc machine logs --name dev --tail 20 # the machine's console (works while stopped)
rc machine stop --name dev # flushes the disk and powers off
rc machine ls # all of them, with their state
rc machine delete --name dev # deletes machine and disk (-f if running)
--name is optional everywhere: the default is $RC_MACHINE_NAME or, without it, default. With direnv you can pin one
machine per project.
With a runner.code in the directory, create reads the base (stack or image), resources,
environment, init and workload from it; flags you pass override the file.
What persistsβ
| Path | Persists? | Where it lives |
|---|---|---|
/ (packages, /etc, /opt, /root...) | yes | writable layer (overlay) on the machine's disk, on top of the stack image |
/workspace | yes | directory on the machine's disk; the recommended place for code, data and results |
/tmp | no | 256 MiB tmpfs, empty on every start |
HOME is /root, which also persists.
How it worksβ
host (macOS) β guest
β
~/Library/Caches/runner-codes/machines/dev/ β
machine.json registry (stack, cpus, ...) β
disk.ext4 machine disk ββ /dev/vdb βββββββββΌββΆ /persist ββ¬β upper/ (what was written)
spec.json VM spec β ββ work/ (overlayfs)
console.log console (rc machine logs) β ββ workspace/ ββΆ /workspace
v.sock guest channel (vsock 5000) β
vmm.pid VM process β stack image ββ /dev/vda (read-only)
β overlay: image below + upper on top ββΆ /
- Disk: the first time, the build VM formats an empty sparse ext4 (
disks/data-10g.ext4). Each machine gets a clone of it: clonefile on APFS, FICLONE on btrfs/XFS, a sparse copy everywhere else. Measured on macOS: a 10 GiB disk takes 6.4 MB on the host right after creation. - Boot: the machine's first process is
/opt/runner/machine.sh, in place of the jobs'start.sh. It mounts the disk at/persistand an overlayfs with the image underneath andupper/on top, binds/workspace, and runs the guest-runner in achrootthere. - Detached VM: the machine's
rc vmmprocess runs in its own session (setsid), with no watch pipe and noPdeathsig. That is why it outlives thercthat booted it. The pid lives invmm.pid, and "running" means the pid is alive. The ephemeral jobs are unchanged: their VM still dies together with its parent. - Stop: the host asks the guest to
shutdown; the guest runssync(writes cached data to the ext4), replies and exits, and the VM powers off. If it does not finish within--timeout(default 10 s), it getsSIGKILL.--timeout 0kills it immediately. - Terminal (
shell): the guest opens a PTY (devpts mounted bymachine.sh) and runs the shell in it. The connection becomes a two-way frame channel:input(keystrokes, base64-encoded so no control byte is lost) andresizefrom the host;outputandexitfrom the guest. The host terminal stays in raw mode, andSIGWINCHbecomesresize. Closing the terminal kills the program in the guest; the machine stays up. - Updated image: start uses the stack's current image. If the image is rebuilt, the machine starts seeing the new one underneath what it wrote itself, like a system update.
Ephemeral machine: machine runβ
Create, boot and use in one go:
rc machine run --net --image alpine -- uname -a # runs and deletes
rc machine run --net -it --image alpine -- /bin/sh # terminal; deleted on exit
echo dados | rc machine run -i --stack python -- sh -c 'read x; echo $x'
rc machine run -d -s runner.code # stays up with the workload
docker save meuapp | rc machine run --image - -- ./app # local image, no registry
rc machine run --image ./meuapp.tar -- ./app # docker save tar or OCI layout directory
Tabsβ
runner.codes Β· agent β claude β shell β eventos β β 3000
| Tab | What it is |
|---|---|
the command's tab (claude) | the program passed after --; without a command, the machine's shell. Closing the browser tab stops nothing |
shell | a separate terminal on the same machine, opened on first click (up to 8 live sessions per page) |
eventos (events) | the timeline: session started, session exited (with its code), port opened, port closed |
β 3000 | one tab per port open in the machine, always last: the app in an iframe, with reload and new tab β (some sites refuse to be framed). It goes away when the server stops |
| Point | How it works |
|---|---|
| what runs | the command after --; otherwise the runner.code cmd/entrypoint; otherwise, with -t, a shell |
without -d | name run-<hex>; the machine is deleted at the end, also on Ctrl+C (rc 130) and on SIGTERM |
-d | name default (or --name); the command becomes the workload and the machine stays up |
-t / -it | a real terminal (PTY in the guest); -i without -t only forwards stdin; with neither, stdin is not read |
| local image | a path starting with /, ./ or ../: a docker save tar (or OCI tar) or an OCI layout directory; an architecture different from the host's is rejected |
--image - | reads the tar from stdin and stores it in <cache>/imports/sha256-<hash>.tar: the same image reuses the cache |
| init | runs (the disk is always new) |
The declaration flags are the same as create's, including -s/-f, -v, -p, --net, the network policy and
--display WxH (see Display in the browser).
Branch and pauseβ
rc machine pause --name dev # freezes: processes, memory and connections stay as they are
rc machine resume --name dev
rc machine branch --from dev --name try-1 # copy-on-write copy of the disk (running or stopped)
| Point | How it works |
|---|---|
| pause | SIGSTOP on the VM process: vCPUs, network gateway and viewer stop together; exec, cp, update and start refuse with INVALID_STATE; stop resumes before powering off |
| branch | the child's disk is a copy-on-write clone of the parent's (clonefile on APFS, FICLONE on Linux): instant and taking no space until it diverges. Installed software, /root and /workspace come along, and init does not run again |
| parent running or paused | the guest runs sync and the VM freezes only during the clone; the disk comes out as consistent as after a power loss |
| what does not come | the execution: processes and memory. A RAM and CPU checkpoint needs snapshots, which upstream libkrun does not have |
| ports and display | the child inherits the same ports and display port; the two cannot run at the same time unless one of them is changed |
Commandsβ
| Command | Flags | Notes |
|---|---|---|
create | --name, -f FILE, --stack or --image, --cpus (4), --memory MiB (8192, elastic), --disk-gib (10), --gpu, --gpu-vram, -e K=V, -w DIR, -u USER, --init CMD, --stop-signal, --stop-timeout, --stop-on-exit, -v, -p, --docker-socket, --expose-socket, --mount-socket, --secret-env, --secret-file, --ssh-agent, --nested, -- WORKLOAD | does not boot the machine. Without -f, reads ./runner.code if it exists. See runner.code |
start | --name | waits for the guest to respond (up to 30 s); if it does not, kills it and shows the tail of the console. If the VM process dies first (a rejected configuration, like --nested on a host without nesting), the error comes right away with the last line of its log. Then runs init (first time on the disk) and starts the workload |
stop | --name, --timeout (default: the machine's stop_timeout, or 10s) | sends stop_signal to the workload before the sync; stopping an already stopped machine is not an error |
status | --name, --json | running or stopped, with the pid |
ls | --json | all machines |
delete | --name, -f | a running one only with -f |
exec | --name, -e K=V (repeatable), -w DIR, -i (forwards stdin), --timeout (0 = no limit) | argv with no implicit shell: for a shell, -- sh -c '...'. The rc exit code is the command's (124 on timeout) |
shell | --name, -e K=V, -w DIR, optional -- CMD | a real terminal (PTY in the guest): Ctrl+C interrupts only the command, the window size follows the host. Without a command, opens bash if the image has it, otherwise sh. The rc exit code is the one from exit |
web-terminal | --name, -e K=V, -w DIR, -u USER, --port (0 = free), --no-open, optional -- CMD | the same terminal in the browser: serves an xterm.js page on 127.0.0.1 with a token in the URL and opens the browser. The rc exit code is the program's |
cp | SRC DST, with name:/path on one side | 1 MiB chunks; atomic on both sides; up to 4 GiB |
logs | --name, --tail N, --source all|workload | the VM console: what the guest, machine.sh and the workload write; workload filters to it alone |
sync | --name | returns the staged volume copies to the host (see Network and volumes) |
data-dir | --name | the machine's directory on the host (registry, disk, published sockets, logs) |
screen | --name; screenshot, click X Y, move, drag, type TEXT, key SHORTCUT, scroll, do -f actions.json | operates the screen of a machine with a display, like a person would: see Screen control |
prune | --all | the same as rc image prune: frees old image builds; with --all, also the referenced images no machine uses |
Web terminalβ
rc machine web-terminal --name dev # the machine's shell in the browser
rc machine web-terminal --name agent -- claude --dangerously-skip-permissions
rc prints web terminal: http://127.0.0.1:PORT/?t=<token> and opens the browser. The page is a real terminal
(xterm.js, embedded in the binary, works offline): Ctrl+C, arrow keys, full-screen editors, paste and the window size
reach the guest's PTY. Links on screen are clickable.
| Point | How it works |
|---|---|
| session | starts with the command, not with the page: the program is already running while the browser opens |
| reload, second tab | get the last 256 KiB of the screen again and continue live; every tab types into the same session |
| end | when the program exits, the page shows the code and rc exits with it; Ctrl+C in the launching terminal ends the session. The machine stays running |
| network | 127.0.0.1 only; --port picks the port (0 takes a free one) |
| token | 128 random bits in the URL, required on every route that reads or writes the session; without it, 403 |
| other defenses | a non-loopback Host (DNS rebinding) and a POST with another site's Origin get 403; keyboard bodies are capped at 64 KiB |
| stopped machine | refused before any server or browser opens |
| ports | any TCP port a program opens in the machine (on 0.0.0.0, ::, 127.0.0.1 or ::1) becomes a tab within 2 s and answers on the host's 127.0.0.1, on the same number; if that number is taken on the host, on a free one (the tab shows 5432 β 51234). It is raw TCP over vsock: WebSocket, hot reload and absolute paths (FastAPI's /docs) work, and the machine needs no --net. A server bound only to the eth0 IP is left out. While the tab exists, the number is taken on the host: another machine's -p 3000 fails with address already in use |
The Claude Code use case opens the agent this way. With IS_SANDBOX=1 in the machine's
environment, --dangerously-skip-permissions runs as root; without it, Claude refuses (cannot be used with root/sudo privileges).
Errorsβ
Every rejection carries a stable code. Decide by the code, not by the message:
| Code | When |
|---|---|
NOT_FOUND | the machine (or the file, in cp) does not exist |
CONFLICT | create with a name already in use |
INVALID_STATE | start on a running one; start on a paused one (is paused; resume it ... or stop it first); exec/cp on a stopped one; delete on a running one without -f; sync on a stopped one with a staged volume |
CONFIG_ERROR | invalid name (lowercase letters, digits and -, up to 63), stack without an image, --gpu on a stack without GPU, relative path in cp |
Measured numbersβ
macOS 27, Apple M1 Pro, python stack:
| Measurement | Value |
|---|---|
start until the guest responds | 204β244 ms |
| freshly created 10 GiB disk, space on the host | 6.4 MB |
cp of 50 MB to the machine | 620 ms (~84 MB/s) |
cp of 50 MB from the machine | 594 ms (~88 MB/s) |
User, secrets, SSH agent and socketsβ
| Flag | What it does |
|---|---|
-u, --user NAME|uid[:gid] | workload, exec and shell run as that user; init runs as root. exec --user and shell --user switch just that command |
--secret-env VAR=HOST_VAR | the value of the host variable, read at start, in the environment of every command; it lives only in the guest's memory, never in the registry |
--secret-file VAR=/abs/file | the same, with the content of a host file. A secret with a credential's name feeds the binding and does not enter the VM |
--ssh-agent | the agent of whoever runs start (SSH_AUTH_SOCK) appears in the guest; the private key stays on the host |
--docker-socket | the guest's /var/run/docker.sock becomes docker.sock in the machine's data-dir |
--expose-socket GUEST[:HOST] | a Unix socket the workload listens on, reachable from the host (default: its name in the data-dir) |
--mount-socket HOST:GUEST | a host socket inside the machine; the guest directory is created if missing. Up to 64 bridges per machine |
--nested | exposes the host's virtualization extensions (/dev/kvm in the guest). Needs nested KVM on Linux or an M3+ Mac with macOS 15+; rc doctor tells whether this host supports nesting. runner.code does not turn it on |
Socket bridges exist only in the CLI: through the API they would be arbitrary paths on the server's disk.
APIβ
The same operations in rc serve. See API overview.
Not in this versionβ
The API's interactive WebSocket (the terminal is CLI-only today), machine stats and the RAM checkpoint (upstream
libkrun has no snapshots).
Network, ports and volumes are in Network and volumes. The plan is in the
Agent reference.