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 — 127.0.0.1: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+)

The GUI no longer treats "port 47200 is taken" as fatal. Walk-bind loop:

  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. Write the chosen port to ~/.adom/direct-api-port so callers don't have to scan.
  5. Report the bound address in /status.endpoint — never a hardcoded constant.

This handles all four 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.

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`."
  },
  "_hint": "POST /command with {app, command, args}. See https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-desktop for the verb catalog."
}

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.

POST /command

Dispatches a verb. Body envelope is identical to the WS protocol's CommandPayload:

{
  "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.

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.

app value What goes in command Examples (CLI verb → direct envelope)
desktop full verb name (no prefix to strip) 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