Adom Desktop
Public Made by Adomby adom
Installs the adom-desktop CLI and its Claude skills INTO A CLOUD CONTAINER so an AI assistant there can understand and drive Adom Desktop over the relay. This is the container side, NOT the app itself. The Adom Desktop app is the signed Windows installer under Download below: run THAT on the PC you want the AI to control. Rule of thumb: pkg install here (in your Linux container), Download there (on your Windows PC). Once installed, the AI can drive file transfer, screenshots, notifications, KiCa
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
---
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