Adom Desktop
UnreviewedLaptop bridge: screenshots, file transfer, notifications, KiCad + Fusion 360 control, real-Chrome (pup) automation. One install gives Claude the main + pup + kicad + fusion skills.
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.
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.
curl -sf http://127.0.0.1:8770/status
{
"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:
{
"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 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, never0.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: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)
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:
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:
- Probe
/healthbefore any/commandso a missing GUI is a clean "not available" branch, not a noisy 10-second timeout on every dispatch. - Per-request timeouts ≥ the verb's natural duration. Most verbs are sub-second;
hd_build*returns in <2 s;bridge_installcan be 30 s for a large zip. Pick the timeout based on the verb you're calling. - 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.
- Read the
_hintanderrorCodefields 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 serverunning (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— feature listskills/SKILL.md— full verb catalog (the entries Docker callers read)src-tauri/src/direct_api.rs— implementation- Adom Wiki — apps/adom-desktop — public verb reference