name: adom-desktop-direct-api description: Direct HTTP API on the adom-desktop GUI. Use when authoring a sibling Tauri app (Hydrogen Desktop, future Adom-family apps) on the same Windows machine that needs to send commands into adom-desktop without spawning the CLI binary or going through the WebSocket relay. Local-only (loopback bind).

Direct HTTP API — discover via ~/.adom/direct-api-port

Port is no longer fixed. As of v1.8.89, AD's direct API binds an OS-assigned ephemeral port whenever the fixed 47200..47209 range is unavailable (Windows HNS / Hyper-V silently reserves chunks of that range on most dev machines — see PORTS.md for the full story). Sibling apps MUST read the discovery file %USERPROFILE%\.adom\direct-api-port (single-line host:port, e.g. 127.0.0.1:55272) before any hardcoded probe. The legacy 47200..47209 scan stays as a back-compat fallback for older AD installs.

Old: hardcoded :47200

Sibling apps running on the same Windows host as adom-desktop can POST JSON commands directly into the running GUI process — no CLI process spawn, no WebSocket relay, no auth-token dance. The endpoint lives inside the GUI itself (loopback-only 127.0.0.1 bind, can't be reached off-box), uses the same dispatcher as the WebSocket path, and returns the same JSON shape — including every _hint field — that adom-desktop <verb> returns from the CLI.

This skill is for sibling-app authors (Hydrogen Desktop's Tauri side, plus any future "Adom-family" desktop app). Docker callers should continue using the CLI or the WS proxy — those paths handle cross-machine transport and binary streaming, which the direct API intentionally doesn't.

Shipped in adom-desktop v1.8.25+. Requires the adom-desktop GUI to be running on the host.

When to use this (vs. the CLI)

You are… Use
A sibling Tauri app on the same Windows machine as adom-desktop, calling sync verbs like server_add, bridge_list, hd_status, desktop_screenshot_window Direct API. One HTTP round-trip, ~5 ms, no process spawn.
Inside Hydrogen Desktop's local Docker container, using the adom-desktop CLI Just use the CLI. As of v1.8.27 it auto-detects the direct API at host.docker.internal:47200 on every invocation and routes there transparently. Zero config in HD's container. The verb returns identical JSON to the cross-machine path.
On the Windows host, running the adom-desktop.exe CLI directly Same — just use the CLI. Auto-detects 127.0.0.1:47200 and routes through the direct API. ~60 ms warm per call vs ~150 ms for the relay path.
Docker / Linux / cross-machine caller The CLI adom-desktop <verb> (probe fails, falls through to the wss proxy → relay → GUI WS path — same behavior as v1.8.26 and earlier).
A sibling app needing pull_file, send_files, or shell_execute Spawn the CLI binary. The direct API refuses these with errorCode:"cli_required". The CLI itself handles the fallback automatically when called.

CLI auto-route (v1.8.27+)

When the adom-desktop CLI binary runs in any of these contexts, it probes the direct API on the first verb invocation and caches the result for 30 seconds (in /tmp/adom-direct-probe.json):

  • Discovery file (v1.8.31+, fastest path): ~/.adom/direct-api-port (Windows: %USERPROFILE%\.adom\direct-api-port). Contents are host:port (e.g. 127.0.0.1:47201) and the CLI tries this first if present. The GUI writes the file at bind time and removes it at graceful shutdown.
  • Probe order (first 200 OK wins): 127.0.0.1:47200, host.docker.internal:47200, localhost:47200
  • Fallback scan (v1.8.31+): if the discovery file is missing/stale AND the default candidates all fail, the probe scans 127.0.0.1:47201..47209. Cheap — each closed port refuses connection in <5 ms on Windows loopback.
  • Connect timeout: 400 ms per candidate — fails fast when nothing's there
  • Override: $ADOM_DIRECT_URL=off forces relay-only; $ADOM_DIRECT_URL=http://…:47209 skips the probe entirely and uses that URL; auto (default) does the probe

The ping verb response now includes a transport field — direct-http or relay-ws — so you can confirm which path served the call. pull_file, send_files, and shell_execute always use the relay path (they have their own specialized streaming/approval flows). Everything else routes through the direct API when reachable.

Port-conflict auto-recovery (v1.8.31+, ephemeral fallback in v1.8.89+)

The GUI never treats any individual port as fatal. Bind walk:

  1. Probe first: connect to the candidate port and send a GET /health. If anything responds within 1 s, a LIVE process owns the port — skip rather than coexist (SO_REUSEADDR would let us bind too, but the kernel would route incoming connections nondeterministically between us and the other listener).
  2. Reuse-bind with SO_REUSEADDR set via socket2 BEFORE bind. On Windows, this lets us claim a port held by a dead PID's zombie LISTEN socket — provided that prior process also used SO_REUSEADDR. (Pre-v1.8.31 zombies are stuck until reboot; v1.8.31+ zombies always release on the next launch.)
  3. Walk the range 47200..47209 on three categorical errors: AddrInUse (10048), PermissionDenied (10013 — Windows' code for "can't take over a non-REUSEADDR zombie"), or live-owner skip.
  4. (NEW in v1.8.89) Ephemeral fallback: if all of 47200..47209 fail (always WSAEADDRINUSE — which is what happens when HNS / Hyper-V has silently reserved the whole chunk; see PORTS.md for why), bind 127.0.0.1:0 and let the OS pick. Whatever it picks goes to step 5. This case is COMMON on dev machines with Docker Desktop / WSL2 / Hyper-V VMs — the 47200..47299 chunk is reserved invisibly (doesn't show in netstat, doesn't show in excludedportrange, only fails-to-bind reveals it).
  5. Write the chosen port to ~/.adom/direct-api-port so callers don't have to scan.
  6. Report the bound address in /status.endpoint — never a hardcoded constant.

This handles all five practical cases:

  • Zombie from v1.8.31+ adom-desktop (was SO_REUSEADDR-bound) — new bind succeeds, takes over the same port.
  • Zombie from older code OR force-killed third-party (exclusive bind) — WSAEACCES on bind; walk to next port.
  • Live owner (Hydrogen Desktop's bridges, dev instance, anything responding) — probe detects, skip to next port.
  • Empty port — bind cleanly.
  • HNS reserved the whole range (v1.8.89+) — ephemeral fallback wins.

Graceful shutdown (v1.8.31+)

When the GUI exits via the tray "Quit" menu (or any path that calls app.exit(0)), Tauri's RunEvent::Exit fires direct_api::shutdown() which:

  1. Sends a oneshot signal to the axum server
  2. with_graceful_shutdown stops accepting new connections and drains in-flight ones (~tens of ms)
  3. The TCP listener drops, releasing the port back to the OS immediately
  4. The discovery file is removed so callers don't connect to a port that's about to close

This prevents zombie sockets on clean exits. For force-kill scenarios (taskkill /F, crash, BSOD), zombies can still exist temporarily — but the v1.8.31+ reuse-bind pattern means the NEXT launch can take over the same port instead of having to walk past it. Two layers of defense.

Endpoints

GET /health

Cheap probe. Use to detect "is the GUI running" before falling through to the CLI fallback.

curl -sf http://127.0.0.1:47200/health
# → {"ok":true,"service":"adom-desktop"}

Returns 200 + JSON when the GUI is up and the listener bound successfully. Connection refused (or timeout) means: GUI not running OR the port was already taken by something else when the GUI started. The CLI binary's serve mode does NOT bind 47200 — only the GUI does.

GET /status

Service banner + version + capability inventory. Read once on sibling-app startup to learn the verb surface and the cliRequired list.

curl -sf http://127.0.0.1:47200/status
{
  "ok": true,
  "service": "adom-desktop",
  "version": "1.8.25",
  "schema": 1,
  "transport": "direct-http",
  "endpoint": "http://127.0.0.1:47200",
  "directApi": {
    "cliRequired": ["pull_file", "send_files", "shell_execute"],
    "note": "Everything else is safe via direct POST /command. The listed verbs use binary streaming or multi-minute approval flows; spawn the `adom-desktop` CLI binary for those.",
    "envelope": "{\"app\": <namespace>, \"command\": <verb>, \"args\": <args object>}",
    "responseShape": "Identical to what `adom-desktop <verb>` returns — same `_hint` fields, same `success`/`ok`/`error` keys, same payload structure. The CLI and direct paths converge in `commands::handle_command`."
  },
  "wsl": {
    "wslProcessCount": 18,
    "wedged": false,
    "_hint": null
  },
  "_hint": "POST /command with {app, command, args}. See https://wiki.adom.inc/adom/adom-desktop for the verb catalog."
}

The wsl block (v1.8.157+) surfaces WSL health: wslProcessCount is a cheap Toolhelp snapshot of live wsl.exe; wedged:true (at/above 50) warns that orphaned/hung clients are piling up. When wedged, _hint points to wsl_recover. If a wsl_exec/run_script call returns errorCode:"wsl_wedged" (refuse-cap at 80), the machine is wedging — call wsl_recover (dry-run, then {confirm:true}) before retrying, rather than spawning more doomed clients. wsl_recover is reachable via POST /command {app:"desktop", command:"wsl_recover"} (the desktop_ prefix is optional).

The schema field is the contract version. v1 is the only one shipped. If schema > 1 ever appears, expect a breaking change in the envelope/response shape and read this skill again.

GET /commands (v1.8.156+)

Verb discovery — what {app, command} can a /command caller reach? A bridge delegating to AD no longer has to probe verb-by-verb and hit "Unknown desktop command".

curl -sf http://127.0.0.1:47200/commands
{
  "ok": true,
  "desktop": {
    "app": "desktop",
    "commands": ["list_windows","find_window","screenshot_window","taskbar","flash_window","activity_filter","run_script","process_list", "..."],
    "note": "POST /command {app:\"desktop\", command}. The desktop_ prefix is OPTIONAL (desktop_taskbar == taskbar). Bad/missing args → the verb's _hint returns the arg schema."
  },
  "bridges": [{"name":"native-browser","verbPrefixes":["nbrowser_"],"verbs":["..."],"paused":false}],
  "cliRequired": ["pull_file","send_files","shell_execute"],
  "_hint": "Desktop verbs → app:\"desktop\" (prefix optional). Bridge verbs → app:\"dynamic\", command = full verb incl. prefix. cliRequired verbs need the CLI, not this HTTP API."
}

Pair it with the per-verb schema hints: call a verb with missing args and its _hint returns required/optional/example. Between /commands (what exists) and the bad-args hint (how to call it), a fresh AI needs no prior knowledge.

POST /command

Dispatches a verb. Body envelope (app is OPTIONAL as of v1.9.84 — inferred from the command):

{
  "command": "server_add",
  "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }
}

Full verb surface + cross-AD (v1.9.84)

A caller (a bridge especially) reaches every dispatchable verb here, not just desktop_* — same dispatcher as the CLI/WS path, identical JSON back (_hint/errorCode/statusVerb). GET /commands lists the full set to capability-probe.

  • app is optional — inferred (kicad_*→kicad, fusion_*→fusion360, browser_*→browser, hd_*→hd, aps_*→aps, any installed bridge's prefix→dynamic, else desktop). Pass app explicitly to override.
  • Top-level verbs now reachable: notify_user (toast → returns {action:"displayed"}), notify_response, targets (list the OTHER ADs on the relay), ping (this AD's liveness+version), plus bridge_list/bridge_info/refresh_bridges/runtimes/status.
  • target (cross-AD): add "target": "<clientName>" (from targets) or "all" to route the call to a PEER AD via the relay — e.g. a bridge on a VM notifies the user on their laptop. "self"/"local"/absent = local. "attended"errorCode:attended_unresolved (not built yet; use a concrete clientName).
  • X-Adom-Bridge-Token header = ATTRIBUTION only. A bridge MAY send its spawn-time ADOM_BRIDGE_TOKEN env value; AD then badges the call "bridge" in the Activity Log. It is not an approval gate — a bridge is trusted-by-install (127.0.0.1-bound; PERMISSION_MODEL.md). A stale/wrong token → 403; absent → fine. The full surface (incl. write_file/run_script) runs ungated for any local caller; only the remote relay origin is approval-gated.

Example — a bridge fires a toast to the user's laptop while running on a VM:

curl -s -X POST "$ADOM_DIRECT_API_URL/command" -H 'Content-Type: application/json' \
  -H "X-Adom-Bridge-Token: $ADOM_BRIDGE_TOKEN" \
  -d '{"command":"notify_user","target":"AdomLapper","args":{"title":"Action needed","body":"Approve the Chrome dialog on your laptop"}}'

Plain single-AD envelope (unchanged, app still honored when provided):

{
  "app": "desktop",
  "command": "server_add",
  "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }
}

Response is the verb's normal payload (200 OK), e.g.:

{
  "ok": true,
  "success": true,
  "name": "hydrogen-workspace",
  "url": "ws://localhost:8765",
  "id": "...",
  "connected": true,
  "created": true,
  "_hint": "Server registered. Relay commands for this container now route through adom-desktop. Use server_list to see all connections."
}

Error responses

HTTP When Body shape
400 Bad Request Envelope missing app or command {ok:false, error, _hint}
412 Precondition Failed Verb is in cliRequired list {ok:false, errorCode:"cli_required", _hint}
500 Internal Server Error Handler dropped the response channel (bug) {ok:false, errorCode:"handler_silent", _hint}
504 Gateway Timeout Handler didn't respond within the per-verb timeout (see below) {ok:false, errorCode:"timeout", _hint}

Every error carries an actionable _hint and (for non-400s) a stable errorCode string you can branch on.

Per-verb timeouts (v1.8.31+)

The 504 timeout is per-verb, not a hardcoded 120s ceiling:

Verb category Default timeout
walk_cloud_tree, search_cloud_files 620 s (10+ min — cloud trees can be hundreds of folders @ ~1 fps)
export_step/iges/sat/stl/3mf/usdz/obj/f3d/fbx/skp/source/eagle_source 320 s
export_gerbers 200 s
export_dxf/dwg 140 s
drc, erc 180 s
bridge_install, fusion_start 300 s
Everything else 120 s

Override per-call by passing args.timeout (seconds), clamped to a 1800 s ceiling:

{
  "app": "fusion",
  "command": "search_cloud_files",
  "args": {
    "query": "cosmiic",
    "recursive": true,
    "maxFolders": 1000,
    "searchTimeout": 1500,
    "timeout": 1700
  }
}

The CLI dispatcher honors args.timeout the same way, so the two paths stay symmetric.

Update endpoints (v1.8.134+) — HD-driven embedded auto-update

AD owns its own updater (fetch → SHA-verify → stage). HD does not download or install AD's bytes — it reads AD's update state and, at a safe moment, "clicks AD's Update button" via POST /update/apply. The contract is intentionally tiny and is meant to generalize to other HD-bundled children (e.g. the Adom screensaver): { update_status, apply, busy }.

GET /update/status

curl -sf http://127.0.0.1:47200/update/status
{
  "current_version": "1.8.133",
  "available_version": "1.8.134",
  "update_ready": true,
  "state": "staged",
  "busy": false
}
Field Meaning
current_version The running AD version. Plain dotted semver; compare component-wise, numeric, never-downgrade.
available_version Latest version AD knows about from its manifest (null until the first check). May equal current_version when on latest.
update_ready true only when a newer installer is already downloaded + SHA256-verified + staged on disk — appliable with no further network. Equivalent to state == "staged".
state One of idle | checking | downloading | staged | applying.
busy true while AD is mid-operation it shouldn't be interrupted for — an in-flight screenshot, a file transfer, or a bridge (kicad/fusion/puppeteer) command awaiting its reply. HD should defer the apply until busy == false. (A screen-share/getDisplayMedia source is reserved for when AD adds that feature.)

state machine: idlechecking (a manifest check is running) → downloading (fetching + verifying the installer) → staged (verified on disk; update_ready=true) → applying (apply triggered; AD about to restart). A failed check never un-stages a previously-staged update.

POST /update/apply

Applies the already-staged update — the programmatic equivalent of the user clicking "Update". No request body.

curl -sf -X POST http://127.0.0.1:47200/update/apply
{ "ok": true, "applying": true }
  • Only ever applies what AD itself staged + verified. If nothing is staged, returns HTTP 409 { "ok": false, "applying": false, "error": "no update staged …", "_hint": … }. Call POST /update/check first and poll /update/status until update_ready=true.
  • Embedded-aware: no modal / blocking UI. AD returns this JSON promptly (before it restarts), runs the staged installer, then exits cleanly ~1.5 s later.
  • Comes back in embedded mode automatically. AD's embedded marker on disk persists across the restart (the exit hook only drains the direct-API socket — it doesn't delete the marker), so the relaunched AD re-enters embedded mode via the marker + the HD-liveness probe. HD's normal adopt (POST /embedded/enter) on the next supervisor tick re-asserts it idempotently.

Restart handshake — HD MUST suspend respawn for the apply window

Because the apply runs the NSIS installer over the running AD exe, Windows' mmap lock on the running image means the file replacement only succeeds once AD has fully exited. If HD's supervisor respawns AD during the install window, it re-locks the exe and the replacement silently no-ops (AD comes back on the OLD version). So:

  1. GET /update/status → wait for update_ready == true && busy == false.
  2. POST /update/apply → on { applying: true }, stop respawning AD (suspend the ~5 s health-respawn) for ~60 s.
  3. AD exits → the staged installer replaces files → relaunches AD (installer POSTINSTALL). AD re-embeds via its marker.
  4. HD polls GET /update/status (or /health//buildinfo); when AD answers again with current_version == the new version, resume normal supervision + re-adopt (POST /embedded/enter).

HD initiated the apply, so HD already knows the restart is intentional — treat AD's disappearance in this window as expected, not a crash.

POST /update/check

Force a manifest check now. If a newer version is found, AD stages it in the background; this call returns the /update/status shape promptly (poll it until staged). No request body.

curl -sf -X POST http://127.0.0.1:47200/update/check

Returns the same JSON shape as GET /update/status.

Standalone vs embedded

  • Standalone AD: the normal user-facing "Update available → Update" footer button + flow are unchanged. auto mode still silently self-updates; prompt shows the banner; off disables checks (and staging).
  • Embedded AD: AD checks + stages on its own (so update_ready becomes true for HD) but never auto-applies — HD owns the apply timing via POST /update/apply. The embedded apply path never pops its own restart UI.

What the user sees in the GUI Activity Log

Each POST /command call shows up in adom-desktop's Activity Log panel with a direct:47200 badge (the port-explicit "server name" the dispatcher attaches to direct-API calls) plus the verb's normal event tag (desktop, kicad, hd, etc.). This is visually distinct from the localhost badge attached to WS-relay traffic going through the localhost dev server at ws://localhost:8865.

At startup, a single direct-api event-tagged entry says "direct-api listening on http://127.0.0.1:47200 (loopback-only). Sibling apps can POST /command without spawning the CLI." If the bind ever fails (port taken), the user sees a corresponding error entry — no silent failure.

Trust model

  • Loopback-only bind. The listener binds 127.0.0.1, never 0.0.0.0. Off-machine HTTP requests can't reach it.
  • No token required. Anything on the user's box can send commands. The loopback bind is the boundary. If you need stricter isolation, the WS path's auth-token handshake is what to use.
  • Same verb surface as the WS path. Every authorization check that lives inside a verb handler (shell auto-approve, file-path sandboxing, bridge-paused refusal) applies equally to direct calls — the dispatcher is the same code.

Reference integration: Hydrogen Desktop startup

This is the canonical pattern HD uses to register its container relay with adom-desktop at boot.

Rust (HD's src-tauri/src/lib.rs or equivalent)

use serde_json::json;
use std::time::Duration;

const ADOM_HEALTH: &str = "http://127.0.0.1:47200/health";
const ADOM_COMMAND: &str = "http://127.0.0.1:47200/command";

async fn register_with_adom_desktop(workspace_name: &str, relay_url: &str) -> Result<(), String> {
    // 1. Cheap probe — is adom-desktop running and listening?
    let client = reqwest::Client::builder()
        .timeout(Duration::from_secs(2))
        .build()
        .map_err(|e| format!("client init: {e}"))?;

    let healthy = client
        .get(ADOM_HEALTH)
        .send()
        .await
        .ok()
        .filter(|r| r.status().is_success())
        .is_some();

    if !healthy {
        // adom-desktop not running. Either tell the user to launch it,
        // or shell out to the CLI as the legacy fallback. Don't block
        // HD startup.
        log::warn!("adom-desktop not reachable on 127.0.0.1:47200 — relay will not be auto-registered");
        return Ok(());
    }

    // 2. POST the server_add command.
    let resp = client
        .post(ADOM_COMMAND)
        .timeout(Duration::from_secs(10))
        .json(&json!({
            "app": "desktop",
            "command": "server_add",
            "args": {
                "name": workspace_name,
                "url": relay_url,
                "autoConnect": true,
            }
        }))
        .send()
        .await
        .map_err(|e| format!("POST /command: {e}"))?;

    let body: serde_json::Value = resp.json().await.map_err(|e| format!("parse: {e}"))?;

    if body.get("ok").and_then(|v| v.as_bool()) == Some(true) {
        log::info!(
            "adom-desktop registered: name={workspace_name}, url={relay_url}, _hint={}",
            body.get("_hint").and_then(|v| v.as_str()).unwrap_or("")
        );
        Ok(())
    } else {
        Err(format!("adom-desktop refused: {body}"))
    }
}

TypeScript (HD's frontend, if it ever calls directly)

async function registerWithAdomDesktop(name: string, url: string): Promise<boolean> {
  // probe first
  const health = await fetch('http://127.0.0.1:47200/health', { signal: AbortSignal.timeout(2000) }).catch(() => null);
  if (!health?.ok) return false;

  const resp = await fetch('http://127.0.0.1:47200/command', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    signal: AbortSignal.timeout(10_000),
    body: JSON.stringify({
      app: 'desktop',
      command: 'server_add',
      args: { name, url, autoConnect: true },
    }),
  });
  const body = await resp.json();
  return body.ok === true;
}

Cleanup at shutdown

Mirror the registration with a server_remove:

curl -X POST http://127.0.0.1:47200/command \
  -H 'Content-Type: application/json' \
  -d '{"app":"desktop","command":"server_remove","args":{"name":"hydrogen-workspace"}}'

If adom-desktop has already exited, the connection refuses — that's fine, the server entry is stale either way. Treat shutdown registration cleanup as best-effort.

Embedded-mode integration (HD bundling AD, v1.8.42+)

When HD bundles AD, the direct API is the channel for HD's tray menu items and runtime control. HD spawns AD with --embedded --start-hidden --relay-url ... --session-token ..., then drives it via these direct-API calls:

HD action direct-API envelope
"Open Adom Desktop" tray menu item {app:"desktop", command:"window_show"}
Hide AD's window again {app:"desktop", command:"window_hide"}
"Connect All" / "Disconnect All" tray items {app:"desktop", command:"connect_all"} / disconnect_all
HD's "Quit" menu (cascade-stops AD) {app:"desktop", command:"shutdown"}
HD sign-out propagation {app:"desktop", command:"logout"}
Introspect embedded state {app:"desktop", command:"embedded_status"}
Force permanent shell auto-approve {app:"desktop", command:"set_shell_auto_approve", args:{permanent:true}}

embedded_status returns {embedded, owner, source, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists} — useful for HD's tray to know whether AD already booted embedded or it's running standalone.

// HD's tray-click handler — example
async function openAdomDesktop() {
  await fetch('http://127.0.0.1:47200/command', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ app: 'desktop', command: 'window_show' }),
  });
}

Note: HD typically calls set_shell_auto_approve {permanent: true} only as a recovery path — AD already defaults to permanent at embedded boot. Use it when toggling user-controlled preferences (e.g. an HD setting like "Auto-approve AD shell commands ON/OFF").

What about verbs not in the directApi list?

The cliRequired array in GET /status lists the verbs that need the CLI binary. Anything not in that list works over the direct API. The dispatcher is the same code; if a verb works via adom-desktop <verb> it works via direct POST.

Mapping CLI verb → direct-API envelope

The prefix-stripping rule varies per namespace. Easiest mental model: look at the CLI's cli/src/commands.rs dispatch line for the verb you want. Whatever string it passes as the second arg of relay::desktop_command(app, command, ...) is exactly what your direct-API envelope's command field should be.

v1.8.153+: for app:"desktop" the desktop_ prefix is OPTIONAL — POST the public verb name as-is. A bridge delegating via ADOM_DIRECT_API_URL/command can send the exact verb it knows (desktop_taskbar, desktop_flash_window, desktop_list_windows, desktop_find_window, desktop_screenshot_window, desktop_record_window_start/stop/status) OR the stripped form (taskbar, …) — both dispatch. (Before 1.8.153 the dispatch arms were unprefixed, so desktop_taskbar returned "Unknown desktop command" while run_script worked — that asymmetry is gone.)

app value What goes in command Examples (CLI verb → direct envelope)
desktop public verb name; desktop_ prefix optional (v1.8.153+) desktop_taskbar{app:"desktop", command:"desktop_taskbar"} (or "taskbar"); server_add{app:"desktop", command:"server_add"}
kicad stripped — drop the kicad_ prefix kicad_open_board{app:"kicad", command:"open_board"}
fusion360 stripped — drop the fusion_ prefix fusion_start{app:"fusion360", command:"launch"} (note: also the verb name shifts internally)
browser kept — pup bridge dispatches on the full name browser_open_window{app:"browser", command:"browser_open_window"}
hd stripped — drop the hd_ prefix hd_status{app:"hd", command:"status"}
dynamic full verb name with third-party bridge prefix preserved (dispatcher routes by prefix)

Common direct-safe verb groups:

Namespace Examples Notes
desktop server_add, server_remove, server_list, bridge_list, bridge_install, bridge_pause, bridge_resume, desktop_list_windows, desktop_screenshot_window, desktop_open_url, set_shell_auto_approve All sync, no prefix stripping.
hd status, eval, log, screenshot, build, build_frontend, build_rust, build_status, build_log, build_tail, launch, stop, restart build* returns instantly with {pid, logPath}; poll build_status or build_tail for progress.
kicad list_versions, open_board, open_schematic, run_drc, lint_board, install_symbol, ... Multi-version-aware.
fusion360 launch, export_step, walk_cloud_tree, window_info, get_app_state, ... 50+ verbs.
browser browser_open_window, browser_navigate, browser_screenshot, browser_eval, browser_click, browser_record_start Per-tab. Keep the prefix.

Failure modes & retry

The direct API doesn't auto-retry. Sibling apps should:

  1. Probe /health before any /command so a missing GUI is a clean "not available" branch, not a noisy 10-second timeout on every dispatch.
  2. Per-request timeouts ≥ the verb's natural duration. Most verbs are sub-second; hd_build* returns in <2 s; bridge_install can be 30 s for a large zip. Pick the timeout based on the verb you're calling.
  3. Treat 504 timeouts as "unknown state, ask later" rather than retrying blindly. A successful retry on a 504 might double-register a server or double-install a bridge.
  4. Read the _hint and errorCode fields on every error response. They're machine-friendly: cli_required, timeout, handler_silent, plus whatever the verb's handler emits (bridge_not_found, binary_missing, etc.).

Version skew

If GET /status returns version < 1.8.25 OR fails entirely OR schema is missing, the direct API isn't present (older GUI). Fall back to spawning the CLI binary. The CLI has been the supported integration path since v1.7.x; everything that works via the direct API also works via the CLI, just slower.

Why a separate port from the WS relay's 8766?

  • Different transport, different trust model. 8766 is the relay's HTTP endpoint, which goes through a WS bridge to the GUI and gates non-CLI User-Agents. 47200 is direct to the GUI, no auth, loopback-only.
  • Different process lifecycle. 8766 needs adom-desktop serve running (a separate process). 47200 is inside the GUI — if the GUI is running, 47200 is up.
  • No risk of breaking Docker. Docker callers keep using 8766 + the WS proxy. Sibling apps get 47200. They don't fight for the same port or auth scheme.

See also