← Commit history

Migrate asset from Helium wiki

Kyle Bergstedt ·0cd6c14ebb ·4mo ago ·parent 1c1fbc4
1 file changed +266
DIRECT_API.mdadded+266
@@ -0,0 +1,266 @@+---+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:8770`++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. |+| Docker / Linux / cross-machine caller | The CLI `adom-desktop <verb>` (goes through the wss proxy → relay → GUI 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"`. |++## Endpoints++### `GET /health`++Cheap probe. Use to detect "is the GUI running" before falling through to the CLI fallback.++```bash+curl -sf http://127.0.0.1:8770/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 8770 — 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.++```bash+curl -sf http://127.0.0.1:8770/status+```++```json+{+  "ok": true,+  "service": "adom-desktop",+  "version": "1.8.25",+  "schema": 1,+  "transport": "direct-http",+  "endpoint": "http://127.0.0.1:8770",+  "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`:++```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-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 120s | `{ok:false, errorCode:"timeout", _hint}` |++Every error carries an actionable `_hint` and (for non-400s) a stable `errorCode` string you can branch on.++## 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:8770` 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:8770 (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)++```rust+use serde_json::json;+use std::time::Duration;++const ADOM_HEALTH: &str = "http://127.0.0.1:8770/health";+const ADOM_COMMAND: &str = "http://127.0.0.1:8770/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:8770 — 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)++```typescript+async function registerWithAdomDesktop(name: string, url: string): Promise<boolean> {+  // probe first+  const health = await fetch('http://127.0.0.1:8770/health', { signal: AbortSignal.timeout(2000) }).catch(() => null);+  if (!health?.ok) return false;++  const resp = await fetch('http://127.0.0.1:8770/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:8770/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.++## 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. 8770 is direct to the GUI, no auth, loopback-only.+- **Different process lifecycle.** 8766 needs `adom-desktop serve` running (a separate process). 8770 is inside the GUI — if the GUI is running, 8770 is up.+- **No risk of breaking Docker.** Docker callers keep using 8766 + the WS proxy. Sibling apps get 8770. 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 Docker callers read)+- [`src-tauri/src/direct_api.rs`](../src-tauri/src/direct_api.rs) — implementation+- [Adom Wiki — apps/adom-desktop](https://wiki-ufypy5dpx93o.adom.cloud/apps/adom-desktop) — public verb reference