Adom Desktop
Public Made by Adomby adom
Installs the adom-desktop CLI and its Claude skills INTO A CLOUD CONTAINER so an AI assistant there can understand and drive Adom Desktop over the relay. This is the container side, NOT the app itself. The Adom Desktop app is the signed Windows installer under Download below: run THAT on the PC you want the AI to control. Rule of thumb: pkg install here (in your Linux container), Download there (on your Windows PC). Once installed, the AI can drive file transfer, screenshots, notifications, KiCa
Desktop & bridge mgmt verbs
Windows, screenshots, files, embedded mode, relay servers, tunnels, and bridge lifecycle.
Part of the Adom Desktop verb reference. Invoke as adom-desktop <verb> '<json>'.
bridge_check_updates
READ-ONLY: report, for every installed bridge, the version AD runs now vs the latest version on the wiki, and whether an update is available. Downloads NOTHING — this is the 'what's updatable?' probe. v1.8.144+. Use it before refresh_bridges to decide what to pull. Mirrors the GUI's 'Check for updates' button.
Returns: {success:true, bridges:[{name, current, latest, updateAvailable:bool, source, wikiReachable:bool}], updatesAvailable:int, _hint}. current = what AD runs now (cache if newer, else bundled). latest = wiki manifest version (null if unreachable / not wiki-hosted). updateAvailable = wiki strictly newer than current.
Note: A bundled bridge shipped AHEAD of the wiki (and bundled-only puppeteer, whose deps exceed the wiki file limit) correctly reports updateAvailable=false — AD keeps the newer bundled copy. wikiReachable=false means offline or the bridge isn't wiki-hosted. To APPLY an update: refresh_bridges {"name":""} for one, or refresh_bridges for all.
bridge_install
Install a bridge from a wiki manifest URL. The manifest's referenced zip is downloaded, sha256-verified, extracted into the cache, and the runtime registry is rescanned. Third-party bridges (Altium, MATLAB, oscilloscope vendors, etc.) ship via wiki using the same scripts/release-bridge.sh format. v1.8.0+.
Args:
manifestUrl— required URL pointing at a -bridge-manifest.json (e.g. https://wiki.adom.inc/api/v1/pages/adom-desktop/files/altium-bridge-manifest.json)
Returns: {success, ok, bridge:, version, installPath, _hint}
Note: The bridge zip MUST contain a bridge.json at its root with name + spawn.{kind,entrypoint,port,...} + verbPrefixes. See https://github.com/adom-inc/adom-desktop/blob/main/plugins/kicad/bridge.json for the schema. To update an existing bridge, install again — the cache atomic-swaps.
bridge_list
List every installed bridge (kicad, puppeteer, fusion360, plus any third-party bridges installed via bridge_install). v1.8.0+. Each entry reports name, displayName, version, source (bundled/cache/third_party), installPath, paused flag, spawn kind, ports, verbPrefixes the bridge owns, and verbCount.
Returns: {success:true, bridges:[{name, displayName, version, description, source, paused, spawn:{kind, manifestPort, runtimePort, entrypoint}, verbPrefixes, verbCount, ...}], summary:{total, bundled, cache, third_party, paused}, _hint}
Note: v1.8.0 made the bridge list dynamic. Bridges are discovered by scanning %LOCALAPPDATA%\Adom Desktop\bridges-cache<name>\bridge.json (writable; wiki-streamed + third-party installs) and the bundled installer location (read-only fallback). Cache shadows bundled when both exist. Third-party bridges are installed via bridge_install. v1.8.31+: bridge ports are dynamic, no more 8772/8773/8851 hardcoded — eliminates collision with HD.
Ports: v1.8.31+: bridges use DYNAMIC ports. manifestPort is what the bridge.json declares (0 = 'let adom-desktop pick'). runtimePort is what the bridge is ACTUALLY bound to right now (null if not yet spawned). CALLERS NEVER NEED EITHER. Always route bridge verbs via adom-desktop's CLI/direct-API by bridge id (kicad_*, fusion_*, etc.) — adom-desktop forwards to whatever port the bridge is on. Bridge ports are pure internal plumbing; if you find yourself reading them, you're probably doing something wrong.
bridge_pause
Pause a bridge: subsequent calls to its verb prefixes (e.g. kicad_*) will fail with bridge_paused until you bridge_resume. The running server process is NOT killed; this is a routing-layer flag.
Args:
name— required — bridge name from bridge_list (e.g. 'kicad')
Returns: {success, name, paused:true, _hint}
bridge_resume
Resume a paused bridge — flip the routing flag back. Inverse of bridge_pause.
Args:
name— required — bridge name
Returns: {success, name, paused:false, _hint}
bridge_uninstall
Remove a bridge from the cache. v1.8.0+. Bundled bridges (kicad, puppeteer, fusion360 in current installer) revert to the bundled fallback; third-party bridges are fully removed. The running bridge server process is NOT killed automatically; the response _hint includes the kill command if you want it gone immediately.
Args:
name— required — bridge name
Returns: {success, name, cacheRemoved, stillInstalledViaBundled, _hint}
desktop_bring_to_front
Bring a window to the foreground
Args:
hwnd— optional intstate— optional: maximize or restore (default: preserve current state)titleContains— optional string (case-insensitive)
desktop_caption
Show a large always-on-top, click-through text overlay on the user's DESKTOP (not a chat caption) — for narrating what you're doing on their screen, screen-share / demo labels, status banners. Win32 layered window over every app.
Args:
action— optional: "hide" (dismiss this id, or all if no id) | "force-clear" (nuke ALL caption windows)duration— optional ms before auto-clear. Omitted or 0 → 30000 (30s) default. Clamped to max 600000 (10 min). NOT 'forever' anymore.fontSize— optional int px (8–400), overrides sizeid— optional string — captions with the SAME id replace each other (and re-issuing RESETS the timer = how you extend); different ids coexist. Default id "_default".persist— optional bool. true → sticky, NO auto-clear (supervised use only, e.g. demo recordings) — YOU must dismiss it.position— optional: top | center | bottom (default) | top-left | top-right | bottom-left | bottom-rightsize— optional: small (20px) | medium (32px, default) | large (72px)text— the caption text (omit, or action:"hide", to dismiss)x— optional 0.0–1.0 horizontal fraction (overrides position)y— optional 0.0–1.0 vertical fraction (overrides position)
Returns: {visible, text, id, persist, expiresInMs (null if persist), _hint}
Note: AUTO-EXPIRE by design so a forgotten caption can't be stranded on the user's screen. Default 30s. To keep one up LONGER: re-issue the same id before it expires (resets the timer — a heartbeat) OR pass a bigger duration (≤10 min). Only persist:true is truly sticky, and then you MUST clean up with {"action":"hide","id":""}. Always dismiss captions when the narrated action is done.
desktop_connect_all
v1.8.42+: programmatically trigger 'Connect All' for every enabled server entry. Mirrors the tray-menu button and HD's tray-menu 'Connect All' item. Spawns ws_loop for any entry that isn't already connected.
Returns: {ok, success, totalEnabled, spawned, alreadyConnected, _hint}
desktop_disconnect_all
v1.8.42+: programmatically drop every live WS connection. Server entries stay in config (use server_remove to delete). Mirrors HD's tray 'Disconnect All'.
Returns: {ok, success, disconnected, _hint}
desktop_embedded_enter
v1.8.73+: explicit runtime adopt — HD calls this to switch a running standalone AD into embedded mode immediately (no 60s wait for the embedded_monitor tick). Always sets entered_via=runtime-adopt, meaning: when HD subsequently dies, AD will REVERT to standalone (not exit). If HD wants AD to exit on HD-death (the no-hanging-chad rule), HD must spawn AD with --embedded instead — that sets entered_via=launch-flag. Idempotent: returns was_embedded:true if AD was already embedded; no-ops in that case.
Returns: {ok:true, success:true, was_embedded:bool, embedded:true, entered_via:'runtime-adopt', owner:'hydrogen-desktop', _hint}
See also: desktop_embedded_release (revert), desktop_embedded_status (introspect). HD-side recipe in plugins/hd/SKILL.md.
desktop_embedded_release
v1.8.73+: explicit transition out of embedded mode. v1.8.77+ ALWAYS reverts to standalone, regardless of entered_via. AD's process REMAINS RUNNING after this call — tray restored, footer pill hidden, updater poller resumed, marker deleted. The two operations are now explicitly separate: this verb says 'stop being embedded, become standalone'; use desktop_shutdown to exit AD. Pre-v1.8.77 a launch-flag AD would exit on release, which fought HD's spawn supervisor (HD calls release → AD exits → supervisor respawns → looks like AD didn't exit). v1.8.77 decoupling fixes that.
Returns: {ok:true, success:true, was_embedded:bool, entered_via_before:'launch-flag'|'runtime-adopt'|null, will_exit:false, _hint}. will_exit is always false in v1.8.77+ — kept for back-compat with HD code that checked it pre-v1.8.77.
See also: desktop_embedded_enter (adopt), desktop_shutdown (explicit AD exit). The 60s embedded_monitor's HD-process-death branch is unchanged: ungraceful HD death still uses entered_via to decide exit-vs-revert (LaunchFlag→exit, RuntimeAdopt→revert).
desktop_embedded_status
v1.8.42+: introspect whether AD is currently running in embedded mode (HD bundling AD). v1.8.73+ also reports enteredVia — launch-flag means HD spawned AD as its child (AD exits when HD dies, no orphan); runtime-adopt means AD was running standalone and HD adopted it at runtime via /embedded/enter (AD reverts to standalone when HD dies, keeps running). Standalone AD returns embedded=false.
Returns: {ok, success, embedded:bool, owner:string|null, source:string|null, enteredVia:'launch-flag'|'runtime-adopt'|null, pendingRelayUrl, pendingRelayName, startHidden:bool, markerPath, markerExists:bool, _hint}
desktop_install_kicad
Unattended KiCad install via winget (Windows only, v1.6.0+). Single-purpose: runs winget install --id KiCad.KiCad --silent --accept-package-agreements --accept-source-agreements. Long-running (2-5 min — downloads ~700 MB then installs). Returns the winget exit code + stdout/stderr so the caller can decide what to do. Doesn't go through shell_execute approval — it's a fixed-command verb the AI calls deliberately, not a free-form shell.
Returns: On success: {ok:true, exitCode, stdout, stderr, _hint:"Run kicad_list_versions to verify"}. On winget unavailable: {ok:false, errorCode:"winget_unavailable", _hint}. On winget failure: {ok:false, errorCode:"winget_install_failed", exitCode, stdout, stderr, _hint}.
Note: Trigger this when kicad_list_versions returns errorCode:"kicad_not_installed". After install completes, restart Adom Desktop OR call kicad_list_versions again — the kicad bridge caches detection results, so a fresh detection scan may be needed to pick up the freshly-installed binaries.
desktop_install_node
Unattended Node.js LTS install via winget (Windows only, v1.7.0+). Single-purpose: runs winget install --id OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements. ~1-2 min depending on network. Trigger this when ANY browser_* command returns errorCode:"node_not_found" — that error fires when the puppeteer bridge can't auto-spawn because Node isn't installed. After install, retry the original browser_* command — the bridge reads node.exe location from the Windows registry (which winget writes), so a restart isn't needed.
Returns: On success: {ok:true, exitCode:0, stdout, stderr, hint:"Retry the original browser* command"}. On winget unavailable: {ok:false, errorCode:"winget_unavailable", _hint}. On install failure: {ok:false, errorCode:"winget_install_failed", exitCode, stdout, stderr, _hint}.
Note: v1.7.0 closes the fresh-Windows-machine pup gap. Pre-v1.7.0, a user without Node hit a generic 'bridge not running' error and had to debug it. Now: errorCode:"node_not_found" → desktop_install_node → retry. Adom Desktop also pre-fetches Node + Chrome for Testing on first launch after install (background task), so most users won't ever see node_not_found.
desktop_list_browsers
Catalog of every installed native browser on the desktop, with each browser's profiles + their signed-in account info (when known). Reads each browser's canonical metadata file directly — Chromium-based browsers (Chrome/Edge/Brave) all use the same Local State JSON layout; Firefox uses profiles.ini. No shell, no version-probing trial-and-error. Use this to introspect what's available BEFORE calling desktop_open_url with a specific browser+profile.
Returns: {ok, default:"chrome"|"edge"|"firefox"|"brave"|"unknown" (OS-registered URL handler), browsers:[{name, displayName, exePath, version?, profiles:[{id, name, gaia?, isDefault?}]}]}. Profiles are listed default-first, then alphabetical. id is the filesystem dirname (Chromium: "Default", "Profile 1"; Firefox: profile name). gaia is the signed-in Google/Microsoft account email — populated for Chromium browsers when the profile is signed in, absent otherwise. isDefault is the browser's last-used profile.
Note: Typical flow: call desktop_list_browsers, find the profile whose gaia matches what the URL needs (e.g. work email for adom.inc Google Workspace links, personal Gmail address for [email protected] pages, media account for YouTube channel work), then pass that profile.id to desktop_open_url. Falls back gracefully — browsers with no install are absent from the list, profiles with no metadata file (browser never opened) return as an empty list. v1.7.1+.
desktop_list_files
List files in a directory matching a shell-glob, optionally filtered to those modified after a given timestamp. Pure filesystem op (no shell) — replaces the shell_execute + PowerShell-poll-Downloads pattern that callers like chip-fetcher used to use to wait for a download. NOT recursive; lists one directory only.
Args:
glob— optional string (default ""). Shell-glob: * = any non-separator run; ? = single non-separator char. Case-insensitive on Windows (matches the filesystem's own behavior). Examples: "ul_.zip", "LIB_*.zip", ".pdf", ".step", "datasheet_*.pdf".modifiedSince— optional. Number (unix seconds) OR string (unix-seconds-as-text OR ISO-8601/RFC-3339 e.g. "2026-05-04T22:30:00Z"). Files with mtime <= since are filtered out. Use to find files that arrived after a click.path— required string. Windows abs path. ~, %USERPROFILE%, %APPDATA%, %LOCALAPPDATA%, %TEMP% are expanded. Forward slashes (/) and back slashes (\) both work.
Returns: {ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}
Note: Common pattern: record since = $(date +%s) BEFORE you click a download button, then list_files after. The newest-first sort means files[0] is your fresh download. For a watch-until-arrived loop, use desktop_watch_files instead — same args, but blocks until a match appears.
desktop_list_windows
List all visible windows with HWNDs, titles, positions
desktop_logout
v1.8.42+: clear AD's cached Adom Cloud session (~/.adom/session.json). HD calls this on its own sign-out so AD doesn't display a stale email in its footer. Equivalent to the GUI's Sign-out link.
Returns: {ok, success, _hint}. After logout, adom_whoami returns null until the user signs in again (via the GUI footer link or another handoff).
desktop_open_folder
Open file/folder in Windows Explorer
Args:
path— required string
desktop_open_url
Open a URL in the user's NATIVE OS BROWSER — i.e. their everyday daily-driver Edge / Chrome / Firefox / Brave (whatever Windows has registered as the default URL handler), with their existing logged-in sessions, saved passwords, history, bookmarks, and extensions. This is NOT Puppeteer / Chrome for Testing — it's the real browser the human uses. Use this when a HUMAN needs to interact with a page that requires being signed in (claude.ai, GitHub, internal tools, banking, etc.). v1.7.1+ supports profile for picking a specific Chrome/Edge/Firefox profile (work vs personal vs media account, etc.) — call desktop_list_browsers first to discover available profile IDs.
Args:
browser— optional string: "default" (let Windows pick the user's registered default — they could have Edge, Chrome, Firefox, Brave set as default), "chrome", "edge", "firefox", "brave" (force a specific one if you know they prefer it). Default = "default".profile— optional string (v1.7.1+) — filesystem profile ID. Chromium-based: pass exact id from desktop_list_browsers (e.g. "Default", "Profile 1", "Profile 2"); maps to --profile-directory. Firefox: pass profile name; maps to -P. Ignored when browser="default" (no clean way to inject profile flags through the OS URL handler). Use desktop_list_browsers to enumerate profiles + their gaia (signed-in account email) so you can pick the right one.url— required string (https://... or any URL the OS shell can dispatch)
Returns: {ok:bool, browser, exePath?, url, profile?} — profile echoed if it was applied
Note: desktop_open_url vs browser_open_window — pick the right one:
• desktop_open_url => NATIVE OS BROWSER (Edge/Chrome/Firefox/Brave the human uses every day). Has saved logins. Human will interact with the page. Claude CANNOT drive it afterward — once you hand off, you're done.
• browser_open_window => PUPPETEER-controlled CHROME FOR TESTING (separate Chromium build, isolated profile under plugins/puppeteer/profiles/). NO saved logins. Claude DRIVES it programmatically (screenshots, clicks, eval, recording).
Rule of thumb: human-in-the-loop login flow => desktop_open_url. AI-driven automation => browser_open_window.
v1.7.1+: when the user has multiple Chrome profiles (work + personal + media account, etc.) call desktop_list_browsers first to enumerate, then pass the matching profile ID. The response surfaces gaia/email per profile so you can match by account context.
desktop_pull_glob
Convenience orchestrator: list_files (or watch_files if wait:true) → for each match, pull_file streams the bytes to a container-side absolute path. One command instead of three. Designed for the canonical chip-fetcher pattern: record before-timestamp, click, wait, pull.
Args:
glob— optional string (default "*").path— required string. Windows abs path; same expansion as desktop_list_files.pollMs— optional (only used when wait:true). Default 1000.saveTo— required string. Container-side absolute path (a directory). Parent dirs auto-created. Each pulled file lands at saveTo/.since— optional unix-seconds OR ISO-8601. Only files with mtime > since are pulled.timeoutMs— optional (only used when wait:true). Default 60000.wait— optional bool (default false). When true, blocks via desktop_watch_files until at least one match appears, then re-lists and pulls everything matching (so .crdownload + final .zip both come along when the browser writes them in the same poll tick). When false, just lists what's there now.
Returns: {ok, files:[{name, path, size, sha256, chunks}, ...], errors:[...], matchedCount}
Note: Use this instead of shell_execute('powershell -c ... while not exists ...; sleep 1') — that pattern is deprecated for new code. shell_execute itself remains as an escape hatch but should not be used for download polling.
desktop_revoke_approvals
Clear all shell auto-approve permissions and deny pending approvals
desktop_screenshot_screen
Capture the entire desktop (all monitors). v1.8.62+ produces full + safe-resized PNG pair to %TEMP%\adom-desktop-shots\screen-.{full,safe}.png. v1.8.61+ universal screenshot delivery contract applies (CLI auto-pulls both to /tmp/ad-shots/, enriches response with local* + dimensions + narrative). See desktop_screenshot_window for the full return shape — same except hwnd-specific prefix is replaced with screen-.
Returns: Same shape as desktop_screenshot_window's returns (top-level fullPath + safePath + dimensions; CLI-side local* fields added by v1.8.61 auto-pull).
desktop_screenshot_window
Capture a specific window by HWND. v1.8.62+ writes BOTH a lossless full-res PNG + a Claude-safe-resized copy (≤resizeMax) to %TEMP%\adom-desktop-shots\window--.{full,safe}.png. v1.8.61+ universal screenshot delivery contract: the CLI auto-pulls both PNGs to /tmp/ad-shots/ and enriches the response with localFullPath, localSafePath, localFullExists, localSafeExists, fullBytes, safeBytes, source, narrative. Backward compatible: existing data.image base64 + sizeBytes still inside output JSON string.
Args:
hwnd— required intresizeMax— optional int, default 1400. Longest side of the safe-resized PNG.
Returns: Top-level: success, output (JSON string with the existing message/data/image/format/encoding/sizeBytes shape), error, fullPath, safePath, fullWidth, fullHeight, safeWidth, safeHeight, fullBytesHost, safeBytesHost, resizeMax. After v1.8.61+ CLI auto-pull: localFullPath, localSafePath, localFullExists, localSafeExists, fullBytes (= bytes on caller side), safeBytes, source, narrative. On disk-write failure (rare): same shape minus the file fields + _hint pointing at %TEMP% / AV diagnosis.
desktop_set_window_state
Set window state without necessarily foregrounding
Args:
hwnd— optional intstate— required: maximize|minimize|restore|show|hidetitleContains— optional string
Note: Use bring_to_front to foreground + maximize in one call. Use this for minimize/hide/show.
desktop_show_ui
v1.8.137+: stage an in-window UI element so it can be captured via desktop_screenshot_window (no computer-use). Emits the ad-show-ui frontend event, waits ~350ms for render, returns.
Args:
show— optional bool, default true (false hides/resets)target— required: settings | build_info | direct_api | shell_approval | none (reset)
Note: Workflow for docs screenshots: desktop_show_ui {"target":"settings"} then desktop_screenshot_window on the AD window. For embedded-vs-standalone, toggle desktop_embedded_enter / desktop_embedded_release first, then screenshot.
desktop_shutdown
v1.8.42+: graceful AD exit — stop bridges (kicad, fusion, puppeteer) then exit. HD's tray 'Quit' uses this to take AD down with HD. Standalone callers can use it for clean reset between dev iterations.
Returns: {ok, success, output, _hint}. The exit happens ~500ms after the response is sent so the caller doesn't see connection-reset.
desktop_watch_files
Block until a file matching the glob appears in the directory (mtime > since), or timeoutMs elapses. Pure filesystem op, no shell. Polls fs.read_dir every pollMs (default 1000ms). Replaces the shell_execute + PowerShell while-loop pattern for waiting on a download.
Args:
glob— optional string (default "*"). Same semantics as desktop_list_files.path— required string. Same expansion semantics as desktop_list_files.pollMs— optional int (default 1000, floor 100). How often to re-list the directory.since— optional. Same parse rules as desktop_list_files.modifiedSince. DEFAULTS TO NOW — without an explicitsince, only files created AFTER this watch call started are reported. That matches the canonical 'click then watch' flow; pass an explicitsinceif you might miss the file by starting the watch slightly late.timeoutMs— optional int (default 60000, max 600000 = 10 min). After this elapses, returns {ok:false, error:"timeout", elapsedMs, _hint}.
Returns: On match: {ok:true, path, glob, file:{path,name,size,mtime}, elapsedMs}. On timeout: {ok:false, error:"timeout", elapsedMs, _hint, path, glob}.
Note: Returns the FIRST file that matches — sorted newest-first within a poll cycle. Use desktop_pull_glob if you want the watch + multi-file pull in one call.
desktop_window_hide
v1.8.42+: hide AD's main window. App keeps running in the background; use desktop_window_show to bring it back. Standalone AD users typically use the window's X button which is bound to hide-not-quit.
Returns: {ok, success, _hint}
desktop_window_show
v1.8.42+: show + focus AD's main window. Used by Hydrogen Desktop's tray menu when bundling AD in embedded mode (HD's tray entry 'Open Adom Desktop' fires this). Also reachable standalone — any caller can bring AD to the foreground.
Returns: {ok, success, _hint}
direct_api
INFORMATIONAL — not a CLI verb. v1.8.25+ adds a local HTTP listener on http://127.0.0.1:47200 inside the adom-desktop GUI. Sibling apps on the same machine (Hydrogen Desktop, future Adom-family apps) can POST commands straight in without spawning the CLI or going through the WS relay. Loopback-only bind. The port (47200) lives in the IANA dynamic/private range (49152-65535-adjacent), squarely outside HD's 47083-47185 fallback range — clearly an Adom-family port, but no collision potential. Same {app, command, args} envelope as the WS protocol; same response payload (including _hint fields) as the CLI returns. Endpoints: GET /health (cheap probe), GET /status (capability inventory + cliRequired list), POST /command (verb dispatch). The dispatcher is commands::handle_command — exactly the same code the WS path uses.
Note: If you (Docker Claude) are talking to a user authoring a sibling Tauri app and they ask 'how do I call adom-desktop without invoking the CLI', point them at this endpoint + the direct-api-SKILL.md asset on the wiki. For your own use (cross-machine Docker → Windows), keep using the CLI — the direct API is loopback-only by design and won't be reachable. Streaming verbs (pull_file, send_files, shell_execute) still require the CLI even for sibling apps; the direct API refuses them with errorCode:"cli_required".
Env vars: ADOM_DIRECT_URL=off forces relay-only. ADOM_DIRECT_URL=http://127.0.0.1:47209 skips probe + uses that URL. ADOM_DIRECT_LISTEN_ADDR=127.0.0.1:47205 (GUI-side) overrides the default bind primary; the 47200-47209 fallback range still applies.
Discovery: Port can fall back from 47200 if taken (zombie socket, dev instance, third-party collision). The GUI walks 47200..47209 picking the first free port, then writes its chosen host:port to ~/.adom/direct-api-port (Windows: %USERPROFILE%\.adom\direct-api-port). Sibling apps and the CLI read that file first, then fall back to scanning 47200..47209. SO_REUSEADDR + graceful shutdown ensure zombie sockets from prior crashes don't block the next launch. v1.8.32 and earlier used 8770..8779 — that range is no longer probed; auto-update from the wiki carries users forward.
Timeouts: Direct API timeouts are now per-verb (matching the CLI dispatcher table): walk_cloud_tree + search_cloud_files = 620s; heavy exports = 320s; gerbers = 200s; drc/erc = 180s; bridge_install + fusion_start = 300s; default = 120s. Caller can override via args.timeout (seconds, clamped to 1800).
rdp
v1.8.85+: convenience wrapper around tunnel + launch_rdp_client. Opens a tunnel to the given RDP target then spawns mstsc.exe on the desktop pointed at the local end. One verb = RDP session established (the user still has to type credentials in mstsc). v1.8.86+: defaults local OFF 3389 (which collides with the user's own Windows RDP service on most laptops); spawns mstsc at the ACTUALLY-bound port when the requested port was in use.
Args:
local— optional int — local listener port on the desktop. Default 13389 (NOT 3389 — Windows' own Remote Desktop service usually owns 3389 and the bind silently coexists pre-v1.8.86). If the requested local is taken, AD falls back to an OS-assigned ephemeral port; the response'slocalPort+actualPortreflect what mstsc was actually pointed at.to— REQUIRED string — RDP target inhost:portform. If port omitted, defaults to 3389.
Returns: {ok, tunnelId, localPort, requestedLocal, fellBack, toHost, toPort, open, launch, _hint}. localPort is the actually-bound port (= what mstsc is connecting to). fellBack:true means the requested port was unavailable and AD picked ephemeral.
refresh_bridges
Force an immediate wiki sync of the bridge cache (kicad / puppeteer / fusion360 + any installed third-party bridges). v1.7.19+: bridges live as standalone wiki assets (decoupled from the Tauri installer ship cycle) and auto-update on launch + every 4 hours. This verb triggers the sync NOW. v1.8.144+: pass {"name":""} to update JUST that bridge; omit to sync all. Tip: run bridge_check_updates FIRST (read-only) to see what's actually updatable before downloading.
Args:
name— optional — bridge name from bridge_list (e.g. 'kicad'). Omit to sync every bridge.
Returns: {success:true, results:[{bridge, ok, from, to, action}], _hint}. action ∈ {current, current_offline, current_bundled_newer, bootstrapped, updated}. After 'updated' the bridge SERVER PROCESS (python.exe / node.exe) needs to restart to pick up new code — the cache update is on-disk, not in-memory.
Note: Updates the writable cache at %LOCALAPPDATA%\Adom Desktop\bridges-cache<bridge>. The bridge servers resolve their payload from there (cache-first, bundled-fallback). To FORCE the running bridge to pick up new cache content, follow with: taskkill /f /im python.exe (kicad+fusion) or taskkill /f /im node.exe (puppeteer). On next *_command call the bridge respawns with the new code.
register_container
v1.8.37+: register THIS cloud Docker container in the user's Adom Cloud profile so Adom Desktop on their Windows laptop auto-discovers + connects after sign-in. Runs entirely on the cloud Docker side — uses /var/run/adom/api-key (the Adom-cloud-mounted session token). PATCHes three carbon-prefs keys: desktop.container. (full ServerEntry as JSON-string), desktop.last_slug, desktop.enabled=true. No relay/desktop dependency. The user's flow: run this verb from cloud Docker → install AD on Windows → sign in → container appears automatically.
Args:
auth_token— optional string — relay auth token (defaults to adom-dev-token-2025, which is what the cloud relay accepts by default)auto_connect— optional bool — auto-dial on AD launch (default true)enabled— optional bool — enabled in AD's server list (default true)id— optional string — container id slug (defaults to uuid v4). Stays stable across re-runs if you provide it; the same id PATCHes the same key, overwriting any prior entry.name— REQUIRED string — human-friendly label for AD's server list (e.g. 'galliaApril')url— REQUIRED string — public wss:// URL the desktop will dial (typically wss://.adom.cloud/proxy/8765/)
Returns: {ok:true, id, name, url, _hint:'now install AD on your Windows laptop + sign in'} on success. On missing args / Carbon failure / api-key unavailable: {ok:false, error, _hint} envelope explaining what to fix.
User flow: Cloud Docker: adom-desktop register_container '{"name":"my-container","url":"wss://..."}' → user installs Adom Desktop on Windows from https://wiki.adom.inc/adom/adom-desktop → user clicks 'Sign in' in AD's footer + completes the browser flow → AD's boot-time cloud-config pull (Phase 2) reads desktop.container.* from the user's carbon-prefs + merges into the GUI's server list. Without register_container the user has to paste-JSON manually; with it the container appears automatically.
See also: Designed to work with Phase 1 (auth in AD), Phase 2 (cloud-config sync in AD), and Phase 4 (HD→AD session handoff if AD is bundled with Hydrogen Desktop). The full bundle landed across v1.8.33 - v1.8.37.
relaunch_self
v1.8.103+: visible self-restart helper for dev iteration. Spawns the standalone ad-relauncher.exe (a small signed Rust app shipped in the NSIS bundle) which runs in HOST context and drives the redeploy + relaunch sequence: preflight → stop AD → deploy (atomic temp+rename) → launch (CreateProcessW DETACHED_PROCESS) → verify reachable (polls AD's ephemeral direct-api port from the discovery file, v1.8.118+) → verify version (running PE FileVersion matches source) → verify visible (EnumWindows for the new PID). A visible egui window shows per-step progress + a green HOST / red SANDBOX context banner so the sandbox trap is impossible to miss. Replaces all the prior ad-hoc shell glue — no taskkill, no start, no PowerShell, no .bat. AD itself goes down partway through Stop AD; the relauncher is detached and survives. Poll progress via relauncher_status — v1.8.118+ it returns the terminal outcome from a persisted file even after the relauncher's GUI auto-closes, so the poll loop always terminates.
Args:
destExe— optional dest path (default: %LOCALAPPDATA%\Adom Desktop\adom-desktop.exe)noAutoStart— optional bool — if true, the relauncher shows its GUI but waits for POST /start instead of running immediatelyport— optional HTTP API port for the relauncher (default: 47210)sourceExe— optional path to the freshly-built adom-desktop.exe (default: src-tauri/target/release/adom-desktop.exe relative to the relauncher's location)
Returns: {ok, success, relauncherPid, relauncherExe, relauncherPort, _hint}
See also: relauncher_status (poll progress), skills/AD_RELAUNCH.md (full recipe).
relauncher_status
v1.8.103+: query the ad-relauncher's HTTP API and return its full state JSON. AD proxies GET http://127.0.0.1:47210/status. While AD is going down during Stop AD this call will fail with relauncherRunning:false — that's expected; once AD comes back up post-Launch the status appears again. Returns the relauncher's overall status, the current step, per-step progress + events, stopping/launching VersionInfo (PID, path, FileVersion, sha256, signature, window-visible), HOST/SANDBOX context, and the log tail. Poll every 1-2s.
Returns: {ok, relauncherRunning, context:{kind:'host'|'sandbox',package_name?}, overall:'pending'|'running'|'done_ok'|'done_fail'|'cancelled', failedStep, currentStep, steps:[{name,status,progress,message,events}], stopping:{pid,path,fileVersion,sha256,signature,size,windowVisible,windowTitle}, launching:{...same shape}, runId, logTail:[{seq,ts_ms,level,text}], _hint}
See also: relaunch_self (spawn), skills/AD_RELAUNCH.md (recipe).
server_add
Register a relay server connection programmatically — equivalent to the GUI's Quick Add paste-box + connect button, but driven by CLI. Upserts by name: if a server with the same name already exists, its URL (and optionally authToken) is updated rather than creating a duplicate. By default the connection is established immediately (autoConnect:true). If the URL is unchanged AND we're already connected, no churn — returns ok and leaves the live connection in place. Use this when an external app (e.g. Hydrogen Desktop's Docker container) needs to auto-register its relay at startup without the user pasting JSON. v1.8.22+.
Args:
authToken— optional string — defaults to 'adom-dev-token-2025'. Empty string means 'keep existing token if this is an update; otherwise use default'.autoConnect— optional bool, default true — connect right after upsert. Set false to add the entry without dialling out.name— required string — human-friendly server label. Acts as the dedup key for upserts.url— required string — relay WebSocket URL (e.g. ws://localhost:8765 or wss://your-host/proxy/8765/).
Returns: {ok:true, name, url, id, connected:bool, created:bool, _hint}. created is true when a new entry was inserted, false when an existing one was updated. connected is true once the WS loop has been registered (the actual transport handshake may still be pending — call server_list to see the live state).
Note: Persists to the same ~/.adom/config.json the GUI uses, so the entry survives a GUI restart. The ws_client supervisor (runs every 30s inside the GUI) auto-reconnects on next launch via the entry's auto_connect flag. To remove: use server_remove '{"name":""}'.
server_list
Return the current persisted server list with live connection status. Same data the GUI's server-list panel renders, exposed as JSON for scripts/external apps. v1.8.22+.
Returns: {ok:true, servers:[{name, url, id, autoConnect, enabled, connected:bool, clientCount:0|1, status:'connected'|'reconnecting'|'disconnected'}], count, _hint}. connected is the strict boolean (true only when the WS link is in the Connected state, not Reconnecting). clientCount is 0 or 1 per server (each adom-desktop contributes at most one client) — left in the schema for symmetry with the relay's own client-count terminology.
server_remove
Disconnect (if connected) and delete a relay server entry by name. Inverse of server_add. Idempotent: removing a non-existent server returns ok with removed:false. v1.8.22+.
Args:
name— required string — the samenameyou passed to server_add.
Returns: {ok:true, removed:bool, wasConnected:bool, name, _hint}. removed is true iff an entry was actually deleted; false means no such server was registered.
shell_auto_approve
Programmatically enable/revoke shell_execute auto-approve. v1.8.3+. v1.8.116+: permanent is the DURABLE user preference — it writes AD's Settings checkbox ('Auto-approve shell commands permanently', config-backed, default OFF), so it survives restarts by design. Timed grants (duration_secs) are session-scoped. In embedded mode HD owns this preference: pass --shell-approval <prompt|1h|24h|permanent> when spawning AD, and/or drive this verb at runtime; AD's own checkbox goes read-only ('Managed by Hydrogen Desktop').
Args:
duration_secs— optional int — seconds to enable for (e.g. 86400 = 24h), session-scoped. Pass 0 to revoke immediately (also clears the durable preference). Ignored ifpermanent: true.permanent— optional bool — true sets the durable Settings preference + arms it now; false clears it (full off). Default: absent (no preference change for timed grants).
Returns: {success, output, active:bool, remainingSecs, permanent:bool}
Note: Use sparingly. Auto-approve means anything that can reach the relay can run arbitrary shell commands without user consent. The PUBLIC default is per-command approval — leave it that way for end users; permanent is an explicit owner opt-in. Typical patterns:
- Dev session:
adom-desktop shell_auto_approve '{"duration_secs": 86400}'(24h, then expires). - Owner's box, durable:
adom-desktop shell_auto_approve '{"permanent": true}'(equivalent to ticking the Settings checkbox). - HD embedded spawn: pass
--shell-approval permanent(or the user's HD-side choice) on AD's command line — session-only, never persisted by AD. - Revoke everything:
adom-desktop shell_auto_approve '{"duration_secs": 0}'.
tunnel
v1.8.85+: open a generic TCP port-forward tunnel from a desktop-side 127.0.0.1: listener to a cloud-side --to host:port upstream, riding the existing AD↔relay WebSocket. First use case: RDP back into the laptop's user session via mstsc, but the path is identical for any TCP service (SSH, VNC, HTTP, postgres, ...). No new auth path — the WS is already authed; the tunnel is just bytes routed by tunnelId+connId on top of it.
Args:
local— REQUIRED int — the local 127.0.0.1 port the desktop should bind. mstsc / your TCP client connects HERE.to— REQUIRED string — upstream target inhost:portform. The relay dials this when bytes arrive on the local port.tunnelId— optional u32 — caller-chosen ID. Defaults to a fresh random u32. Reuse across re-invocations to address the same tunnel.
Returns: {ok, tunnelId, toHost, toPort, clientId, actualPort, error?, _hint}. On success, mstsc/anything connecting to 127.0.0.1: on the desktop has its bytes relayed.
See also: rdp (one-shot RDP wrapper), tunnel_list, tunnel_close. For multi-connection tests, every fresh client TCP connection to 127.0.0.1: gets its own connId routed independently.
tunnel_close
v1.8.85+: tear down a tunnel (or one conn within a tunnel). Without connId, drops the listener + all conns. With connId, drops only that one TCP connection.
Args:
connId— optional u32 — if given, close only this conn; otherwise close the whole tunneltunnelId— REQUIRED u32 — fromtunnel/tunnel_list
Returns: {ok, tunnelId, connId?, scope:'tunnel'|'conn', _hint}
tunnel_list
v1.8.85+: list all active tunnels — both the relay's view and AD's view (should agree).
Returns: {ok, relay:[{tunnelId, toHost, toPort, clientId, connCount}, ...], desktop:{tunnels:[...]}, _hint}
# Desktop & bridge mgmt verbs
Windows, screenshots, files, embedded mode, relay servers, tunnels, and bridge lifecycle.
_Part of the [Adom Desktop verb reference](VERB-REFERENCE.md). Invoke as `adom-desktop <verb> '<json>'`._
### `bridge_check_updates`
READ-ONLY: report, for every installed bridge, the version AD runs now vs the latest version on the wiki, and whether an update is available. Downloads NOTHING — this is the 'what's updatable?' probe. v1.8.144+. Use it before refresh_bridges to decide what to pull. Mirrors the GUI's 'Check for updates' button.
**Returns:** {success:true, bridges:[{name, current, latest, updateAvailable:bool, source, wikiReachable:bool}], updatesAvailable:int, _hint}. current = what AD runs now (cache if newer, else bundled). latest = wiki manifest version (null if unreachable / not wiki-hosted). updateAvailable = wiki strictly newer than current.
**Note:** A bundled bridge shipped AHEAD of the wiki (and bundled-only puppeteer, whose deps exceed the wiki file limit) correctly reports updateAvailable=false — AD keeps the newer bundled copy. wikiReachable=false means offline or the bridge isn't wiki-hosted. To APPLY an update: refresh_bridges {"name":"<bridge>"} for one, or refresh_bridges for all.
### `bridge_install`
Install a bridge from a wiki manifest URL. The manifest's referenced zip is downloaded, sha256-verified, extracted into the cache, and the runtime registry is rescanned. Third-party bridges (Altium, MATLAB, oscilloscope vendors, etc.) ship via wiki using the same scripts/release-bridge.sh format. v1.8.0+.
**Args:**
- `manifestUrl` — required URL pointing at a <bridge>-bridge-manifest.json (e.g. https://wiki.adom.inc/api/v1/pages/adom-desktop/files/altium-bridge-manifest.json)
**Returns:** {success, ok, bridge:<name from manifest>, version, installPath, _hint}
**Note:** The bridge zip MUST contain a bridge.json at its root with name + spawn.{kind,entrypoint,port,...} + verbPrefixes. See https://github.com/adom-inc/adom-desktop/blob/main/plugins/kicad/bridge.json for the schema. To update an existing bridge, install again — the cache atomic-swaps.
### `bridge_list`
List every installed bridge (kicad, puppeteer, fusion360, plus any third-party bridges installed via bridge_install). v1.8.0+. Each entry reports name, displayName, version, source (bundled/cache/third_party), installPath, paused flag, spawn kind, ports, verbPrefixes the bridge owns, and verbCount.
**Returns:** {success:true, bridges:[{name, displayName, version, description, source, paused, spawn:{kind, manifestPort, runtimePort, entrypoint}, verbPrefixes, verbCount, ...}], summary:{total, bundled, cache, third_party, paused}, _hint}
**Note:** v1.8.0 made the bridge list dynamic. Bridges are discovered by scanning %LOCALAPPDATA%\Adom Desktop\bridges-cache\<name>\bridge.json (writable; wiki-streamed + third-party installs) and the bundled installer location (read-only fallback). Cache shadows bundled when both exist. Third-party bridges are installed via bridge_install. v1.8.31+: bridge ports are dynamic, no more 8772/8773/8851 hardcoded — eliminates collision with HD.
**Ports:** v1.8.31+: bridges use DYNAMIC ports. `manifestPort` is what the bridge.json declares (0 = 'let adom-desktop pick'). `runtimePort` is what the bridge is ACTUALLY bound to right now (null if not yet spawned). CALLERS NEVER NEED EITHER. Always route bridge verbs via adom-desktop's CLI/direct-API by bridge id (`kicad_*`, `fusion_*`, etc.) — adom-desktop forwards to whatever port the bridge is on. Bridge ports are pure internal plumbing; if you find yourself reading them, you're probably doing something wrong.
### `bridge_pause`
Pause a bridge: subsequent calls to its verb prefixes (e.g. kicad_*) will fail with bridge_paused until you bridge_resume. The running server process is NOT killed; this is a routing-layer flag.
**Args:**
- `name` — required — bridge name from bridge_list (e.g. 'kicad')
**Returns:** {success, name, paused:true, _hint}
### `bridge_resume`
Resume a paused bridge — flip the routing flag back. Inverse of bridge_pause.
**Args:**
- `name` — required — bridge name
**Returns:** {success, name, paused:false, _hint}
### `bridge_uninstall`
Remove a bridge from the cache. v1.8.0+. Bundled bridges (kicad, puppeteer, fusion360 in current installer) revert to the bundled fallback; third-party bridges are fully removed. The running bridge server process is NOT killed automatically; the response _hint includes the kill command if you want it gone immediately.
**Args:**
- `name` — required — bridge name
**Returns:** {success, name, cacheRemoved, stillInstalledViaBundled, _hint}
### `desktop_bring_to_front`
Bring a window to the foreground
**Args:**
- `hwnd` — optional int
- `state` — optional: maximize or restore (default: preserve current state)
- `titleContains` — optional string (case-insensitive)
### `desktop_caption`
Show a large always-on-top, click-through text overlay on the user's DESKTOP (not a chat caption) — for narrating what you're doing on their screen, screen-share / demo labels, status banners. Win32 layered window over every app.
**Args:**
- `action` — optional: "hide" (dismiss this id, or all if no id) | "force-clear" (nuke ALL caption windows)
- `duration` — optional ms before auto-clear. Omitted or 0 → 30000 (30s) default. Clamped to max 600000 (10 min). NOT 'forever' anymore.
- `fontSize` — optional int px (8–400), overrides size
- `id` — optional string — captions with the SAME id replace each other (and re-issuing RESETS the timer = how you extend); different ids coexist. Default id "_default".
- `persist` — optional bool. true → sticky, NO auto-clear (supervised use only, e.g. demo recordings) — YOU must dismiss it.
- `position` — optional: top | center | bottom (default) | top-left | top-right | bottom-left | bottom-right
- `size` — optional: small (20px) | medium (32px, default) | large (72px)
- `text` — the caption text (omit, or action:"hide", to dismiss)
- `x` — optional 0.0–1.0 horizontal fraction (overrides position)
- `y` — optional 0.0–1.0 vertical fraction (overrides position)
**Returns:** {visible, text, id, persist, expiresInMs (null if persist), _hint}
**Note:** AUTO-EXPIRE by design so a forgotten caption can't be stranded on the user's screen. Default 30s. To keep one up LONGER: re-issue the same id before it expires (resets the timer — a heartbeat) OR pass a bigger duration (≤10 min). Only persist:true is truly sticky, and then you MUST clean up with {"action":"hide","id":"<id>"}. Always dismiss captions when the narrated action is done.
### `desktop_connect_all`
v1.8.42+: programmatically trigger 'Connect All' for every enabled server entry. Mirrors the tray-menu button and HD's tray-menu 'Connect All' item. Spawns ws_loop for any entry that isn't already connected.
**Returns:** {ok, success, totalEnabled, spawned, alreadyConnected, _hint}
### `desktop_disconnect_all`
v1.8.42+: programmatically drop every live WS connection. Server entries stay in config (use server_remove to delete). Mirrors HD's tray 'Disconnect All'.
**Returns:** {ok, success, disconnected, _hint}
### `desktop_embedded_enter`
v1.8.73+: explicit runtime adopt — HD calls this to switch a running standalone AD into embedded mode immediately (no 60s wait for the embedded_monitor tick). Always sets `entered_via=runtime-adopt`, meaning: when HD subsequently dies, AD will REVERT to standalone (not exit). If HD wants AD to exit on HD-death (the no-hanging-chad rule), HD must spawn AD with `--embedded` instead — that sets `entered_via=launch-flag`. Idempotent: returns `was_embedded:true` if AD was already embedded; no-ops in that case.
**Returns:** {ok:true, success:true, was_embedded:bool, embedded:true, entered_via:'runtime-adopt', owner:'hydrogen-desktop', _hint}
**See also:** desktop_embedded_release (revert), desktop_embedded_status (introspect). HD-side recipe in plugins/hd/SKILL.md.
### `desktop_embedded_release`
v1.8.73+: explicit transition out of embedded mode. v1.8.77+ ALWAYS reverts to standalone, regardless of `entered_via`. AD's process REMAINS RUNNING after this call — tray restored, footer pill hidden, updater poller resumed, marker deleted. The two operations are now explicitly separate: this verb says 'stop being embedded, become standalone'; use `desktop_shutdown` to exit AD. Pre-v1.8.77 a launch-flag AD would exit on release, which fought HD's spawn supervisor (HD calls release → AD exits → supervisor respawns → looks like AD didn't exit). v1.8.77 decoupling fixes that.
**Returns:** {ok:true, success:true, was_embedded:bool, entered_via_before:'launch-flag'|'runtime-adopt'|null, will_exit:false, _hint}. `will_exit` is always false in v1.8.77+ — kept for back-compat with HD code that checked it pre-v1.8.77.
**See also:** desktop_embedded_enter (adopt), desktop_shutdown (explicit AD exit). The 60s embedded_monitor's HD-process-death branch is unchanged: ungraceful HD death still uses entered_via to decide exit-vs-revert (LaunchFlag→exit, RuntimeAdopt→revert).
### `desktop_embedded_status`
v1.8.42+: introspect whether AD is currently running in embedded mode (HD bundling AD). v1.8.73+ also reports `enteredVia` — `launch-flag` means HD spawned AD as its child (AD exits when HD dies, no orphan); `runtime-adopt` means AD was running standalone and HD adopted it at runtime via `/embedded/enter` (AD reverts to standalone when HD dies, keeps running). Standalone AD returns embedded=false.
**Returns:** {ok, success, embedded:bool, owner:string|null, source:string|null, enteredVia:'launch-flag'|'runtime-adopt'|null, pendingRelayUrl, pendingRelayName, startHidden:bool, markerPath, markerExists:bool, _hint}
### `desktop_install_kicad`
Unattended KiCad install via winget (Windows only, v1.6.0+). Single-purpose: runs `winget install --id KiCad.KiCad --silent --accept-package-agreements --accept-source-agreements`. Long-running (2-5 min — downloads ~700 MB then installs). Returns the winget exit code + stdout/stderr so the caller can decide what to do. Doesn't go through shell_execute approval — it's a fixed-command verb the AI calls deliberately, not a free-form shell.
**Returns:** On success: {ok:true, exitCode, stdout, stderr, _hint:"Run kicad_list_versions to verify"}. On winget unavailable: {ok:false, errorCode:"winget_unavailable", _hint}. On winget failure: {ok:false, errorCode:"winget_install_failed", exitCode, stdout, stderr, _hint}.
**Note:** Trigger this when kicad_list_versions returns errorCode:"kicad_not_installed". After install completes, restart Adom Desktop OR call kicad_list_versions again — the kicad bridge caches detection results, so a fresh detection scan may be needed to pick up the freshly-installed binaries.
### `desktop_install_node`
Unattended Node.js LTS install via winget (Windows only, v1.7.0+). Single-purpose: runs `winget install --id OpenJS.NodeJS.LTS --silent --accept-package-agreements --accept-source-agreements`. ~1-2 min depending on network. Trigger this when ANY browser_* command returns errorCode:"node_not_found" — that error fires when the puppeteer bridge can't auto-spawn because Node isn't installed. After install, retry the original browser_* command — the bridge reads node.exe location from the Windows registry (which winget writes), so a restart isn't needed.
**Returns:** On success: {ok:true, exitCode:0, stdout, stderr, _hint:"Retry the original browser_* command"}. On winget unavailable: {ok:false, errorCode:"winget_unavailable", _hint}. On install failure: {ok:false, errorCode:"winget_install_failed", exitCode, stdout, stderr, _hint}.
**Note:** v1.7.0 closes the fresh-Windows-machine pup gap. Pre-v1.7.0, a user without Node hit a generic 'bridge not running' error and had to debug it. Now: errorCode:"node_not_found" → desktop_install_node → retry. Adom Desktop also pre-fetches Node + Chrome for Testing on first launch after install (background task), so most users won't ever see node_not_found.
### `desktop_list_browsers`
Catalog of every installed native browser on the desktop, with each browser's profiles + their signed-in account info (when known). Reads each browser's canonical metadata file directly — Chromium-based browsers (Chrome/Edge/Brave) all use the same `Local State` JSON layout; Firefox uses `profiles.ini`. No shell, no version-probing trial-and-error. Use this to introspect what's available BEFORE calling desktop_open_url with a specific browser+profile.
**Returns:** {ok, default:"chrome"|"edge"|"firefox"|"brave"|"unknown" (OS-registered URL handler), browsers:[{name, displayName, exePath, version?, profiles:[{id, name, gaia?, isDefault?}]}]}. Profiles are listed default-first, then alphabetical. id is the filesystem dirname (Chromium: "Default", "Profile 1"; Firefox: profile name). gaia is the signed-in Google/Microsoft account email — populated for Chromium browsers when the profile is signed in, absent otherwise. isDefault is the browser's last-used profile.
**Note:** Typical flow: call desktop_list_browsers, find the profile whose gaia matches what the URL needs (e.g. work email for adom.inc Google Workspace links, personal Gmail address for [email protected] pages, media account for YouTube channel work), then pass that profile.id to desktop_open_url. Falls back gracefully — browsers with no install are absent from the list, profiles with no metadata file (browser never opened) return as an empty list. v1.7.1+.
### `desktop_list_files`
List files in a directory matching a shell-glob, optionally filtered to those modified after a given timestamp. Pure filesystem op (no shell) — replaces the shell_execute + PowerShell-poll-Downloads pattern that callers like chip-fetcher used to use to wait for a download. NOT recursive; lists one directory only.
**Args:**
- `glob` — optional string (default "*"). Shell-glob: * = any non-separator run; ? = single non-separator char. Case-insensitive on Windows (matches the filesystem's own behavior). Examples: "ul_*.zip", "LIB_*.zip", "*.pdf", "*.step", "datasheet_*.pdf".
- `modifiedSince` — optional. Number (unix seconds) OR string (unix-seconds-as-text OR ISO-8601/RFC-3339 e.g. "2026-05-04T22:30:00Z"). Files with mtime <= since are filtered out. Use to find files that arrived after a click.
- `path` — required string. Windows abs path. ~, %USERPROFILE%, %APPDATA%, %LOCALAPPDATA%, %TEMP% are expanded. Forward slashes (/) and back slashes (\\) both work.
**Returns:** {ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}
**Note:** Common pattern: record `since = $(date +%s)` BEFORE you click a download button, then list_files after. The newest-first sort means files[0] is your fresh download. For a watch-until-arrived loop, use desktop_watch_files instead — same args, but blocks until a match appears.
### `desktop_list_windows`
List all visible windows with HWNDs, titles, positions
### `desktop_logout`
v1.8.42+: clear AD's cached Adom Cloud session (~/.adom/session.json). HD calls this on its own sign-out so AD doesn't display a stale email in its footer. Equivalent to the GUI's Sign-out link.
**Returns:** {ok, success, _hint}. After logout, adom_whoami returns null until the user signs in again (via the GUI footer link or another handoff).
### `desktop_open_folder`
Open file/folder in Windows Explorer
**Args:**
- `path` — required string
### `desktop_open_url`
Open a URL in the user's NATIVE OS BROWSER — i.e. their everyday daily-driver Edge / Chrome / Firefox / Brave (whatever Windows has registered as the default URL handler), with their existing logged-in sessions, saved passwords, history, bookmarks, and extensions. This is NOT Puppeteer / Chrome for Testing — it's the real browser the human uses. Use this when a HUMAN needs to interact with a page that requires being signed in (claude.ai, GitHub, internal tools, banking, etc.). v1.7.1+ supports `profile` for picking a specific Chrome/Edge/Firefox profile (work vs personal vs media account, etc.) — call desktop_list_browsers first to discover available profile IDs.
**Args:**
- `browser` — optional string: "default" (let Windows pick the user's registered default — they could have Edge, Chrome, Firefox, Brave set as default), "chrome", "edge", "firefox", "brave" (force a specific one if you know they prefer it). Default = "default".
- `profile` — optional string (v1.7.1+) — filesystem profile ID. Chromium-based: pass exact id from desktop_list_browsers (e.g. "Default", "Profile 1", "Profile 2"); maps to --profile-directory. Firefox: pass profile name; maps to -P. Ignored when browser="default" (no clean way to inject profile flags through the OS URL handler). Use desktop_list_browsers to enumerate profiles + their gaia (signed-in account email) so you can pick the right one.
- `url` — required string (https://... or any URL the OS shell can dispatch)
**Returns:** {ok:bool, browser, exePath?, url, profile?} — profile echoed if it was applied
**Note:** desktop_open_url vs browser_open_window — pick the right one:
• desktop_open_url => NATIVE OS BROWSER (Edge/Chrome/Firefox/Brave the human uses every day). Has saved logins. Human will interact with the page. Claude CANNOT drive it afterward — once you hand off, you're done.
• browser_open_window => PUPPETEER-controlled CHROME FOR TESTING (separate Chromium build, isolated profile under plugins/puppeteer/profiles/). NO saved logins. Claude DRIVES it programmatically (screenshots, clicks, eval, recording).
Rule of thumb: human-in-the-loop login flow => desktop_open_url. AI-driven automation => browser_open_window.
v1.7.1+: when the user has multiple Chrome profiles (work + personal + media account, etc.) call desktop_list_browsers first to enumerate, then pass the matching `profile` ID. The response surfaces gaia/email per profile so you can match by account context.
### `desktop_pull_glob`
Convenience orchestrator: list_files (or watch_files if wait:true) → for each match, pull_file streams the bytes to a container-side absolute path. One command instead of three. Designed for the canonical chip-fetcher pattern: record before-timestamp, click, wait, pull.
**Args:**
- `glob` — optional string (default "*").
- `path` — required string. Windows abs path; same expansion as desktop_list_files.
- `pollMs` — optional (only used when wait:true). Default 1000.
- `saveTo` — required string. Container-side absolute path (a directory). Parent dirs auto-created. Each pulled file lands at saveTo/<basename>.
- `since` — optional unix-seconds OR ISO-8601. Only files with mtime > since are pulled.
- `timeoutMs` — optional (only used when wait:true). Default 60000.
- `wait` — optional bool (default false). When true, blocks via desktop_watch_files until at least one match appears, then re-lists and pulls everything matching (so .crdownload + final .zip both come along when the browser writes them in the same poll tick). When false, just lists what's there now.
**Returns:** {ok, files:[{name, path, size, sha256, chunks}, ...], errors:[...], matchedCount}
**Note:** Use this instead of shell_execute('powershell -c ... while not exists ...; sleep 1') — that pattern is deprecated for new code. shell_execute itself remains as an escape hatch but should not be used for download polling.
### `desktop_revoke_approvals`
Clear all shell auto-approve permissions and deny pending approvals
### `desktop_screenshot_screen`
Capture the entire desktop (all monitors). v1.8.62+ produces full + safe-resized PNG pair to %TEMP%\adom-desktop-shots\screen-<ts>.{full,safe}.png. v1.8.61+ universal screenshot delivery contract applies (CLI auto-pulls both to /tmp/ad-shots/, enriches response with local* + dimensions + narrative). See desktop_screenshot_window for the full return shape — same except hwnd-specific prefix is replaced with `screen-`.
**Returns:** Same shape as desktop_screenshot_window's returns (top-level fullPath + safePath + dimensions; CLI-side local* fields added by v1.8.61 auto-pull).
### `desktop_screenshot_window`
Capture a specific window by HWND. v1.8.62+ writes BOTH a lossless full-res PNG + a Claude-safe-resized copy (≤resizeMax) to %TEMP%\adom-desktop-shots\window-<hwnd>-<ts>.{full,safe}.png. v1.8.61+ universal screenshot delivery contract: the CLI auto-pulls both PNGs to /tmp/ad-shots/<basename> and enriches the response with localFullPath, localSafePath, localFullExists, localSafeExists, fullBytes, safeBytes, source, narrative. Backward compatible: existing data.image base64 + sizeBytes still inside `output` JSON string.
**Args:**
- `hwnd` — required int
- `resizeMax` — optional int, default 1400. Longest side of the safe-resized PNG.
**Returns:** Top-level: success, output (JSON string with the existing message/data/image/format/encoding/sizeBytes shape), error, fullPath, safePath, fullWidth, fullHeight, safeWidth, safeHeight, fullBytesHost, safeBytesHost, resizeMax. After v1.8.61+ CLI auto-pull: localFullPath, localSafePath, localFullExists, localSafeExists, fullBytes (= bytes on caller side), safeBytes, source, narrative. On disk-write failure (rare): same shape minus the file fields + `_hint` pointing at %TEMP% / AV diagnosis.
### `desktop_set_window_state`
Set window state without necessarily foregrounding
**Args:**
- `hwnd` — optional int
- `state` — required: maximize|minimize|restore|show|hide
- `titleContains` — optional string
**Note:** Use bring_to_front to foreground + maximize in one call. Use this for minimize/hide/show.
### `desktop_show_ui`
v1.8.137+: stage an in-window UI element so it can be captured via desktop_screenshot_window (no computer-use). Emits the ad-show-ui frontend event, waits ~350ms for render, returns.
**Args:**
- `show` — optional bool, default true (false hides/resets)
- `target` — required: settings | build_info | direct_api | shell_approval | none (reset)
**Note:** Workflow for docs screenshots: desktop_show_ui {"target":"settings"} then desktop_screenshot_window on the AD window. For embedded-vs-standalone, toggle desktop_embedded_enter / desktop_embedded_release first, then screenshot.
### `desktop_shutdown`
v1.8.42+: graceful AD exit — stop bridges (kicad, fusion, puppeteer) then exit. HD's tray 'Quit' uses this to take AD down with HD. Standalone callers can use it for clean reset between dev iterations.
**Returns:** {ok, success, output, _hint}. The exit happens ~500ms after the response is sent so the caller doesn't see connection-reset.
### `desktop_watch_files`
Block until a file matching the glob appears in the directory (mtime > since), or timeoutMs elapses. Pure filesystem op, no shell. Polls fs.read_dir every pollMs (default 1000ms). Replaces the shell_execute + PowerShell while-loop pattern for waiting on a download.
**Args:**
- `glob` — optional string (default "*"). Same semantics as desktop_list_files.
- `path` — required string. Same expansion semantics as desktop_list_files.
- `pollMs` — optional int (default 1000, floor 100). How often to re-list the directory.
- `since` — optional. Same parse rules as desktop_list_files.modifiedSince. DEFAULTS TO NOW — without an explicit `since`, only files created AFTER this watch call started are reported. That matches the canonical 'click then watch' flow; pass an explicit `since` if you might miss the file by starting the watch slightly late.
- `timeoutMs` — optional int (default 60000, max 600000 = 10 min). After this elapses, returns {ok:false, error:"timeout", elapsedMs, _hint}.
**Returns:** On match: {ok:true, path, glob, file:{path,name,size,mtime}, elapsedMs}. On timeout: {ok:false, error:"timeout", elapsedMs, _hint, path, glob}.
**Note:** Returns the FIRST file that matches — sorted newest-first within a poll cycle. Use desktop_pull_glob if you want the watch + multi-file pull in one call.
### `desktop_window_hide`
v1.8.42+: hide AD's main window. App keeps running in the background; use desktop_window_show to bring it back. Standalone AD users typically use the window's X button which is bound to hide-not-quit.
**Returns:** {ok, success, _hint}
### `desktop_window_show`
v1.8.42+: show + focus AD's main window. Used by Hydrogen Desktop's tray menu when bundling AD in embedded mode (HD's tray entry 'Open Adom Desktop' fires this). Also reachable standalone — any caller can bring AD to the foreground.
**Returns:** {ok, success, _hint}
### `direct_api`
INFORMATIONAL — not a CLI verb. v1.8.25+ adds a local HTTP listener on `http://127.0.0.1:47200` inside the adom-desktop GUI. Sibling apps on the same machine (Hydrogen Desktop, future Adom-family apps) can POST commands straight in without spawning the CLI or going through the WS relay. Loopback-only bind. The port (47200) lives in the IANA dynamic/private range (49152-65535-adjacent), squarely outside HD's 47083-47185 fallback range — clearly an Adom-family port, but no collision potential. Same `{app, command, args}` envelope as the WS protocol; same response payload (including `_hint` fields) as the CLI returns. Endpoints: `GET /health` (cheap probe), `GET /status` (capability inventory + `cliRequired` list), `POST /command` (verb dispatch). The dispatcher is `commands::handle_command` — exactly the same code the WS path uses.
**Note:** If you (Docker Claude) are talking to a user authoring a sibling Tauri app and they ask 'how do I call adom-desktop without invoking the CLI', point them at this endpoint + the `direct-api-SKILL.md` asset on the wiki. For your own use (cross-machine Docker → Windows), keep using the CLI — the direct API is loopback-only by design and won't be reachable. Streaming verbs (pull_file, send_files, shell_execute) still require the CLI even for sibling apps; the direct API refuses them with `errorCode:"cli_required"`.
**Env vars:** `ADOM_DIRECT_URL=off` forces relay-only. `ADOM_DIRECT_URL=http://127.0.0.1:47209` skips probe + uses that URL. `ADOM_DIRECT_LISTEN_ADDR=127.0.0.1:47205` (GUI-side) overrides the default bind primary; the 47200-47209 fallback range still applies.
**Discovery:** Port can fall back from 47200 if taken (zombie socket, dev instance, third-party collision). The GUI walks 47200..47209 picking the first free port, then writes its chosen `host:port` to `~/.adom/direct-api-port` (Windows: `%USERPROFILE%\.adom\direct-api-port`). Sibling apps and the CLI read that file first, then fall back to scanning 47200..47209. SO_REUSEADDR + graceful shutdown ensure zombie sockets from prior crashes don't block the next launch. v1.8.32 and earlier used 8770..8779 — that range is no longer probed; auto-update from the wiki carries users forward.
**Timeouts:** Direct API timeouts are now per-verb (matching the CLI dispatcher table): walk_cloud_tree + search_cloud_files = 620s; heavy exports = 320s; gerbers = 200s; drc/erc = 180s; bridge_install + fusion_start = 300s; default = 120s. Caller can override via `args.timeout` (seconds, clamped to 1800).
### `rdp`
v1.8.85+: convenience wrapper around `tunnel` + `launch_rdp_client`. Opens a tunnel to the given RDP target then spawns mstsc.exe on the desktop pointed at the local end. One verb = RDP session established (the user still has to type credentials in mstsc). v1.8.86+: defaults local OFF 3389 (which collides with the user's own Windows RDP service on most laptops); spawns mstsc at the ACTUALLY-bound port when the requested port was in use.
**Args:**
- `local` — optional int — local listener port on the desktop. Default 13389 (NOT 3389 — Windows' own Remote Desktop service usually owns 3389 and the bind silently coexists pre-v1.8.86). If the requested local is taken, AD falls back to an OS-assigned ephemeral port; the response's `localPort` + `actualPort` reflect what mstsc was actually pointed at.
- `to` — REQUIRED string — RDP target in `host:port` form. If port omitted, defaults to 3389.
**Returns:** {ok, tunnelId, localPort, requestedLocal, fellBack, toHost, toPort, open, launch, _hint}. `localPort` is the actually-bound port (= what mstsc is connecting to). `fellBack:true` means the requested port was unavailable and AD picked ephemeral.
### `refresh_bridges`
Force an immediate wiki sync of the bridge cache (kicad / puppeteer / fusion360 + any installed third-party bridges). v1.7.19+: bridges live as standalone wiki assets (decoupled from the Tauri installer ship cycle) and auto-update on launch + every 4 hours. This verb triggers the sync NOW. v1.8.144+: pass {"name":"<bridge>"} to update JUST that bridge; omit to sync all. Tip: run bridge_check_updates FIRST (read-only) to see what's actually updatable before downloading.
**Args:**
- `name` — optional — bridge name from bridge_list (e.g. 'kicad'). Omit to sync every bridge.
**Returns:** {success:true, results:[{bridge, ok, from, to, action}], _hint}. action ∈ {current, current_offline, current_bundled_newer, bootstrapped, updated}. After 'updated' the bridge SERVER PROCESS (python.exe / node.exe) needs to restart to pick up new code — the cache update is on-disk, not in-memory.
**Note:** Updates the writable cache at %LOCALAPPDATA%\Adom Desktop\bridges-cache\<bridge>\. The bridge servers resolve their payload from there (cache-first, bundled-fallback). To FORCE the running bridge to pick up new cache content, follow with: taskkill /f /im python.exe (kicad+fusion) or taskkill /f /im node.exe (puppeteer). On next *_command call the bridge respawns with the new code.
### `register_container`
v1.8.37+: register THIS cloud Docker container in the user's Adom Cloud profile so Adom Desktop on their Windows laptop auto-discovers + connects after sign-in. Runs entirely on the cloud Docker side — uses /var/run/adom/api-key (the Adom-cloud-mounted session token). PATCHes three carbon-prefs keys: desktop.container.<id> (full ServerEntry as JSON-string), desktop.last_slug, desktop.enabled=true. No relay/desktop dependency. The user's flow: run this verb from cloud Docker → install AD on Windows → sign in → container appears automatically.
**Args:**
- `auth_token` — optional string — relay auth token (defaults to adom-dev-token-2025, which is what the cloud relay accepts by default)
- `auto_connect` — optional bool — auto-dial on AD launch (default true)
- `enabled` — optional bool — enabled in AD's server list (default true)
- `id` — optional string — container id slug (defaults to uuid v4). Stays stable across re-runs if you provide it; the same id PATCHes the same key, overwriting any prior entry.
- `name` — REQUIRED string — human-friendly label for AD's server list (e.g. 'galliaApril')
- `url` — REQUIRED string — public wss:// URL the desktop will dial (typically wss://<container-slug>.adom.cloud/proxy/8765/)
**Returns:** {ok:true, id, name, url, _hint:'now install AD on your Windows laptop + sign in'} on success. On missing args / Carbon failure / api-key unavailable: {ok:false, error, _hint} envelope explaining what to fix.
**User flow:** Cloud Docker: adom-desktop register_container '{"name":"my-container","url":"wss://..."}' → user installs Adom Desktop on Windows from https://wiki.adom.inc/adom/adom-desktop → user clicks 'Sign in' in AD's footer + completes the browser flow → AD's boot-time cloud-config pull (Phase 2) reads desktop.container.* from the user's carbon-prefs + merges into the GUI's server list. Without register_container the user has to paste-JSON manually; with it the container appears automatically.
**See also:** Designed to work with Phase 1 (auth in AD), Phase 2 (cloud-config sync in AD), and Phase 4 (HD→AD session handoff if AD is bundled with Hydrogen Desktop). The full bundle landed across v1.8.33 - v1.8.37.
### `relaunch_self`
v1.8.103+: visible self-restart helper for dev iteration. Spawns the standalone ad-relauncher.exe (a small signed Rust app shipped in the NSIS bundle) which runs in HOST context and drives the redeploy + relaunch sequence: preflight → stop AD → deploy (atomic temp+rename) → launch (CreateProcessW DETACHED_PROCESS) → verify reachable (polls AD's ephemeral direct-api port from the discovery file, v1.8.118+) → verify version (running PE FileVersion matches source) → verify visible (EnumWindows for the new PID). A visible egui window shows per-step progress + a green HOST / red SANDBOX context banner so the sandbox trap is impossible to miss. Replaces all the prior ad-hoc shell glue — no taskkill, no `start`, no PowerShell, no `.bat`. AD itself goes down partway through Stop AD; the relauncher is detached and survives. Poll progress via `relauncher_status` — v1.8.118+ it returns the terminal outcome from a persisted file even after the relauncher's GUI auto-closes, so the poll loop always terminates.
**Args:**
- `destExe` — optional dest path (default: %LOCALAPPDATA%\Adom Desktop\adom-desktop.exe)
- `noAutoStart` — optional bool — if true, the relauncher shows its GUI but waits for POST /start instead of running immediately
- `port` — optional HTTP API port for the relauncher (default: 47210)
- `sourceExe` — optional path to the freshly-built adom-desktop.exe (default: src-tauri/target/release/adom-desktop.exe relative to the relauncher's location)
**Returns:** {ok, success, relauncherPid, relauncherExe, relauncherPort, _hint}
**See also:** relauncher_status (poll progress), skills/AD_RELAUNCH.md (full recipe).
### `relauncher_status`
v1.8.103+: query the ad-relauncher's HTTP API and return its full state JSON. AD proxies GET http://127.0.0.1:47210/status. While AD is going down during Stop AD this call will fail with relauncherRunning:false — that's expected; once AD comes back up post-Launch the status appears again. Returns the relauncher's overall status, the current step, per-step progress + events, stopping/launching VersionInfo (PID, path, FileVersion, sha256, signature, window-visible), HOST/SANDBOX context, and the log tail. Poll every 1-2s.
**Returns:** {ok, relauncherRunning, context:{kind:'host'|'sandbox',package_name?}, overall:'pending'|'running'|'done_ok'|'done_fail'|'cancelled', failedStep, currentStep, steps:[{name,status,progress,message,events}], stopping:{pid,path,fileVersion,sha256,signature,size,windowVisible,windowTitle}, launching:{...same shape}, runId, logTail:[{seq,ts_ms,level,text}], _hint}
**See also:** relaunch_self (spawn), skills/AD_RELAUNCH.md (recipe).
### `server_add`
Register a relay server connection programmatically — equivalent to the GUI's Quick Add paste-box + connect button, but driven by CLI. Upserts by `name`: if a server with the same name already exists, its URL (and optionally authToken) is updated rather than creating a duplicate. By default the connection is established immediately (autoConnect:true). If the URL is unchanged AND we're already connected, no churn — returns ok and leaves the live connection in place. Use this when an external app (e.g. Hydrogen Desktop's Docker container) needs to auto-register its relay at startup without the user pasting JSON. v1.8.22+.
**Args:**
- `authToken` — optional string — defaults to 'adom-dev-token-2025'. Empty string means 'keep existing token if this is an update; otherwise use default'.
- `autoConnect` — optional bool, default true — connect right after upsert. Set false to add the entry without dialling out.
- `name` — required string — human-friendly server label. Acts as the dedup key for upserts.
- `url` — required string — relay WebSocket URL (e.g. ws://localhost:8765 or wss://your-host/proxy/8765/).
**Returns:** {ok:true, name, url, id, connected:bool, created:bool, _hint}. `created` is true when a new entry was inserted, false when an existing one was updated. `connected` is true once the WS loop has been registered (the actual transport handshake may still be pending — call server_list to see the live state).
**Note:** Persists to the same ~/.adom/config.json the GUI uses, so the entry survives a GUI restart. The ws_client supervisor (runs every 30s inside the GUI) auto-reconnects on next launch via the entry's auto_connect flag. To remove: use server_remove '{"name":"<the-name>"}'.
### `server_list`
Return the current persisted server list with live connection status. Same data the GUI's server-list panel renders, exposed as JSON for scripts/external apps. v1.8.22+.
**Returns:** {ok:true, servers:[{name, url, id, autoConnect, enabled, connected:bool, clientCount:0|1, status:'connected'|'reconnecting'|'disconnected'}], count, _hint}. `connected` is the strict boolean (true only when the WS link is in the Connected state, not Reconnecting). `clientCount` is 0 or 1 per server (each adom-desktop contributes at most one client) — left in the schema for symmetry with the relay's own client-count terminology.
### `server_remove`
Disconnect (if connected) and delete a relay server entry by name. Inverse of server_add. Idempotent: removing a non-existent server returns ok with removed:false. v1.8.22+.
**Args:**
- `name` — required string — the same `name` you passed to server_add.
**Returns:** {ok:true, removed:bool, wasConnected:bool, name, _hint}. `removed` is true iff an entry was actually deleted; false means no such server was registered.
### `shell_auto_approve`
Programmatically enable/revoke shell_execute auto-approve. v1.8.3+. v1.8.116+: `permanent` is the DURABLE user preference — it writes AD's Settings checkbox ('Auto-approve shell commands permanently', config-backed, default OFF), so it survives restarts by design. Timed grants (duration_secs) are session-scoped. In embedded mode HD owns this preference: pass `--shell-approval <prompt|1h|24h|permanent>` when spawning AD, and/or drive this verb at runtime; AD's own checkbox goes read-only ('Managed by Hydrogen Desktop').
**Args:**
- `duration_secs` — optional int — seconds to enable for (e.g. 86400 = 24h), session-scoped. Pass 0 to revoke immediately (also clears the durable preference). Ignored if `permanent: true`.
- `permanent` — optional bool — true sets the durable Settings preference + arms it now; false clears it (full off). Default: absent (no preference change for timed grants).
**Returns:** {success, output, active:bool, remainingSecs, permanent:bool}
**Note:** Use sparingly. Auto-approve means anything that can reach the relay can run arbitrary shell commands without user consent. The PUBLIC default is per-command approval — leave it that way for end users; permanent is an explicit owner opt-in. Typical patterns:
- Dev session: `adom-desktop shell_auto_approve '{"duration_secs": 86400}'` (24h, then expires).
- Owner's box, durable: `adom-desktop shell_auto_approve '{"permanent": true}'` (equivalent to ticking the Settings checkbox).
- HD embedded spawn: pass `--shell-approval permanent` (or the user's HD-side choice) on AD's command line — session-only, never persisted by AD.
- Revoke everything: `adom-desktop shell_auto_approve '{"duration_secs": 0}'`.
### `tunnel`
v1.8.85+: open a generic TCP port-forward tunnel from a desktop-side 127.0.0.1:<local> listener to a cloud-side `--to host:port` upstream, riding the existing AD↔relay WebSocket. First use case: RDP back into the laptop's user session via mstsc, but the path is identical for any TCP service (SSH, VNC, HTTP, postgres, ...). No new auth path — the WS is already authed; the tunnel is just bytes routed by tunnelId+connId on top of it.
**Args:**
- `local` — REQUIRED int — the local 127.0.0.1 port the desktop should bind. mstsc / your TCP client connects HERE.
- `to` — REQUIRED string — upstream target in `host:port` form. The relay dials this when bytes arrive on the local port.
- `tunnelId` — optional u32 — caller-chosen ID. Defaults to a fresh random u32. Reuse across re-invocations to address the same tunnel.
**Returns:** {ok, tunnelId, toHost, toPort, clientId, actualPort, error?, _hint}. On success, mstsc/anything connecting to 127.0.0.1:<actualPort> on the desktop has its bytes relayed.
**See also:** rdp (one-shot RDP wrapper), tunnel_list, tunnel_close. For multi-connection tests, every fresh client TCP connection to 127.0.0.1:<local> gets its own connId routed independently.
### `tunnel_close`
v1.8.85+: tear down a tunnel (or one conn within a tunnel). Without `connId`, drops the listener + all conns. With `connId`, drops only that one TCP connection.
**Args:**
- `connId` — optional u32 — if given, close only this conn; otherwise close the whole tunnel
- `tunnelId` — REQUIRED u32 — from `tunnel` / `tunnel_list`
**Returns:** {ok, tunnelId, connId?, scope:'tunnel'|'conn', _hint}
### `tunnel_list`
v1.8.85+: list all active tunnels — both the relay's view and AD's view (should agree).
**Returns:** {ok, relay:[{tunnelId, toHost, toPort, clientId, connCount}, ...], desktop:{tunnels:[...]}, _hint}