Connect an agent
The installer already ran this for you. Run it again after installing a new agent, or pass client names to pick which ones to register:
$ agentpc mcp-install
$ agentpc mcp-install claude claude-desktop codex cursor gemini vscode
Supported: Claude Code, Claude Desktop, Codex, Cursor, Gemini CLI and VS Code.
Restart Claude Desktop after registering. For Codex it also raises the MCP timeouts
so slow builds and boots don't trip them. agentpc mcp-uninstall
removes the registration again.
Claude plugin
In Claude Code, the plugin bundles the MCP server with a skill that teaches Claude when
and how to use the VMs. It runs the installed agentpc binary,
so install that first.
/plugin marketplace add pawanpaudel93/agentpc
/plugin install agentpc@agentpc
With the plugin installed, agentpc mcp-install skips Claude
Code so the server isn't registered twice.
Client configs
For any other MCP client, add the server by hand. The standard shape:
{
"mcpServers": {
"agentpc": { "command": "agentpc", "args": ["mcp"] }
}
}
Codex
A first create_vm can take minutes while the image downloads, so
the server needs longer timeouts than Codex's defaults. agentpc mcp-install codex
adds these for you:
[mcp_servers.agentpc]
command = "agentpc"
args = ["mcp"]
startup_timeout_sec = 60
tool_timeout_sec = 900
VS Code
{
"servers": {
"agentpc": { "type": "stdio", "command": "agentpc", "args": ["mcp"] }
}
}
The agentpc repository ships these as project configs (.mcp.json,
.codex/config.toml, .cursor/mcp.json,
.gemini/settings.json, .vscode/mcp.json),
so an agent opened in a clone picks the server up automatically.
Tools
VMs
| Tool | Use |
|---|---|
list_vms |
VMs (owner, state, size, checkpoints, viewer and, for running x86apps VMs, x86_tso) and the images they come from, with OS versions. Start here. |
create_vm |
New clone, returned once its desktop is ready: Ubuntu or Arch ~1 s, Windows ~4 s (the first
create of an image can take minutes). Takes os
(ubuntu, windows or arch), and optionally
version (Ubuntu "22.04", a pinned "24.04-YYYYMMDD" download, or "x86apps" / "22.04-x86apps" to also run x86_64 and i386 Linux programs; Windows
"11-25h2", "11-24h2",
"11-23h2"; Arch is rolling, so the default, "rolling", "x86apps", or a pinned "rolling-YYYYMMDD" / "rolling-x86apps-YYYYMMDD" download), name (up to 64 letters, digits, . - _),
memory_gb (2–64, default 8 Windows / 4 Ubuntu and Arch),
cpus (1–16, default 4), offline and
open_in_browser (default false). Set open_in_browser: true
to open the ready VM’s viewer in the Mac’s default browser, not a browser inside the guest.
This is a per-call action, not a saved setting; a browser-launch failure warns without failing creation.
A non-default size boots cold (~25 s Windows, ~15 s Ubuntu and Arch). Retrying with the same name and image in the same
session returns the VM it already made, booting it if it was stopped.
from_checkpoint ("<vm>/<label>") makes a new,
independent VM from a checkpoint's disk instead: it boots cold, os/version
may be omitted (if given, they must be the source's image), and the checkpoint's memory, port forwards,
owner, offline and job records are not carried over. Errors before any work:
invalid_checkpoint_ref, no_such_vm,
no_such_checkpoint, image_mismatch.
|
start_vm / stop_vm |
Boot a stopped VM, waiting until it's ready / shut one down cleanly. |
reset_vm |
Discard all changes: back to a fresh copy of the image, booted. |
delete_vm |
Stop a VM and delete it with its disk and checkpoints. |
Checkpoints
| Tool | Use |
|---|---|
checkpoint_vm |
Save a VM's disk and memory under a label (replacing an older one of that name) before a risky step. A running VM's guest first writes its pending data to disk (at most ~10 s; otherwise the reply says "not flushed"), then pauses for a few seconds. New VMs can start from a checkpoint with create_vm's from_checkpoint. |
restore_vm |
Go back to a checkpoint exactly as it was and start it, in seconds. Port forwards (TCP and UDP) must be set up again. |
delete_checkpoint |
Delete one checkpoint by label; the VM is untouched. |
Shell, files and ports
| Tool | Use |
|---|---|
run_command |
PowerShell on Windows, bash on Ubuntu and Arch, over SSH. Returns typed
structuredContent plus the readable exit code, stdout and
stderr, each stream trimmed to its first and last 10,000 characters. A foreground run that
reaches timeout (default 120 s) gets an attempt to stop it, and its partial output
is kept; the reply says whether the stop was confirmed. A foreground run whose request is
cancelled, or whose client disconnects, is stopped the same way.
background: true returns a detached job id that cancellation does not stop.
Results and recovery.
|
get_job_status |
A job's state (queued, running, exited with its code, stopped, ended without an exit code, or
missing) and its log, typed. By default the tail (tail_lines, default 50, at most 16 KiB);
wait_seconds (0 to 30, default 0) waits without ever stopping the job;
cursor reads the log incrementally. Also takes the
job_id of a foreground run whose outcome was uncertain.
|
list_jobs |
Find a job again when its id was lost (a cancelled or disconnected
run_command): the VM's job records, newest id first, as metadata only (id,
kind, state, exit code, stop reason, which records exist, file times), never a command, environment,
output or log. limit 1 to 100 (default 20); next_cursor
continues below the last job shown. Read-only. Results and recovery.
|
wait_for_service |
Wait until a TCP port accepts a connection, or an HTTP path answers its expected status, on the VM's own
127.0.0.1. timeout_seconds 1 to 120 (default 30).
Read-only: it never stops or restarts the service or a job.
Results and recovery.
|
stop_job |
Stop a background job (or a foreground run's job_id) and everything it started: on Linux its systemd scope, which processes that detach stay in, when the guest has a user manager, else the processes marked with the job and their descendants; on Windows its process tree. For a job that already exited, it stops only what the job left running. |
upload_file / download_file |
Copy files or folders between the Mac and a VM. |
forward_port |
Reach a server in the VM from the Mac at 127.0.0.1:<host_port>.
An SSH tunnel, so it reaches servers bound to the guest's own
127.0.0.1. Lasts until the VM stops.
protocol: "udp" forwards UDP instead, to a guest server listening
on 0.0.0.0 (not on offline VMs).
|
list_forwards / delete_forward |
List a VM's forwards, their protocol and whether each is alive / stop one by its host port (and optional protocol). |
Screen and desktop
| Tool | Use |
|---|---|
take_screenshot |
PNG straight from the hypervisor, even while booting or hung. save_to also writes it to a path on the Mac. |
list_desktop_tools |
The desktop-control tools inside a VM, with each one's required arguments and whether it's read-only, or one tool's full schema with its annotations (a mistyped name returns close matches). |
use_desktop_tool |
Call one of them (tool plus arguments): click, type, launch apps, read the UI tree, and more. Wrong arguments or a wrong name return the tool's arguments or close matches. Marked destructive. Gives up after 120 s. |
inspect_desktop |
The read-only twin of use_desktop_tool, with the same arguments. It runs a tool only if
the VM's desktop driver explicitly marks it read-only, and refuses anything else before dispatching it.
Marked read-only, so clients can auto-approve looking while still confirming acting.
|
read_vm_log |
Tail a VM's qemu or serial log (tail_lines, default 100) when it won't boot or the desktop is unreachable. |
Desktop control
All three guests (Windows, Ubuntu, Arch) run cua-driver and are
1280×800. Work in a loop: look (take_screenshot or a
snapshot tool, through inspect_desktop when you only need to look),
act (use_desktop_tool), then look again to
verify. Prefer run_command for anything a shell can do.
| Guest | How to drive it |
|---|---|
| Windows |
launch_app takes a name such as notepad
and returns the pid and window_ids.
get_window_state(pid, window_id) returns numbered elements and a
snapshot_id; click and
type_text take that snapshot_id with an
element_index. Input goes in the background first; typing into the
focused field, scroll, drag and right-click often need
"delivery_mode": "foreground", which the driver asks for when
background input can't reach the target.
|
| Ubuntu |
XFCE on X11. get_desktop_state returns a screenshot plus window
pid/window_id values. Keyboard and mouse
tools need "delivery_mode": "foreground".
launch_app takes a command such as
xfce4-terminal. Google Chrome is installed: launch it with
launch_app (google-chrome, URL in
additional_arguments) and read the page with
get_window_state, or drive it with the
browser_* tools after browser_prepare.
Opening a .deb (a double-click, or xdg-open)
installs it with apt in a terminal window.
|
| Arch |
Arch Linux ARM, with the same XFCE desktop and tools as Ubuntu. The browser is Chromium:
use chromium wherever Ubuntu uses
google-chrome. Install packages with
sudo pacman -Syu --noconfirm <pkg> (Arch doesn't support
partial upgrades). The first -Syu may upgrade everything, so give
run_command a longer timeout or
background: true, and reboot (start_vm) after a
kernel upgrade before loading new modules. /tmp is a tmpfs, cleared at every boot.
|
Open Notepad on Windows and type into it, as three use_desktop_tool calls. The pid and window_id come from launch_app; the snapshot_id and element_index from get_window_state:
{ "name": "windows-1", "tool": "launch_app", "arguments": { "name": "notepad" } }
{ "name": "windows-1", "tool": "get_window_state", "arguments": { "pid": 4120, "window_id": 65812 } }
{ "name": "windows-1", "tool": "type_text", "arguments": {
"pid": 4120, "window_id": 65812, "snapshot_id": "s1a2b3c4d",
"element_index": 7, "text": "Hello from the agent" } }
If a reply starts with a reconnect note (the VM or its driver restarted), earlier snapshot ids
and browser sessions are gone: take a new snapshot and run browser_prepare again.
Guest tips
All guests are ARM64. On Windows, x64/x86 apps run through Prism (~2–4× slower; no x64 drivers).
For x86_64 and i386 Linux programs, create ubuntu or arch
with version: "x86apps": they run through FEX, about 2× slower (Node 6–7×).
Go programs work; amd64 containers run with docker run --platform linux/amd64
(install Docker first: sudo apt install docker.io on Ubuntu,
sudo pacman -Syu --noconfirm docker && sudo systemctl start docker on Arch);
x86 Electron/Chromium apps need --no-sandbox. On Ubuntu, a missing x86 library
installs with sudo apt install libfoo:amd64 and an x86 app's
.deb with sudo apt install ./app_amd64.deb; on Arch (no multiarch) x86
programs use the image's x86 Arch Linux tree, and
sudo fex-pacman -Sy --noconfirm --needed <pkg> installs more x86 packages into it.
x86 systemd services run too, hardened ones included (a unit that runs its x86 program through a
script needs sudo fex-unit <unit>). list_vms shows
x86_tso: hardware (needs macOS 15+) or
emulated.
Typical session
Test an install script on a clean machine, keeping a checkpoint to retry from:
create_vm { "os": "ubuntu", "name": "install-test" }
upload_file { "name": "install-test", "host_path": "./install.sh", "guest_path": "/tmp/" }
checkpoint_vm { "name": "install-test", "label": "before-install" }
run_command { "name": "install-test", "command": "bash /tmp/install.sh" }
take_screenshot { "name": "install-test" }
restore_vm { "name": "install-test", "label": "before-install" } # retry from the same state
delete_vm { "name": "install-test" }
Some prompts to try:
- "Create an Ubuntu VM, open a terminal and run
uname -a." - "Reset ubuntu-1 and check that my install script works on a clean machine."
- "Open Notepad on windows-1, type a short note and show me a screenshot."
Long jobs and servers
A foreground run_command ends when the command returns, and is
given an attempted stop at its timeout. Start builds, servers and GUI apps with
background: true, then poll:
run_command { "name": "ubuntu-1", "command": "npm run dev", "background": true } # → job id 1
get_job_status { "name": "ubuntu-1", "id": 1, "tail_lines": 40 }
stop_job { "name": "ubuntu-1", "id": 1 } # when done: the server and its children
forward_port { "name": "ubuntu-1", "guest_port": 3000 } # → 127.0.0.1:<port> on the Mac
get_job_status without wait_seconds or
cursor answers at once, as it always has. To follow a job without polling
in a loop, wait and read from a cursor:
get_job_status { "name": "ubuntu-1", "id": 1, "cursor": "start", "wait_seconds": 10 } # → output, next_cursor
get_job_status { "name": "ubuntu-1", "id": 1, "cursor": "<next_cursor>", "wait_seconds": 30 } # new output only
Reboots (some installers) drop SSH. Call start_vm on the same VM,
which waits until the desktop is ready again even if it's already running, then rerun the
command. If its reply was connection_lost with
effect_unknown: true, check what it did first with
get_job_status and its job_id.
Results and recovery
run_command, get_job_status, list_jobs and wait_for_service declare an
outputSchema and return matching structuredContent
(schema_version 1) next to the readable text scripts have always parsed
(exit code: N, STATE: …). The text is a single block,
not a JSON copy, so a client that joins text blocks sees no extra output. Read the typed fields and
isError.
run_command
| Field | Meaning |
|---|---|
state |
exited (it ran to its end), timed_out, not_started (it never ran), connection_lost (SSH failed while it may have been running), started (a background job) or cancelled (its request was cancelled and agentpc tried to stop it). |
exit_code, stdout, stderr |
The command's code and its separate streams, with stdout_bytes / stderr_bytes and truncation flags (output_truncated) saying whether part of a stream was left out. Foreground codes are the low 8 bits SSH reports: on Windows exit -1 reads 255. |
timed_out, request_cancelled, termination_confirmed, effect_unknown |
Whether the call reached its timeout; whether its request was cancelled, its client disconnected or its session ended before the command's end was known; whether agentpc verified that none of the processes it identified is left (null when nothing needed stopping); and whether it is unknown if the command ran, finished or is still running. |
execution_id, job_id, recovery_action |
A run is issued an execution_id (null only when the call failed before one was, such as an unknown VM). job_id is the handle for get_job_status and stop_job: set for a background job and for a foreground run whose fate is uncertain. recovery_action says what to do next: get_job_status, stop_job, start_vm, inspect_vm or retry (only when the command provably never ran). |
isError is true for every nonzero exit, every timeout (even when the command's own
exit code is known to be 0), every cancelled request, and every run that didn't start or lost its connection. It is false only for exit 0 and a
background start. Commands are never retried automatically.
Timeouts
- At
timeout, agentpc attempts to stop the command and what it started, and keeps the partial stdout and stderr. The reply usually follows within a few seconds; the cleanup is bounded, about 50 seconds beyond the timeout at most. termination_confirmed: truemeans agentpc verified that none of the processes it identified is left. On Linux those are the command's transient systemd user scope when the guest's user manager is available, the processes marked with its execution id and their descendants (all that is checked without a user manager, where a process that clears its environment and detaches can be missed), and its verified root. On Windows it is the verified process tree, best effort: a process whose launcher had already exited can be missed. It is not a sandbox guarantee. System services, other systemd units, and Windows services, scheduled tasks and WMI-created processes are not covered.- With
termination_confirmed: false, orconnection_lostwitheffect_unknown: true, the command may still be running. (Aconnection_lostwitheffect_unknown: falsecarries the VM's recordedexit_codeand nojob_id: it finished, and only its output may be incomplete.) Use thejob_idwithget_job_statusandstop_job, and don't rerun it before you know. A process is only signalled after its pid and start time are proven to be the command's. - A command whose exit code the VM recorded but whose run had not ended by the timeout (a child it left may have held its output, or it ended right at the deadline) returns that exit code with
timed_out: true, after cleanup verified that none of the processes agentpc identified remains. Start servers withbackground: true. - A Linux foreground command is limited to 92 KiB (94,208 UTF-8 bytes) of text. agentpc checks that before sending anything: a bigger one comes back
not_startedwith norecovery_action. For a bigger script,upload_fileit and run the file.
Cancellation
- A foreground
run_commandis stopped in the VM, as at a timeout (bounded, identity-checked, never retried), when its request is cancelled (notifications/cancelled), the client disconnects or the MCP session ends;request_cancelledis then true and the call is an error. cancelledwithtermination_confirmed: true: stopped and verified.exitedwith its code: it had already finished.not_startedwith norecovery_action: it never began.cancelledwithtermination_confirmed: false,effect_unknown: trueandrecovery_action: stop_job: it may still be running; use thejob_idand don't rerun it before you know.- A client that cancelled usually never receives that reply. Find the run with
list_jobs;get_job_statusthen showsstoppedwithstop_reason: request_cancelled, ornot_started. - A
background: truestart is detached on purpose: cancelling its request, disconnecting or ending the session does not stop the job. End it withstop_job.AGENTPC_KEEP_RUNNING=1keeps VMs, not foreground commands, and a hard kill of the MCP server cleans up nothing: the job records stay in the VM.
get_job_status
state:queued,running,exited,stopped,ended,not_started,missingorunknown(the VM couldn't be read: an error, never reported asended). An exited job reports its own exit code even if it left a child running.wait_seconds(0 to 30, default 0) waits for the job to leaverunning, or with acursorfor new output. If nothing happens it returns the current state withwait_timed_out: true. A wait only waits and never stops the job. It can run about 2 seconds past the wait on a slow VM; to wait longer, call again with the lastnext_cursor.cursor(anext_cursor, or"start") returns at most 16 KiB from that byte position and advances by exactly the bytes it returned, so output is neither repeated nor skipped (has_moresays call again). UTF-8 characters are never split across reads, invalid bytes become U+FFFD (invalid_utf8), and a byte-order mark is consumed unseen.- A cursor that no longer matches (VM reset or restored, guest reboot, log replaced, shortened, rewritten or removed) comes back with
cursor_reset: trueand acursor_reset_reason, restarted at the log's beginning: discard what you assumed. Detection is best effort; it can miss a guest clock step, a Windows log recreated within the file system's tunneling window, and a log rewritten with the same first 64 bytes. missingmeans nothing has recorded itself under that id yet. That doesn't prove the command never started, so don't blindly retry it.- A foreground
job_idhas status only (exited with its code, stopped at its timeout, or not started); its output went to therun_commandreply, so there is no log to replay.
list_jobs
- Read-only, metadata only: per job
job_id,kind,state,exit_code,stop_reason(timeout,stop_job,request_cancelled),log_available,records,recorded_atandfinished_at. Never a command, environment, output, log text or path. Newest id first;limitis 1 to 100 (default 20). - When
has_moreis true, passnext_cursorascursor: the next page continues below the last job shown within the VM as the first page saw it, so jobs created meanwhile don't shift it. - A cursor from before a VM reset, restore or recreation is
cursor_stale: the VM's generation changed, so its records may differ from what the earlier pages listed (a restored checkpoint can keep some) and the listing cannot safely continue. Call again without a cursor. A malformed cursor, or one of another VM, isinvalid_cursor. - Best effort, not an immutable history: records age out, and a huge job directory keeps only its first entries (
scan_truncated), so jobs may be missing. Aqueued,missingorunknownentry is unresolved, not proof the command never started. Cancelling the call abandons only the listing.
wait_for_service
- Looks only at the VM's own IPv4
127.0.0.1(never another host, a URL or a proxy).protocol: "tcp"(default) is ready when a connection is accepted;"http"is ready when oneGET path(/by default; a path, not a URL) answersexpected_status(default 200).pathandexpected_statusare HTTP-only and refused withtcp. Redirects are not followed, and no body, header or guest text is returned. Don't put credentials inpath: it is echoed back and visible in the VM's process list. timeout_secondsis 1 to 120 (default 30) and bounds the waiting, not the wall clock: a call can run about 5 s past it while a last probe finishes.stateisready,timed_out,job_failed,cancelledorerror, with the lasttcporhttp_outcome(status_matched,redirect,status_mismatched,no_response,malformed) andhttp_status. Anything butreadyis an error andtimed_outkeeps the last observation.probe_unsupportedmeans the guest's bash has no/dev/tcpor it has notimeoutcommand.- With
job_idthe wait ends early (job_failed) only for a proven failure: a nonzero exit, a stopped job, or one that never started. A job that exited 0 (a server may outlive its wrapper), is queued, running, ended without a code, missing or unreadable is not failure. - Ready describes the endpoint, not a process: nothing checks who owns the port, so it doesn't prove the job started it. Waiting never stops or restarts the service or the job; cancelling it only stops probing. Each probe makes short-lived connections (and one GET), so it can consume a one-shot listener.
inspect_desktop
Before each dispatch, inspect_desktop asks the VM's driver for the tool's annotations and runs only a
tool it explicitly marks readOnlyHint: true (list_desktop_tools shows
[read-only]). A tool marked otherwise, unmarked or unknown is refused before it is dispatched, with nothing run (looking up the annotations does ask the VM's driver). After a dropped call the
driver is asked again on the new connection; the call is re-sent only if the old connection had listed the tool read-only
and the new one still does. The marking is the driver's own claim: a guard against mistakes, not a guarantee against a
mislabelling driver or guest. For compact observation, get_window_state with
include_screenshot: false returns the element tree without a screenshot, and its
query argument narrows what comes back. use_desktop_tool is unchanged.
Session lifecycle
- VMs a session created, started, reset or restored are stopped, never deleted, when the session ends. Set
AGENTPC_KEEP_RUNNING=1to keep them running. list_vmsshows each VM's owner. Agents should create their own uniquely named VM per task, and never reset, restore or delete one they didn't create. A VM whose session ended without stopping it (its server was killed) is stopped by the next MCP server and showsowner_running: false;create_vmwith its name and image hands it back to the agent that made it.- Each running VM takes 4 GB (Ubuntu, Arch) or 8 GB (Windows) of RAM, so delete VMs when done.
- The guest login is
agent/agent(desktop and sudo; SSH takes only agentpc's key).
Approval prompts
Every desktop action goes through use_desktop_tool, which is
marked destructive, so clients that confirm tool calls ask before each click or
keystroke. inspect_desktop is marked read-only and only runs tools the
driver marks read-only. To approve agentpc's tools up front:
| Client | Setting |
|---|---|
| Claude Code | Allow mcp__agentpc (all of its tools) with /permissions, or in permissions.allow in settings. |
| Codex | default_tools_approval_mode = "approve" under [mcp_servers.agentpc] in ~/.codex/config.toml. |
| Gemini CLI | "trust": true on the agentpc server in ~/.gemini/settings.json. |
| Others | The client's own tool-approval settings. |
That approves every agentpc tool, including upload_file and
download_file, which read and write files on your Mac. The VMs
themselves are throwaway: reset_vm undoes anything done inside one.
Tools also carry MCP annotations, which some clients use to decide what to confirm:
list_vms, take_screenshot,
list_desktop_tools, inspect_desktop,
get_job_status, list_jobs,
wait_for_service,
list_forwards and read_vm_log are read-only.
Tools that discard or overwrite state are marked destructive, including
download_file, which writes to your Mac.
start_vm, stop_vm and stop_job are idempotent.