Adom Bridge (macOS)
Public Made by Adomby adom
Adom Bridge for macOS: the menu-bar daemon that connects AI tools to your Mac.
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:47200when 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-linehost: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 arehost: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=offforces relay-only;$ADOM_DIRECT_URL=http://...:47209skips 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:
- 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). - Reuse-bind with
SO_REUSEADDRset before bind, so a dead process's lingering socket doesn't block the next launch. - Walk the range
47200..47209on address-in-use, permission-denied, or live-owner skip. - Ephemeral fallback: if all of 47200..47209 fail, bind
127.0.0.1:0and let the OS pick. Whatever it picks goes to step 5. - Write the chosen port to
~/.adom/direct-api-portso callers don't have to scan. - 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:
- Sends a
oneshotsignal to the axum server with_graceful_shutdownstops accepting new connections and drains in-flight ones (~tens of ms)- The TCP listener drops, releasing the port back to the OS immediately
- 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.
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.
curl -sf http://127.0.0.1:47200/status
{
"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".
curl -sf http://127.0.0.1:47200/commands
{
"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):
{
"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.
appis 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). Passappexplicitly 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), plusbridge_list/bridge_info/refresh_bridges/runtimes/statusand the update verbs (update_status,update_check,apply_update,request_update_approval,update_set_mode). target(cross-machine): add"target": "<clientName>"(fromtargets) 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"returnserrorCode:attended_unresolved(not built yet; use a concrete clientName).X-Adom-Bridge-Tokenheader = ATTRIBUTION only. A bridge MAY send its spawn-timeADOM_BRIDGE_TOKENenv 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:
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):
{
"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 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:
{
"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
curl -sf http://127.0.0.1:47200/update/status
{
"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.
curl -sf -X POST http://127.0.0.1:47200/update/apply
{ "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": ... }. CallPOST /update/checkfirst and poll/update/statusuntilupdate_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
.appbundle, 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:
GET /update/status: wait forupdate_ready == true && busy == false.POST /update/apply: on{ applying: true }, stop respawning Bridge (suspend the health-respawn) for ~60 s.- The app exits, the staged update replaces the bundle, the new build relaunches and re-embeds via its marker.
- The parent polls
GET /update/status(or/health); when the app answers again withcurrent_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.
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.
automode silently self-updates;promptshows the banner;offdisables checks (and staging). - Embedded: the app checks + stages on its own (so
update_readybecomes true for the parent) but never auto-applies: the parent owns the apply timing viaPOST /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, 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.
- "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) noAccess-Control-Allow-Originon any response, so a page cannot READ results; (b)OPTIONSreturns 405, so a CORS preflight fails; (c) the body parser acceptsapplication/jsononly:application/x-www-form-urlencodedandtext/plainboth 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 anOPTIONShandler 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, withshell_executeandwrite_filebehind 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)
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)
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:
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.
// 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:
Probe
/healthbefore any/commandso a missing app is a clean "not available" branch, not a noisy 10-second timeout on every dispatch.Per-request timeouts at or above the verb's natural duration. Most verbs are sub-second;
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.).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-portis 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 byif (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 serverunning (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: feature listskills/SKILL.md: full verb catalog (the entries container callers read)src-tauri/src/direct_api.rs: implementation- Adom Wiki, apps/adom-desktop: public verb reference
---
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