Skip to main content

windows

Run a UTM Windows VM and control it via agent-browser over noVNC

Source facts

Repository
Vesely/skills
Last source activity
August 25, 2026 at 09:59
Detected SKILL.md language
English
Stars
28
Forks
4

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

File Explorer
12 files

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
windows
description
Run a UTM Windows VM and control it via agent-browser over noVNC
allowed-tools
["Bash(/Applications/UTM.app/Contents/MacOS/utmctl:*)","Bash(agent-browser:*)","Bash(websockify:*)","Bash(~/.local/bin/websockify:*)","Bash(pipx:*)","Bash(/usr/libexec/PlistBuddy:*)","Bash(vncdo:*)","Bash(ssh:*)","Bash(scp:*)","Bash(curl:*)","Bash(nc:*)","Bash(netstat:*)","Bash(lsof:*)","Bash(pgrep:*)","Bash(kill:*)","Bash(osascript:*)","Bash(sips:*)","Bash(screencapture:*)","Bash(cliclick:*)","Bash(killall:*)","Bash(git:*)","Bash(grep:*)","Bash(head:*)","Bash(od:*)","Bash(mv:*)","Bash(ps:*)","Bash(mktemp:*)","Bash(timeout:*)","Bash(gtimeout:*)","Bash(open:*)","Bash(cp:*)","Bash(ls:*)","[Truncated]"]
when_to_use
Use ONLY when the user explicitly invokes `/windows` (with optional action description). Examples: `/windows`, `/windows open Notepad`, `/windows close` (tear down the browser session), `/windows stop` (shut the VM down properly), `/windows <action> on <target>` to pick a non-default VM from config.json. Do NOT auto-invoke from general phrasing about Windows or UTM — wait for the slash command.
argument-hint
[action | close | stop] [target]
arguments
["action"]
# Windows VM (UTM + noVNC + agent-browser) Start a UTM Windows VM, bring up a noVNC bridge, and drive the guest from a browser session. A fast path skips setup when everything is already running; recipes cache sequences that worked. ## Inputs - `$action`: (optional) what to do, e.g. "open Notepad". Special values: - `close` = tear down the browser session only (leave the VM running). - `stop` / `shutdown` = shut Windows down properly from the inside, then quit UTM. - The action may also name a target from `config.json`, or one of its `aliases`. ## References — read the relevant one *before* you need it | File | Read it when | |---|---| | `references/config.md` | writing or extending `config.json`, or a key is missing | | `references/input.md` | **before your first click or keystroke on a target**, and whenever input stops working | | `references/troubleshooting.md` | anything looks frozen, black, stale or off by a factor | | `references/keyboard-layouts.md` | `guest_keyboard` is not `us`, or typed text comes out wrong | | `references/shared-folder.md` | moving files in or out, or a guest dialog hangs | | `references/guest-channel.md` | noVNC and `vncdo` are both insufficient, or you need real work done inside the guest | | `references/recipes.md` | saving, loading or writing a recipe | ## Configuration **Everything machine-specific lives in `config.json` in this skill's directory.** No hostname, UUID, port or personal path appears in this file, so the skill can be shared as-is; each user brings their own config. Standard install locations (UTM's app bundle and container) do appear, and the ones that genuinely vary — `novnc_path`, `websockify` — are config keys. **If `config.json` is missing, or a key you need is absent — HALT.** Print the setup block below and stop. Never fall back to `config.example.json`, and never guess a value: a wrong UUID starts someone else's VM, and a wrong `ssh_host` connects to a machine that is not yours. > Copy `config.example.json` to `config.json` and fill in `vm_uuid` and `bundle`. > Get the UUID with `/Applications/UTM.app/Contents/MacOS/utmctl list`. Minimum config — two fields, everything else defaults: ```json { "vm_uuid": "XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX", "bundle": "Windows.utm" } ``` For several VMs, target selection, and the full key table, see `references/config.md`. ### Loading it ```bash # SKILL_DIR = the directory you loaded this SKILL.md from. This file is not an executed # script, so there is no $0 to derive it from — substitute the literal path (it is # usually ~/.claude/skills/windows, often a symlink to a checkout elsewhere). SKILL_DIR=<directory this SKILL.md was loaded from> CFG="$SKILL_DIR/config.json" TARGET=<chosen target key> # $action alias match → target key → default_target SSH_HOST=<ssh_host or empty> VNCHOST=<vnc_host> VNCPORT=<vnc_port> # port ON THE VM HOST TUNNEL_PORT=<tunnel_local_port or empty> VMDIR=<bundle> VMUUID=<vm_uuid> BRIDGE_PORT=<bridge_port> SESSION=<session_name> # What websockify and vncdo connect to on THIS Mac: if [[ -n "$TUNNEL_PORT" ]]; then BRIDGE_TARGET="localhost:$TUNNEL_PORT" else BRIDGE_TARGET="$VNCHOST:$VNCPORT"; fi # Run a command on whichever machine hosts the VM. # It takes ONE string and runs it through a shell, so redirects, pipes and || work # the same locally and remotely. Quote every call site accordingly. run() { if [[ -n "$SSH_HOST" ]]; then ssh "$SSH_HOST" "$1" else bash -c "$1"; fi } PLIST=~/Library/Containers/com.utmapp.UTM/Data/Documents/$VMDIR/config.plist # Scratch for this run. Fixed /tmp names collide with another session of the same user, # and screenshots here are full captures of someone's desktop. RUNDIR=$(umask 077; mktemp -d "/tmp/windows-skill.XXXXXX") ``` Put every screenshot, log and marker under `$RUNDIR` and delete it when you finish. The one deliberate exception is the readiness marker below, which has to outlive the run. **Validate every value before it reaches a shell command.** `vm_uuid` must match the UUID shape; `vnc_port`, `tunnel_local_port` and `bridge_port` must be integers in range; and `ssh_host`, `bundle`, `vnc_host`, `novnc_path`, `websockify`, `session_name`, `shared_folder`, the target names and their `aliases` must contain no whitespace, quotes, `;`, `|`, `&`, `$`, newlines or backticks — every one of them is substituted into a command line, several of them unquoted. You may not be the person who wrote that file. **`run` must be a function, not a variable.** In zsh an unquoted variable is **not** word-split — `SSHP="ssh host"; $SSHP ls` fails with `command not found: ssh host`. Add `2>/dev/null` and the command vanishes without a trace, looking like it succeeded. **And it must go through a shell on both branches.** A `run() { "$@"; }` local branch execs its arguments directly, so `run 'pgrep -x UTM >/dev/null || open -a UTM'` looks for a program with that entire sentence as its name — which fails on the local target only, while the remote target works. Use one string plus `bash -c`, as above. **Anything you want expanded on the far side must be escaped**: `\$(date …)` inside a double-quoted `run` argument, or the host's own value is substituted before it is sent. `$PLIST` expands **locally**, which is right only if the remote user has the same home path. If not, build it on the far side or keep `~/Library/…` in single quotes. **The bridge and the browser session always run on this Mac**, whatever the target is. Only websockify's upstream address changes. ## Hard rules Rules marked **[universal]** follow from how UTM/QEMU/noVNC/Win32 work. Rules marked **[verify once]** were observed on a specific setup and may differ on yours — check them the first time you use a target and record the answer in that target's `notes`. ### Never run two copies of the same VM at once **[universal]** Two QEMU processes on the **same disk image** corrupt it outright. Two *copies* of one image are less dramatic but still bad: they share a Windows machine SID and often a `vm_uuid`, so they collide on the network and on anything licence- or identity-bound, and afterwards you cannot tell which copy holds the real data. Either way, run one. ```bash pgrep -f qemu >/dev/null && echo "A VM IS RUNNING LOCALLY" # every DISTINCT ssh_host across all targets, not just the one you are about to start for h in $(<every distinct non-null ssh_host in config.json>); do ssh -o ConnectTimeout=5 "$h" 'pgrep -f qemu >/dev/null' 2>/dev/null \ && echo "A VM IS RUNNING ON $h" done ``` If one is up and the user asked for another, **stop and ask**. This is the one place in this skill where asking is mandatory. ### The VM is shared. Claim it atomically before you touch it **[universal]** GUI automation is exclusive to the foreground window: two sessions at once switch each other's windows, clicks land in the wrong place, and both lose their work — or worse, save something in the wrong place. Claim with an operation that cannot interleave. `mkdir` fails if the directory exists, so it is a lock; reading a file and then writing it is not. ```bash # On the VM HOST's own filesystem, not on the shared folder — the share is the least # reliable thing here, and a lock you cannot read is worse than no lock. LOCK=/tmp/vm-owner-$VMUUID.lock if run "mkdir '$LOCK' 2>/dev/null"; then run "echo '<session-name> '\$(date +%FT%T) > '$LOCK/held-by'" # we hold it else run "cat '$LOCK/held-by' 2>/dev/null" # someone else does fi ``` Everyone driving that VM goes through its host, so a lock there is seen by every session, including ones on other machines. Key it by `$VMUUID` so two different VMs on one host do not block each other. - **Refresh `held-by` as you work** (its mtime is the liveness signal). Release it when you are done — including when you abort or hit an error — but **only if it is still yours**; between your last refresh and now someone may legitimately have taken over: ```bash run "grep -q '<session-name>' '$LOCK/held-by' 2>/dev/null && rm -rf '$LOCK'" ``` - **When it is taken, do not start anything** — not even "just one screenshot", which still switches the foreground window. Do not wait blindly either: 1. **Ask the user** who holds it and whether to wait — always available, always correct. 2. If your harness can address other agent sessions by name and the owner string looks like a session name, ask that session directly. An optimisation, not a requirement. Short wait (~10 min) → wait and retry. Long or no answer → tell the user and let them decide. - **A stale lock is a judgement call, not a timer.** An old `held-by` with no other activity is probably a crashed session — but a long-running import looks identical. Confirm the guest is idle (two screenshots 60 s apart, unchanged) *and* say what you are doing before you break someone's lock. Never break one on a timer alone. - **Before releasing, clean up inside the guest.** Close the applications and dialogs you opened, and leave the desktop as you found it. Anything you did not open, leave alone. ### Never send `mouse down` without an immediate `mouse up` **[universal]** `mouse down` and `mouse up` are separate CLI processes. If anything happens between them, the guest is left holding a button — and the whole input path can wedge: the framebuffer keeps updating, but **no input arrives at all, mouse or keyboard, including Escape**. Restarting the session or websockify does not clear it; a bare `mouse up` does. **Do not click in the guest through noVNC.** Use `vncdo` or an in-guest channel for that. Exactly two noVNC pointer uses remain allowed, both emitted as one uninterrupted unit with nothing in between: - `mouse move` alone — a hover, for the tooltip probe. No button involved. - `mouse move` → `down` → `up` **once**, to give the browser canvas focus so keystrokes reach noVNC. That is a focus tap, not a click in the guest, and it is the only place the sequence appears. Full reasoning and the recovery procedure are in `references/input.md`. ### Only one VNC client at a time **[verify once]** Treat the VNC head as exclusive unless a probe proves otherwise on your setup. While noVNC holds it, `vncdo capture` comes back black or hangs — which looks exactly like a dead VM. **Never run `vncdo` while the browser session is connected**: release the session and the bridge first, confirm the upstream socket is gone, then reconnect afterwards. Procedure in `references/input.md`. ### Probe the input channel; never trust a cached answer **[verify once]** Which channel reaches the guest is **not stable**. Observed over months on one VM: the mouse worked, died, worked, worked only via `vncdo`, died again after a restart — with no configuration change. On the same day two applications in the same guest disagreed about whether the keyboard or the pointer was the working one. So `input_preference` in `config.json` is the order to **try**, never a verdict. Probe at the start of a session that will do more than take a screenshot (~20 s), and re-probe whenever a step silently does nothing, after any restart, and when the foreground application changes. If nothing is verified, **stop and say so** — do not "try a click". The probe is in `references/input.md`. ### Bound everything that talks to the VM **[universal]** Every `vncdo`, `ssh`, `nc`, share and capture call gets an explicit timeout. Un-bounded, they do not fail — they hang for tens of minutes and look like a dead guest. In particular, never probe a network share with a bare `Test-Path`; see `references/shared-folder.md`. ### Kill by pid, never by pattern **[universal]** `pkill -f websockify` and friends also kill other sessions' bridges, other agents' work and other people's processes. Resolve the pid first (`lsof -t`, `pgrep -f`), look at what you found, then kill that pid. **One deliberate exception: `killall UTM`.** UTM is a single application instance owning every VM on that host, so there is no per-VM process to target — and an `osascript` quit fails silently over SSH. It is still blunt: it takes down *every* VM on that machine, so check the other targets' locks on that host first, and never reach for it while another session holds one. ### The shared folder is the most fragile link **[universal for UTM's WebDAV sharing]** When it dies, **every guest dialog that touches it hangs for many minutes**, and anything in the guest polling a file there dies with it. Check it is alive before a long series of steps. Recovery order and the safe liveness test: `references/shared-folder.md`. ### A dialog that "ignores the keyboard" usually just lacks focus **[universal]** Send `alt-tab` first, then `esc`/`enter` — one `alt-tab` per stuck dialog. Verified on a pair of windows that had resisted `esc`, `tab`+`enter` and clicking, and which two sessions independently concluded were unreachable by keyboard. They were reachable; the keys were going somewhere else. ### VNC has no authentication here **[universal]** `vnc_display_arg` must keep its `127.0.0.1` prefix. Reach the port from another machine through the SSH tunnel this skill sets up — never by binding QEMU to `0.0.0.0`. ## Fast path (try first, ~0.3 s) **Never call `eval` on the fast path.** Not for cost, but because `session list` plus a port check answers faster, and `eval` needs a live connected canvas — exactly what you are still verifying. ```bash # 1. ports listening — VNC on the target, bridge always local nc -z -w1 ${BRIDGE_TARGET%:*} ${BRIDGE_TARGET##*:} >/dev/null 2>&1 \ && lsof -nP -iTCP:"$BRIDGE_PORT" -sTCP:LISTEN -t >/dev/null || exit 1 # 2. session exists agent-browser session list 2>/dev/null | grep -qE "^[[:space:]]*$SESSION\$" || exit 1 # 3. the browser is really attached — without this a zombie session passes: # window still open, websocket long dead lsof -nP -iTCP:"$BRIDGE_PORT" -sTCP:ESTABLISHED -t >/dev/null 2>&1 || exit 1 # 4. marker from the last successful setup, TTL 30 min AND the same target M=/tmp/windows-skill-ready-$SESSION [[ -f $M ]] && [[ $(($(date +%s) - $(stat -f %m $M))) -lt 1800 ]] \ && [[ "$(cat $M)" == "$TARGET" ]] || exit 1 echo "ALL_READY" ``` `ALL_READY` → jump to **Step 5**. Otherwise walk 1–4, and on success `echo "$TARGET" > /tmp/windows-skill-ready-$SESSION`. **Why the `ESTABLISHED` check.** A session whose websocket dies keeps running and looks alive: `session list` shows it, the bridge port listens. Measured once: such a session held **two cores for 8 hours**, because a disconnected noVNC canvas retries forever. The socket is the only reliable evidence — though it is *necessary, not sufficient* (sockets exist during "Connecting…" too), so the sanity screenshot confirms the handshake completed. **The marker carries the target on purpose.** If it only had to exist, switching targets would sail through and you would be talking to the old bridge and the still-running VM behind it. **Sanity verify before the first action** (not on the fast path): one screenshot, `agent-browser --session "$SESSION" screenshot "$RUNDIR/_vnc.png"`. If it is dominated by a dark background with the noVNC logo, reconnect (Step 4). ## Steps ### 1. Preflight (only if the fast path failed) Local tools, in parallel: `which agent-browser`; `<websockify> --help` (else `pipx install websockify`); `ls <novnc_path>/vnc.html` (else `git clone --depth 1 https://github.com/novnc/noVNC.git <novnc_path>`); `which vncdo` (`pipx install vncdotool`); `which cliclick` only for `start_method: cliclick`. VM configuration, on the target. Read the plist directly rather than through `utmctl`: ```bash run "/usr/libexec/PlistBuddy -c 'Print :QEMU:AdditionalArguments' '$PLIST'" run "/usr/libexec/PlistBuddy -c 'Print :Display:0:Hardware' '$PLIST'" ``` Expected: a **non-GL** display, and the VNC argument matching `vnc_display_arg`. A `-gl` display makes QEMU refuse the VNC console (`The console requires a GL context`). On the setup this was written against the working value was `virtio-ramfb`; the right value is whatever your UTM build offers without `-gl`, so read the current one before changing it. If they match, continue. If not: 1. If the VM is running, **human checkpoint**: shut Windows down from the inside over VNC. Poll until the qemu process is gone. Never `--force`. 2. Quit UTM — over SSH `run "killall UTM"` (an osascript quit fails silently over SSH); locally `osascript -e 'tell application "UTM" to quit'`. Verify with `run "pgrep -x UTM"`.
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub