name: adom-desktop description: Use when the user wants to send files to their Mac, control KiCad or Fusion 360, send desktop notifications, or troubleshoot the Adom Bridge connection. Provides CLI tools for bridging the Docker container to the user's local machine. The CLI binary is still named adom-desktop.

Adom Bridge

Bridge between Claude Code (running in an Adom Docker container) and the user's Mac via WebSocket. The product is Adom Bridge (formerly named "Adom Desktop"); the CLI binary name is unchanged: adom-desktop. This build is macOS-only (Apple Silicon).

Install surface: the canonical install page is adom/adom-bridge-macos on the Adom wiki. One-line install on the Mac:

curl -fsSL https://wiki.adom.inc/api/v1/pages/adom-bridge-macos/files/install.sh | sh

or download the dmg from that page. Adom Hydrogen also installs Bridge automatically (Hydrogen-managed installs live under ~/Library/Application Support/adom/bridge/). adom-desktop setup_desktop returns the install command and this container's server config.

Bridge is a menu-bar agent. A signed-in launch goes straight to the menu bar: no window, no Dock icon. The window is summoned from the menu-bar item (or by launching the app again) for settings, approvals, and sign-in. First run (signed out) shows the sign-in window. Launch-at-login is on by default via a LaunchAgent (~/Library/LaunchAgents/inc.adom.desktop.plist). One Adom sign-in is shared with Adom Hydrogen through a shared macOS keychain item; the session survives uninstall and reinstall, and sign-out deletes it everywhere.

First-time setup? If the user hasn't installed Bridge yet, see the Setup section below to walk them through installing and connecting the app.

Quick check if desktop is connected:

adom-desktop ping

Companion skills (installed alongside this one from the wiki):

  • adom-desktop-kicad - KiCad bridge: launch editors, open designs, install libraries, run DRC, window capture + keyboard/click automation (plugins/kicad/SKILL.md)
  • adom-desktop-fusion - Fusion 360 bridge: launch, open designs, STEP/GLB/.lbr import-export, BOM + API queries, Fusion screenshots (plugins/fusion360/SKILL.md)
  • pup - browser automation (Puppeteer-style): open URLs, screenshot, eval JS, multi-session Chrome (installed by sync_skills)

How It Works

Claude Code -> adom-desktop <command> -> Relay Server (HTTP :8766) -> WebSocket :8765 -> Adom Bridge App -> KiCad / Fusion 360 / Browser / Shell

The adom-desktop binary is a single Rust CLI that does everything:

  • adom-desktop serve -- Start the relay server (WebSocket :8765 + HTTP API :8766)
  • adom-desktop <command> '<json>' -- Send commands to the desktop app via the running relay

The relay server runs in the Docker container. The Adom Bridge app runs on the user's Mac (as a menu-bar agent) and connects out via WebSocket.

Starting the Relay Server

The relay must be running before the desktop app can connect. In a container, start it SUPERVISED (v1.9.168+):

adom-desktop serve --supervise

Why not adom-desktop serve &? A backgrounded (&) relay is still a child of your shell/session. When your session is torn down (you finish, or the container reaps it), the relay dies with it - and then every connected desktop hits HTTP 500 on /proxy/8765/ and loops "Reconnecting in 30s" for hours until a human restarts it (wiki issue #24, hit live 2026-07-23). --supervise detaches the relay into its own session (reparented to init) so it survives your session, and starts a watchdog that:

  • restarts the relay with exponential backoff (2s → … → 5 min cap; a clean run resets it - a broken relay is never bludgeoned), and
  • toasts the connected desktops on every restart with the reason from the relay log, so the owner finds out something went wrong.
adom-desktop serve --supervise            # detached + self-healing (use this in containers)
adom-desktop serve --supervise --owner AdomLapper   # send restart toasts to one desktop
adom-desktop serve --stop                 # stop the watchdog + relay

serve (plain, foreground) still works and prints a loud hint telling you it will die with the session - fine for a quick local relay you'll sit and watch. Caveat: a full CONTAINER REBOOT still needs a boot hook (cron @reboot / the container's init); the watchdog covers session teardown, not host reboot.

This starts:

  • WebSocket server on 0.0.0.0:8765 (desktop app connects here)
  • HTTP API on 127.0.0.1:8766 (CLI commands go here)

Check if it's running:

curl -sf http://127.0.0.1:8766/health

First-Time Setup (Install & Connect)

You are running on a Docker container. You have NO access to the user's desktop. Guide them step-by-step, ask questions, wait for answers, and verify each step.

As soon as you're connected, run adom-desktop sync_skills (v1.9.37+). Installing the adom-desktop core pkg gives you only CORE skills - it does NOT give you a DYNAMIC/cloud bridge's skill (kicad, fusion, blender, any third-party). sync_skills runs bridge_list and installs each bridge's own skill pkg into ~/.claude/skills/, so you can actually discover + drive whatever bridges THIS desktop has. (The CLI also runs this automatically once/day in the background, but doing it on connect makes the bridge skills available to the current session immediately.) Re-run it any time the desktop gains a bridge; adom-wiki pkg update keeps them fresh.

Step 1: Check if already connected

adom-desktop ping

If this returns { "status": "connected" }, the desktop is already set up -- skip to "Verify the connection" below. If it errors, continue.

Step 2: Ensure the relay is running

curl -sf http://127.0.0.1:8766/health

If not running (in a container, ALWAYS use --supervise so it survives your session): adom-desktop serve --supervise

Step 3: Confirm the machine

Ask the user: "Is your machine a Mac on Apple Silicon?"

  • macOS on Apple Silicon -- proceed. This build of Adom Bridge is macOS-only.
  • Windows / Linux / Intel Mac -- point them at the Windows product page (adom/adom-desktop on the wiki) or tell them support for their platform is separate from this build.

Step 4: Install & connect

Run adom-desktop setup_desktop - it auto-detects this container's relay URL and returns a ready-to-run register_command plus a fallback server_config JSON. Prefer the zero-paste path:


Option 0: Zero-paste via your Adom account (PREFERRED)

Run the register_command that setup_desktop returned (it's adom-desktop register_container with the name + URL filled in). That injects this connection into the user's Adom cloud profile. Then the user just:

  1. Installs Adom Bridge (the one-liner: curl -fsSL https://wiki.adom.inc/api/v1/pages/adom-bridge-macos/files/install.sh | sh, or the dmg from the adom/adom-bridge-macos wiki page) and opens it,
  2. Signs in with their Adom account (first run shows the sign-in window; after that, the app lives in the menu bar),

and the connection appears in their list and auto-connects - no JSON paste at all. This is the modern path and the one to try first. (register_container needs an Adom cloud container - it uses the mounted account token - and returns ok:false on a non-Adom box; if so, use Option A/B below.) One sign-in is shared with Adom Hydrogen via the keychain, so a user who already runs Hydrogen is already signed in.


Option A: Automatic setup via a local agent (paste fallback)

If the user has Claude Code or Claude Desktop on the Mac, they can paste this prompt:

Install Adom Bridge and connect it to my cloud container. (Adom Hydrogen users get Bridge installed and connected automatically; use this prompt when driving the Mac from a cloud container.) Run curl -fsSL https://wiki.adom.inc/api/v1/pages/adom-bridge-macos/files/install.sh | sh, then write this JSON to ~/.adom/config.json:

{"servers":[<server_config JSON>]}

Then open "Adom Bridge" from /Applications.

(Replace <server_config JSON> with the actual value from setup_desktop.)

Option B: Manual setup

  1. Download the dmg from the adom/adom-bridge-macos wiki page and drag Adom Bridge.app to /Applications
  2. Open Adom Bridge; sign in when prompted (or skip to paste JSON)
  3. Summon the window from the menu-bar item if it isn't already showing
  4. Paste this JSON into the text field that says "Paste server JSON to add ..." and press Enter:
    <server_config JSON>
    
  5. The server will appear and auto-connect

Wait for the user to confirm they see a green dot next to the server name before proceeding.

Important: Each container has its own relay server. New containers need a new entry -- old entries from previous containers won't work.

Step 6: Verify the connection

adom-desktop ping
# Expected: { "echo": "pong", "roundTripMs": ..., "status": "connected" }

adom-desktop status
# Expected: one client with the user's hostname and capabilities

adom-desktop notify_user '{"title":"Hello from Docker!","body":"Your desktop is connected."}'

Tell the user what you see. If ping succeeds:

"Your Mac is connected! I can now send files to your machine, open browser windows for visual debugging, control KiCad and Fusion 360, take screenshots of your desktop, and send you notifications."

Step 7: Node.js for browser features

Nothing for the user to do: Bridge manages its own portable Node and Python runtimes (no admin rights, no Homebrew required). If a browser_* command ever reports Node missing, call adom-desktop desktop_install_node '{}' - it fetches the managed runtime and the bridge picks it up on the next call.

Common connection issues

Symptom Fix
ping returns "No desktop client connected" User hasn't added this container in the Bridge app yet
Bridge app shows "disconnected" Check the URL uses wss:// (not ws:// or https://), port is 8765
Relay server not running adom-desktop serve --supervise (survives your session; see Starting the Relay Server)
Multiple stale connections adom-desktop kick_all -- app auto-reconnects within seconds
Bridge app not installed curl -fsSL https://wiki.adom.inc/api/v1/pages/adom-bridge-macos/files/install.sh | sh, or the dmg from the adom/adom-bridge-macos wiki page
App seems "not running" but no window Normal: Bridge is a menu-bar agent with no Dock icon. Look for the menu-bar item; launching the app again summons the window.

CLI Tool

adom-desktop <command> '<json-args>'

Examples:

adom-desktop ping
adom-desktop status
adom-desktop browser_open_window '{"sessionId":"dart2","url":"https://example.com"}'
adom-desktop browser_eval '{"sessionId":"dart2","expr":"document.title"}'
adom-desktop browser_screenshot '{"sessionId":"dart2"}'
adom-desktop browser_list_windows
adom-desktop browser_close_window '{"sessionId":"dart2"}'
adom-desktop notify_user '{"title":"Hello","body":"From Docker"}'
adom-desktop shell_execute '{"command":"echo hello"}'
adom-desktop pull_file '{"filePaths":["/Users/john/Downloads/image.png"],"saveTo":"/tmp"}'

Output: JSON to stdout. Screenshots are auto-pulled to a local cache dir (see "Screenshot cache" below) and the response returns the file path.

Config location: The Bridge app stores server config at ~/.adom/config.json. This is separate from the binary - config survives updates and reinstalls.

Available Commands

Get the full structured command list with descriptions, args, and prerequisites:

adom-desktop commands            # aliases: list_commands, help
adom-desktop --target <name> commands   # + THAT box's installed bridges (installed_bridges_on_target)

Returns categorized JSON with every command, its required/optional args, return values, prerequisites, and workflow notes. Run this FIRST when you're unsure what a verb is called.

Two kinds of verbs (v1.9.103 - don't confuse them):

  • BUILT-IN Bridge-core verbs are always available on every Adom Bridge, no install, NOT bridges. This is where screenshots, windows, input, accessibility (AX), files, shell, tunnel, update, and notify live. A whole-screen shot is desktop_screenshot_screen; one window (with chrome) is desktop_screenshot_window {"hwnd":N} or {"titleContains":"..."} (get window ids from desktop_list_windows; on macOS the hwnd field carries the CGWindowID, same arg name for compatibility).
  • BRIDGE verbs (kicad_/fusion_/browser_/blender_/nbrowser_…) need that bridge installed - see installed_bridges_on_target in commands, or run bridge_list.

If a verb returns errorCode:"unknown_verb", read the error's _hint - it carries a fuzzy "did you mean" (e.g. desktop_screenshotdesktop_screenshot_screen) and tells you whether the right verb is built-in or a bridge. Don't conclude a capability is "a missing bridge" from a guessed verb name; call commands and check.

Connection Management

  • ping -- 5s round-trip test. Use BEFORE browser/shell commands to verify the desktop connection is alive.
  • status -- Check who's connected, their capabilities, desktop paths, and app installation status. The desktop.apps object shows:
    • kicad.installed / kicad.version / kicad.bridgeRunning
    • fusion360.installed / fusion360.running / fusion360.bridgeRunning / fusion360.addinInstalled / fusion360.addinConnected
    • browser.bridgeRunning
  • kick_all -- Force-disconnect all WebSocket clients. Active Adom Bridge apps auto-reconnect within seconds.

Programmatic server registration (v1.8.22+)

Three verbs let external apps (Adom Hydrogen's workspace, in particular) register their relay server with adom-desktop without the user pasting JSON into the GUI by hand. Mirrors the GUI's Quick Add bar + connect/disconnect buttons.

  • server_add -- Upsert a relay server by name. If a server with that name already exists, the URL (and optionally authToken) is updated rather than creating a duplicate.
    adom-desktop server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'
    # → {ok:true, name, url, id, connected:bool, created:bool, _hint}
    
    • name (required) - dedup key. Repeat calls with the same name are idempotent.
    • url (required) - relay WebSocket URL.
    • authToken (optional) - defaults to a dev token (leave unset unless your relay requires one).
    • autoConnect (optional, default true) - connect right after upsert. Set false to add the entry without dialling out.
    • Behavior: same URL + already connected → no churn, returns ok. URL changed + autoConnect=true → disconnect old loop + spawn new one. URL changed + autoConnect=false → disconnect old, leave entry registered but not connected.
  • server_remove -- Disconnect (if connected) and delete an entry by name. Returns {ok, removed:bool, wasConnected:bool}. Idempotent (removing a non-existent entry returns ok with removed:false).
    adom-desktop server_remove '{"name":"hydrogen-workspace"}'
    
  • server_list -- Persisted server list with live connection status: {servers:[{name, url, id, autoConnect, enabled, connected:bool, clientCount:0|1, status}]}. status is the fine-grained connected | reconnecting | disconnected state machine value.

Persists to the same ~/.adom/config.json the GUI uses, so entries survive a GUI restart. The ws_client supervisor (runs every 30s inside the GUI) auto-reconnects entries with autoConnect:true on next launch.

HD-style usage:

# At HD container startup
adom-desktop server_add '{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}'

# At HD container shutdown
adom-desktop server_remove '{"name":"hydrogen-workspace"}'

Addressing multiple Adom Bridges on one relay (v1.8.133+)

One relay can have several Adom Bridges connected at once - e.g. a laptop and an Azure VM, eventually many. Each Bridge has a name (its --client-name / Settings value, defaulting to the OS hostname). The cloud CLI addresses a specific one, or fans out to all:

  • adom-desktop targets - list connected Bridges: {count, targets:[{name, hostname, version, os, connectedAt}]}. Discover names here first.
  • adom-desktop --target <name> <verb> - route the verb to that Bridge only. --target is a global flag (goes before the verb; case-insensitive; matches clientName, else hostname). ADOM_TARGET=<name> sets a default (the flag wins).
  • adom-desktop --target all <verb> - fan out to every connected Bridge; returns {broadcast:true, targets:[...], results:{<name>:<verb-result>}, summary:{total,ok,failed}}. A per-Bridge timeout/failure is isolated to that Bridge's entry.
adom-desktop targets
adom-desktop --target azurevm desktop_screenshot_screen
adom-desktop --target all hd_status        # every box at once, results keyed by name

No-target behavior: with exactly ONE Bridge connected, no --target is needed (back-compat). With MULTIPLE connected and no --target, the call FAILS with {error:"ambiguous_target", connected:[...], _hint} (exit 1) - it won't guess which box. A bad name → {error:"target_not_found", requested, connected:[...]} (exit 1). Set each Bridge's name with the --client-name <name> launch flag (HD passes this when spawning the embedded Bridge), the Settings field, or config.json client_name. Full design + the relay-routing internals: skills/MULTI_AD_ADDRESSING.md.

Direct HTTP API for sibling apps (v1.8.25+)

If you're authoring a sibling Tauri app on the same machine (Adom Hydrogen, or any future "Adom-family" app), you can skip the CLI binary entirely and POST commands straight into the running adom-desktop GUI on 127.0.0.1:47200 (was 127.0.0.1:8770 through v1.8.32 - moved to be a better neighbor to HD on 47080+ and avoid the 8000-range collision risk). The endpoint runs inside the GUI process (loopback-only bind), uses the same dispatcher the WS path uses, and returns the same JSON shape as adom-desktop <verb> - including every _hint field.

Method Path Body Returns
GET /health - {"ok":true,"service":"adom-desktop"} cheap probe
GET /status - service banner + version + schema + directApi.cliRequired list
POST /command {"app":"<ns>","command":"<verb>","args":{...}} The verb's normal payload (200 OK), or {error, errorCode, _hint} (4xx/5xx)

v1.8.33+ port discovery (sibling apps READ THIS): Don't hardcode 47200. The GUI may have bound to 47200-47209 instead if the default port was taken (zombie socket, dev instance, third-party collision). Discovery protocol:

  1. Read ~/.adom/direct-api-port - single-line host:port, written by the GUI at bind time, removed at graceful shutdown
  2. If file missing or its port doesn't /health, scan 127.0.0.1:47200..=47209 for any port answering with {"ok":true,"service":"adom-desktop"}
  3. Validate service == "adom-desktop" to disambiguate from other apps that might bind a port in our range

The CLI does this automatically (see cli/src/direct_probe.rs). For sibling-app Rust code, see skills/DIRECT_API.md for a copy-paste-ready helper.

Per-verb timeouts (v1.8.31+): the direct API's command-timeout is no longer a hardcoded 120s - it mirrors the CLI dispatcher's per-verb table (walk_cloud_tree/search_cloud_files=620s; heavy exports=320s; bridge_install/fusion_start=300s; default=120s). Caller can override via args.timeout (seconds, clamped to 1800).

Example - programmatic server registration without the CLI:

curl -X POST http://127.0.0.1:47200/command \
  -H 'Content-Type: application/json' \
  -d '{"app":"desktop","command":"server_add","args":{"name":"hydrogen-workspace","url":"ws://localhost:8765","autoConnect":true}}'
# → identical JSON to `adom-desktop server_add '{...}'`, including the _hint

What's safe to send directly: essentially everything sync (server_*, bridge_list, hd_status, hd_build_status, desktop_list_windows, desktop_screenshot_*, kicad_*, fusion_*, browser_screenshot, notify_user, ...) plus async-dispatching verbs that return a job id in <500 ms (bridge_install, hd_build, desktop_install_kicad, ...).

What requires the CLI fallback: verbs returning a structured errorCode:"cli_required" from this endpoint - currently pull_file, send_files, shell_execute. These use binary streaming or multi-minute approval polling that doesn't fit a single synchronous HTTP request. GET /status returns the full list at runtime so callers can branch defensively.

Full integration guide (loopback trust model, recipes for HD startup/shutdown, retry/backoff patterns, port-discovery code in Rust + TypeScript) lives in skills/DIRECT_API.md in this repo, and as a wiki asset attached to apps/adom-desktop.

Embedded mode - Bridge managed by Adom Hydrogen (v1.8.42+)

When Adom Hydrogen (HD) manages Bridge, Bridge enters "embedded mode" at boot. HD owns the visible surface (Bridge's menu-bar item is suppressed), HD owns the Adom Cloud login (the shared keychain session), HD owns auto-updates, and HD spawns Bridge hidden (the window opens on demand via desktop_window_show). Standalone Bridge users see zero change - none of the signals fire.

Detection - three-signal cascade (any one triggers embedded mode):

  1. --embedded CLI flag (what HD passes on every spawn)
  2. ADOM_EMBEDDED=1 env var (backup channel)
  3. ~/.adom/embedded.json marker file (survives Bridge restarts)

CLI flags HD passes when spawning Bridge:

"Adom Bridge.app/Contents/MacOS/adom-desktop" --embedded --start-hidden \
  --relay-url ws://127.0.0.1:8765 --relay-name adom-hydrogen \
  --session-token <hd's-stored-token>
  • --embedded → enter embedded mode (also writes the marker)
  • --start-hidden → boot with main window invisible (the normal daemon state; HD owns the surface and opens the window via desktop_window_show). The machine owner can still flip "Always show the window at launch" in Settings (config always_show_window_on_launch, default OFF) - a personal-dev-box preference, not the product default.
  • --shell-approval <prompt|1h|24h|permanent> → HD's passthrough of its user's shell-approval choice (v1.8.116+, session-only)
  • --relay-url <url> → upsert this relay via existing server_add dedup; default name adom-hydrogen
  • --relay-name <name> → override the default name
  • --session-token <tok> → already covered by Phase 4 handoff (env / CLI arg / file)

Docker introspection - adom-desktop desktop_embedded_status returns {embedded, owner, source, enteredVia, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists} so cloud-side callers can branch on whether Bridge is standalone or HD-managed. v1.8.73+: enteredVia reports "launch-flag" (HD spawned Bridge as a child via --embedded) or "runtime-adopt" (Bridge was running standalone; HD adopted it at runtime) - see "Runtime adopt + release" below.

New verbs HD uses to drive Bridge (all also usable standalone):

Verb Purpose
desktop_window_show Bring Bridge's main window to foreground
desktop_window_hide Hide Bridge's main window (app keeps running)
desktop_connect_all Spawn ws_loop for every enabled server
desktop_disconnect_all Drop every live WS connection (entries stay in config)
desktop_shutdown Explicit Bridge termination (stop bridges, then exit)
desktop_embedded_status Introspect current embedded state (incl. enteredVia)
desktop_embedded_enter v1.8.73+ runtime adopt: standalone → embedded as runtime-adopt
desktop_embedded_release v1.8.73+ revert to standalone. v1.8.77+: always reverts, never exits Bridge (regardless of entered_via) - use desktop_shutdown for explicit exit
desktop_logout Clear Bridge's ~/.adom/session.json (HD's sign-out propagation)

Runtime adopt + release (v1.8.73+) - the LaunchFlag-vs-RuntimeAdopt distinction

HD has two ways to put Bridge into embedded mode:

  1. Spawn Bridge with --embedded (LaunchFlag) - HD launches a fresh Bridge process as its child. Bridge enters embedded with entered_via=launch-flag. When HD subsequently dies ungracefully (without calling /embedded/release or desktop_shutdown first), Bridge's 60s embedded_monitor tick notices HD's control port is dead and exits Bridge (the "no hanging chad" rule - no tray-less orphan).
  2. Adopt at runtime via POST /embedded/enter (RuntimeAdopt) - Bridge was already running standalone; HD calls the direct-API endpoint to flip Bridge into embedded mode without a respawn. Bridge enters embedded with entered_via=runtime-adopt, preserving its open state, current PID, all in-flight connections. When HD subsequently dies ungracefully, Bridge's monitor reverts to standalone - menu-bar item restored, footer pill hidden, marker deleted. The Bridge process stays alive.

The rule is simple: Bridge survives its parent unless the spawner explicitly asked otherwise. On ungraceful HD death Bridge reverts to standalone and keeps serving the relay (it is the machine's recovery channel).

Two new direct-API endpoints (v1.8.73+, sugar over POST /command):

# Adopt - runs the same handler as desktop_embedded_enter:
curl -s -X POST http://127.0.0.1:47200/embedded/enter

# Release - runs the same handler as desktop_embedded_release.
# v1.8.77+: ALWAYS reverts to standalone. Bridge never exits via this call.
curl -s -X POST http://127.0.0.1:47200/embedded/release

Probe target (v1.8.75+): the embedded_monitor checks HD's control port (default 47084, discovered from ~/.config/adom-hydrogen/ports.json key control). Same discovery hd_api uses.

Live frontend update (v1.8.76+): the "Embedded · HD" footer pill responds to Tauri mode-changed events emitted on every transition. So if Bridge's window is open when HD adopts it (or HD releases it), the pill flips immediately - no page reload, no manual refresh.

Stale-marker safety (v1.8.74+): a stale entered_via=launch-flag marker with no live HD at boot is cleaned up, so the next launch starts standalone normally.

Shell auto-approve is a user preference (v1.8.116+ - replaces the v1.8.44 embedded-permanent default). The safe default everywhere is per-command approval. Resolution order at Bridge boot:

  1. --shell-approval <prompt|1h|24h|permanent> launch flag - HD's passthrough of its user's choice when spawning Bridge embedded. Session-only: never persisted by Bridge.
  2. The durable Settings checkbox ("Auto-approve shell commands permanently", config field shell_auto_approve_permanent, default OFF). Also set/cleared via shell_auto_approve '{"permanent": true|false}'.
  3. A restored timed grant from a previous session (+1h/+24h buttons, duration_secs calls).
  4. Otherwise: prompt per command.

In embedded mode HD owns the preference (HD is the parent, Bridge the child): Bridge's Settings checkbox goes read-only with "Managed by Adom Hydrogen", and the footer shows "Permanent (managed by Adom Hydrogen)" when armed. HD changes it at runtime with the same verb:

# revoke everything (timed + permanent + the saved checkbox):
adom-desktop shell_auto_approve '{"duration_secs": 0}'

# set the durable preference (equivalent to ticking the Settings checkbox):
adom-desktop shell_auto_approve '{"permanent": true}'

Migration note: pre-1.8.116 embedded boots persisted a permanent grant that leaked into standalone sessions. On first v1.8.116 boot, a permanent-class grant on disk WITHOUT the config preference is cleared - re-opt-in via the checkbox or the verb if you actually wanted it.

⚠ v1.9.90 - a cloud/relay AI CANNOT grant shell approval itself. The shell_auto_approve examples above are HD's (a trusted LOCAL caller) or the human's own. From a relay connection (the cloud AI), a GRANT (shell_auto_approve {"permanent":true} / {"duration_secs":N>0}) is REFUSED with errorCode:"human_only" and instead POPS the human approval prompt in Adom Bridge. The AI's path: call request_shell_approval (or just let the gate/refusal auto-surface the prompt) → ask the user to click Approve 1 hr / 24 hr / Bypass Permissions → RETRY your original verb. Only a human clicking in Bridge (or HD proxying its own user) grants. A REVOKE (duration_secs:0) is allowed for the AI.

Bridge ports are dynamic - you never need to know one (v1.8.31+)

KiCad, Fusion, Browser/Puppeteer, and all third-party bridges now bind OS-assigned ephemeral ports (not the legacy 8772/8773/8851 you may remember). The runtime port changes every spawn. Callers never need to know it.

  • The CLI verb namespace (kicad_*, fusion_*, browser_*, plus any third-party <bridge>_*) is the contract. Always go through that.
  • adom-desktop's direct API forwards to whatever port each bridge is on at the moment of the call.
  • bridge_list reports spawn.runtimePort per bridge for debugging - do NOT hardcode it anywhere.
  • Why this changed: HD's bridges also wanted 8772/8773/8851; we used to collide silently. Dynamic ports = clean coexistence.

Managing bridges (the AI fully controls bridges on its own)

Bridges are Bridge's plugins (kicad, puppeteer, fusion360 ship bundled; others install from the wiki). You - the AI - can see, update, install, remove, pause, and resume them entirely through verbs. No human step is required.

Verb What it does
bridge_list Every installed bridge: name, displayName, version (what Bridge runs now), source (bundled / cache / third_party), paused, spawn kind, verbPrefixes. Start here.
bridge_check_updates READ-ONLY. Per bridge: current vs latest (wiki) + updateAvailable. Downloads nothing. The "what's behind?" probe.
refresh_bridges Apply updates. No args = sync ALL bridges from the wiki. {"name":"kicad"} = update just one.
bridge_install Install a new third-party bridge from a wiki manifest URL ({"manifestUrl":"…/<bridge>-bridge-manifest.json"}). On success:true the capsule appears in the Bridge GUI immediately (no verb call needed; it's gray/idle until first use, then green). On success:false nothing is registered and no capsule appears - read error (a read zip body/builder error means the manifest's zip URL couldn't be fetched). Don't treat a parsed manifest as a completed install; confirm with bridge_list. The manifest url may be relative (resolved against the manifest's dir, v1.8.169+).
bridge_uninstall Remove a bridge ({"name":"…"}). Bundled ones revert to the bundled copy; third-party ones are fully removed.
bridge_pause / bridge_resume Flip a routing flag so a bridge's verbs fail-fast with bridge_paused (or resume). Does NOT kill the process.

Naming convention for published bridges (3rd-party authors, read this): when you publish a bridge to the Adom wiki, its page title / catalog descriptive name MUST start with Adom Desktop - (spaced hyphen; the historical catalog prefix that existing bridge pages use) - e.g. Adom Desktop - Altium Bridge, matching the bundled bridges (Adom Desktop - Puppeteer Bridge, Adom Desktop - KiCad Bridge). This keeps every bridge grouped with consistent names in app-index pages instead of scattering as bare slugs. It applies to the wiki/catalog title only - NOT bridge.json displayName (the in-app chip label, which stays tool-descriptive like Altium Designer) or name (the lowercase slug). Full authoring walkthrough: the adom-desktop-bridge-sdk skill (the adom-desktop-bridges wiki page) + its fill-in-the-blanks templates.

Typical "am I on the latest bridges?" flow:

adom-desktop bridge_check_updates            # read-only: see current vs wiki per bridge
# → {bridges:[{name,current,latest,updateAvailable,source,wikiReachable}], updatesAvailable:N}
adom-desktop refresh_bridges                  # update ALL that are behind
adom-desktop refresh_bridges '{"name":"kicad"}'   # or just one

Version semantics (so the numbers make sense):

  • current = what Bridge actually runs = the newer of the writable cache and the bundled copy (version-aware, numeric semver - 1.0.10 > 1.0.9).
  • A bundled bridge that ships AHEAD of the wiki shows updateAvailable:false - Bridge correctly keeps the newer bundled copy. puppeteer is bundled-only (its node_modules deps exceed the wiki file limit), so it never shows a wiki update; it updates with the Bridge installer.
  • After an updated action, the bridge SERVER PROCESS still holds the old code in memory until it respawns. is_bridge_running is version-aware (v1.8.142+) and forces a respawn on the next verb call, so you normally don't need to kill anything - but if you want it instant, use bridge_kill '{"name":"..."}'.

Auto-update cadence: a running Bridge also auto-syncs bridges on launch + every 4 hours. bridge_check_updates / refresh_bridges (or the GUI's "Check for updates" button next to "+ Bridge") are the on-demand "I just published, pick it up now" path.

Bridge lifecycle - one instance, stable port (v1.8.153+)

A dynamic bridge gets one stable port that Bridge reuses across every spawn and across Bridge restarts (persisted in ~/.adom/bridge-ports.json). A long-lived external client (e.g. a browser extension that dials the bridge) keeps reaching the same port - no chasing a fresh ephemeral port each cycle, no "the client is on bridge A but Bridge routes to bridge B" split. Bridge enforces a single instance: it tracks every spawned bridge PID, reaps a stale/wedged one before respawning on the same port, and bridge_stop_all reaps orphans too.

spawn.persistent (in bridge.json) is now surfaced in bridge_list (spawn.persistent) and means: Bridge auto-respawns this bridge on death, on its stable port (a supervisor runs each ~15 s). So "kill the bridge to load new code" → Bridge brings it right back at the same port the client keeps dialing. bridge_install / refresh_bridges also no longer fail with the cache-dir lock (Bridge reaps the process holding the dir as its cwd, then swaps; in-place merge fallback if a handle lingers).

v1.8.186/187 - first-class bridges (panel + remote management + optional manifest fields). The Bridge window has a full-height Bridges sidebar that shows each bridge as a mini-app (hero image, LED, rich tooltip); clicking one opens a wide pop-out with a live console (its ~/.adom/bridge-logs/<name>.log + the per-bridge command history), a ports/process debug view (live PIDs - >1 flags a runaway), persistent-vs-one-off + codebase-language badges, and all management actions (install/uninstall/kill/restart/pause/resume + "filter activity log to this bridge" + "copy contribution prompt"). The same management is available remotely to cloud threads via verbs: bridge_info/bridge_detail (rich aggregate), bridge_log_read {name, sinceOffset?} (cursor-streaming remote tail -f), bridge_kill (wipe one bridge's processes). As of v1.8.186 all bridges write their per-bridge log (bundled kicad/fusion360/puppeteer included, not just dynamic ones). New optional bridge.json fields (all back-compat, surfaced in the panel): hero (a relative filename on your wiki page, or a full URL - your app-icon; absent → a monogram tile), languages (override the auto-scan, e.g. ["Python"]), releasedAt (ISO date; absent → bridge.json mtime).

v1.8.156 - strays + self-documenting hints. Bridge now tracks every bridge PID it spawns (persisted across Bridge restarts) and reaps all stray instances before respawning - so you don't end up with 4 copies of one bridge fighting over the routing. bridge_list surfaces spawn.instanceCount per dynamic bridge (normally 0 or 1; >1 = strays piled up - visible at a glance). If a bridge verb fails to reach a healthy bridge, the error _hint tells you to retry / check instanceCount / process_list; and a verb response right after a (re)spawn carries an _ad_note saying the external host reconnects within a few seconds - retry instead of treating a transient "not connected" as terminal. For verb discovery from a bridge, GET <ADOM_DIRECT_API_URL>/commands lists every reachable verb (see skills/DIRECT_API.md).

Bridge status chip - self-reported LED + tooltip (v1.8.152+)

Each bridge has a status dot in the Bridge GUI sidebar. Bridge is a pure renderer of bridge health - a live bridge decides its own LED color and hover text; Bridge just paints what the bridge reports. This replaced the old scheme where Bridge inferred the LED from whether it was holding a spawn handle, which left fire-and-forget (dynamic/installed) bridges stuck RED even while healthy and routing.

The contract (all three fields OPTIONAL). On the endpoint Bridge already polls - your bridge.json healthEndpoint (default /health; the sample bridges use /status) - return a 2xx JSON body that MAY include:

Field Type Meaning
led "green" | "yellow" | "red" The dot color. Green = healthy; yellow = degraded/warning; red = the bridge itself knows it's broken (e.g. lost its upstream).
summary string Short label shown next to the dot. Keep it tiny (≤ ~24 chars) - e.g. "2 windows", "ext disconnected".
tooltip string Full hover text. Newlines allowed; no hard length limit (keep it readable).

Rules Bridge enforces:

  • Endpoint returns 2xx with led/summary/tooltip → rendered verbatim, no parsing or logic.
  • Endpoint returns 2xx without them → Bridge defaults to green (you're reachable) + a generic tooltip. Existing bridges work unchanged - this is opt-in.
  • Endpoint unreachable → Bridge owns this ONE state: a gray/offline dot + Bridge's own tooltip ("Not running" / "Bridge not reachable"). A dead bridge can't self-report, so Bridge never lets a bridge claim "offline" - it detects it.

Cadence: Bridge re-polls every ~15 s, and a dynamic bridge flips green the instant it spawns. So a bridge that surfaces live counters (open windows, queue depth, ext-connection state) in summary/tooltip updates in the GUI within a tick.

Reference implementations: scripts/sample-bridges/hello-python/server.py and hello-rust/src/main.rs both self-report led/summary/tooltip on /status - fork either.

File Transfer

  • send_files -- Send files from the Docker container to the desktop. Files are base64-encoded in transit.

    • filePaths: array of absolute paths on the server
    • targetApp: "kicad", "fusion360", or "general"
    • dest: absolute path on the Mac (under a safe root) to land the files exactly where you want (v1.8.148+). Prefer this when you know the target dir.
    • destinationFolder: relative subfolder only (e.g. "kicad/symbols", "fusion"). The Bridge app controls the base directory. Absolute paths are rejected. With neither dest nor destinationFolder, files land in the Mac's Downloads folder.
    • Returns destinationPaths[] with the absolute path of every saved file, plus (v1.8.168) savedToDir (the folder) and a rich _hint. The files land on the MAC, not in your container - verify with list_dir, pull one back with pull_file.
    • send_files only COPIES bytes - it does NOT install or register a bridge. Dropping bridge files with send_files will NOT make a capsule appear or verbs route. To add a bridge use bridge_install '{"manifestUrl":"…/<bridge>-bridge-manifest.json"}', then confirm with bridge_list.
  • pull_file -- Pull files from the Mac to the container.

    • filePaths: array of absolute paths on the Mac
    • saveTo: directory on the container to save files (default: /tmp)
    • Streaming since v1.4.3. Each file is transferred as 1 MiB binary WS frames straight to disk on the Docker side, with incremental SHA256 verification. The legacy 30s base64-JSON path is gone - large files (50 MB+ datasheets, 75 MB reference manuals) no longer time out. Per-file timeout is 600s.
    • Returns files: [{name, path, size, sha256, chunks}]. Use sha256 to verify the transfer (the desktop side computes it during streaming and the container side verifies on completion; mismatch deletes the partial file and reports failure). chunks is the count of 1 MiB binary frames received.
    • When at least one but not all files succeed: success is true, errors[] lists the failures alongside files[]. When ALL fail: success is false.

Desktop Notifications

  • notify_user -- Send a native macOS notification (UNUserNotificationCenter, Adom branding). Rich types: action buttons + a round-trip via notify_response; wait:true blocks until the user answers.
    • title, body, level (info/success/warning/error/emergency), actions/buttons (label buttons), id (correlation id for the round-trip). Some rich fields from the Windows product (toast progress bars, inline inputs) do not exist on macOS; the verb ignores what the platform can't render.
    • Full guide + copy-paste examples per type: the adom-desktop-notify skill (skills/NOTIFY.md).
  • notify_response -- Poll which notification button the user clicked, by correlation id. {pending:true} until they click; {pending:false, action} once they do (consumed on read). notify_events drains the durable click stream.

KiCad (kicad_*) - bridge-owned; light pointer

The KiCad bridge is cloud-owned and ships its OWN full skill (wiki pkg adom/adom-desktop-kicad-bridge). This page keeps only the pointer: run adom-desktop sync_skills after connecting and the full KiCad guide (project workflows, three-tier kicad_open_* routing, DRC recipes, library install) lands in ~/.claude/skills/.

Most-used verbs to get moving:

adom-desktop kicad_open_board '{"path": "/Users/<you>/projects/board/board.kicad_pcb"}'
adom-desktop kicad_screenshot_all '{}'          # every KiCad window incl. error dialogs, as an array
adom-desktop kicad_run_drc '{}'
adom-desktop kicad_window_info '{}'
adom-desktop kicad_open_symbol_editor '{"library": "Device"}'

If KiCad is not installed, the bridge knows how to install it for the user - offer, never point them at a download page. Full details: the bridge's own skill.

Fusion 360 (fusion_*) - bridge-owned; light pointer

The Fusion 360 bridge is cloud-owned and ships its OWN full skill (wiki pkg adom/adom-desktop-fusion-bridge). Run adom-desktop sync_skills and the full guide (cloud files, import/export, add-in troubleshooting, APS search) lands in ~/.claude/skills/.

Most-used verbs:

adom-desktop fusion_start '{}'                       # launches Fusion; slow on cold start (minutes)
adom-desktop fusion_get_app_state '{}'               # the status/poll verb
adom-desktop fusion_addin_status '{}'
adom-desktop fusion_dismiss_blocking_dialogs '{}'
adom-desktop fusion_send_key '{"key": "Escape"}'

Fusion launches take 2-4 minutes cold; respect timeoutSeconds + statusVerb on responses instead of re-issuing. Full details: the bridge's own skill.

Launching apps (generic)

Two generic CLI commands exist for launching any executable or app safely - they verify the target exists BEFORE handing it to the OS, so you never trigger a "not found" dialog:

  • adom-desktop find_exe '{"name":"..."}' -- Resolve a binary by absolute path, glob (newest match wins), or bare name (searches PATH). Returns {path, source}. Does NOT launch.
  • adom-desktop launch '{"path":"...", "args":[...], "cwd":"...", "detached":true}' -- Same resolution rules as find_exe, then spawns. Fails in terminal (exit 1) with a clear error if the path doesn't exist. Always prefer this over a raw shell open.

For Fusion specifically, use fusion_start - it wraps launch plus the full startup-picker / add-in-readiness dance.

  • fusion_import_step -- Import a STEP/STL/IGES file into Fusion 360
  • fusion_open_lbr -- Open an EAGLE .lbr library file
  • fusion_open_electronics -- Check if the Electronics workspace is active
  • fusion_electron_run -- Execute any EAGLE command via Electron.run. Returns rich state: activeWorkspace, activeDocument, commandType, _hint, and fusionOperations (diff of Fusion's internal operation log showing what actually fired). Avoid blocking commands - see list below. Full EAGLE command reference: skills/eagle-commands.md - popular/safe commands split from blocking/modal ones, with layer reference and chaining syntax.
  • fusion_execute_text_command -- Low-level app.executeTextCommand() access. Returns the command result plus workspace context.
  • fusion_board_info -- Get structured board data from the open PCB layout. Returns: component placements (name, package, x, y, rotation), net names, copper traces, layer setup, board thickness, DRC violations. Much richer than a screenshot - gives exact coordinates and connectivity. Requires a .brd board open in PCB Editor.

fusion_electron_run - EAGLE Command Execution

Executes EAGLE commands inside Fusion 360's Electronics workspace. Works in Schematic Editor, PCB Editor (Board Layout), and Electronics Library contexts.

How it works: Sends the command string via Fusion's Electron.run text command. EAGLE's Electron.run is fire-and-forget - it never returns output or throws on invalid commands. To compensate, the handler snapshots Fusion's internal operation log (Diagnostics.RecentOperations) before and after execution, returning a diff showing what actually fired.

Usage:

# Basic command
adom-desktop fusion_electron_run '{"command": "WINDOW FIT"}'

# Multiple commands in sequence (use semicolons)
adom-desktop fusion_electron_run '{"command": "DISPLAY NONE; DISPLAY 1 16 17 18 20 21"}'

# Navigate in library editor
adom-desktop fusion_electron_run '{"command": "EDIT SOIC8.pac"}'
adom-desktop fusion_electron_run '{"command": "EDIT RESISTOR.sym"}'
adom-desktop fusion_electron_run '{"command": "EDIT MYDEVICE.dev"}'

Response fields:

  • activeWorkspace - Current workspace (Schematic Editor, PCB Editor, Electronics Library)
  • activeDocument - Name of the open document
  • commandType - Detected type: view_control, layer_control, edit, design_rule, etc.
  • editorType - For EDIT commands: package, symbol, device
  • fusionOperations - Array of Fusion operations that fired (diff of internal log)
  • hint - Human-readable description of what happened
  • rawResult - Raw return from Electron.run (usually empty)

EAGLE Command Reference - Safe for automation:

Command Context Description
View / Navigation
WINDOW FIT Any Zoom to fit all content
WINDOW (x1 y1 x2 y2) Any Zoom to specific area (coordinates in current units)
DISPLAY ALL Any Show all layers
DISPLAY NONE Any Hide all layers
DISPLAY 1 16 17 18 20 21 Board Show specific layers by number
Grid
GRID MM 0.1 Any Set grid to 0.1mm
GRID MIL 25 Any Set grid to 25mil
GRID INCH 0.05 Any Set grid to 0.05 inch
Board Layout
RATSNEST Board Recalculate airwires (unrouted connections)
RIPUP Board Remove all routed traces
RIPUP * Board Remove all traces (same as RIPUP with no selection)
ROUTE Board Start auto-router
DRC Board Run design rule check
BOARD Schematic Switch to paired board layout
SCHEMATIC Board Switch to paired schematic
Schematic
VALUE value Schematic Set component value
NAME name Any Rename selected element
SMASH Any Detach name/value labels from components
Library Editor
EDIT name.pac Library Open a package (footprint) for editing
EDIT name.sym Library Open a symbol for editing
EDIT name.dev Library Open a deviceset for editing
EXPORT SCRIPT 'path.scr' Library Export entire library as EAGLE script
Scripting / Settings
SET CONFIRM YES Any Suppress confirmation dialogs
SET CONFIRM OFF Any Re-enable confirmation dialogs
SCRIPT 'path.scr' Any Run batch commands from a .scr script file

EAGLE Layer Numbers (commonly used):

Layer Name What it shows
1 Top Top copper
16 Bottom Bottom copper
17 Pads Through-hole pads
18 Vias Via holes
19 Unrouted Airwires (ratsnest)
20 Dimension Board outline (required for 3D)
21 tPlace Top silkscreen
22 bPlace Bottom silkscreen
25 tNames Top component names
27 tValues Top component values
29 tStop Top solder mask
31 tCream Top stencil/paste
51 tDocu Top documentation

Blocking commands - AVOID from automation:

Command Why it blocks
WRITE Opens Save As dialog - use fusion_save_lbr or fusion_close_document instead
ADD Opens component picker dialog
SHOW name Opens interactive highlight mode
SET (no params) Opens settings dialog
GRID (no params) Opens grid settings dialog
EDIT new.sym Opens "Create new?" confirmation if symbol doesn't exist
CHANGE Opens interactive change mode
MOVE Opens interactive move mode

Tips:

  • Always run WINDOW FIT after opening a file or switching views
  • Use DISPLAY NONE then DISPLAY <layers> to show only specific layers
  • Combine commands with ; - e.g., SET CONFIRM YES; RIPUP *; RATSNEST
  • For board screenshots: DISPLAY NONE; DISPLAY 1 16 17 18 20 21; WINDOW FIT
  • Use fusion_board_info instead of EAGLE commands when you need structured data
  • fusion_export_lbr -- Export the open Electronics library as an EAGLE .scr script (note: does NOT include 3D package references - those are cloud-linked only)
  • fusion_save_lbr -- Save the open Electronics library as a .flbr file

EAGLE Libraries with 3D Packages

Fusion 360's .lbr format supports package3d elements that link footprints to 3D models. Key facts:

  • 3D models are cloud-hosted - each package3d has a wip_urn (e.g., urn:adsk.wipprod:fs.file:vf.xxxxx) pointing to a Fusion cloud document. You cannot embed STEP files directly in .lbr XML.
  • Creating 3D packages requires the Fusion UI - use Package3DCreateCmd in Electronics Library Editor, which opens a new Design workspace where you model/import the 3D shape, then save to link it.
  • EXPORT SCRIPT strips 3D references - the .scr export only contains 2D data (symbols, footprints, devicesets). To preserve 3D links, keep the .lbr XML format.
  • Fusion's built-in examples have 3D packages - 34 of 37 libraries in the EAGLE examples directory (e.g., Connector_USB.lbr, Resistor.lbr, Capacitor.lbr) include package3d references.
  • Example libraries location (macOS): under the Fusion install's .../Applications/Electron/LibEagle/examples/libraries/examples/ (find the install with find_exe or ask the bridge; the webdeploy hash changes per build)

To open a built-in example library for reference:

# Find the examples directory first
adom-desktop fusion_execute_text_command '{"command": "Python.RunScript /tmp/find_eagle_libs.py"}'
# Then open one
adom-desktop fusion_open_lbr '{"filePath": "<path>/Connector_USB.lbr"}'
  • fusion_open_schematic -- Open a .sch schematic in Fusion's Schematic Editor. Args: filePath.
  • fusion_open_board -- Open a .brd board layout in Fusion's Board Layout editor. Args: filePath.
  • fusion_show_3d_board -- Switch to 3D PCB board view (must have a .brd open). Board MUST have an outline on layer 20 (Dimension) or 3D generation fails. Auto-zooms to fit after switching.
  • fusion_show_2d_board -- Switch back to 2D board layout from 3D PCB view. EAGLE commands via fusion_electron_run only work in 2D.
  • fusion_close_document -- Close a document without save dialog. Args: name (optional, defaults to active doc), save (optional, default false). Essential for automation - avoids modal save dialog that blocks Fusion.
  • fusion_document_info -- List ALL open documents/tabs with name, type, and active status, plus detailed cloud info for the active document. Returns openDocuments array (every tab) and active doc details (cloud project, folder, file ID, version, save status). Lightweight - uses only in-memory data, no cloud API calls. Use this instead of fusion_walk_cloud_tree when you just need to know what's open.
  • fusion_activate_document -- Switch to a specific open document tab. Args: name (substring match, case-insensitive), documentType ("Electronics", "PCB", "FusionDesign", "Drawing"). Essential for automation - switch between schematic, board, and library tabs without user interaction. If no match found, returns the list of open documents so you can refine.
  • fusion_close -- Close Fusion 360. Always call this when done with Fusion 360 commands to clean up.
  • fusion_dismiss_recovery -- Dismiss recovery document dialogs (both "Recovered Documents" list and "Open recovery document instead?" prompts). Also relocates recovery files to ~/.adom/recovery/fusion/ for safekeeping.
  • fusion_relocate_recovery -- Proactively move Fusion crash recovery files to ~/.adom/recovery/fusion/<timestamp>/ without dismissing any dialogs. Call this before launching Fusion to prevent recovery dialogs from appearing. Files are preserved (not deleted) so the user can manually restore them if needed.
  • fusion_close_all_documents -- Close all open documents. Args: saveChanges (default: false). Use before force-killing Fusion to prevent recovery files.

Fusion 360 UI Interaction

Commands for interacting with Fusion 360's UI - detecting dialogs, taking screenshots, clicking, and sending keyboard input. Essential for handling blocking dialogs and automating CEF-based UI elements.

  • fusion_window_info -- Returns the Fusion main window HWND, title, rect, and a list of all Qt dialog windows (recovery dialogs, wizards, file pickers). Essential for detecting blocking dialogs before/after operations.

    • Example: adom-desktop fusion_window_info
    • Returns: {hwnd, title, rect, dialogs: [{hwnd, title, className, rect}]}
  • fusion_screenshot_fusion -- Captures the Fusion main window or a specific dialog by window id. Background capture (works without bringing to foreground). Saves WebP to /tmp/adom-desktop-screenshots/ (falls back to PNG if WebP unavailable). Downscaled to ≤1568px - use relative coords (0.0-1.0) for clicking, they're scale-independent. DPI-aware.

    • Args: hwnd (optional - dialog HWND to screenshot instead of main window)
    • Example: adom-desktop fusion_screenshot_fusion (main window)
    • Example: adom-desktop fusion_screenshot_fusion '{"hwnd": 12345}' (specific dialog)
  • fusion_screenshot_all -- Screenshots the main Fusion window and lists all dialog windows with their HWNDs. Use fusion_screenshot_fusion {"hwnd": ...} to capture each dialog.

    • Example: adom-desktop fusion_screenshot_all
  • fusion_click_fusion -- Click at coordinates within the Fusion window or a specific dialog. x/y are relative (0.0-1.0) by default. Set "relative": false for pixel offsets. Uses synthetic OS input for CEF dialog compatibility. Prefer fusion_send_key for dialogs - enter/escape/tab covers most cases.

    • Args: x (required), y (required), relative (optional, default true), hwnd (optional - target a specific dialog HWND instead of main window)
    • Example: adom-desktop fusion_click_fusion '{"x": 0.5, "y": 0.7}' (main window)
    • Example: adom-desktop fusion_click_fusion '{"hwnd": 12345, "x": 0.75, "y": 0.85}' (dialog button)
  • fusion_send_key -- Send keyboard input to Fusion or a specific dialog via synthetic OS input. Preferred way to dismiss dialogs: enter to confirm, escape to cancel, tab to cycle buttons. Use click only if tab order doesn't reach the right button.

    • Args: key (required), hwnd (optional - target a specific dialog HWND)
    • Example: adom-desktop fusion_send_key '{"key": "escape"}' (dismiss CEF dialog)
    • Example: adom-desktop fusion_send_key '{"key": "enter", "hwnd": 12345}' (confirm dialog)

Fusion 360 Workflow Guide

Opening Electronics Projects
  1. Open the .fprj file: adom-desktop fusion_open_cloud_file '{"projectName":"Main","fileName":"DRV8411A","fileExtension":"fprj","folderPath":"Molecules/XRP/DRV8411A"}'
  2. Do NOT try to open .fbrd or .fsch directly - they fail or show a "Select Electronics Design File" dialog
  3. Enter the board editor: adom-desktop fusion_show_2d_board
  4. Enter the schematic editor: After step 3, use adom-desktop fusion_electron_run '{"command":"EDIT .sch"}'
  5. Switch back to board: adom-desktop fusion_electron_run '{"command":"EDIT .brd"}'
Auto-Screenshot on Open Commands

Open commands automatically screenshot Fusion and return the images in the response. The following commands include postOpenScreenshot in their data field:

  • fusion_open_cloud_file, fusion_open_schematic, fusion_open_board, fusion_show_3d_board, fusion_show_2d_board

The response data.postOpenScreenshot contains:

  • screenshots[] - array of {type, savedTo, sizeKB, title?, hwnd?}. Type is "main_window" or "dialog".
  • message - human-readable instruction to READ each screenshot and check for blocking dialogs
  • dialogBlocking - true if a modal dialog was detected

After receiving the response, you MUST:

  1. READ each screenshot file using the Read tool to visually inspect what Fusion shows
  2. Look for blocking dialogs in the screenshots:
    • "What to design?" wizard → dismiss with fusion_send_key {"key": "escape"}
    • "PCB out of date" banner → click Update or X
    • "Recovered Documents" → fusion_dismiss_recovery
    • "Save changes?" → fusion_send_key {"key": "escape"}
    • Any other modal → identify and dismiss
  3. Screenshot again after dismissing to confirm it's clear

Screenshots are saved as WebP (lossless, ~20-40KB each, downscaled to ≤1568px) for token efficiency. Both the main Fusion window AND all Qt dialog windows are captured separately.

Why this matters: Fusion shows Qt dialogs and CEF overlays that are invisible to the API. The success: true response from an open command does NOT mean the UI is ready - a dialog may be blocking all further operations. The API cannot detect these. Only a screenshot can.

Detecting and Handling Blocking Dialogs
  • The auto-screenshot captures dialog windows separately (look for type: "dialog" entries in postOpenScreenshot.screenshots[])
  • Common blocking dialogs: "What do you want to design?", "Recovered Documents", "Open recovery document instead?", "Select Electronics Design File"
  • Dismiss recovery dialogs: adom-desktop fusion_dismiss_recovery
  • Dismiss CEF dialogs (inside main window): adom-desktop fusion_send_key '{"key": "escape"}' or fusion_click_fusion
  • Dismiss "What do you want to design?" wizard: adom-desktop fusion_send_key '{"key": "escape"}'
  • For manual screenshot of specific dialogs: adom-desktop desktop_screenshot_window '{"hwnd": <DIALOG_HWND>}'
Preventing Recovery Documents
  • Recovery files are at: ~/Library/Application Support/Autodesk/Autodesk Fusion 360/<USER_ID>/CrashRecovery/
  • Before force-killing Fusion, close all documents: adom-desktop fusion_close_all_documents '{"saveChanges": false}'
  • Best practice: Call adom-desktop fusion_relocate_recovery before launching Fusion. This moves recovery files to ~/.adom/recovery/fusion/<timestamp>/ - preserving them for the user while preventing modal dialogs.
  • The fusion_start command also auto-relocates recovery files before starting Fusion.
  • The fusion_dismiss_recovery command handles both "Recovered Documents" list and "Open recovery document instead?" prompts, and also relocates files.
EAGLE Export Limitations
  • Supported: EXPORT IMAGE, EXPORT NETLIST, EXPORT PARTLIST
  • NOT supported (fail silently): EXPORT DXF, EXPORT SVG, EXPORT DRILL
  • Always verify export output: the updated fusion_electron_run now checks if the output file was created
  • Use fusion_export_bom and fusion_export_cpl for manufacturing data (these use the add-in's XML parser, not EAGLE export)
  • Use fusion_export_gerbers for Gerber files - produces a ZIP with all gerber layers (GTL, GBL, GTS, GBS, GTP, GBP, GTO, GBO, GKO, XLN). Auto-detects 2-layer vs 4-layer boards.
3D Model Exports
  • From the 3D view (activate the .f3d document): fusion_export_step, fusion_export_stl, fusion_export_3mf, fusion_export_f3d, fusion_export_usdz, fusion_export_iges, fusion_export_sat
  • Switch to 3D view: fusion_show_3d_board or activate the .f3d document
  • STEP - Industry-standard CAD interchange (SolidWorks, CATIA, Creo). Highest geometric fidelity.
  • IGES - Legacy CAD interchange. Use STEP for modern workflows.
  • SAT - ACIS solid model format. Used by SolidWorks, SpaceClaim.
  • STL - Mesh format for 3D printing and visualization. Options: refinement "low"/"medium"/"high".
  • 3MF - Modern 3D printing with color/material support and multi-body.
  • F3D - Native Fusion 360 archive. Preserves parametric features, sketches, timeline, component refs. Best for archival.
  • USDZ - Best for digital twins and GLB conversion. Preserves full component hierarchy (Board, copper layers, soldermask, Packages), PBR materials, and named nodes. Each component becomes a toggleable node in GLB viewers. Also viewable directly on iOS/macOS (Apple Quick Look / AR).
    • Digital twin pipeline: fusion_export_usdz → pull_file → blender --background --python-expr "import bpy; bpy.ops.wm.usd_import(filepath='board.usdz'); bpy.ops.export_scene.gltf(filepath='board.glb')"
  • FBX - Not available in current Fusion builds via the API. The command exists but fails clearly. Use fusion_export_usdz instead.
  • DXF/DWG/OBJ/SKP - Not available via the Fusion API (dialog-only). Commands exist but return clear errors with alternatives.
3D Viewport Captures
  • fusion_take_screenshot - Capture the Fusion viewport at any resolution without opening a dialog. Uses Fusion's render API (saveAsImageFile).
    • Args: outputPath (required), width (default 1920), height (default 1080), orientation (optional)
    • Orientations: home, front, back, top, bottom, left, right
    • Example: Capture all 6 standard views for use as product images/icons:
      for orient in home front back top bottom left right; do
        adom-desktop fusion_take_screenshot "{\"outputPath\": \"/tmp/3d-${orient}.png\", \"width\": 1920, \"height\": 1080, \"orientation\": \"${orient}\"}"
      done
      
Electronics Source File Export (fusion_export_source)
  • fusion_export_source - Export the active electronics document as .fsch, .fbrd, or .flbr source file.

    • Args: outputPath (required - full path with extension)
    • The extension determines the format: .fsch (schematic), .fbrd (board), .flbr (library)
    • Validates extension, creates output directory, verifies file was created, returns file size
  • Full workflow to export both board and schematic from a cloud project:

    # Step 1: Open the .fprj (NOT .fbrd/.fsch directly - those fail)
    adom-desktop fusion_open_cloud_file '{"projectName":"Main", "fileName":"MyDesign", "fileExtension":"fprj", "folderPath":"Molecules/MyDesign"}'
    
    # Step 2: Screenshot to check for blocking dialogs (always do this after open)
    adom-desktop fusion_screenshot_fusion
    
    # Step 3: Enter board view (MUST be Board Layout workspace, not 3D)
    adom-desktop fusion_show_2d_board
    
    # Step 4: Export .fbrd (board first - it's already in board view)
    adom-desktop fusion_export_source '{"outputPath": "/tmp/exports/MyDesign.fbrd"}'
    
    # Step 5: Switch to schematic (EDIT .s1 = first schematic sheet)
    adom-desktop fusion_electron_run '{"command": "EDIT .s1"}'
    
    # Step 6: Export .fsch
    adom-desktop fusion_export_source '{"outputPath": "/tmp/exports/MyDesign.fsch"}'
    
    # Step 7: Pull files to Docker
    adom-desktop pull_file '{"filePaths":["/tmp/exports/MyDesign.fbrd","/tmp/exports/MyDesign.fsch"], "saveTo":"/tmp/exports"}'
    
  • Critical gotchas:

    • You MUST open the .fprj first - opening .fbrd/.fsch directly fails or triggers blocking dialogs
    • You MUST call fusion_show_2d_board before exporting .fbrd (the Electronics Design overview won't work)
    • Always export .fbrd FIRST (from board view), then switch to schematic for .fsch
    • If export fails with "file not found", the wrong workspace is active - screenshot to verify
    • After fusion_open_cloud_file, always screenshot to check for "Select Electronics Design File" dialog
    • WRITE command is blocked - it opens a blocking save dialog. Use fusion_export_source instead
  • Note: WRITE is blocked - it opens a "Version Description" dialog on cloud docs even with a path argument (tested 2026-04-10). Use fusion_save_to_cloud to save to cloud, fusion_export_source for Fusion-format, or fusion_export_eagle_source for plain EAGLE format.

Plain EAGLE Source Export (fusion_export_eagle_source)
  • fusion_export_eagle_source - Export the active electronics document as plain EAGLE XML .sch or .brd.

    • Args: outputPath (required - full path ending in .sch or .brd)
    • Internally: exports .fsch/.fbrd via Document.CopyToDesktop, then extracts the EAGLE XML from the ZIP container, cleans up the temp file.
    • The output is valid EAGLE XML (<?xml><eagle version="9.7.0">...) parseable by standalone EAGLE, KiCad import, or any XML tool.
    • Same workflow/prerequisites as fusion_export_source - just use .sch/.brd extensions instead of .fsch/.fbrd.
    # Board (.brd) - must be in PCB Editor / Board Layout
    adom-desktop fusion_show_2d_board
    adom-desktop fusion_export_eagle_source '{"outputPath": "/tmp/exports/MyDesign.brd"}'
    
    # Schematic (.sch) - must be in Schematic Editor
    adom-desktop fusion_electron_run '{"command": "EDIT .s1"}'
    adom-desktop fusion_export_eagle_source '{"outputPath": "/tmp/exports/MyDesign.sch"}'
    
Dialog Dismissal (fusion_close_window)
  • fusion_close_window - Close a specific Fusion dialog by sending WM_CLOSE (equivalent to clicking X).
    • Args: hwnd (required - from fusion_window_info or fusion_dismiss_blocking_dialogs remaining[])
    • Works on dialogs that Escape doesn't close - e.g. "Recovered Documents"
    • Does NOT force-kill - the dialog can still intercept WM_CLOSE
Electronics Import (Round-Trip)
  • fusion_import_electronics - Import Fusion-native .fsch, .fbrd, or .flbr files as new local documents
    • Uses Document.newDesignFromLocal under the hood
    • Auto-screenshots after import (catches blocking dialogs)
    • Args: filePath (required path on the Mac to .fsch, .fbrd, or .flbr)
    • After import, use fusion_save_to_cloud to persist to Fusion cloud
    • .fsch import works standalone - creates a new schematic project
    • .fbrd import may fail - boards have external references to schematics/libraries. Error: "New design cannot be created from a local file containing external references"
    • .flbr import works standalone - creates a new library project
    • For legacy EAGLE files: use fusion_open_schematic (.sch), fusion_open_board (.brd), fusion_open_lbr (.lbr)
Library Round-Trip Workflow

Export and re-import Fusion electronics libraries:

# 1. Open library from cloud
adom-desktop fusion_open_cloud_file '{"projectName":"Main","fileName":"Adom Common Components","folderPath":"Molecules/Libraries"}'

# 2. Export as .flbr (Fusion-native binary) and .scr (EAGLE script text)
adom-desktop fusion_save_lbr '{"outputPath":"/tmp/library.flbr"}'
adom-desktop fusion_export_lbr '{"outputPath":"/tmp/library.scr"}'
adom-desktop fusion_close_document

# 3. Re-import the .flbr
adom-desktop fusion_import_electronics '{"filePath":"/tmp/library.flbr"}'

# 4. Verify symbols survived round-trip
adom-desktop fusion_export_lbr '{"outputPath":"/tmp/library-verify.scr"}'

# 5. Save to cloud with new name
adom-desktop fusion_save_to_cloud '{"name":"Library-copy"}'
Demo Projects

171+ electronics molecules are exported in the adom-desktop-demo repo (separate from adom-desktop). Three reference boards with full export formats:

Board Cloud Path Layers Components Files
DRV8411A Main / Molecules / XRP / DRV8411A 4 29 27
DRV8323SR Main / Molecules / Experiments / MotorControl / DRV8323SR 2 43 27
VL53L8BreakoutMolecule Main / Molecules / XRP / TimeOfFlightVL53L8 / VL53L8BreakoutMolecule 2 30 27

Each folder contains: .fsch, .fbrd, bom.csv, cpl.csv, gerbers.zip, 6 board images, 7 3D renders (home/front/back/top/bottom/left/right), STEP, IGES, SAT, STL, 3MF, F3D, USDZ, board screenshot, 3D board screenshot.

Cloud Document Management

Manage Fusion 360 cloud documents (hub projects, files, versions). Required for 3D package workflows since EAGLE library 3D models are stored as cloud documents.

  • fusion_save_to_cloud -- Save the active Fusion document to the cloud. Args: name (required), projectName (optional, defaults to active project), folderPath (optional), description (optional). Returns: fileId, versionNumber, wipUrn (if cloud-hosted).
  • fusion_list_cloud_projects -- List all cloud projects in the user's hub. Returns array of {name, id} per hub.
  • fusion_list_cloud_files -- List files in a cloud project/folder. Args: projectName (optional), folderPath (optional). Returns: files array with {name, id, versionNumber, fileExtension, dateModified} and subfolders array.
  • fusion_create_cloud_folder -- Create a folder in a cloud project. Args: folderName (required), projectName (optional), parentPath (optional). Returns folderId. Idempotent - returns existing folder if it already exists.
  • fusion_check_recovery -- Check if a cloud file has a recovery document from a previous crash. Args: fileName (required), projectName (optional), folderPath (optional). Returns hasRecovery: true/false. Use this BEFORE opening files to avoid the blocking "Open recovery document instead?" dialog. If recovery exists, call fusion_open_cloud_file with recovery: "open" (restore unsaved work) or recovery: "discard" (delete recovery, open cloud version).
  • fusion_open_cloud_file -- Open a cloud file in Fusion by name. If a recovery document exists and no recovery arg is given, the command STOPS and reports the recovery instead of opening - you must decide whether to preserve or discard unsaved work. Args: fileName (required), projectName (optional), folderPath (optional), recovery ("open" = restore unsaved work, "discard" = delete recovery and open cloud version).
  • fusion_export_cloud_file -- Export the active Fusion document to a local file for transfer back to Docker. Args: outputPath (required), format (optional, default "step"). The exported file can then be pulled back to Docker via pull_file.
  • fusion_delete_cloud_file -- Delete a cloud file by name. Args: fileName (required), projectName (optional), folderPath (optional). File must not be open in Fusion - close it first with fusion_close_document.
  • fusion_walk_cloud_tree -- Long-running. BFS walk of a cloud folder tree. Returns a flat list of all files and folders. Runs entirely on the Fusion main thread - blocks all other add-in commands until done (check progress with fusion_addin_status).
    • Args: projectName (optional), folderPath (optional, starting folder), maxDepth (default 10), maxFolders (default 500), extensions (optional list, e.g. ["f3d","fprj"]), nameContains (optional substring filter), includeFiles (default true)
    • Returns: {project, rootFolder, folders[], files[], stats: {foldersVisited, foldersSkipped, filesFound, maxDepthReached, truncated}}
    • Per-folder timeout (30s): If a single folder's cloud API calls take >30s (common for large projects), the folder is skipped and counted in foldersSkipped. This prevents indefinite hangs.
    • Progress tracking: While running, fusion_addin_status returns walkProgress with {foldersVisited, filesFound, currentFolder, queueSize} - poll this every 1–10s to monitor progress (see "Live folder progress streaming" below).
    • Non-blocking alternative: Use fusion_search_cloud_files for targeted searches, or fusion_list_cloud_files for single-folder listings (these are faster but don't recurse).
    • Example: adom-desktop fusion_walk_cloud_tree '{"projectName":"Main","folderPath":"Molecules","nameContains":"DRV","extensions":["fprj"]}'

Live folder progress streaming with watch

For long walks, use the built-in watch wrapper. It spawns the inner search command on a worker thread, polls fusion_addin_status internally, and emits one JSON event per line to stdout as the walker visits each folder. No manual polling loop needed.

adom-desktop watch '{"command":"fusion_walk_cloud_tree","args":{"projectName":"Main","folderPath":"Molecules","nameContains":"BQ25792"}}'

Output is one JSON object per line, three event types:

{"event":"started","command":"fusion_walk_cloud_tree","args":{...},"interval":2,"_hint":"streaming progress events follow, one per line, ending with 'complete' or 'error'"}
{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":15.0,"foldersVisited":14,"queueSize":50,"filesFound":0,"currentFolder":"Molecules/Sensing"}
{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":23.4,"foldersVisited":22,"queueSize":108,"filesFound":0,"currentFolder":"Molecules/Examples"}
{"event":"progress","busyCommand":"walk_cloud_tree","elapsedSeconds":31.8,"foldersVisited":30,"queueSize":100,"filesFound":0,"currentFolder":"Molecules/RAPID NAME TAGS/Connor Wood"}
... (one per real change in walkProgress, deduped) ...
{"event":"complete","result":{"folders":[...],"files":[...],"stats":{"foldersVisited":169,"filesFound":12,"truncated":false}}}

Read stdout line-by-line. Stop when you see event:complete or event:error. The full final result is in the complete event's result field.

Optional interval arg (default 2s, clamped to [1, 30]):

adom-desktop watch '{"command":"fusion_walk_cloud_tree","args":{...},"interval":1}'

For Claude Code consumers: pipe watch directly into the Monitor tool. Each JSON line becomes a real-time event notification in the chat - you (and the user) see folder names appear one-by-one as the walker visits them. No bash loop, no disown, no subprocess gymnastics. Verified live in 1.3.16 on a 169-folder walk that returned all 12 BQ25792 cloud files cleanly.

Watchable commands (whitelist): fusion_walk_cloud_tree, fusion_search_cloud_files. Other commands return success:false with a _hint listing the watchable set.

When NOT to use watch: short single-folder operations (fusion_list_cloud_files, fusion_open_cloud_file) - those return in <2s and don't need streaming. The watch wrapper is purely for the long BFS commands.

  • fusion_search_cloud_files -- Long-running. Recursive substring search on file names across cloud folders. v1.0.2+ of the add-in adds: per-folder timeout, doEvents() every 50 files, per-file try/except, iterative BFS, and a stale-lock watchdog - Fusion stays responsive throughout (no "Not Responding" freeze).
    • Args (no hard upper caps in v1.0.2+): query (required, substring, case-insensitive), projectName (optional, defaults to active), folderPath (optional starting subfolder - narrow with this), recursive (default false), maxDepth (default 2), maxFolders (default 10), maxResults (default 20), folderTimeout (default 30s), searchTimeout (default 120s), timeout (HTTP envelope, default 620s).
    • Before calling: if the user knows roughly where the file is (e.g. customer-named folder), ask them. Naming a subfolder cuts 5-40 min searches down to ~10s.
    • Why slow: Autodesk's free Fusion 360 Python API has no indexed file-search endpoint. We walk the Data API one folder at a time - each = one HTTP round-trip to Autodesk. This is an Autodesk API limitation, not adom-desktop's. When telling the user the search is taking a while, attribute it to Autodesk / Fusion 360, not to adom-desktop's bridge. There is now a FAST native alternative - aps_search (see "APS - fast indexed cloud search" below). It hits Autodesk Platform Services' indexed Data Management API and returns ~instantly, even when Fusion is CLOSED. Call aps_status first; if configured:true, prefer aps_search and only fall back to this folder-walking verb when APS isn't set up.
    • Interpreting results (critical for no-false-negatives): the response contains searchComplete (bool), foldersSkipped, filesSkipped, truncated, folderLimitReached, searchTimedOut. searchComplete:true ONLY when all five are clean - that's the confidence flag. If searchComplete:false, the search hit a cap before exhausting the scope; don't report "file not found" - re-run with broader caps OR narrower folderPath.
    • Case sensitivity: fully case-insensitive both directions (query and file names lowercased). Substring match, not whole-word - "cosm" matches COSMIIC, COSMOCOIL, etc.
    • Cost feedback in response: costAnalysis: {elapsedSeconds, foldersPerSecond, estimatedSecondsPer100Folders} so the AI can budget the next search realistically.
    • Real numbers from a live test: searching Main/Molecules recursively (173 folders deep, max 8 depth) for "cosmiic" returned in 168 s with searchComplete:true, totalFound:3. Fusion stayed main_thread:responsive throughout.
    • Pair with watch for streaming progress updates: see below.

APS - fast indexed cloud search (v1.8.182+)

aps_search is a native, ~instant alternative to the folder-walking fusion_search_cloud_files. It queries Autodesk Platform Services (APS, formerly Forge) Data Management API, which has a real server-side index - so a search that takes minutes by folder-walking returns in well under a second, and it works even when Fusion 360 is closed (it's pure HTTPS, no bridge required). adom-desktop runs its own 3-legged PKCE OAuth, so the user's APS client secret never leaves their machine.

One-time setup (user side):

  1. Register a free app at https://aps.autodesk.com → enable the Data Management API.
  2. Set the app's Callback URL to exactly http://localhost:8910/aps/callback.
  3. Put the Client ID in Bridge config key aps_client_id (in ~/.adom/config.json) or via Settings.
  4. Run aps_auth once - it opens the system browser for Autodesk sign-in. Tokens cache in ~/.adom/aps_tokens.json and auto-refresh; you won't be prompted again.

Verbs:

  • aps_status - call this first. Non-prompting. Returns {configured, signedIn}. configured:false → no client id; use fusion_search_cloud_files. configured:true, signedIn:false → run aps_auth. Both true → aps_search is ready.
  • aps_auth - interactive sign-in (opens browser). One-time per device.
  • aps_hubs - {hubs[]{id,name}} (your Autodesk teams).
  • aps_projects {hubId?} - {hubId, projects[]{id,name}} (default: first hub).
  • aps_search {query, projectName?, projectId?, hubId?} - {query, hubId, count, results[]{name,id,type,lastModified,project}}. query is a case-insensitive substring of the file's displayName. Without projectName/projectId it searches up to 8 projects in the hub. Open a hit with fusion_open_cloud_file {fileName, projectName}.

Decision flow for an AI doing a cloud file search: aps_status → if configured (sign in with aps_auth if needed) use aps_search; else fall back to fusion_search_cloud_files and tell the user that registering a free APS app (above) would make future searches instant.

Export formats for fusion_export_cloud_file:

Format Extension Use case
step .step Industry-standard CAD interchange (default)
stl .stl 3D printing, mesh-based
f3d .f3d Fusion 360 native archive (preserves all features)
iges .iges Legacy CAD interchange
sat .sat ACIS solid modeling kernel format
smt .smt Parasolid format

Also supported for import via fusion_import_step: STEP (.step/.stp), STL (.stl), IGES (.iges/.igs), SAT (.sat), SMT (.smt), OBJ (.obj), F3D (.f3d).

Round-trip: workspace → Fusion Cloud → workspace

# === workspace to Fusion Cloud ===
# 1. Send file from Docker to the Mac
adom-desktop send_files '{"files": [{"path": "/home/user/component.step"}]}'
# 2. Import into Fusion
adom-desktop fusion_import_step '{"filePath": "/Users/<you>/Downloads/component.step"}'
# 3. Save to cloud (gets a wip_urn for 3D library linking)
adom-desktop fusion_save_to_cloud '{"name": "my-component", "projectName": "Personal"}'

# === Fusion Cloud to Docker ===
# 1. Find and open the cloud file
adom-desktop fusion_walk_cloud_tree '{"projectName": "Personal", "nameContains": "my-component"}'
adom-desktop fusion_open_cloud_file '{"fileName": "my-component", "projectName": "Personal"}'
# 2. Export to local filesystem
adom-desktop fusion_export_cloud_file '{"outputPath": "/tmp/export/my-component.step", "format": "step"}'
# 3. Pull back to Docker
adom-desktop pull_file '{"path": "/tmp/export/my-component.step"}'

# === Cloud management ===
adom-desktop fusion_list_cloud_projects '{}'
adom-desktop fusion_create_cloud_folder '{"folderName": "Electronics", "projectName": "Main"}'
adom-desktop fusion_list_cloud_files '{"projectName": "Main", "folderPath": "Electronics"}'
adom-desktop fusion_delete_cloud_file '{"fileName": "old-file", "projectName": "Main"}'

Manufacturing Exports (Gerbers, BOM, CPL)

Export manufacturing files from an open PCB board - everything needed to fabricate boards and assemble components via Adom's PCBA service. All manufacturing commands require a .brd board open in PCB Editor (use fusion_open_board or fusion_show_2d_board).

Recommended workflow order: detect_layersset_design_rules → DRC → export_gerbersexport_bomexport_cplpull_file all back to Docker.

Every manufacturing command returns structured JSON with:

  • message - AI-oriented summary explaining what was produced and why it matters
  • nextSteps[] - ordered list of what to run next in the manufacturing pipeline
  • hint - tips for interpreting results or recovering from issues
  • data - structured metadata (paths, counts, layer info) for programmatic use

Decision guide - which command to run:

  • Don't know the layer count? → Run fusion_detect_layers first
  • Need to check if the board meets fab specs? → Run fusion_set_design_rules then DRC
  • Ready to generate fab files? → Run fusion_export_gerbers, then fusion_export_bom, then fusion_export_cpl
  • Need visual review before export? → Run fusion_export_board_image with preset assembly_top or fabrication
  • Something failed? → Check data.hint and data.recoverySteps in the error response

Commands:

  • fusion_detect_layers -- Detect if the open board is 2-layer or 4-layer. Uses ULP script (primary) and CAM comparison (fallback). Returns layerCount, copperLayers[], method (how it was detected), and nextSteps[]. Run this first - all other manufacturing commands auto-detect layers too, but running this explicitly gives you the data before committing to exports.

  • fusion_set_design_rules -- Apply Adom's JLCPCB-derived design rules (.edru XML files) to the open board. Auto-detects 2-layer vs 4-layer and loads the appropriate rule set. Returns description (human-readable rule summary), edruFile, and nextSteps[].

    • action: "apply" (default) loads rules into the board; "export" saves current board rules to a file; "show" displays rule capabilities without modifying anything
    • layers: "auto" (default), "2", or "4" - force a specific rule set
    • After applying: run fusion_electron_run '{"command": "DRC"}' to check for violations. DRC markers appear in the board editor.
  • fusion_apply_instapcb_rules -- Convenience wrapper that applies the bundled Adom InstaPCB design rule sets without needing a local .edru file. Args: layers ("2" or "4"). Sends the bundled .edru from Docker to the Mac automatically and loads it via the EAGLE drc load command. Use this instead of fusion_set_design_rules when you just want Adom's standard 2-layer or 4-layer InstaPCB rules.

  • fusion_load_design_rules -- Generic loader for ANY custom .edru file by path. Args: filePath. Use this for third-party fab vendor rules (JLCPCB, PCBWay, OSHPark, etc.) that the user has on disk.

  • fusion_export_gerbers -- Export Gerber (RS-274X) + Excellon drill files as a ZIP. Auto-detects 2-layer vs 4-layer and selects the correct JLCPCB-compatible CAM job. Returns zipPath, zipSizeKB, files[] (list of gerber files in the ZIP with sizes), layerCount, and nextSteps[].

    • outputDir: directory for the output ZIP (default: /tmp/adom-gerbers/)
    • boardName: prefix for the ZIP filename (default: from active document name)
    • layers: "auto" (default), "2", or "4" - force CAM job selection
    • Output ZIP contains: GTL, GBL (copper), GTS, GBS (solder mask), GTP, GBP (paste), GTO, GBO (silkscreen), GKO (outline), XLN (drill). 4-layer boards also get G1, G2 (inner copper).
  • fusion_export_bom -- Export Bill of Materials as CSV. Groups identical parts by value+package with quantity counts. Returns componentCount, uniquePartCount, and nextSteps[].

    • outputPath: (default: /tmp/adom-bom.csv)
    • grouped: true (default) groups by value+package; false lists every component individually
    • Output columns: Comment, Designator, Footprint, Quantity, Library - compatible with JLCPCB, PCBWay, Mouser, Digi-Key.
  • fusion_export_cpl -- Export Component Placement List (pick-and-place) as CSV. Returns totalPlacements, topCount, bottomCount, and nextSteps[].

    • outputPath: (default: /tmp/adom-cpl.csv)
    • side: "all" (default), "top", or "bottom" - filter for single-sided assembly
    • Output columns: Designator, Mid X, Mid Y, Layer, Rotation - coordinates in mm.
  • fusion_export_board_image -- Export PNG image of the board with layer presets. Returns fileSize, preset, and nextSteps[].

    • outputPath: (default: /tmp/adom-board.png)
    • dpi: resolution (default: 300, max: 600)
    • preset: layer preset name (see table below)
    • layers: custom layer numbers as int array - overrides preset
    • monochrome: true for black & white
    • listPresets: set true to get available presets instead of exporting

Layer presets for fusion_export_board_image:

Preset Layers Description
all All Every layer visible
top_copper 1, 17, 18 Top copper + pads + vias
bottom_copper 16, 17, 18 Bottom copper + pads + vias
top_silkscreen 21, 25 Top silkscreen + component names
bottom_silkscreen 22, 26 Bottom silkscreen + component names
top_soldermask 29 Top solder mask openings
bottom_soldermask 30 Bottom solder mask openings
top_paste 31 Top paste/stencil openings
bottom_paste 32 Bottom paste/stencil openings
board_outline 20 Board outline (dimension layer)
drill 44, 45, 17, 18 Drill holes + vias
assembly_top 1, 17, 18, 20, 21, 25, 51 Top assembly - copper + silk + outline
assembly_bottom 16, 17, 18, 20, 22, 26, 52 Bottom assembly - copper + silk + outline
fabrication 1, 16, 17–20, 21, 22, 25, 26, 29, 30, 51 Full fabrication view

Adom InstapcbPCB manufacturing capabilities (used by fusion_set_design_rules):

Parameter Metric Imperial
Layers 1, 2, 4, 6 -
Min trace width 0.08mm 3 mil
Min trace spacing 0.08mm 3 mil
Min via drill 0.2mm 8 mil
Min silkscreen text 0.1mm 4 mil
Board edge clearance 0.05mm 2 mil
Board thickness 1.6mm 63 mil
Copper weight 0.5 oz (17.5 μm) -
Solder mask Green -
Surface finish HASL / ENIG -
Turnaround 4 hours (fab + assembly) -

Manufacturing workflow - Fusion to Adom PCBA:

# 1. Open the board
adom-desktop fusion_open_board '{"filePath": "C:/projects/myboard.brd"}'

# 2. Detect layer count (auto-selects 2-layer or 4-layer rules/CAM)
adom-desktop fusion_detect_layers

# 3. Apply Adom design rules for the detected layer count + run DRC
adom-desktop fusion_set_design_rules '{"action": "apply"}'
adom-desktop fusion_electron_run '{"command": "DRC"}'

# 4. Export manufacturing files (gerbers auto-select correct CAM job)
adom-desktop fusion_export_gerbers '{"outputDir": "/tmp/mfg"}'
adom-desktop fusion_export_bom '{"outputPath": "/tmp/mfg/bom.csv"}'
adom-desktop fusion_export_cpl '{"outputPath": "/tmp/mfg/cpl.csv"}'

# 5. Export board images for review
adom-desktop fusion_export_board_image '{"outputPath": "/tmp/mfg/top-copper.png", "preset": "top_copper"}'
adom-desktop fusion_export_board_image '{"outputPath": "/tmp/mfg/assembly-top.png", "preset": "assembly_top"}'
adom-desktop fusion_export_board_image '{"outputPath": "/tmp/mfg/board-outline.png", "preset": "board_outline"}'

# 6. Pull files back to Docker for Adom PCBA ordering
adom-desktop pull_file '{"path": "/tmp/mfg/bom.csv"}'
adom-desktop pull_file '{"path": "/tmp/mfg/cpl.csv"}'

Desktop Tools

  • desktop_open_folder -- Open a file or folder in Finder. If given a file path, Finder opens with the file revealed (selected). Args: path (file or folder path).
  • desktop_open_url -- Open a URL in the user's NATIVE OS BROWSER - i.e. the real Edge / Chrome / Firefox / Brave they use every day, with their saved logins, history, bookmarks, and extensions. This is NOT pup, NOT Chrome for Testing - it's the browser the human actually uses. Hand it off when the user needs to interact with a logged-in account themselves.
    • Args: url (required string), browser (optional: "default" (the macOS default browser - could be Safari, Chrome, Firefox, Brave; whatever they set), "chrome", "edge", "firefox", "brave").
    • Examples:
      • adom-desktop desktop_open_url '{"url":"https://claude.ai/"}' -- opens in user's default native browser, already signed in
      • adom-desktop desktop_open_url '{"url":"https://docs.example.com","browser":"edge"}' -- force native Edge
    • Returns {ok, browser, url, exePath?}. Allowed schemes: http, https, mailto, ftp, ftps. Other schemes refused.

desktop_open_url vs browser_open_window - two completely different browsers

desktop_open_url browser_open_window
What browser Native OS browser the user uses daily - Safari / Google Chrome / Microsoft Edge / Firefox / Brave (whichever is the macOS default, or the one you name explicitly) Puppeteer-controlled Chrome for Testing - a separate Chromium build that pup launches
Profile / data The user's real profile: saved logins, history, bookmarks, extensions, autofill Isolated profile under plugins/puppeteer/profiles/<sessionId>/ - empty, no saved logins
Who interacts with the page The HUMAN. Claude hands the URL off and is done. CLAUDE drives it programmatically - screenshots, clicks, eval, recording. The human just watches.
Use when A login is required (claude.ai, GitHub, internal tools, banking) - the human's existing session must be reused Automation, scraping, screenshot capture, video recording, headful UI testing
Can Claude control it after launch? No - once handed off, Claude can't see or drive it Yes - every browser_* verb (screenshot, eval, navigate, record, etc.)

Decision rule: human-in-the-loop login flow → desktop_open_url. AI-driven automation → browser_open_window (defaults to Chrome for Testing). AI-driven automation that must be the user's real, signed-in profile (GCP Console with org/projects, internal SSO behind a managed-Chrome policy, anything Context-Aware Access blocks for non-enrolled browsers) → browser_open_window with the new nativeBrowser + nativeProfile args (v1.0.5+, see below).

When pup means "the user's real Chrome/Edge" - browser_open_window native mode (v1.0.6+, smart flow v1.0.11)

Default browser_open_window (no native args) launches Chrome for Testing - same as day one. Never auto-switch a plain pup request to native. "Open this URL in pup", "screenshot this", "click that button" all stay on CfT.

Native mode drives the user's REAL browser - their actual signed-in profile, with live cookies, org policies, and device enrollment intact (NOT a clone - v1.0.6 replaced the old clone approach). Opt-in via TWO additive args from desktop_list_browsers:

adom-desktop desktop_list_browsers     # installed browsers + profiles (+ the signed-in account per profile)
adom-desktop browser_open_window '{
  "sessionId":"gcp",
  "nativeBrowser":"chrome",            # chrome / edge / brave
  "nativeProfile":"Default",           # profile id: Default / Profile 1 / Profile 2
  "url":"https://console.cloud.google.com"
}'

The smart flow. CDP (the protocol pup drives) needs the browser running with --remote-debugging-port, which can only be set at launch. So the bridge handles each situation differently and tells you which happened:

Situation What Bridge does Response
Already driving that browser+profile Reuse the live connection ok + narrative
Running with a debug port (power-user launched it that way, or Bridge did earlier) Connect, no relaunch ok + narrative
Not running at all Launch it fresh with the debug port - NO prompt (nothing to disrupt) ok, launchedFresh:true + narrative - just proceed
Open without a debug port Ask the user - Bridge must close+reopen it errorCode:"native_relaunch_required" + confirmArgs + alternatives
Debug port refuses to bind (enterprise policy) Won't close it again errorCode:"native_cdp_blocked" + diagnosis + alternatives

Handling the two consent/blocked codes:

  • native_relaunch_required - the browser is open without the port, so driving it means closing + reopening it (with --restore-last-session, so all tabs/windows come back). Ask the user's permission, then re-call with "nativeRelaunch":true. The response's alternatives lists other installed browsers you could offer instead.
  • native_cdp_blocked - the debug port won't open, almost always an enterprise policy (RemoteDebuggingAllowed disabled via Google Workspace / Chrome cloud management - affects Chrome browser-wide, every profile). diagnosis says whether it's a confirmed local policy or a likely cloud-managed one. Bridge will not close that browser again. alternatives names other installed browsers - Edge usually escapes Google management, so it often drives natively when Chrome can't. Offer the user the alternative, or fall back to generic CfT.

Always available - generic CfT. browser_open_window with NO nativeBrowser/nativeProfile drives a clean Chrome-for-Testing instance: no user session, no managed policy, fully controllable. If a task needs the user's logged-in session there, have them sign in once. This is the universal fallback whenever native is blocked.

Power-user shortcut: if the user launches their browser at startup with --remote-debugging-port=<N>, Bridge reconnects every session with no prompt at all (a productized opt-in "keep native browsers debug-ready at boot" is on the roadmap, default OFF for security).

Routing rule - DON'T auto-escalate. Treat native as opt-in:

User says What to do
"pup" / "open in pup" / screenshot / eval / debug DEFAULT pup (CfT). Never mention profiles.
"native chrome" / "my real browser" / "my work profile" / "my <account> profile" Native. Call desktop_list_browsers. If exactly one profile clearly fits the request, use it and say which one ("opening in your [email protected] profile on Chrome"). If multiple profiles could fit (e.g. a google.com URL with both [email protected] and [email protected] profiles signed in), ASK ("Which profile - work [email protected] or personal [email protected]?") and then drive that pair.
anything else Default pup.

Anti-fingerprint: native sessions launch without --enable-automation and with --disable-blink-features=AutomationControlled. navigator.webdriver === false. No "controlled by automated software" banner. Sites that probe for automation see a normal browser.

Firefox appears in desktop_list_browsers but is rejected for native drive with errorCode:"firefox_no_cdp" - Firefox doesn't speak the Chrome DevTools Protocol, so puppeteer can't drive it. If the user wants Firefox, hand off via desktop_open_url instead.

Native-mode error codes:

  • native_relaunch_required - browser open without a port; ask the user, re-call with nativeRelaunch:true (see the smart-flow table above).
  • native_cdp_blocked - debug port policy-blocked; read diagnosis, offer alternatives or generic CfT (see above).
  • firefox_no_cdp - caller passed nativeBrowser:"firefox". Firefox doesn't speak CDP; hand off via desktop_open_url.
  • native_browser_not_installed - that browser isn't installed on this machine.
  • native_user_data_missing - installed but never launched, so the User Data dir doesn't exist yet. Ask the user to open it once.
  • native_profile_not_found - nativeProfile isn't a real profile id under this browser's User Data. Call desktop_list_browsers to see what's valid.

Common footgun - localhost / file:// URLs from Docker AIs

If you're running this CLI from a remote Docker container (galliaApril, dartv4, etc.), browser_open_window / browser_open_tab / browser_navigate will load the URL inside the USER's local pup - NOT inside your container. So a URL like http://localhost:8080/foo.html resolves to the user's machine, not your container's service.

v1.7.10+ detects this. When you pass a URL whose host is localhost, 127.0.0.1, 0.0.0.0, ::1, or scheme is file://, the response includes a _hint field explaining the issue and suggesting the public-slug URL pattern (https://<user>-<repo>-<suffix>.adom.cloud/proxy/<port>/<path>). The call still proceeds - rare legitimate cases exist where the user actually does have something running on their localhost.

Recipe - load something from inside your container into pup:

# 1. Start your service inside the container (e.g. a local HTML viewer):
python3 -m http.server 8786 --bind 0.0.0.0 &

# 2. Ask your USER for the container's adom.cloud slug suffix
#    (look at their browser URL when they open hydrogen or claude.ai/code).
#    Slug looks like: <username>-<container>-<random-id>

# 3. Open the public URL - NOT localhost:
adom-desktop browser_open_window '{
  "sessionId":"demo",
  "url":"https://<your-container-slug>.adom.cloud/proxy/8786/foo.html"
}'

If you don't have the suffix handy, just ask the user. Or if the file you wanted to show pup is one your container already wrote to disk, push it to a public location (wiki asset, S3) and load from there.

  • desktop_bring_to_front -- Bring a window to the foreground by window id (hwnd) or title substring. Activates the owning app and raises the window. Preserves maximized state.
    • Args: hwnd (int) OR titleContains (string, case-insensitive). One required.
    • Example: adom-desktop desktop_bring_to_front '{"titleContains": "Fusion"}'
  • desktop_set_window_state -- Change a window's show state without bringing it to the foreground (or both, if combined with desktop_bring_to_front). Args: hwnd (int) OR titleContains (string) OR cacheKey, plus state (one of "maximize", "minimize", "restore", "show", "hide", "bottom"). Use to ensure Fusion or KiCad windows are maximized for screenshots, or to hide noisy background windows during demos.
    • Example: adom-desktop desktop_set_window_state '{"hwnd":133560,"state":"maximize"}'
    • state:"bottom" (v1.9.114) -- z-order send-to-back, background. Drops the window BEHIND all others without focusing, moving, or resizing it; works even on an off-screen window. It's an ATOMIC check-and-demote: it reads the OS foreground window first, and if the target IS the foreground (the user is interacting with it) it SKIPS the demotion and returns wasForeground:true, sentToBack:false so the caller backs off with no race; otherwise wasForeground:false, sentToBack:true. Use to park a background window behind the user's windows (still on-screen, rendering for screenshots) without stealing focus.
    • force:true (v1.9.115) sends it to back EVEN IF the window is currently the OS foreground (still no activation change; sentToBack:true always, wasForeground still reported). Use at window-LAUNCH time: the OS often hands the foreground to a brand-new window, so "foreground at launch" is not a real user signal.
    • Example: adom-desktop desktop_set_window_state '{"titleContains":"(session: abc","state":"bottom","force":true}'

Real OS input - mouse + keyboard (v1.8.170+)

desktop_click / desktop_double_click / desktop_right_click / desktop_hover / desktop_type / desktop_press_key synthesize real OS input via CGEvent - so they land on chrome:// pages and native dialogs that browser CDP (nbrowser_* / browser_*) cannot reach. The verb names mirror the browser extension's nbrowser_* so the model transfers between in-page and whole-desktop driving.

  • Gated behind shell-auto-approve (same risk class as shell_execute - keystrokes/clicks hit whatever is focused on the user's live machine). If it's off you get errorCode:"input_not_approved". You (a cloud/relay AI) can't grant it yourself (v1.9.90) - call request_shell_approval (or let the refusal auto-surface the prompt) and ask the user to click Approve / Bypass Permissions in Adom Bridge, then retry. Every action is written to the activity log. Synthetic input also needs macOS Accessibility permission for the Bridge app (System Settings > Privacy & Security > Accessibility).
  • Focus a window first by passing {window:"<title substring>"} (alias titleContains) or {hwnd:<int>} - reuses desktop_find_window's resolver, so input lands on the intended window, not whatever happened to be focused. Get the window id from desktop_list_windows / desktop_find_window.
  • Coords are physical screen pixels - the same space as desktop_list_windows rects.

Verbs:

  • desktop_click {x, y, button?:"left"|"right"|"middle", double?, window?|hwnd?} - left-click by default. desktop_double_click / desktop_right_click are wrappers; desktop_hover moves the cursor without clicking (reveals tooltips).
  • desktop_type {text, window?|hwnd?} - types a literal Unicode string (any keyboard layout).
  • desktop_press_key {keys:[...], repeat?, window?|hwnd?} (alias desktop_send_keys) - chords/shortcuts. Modifiers ctrl/alt/shift/cmd (aliases meta/super/win mean Command); named keys enter/tab/esc/space/backspace/delete/home/end/pgup/pgdn/up/down/left/right/f1-f12.

Canonical "navigate a browser to a URL" flow (works on chrome:// where CDP can't):

adom-desktop desktop_press_key '{"keys":["cmd+l"],"window":"Chrome"}'   # focus Chrome + select the address bar
adom-desktop desktop_type        '{"text":"chrome://extensions","window":"Chrome"}'
adom-desktop desktop_press_key   '{"keys":["enter"],"window":"Chrome"}'

For a control that isn't reachable by CDP/coords (e.g. a button inside a shadow DOM), Tab-navigate to it then Enter: desktop_press_key {"keys":["tab","tab","enter"]}.

Visual "Adom cursor" (v1.8.171+) - a click-through teal cursor + a label that narrates what you're driving (trust cue; byte-faithful to the browser extension's cursor). Pure visual, not gated:

  • desktop_cursor {action:"show"|"move"|"hide", x, y, label} - drive/narrate the cursor on its own ("watch me"). Returns the overlay hwnd.
  • Or pass cursor:true + label:"Clicking Reload" on desktop_click/desktop_double_click/desktop_right_click/desktop_hover - Bridge glides the teal cursor to the target (narrating the label) then does the real click.
  • Auto-hide (v1.8.174+): the cursor hides itself after ttlMs (default 60s) so it never lingers on the user's screen if you forget to hide it. Each show/move refreshes the timer. Pass ttlMs:<ms> (≤600000) for longer, or persist:true to keep it until you explicitly desktop_cursor {action:"hide"}. If a cursor you showed was auto-hidden, the next cursor/click response prefixes its _hint with a NOTE so you learn (no chatty unsolicited message).

Background input via macOS Accessibility (AX) - no foreground, no focus steal

The mouse/keyboard verbs above are real OS input, which lands on the foreground window - so Bridge foregrounds the target first, yanking it in front of the user's work. When you must NOT steal focus, use the Accessibility (AX) verbs instead: AXPress / setting AXValue are programmatic - the app services them via its accessibility tree with no cursor move, no focus change, no foregrounding. Chromium browsers expose their a11y tree to AX, so chrome:// page buttons/links are reachable by accessible name (not coordinates). The verb names keep the desktop_ui_* spelling for cross-platform compatibility; on this build they are implemented on macOS AX (desktop_ax).

  • desktop_find_control {window|hwnd, name|contains, role?} - read-only discovery (ungated). Returns best:{name, role, rect, invokable, settable}. Call this first to learn whether a control is AX-reachable.
  • desktop_ui_click {window|hwnd, name|contains, role?} - AX press by accessible name, in the background. Gated behind shell-auto-approve.
  • desktop_ui_set {window|hwnd, name|contains, text} - AX set value on a text field, in the background. Gated.
  • desktop_navigate {window, url} - background browser navigation via the address bar's AX value. Gated like ui_set.

rolebutton|edit|link|checkbox|menuitem|tab|listitem|combobox|radiobutton|document|group|spinner. Caveat (surfaced in _hint, not hidden): some shadow-DOM controls aren't in the AX tree - if find_control reports invokable:false/not-found, the control genuinely needs a foregrounded desktop_click. So: prefer desktop_ui_click (background) → fall back to desktop_click (foreground) only when AX can't reach it.

Permission: all AX verbs require the Bridge app to be trusted in System Settings > Privacy & Security > Accessibility. Without it every AX call returns errorCode:"ax_not_trusted" with a hint - ask the user to enable it there, then retry.

The wider AX surface - enumerate, read, drive

Beyond click/set, a near-complete agent-drivable accessibility client is available (same shared selector everywhere):

Shared selector on every verb: {hwnd|window(titleContains)} + {name|contains|role} + nth:<int> (0-based - target the Nth match without the exact name). Rects are screen px with centerX/centerY; space:"image"+shotId is accepted wherever a coordinate is taken.

  • Enumeration (read-only, ungated):
    • desktop_ui_tree {window|hwnd, maxDepth?, roleFilter?[], nameContains?, maxNodes?(500)} - flagship. Dump the a11y subtree; nodes carry rect + invokable/settable + value/toggle/selection state + children[]. truncated:true when it hits maxNodes (logged, never silent).
    • desktop_ui_from_point {x,y | space:"image",shotId,x,y} - the control under a pixel.
    • desktop_find_controls {selector, maxResults?(25)} - ALL matches as an array with nth indices (vs find_control's single best).
  • Reading (read-only, ungated): desktop_ui_text {selector}; desktop_ui_get {selector} (current value/toggle/range/selection state); desktop_ui_grid_get {selector, row?, col?} (table cell + rowCount/columnCount).
  • Actions (gated by shell-auto-approve; background via AX): desktop_ui_toggle {selector, state?}, desktop_ui_expand {selector, action?:expand|collapse|toggle}, desktop_ui_select {selector, action?:select|add|remove}, desktop_ui_scroll {selector, dir?, amount?}, desktop_ui_set_range {selector, value}, desktop_ui_focus {selector} (usually raises; reports it), desktop_ui_window {window, action:minimize|maximize|restore|close|move|resize}.
  • Introspection: desktop_ui_capabilities {} - read-only, ungated. Lists the read/action verb sets and what this platform implements.

Not on macOS: the event-subscription verbs (desktop_ui_watch / desktop_ui_events / desktop_ui_unwatch / desktop_ui_selftest) return a clean not_implemented_macos error. To wait for a dialog/window, poll desktop_find_window or desktop_screenshot_window instead.

Zero-math coordinate clicks - coordMap + image-space (v1.8.175+)

You no longer recompute scale/offset/DPI to click what you see in a screenshot. Every screenshot verb returns a coordMap:

"coordMap": {
  "shotId": "shot-w5050328-1",
  "image": { "w": 1372, "h": 1031 },              // the DELIVERED (safe) PNG's pixels
  "screenRect": { "x": 594, "y": 254, "w": 1372, "h": 1031 },  // physical px = input-verb space
  "dpiScale": 1.5,
  "imageToScreen": { "sx": 1.0, "sy": 1.0, "ox": 594, "oy": 254 }  // screen = img*s + o
}

Two ways to use it:

  • Let Bridge convert (preferred, zero math): desktop_click {space:"image", shotId, x, y} where x,y are pixels in that screenshot. Also works on desktop_double_click/right_click/hover. The shotId comes from the capture's coordMap.shotId (last ~64 shots retained; re-capture if it expires).
  • Compute yourself: screenX = x*sx + ox, screenY = y*sy + oy.

screenRect is in the SAME physical-px space as desktop_list_windows rects and the desktop_click default (space:"screen").

Driving a GUI app that pops error boxes / modal dialogs? Read the dedicated adom-desktop-window-capture skill. desktop_screenshot_window {hwnd} captures ONE window without foregrounding it (per-window screencapture -l, no focus steal). It is the breakthrough for automating native apps whose dialogs a full-screen grab can't see. The rest of this section is the reference; that skill is the how-and-why.

Dialogs / secondary windows: an app's dialogs are separate windows in desktop_list_windows on macOS - capture each by its own window id, or use kicad_screenshot_all (returns every KiCad window incl. error dialogs as a screenshots[] array, each with its own coordMap+shotId). Use desktop_screenshot_screen (full-desktop grab) to see everything at once.

Screen geometry on every shot (v1.8.178): every desktop_screenshot_window / _screen response now carries:

  • windowRect: {x,y,w,h,monitor} - the window's position + size on the overall (possibly multi-monitor) desktop, in physical screen px.
  • display: {virtualScreen, primary{w,h,dpi,scale}, monitors[], monitorOfWindow} - the full monitor topology: total virtual bounds, and per-monitor bounds/dpi/scale/primary. monitorOfWindow indexes into monitors[]. (Same data as the standalone desktop_display verb, folded in.)
  • drive: "background" - screenshots never foreground the window. The companion _related field lists sibling screenshot/window verbs.

Screenshot cache is self-cleaning + self-describing (v1.9.107/108/113): the CLI auto-pulls every screenshot to ONE user-visible, namespaced dir on the caller's machine (~/project/screenshots/adom-desktop/, else ~/.adom/screenshots/; ADOM_SHOTS_DIR overrides) - never /tmp anymore. Shots are bucketed by kind into subfolders: window/ (a specific window / app / owned popup) and screen/ (a full-desktop grab). Human-readable filenames (v1.9.113): <YYYY-MM-DDThh-mm-ss.mmm>[-<hwnd>].png - a sortable ISO stamp (lexicographic == chronological), plus the hwnd for a window capture; the window/screen kind is NOT repeated in the name because the folder already says it. One file when small (v1.9.114): if the shot was already within the safe size cap (the common case) it's a single <stem>.png and localFullPath == localSafePath - no redundant re-encoded copy. Only a shot large enough to be downscaled produces a <stem>.full.png + <stem>.safe.png pair (Read the .safe one for image analysis; use .full for archive). Each shot gets a <stem>.json sidecar (shotlog-style: source, timestamp, kind, dims + bytes, coordMap, rect; a full+safe pair shares one). Parent/child grouping (v1.9.111/113): when desktop_screenshot_window returns owned popups in screenshots[], the parent AND each pulled child share the same stem, and each child gets a .popup-NN label - e.g. parent 2026-07-15T01-14-16.937-986710.safe.png and children 2026-07-15T01-14-16.937-986710.popup-01.safe.png, ...popup-02.... So a plain ls shows the whole capture grouped (shared stem) and the role (.popup-NN) without opening a sidecar; the sidecar also carries parent + popupIndex. (kicad_screenshot_all returns sibling windows with no parent, so those keep their own names.) A janitor prunes it (7 days / 2 GB / newest kept; sidecars follow their PNG, orphans swept) and drains the old /tmp/ad-shots + /tmp/adom-desktop-screenshots backlogs, running opportunistically at most once/24h on any CLI call. You do not need to clean it up or write your own cron. Every screenshot response carries a _cacheHint; verify coverage with adom-desktop janitor (status: each subfolder, size, policy, last sweep) or force a sweep with adom-desktop janitor '{"sub":"run"}'. It never touches other tools' files under ~/project/screenshots/ (e.g. shotlog). Tune via ADOM_SHOTS_MAX_AGE_DAYS / ADOM_SHOTS_MAX_GB / ADOM_SHOTS_MAX_FILES, or disable with ADOM_JANITOR=0.

Background vs foreground driving (v1.8.178): every desktop driving verb carries a structured drive field ("background" | "foreground" | "n/a") - in GET /commands, in adom-desktop help <verb>, and echoed in the verb's own response. Background (no foreground / focus steal / cursor move - AX + capture): desktop_ui_click, desktop_ui_set, desktop_navigate, desktop_find_control, desktop_screenshot_window/_screen, desktop_record_window_start. Foreground (synthetic input goes to the focused window, so Bridge foregrounds first): desktop_click/_double_click/_right_click/_hover, desktop_type, desktop_press_key, desktop_bring_to_front. Run adom-desktop help background for the full grouping. Rule: to act without disturbing the user's window, drive by NAME (desktop_find_controldesktop_ui_click/ui_set); fall back to coordinate clicks/keys only when the control isn't in the AX tree.

Click a control by name → coords: desktop_find_control returns best.rect{x,y,w,h,centerX,centerY} in screen px - desktop_click {x:centerX, y:centerY} to click it (handy when invokable/settable are both false). Target by name, contains, or role.

Keyboard chords: desktop_press_key accepts a chord as one string ["cmd+w"] OR separate tokens ["cmd","w"] - both mean Command held + W. A sequence of distinct chords uses + per element: ["cmd+l","enter"]. The reply echoes chords showing how Bridge interpreted your keys.

  • desktop_flash_window (v1.8.152+) -- Request the user's attention for the Bridge app (a Dock bounce / attention request on macOS) as an "attention / ongoing activity" cue. Args: hwnd (int) OR titleContains (string); mode ("until_focused" default, or "count"); count (int, mode:"count" only, default 3); stop (bool -- clear an active request). Returns {ok, hwnd, title, mode}. Distinct from the puppeteer bridge's browser_alert_window, which flashes pup's OWN tracked windows.
    • Example: adom-desktop desktop_flash_window '{"titleContains":"Fusion"}' then add "stop":true to clear.
  • desktop_taskbar (v1.8.152+) -- Glanceable status on macOS maps to the Dock: flash becomes a Dock attention bounce ("until_focused" | "stop"), and badge/progress channels update Bridge's own Dock tile. Windows-only channels from the original contract (per-window overlay icons, thumbnail tooltips/clips, painting OTHER apps' buttons) do not exist on macOS and are ignored or reported in the response's applied[]. Use desktop_flash_window / a notification for "come look" cues, and desktop_caption for on-screen narration.
  • desktop_revoke_approvals -- Revoke all shell auto-approve permissions and deny pending approvals. The next shell command will show the approval dialog.
    • Example: adom-desktop desktop_revoke_approvals
  • desktop_caption -- Show a global caption overlay that stays visible above ALL windows (KiCad, Fusion, Chrome). Native always-on-top overlay window, not a browser DOM element. Click-through: mouse events pass to windows below. Captured by screen recordings.
    • Show: adom-desktop desktop_caption '{"text":"Step 1: Opening the board","position":"top","size":"large","duration":3000}'
    • Custom position: adom-desktop desktop_caption '{"text":"Look here","x":0.3,"y":0.2,"size":"large","persist":true}'
    • Hide: adom-desktop desktop_caption '{"action":"hide"}'
    • Multiple simultaneous captions (use id to keep them independent):
      adom-desktop desktop_caption '{"text":"Step 1: Opening board","id":"step","position":"center","size":"large","persist":true}'
      adom-desktop desktop_caption '{"text":"REC ●","id":"status","position":"bottom-left","size":"medium","persist":true}'
      
    • Replace only the step caption (status stays):
      adom-desktop desktop_caption '{"text":"Step 2: Routing","id":"step","position":"center","size":"large","persist":true}'
      
    • Hide just the step caption: adom-desktop desktop_caption '{"action":"hide","id":"step"}'
    • Args:
      • text (string) - caption text
      • id (string, optional) - identifies this caption. Captions with the same id replace each other; different ids coexist simultaneously. Default "_default" - so bare calls without id still replace each other for backward compat.
      • position - preset: "top", "bottom" (default), "center", "top-left", "top-right", "bottom-left", "bottom-right"
      • x (float 0.0–1.0) - normalized screen X, overrides position horizontal. 0.0 = left edge, 1.0 = right edge. Centers the caption box on this point.
      • y (float 0.0–1.0) - normalized screen Y, overrides position vertical. 0.0 = top edge, 1.0 = bottom edge.
      • size - preset: "large" (72px), "medium" (32px, default), "small" (20px)
      • fontSize (int) - custom font size in px (8–400). Overrides size preset when provided.
      • duration (ms) - auto-dismiss timer. Omitted or 030000 (30s); clamped to max 600000 (10 min). Captions ALWAYS auto-expire (v1.8.138+) so a forgotten one can't be stranded on the user's desktop. To keep one up longer, re-issue the same id (resets the timer) or pass a bigger duration.
      • persist (bool, optional) - true = sticky, NO auto-dismiss. Supervised use only (e.g. demo recordings); you MUST then dismiss it yourself with action:"hide". This is the only way to a never-expiring caption (duration:0 no longer does that).
      • action - "hide" to dismiss a caption (with id: hides only that caption; without id: hides all captions), "force-clear" to destroy ALL caption windows regardless of id (nuclear option - use at top of demo scripts for guaranteed clean slate)
    • Captions with the same id replace each other instantly (previous destroyed, no fade overlap). Captions with different ids coexist - multiple captions can be visible simultaneously.

Desktop Screenshots

Take screenshots of the user's desktop or individual windows. All screenshots use lossless PNG.

Always save screenshots to project-content/screenshots/.

Naming convention: desktop-<descriptive-name>-YYYY-MM-DD-HhMMam/pm.png

Workflow: Call desktop_list_windows first to get window ids, then desktop_screenshot_window with the id.

  • desktop_list_windows -- List all visible windows (returns the window id as hwnd (the CGWindowID on macOS), title, owning app, position/size, and z = Z-order index, 0 = topmost)
  • desktop_find_window (v1.8.153+) -- Resolve a window's id by titleContains (case-insensitive substring), returned in Z-order with best = the topmost match. Use this instead of diffing desktop_list_windows before/after launching something - it's race-free for "the window I just opened." Returns {ok, count, best:{hwnd,title,z,rect}|null, windows[]} (ok true only when ≥1 matched). Pass best.hwnd to desktop_flash_window / desktop_screenshot_window.
    • Example: adom-desktop desktop_find_window '{"titleContains":"GitHub"}'
  • desktop_activity_filter (v1.8.155+) -- Scope the user's GUI activity log so they watch the slice you care about while you work (e.g. focus it on one bridge before a debugging session). Args: bridge (a bridge registry name from bridge_list | "none" = only global/non-bridge Bridge commands | "all"/omit = everything), text (free-text substring, ANDs with bridge), clear (bool - reset). Drives the human's GUI view only - it does NOT change which verbs you can call or what they return. The GUI shows a status bar stating exactly what's shown + what's hidden.
    • Example: adom-desktop desktop_activity_filter '{"bridge":"kicad"}' then adom-desktop desktop_activity_filter '{"clear":true}'
  • desktop_screenshot_window -- Capture a specific window by its id (hwnd)
  • desktop_screenshot_screen -- Capture the entire desktop (all monitors)
  • desktop_ad_scroll (v1.9.86+) -- Scroll Bridge's OWN window panels. Bridge is a fully AI-controllable app, so you can drive its own scrollbars - most usefully to page the bridges sidebar and screenshot cards that are below the fold. Args: panel (bridges default | activity | connections), to (top|bottom, absolute - wins over dy), dy (relative px, default 400; negative = up). Returns {ok, panel, scrolled, state:{scrollTop,scrollHeight,clientHeight,atTop,atBottom,found}|null, _hint} - round-tripped from the live window so you can loop until state.atBottom==true. A collapsed bridges sidebar is auto-expanded first; state==null means the panel didn't report (collapsed/empty).
    • Capture-all-cards workflow: desktop_ad_scroll '{"panel":"bridges","to":"top"}'desktop_screenshot_window (Bridge hwnd) → desktop_ad_scroll '{"panel":"bridges"}' → screenshot → repeat until atBottom.

App self-update (relay-driven, v1.9.87+)

Bridge auto-updates itself from the wiki manifest via a silent dmg swap (mount, rename-swap the .app, relaunch; manual-open fallback), but historically only a HUMAN clicking the footer "Install now" banner could apply it - so a headless / fleet box could sit a version behind with no path forward from the relay. These verbs give the AI that path (same shape as the shell-approval surfacing verbs). Use --target <box>.

  • update_status (read-only, ungated) -- {currentVersion, latestVersion, updateAvailable, updateReady, state, busy, updateMode}. updateReady:true = the newer version is downloaded + SHA-verified on disk, appliable with no network.
  • update_check -- force a manifest check now; if newer, download + verify (stage) it in the background. Poll update_status until updateReady:true.
  • apply_update -- the programmatic "Install now": apply the staged update + relaunch Bridge. Respects busy (refuses if a command is in flight). Only ever applies Bridge's own downloaded + SHA-verified SIGNED wiki release. Returns {applying:true} then Bridge restarts (the relay drops + reconnects; poll targets/ping for the new version). If not staged yet, it kicks staging and tells you to poll + retry.
  • request_update_approval -- surface the "Update available - Install now / Later" banner on the target + bring Bridge to the front (for an ATTENDED box where the human should decide). Does NOT apply; apply_update installs.
  • update_set_mode {mode: auto|prompt|off} -- the auto-apply POLICY. auto = unattended stage+apply on the 4h/on-launch poll (set this ONCE on headless fleet boxes and they self-update every release with no relay call); prompt = show the banner (default, attended); off = no checks.
    • Fleet flow: update_status --target <box> → if updateAvailable and unattended, apply_update --target <box> (or set update_set_mode {"mode":"auto"} once). A box must already be on ≥1.9.87 to have these verbs; the one-time bootstrap of an older box still needs a human click OR auto mode.

Browser automation (browser_*, Puppeteer/pup) - bridge-owned; light pointer

The pup bridge is cloud-owned and ships its OWN full skill (its wiki page / skill pkg - bridge_list names it; adom-desktop sync_skills installs it). This page keeps only the pointer plus first-touch examples; sessions/profiles, credential vault, downloads, recording, CDP details are the bridge's skill.

Most-used verbs:

adom-desktop browser_open_window '{"sessionId": "s1", "profile": "p1", "url": "https://example.com"}'
adom-desktop browser_navigate '{"sessionId": "s1", "url": "https://example.com/page2"}'
adom-desktop browser_screenshot '{"sessionId": "s1"}'
adom-desktop browser_eval '{"sessionId": "s1", "expression": "document.title"}'
adom-desktop browser_fetch_url '{"url": "https://example.com/data.json"}'

browser_readiness is the read-only probe (never spawns or downloads). Full details: the bridge's own skill via sync_skills.

Screen recording - Bridge's desktop recorder (tab recording is pup's)

Bridge owns DESKTOP recording: a visible HUD opens with the REASON you supply, so the user always sees that recording is on and why. Full guide: the adom-desktop-recording skill. There is also a native screen recorder: desktop_record_window_start / _stop / _status records via macOS screencapture -v to a pullable .mov (on macOS it records the screen; graceful stop finalizes the file).

adom-desktop desktop_recorder_open '{"reason": "record the DRC walkthrough for review"}'
adom-desktop desktop_record_start '{"confirmDesktopNotTabRecording": true}'
# ... drive the desktop ...
adom-desktop desktop_record_stop '{}'
adom-desktop desktop_record_list '{}'
adom-desktop desktop_recorder_close '{}'

desktop_record_start without confirmDesktopNotTabRecording: true refuses with errorCode: desktop_record_needs_confirmation - that guard exists because recording a BROWSER TAB is the pup bridge's separate feature (browser_record_start etc., see pup's own skill).

Filesystem primitives - desktop_list_files / desktop_watch_files / desktop_pull_glob (v1.5.0+)

These are the canonical "wait for a download" primitives. They replace shell polling of the Downloads folder. No shell, no user approval prompt, no parsing of ls output. They run pure Rust on the desktop side, so they're fast and predictable.

The whole "wait for a file to arrive" flow is one call:

adom-desktop desktop_watch_files '{
  "path": "~/Downloads",
  "glob": "ul_*.zip",
  "timeoutMs": 60000
}'
# → {ok:true, file:{path,name,size,mtime}, elapsedMs} on success
# → {ok:false, error:"timeout", elapsedMs, _hint, ...} on timeout

desktop_list_files - one-shot directory listing

Lists files in a directory (non-recursive) matching a shell-glob, optionally filtered to those modified after a given timestamp.

  • Args: path (abs path on the Mac; ~ / %USERPROFILE% / %TEMP% are expanded - the Windows-style tokens still map to home / temp for cross-platform callers), glob (default *; shell-style with * and ?), modifiedSince (optional unix-seconds OR ISO-8601 string).
  • Returns: {ok, path, glob, files: [{path, name, size, mtime}, ...] sorted newest-first, count}.
  • Does NOT recurse. Lists one directory only.

desktop_watch_files - block until a match arrives

Polls the directory every pollMs (default 1000) until at least one file matches the glob with mtime > since, or timeoutMs (default 60000, max 600000 = 10 min) elapses.

  • Args: same as desktop_list_files plus since (defaults to now - without an explicit value, only NEW files report), timeoutMs, pollMs.
  • Returns on match: {ok:true, file:{path,name,size,mtime}, elapsedMs}.
  • Returns on timeout: {ok:false, error:"timeout", elapsedMs, _hint, path, glob}.
  • The since-defaults-to-now behavior matches the canonical "click then watch" flow. If you might miss the file by starting the watch slightly late (e.g. fast small downloads), record $(date +%s) BEFORE the click and pass it as since.

Composes desktop_list_files (or desktop_watch_files if wait:true) with the existing streaming pull_file mechanism. The whole "wait for a download then pull it" flow in one call:

BEFORE=$(date +%s)
# … trigger the download click via browser_input_dispatch …
adom-desktop desktop_pull_glob "{
  \"path\": \"~/Downloads\",
  \"glob\": \"ul_*.zip\",
  \"since\": $BEFORE,
  \"wait\": true,
  \"timeoutMs\": 60000,
  \"saveTo\": \"/tmp/cse-out\"
}"
# → {ok:true, files:[{name,path,size,sha256,chunks}, ...], errors:[], matchedCount}
  • When wait:true: blocks via desktop_watch_files until at least one match appears, then re-lists the directory and pulls everything matching (so a .crdownload + final .zip written in the same poll tick both come along).
  • When wait:false (default): just lists what's there now and pulls those.
  • Pulls use the existing pull_file streaming pipeline - sha256-verified, ~1MB chunks, resumes the same way pull_file does.

Glob semantics

  • * matches any run of non-separator chars (within a single filename - these primitives don't recurse).
  • ? matches a single non-separator char.
  • Case-sensitive on macOS/Linux (matches the filesystem) - *.step and *.STEP are different globs here.
  • Examples: ul_*.zip, LIB_*.zip, *.pdf, datasheet_*.pdf, *.step.

Path expansion

  • ~ and ~/foo → home dir.
  • %USERPROFILE% → home dir (kept for cross-platform callers; case-insensitive).
  • %APPDATA% / %LOCALAPPDATA% → the platform config / local-data dirs (~/Library/... on macOS).
  • %TEMP% → the temp dir (/tmp-style on macOS).

Time format for since / modifiedSince

  • Number: unix seconds (e.g. 1746483600). Floats accepted.
  • String of digits: same (e.g. "1746483600").
  • ISO-8601 / RFC-3339 string: e.g. "2026-05-04T22:30:00Z" or with offset "2026-05-04T15:30:00-07:00".
  • Files with mtime <= since are filtered out (strict >).

Adom Hydrogen (hd_*) - proxy + build/lifecycle for the sibling Tauri app

When the user has Adom Hydrogen running (the sibling Tauri v2 app), built-in hd_* verbs reach into it. NOT a separate bridge process - Bridge proxies HTTP calls directly to HD's control API.

Port discovery (v1.8.72+): HD's control API lives on port 47084 (default; the actual port is read from ~/.config/adom-hydrogen/ports.json key control on every hd_api call, fallback 47084).

The canonical generic verb - hd_api (v1.8.72+)

adom-desktop hd_api '{"method":"GET","path":"/health"}'
adom-desktop hd_api '{"method":"POST","path":"/setup/run-step","body":{"id":"welcome"}}'
adom-desktop hd_api '{"method":"GET","path":"/workspace/health"}'

Generic HTTP pass-through to any HD endpoint. Raw JSON in body - no curl-through-the-relay quote mangling. Returns {success, status, body, port, method, path} mirroring HD's response. If HD isn't running, returns {ok:false, error:"HD not reachable on :47084", _hint:"..."} instead of timing out.

Public denylist - these paths are refused with {ok:false, error:"not_allowed_in_public", denylist_match:"..."} on public Bridge builds (use the internal hd-dev cargo feature to bypass):

/wsl/unregister, /setup/virgin-reset, /container/destroy,
/container/reset, /workspace/reset, /workspace/destroy

Inspect + drive a running HD (v1.8.15+, post-v1.8.72 rework)

Verb What Status
hd_status Composes Bridge-side facts (running, repo SHA) + HD's /workspace/health + /buildinfo relayed ✓ shipped
hd_api Generic HTTP pass-through (above) ✓ v1.8.72+
hd_log '{"tail":30}' Tail HD's log file on disk (works EVEN when HD is down) ✓ shipped
hd_open_url '{"url":"...","browser":"chrome","profileDir":"Default"}' POST /open-in-profile - open URL in a specific browser profile ✓ shipped
hd_browser_profiles GET /browser-profiles - enumerate browsers + profiles ✓ shipped
hd_screenshot / hd_shot Find HD window, capture lossless PNG (background per-window capture); hd_shot adds region-scoped variant ✓ shipped
hd_eval '{"js":"..."}' POST /eval - run JS in HD's main webview Moved to hd-dev in v1.8.72. Public returns verb_moved_to_hd_dev. Use hd_api '{"method":"POST","path":"/eval","body":{"js":"..."}}'
hd_iframe_eval '{"js":"...","contextIndex":0}' POST /iframe-eval - JS in code-server iframe via CDP ⚠ Moved to hd-dev. Use hd_api equivalent
hd_reload_vscode POST /reload-vscode ⚠ Moved to hd-dev. Use hd_api equivalent
hd_container_exec '{"command":"..."}' POST /container-exec ⚠ Moved to hd-dev. Use hd_api equivalent
hd_launch / hd_stop / hd_restart HD process lifecycle ✓ shipped
hd_ship Atomic build+relaunch+verify-SHA (see below) ✓ shipped
hd_build* (v1.8.16+) Build pipeline orchestration ⚠ Moved to hd-dev in v1.8.72 (HD developers only)

Atomic ship - hd_ship (v1.8.49+) - START HERE for post-push rebuilds

One verb, one call, one structured pass/fail. Replaces the 4-step chain hd_stophd_build_rust → poll hd_build_statushd_launchcurl :47084/buildinfo. Eliminates the "is the new code actually running?" guesswork after every push.

Sequence inside one call:

  1. kill HD + poll until zero instances (frees the binary lock)
  2. git fetch && git reset --hard origin/main && rev-parse HEAD - asserts HEAD == expectSha (fails fast if GitHub hasn't replicated the push yet)
  3. if clean:true, cargo clean -p adom-hydrogen (defeats stale-relink)
  4. cargo build and parse stderr for Compiling adom-hydrogen to know whether anything actually compiled
  5. launch + poll http://127.0.0.1:47084/buildinfo until it responds + assert build_sha == expectSha
# End-to-end after a push:
git push && SHA=$(git rev-parse HEAD) && \
  adom-desktop hd_ship "{\"expectSha\":\"$SHA\"}"

Args:

  • expectSha - REQUIRED string. Full or prefix SHA of the commit you want running. Bidirectional-prefix match (abc123 matches the full SHA starting with abc123 and vice versa).
  • clean - optional bool, default false. true forces cargo clean -p adom-hydrogen before build. Use when a prior hd_ship succeeded but runningSha != expectSha (the stale-relink trap - cargo said Finished but reused the cached binary).

Returns on success: {ok:true, builtSha (== expectSha), runningSha (== expectSha), compiled:bool, elapsedSecs, _hint}. compiled:false on success means cargo found nothing to recompile (cached relink or HEAD didn't actually change - usually fine but surface to caller in case the push was a no-op).

Returns on failure: {ok:false, failedAt:'args'|'kill'|'git'|'clean'|'build'|'launch'|'verify', error, builtSha (set after git step), runningSha (set if launch worked but verify saw the wrong SHA), compiled (set after build step), elapsedSecs, _hint}. The partials let you reason about WHERE it broke:

failedAt What it means
args expectSha missing / malformed
kill Couldn't kill HD (rare - usually permissions)
git git fetch / reset failed, OR HEAD != expectSha after reset (push hasn't replicated to origin/main yet - retry in a few seconds)
clean cargo clean failed (rare)
build cargo build failed - read _hint for next step (usually hd_build_log to see the compile error)
launch Cargo finished but the adom-hydrogen binary wouldn't start - binary_missing or process spawn error
verify HD launched but /buildinfo either never responded or reported a different SHA than expectSha (the stale-relink trap - retry with clean:true)

Long-running: cold cargo build can take 2-3 min. CLI WS timeout is 12 min - call as a single sync invocation and wait. Don't poll inside hd_ship; it does its own internal polling.

Lower-level build + lifecycle suite (v1.8.16+, enriched in v1.8.52+)

Use these directly only when you need finer control than hd_ship provides (e.g. frontend-only build, watching log stream live, manual launch sequencing). For the common "I pushed code, get it running" path use hd_ship.

The relay's shell_execute has a 30s timeout; HD's pnpm build + cargo build exceed that, so the build is split into async-spawn verbs + sync poll/tail verbs:

# Frontend-only rebuild, watch progress, relaunch (no hd_ship since hd_ship is Rust-build-only)
adom-desktop hd_stop
adom-desktop hd_build_frontend '{"show":false}'      # returns {ok, pid, logPath} immediately
OFFSET=0
while true; do
  RESP=$(adom-desktop hd_build_tail "{\"offset\":$OFFSET}")
  echo "$RESP" | jq -r '.lines[]'
  DONE=$(echo "$RESP" | jq -r '.done')
  OFFSET=$(echo "$RESP" | jq -r '.newOffset')
  [[ "$DONE" == "true" ]] && break
  sleep 3
done
adom-desktop hd_build_status      # confirm succeeded:true && relinkOnly:false
adom-desktop hd_launch            # start the new debug binary
hd_build / hd_build_frontend / hd_build_rust - async-spawn build verbs
Verb What
hd_build '{"show":false}' Full async build: git pull + pnpm build + cargo build. Returns instantly with {pid, logPath, flavor:'full'}. Final log line is BUILD_OK or BUILD_FAILED: <step>.
hd_build_frontend Just pnpm build (root). Returns {pid, logPath, flavor:'frontend'}.
hd_build_rust Just cargo build (src-tauri/). v1.8.52+: dispatch response also carries builtSha + exeMtimeBefore + exeSizeBefore (captured at spawn time into the temp dir's hd-build-meta.json) so the subsequent hd_build_status / hd_launch don't need a separate git rev-parse + stat call. Returns {ok, pid, logPath, flavor:'rust', show, lingerSecs, builtSha, exeMtimeBefore, exeSizeBefore, _hint}.
hd_build_status - sync state probe (v1.8.52+ enriched)

Returns:

{
  ok, building, succeeded, failed,
  lastLines:[string],                  # last ~40 lines of stderr
  logPath, pid, pidAlive,
  failureReason,                       # 'compile_error' | 'exe_locked' | 'orphaned_no_sentinel'
  compiledCrates:[string],             # names parsed from 'Compiling X v...' lines
  relinkOnly:bool,                     # true iff succeeded && compiledCrates is empty
  exeChanged:bool,                     # mtime OR size after differs from before
  exeMtimeBefore:int, exeMtimeAfter:int,   # unix seconds
  exeSizeBefore:int,  exeSizeAfter:int,    # bytes
  exeLocked:bool,                      # true when build failed because the adom-hydrogen binary was held
  holderPids:[int],                    # PIDs holding the binary - kill these
  builtSha:string|null,                # SHA hd_build_rust/hd_build was dispatched at
  elapsedSecs:int,
  flavor:string,                       # 'rust' | 'frontend' | 'full'
  _hint
}

relinkOnly:true on success is the silent-fail trap to look for - your push didn't actually rebuild because cargo found nothing to recompile. Usually means your source changes weren't pulled, or you touched a file the workspace doesn't track. Use hd_ship with clean:true to force a fresh build.

exeLocked:true on failure + holderPids tells you exactly which PIDs to kill before retry; or just use hd_ship which kills HD before building.

hd_launch - start the debug binary (v1.8.52+ enriched)

Args: killExisting - optional bool, default false. When false and HD is already running, refuses with reason:'already_running' so you don't accidentally attach to a stale process. When true, kill the existing instance with a 600 ms grace period before launching fresh.

Returns on launch: {ok:true, launched:true, wasAlreadyRunning:bool, killedFirst:bool, pid:int (HD's actual PID, not the cmd-wrapper), exePath, builtSha:string|null, _hint}.

Returns on refusal: {ok:false, launched:false, wasAlreadyRunning:bool, pid:int|null, killedFirst:false, reason, _hint}. reason ∈:

  • build_in_progress - wait for hd_build_status.building:false
  • build_failed - fix and retry hd_build (or use hd_ship)
  • already_running - pass killExisting:true to force-kill+relaunch, or use hd_restart
  • binary_missing - the target/debug/adom-hydrogen binary not on disk; run hd_build_rust (or hd_ship) first

launched:true, wasAlreadyRunning:false, killedFirst:false is the proof you need that truly fresh code is running. Any other combo means you should compare builtSha to HD's actual /buildinfo.build_sha.

hd_stop / hd_restart / hd_build_log / hd_build_tail
Verb What
hd_stop Force-kills the adom-hydrogen process. {wasRunning} distinguishes killed vs no-op.
hd_restart Stop + launch in one call. Same guards as hd_launch (skip already_running).
hd_build_log Full log dump from the temp dir's hd-build.log.
hd_build_tail '{"offset":N}' Incremental stream - {lines, newOffset, totalBytes, done, succeeded, _hint}. Pass newOffset back for the next chunk.

The show: true arg on any build verb opens a visible terminal window so the user can watch the build scroll. Default show: false runs hidden.

Auto-close (v1.8.23+). Visible build windows auto-close 30 seconds after the final BUILD_OK / BUILD_FAILED line - long enough for the user to read the last error, short enough that build-after-build sessions don't accumulate orphan windows. The window prints a clear green/red "auto-closes in 30 seconds" banner before sleeping. Override with lingerSecs:

  • '{"show":true, "lingerSecs":60}' - give yourself 60s instead of 30
  • '{"show":true, "lingerSecs":0}' - close immediately, no grace period
  • '{"show":true, "lingerSecs":3600}' - keep open for an hour (cap) The response includes the applied lingerSecs so you can confirm what landed. Hidden builds (show:false) ignore lingerSecs entirely - no window to linger.

Don't call hd_launch until hd_build_status shows succeeded: true - the guard refuses with reason: "build_failed" if you do, and the _hint tells the AI exactly what to do (hd_build_log to see errors, fix, rebuild). Same for hd_restart.

Named-region screenshots - hd_shot (v1.8.54+)

One verb replaces the desktop_list_windows | grep 'Hydrogen' → desktop_screenshot_window <hwnd> chain (3 calls + fragile HWND parsing). Captures a named region of HD, produces BOTH a full-res PNG and a Claude-safe-resized copy, and returns rich metadata so the AI never has to chain screenshot → pull → convert → crop → resize → Read manually.

Each shot is self-identifying: it includes the running build's SHA + local build time read from HD's /buildinfo so you can confirm you're looking at the build you expect before trusting what you see (no more "this screenshot was from the previous binary, not my fix" confusion).

Regions are deterministic - they resolve via HD window bounds + CDP element rects queried through HD's /eval endpoint. No AI-supplied pixel coords.

Args:

  • region - REQUIRED. One of:
    • full - whole HD window
    • titlebar - top ~40 px strip (HD's build SHA / timestamp readout lives here)
    • vscode - VS Code iframe pane via CDP element rect
    • setup-panel - Setup Steps panel (.setup-panel / .setup-panel-output)
    • wiki - wiki / browser tab pane
    • claude - Claude Code panel
  • resizeMax - optional int, default 1400. Longest side of the safe-resized copy. Use ≈800 to fit more shots in one Claude turn; 1568 for max fidelity.

Returns:

{
  ok, region,
  fullPath,                    # lossless raw PNG - for shotlog/archival, NOT Read
  safePath,                    # ≤resizeMax PNG - pull_file this, Read this
  fullSize:[w,h], safeSize:[w,h],
  clipRect:[x,y,w,h],          # captured-image pixels (NOT screen pixels)
  capturedAtLocal,
  runningBuildSha,             # from HD's /buildinfo - null if HD doesn't expose it yet
  runningBuildLocal,
  hdWindowFound:bool, regionFound:bool,
  windowOuter:[w,h], capturedSize:[w,h],
  _hints: {
    pull,                      # exact pull_file command for safePath
    fullForArchive,            # note that fullPath is for archive, not Read
    staleWindow,               # set when runningBuildSha looks wrong
    regionMiss                 # set when regionFound:false
  }
}

Recipe - verify-which-build-is-running + confirm-config-change-took-effect + full dump:

# After hd_ship, confirm the titlebar shows the SHA you expect:
adom-desktop hd_shot '{"region":"titlebar"}'
# → runningBuildSha lets you assert this before trusting any other shot

# After flipping a Setup Steps toggle, screenshot the panel to confirm:
adom-desktop hd_shot '{"region":"setup-panel","resizeMax":1568}'

# Full-window dump for general debugging:
adom-desktop hd_shot '{"region":"full"}'

Edge cases:

  • Unknown region - refusal: {ok:false, error, knownRegions:[...]}. No fallback - fix the call.
  • regionFound:false - the element rect wasn't in HD's DOM. The panel/iframe may not be mounted yet, OR HD's markup changed and region_selectors() in hd_bridge.rs is out of date. The shot is still produced (falls back to the full window) so you have something to look at; check _hints.regionMiss for the reason.
  • runningBuildSha:null - HD's /buildinfo isn't exposed yet on the running version. The shot is still produced; you just can't self-verify which build it represents.

Always pull_file the safePath and Read THAT - it's already sized for Claude's image analysis. The fullPath is for shotlog / disk archival, NOT direct Read (may exceed Claude's per-image budget). The _hints.pull field gives you the exact pull_file command to copy-paste.

Old workaround to drop: Do NOT use desktop_list_windows | filter 'Hydrogen' | desktop_screenshot_window <hwnd> anymore for HD - hd_shot replaces that entire pattern. Generic desktop_screenshot_window still applies to non-HD windows (other apps, dialogs, etc.).

TCP Port-forward Tunnels - tunnel open|close|list|check|rdp + display (v1.8.85+, reworked v1.8.147)

Reach a TCP service addressable only from the cloud side through the Mac side: Bridge binds a 127.0.0.1:<port> listener on the Mac and pumps bytes over the existing Bridge↔relay WebSocket; the relay dials the upstream to. First use case: RDP into a Windows VM behind an NSG only the cloud container reaches - the RDP client on the Mac connects to a local port and is talking through the cloud. Same path works for SSH, VNC, HTTP, postgres - any TCP.

tunnel is a grouped verb (rdp / tunnel_close / tunnel_list remain back-compat aliases):

# Open a VERIFIED tunnel (both legs probed before ok). Auto-picks a free local port.
adom-desktop --target <name> tunnel open '{"to":"127.0.0.1:13389"}'

# RDP: open + verify + launch the RDP client, sized to the Mac's real screen.
adom-desktop --target <name> tunnel rdp '{"to":"127.0.0.1:13389","size":"90%"}'
#   size: "90%" (default) | "100%" | "fullscreen" | "maximized"
#   OR monitor:<index> (0=primary)  OR explicit width+height

adom-desktop --target <name> tunnel list           # live tunnels + leak detection
adom-desktop --target <name> tunnel check '{"tunnelId":<id>}'   # per-leg health
adom-desktop --target <name> tunnel close '{"tunnelId":<id>}'   # or {"all":true}

adom-desktop --target <name> display               # monitor geometry (for sizing)

--target is mandatory when >1 Bridge is connected. v1.8.147 fixed a false-success bug: the bind, the upstream probe, and the client launch now ALL resolve to the SAME machine. No target + multiple Bridges → ambiguous_target listing the names; --target all is rejected (a tunnel binds one machine). adom-desktop targets lists connected Bridges.

Active health verification - every open/rdp/check PROVES the path before reporting ok:

  • local-bind - Bridge self-probes 127.0.0.1:<boundPort> right after binding. If it can't connect, Bridge tears the tunnel down and returns ok:false (this alone kills the false-success class).
  • upstream-refused - the relay dials to (retry/backoff ~4×/2.5s). If unreachable, the tunnel is torn down (no leaked row) and ok:false.
  • A failure names the leg: failedLeg: "relay-to-ad" | "local-bind" | "upstream-refused". Health fields surfaced: listening, relayConnected, upstreamReachable, connCount, clientAlive.

Progressive hints. Every response carries _hint (what's true now) + _next (the exact next verb with real values, copy-pasteable). e.g. a successful open's _next hands you the tunnel rdp / tunnel check / tunnel close commands with the real to/target/tunnelId filled in. A missing to returns a structured error with an example. Walk the workflow from the responses alone.

Display-aware RDP sizing. tunnel rdp resolves size/monitor/width/height against the Mac's real screen (queried via display), writes an .rdp file with the geometry, and opens it with the user's RDP client (Microsoft's Windows App / Remote Desktop). e.g. size:"90%" on a 2560x1600 primary → a 2304x1440 window. Default 90% of primary. The response carries clientAlive (did the client stay up past launch). You never hardcode a resolution - call display if you want the layout first.

tunnel list reconciles + reaps leaks. It cross-checks the relay registry against every connected Bridge's own registry and reports leaks:[...] for rows only one side knows about; tunnel close '{"all":true}' reaps them. The relay stores the actually-bound localPort (so tunnel close '{"local":<port>}' works).

Architectural notes for Claude:

  • Bytes-only - no payload parsing, so any TCP service works. TLS/cert-pinning still pins to whatever the cloud-side dial resolves (for RDP the upstream cert CN must match what the RDP client expects).
  • Each fresh laptop-side TCP connection gets its own connId multiplexed over one tunnelId; concurrent client reconnects / parallel sessions work without re-opening. Backpressure bounded (256-slot mpsc/conn, 64 KiB buf).
  • Tunnels don't survive Bridge/relay restarts. If the client disconnects, tunnel check (names the dead leg), then re-open.
  • The default local port is 13389 + SO_EXCLUSIVEADDRUSE + ephemeral fallback (fellBack:true in the response; read localPort for the real port).

Old workarounds to drop: (1) NSG WAN-IP whitelisting for roaming-laptop RDP - the tunnel rides existing auth, reachable regardless of WAN IP. (2) Trusting a bare ok:true from the old rdp - it could be a dead port; now ok means both legs were probed. (3) Hardcoding the RDP resolution - pass size/monitor; Bridge resolves against the real screen.

Shell - shell_execute (escape hatch only)

  • shell_execute -- Run a shell command on the desktop. The CLI handles approval polling internally - it returns the final {success, output, error, exitCode} once the user clicks Allow on the desktop dialog. You do not need to poll, retry, or call get_deferred_result yourself. The CLI emits HINT: lines to stderr while waiting (every 15s) so an AI Monitor sees progress.
  • shell_kill_all -- Kill all running shell commands and deny pending approvals.

v1.7.16 fix. Earlier versions (v1.7.15 specifically) had a regression where the CLI's approval-wait loop re-sent the command every 1s instead of polling for the deferred result; that produced an infinite "Another shell command is already waiting for approval" loop because each retry created a fresh approval the user could never out-click. v1.7.16 fixes it. If you see that error string with a >= 1.7.16 CLI, the bug is back - file an issue. If you see it with < 1.7.16, upgrade the CLI: adom-wiki asset get apps/adom-desktop docker_binary -o /usr/local/bin/adom-desktop.

Deprecated for download polling. As of v1.5.0, use desktop_watch_files / desktop_pull_glob for waiting on download arrivals - those don't require user approval, don't go through shell quoting, and are O(directory entries) rather than spawning a process every second. shell_execute itself stays as an escape hatch for genuinely shell-only operations (multi-step ad-hoc admin tasks, chained pipelines, etc.).

Use python3 for Python -- a bare python may not exist on macOS.

run_script - base64-payload script runner (v1.8.46+)

When you want to send a multi-line script to the Mac without the shell-quoting hell that shell_execute requires, use run_script. The script crosses the wire as base64 so NO escaping survives to be mangled by any shell layer. The caller never hand-escapes anything.

It requires shell auto-approve to be active - same trust level as shell_execute. It refuses cleanly with an actionable error if not approved; no per-call modal (it is a programmatic verb).

Reading the result - status + durationMs (v1.8.162+). EVERY CLI result carries a single authoritative status field so you never have to guess whether an empty stdout was a real empty-output success or a timeout:

status meaning what to do
ok completed (stdout may legitimately be empty) use exitCode/stdout/stderr
timeout did NOT finish in time (NOT an empty success) raise timeoutSeconds, or use a detached job (launch_process)
busy desktop couldn't service it (no client connected) check adom-desktop status; retry
error the command/verb failed read error / stderr / _hint

durationMs is the wall-clock of the call. exitCode/stdout/stderr pass through unchanged; on timeout the verb's partialStdout/partialStderr hold what was written before the kill. A timeout/busy result also carries a recovery _hint. For multi-minute work don't fight the timeout - use launch_process (detached, returns instantly) and poll.

run_script - base64 script + interpreter (bash default | zsh | sh):

SCRIPT_B64=$(python3 -c "import base64; print(base64.b64encode(b'''
echo \"multi-line script\"
VAR=\"value with spaces and \$dollars\"
echo \"VAR=\$VAR\"
exit 0
''').decode())")
adom-desktop run_script "{\"interpreter\":\"bash\",\"scriptB64\":\"$SCRIPT_B64\"}"
# → {success, ok, stdout, stderr, exitCode}

Args: interpreter (bash (default, /bin/bash 3.2) | zsh (the macOS login shell) | sh (POSIX)), scriptB64 (required), timeoutSeconds (default 60).

The decoded script bytes are piped to the interpreter's stdin (no temp file); the interpreter reads and executes without ever opening a file, so nested quotes, $, backslashes, newlines and pipes all survive verbatim.

Detecting App Installation

After connecting, always run adom-desktop status to check what's installed. The desktop.apps object tells you exactly what the user has:

adom-desktop status
# Look at the desktop.apps field in the response

Handling "not installed" errors

When a command returns errorCode: "node_not_found", errorCode: "kicad_not_installed", or errorCode: "fusion_not_installed", guide the user through installation:

Picking the right native browser + profile (v1.7.1+):

When you need to open a URL in the user's NATIVE browser AND it matters which account is signed in (work Google Workspace vs personal Gmail vs media YouTube channel etc.), desktop_open_url alone isn't enough - you need to target a specific profile. The flow:

# 1. Discover what's installed + which profiles are configured.
adom-desktop desktop_list_browsers '{}'
# → {
#     "default": "chrome",
#     "browsers": [
#       { "name": "chrome", "displayName": "Google Chrome", "version": "146...",
#         "exePath": "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
#         "profiles": [
#           { "id": "Default",   "name": "Alice (work)", "gaia": "[email protected]",     "isDefault": true },
#           { "id": "Profile 1", "name": "Personal",     "gaia": "[email protected]" },
#           { "id": "Profile 2", "name": "Adom Media",   "gaia": "[email protected]" }
#         ] },
#       { "name": "edge", "profiles": [...] },
#       { "name": "firefox", "profiles": [{"id":"default-release","name":"default","isDefault":true}] }
#     ]
#   }

# 2. Match the URL's context to the right profile, then open it there.
# E.g. opening a Google Doc shared by your work team → use the work profile:
adom-desktop desktop_open_url '{
  "url":"https://docs.google.com/document/d/...",
  "browser":"chrome",
  "profile":"Default"
}'
# Or YouTube channel management for the media account:
adom-desktop desktop_open_url '{
  "url":"https://studio.youtube.com",
  "browser":"chrome",
  "profile":"Profile 2"
}'

Profile-picking heuristics for Docker Claude:

  • Workspace / @adom.inc URLs → match profile.gaia.endsWith("@adom.inc") and not the media@ one → typically Default.
  • Personal Gmail / Drive / etc. → match profile.gaia === "[email protected]" (or whatever the user's personal address resolves to).
  • YouTube Studio / channel-specific work → match the media account profile.
  • No clear match → fall back to browser:"default" (no profile flag) and let the user pick.

profile is optional. Omit it to open in whichever profile the browser was last using (legacy v1.6.x behavior). Profile flag is silently ignored when browser:"default" (no clean way to inject through the OS URL handler - name the browser explicitly to use it).

Node.js not installed (the puppeteer bridge can't auto-spawn):

Rare on this build: Bridge manages its own portable Node runtime (fetched on first launch, no admin rights). If a browser_* command ever returns errorCode:"node_not_found", trigger the managed install:

adom-desktop desktop_install_node '{}'
# → fetches the managed portable Node runtime (~40 MB, ~30-90s; idempotent).
# → returns {ok:true, installed:true, source:"managed-portable", nodePath, _hint} on success.

After install, just retry the original browser_* command - the bridge auto-spawns with the managed node. Node detection also honors a system Node on PATH and Homebrew installs (/opt/homebrew/bin, /usr/local/bin). If detection misses a real install on a user's machine, get the actual node path from the user - that's a bug to file.

KiCad not installed:

desktop_install_kicad is not available on macOS (it was a winget verb). Install it FOR the user instead of pointing them at a download page: with shell approval granted, run brew install --cask kicad via shell_execute (or download the macOS dmg from https://www.kicad.org/download/macos/ and open it for them). Then call kicad_list_versions to verify - the kicad bridge caches detection results, so a fresh scan may be needed to pick up a fresh install.

KiCad detection on macOS scans /Applications/KiCad/ and /Applications/ directly. If detection misses a real install, that's a bug to file - get the actual install path from the user.

Fusion 360 not installed:

Fusion 360 isn't installed on your desktop. Would you like to install it? Download from: https://www.autodesk.com/products/fusion-360 It's free for personal/hobby use (requires an Autodesk account). Let me know when the install is done and I'll verify the connection.

After install, have them relaunch Adom Bridge (or just wait for the bridge's next scan), then run adom-desktop status to verify.

Handling "not running" errors

When errorCode: "fusion_not_running", launch it programmatically with the first-class startup command:

adom-desktop fusion_start
# Discovers the Fusion install on the Mac and launches it.
# From Docker: delegates to the bridge on the connected desktop via relay.
# ~15-30s typical. Auto-dismisses startup dialogs.
# Returns {"addinReady": true, "pickerDismissed": true|false, ...}

KiCad doesn't need to be running for most commands (the bridge launches it on demand).

Handling "add-in not installed" errors

When Fusion is running but addinInstalled: false or addinConnected: false:

The AdomBridge add-in needs to be installed in Fusion 360. I can install it for you - this lets me control Fusion remotely.

The add-in auto-installs when the Fusion bridge starts and Fusion is detected.

Handling fusion_addin_not_responding errors

When errorCode: "fusion_addin_not_responding", Fusion is running but the AdomBridge add-in isn't answering. Call fusion_dismiss_blocking_dialogs FIRST - a modal dialog is the most common cause. If that doesn't help, try fusion_start to restart Fusion cleanly. Last resort: user enables add-in manually via UTILITIES > ADD-INS > AdomBridge > Run on Startup + Run.

Handling main_thread_busy errors

When errorCode: "main_thread_busy", the Fusion add-in's main thread is occupied by a long-running command (typically walk_cloud_tree or search_cloud_files). This applies across all bridges/sessions - even if you didn't start the walk, another session might have.

Do NOT:

  • Retry the failed command - it will block behind the same lock
  • Call fusion_dismiss_blocking_dialogs - there's no dialog to dismiss, and sending Escape will interrupt the active walk
  • Force-kill Fusion - the walk will complete on its own

Do:

  • Wait for the walk/search to finish. If you started it, you should be using adom-desktop watch (see "Live folder progress streaming with watch" above) which streams live progress automatically. If another session started it, poll fusion_addin_status every 2–5s to check progress.
  • Use commands that don't need the main thread while waiting:
    • fusion_addin_status - check busy state and walk progress
    • fusion_window_info - get window HWND, title, dialogs
    • fusion_screenshot_fusion - capture what Fusion looks like
    • fusion_click_fusion - click in the Fusion window
    • fusion_send_key - send keyboard input
    • fusion_close_window - close a specific dialog by HWND

The response includes progress info:

{
  "errorCode": "main_thread_busy",
  "busyCommand": "walk_cloud_tree",
  "elapsedSeconds": 42.3,
  "walkProgress": {
    "foldersVisited": 15,
    "filesFound": 87,
    "currentFolder": "Molecules/XRP",
    "queueSize": 8
  },
  "_hint": "Add-in is busy with a long-running command. Do NOT retry..."
}

Stalled walk detection: If fusion_addin_status returns busy: true but walkProgress is missing and mainThreadStalled: true, a modal dialog is blocking the event loop - the walk was dispatched but never started. Call fusion_dismiss_blocking_dialogs, then the walk auto-resumes.

Auto-recovery (_autoRecovery field)

When a fusion_* command fails with "not responding" / "not connected", the CLI automatically:

  1. Calls fusion_dismiss_blocking_dialogs to clear any modal
  2. If successful, retries the original command
  3. Attaches _autoRecovery: {action, dismissed[]} to the retry result

If you see _autoRecovery in a response, the command already succeeded after auto-dismissal - no manual intervention needed. The field is informational.

Troubleshooting

Check connection status

Use status to see connected clients. A healthy connection shows one client from the user's hostname with a recent lastPong timestamp.

Stale connections causing timeouts

Use kick_all to reset -- active Adom Bridge apps reconnect within seconds.

No desktop client connected

  1. Confirm the Adom Bridge app is running on the user's Mac (menu-bar item present; no Dock icon is normal)
  2. Confirm it's pointed at the correct WebSocket URL
  3. Check if port 8765 is exposed and reachable

Relay not running

curl -sf http://127.0.0.1:8766/health
# If fails: adom-desktop serve --supervise

Shell commands on the Mac

shell_execute runs via sh -c on the Mac. For multi-line work prefer run_script (base64, no quoting hell). Use python3, not python.

Building from Source

cd cli && cargo build --release
# Binary at: cli/target/release/adom-desktop

Repo

github.com/adom-inc/adom-desktop

Run wiki ops on the user's Mac - wiki_exec (v1.9.116+)

Bridge bundles the adom-wiki CLI inside the app (Contents/Resources), and wiki_exec runs it ON THE MAC - so version-controlling a local project (KiCad board, docs folder) on wiki.adom.inc needs NO per-file pull_file round trip to the container and there is no container/Mac drift.

adom-desktop wiki_exec '{"args":["repo","push","adom/my-board","--files","board.kicad_pcb","board.kicad_sch","-m","rev B"],"cwd":"/Users/me/boards/my-board"}'
  • args = adom-wiki argv (any pillar: repo, pkg, release, discussion). cwd = Mac-side project dir so --files are project-relative. timeoutSec optional (default 120, max 600).
  • Returns {success, exitCode, timedOut, stdout, stderr, exe, durationMs}.
  • Auth is automatic: Bridge injects the signed-in user's session token as ADOM_WIKI_TOKEN - the caller never handles credentials.
  • Gated like shell_execute for relay callers (it writes to the wiki as the user). errorCode:wiki_cli_missing means the desktop Bridge predates 1.9.116.
  • Container-side publishing of the CONTAINER's own files still uses your local adom-wiki directly; wiki_exec is for files that live on the Mac.

Note (v1.9.117): Bridge's Settings panel no longer has a KiCad section - bridges own their app-specific config (Bridge stays generic). kicad_library_paths / project_watch keep working from ~/.adom/config.json and the set_project_watch / trigger_project_watch verbs.