← Commit history

Delete skills/adom-desktop-direct-api

Kyle Bergstedt ·7967d88fe5 ·2mo ago ·parent ea5c606
2 files changed −496
skills/adom-desktop-direct-api/._SKILL.mddeleted
⋯ 1 unchanged line ⋯
skills/adom-desktop-direct-api/SKILL.mddeleted−496
@@ -1,496 +0,0 @@-----name: adom-desktop-direct-api-description: Direct HTTP API on the Adom Bridge app. Use when authoring a sibling app (Adom Hydrogen, future Adom-family apps) on the same Mac that needs to send commands into Adom Bridge 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 not guaranteed fixed.** Adom Bridge's direct API binds `127.0.0.1:47200` when it can, and falls back (range walk, then an OS-assigned ephemeral port) when that port is unavailable. **Sibling apps MUST read the discovery file** `~/.adom/direct-api-port` (single-line `host:port`, e.g. `127.0.0.1:47200`) before any hardcoded probe. The legacy 47200..47209 scan stays as a back-compat fallback.--Sibling apps running on the same Mac as Adom Bridge can POST JSON commands directly into the running app process: no CLI process spawn, no WebSocket relay, no auth-token dance. The endpoint lives inside the app 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** (Adom Hydrogen's native side, plus any future Adom-family desktop app). Container 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.--Requires the Adom Bridge app to be running on the Mac. Bridge is a menu-bar agent: it serves this API whether or not its window is open.--## When to use this (vs. the CLI)--| You are... | Use |-|---|---|-| A sibling app on the same Mac as Adom Bridge, 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 Adom Hydrogen's local container, using the `adom-desktop` CLI | **Just use the CLI.** It auto-detects the direct API (including `host.docker.internal`) on every invocation and routes there transparently. Zero config. The verb returns identical JSON to the cross-machine path. |-| On the Mac, running the bundled `adom-desktop-cli` directly | **Same: just use the CLI.** Auto-detects the local direct API and routes through it, faster than the relay path. |-| Container / Linux / cross-machine caller | The CLI `adom-desktop <verb>` (probe fails, falls through to the wss proxy, relay, app WS path). |-| 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--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 (fastest path):** `~/.adom/direct-api-port`. Contents are `host:port` (e.g. `127.0.0.1:47200`) and the CLI tries this first if present. The app 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:** 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 a few ms on loopback.-- **Connect timeout**: 400 ms per candidate, so it 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 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--The app 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 the two listeners).-2. **Reuse-bind** with `SO_REUSEADDR` set before bind, so a dead process's lingering socket doesn't block the next launch.-3. **Walk the range** `47200`..`47209` on address-in-use, permission-denied, or live-owner skip.-4. **Ephemeral fallback**: if all of 47200..47209 fail, bind `127.0.0.1:0` and let the OS pick. Whatever it picks goes to step 5.-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.--The app also prefers a *remembered* port (`~/.adom/direct-api-port-preferred`), so in practice the port is stable across restarts and self-updates.--### Graceful shutdown--When the app exits cleanly (menu-bar Quit, `desktop_shutdown`, or any path that calls `app.exit(0)`), the exit hook 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 lingering sockets on **clean exits**. For **force-kill** scenarios (`kill -9`, crash), the 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 app running" before falling through to the CLI fallback.--```bash-curl -sf http://127.0.0.1:47200/health-# -> {"ok":true,"service":"adom-desktop"}-```--Returns 200 + JSON when the app is up and the listener bound successfully. Connection refused (or timeout) means: app not running OR the port was already taken by something else when the app started. The CLI binary's `serve` mode does NOT bind this port; only the app does.--### `GET /status`--Service banner + version + capability inventory. Read once on sibling-app startup to learn the verb surface and the `cliRequired` list.--```bash-curl -sf http://127.0.0.1:47200/status-```--```json-{-  "ok": true,-  "service": "adom-desktop",-  "version": "1.9.187",-  "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.adom.inc/adom/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.--### `GET /commands`--Verb discovery: what `{app, command}` can a `/command` caller reach? A bridge delegating to the app no longer has to probe verb-by-verb and hit "Unknown desktop command".--```bash-curl -sf http://127.0.0.1:47200/commands-```--```json-{-  "ok": true,-  "desktop": {-    "app": "desktop",-    "commands": ["list_windows","find_window","screenshot_window","run_script","process_list", "..."],-    "note": "POST /command {app:\"desktop\", command}. The desktop_ prefix is OPTIONAL (desktop_screenshot_window == screenshot_window). 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: inferred from the command):--```json-{-  "command": "server_add",-  "args": { "name": "hydrogen-workspace", "url": "ws://localhost:8765", "autoConnect": true }-}-```--#### Full verb surface + cross-machine routing--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_*` to kicad, `fusion_*` to fusion360, `browser_*` to browser, `hd_*` to hd, `aps_*` to aps, any installed bridge's prefix to dynamic, else desktop). Pass `app` explicitly to override.-- **Top-level verbs reachable:** `notify_user` (notification, returns `{action:"displayed"}`), `notify_response`, `targets` (list the OTHER machines on the relay), `ping` (this machine's liveness + version), plus `bridge_list`/`bridge_info`/`refresh_bridges`/`runtimes`/`status` and the update verbs (`update_status`, `update_check`, `apply_update`, `request_update_approval`, `update_set_mode`).-- **`target` (cross-machine):** add `"target": "<clientName>"` (from `targets`) or `"all"` to route the call to a PEER machine via the relay, e.g. a bridge on one box notifies the user on their laptop. `"self"`/`"local"`/absent = local. `"attended"` returns `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; the app 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; see PERMISSION_MODEL.md). A stale/wrong token gets 403; absent is 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 notification to the user's laptop while running on another machine:-```bash-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":"MyMac","args":{"title":"Action needed","body":"Approve the Chrome dialog on your laptop"}}'-```--Plain single-machine envelope (unchanged, `app` still honored when provided):--```json-{-  "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.:--```json-{-  "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 Bridge. Use server_list to see all connections."-}-```--#### Error responses--| HTTP | When | Body shape |-|---|---|---|-| `400 Bad Request` | Envelope missing `command` (or unparseable) | `{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--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 at ~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:--```json-{-  "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: parent-driven embedded auto-update--The app owns its own updater (fetch, SHA-verify, stage). Adom Hydrogen does **not** download or install Bridge's bytes: it reads Bridge's update state and, at a safe moment, "clicks the Update button" via `POST /update/apply`. The contract is intentionally tiny and generalizes to other Hydrogen-bundled children: `{ update_status, apply, busy }`.--### `GET /update/status`--```bash-curl -sf http://127.0.0.1:47200/update/status-```--```json-{-  "current_version": "1.9.186",-  "available_version": "1.9.187",-  "update_ready": true,-  "state": "staged",-  "busy": false-}-```--| Field | Meaning |-|---|---|-| `current_version` | The running version. Plain dotted semver; compare component-wise, numeric, never-downgrade. |-| `available_version` | Latest version known from the 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, and staged on disk: appliable with **no further network**. Equivalent to `state == "staged"`. |-| `state` | One of `idle \| checking \| downloading \| staged \| applying`. |-| `busy` | `true` while the app is mid-operation it shouldn't be interrupted for: an in-flight screenshot, a file transfer, or a bridge command awaiting its reply. **The parent should defer the apply until `busy == false`.** |--`state` machine: `idle`, then `checking` (a manifest check is running), then `downloading` (fetching + verifying), then `staged` (verified on disk; `update_ready=true`), then `applying` (apply triggered; the app is 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.--```bash-curl -sf -X POST http://127.0.0.1:47200/update/apply-```--```json-{ "ok": true, "applying": true }-```--- **Only ever applies what the app 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 or blocking UI. The app returns this JSON **promptly** (before it restarts), applies the staged update (on macOS: mount the staged dmg, rename-swap the `.app` bundle, relaunch), then exits cleanly moments later.-- **Comes back in embedded mode automatically.** The 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 app re-enters embedded mode via the marker + the Hydrogen-liveness probe. Hydrogen's normal adopt (`POST /embedded/enter`) on the next supervisor tick re-asserts it idempotently.--#### Restart handshake: the parent MUST suspend respawn for the apply window--The apply swaps the running `.app` bundle and relaunches it. **If the parent's supervisor respawns Bridge mid-swap, the two launches race** and you can end up with a stale process still serving. So:--1. `GET /update/status`: wait for `update_ready == true && busy == false`.-2. `POST /update/apply`: on `{ applying: true }`, **stop respawning Bridge** (suspend the health-respawn) for ~60 s.-3. The app exits, the staged update replaces the bundle, the new build relaunches and re-embeds via its marker.-4. The parent polls `GET /update/status` (or `/health`); when the app answers again with `current_version ==` the new version, resume normal supervision + re-adopt (`POST /embedded/enter`).--The parent initiated the apply, so it already knows the restart is intentional: treat the app's disappearance in this window as expected, not a crash.--### `POST /update/check`--Force a manifest check now. If a newer version is found, the app stages it **in the background**; this call returns the `/update/status` shape promptly (poll it until `staged`). No request body.--```bash-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:** the normal user-facing "Update available" flow is unchanged. `auto` mode silently self-updates; `prompt` shows the banner; `off` disables checks (and staging).-- **Embedded:** the app checks + **stages** on its own (so `update_ready` becomes true for the parent) but **never auto-applies**: the parent 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 the Activity Log panel with a `direct:<port>` 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 badge attached to WS-relay traffic.--At startup, a single `direct-api` event-tagged entry says the direct API is listening on its loopback address. If the bind ever fails, 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.-- **"Anything on the box" includes a WEB PAGE the user visits, unless the browser path stays closed.** A loopback bind stops off-machine callers; it does NOT stop a page in the user's own browser from issuing cross-origin requests to `127.0.0.1`. Measured 2026-07-24: the app is closed on every link of that chain, and **each one is load-bearing**: (a) no `Access-Control-Allow-Origin` on any response, so a page cannot READ results; (b) `OPTIONS` returns **405**, so a CORS preflight fails; (c) the body parser accepts **`application/json` only**: `application/x-www-form-urlencoded` and `text/plain` both 415, and those are precisely the two content types a page can send WITHOUT a preflight. (c) is what stops a page firing a verb blind.-  **None of those three were written as security controls, so do not treat them as incidental.** Adding an `OPTIONS` handler to quiet a CORS warning, or accepting form-encoded bodies "for convenience", silently converts this API into one that any visited web page can drive, with `shell_execute` and `write_file` behind it. If a browser-origin consumer is ever genuinely needed here, **the authentication gate must land in the same change as the CORS header, never after it.**-- **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: Adom Hydrogen startup--This is the canonical pattern Hydrogen uses to register its container relay with Adom Bridge at boot.--### Rust (Hydrogen's `src-tauri/src/lib.rs` or equivalent)--```rust-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_bridge(workspace_name: &str, relay_url: &str) -> Result<(), String> {-    // 1. Cheap probe: is Adom Bridge 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 Bridge not running. Either tell the user to launch it,-        // or shell out to the CLI as the legacy fallback. Don't block-        // Hydrogen startup.-        log::warn!("Adom Bridge 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 Bridge registered: name={workspace_name}, url={relay_url}, _hint={}",-            body.get("_hint").and_then(|v| v.as_str()).unwrap_or("")-        );-        Ok(())-    } else {-        Err(format!("Adom Bridge refused: {body}"))-    }-}-```--(In production, read the port from `~/.adom/direct-api-port` instead of hardcoding 47200.)--### TypeScript (Hydrogen's frontend, if it ever calls directly)--```typescript-async function registerWithAdomBridge(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`:--```bash-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 Bridge 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 (Hydrogen bundling Bridge)--When Hydrogen bundles Bridge, the direct API is the channel for Hydrogen's menu items and runtime control. Hydrogen spawns Bridge with `--embedded --start-hidden --relay-url ... --session-token ...`, then drives it via these direct-API calls:--| Hydrogen action | direct-API envelope |-|---|---|-| "Open Adom Bridge" menu item | `{app:"desktop", command:"window_show"}` |-| Hide Bridge's window again | `{app:"desktop", command:"window_hide"}` |-| "Connect All" / "Disconnect All" items | `{app:"desktop", command:"connect_all"}` / `disconnect_all` |-| Hydrogen's "Quit" (cascade-stops Bridge) | `{app:"desktop", command:"shutdown"}` |-| Hydrogen sign-out propagation | `{app:"desktop", command:"logout"}` |-| Introspect embedded state | `{app:"desktop", command:"embedded_status"}` |--`embedded_status` returns `{embedded, owner, source, pendingRelayUrl, pendingRelayName, startHidden, markerPath, markerExists}`: useful for Hydrogen to know whether Bridge already booted embedded or is running standalone.--```typescript-// Hydrogen's menu-click handler, example-async function openAdomBridge() {-  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 on shell approval: granting is human-only. A relay (cloud-AI) caller's grant via `set_shell_auto_approve` is refused (`errorCode:human_only`); a trusted local caller (Hydrogen proxying its own user's toggle, the GUI, a local human at the CLI) may still grant, and a revoke (`duration_secs:0`) is allowed for anyone.--## 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.--**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_list_windows`, `desktop_find_window`, `desktop_screenshot_window`, `desktop_record_window_start/stop/status`) OR the stripped form; both dispatch.--| `app` value | What goes in `command` | Examples (CLI verb, then direct envelope) |-|---|---|---|-| `desktop` | public verb name; `desktop_` prefix optional | `desktop_screenshot_window` becomes `{app:"desktop", command:"desktop_screenshot_window"}` (or `"screenshot_window"`); `server_add` becomes `{app:"desktop", command:"server_add"}` |-| `kicad` | **stripped**: drop the `kicad_` prefix | `kicad_open_board` becomes `{app:"kicad", command:"open_board"}` |-| `fusion360` | **stripped**: drop the `fusion_` prefix | `fusion_start` becomes `{app:"fusion360", command:"launch"}` (note: also the verb name shifts internally) |-| `browser` | **kept**: pup bridge dispatches on the full name | `browser_open_window` becomes `{app:"browser", command:"browser_open_window"}` |-| `hd` | **stripped**: drop the `hd_` prefix | `hd_status` becomes `{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` | All sync, no prefix stripping. |-| `hd` | `status` | Relays Adom Hydrogen's own runtime state. |-| `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 app is a clean "not available" branch, not a noisy 10-second timeout on every dispatch.-2. **Per-request timeouts at or above the verb's natural duration.** Most verbs are sub-second; `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.).-5. **NEVER cache the resolved base forever: RE-READ the discovery file on any connection failure.** The app prefers a remembered port and only walks elsewhere when that one is taken or has a live owner, so in practice the port is **stable across restarts and self-updates**, and that stability is exactly what makes a missing invalidation path dangerous. A cached base works for weeks, so nothing ever exercises the re-discovery code; then one day something occupies the port during a restart, the app walks to a different one, and every caller holding the old base is permanently dead with no route back. `~/.adom/direct-api-port` is the single source of truth and the app rewrites it on every bind. The bug shape to grep your own code for: a module-level cache guarded by `if (base !== undefined)` that nothing ever resets (found live in a bridge 2026-07-24). **"It never changed during testing" is not evidence it is safe; it is the reason the bug survives to production.**--   Generalize it: **rarity of change is a risk factor, not a safety property.** A resource that changes often gets correct invalidation for free, because the bug is immediate and unmissable. A resource that changes rarely, like the remembered port, accumulates caches nobody invalidates, and the bug takes weeks to appear and lands on a user rather than a developer.--## Version skew--If `GET /status` fails entirely OR `schema` is missing, the direct API isn't present (much older app). Fall back to spawning the CLI binary. 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 app and gates non-CLI User-Agents. The direct port is direct to the app, no auth, loopback-only.-- **Different process lifecycle.** 8766 needs `adom-desktop serve` running (a separate process). The direct port is inside the app: if the app is running, it is up.-- **No risk of breaking containers.** Container callers keep using 8766 + the WS proxy. Sibling apps get the direct port. They don't fight for the same port or auth scheme.--## See also--- [`README.md`](../README.md): feature list-- [`skills/SKILL.md`](SKILL.md): full verb catalog (the entries container callers read)-- [`src-tauri/src/direct_api.rs`](../src-tauri/src/direct_api.rs): implementation-- [Adom Wiki, apps/adom-desktop](https://wiki.adom.inc/adom/adom-desktop): public verb reference