local-api
GitHub通过本地 HTTP API 编程式驱动 smolvm,涵盖机器创建、命令执行、文件传输及生命周期管理。用于构建客户端或测试工具,强调验证 HTTP 200 不等于成功及文件上传时机等陷阱。
Trigger Scenarios
Install
npx skills add smol-machines/smolvm --skill local-api -g -y
SKILL.md
Frontmatter
{
"name": "local-api",
"description": "Drives smolvm programmatically over its local HTTP API (smolvm serve) instead of the CLI: create machines, exec and stream commands, move files in and out, and tear them down. Use when building a client, harness, agent tool or MCP backend over smolvm; when a create call is accepted but the machine behaves as if a field was ignored; when a file uploaded over the API has disappeared; when an exec that failed still returned HTTP 200; or when choosing between the Unix socket and loopback TCP. Do not use it as a substitute for the CLI in a shell script, and do not bind a plain listener beyond loopback: a server started without mutual TLS has no authentication of any kind."
}
Driving smolvm over HTTP
Verified on smolvm v1.29.0 on macOS arm64, 2026-10-15, and on v1.18.2 on Linux aarch64, 2026-09-24. Done means a machine went through
its whole lifecycle over HTTP and GET /api/v1/machines/<name> answers NOT_FOUND at the end.
The Linux runs used the scripts of their date; this version's preflight and cleanup scripts ran
on Linux aarch64 on v1.22.2 on 2026-10-03.
Two rules run through everything here, and both are the same shape: a 200 is not a result.
- A failing guest command still returns HTTP 200. Assert
exitCodefrom the body. - Upload files only after the workload container is running. An upload before that returns 200 with the resolved path and byte count for a file that is then unreadable.
Procedure
1. Preflight.
scripts/preflight.sh
It reports auth=none_unless_mtls, which is not a warning about your setup: a server started the
way this packet starts it has no TLS, token or auth of any kind, so the transport you pick is the
access control. Mutual TLS exists, configured from the environment rather than by a flag;
references/traps.md has what it does and the plain listener it opens beside itself.
2. Start the server. A Unix socket is the default here, as it is smolvm's.
scripts/serve-start.sh
scripts/serve-start.sh --listen 127.0.0.1:8899
It waits for "status":"ok" from /health rather than for the process to exist, records the
listen address and pid for cleanup, and reports any Reclaimed N dangling VM data dir(es) line so
you do not later read those directories as a leak. Run again while the recorded server is up, it
prints result=already_serving with the recorded address and starts nothing, whatever --listen
says.
3. Export the spec before writing a request body. This is the step that saves the most time.
smolvm serve openapi -o ./openapi.json
The field names are the schema's, not the CLI's flags. network not net, memoryMb not
memory, and cmd for the workload you would pass after --. From v1.22.2 an unknown field is
refused with 422 and a message that names it; before that it was accepted with 200 and ignored.
references/api-fields.md has the full list and what each wrong name cost on older releases.
4. Run the lifecycle.
scripts/lifecycle-check.sh
Create, start, wait for the workload container to answer with a value, exec, exec a deliberately
failing command and assert its non-zero exitCode came back on a 200, stream, round-trip a file,
stop, delete, and assert the machine is gone:
created_state=ok (created)
started_state=ok (running)
workload_ready_after_s=0
workload_ready=ok (yes)
exec_exit_code=ok (0)
exec_stdout=ok
failing_exec_exit_code=ok (3)
stream_lines=ok (3)
stream_exit_event=ok
file_roundtrip=ok (PAYLOAD123)
machine_gone=ok (NOT_FOUND)
result=lifecycle_ok
5. Clean up, machines first.
scripts/cleanup.sh --purge
cleanup.sh waits up to 20 seconds, polling the machine list, and prints waiting=up to 20s
first: an ephemeral machine's entry retires after its run returns.
Order matters: stopping the server does not stop machines, which keep running after it exits. The script deletes recorded machines, then stops the server, then removes the socket.
The calls, in short
B=http://127.0.0.1:8899 # or: curl --unix-socket "$SOCK" http://localhost/...
curl $B/health
curl -X POST $B/api/v1/machines -H 'content-type: application/json' \
-d '{"name":"m","image":"python:3.12-alpine","network":true,"memoryMb":2048,
"cmd":["sh","-c","while true; do sleep 3600; done"]}'
curl -X POST $B/api/v1/machines/m/start -H 'content-type: application/json' -d '{}'
curl -X POST $B/api/v1/machines/m/exec -H 'content-type: application/json' \
-d '{"command":["sh","-c","echo hi"]}'
curl -N -X POST $B/api/v1/machines/m/exec/stream -H 'content-type: application/json' \
-d '{"command":["sh","-c","for i in 1 2 3; do echo line$i; sleep 1; done"]}'
curl -X PUT "$B/api/v1/machines/m/files/%2Ftmp%2Fabs.txt" --data-binary 'PAYLOAD'
curl "$B/api/v1/machines/m/files/%2Ftmp%2Fabs.txt"
curl -X POST $B/api/v1/machines/m/stop -H 'content-type: application/json' -d '{}'
curl -X DELETE $B/api/v1/machines/m
Paths in the files route are absolute and URL-encoded. exec returns stdout as text and as
base64; exec/stream emits one event: stdout per line then a terminal event: exit.
Traps
Full detail in references/traps.md and references/api-fields.md.
- Upload after the container is up, never before. On Linux aarch64 on v1.14.6 and v1.18.2 the
PUT returned
200 {"path":"/tmp/r1.txt","size":6}for a file that was never readable;/tmpis a path the container mounts over. It did not reproduce on v1.16.1 or v1.22.2, nor ever on macOS. Order the upload after a successfulexec; a run that happens to work proves nothing. - Unknown fields are refused from v1.22.2, and were silently dropped before it. Before v1.22.2
a
memoryformemoryMbgot no diagnostic at all and the machine took the default. - A second
serve starton the same host fails withbind guest rollout ingress: 127.0.0.1:10081: Address already in use, whatever--listensays: every server binds that port for the branch-pool rollout routes. Give the second one--guest-rollout-port(v1.29.0+) orSMOLVM_GUEST_ROLLOUT_HOST_PORTwith another port. - The spec's
info.versionis not the binary's. It says0.5.2on v1.14.2 while/healthsays1.14.2, and still0.5.2on v1.22.2. Take the version from/health. - The default listen path differs per platform, and
--helpshows only one of them. The help prints[default: unix:///tmp/smolvm.sock], and its own example line saysunix:///$XDG_RUNTIME_DIR/smolvm.sock. Observed on v1.16.1: the socket appeared at/tmp/smolvm.sockon macOS arm64 and at/run/user/501/smolvm.sockon Lima aarch64. Read the path the server reports rather than assuming either.
Pausing a machine over the API
v1.18.0 added POST /api/v1/machines/{name}/pause and /resume. Pause saves RAM, disks and the
running execution and stops the machine; resume brings back that execution under the same name
rather than booting a fresh guest. On macOS before v1.20.0 the machine has to be started
branchable, which over the API is a query parameter on start, and the calls below always do it:
curl -X POST "$B/api/v1/machines/m/start?branchable=true" -H 'content-type: application/json' -d '{}'
curl -X POST $B/api/v1/machines/m/pause -H 'content-type: application/json' -d '{}' # state: paused
curl -X POST $B/api/v1/machines/m/resume -H 'content-type: application/json' -d '{}' # state: running
Assert on a value the workload holds in memory, not on the state field. Measured on v1.18.2 on
both hosts, and on macOS again on v1.22.2, with a workload that writes an incrementing counter every second: it read 9 before the
pause, the machine stayed paused for 10 s, and 3 s after the resume it read 13 on macOS and 12 on
Linux, so the process continued from where it stopped, did not restart at 1, and did not run while
paused. Over the CLI a paused machine refuses stop and start; the teardown packet has those
messages, and branch-and-checkpoint covers pause alongside checkpoints.
Security defaults, and why they are the defaults
- The Unix socket is the default because it is the only access control a plain server has. A server started without the mutual TLS variables has no TLS, certificate, token or auth of any kind, and the routes it exposes create machines, exec arbitrary commands and read and write files. The socket's file permissions are a real boundary; a loopback port is a boundary only in the sense that every process on the host is inside it.
- Loopback TCP is for when you need a URL, in a container network namespace or for a client
that cannot do Unix sockets. Treat the port as equivalent to a shell on the host, and do not
bind anything but
127.0.0.1. scripts/serve-start.shputs its socket under the packet's own state directory, not in a world-traversable temporary directory, and removes it at cleanup.- Machines are deleted before the server is stopped, because stopping the server does not stop
them:
serve start --helpsays machines persist independently of the server, and on shutdown it printsShutting down server (VMs continue running)....
Platform arms
- macOS arm64: v1.29.0 over both, v1.27.3 over the Unix socket, and v1.22.2 over both.
- Linux aarch64: v1.18.2 over the Unix socket, 2026-09-24,
result=lifecycle_okwith all eleven checks. The single v1.22.2 run failed only atmachines_empty, the check that then required an empty machine list, onimage-seed-*helpers an interrupted run had left listed. On v1.23.0 the current script passed over the Unix socket, run once. - Linux x86_64: verified on v1.14.2 on an NVIDIA A10 cloud host, over both transports, not re-run since.
- Windows x86_64:
references/windows.md, re-run on 2026-10-03 against v1.22.2 on Windows 11 Home build 10.0.26200 UBR 9457, where the whole lifecycle passed over loopback TCP. Aunix://listen is refused there. Error bodies arrive as on Unix, and PowerShell 5.1'sInvoke-WebRequestdrops them, so read them withcurl.exe. Routes live under/api/v1/; a bodiless POST tostartis accepted from v1.21.0.
Eval prompts, and what they produced
1. "Write me something that drives a smolvm machine over HTTP end to end and proves it worked."
scripts/lifecycle-check.sh. All eleven checks passed on macOS arm64 on v1.22.2, over loopback TCP
and the Unix socket, and on v1.23.0 over loopback TCP on macOS and the Unix socket on Linux, with
the output shown in step 4, including failing_exec_exit_code=ok (3), the assertion that catches a
guest failure hiding behind a 200. The final machine_gone check ran over loopback TCP on
2026-10-03 and over the Unix socket on 2026-10-04; the earlier runs ended with machines_empty.
2. "I uploaded a file right after starting the machine and now the API says it does not exist."
Reproduced on Linux aarch64 on v1.14.6 and on v1.18.2 by doing exactly that, with the output in
references/traps.md; on v1.22.2 it did not reproduce, 0 of 3 on each host.
3. "My create call returned 200 but the machine has the wrong settings."
On v1.22.2 a misnamed field is refused before anything is created; before v1.22.2 it was accepted
and dropped, which references/api-fields.md records:
POST /api/v1/machines {"name":"...","image":"alpine","network":true,"memory":1024}
-> 422 Failed to deserialize the JSON body into the target type: memory: unknown field `memory`,
expected one of `name`, `cpus`, `memoryMb`, `mounts`, `ports`, `network`, ... at line 1 column 63
A 404 returns {"error":"machine 'nope-does-not-exist' not found","code":"NOT_FOUND"}.
Re-verified on v1.29.0
Run 2026-10-15 PT against v1.29.0 from the published release, checksum checked, under a fresh
isolated HOME on macOS 27.0.1 arm64, the scripts twice, the second time from a fresh HOME, and
the checks beyond them once. Linux was not run, so the Linux stamp stays on its earlier release.
Over the Unix socket and over 127.0.0.1:8899 the lifecycle gave result=lifecycle_ok, and
/health reported "version":"1.29.0". An unknown memory field got 422 naming it, and an
exec of exit 42 came back 200 with exitCode 42. A second server failed on port 10081 and
started with --guest-rollout-port 10091.
Re-verified on v1.27.3
Run 2026-10-12 PT against v1.27.3 from the published release, checksum checked, under a fresh
isolated HOME on macOS 27.0.1 arm64, once: preflight.sh result=ready, serve-start.sh
result=serving on the Unix socket, lifecycle-check.sh result=lifecycle_ok with all eleven
checks, and cleanup.sh --purge machines=clean. The local image refusals in references/traps.md
were measured the same evening. Linux was not run.
Re-verified on v1.23.0
Run 2026-10-04 PT against v1.23.0 from the published release, checksum checked, under a fresh
isolated HOME on macOS 27.0.1 arm64, once. On Lima linux-kvm (Ubuntu 24.04 aarch64) the
checks named below ran once, so the Linux stamp stays on its earlier release.
macOS: result=lifecycle_ok with all eleven checks over loopback TCP, and serve-start.sh
result=serving on the Unix socket. A memory and a net field were each refused with 422, the
expected list now including nestedVirt and autoGraph; the upload race did not reproduce, 0 of
3; pause and resume over the API brought the counter from 1 before a 10 s pause to 5 three seconds
after the resume. The spec still says info.version 0.5.2. A create with "network":false and a
registry image returned 200, which references/api-fields.md records.
Linux aarch64: serve-start.sh on the Unix socket with the server's defaults, which from v1.23.0
include --seccomp enforce on arm64 (#1533),
then lifecycle-check.sh result=lifecycle_ok through machine_gone=ok (NOT_FOUND).
Re-verified on v1.22.2
Run 2026-10-03 PT against v1.22.2 from the published release, checksum checked, under an isolated
HOME on macOS 27.0.1 arm64, twice, the second time from a fresh HOME. On Lima linux-kvm
(Ubuntu 24.04 aarch64) guests above 2048 MiB timed out on 2026-10-03, so the Linux lines below are
a single run and the Linux stamp stays on its earlier release.
macOS: result=lifecycle_ok over loopback TCP and the Unix socket, a memory and a net field
each refused with 422, the upload race 0 of 3, and pause and resume over the API with the counter
at 11 before a 10 s pause and 15 three seconds after the resume. The spec says
info.version 0.5.2. Mutual TLS and the second-server port are in references/traps.md.
Linux aarch64: both 422 refusals word for word and the upload race 0 of 3. The lifecycle, run
with the script of that date, failed only at machines_empty, the check that then required an
empty machine list, on two image-seed-* helpers an interrupted run had left in the list.
What was not run
- Linux x86_64.
- Loopback TCP on Linux.
- A client of a mutual TLS server driving the lifecycle. The handshake was checked on v1.22.2,
a client without a certificate refused and one with a certificate answered on
/health, but no machine was created over it. - The pool and rollout-executor routes in the spec. They belong to the branch-pool feature.
Related packets
installfor the boot this assumes,teardownfor the cleanup script.dev-envfor the same lifecycle through the CLI, and for the workload-container behaviour that thecmdfield addresses here.
Version History
- 16524db Current 2026-10-19 04:20


