| name | vm4a-cli |
| description | | Use when this capability is needed. |
VM4A CLI
VM4A ships a single binary vm4a that creates and runs Apple
Virtualization.framework VMs on Apple Silicon from the command line. Each
VM is a directory ("bundle") containing config.json, state.json, a disk
image, and platform identity files. One binary handles the lifecycle, OCI
distribution, and agent channel.
Prerequisites (check first, don't assume)
Before running anything:
-
Apple Silicon + macOS 13+. x86 Macs are not supported.
-
CLI must be codesigned with the CLI entitlements, otherwise run
fails silently:
swift build
codesign --force --sign - \
--entitlements Sources/VM4ACLI/VM4ACLI.entitlements \
./.build/debug/vm4a
Bridged networking and Rosetta share need the CLI entitlements file
specifically — not the App one.
macOS 26 (Tahoe) gotcha: com.apple.vm.networking is a restricted
entitlement. Ad-hoc signing (--sign -) a binary that carries it makes
AMFI SIGKILL the process at launch — every vm4a command dies with no
output (exit 137). If that happens, re-sign with an entitlements file that
drops com.apple.vm.networking (NAT-only; keeps com.apple.security.virtualization):
cp Sources/VM4ACLI/VM4ACLI.entitlements /tmp/nat.entitlements
/usr/libexec/PlistBuddy -c "Delete :com.apple.vm.networking" /tmp/nat.entitlements
codesign --force --sign - --entitlements /tmp/nat.entitlements ./.build/debug/vm4a
./.build/debug/vm4a --version
Ad-hoc NAT covers spawn/exec/cp/run-code/expose-port/OCI — but NOT bridged
networking and NOT snapshots.
Snapshots on macOS 26 (verified): saveMachineStateTo is gated on
hardened runtime + a real signing identity. Ad-hoc signing fails with
VZErrorSave "permission denied" (so snapshot save, run --save-on-stop,
and reset all fail). validateSaveRestoreSupport() is YES — it's purely a
signing issue. Re-sign with --options runtime + an Apple Development /
Developer ID identity:
codesign --force --options runtime \
--sign "Apple Development: YOUR NAME (TEAMID)" \
--entitlements /tmp/nat.entitlements ./.build/debug/vm4a
Bridged mode additionally needs an identity provisioned for the managed
com.apple.vm.networking entitlement (ad-hoc cannot grant it).
-
For bridged mode: vm4a network list should print at least one
interface. If empty, re-check codesigning.
Command map (when to reach for each)
Agent-first primitives (prefer these for any agent-driven flow):
| Intent | Command |
|---|
| One-shot create+start, wait for SSH | vm4a spawn NAME --from ghcr.io/you/img:tag --wait-ssh --output json |
| Run a command in the guest, get JSON | vm4a exec /path/to/bundle --output json -- python3 -c 'print(1+1)' |
| Copy a file host→guest | vm4a cp /path/to/bundle ./local.txt :/work/remote.txt |
| Copy a file guest→host | vm4a cp /path/to/bundle :/var/log/syslog ./syslog.txt |
| Fork a bundle, auto-start, wait for SSH | vm4a fork SRC DST --auto-start --from-snapshot clean.vzstate --wait-ssh |
| Reset to a saved snapshot for retry | vm4a reset /path/to/bundle --from clean.vzstate --wait-ip |
| Save a running VM as a named snapshot (stops it) | vm4a snapshot save /path/to/bundle clean |
| Restore a VM to a named snapshot (~1s) | vm4a restore /path/to/bundle clean (alias for vm4a snapshot restore) |
| List / delete named snapshots | vm4a snapshot list /path/to/bundle [--output json], vm4a snapshot rm /path/to/bundle clean |
Classic lifecycle:
| Intent | Command |
|---|
| Create a Linux VM bundle | vm4a create NAME [--image ISO.iso] [--network bridged --bridged-interface en0] [--rosetta] |
| Install a macOS VM from IPSW (10–20 min, drives VZMacOSInstaller) | vm4a create NAME --os macOS --image macos-15.ipsw --cpu 4 --memory-gb 8 --disk-gb 80 |
| List bundles in a directory | vm4a list --storage /tmp/vm4a [--output json] |
| Start a VM in the background | vm4a run /path/to/bundle |
| Start in foreground (logs to stdout) | vm4a run /path/to/bundle --foreground |
| Boot macOS recovery | vm4a run /path/to/bundle --recovery |
| Restore from a VZ snapshot (macOS 14+) | vm4a run /path/to/bundle --restore state.vzstate |
| Save state on clean shutdown (macOS 14+) | vm4a run /path/to/bundle --save-on-stop state.vzstate |
| Stop a VM (SIGTERM, then SIGKILL if stuck) | vm4a stop /path/to/bundle --timeout 20 |
| Clone a bundle (APFS clonefile when possible) | vm4a clone SRC DST |
| Show Linux ARM64 ISO catalog | vm4a image list |
| Show host bridged interfaces | vm4a network list |
| Look up VM's NAT IP | vm4a ip /path/to/bundle [--output json] |
| SSH into running VM (NAT) | vm4a ssh /path/to/bundle --user root |
| Push bundle to OCI registry | vm4a push /path/to/bundle ghcr.io/you/name:tag |
| Pull bundle from OCI registry | vm4a pull ghcr.io/you/name:tag --storage /tmp/vm4a |
| Check guest agent heartbeat | vm4a agent status /path/to/bundle |
| Ping guest agent | vm4a agent ping /path/to/bundle |
| Run as MCP server (stdio JSON-RPC) | vm4a mcp |
| Run HTTP API server on localhost |
Key workflows
Agent loop (recommended — uses v2 primitives)
vm4a spawn dev --from ghcr.io/yourorg/python-dev-arm64:latest \
--storage /tmp/vm4a \
--save-on-stop /tmp/vm4a/dev/clean.vzstate \
--wait-ssh --output json
vm4a exec /tmp/vm4a/dev -- bash -lc "apt-get install -y ripgrep"
vm4a stop /tmp/vm4a/dev
vm4a fork /tmp/vm4a/dev /tmp/vm4a/task-$JOB_ID \
--auto-start --from-snapshot /tmp/vm4a/dev/clean.vzstate --wait-ssh
vm4a cp /tmp/vm4a/task-$JOB_ID ./step.py :/work/step.py
vm4a exec /tmp/vm4a/task-$JOB_ID --output json --timeout 120 \
-- python3 /work/step.py
vm4a reset /tmp/vm4a/task-$JOB_ID --from /tmp/vm4a/dev/clean.vzstate --wait-ip
Spin up a disposable Ubuntu VM (manual / classic flow)
ISO=~/Downloads/ubuntu-24.04-live-server-arm64.iso
vm4a create demo --storage /tmp/vm4a --image "$ISO" \
--cpu 4 --memory-gb 8 --disk-gb 64
vm4a run /tmp/vm4a/demo
vm4a ip /tmp/vm4a/demo
vm4a ssh /tmp/vm4a/demo
vm4a stop /tmp/vm4a/demo
Distribute a pre-baked VM through GHCR
export VM4A_REGISTRY_USER=youruser
export VM4A_REGISTRY_PASSWORD=ghp_xxx
vm4a push /tmp/vm4a/base-ubuntu ghcr.io/youruser/base-ubuntu:24.04
vm4a pull ghcr.io/youruser/base-ubuntu:24.04 --storage /tmp/vm4a
vm4a run /tmp/vm4a/base-ubuntu
Fast fork of a golden image for CI
vm4a clone /tmp/vm4a/golden /tmp/vm4a/job-$CI_JOB_ID
vm4a run /tmp/vm4a/job-$CI_JOB_ID
trap "vm4a stop /tmp/vm4a/job-$CI_JOB_ID && rm -rf /tmp/vm4a/job-$CI_JOB_ID" EXIT
clone uses APFS clonefile(2) on the same volume so it's O(directory
entries), not O(disk image size).
Non-obvious behaviors
- NAT IP only works for VMs using the default NAT attachment. Bridged
VMs don't land in
/var/db/dhcpd_leases; users must pass --host <ip> to
vm4a ssh or use their router's DHCP view.
--rosetta is Linux only and requires
softwareupdate --install-rosetta --agree-to-license before first run.
The CLI warns but doesn't block.
- macOS guests work end-to-end via the CLI, with one caveat: after
vm4a create --os macOS --image foo.ipsw finishes (10–20 min), the
VM lands at Apple's Setup Assistant on first boot. Apple does not
expose a scriptable skip path, so the user needs to click through it
once in VM4A.app: pick region/keyboard, skip Apple ID, create a
user account, then System Settings → General → Sharing → Remote
Login: ON. After that single click-through, every CLI / MCP / HTTP
/ SDK operation works on the macOS bundle exactly like on Linux,
and pulling a published macOS bundle from GHCR skips Setup Assistant
entirely (the user account + Remote Login are baked in).
- Config JSON format starts at
schemaVersion: 1. Old bundles without
the field still load — tolerant decoding treats missing as 1. When
adding new fields, make them optional in Core.swift decoding.
vm4a stop requires a running pid. If vm4a list shows stopped
but stale files exist, just re-run vm4a run; the CLI cleans stale
PID files on the next list.
--output json is available on create, list, ip, agent status,
and on every v2 primitive (spawn, exec, cp, fork, reset).
Output is one JSON object/array per command invocation (not JSONL).
vm4a exec returns JSON with exit_code, stdout, stderr,
duration_ms, timed_out. The exit code is also the process exit code
(so if vm4a exec ... ; then ... works in shell). With --timeout and a
timed-out command, the agent gets timed_out: true and a non-zero exit.
- , not like docker cp. So
is host→guest. Both sides being host or
both being guest is rejected.
Error exit codes (for scripting)
1 VM4AError.message Generic failure (legacy path)
2 VM4AError.notFound Bundle or file missing
3 VM4AError.alreadyExists Destination already exists
4 VM4AError.invalidState VM running when it should be stopped (or vice versa)
5 VM4AError.hostUnsupported macOS version / hardware capability missing
5 VM4AError.rosettaNotInstalled
When the user is stuck
- Every
vm4a command exits with code 137 / no output (macOS 26): AMFI
killed the ad-hoc-signed binary because of the restricted
com.apple.vm.networking entitlement. Re-sign NAT-only (see Prerequisites #2).
run silently exits (but the binary itself runs): check .vm4a-run.log in
the bundle root.
- "No bridged interfaces available": CLI is not signed with
com.apple.vm.networking. Re-run the codesign command from the top.
- "Rosetta is not supported on this host": CPU doesn't expose VMX for
Rosetta translation. Not fixable in software.
push returns HTTP 401: set VM4A_REGISTRY_USER / VM4A_REGISTRY_PASSWORD
(a PAT for GHCR, a Docker token for Docker Hub).
ssh hangs: VM hasn't DHCP'd yet (vm4a ip returns empty). Wait 10-30s
after vm4a run for first-boot initialization.
snapshot save / --save-on-stop / reset fail with VZErrorSave "permission denied" (macOS 26): the binary is ad-hoc signed. VZ state save
needs hardened runtime + a real identity — re-sign with --options runtime
and an Apple Development / Developer ID identity (see Prerequisites #2).
When NOT to use the CLI
Redirect to the GUI app for:
- Walking the user through Setup Assistant interactively (open VM4A.app, pick the bundle, click Run, do the GUI steps; come back to CLI afterward).
- Changing graphics resolution / audio config (GUI has device editors)
- First-time users who want a wizard
What's new vs older guides
Most recent:
Named snapshots:
snapshot save NAME / restore NAME / snapshot list / snapshot rm, plus
top-level vm4a restore — named .vzstate snapshots stored inside the bundle,
no manual path juggling. Requires hardened-runtime + real-identity signing on
macOS 26 (see Prerequisites #2).
v2.0 P1 — MCP server:
mcp (stdio JSON-RPC 2.0 server; register in .mcp.json to expose
every primitive as an MCP tool to Claude Code / Cursor / Cline)
v2.0 P0 — agent-first primitives:
spawn (one-shot create+start with optional --from <oci-ref>, --wait-ssh)
exec (SSH-driven command runner with structured JSON return)
cp (SCP host ↔ guest with : prefix convention)
fork (clone + re-identify + optional auto-start with snapshot restore)
reset (stop + restart from snapshot for retry loops)
For any agent / scripted flow, prefer these over the manual
create → run → ssh chain. They handle PID management,
snapshot wiring, and JSON output by default.
Earlier additions (still relevant):
push / pull (OCI registry support, tart-style)
network list, image list (host introspection)
ip, ssh (NAT convenience)
agent status, agent ping (guest-agent channel, scaffold)
create --rosetta, create --bridged-interface
run --restore / run --save-on-stop (macOS 14+ snapshots)
Source: everettjf/vm4a — distributed by TomeVault.