Hydrogen Desktop Bootstrap (apps + skills)
Public Made by Adomby adom
Layer Hydrogen Desktop's workspace onto a standard Adom workstation.
name: hd-api description: > Complete reference for Hydrogen Desktop control API endpoints. The control API runs on a dynamic port (find it in the HD log or via port discovery). Use these endpoints to drive HD programmatically: manage the workspace runtime (WSL2 distro or legacy Docker container), control Claude Code, manipulate VS Code, manage workspace tabs, run setup steps, trigger virgin reset, and more. ~200 endpoints. The COMPLETE, always-current catalog is GET /_manifest (every route with a category + description; curated entries are richer). The API Explorer window (Adom menu > API Explorer, or POST /api-explorer/open) renders it with a runnable Try per endpoint. GET /test/help lists the gated /test/* diagnostics. NEVER blind-call destructive endpoints (see the Safety section). Trigger words: HD API, control API, HD endpoint, container exec, claude api, iframe eval, workspace tabs, setup run-all, docker status, reload vscode, browser profiles, open url, virgin reset api, reboot api, diagnostics, ad health, test window-tree, ai cursor, setup panel, screenshot mode.
HD Control API Reference
The control API exposes ~159 endpoints. Don't trust a static count or list in this file to stay exhaustive — the API is self-documenting, with two live sources of truth you should hit first:
| Source | What it gives you | Where |
|---|---|---|
GET /_manifest |
THE complete catalog — every registered route (path, method, args, category, description, visibility). Curated entries carry richer descriptions; the rest are auto-generated from the router (scripts/gen-manifest.py), so it never drifts. Discover here first — don't guess paths. |
hd-control/src/lib.rs /_manifest arm |
| API Explorer window | The human face of /_manifest: every endpoint grouped by category tab, with a runnable Try + Copy URL. Open from the Adom menu, or POST /api-explorer/open. |
Adom menu ▸ API Explorer |
GET /test/help |
The /test/* diagnostics (window enumeration, UIA click, dialog classification, cascade reset). Gated behind HD_TEST_API_ENABLED=1 — 404 by default (expected, not a bug). |
hd-control/src/lib.rs /test/help arm |
⛔ Safety — the API enforces this, and tells you in-band
The API itself now labels, warns about, and gates dangerous endpoints (single source of
truth: safety.rs in hd-control). Three tiers:
catastrophic— irreversibly destroys real user state:/setup/virgin-reset,/setup/panel/(run-virgin-reset|wipe-keep-auth),/wsl/unregister(deletes the whole workspace distro),/docker/(uninstall|clean-reinstall),/system/reboot(reboots the user's COMPUTER),/config-reset,/claude/wipe-creds,*-delete. Headless calls (no browser Origin header — i.e. YOU) get HTTP 409confirmation_requiredand NOTHING happens until you re-call with?confirm=1. Only add?confirm=1when the user explicitly asked for exactly that action — never to make an error go away.destructive— kills/restarts/re-runs real state (recoverable but real):/app/(restart|relaunch-frontend),/reload-vscode,/docker/(stop|restart),/container(-stop|-restart),/workspace/apply-updates,/setup/(run-all|run-step|state| force-close|trigger|countdown),/claude/tabs/close-all,/updater/install. Never call these speculatively or in a sweep.disruptive— the user SEES it (window opens, focus moves, typing lands in their real chat, screen recording starts):/hdbw/open,/api-explorer/open,/open-url,/open-in-profile,/window/foreground,/ai-cursor/*,/recording/native/start,/ui/toast,/claude/(trigger-auth|authorize-in-browser|open-with-message|inject*|send| submit|uia-*|new-chat-and-send|type-and-submit)(the send/submit ones SPEND a real user turn). Run only when the user actually wants that action, and clean up after yourself. (Calling/claude/authorize-in-browserblindly is how you spray OAuth windows into Edge.)
How the API tells you:
- Before calling: every
GET /_manifestentry carriessafety+safety_note. Check them before you POST anything unfamiliar. Unlabeled = safe. - When you call: responses from these endpoints include a
_safetyblock. READ IT — it says exactly what you just did to the user's machine. - The 409 gate is your last chance to stop; it is not an error to route around.
A safe GET/POST returns within a couple seconds with a 2xx or a clean 4xx {ok:false,...}.
If you must probe an endpoint you're unsure about, POST an empty {} body and treat a
graceful validation error as "it works" — a 500 or a hang is the bug.
Base URL: http://127.0.0.1:<dynamic> — the same loopback address from inside the
workspace (WSL2 mirrored networking shares loopback with the Windows host) and on the
Windows host. The port is dynamic per launch; read the live URL from
~/.adom/hd-control-url — see "Finding the Control Port" below.
Finding the Control Port
The control API port is dynamic (default 47084, auto-resolves conflicts).
# From HD log
adom-desktop hd_log '{"tail":100}' | grep "control="
# From ports.json
type %APPDATA%\hydrogen-desktop\ports.json
# From inside the WSL2 workspace — USE THIS. HD writes its live control URL to a discovery
# file every launch (mirrored loopback), so you never hunt for the dynamic port:
CTRL="$(cat ~/.adom/hd-control-url)"; curl -sf "$CTRL/health"
# No file access (remote tool, browser)? Probe the DISCOVERY responder: fixed candidate
# ports tried in order (a single fixed port is not bindable on every Windows box; WSL2
# mirrored networking claims ports invisibly). Take the FIRST that answers with
# service == "hydrogen-desktop-discovery"; its control_url is the live control API:
for p in 47080 48080 49180 50280; do
R=$(curl -sf --max-time 2 "http://127.0.0.1:$p/") && echo "$R" | grep -q hydrogen-desktop-discovery && break
done
⭐ From the workspace, read ~/.adom/hd-control-url (e.g. http://127.0.0.1:<control>) — a
file, NOT an env var (your non-interactive Bash shells don't source .bashrc/profile.d). HD
rewrites it with the live dynamic port every launch. This is the channel for the UI command bus,
the live ports map, Claude control, etc. — distinct from adom-cli (ADOM_HYDROGEN_URL → SSE),
which is unchanged for web-hydrogen parity.
Calling gotchas (learned the hard way)
- Never hardcode the port. It's dynamic (47084 is only the default and it auto-resolves
conflicts). Read
~/.adom/hd-control-urlfrom the workspace, orGET /ports. Hardcoding 47084 is what makes tools show HD as "down" when it's actually up on another port. - Calling from a REMOTE container (hw/cloud)? Use
adom-desktop hd_api, the relay's pass-through verb:hd_api {"method":"POST","path":"/...","body":{...}}. It auto-discovers the port; never shell curl through the relay (quoting gets mangled). Long-blocking calls (typed injection) can outlive the relay timeout: the work still completes on HD's side, so verify state instead of re-firing. - Getting files OUT (recordings, screenshots): streamed binary, never base64. Endpoints
return paths inside the WSL distro; a remote container pulls them with
adom-desktop --target <desktop> pull_file {filePaths:["\\\\wsl$\\Adom-Workspace\\<path>"], saveTo:...}(1 MiB binary chunks, sha256-verified, no practical size limit)./recording/native/stopreturns the ready-madefileWindowsUNC path. Full demo-video pipeline: thehd-demo-recordingskill. - Confirm it's really the control API, not the SPA. In dev, the frontend file server
answers 200 with HTML for any path — so a bare 200 doesn't prove you hit HD's API. Check
GET /healthreturns JSON withservice:"hydrogen-desktop-control". - Driving HD from a page HD serves? Use relative fetches, not Tauri IPC. Tauri
invokeis unavailable on remote http origins; a same-originfetch('/ports')/fetch('/_manifest')works and sidesteps port discovery entirely. - Driving from the CLOUD over the relay: if two Adom Desktops are connected (laptop +
a VM),
desktop_*verbs need--target <name>(e.g.AdomLapper) or they no-route and look empty;nbrowser_*/hd_*auto-route. PowerShell emits CRLF — strip\rbefore string-comparing its output. Long calls can exceed the relay's per-call timeout — run detached + poll a marker (seehd-cloud-relay-patterns).
Health & Status
| Method | Path | Description |
|---|---|---|
| GET | /health |
HD alive check — returns {ok, port, service} |
| GET | /buildinfo |
Running build git SHA + build metadata (the only trustworthy "did my build ship?" check) |
| GET | /ports |
The 4 editable PortConfig ports (proxy, control, cdp, code_server) |
| GET | /ports/all |
LIVE full port map — every host + WSL2 port HD has bound, with status + mirrored-mode info |
AD-health diagnostics (/ad/*)
These verify the adom-desktop / relay / embedded-AD half of HD is alive (the
bridge that connects this cloud container to HD). All GET, all return {ok, ...}.
| Method | Path | Description |
|---|---|---|
| GET | /ad/health |
Overall embedded-AD health roll-up |
| GET | /ad/process |
Is the adom-desktop process running |
| GET | /ad/embedded |
Embedded-AD module status |
| GET | /ad/workspace-direct |
⭐ The converged check — direct path to the workspace relay (use this one) |
| GET | /ad/relay |
Relay (WS/HTTP) reachability |
| GET | /ad/liveness-beacon |
Liveness beacon freshness |
| GET | /ad/direct-api |
Direct-API reachability |
| GET | /ad/discovery-file |
Discovery file present + parseable |
Driving HD's UI (command bus)
Open/close/toggle any HD menu, dialog, or panel — the first-class alternative to CDP clicking. See the hd-ui skill.
| Method | Path | Body | Description |
|---|---|---|---|
| GET | /ui/actions |
— | List every drivable UI action {id,label,description,group} (+ _hints) |
| POST | /ui/invoke |
{"id":"ports.open"} |
Run a UI action; returns {ok,value} only after the frontend ran it |
| GET | /auth-token |
HD session token | |
| GET | /setup-status |
Overall setup completion state | |
| GET | /diagnostics/all |
Run all diagnostic checks | |
| GET | /diagnostics/{name} |
Run a single diagnostic check |
Workspace / Container Management
These endpoints manage the active workspace runtime (a WSL2 distro by default, or a Docker container under HD_RUNTIME=docker).
| Method | Path | Body | Description |
|---|---|---|---|
| GET | /container-status |
— | Workspace runtime available, image/distro present, workspace exists/running |
| GET | /container-stats |
— | CPU, memory, disk usage |
| POST | /container-restart |
— | Restart the active workspace runtime (5s timeout) |
| POST | /container-stop |
— | Stop the active workspace runtime |
| POST | /container-exec |
{"command":"..."} |
Run command in the workspace as user adom |
| POST | /container-delete |
— | Remove the workspace |
| POST | /volume-delete |
— | Remove project volume (DESTRUCTIVE). Docker runtime only — no volume exists under WSL. |
| POST | /image-delete |
— | Remove workspace image |
| GET | /container-images |
— | List available workspace images |
| POST | /container-switch-image |
{"image":"..."} |
Switch to different image |
| POST | /container-pull-image |
{"image":"..."} |
Pull an image |
| GET | /container/logs |
— | Workspace stdout/stderr |
| GET | /container/env |
— | Workspace environment variables |
| GET | /container/ports |
— | Published port mappings |
| GET | /container/disk |
— | Disk usage inside the workspace |
| POST | /container/restart |
— | Alias for /container-restart |
WSL runtime endpoints (default runtime)
When HD runs the workspace as a WSL2 distro, it additionally exposes:
| Method | Path | Body | Description |
|---|---|---|---|
| GET | /wsl/status |
— | WSL distro state (registered, running, code-server reachable) |
| GET | /workspace/health |
— | Deep workspace health (distro + code-server + host reachability) |
| GET | /virgin-reset-progress |
— | Live progress of an in-flight virgin reset |
| POST | /workspace/terminal-profile |
— | Ensure the stable Adom-Workspace Windows Terminal profile (idempotent) |
| POST | /workspace/terminal-profile/remove |
— | Remove the Adom-Workspace Windows Terminal profile(s) |
| POST | /workspace/check-updates |
— | Check upstream for newer code-server + Claude extension; stage them |
| POST | /workspace/apply-updates |
— | Apply staged code-server/extension updates + reload editor |
⛔
POST /wsl/unregisteris REFUSED as deprecated — it was a headless destructive reset (deletes/home/adom/project). Use the Setup panel's Virgin Reset orPOST /setup/panel/run-virgin-resetinstead.
Docker Desktop (legacy Docker runtime only — HD_RUNTIME=docker)
This /docker/* table applies only when HD is running the legacy Docker runtime.
| Method | Path | Body | Description |
|---|---|---|---|
| GET | /docker/status |
— | Docker Desktop running state |
| GET | /docker/version |
— | Docker engine version |
| GET | /docker/containers |
— | List all Docker containers |
| GET | /docker/images |
— | List all Docker images |
| POST | /docker/start |
— | Launch Docker Desktop + start container |
| POST | /docker/stop |
— | Stop Docker Desktop |
| POST | /docker/restart |
— | Restart Docker Desktop |
| POST | /docker/wait-ready |
— | Block until Docker daemon responds |
| POST | /docker/install |
— | Install Docker Desktop (downloads ~730 MB) |
| POST | /docker/uninstall |
— | Uninstall Docker Desktop |
| POST | /docker/clean-reinstall |
— | Uninstall + reinstall Docker Desktop |
| GET | /docker/install-progress |
— | Current install step + progress |
WebView & VS Code
| Method | Path | Body | Description |
|---|---|---|---|
| POST | /eval |
{"js":"..."} |
JS eval in main HD webview |
| POST | /iframe-eval |
{"js":"...","contextIndex":N} |
JS eval in a specific iframe context (0-14) |
| POST | /cdp-eval |
{"expression":"..."} |
Direct Chrome DevTools Protocol eval |
| POST | /click |
{"x":N,"y":N} |
Click at coordinates in webview |
| POST | /mouse-click |
{"x":N,"y":N,"button":"left"} |
Mouse click with options |
| POST | /query |
{"selector":"..."} |
Query DOM elements |
| POST | /reload-vscode |
— | Reload the VS Code iframe |
| POST | /vscode/hide-activity-items |
— | Hide activity-bar items (persisted, overflow-proof) |
| GET | /targets |
— | List CDP targets (all webview contexts) |
| GET | /cdp-info |
— | CDP connection info (port, URL) |
| GET | /devtools |
— | DevTools status |
AI cursor (visible cursor for demos / clicking)
Distinct from /click and /mouse-click — this renders a visible AI cursor,
glides it, and can click. Use for demos or when the user should see where the AI clicks.
| Method | Path | Body | Description |
|---|---|---|---|
| POST | /ai-cursor/move |
{"x":N,"y":N} |
Move (glide) the visible AI cursor to coordinates |
| POST | /ai-cursor/click |
{"x":N,"y":N} |
Move the visible AI cursor there + click |
| POST | /ai-cursor/hide |
— | Hide the visible AI cursor |
Screenshot primitives
| Method | Path | Body | Description |
|---|---|---|---|
| GET | /capture/viewport |
— | Capture the HD webview viewport (PNG) |
| POST | /shot |
{...} |
Screenshot primitive (see hd-self-screenshot for the full workflow) |
Port hints
| Method | Path | Body | Description |
|---|---|---|---|
| GET | /adom-port-hints |
— | Read the workspace port-hints map |
| POST | /adom-port-hints |
{...} |
Write/merge the workspace port-hints map |
Claude Code
Status / auth state (GET)
| Method | Path | Description |
|---|---|---|
| GET | /claude/status |
Install state, auth, active session, version |
| GET | /claude/auth-check |
Deep auth check — file exists, token, expiry |
| GET | /claude/auth-status |
Auth-status detail |
| GET | /claude/auth-page-state |
Which auth screen the panel shows: continue-in-browser | subscription-button | chat; returns oauth_url + at_browser_auth_gate |
| GET | /claude/detect-auth-failure |
Scan webview contexts for auth button/errors |
| GET | /claude/sessions |
List active Claude sessions |
Auth actions (POST)
| Method | Path | Body | Description |
|---|---|---|---|
| POST | /claude/find-auth-button |
— | Find + highlight the sign-in button WITHOUT clicking (validation) |
| POST | /claude/start-auth |
— | Find + highlight + CLICK the "Claude.ai Subscription" button to begin OAuth |
| POST | /claude/trigger-auth |
— | Alias for start-auth |
| POST | /claude/detect-state |
— | Detect current auth/panel state |
| POST | /claude/authorize-in-browser |
{"timeout_ms":N,"dry_run":B,"fresh":B,"oauth_url":"..."} |
Auto-click the "Authorize" button on the claude.ai OAuth page (unattended sign-in) |
| POST | /claude/authorize-tab-enter |
— | Approve the Adom OAuth tab via Enter (branded picker flow) |
| POST | /claude/inject-creds |
— | Push host backup credentials into the workspace |
| POST | /claude/wipe-creds |
{"backup":B} |
Wipe Claude credentials (destructive); backup:false keeps the host backup so reinject can restore |
Panel / conversation control (POST unless noted)
| Method | Path | Body | Description |
|---|---|---|---|
| POST | /claude/open |
— | Open Claude Code panel (claude-vscode.editor.open) |
| POST | /claude/ensure-open |
— | Ensure the panel is open (strict readiness; recovery if not rendered) |
| POST | /claude/open-with-message |
{"message":"..."} |
Open + pre-fill message |
| POST | /claude/focus |
— | Focus Claude panel |
| POST | /claude/new-conversation |
— | Start a fresh conversation (shows sign-in panel when unauthenticated) |
| POST | /claude/new-chat-and-send |
{"text":"...","typed":B,"submit":B,"delay_ms":N} |
Open fresh conversation, wait for compose box, type + (default) submit |
| POST | /claude/submit |
— | Find + click send button |
| POST | /claude/send |
{"message":"..."} |
Send message to Claude |
| POST | /claude/inject |
{"message":"..."} |
Inject text into the compose box |
| POST | /claude/inject-message |
{"message":"..."} |
Inject into conversation |
| POST | /claude/type-and-submit |
{"message":"...","delay_ms":N} |
Type char-by-char (human-paced) + submit |
| POST | /claude/remote-control |
{"name":"..."} |
Turn on Claude Code Remote Control (submits /remote-control slash command) |
| POST | /claude/highlight-open-button |
{"duration":N} |
Glow animation on Claude button |
| POST | /claude/clear-open-highlight |
— | Clear the open-button highlight |
OAuth & Auth
| Method | Path | Body | Description |
|---|---|---|---|
| POST | /claude-auth |
— | Full PKCE OAuth flow — opens browser, sets up callback |
| GET | /oauth/callback |
— | OAuth callback handler (receives auth code) |
| POST | /auth/proxy |
varies | Proxy auth requests to carbon.adom.inc |
| POST | /open-url |
{"url":"..."} |
Open URL (triggers browser picker) |
| POST | /start-oauth-proxy |
{"port":N} |
Start OAuth callback proxy on a port |
| GET | /browser-profiles |
— | List browsers + profiles with avatars |
| POST | /open-in-profile |
{"url":"...","browser":"...","profileDir":"..."} |
Open URL in specific browser profile |
| POST | /browser-picker/opened |
{...} |
Frontend reports the browser picker was opened |
| GET | /browser-picker/last-open |
— | Last browser-picker open event |
Setup Steps & Setup Panel
State / trigger / step control
| Method | Path | Body | Description |
|---|---|---|---|
| GET | /setup/state |
— | Get current setup state (never-run | in-progress | complete) |
| POST | /setup/state |
{"state":"..."} |
Set setup state |
| POST | /setup/step/<id> |
{"continue_after":B} |
⭐ Run one step by id (forces panel visible first); continue_after:true runs this step + every step after it |
| POST | /setup/force-close |
— | Force-close setup panel |
| POST | /setup/countdown |
{"seconds":N,"message":"..."} |
Show countdown overlay |
| GET | /setup/trigger |
— | Read trigger file |
| DELETE | /setup/trigger |
— | Delete trigger file |
| POST | /config-reset |
— | Reset HD config |
⛔ REFUSED as deprecated (headless runs with no visible UI):
POST /setup/run-all,POST /setup/run-step,POST /setup/virgin-reset,POST /wsl/unregister. They return a refusal pointing at the UI-visible path. To run steps: usePOST /setup/step/<id>or relaunch HD to auto-run the cascade.
Setup panel control
| Method | Path | Body | Description |
|---|---|---|---|
| POST | /setup/panel/show |
— | Force the setup panel visible |
| POST | /setup/panel/hide |
— | Hide the setup panel |
| POST | /setup/panel/show-virgin-reset |
— | Show panel + expand the Virgin Reset section |
| POST | /setup/panel/virgin/show |
— | Show the Virgin Reset section only |
| POST | /setup/panel/virgin/hide |
— | Hide the Virgin Reset section |
| POST | /setup/panel/set-virgin-options |
{"options":{...}} |
Set the virgin-reset checkboxes |
| POST | /setup/panel/run-virgin-reset |
— | ⭐ The ONLY allowed virgin-reset trigger — UI-visible by construction (shows panel + checkboxes + progress before deleting anything) |
| POST | /setup/panel/wipe-keep-auth |
— | Synchronous wipe that KEEPS Adom + Claude tokens |
| POST | /setup/screenshot-mode |
{"enabled":B} |
Toggle per-step screenshot capture (default OFF) |
| GET | /setup/screenshot-mode |
— | Whether per-step screenshot mode is on |
Workspace Tabs
| Method | Path | Body | Description |
|---|---|---|---|
| GET | /workspace/tabs |
— | List all workspace tabs |
| GET | /workspace/tabs/find?name=X |
— | Find tab by name (404 if not found) |
| POST | /workspace/tabs |
{tab spec} |
Add a new tab |
System
| Method | Path | Body | Description |
|---|---|---|---|
| GET | /system/nonce |
— | Get nonce for authenticated reboot |
| GET | /system/reboot-state |
— | Check reboot pending state |
| POST | /system/reboot |
{"nonce":"..."} |
Reboot Windows (nonce-authenticated) |
| POST | /system/cancel-reboot |
— | Cancel pending reboot |
App lifecycle & window
| Method | Path | Body | Description |
|---|---|---|---|
| POST | /app/restart |
— | Restart the HD app (kills + respawns the binary) |
| POST | /app/relaunch-frontend |
— | Relaunch the HD GUI (kill+respawn) while it is responsive |
| POST | /window/foreground |
— | Bring the HD window to the foreground (unminimize + focus) |
Diagnostics (/test/*)
Window enumeration, UIA clicking, native-dialog classification, and cascade
diagnostics. Gated behind HD_TEST_API_ENABLED=1 (return 404 / "test API
disabled" otherwise). GET /test/help self-lists the whole cluster.
| Method | Path | Body | Description |
|---|---|---|---|
| GET | /test/help |
— | Self-lists every /test/* endpoint |
| GET | /test/window-tree |
— | Enumerate all visible top-level windows |
| POST | /test/focus |
{"window":"..."} |
Focus HD (self) or a window by title substring |
| POST | /test/click-uia |
{...} |
Poll UIAutomation for a Button by Name; Invoke when found |
| GET | /test/setup-steps |
— | Raw setup-steps.json + derived summary (alias: /test/install-steps) |
| POST | /test/dump-state |
{...} |
Write a diagnostic bundle (logs, install-steps, docker, tasklist, window-tree) |
| POST | /test/reset-cascade |
— | Delete install-steps.json + emit setup-reset event |
| POST | /test/screenshot-hd-window |
{...} |
Capture HD's own window to a PNG file |
| GET | /test/probe-dialogs |
— | Enumerate visible windows, classify against the cascade-blocker catalog (UAC, WSL2 update, MSI installer, Docker EULA, …); returns kind + is_blocking per window |
Key Patterns
iframe-eval context discovery
Contexts 0-14 cover all webview frames. Context numbers shift when panels open/close. Always scan, never hardcode. The Claude Code extension webview IS reachable through these contexts.
POST /iframe-eval {"js":"document.body.innerText.substring(0,100)","contextIndex":3}
container-exec
Runs as user adom inside the workspace. Use full paths (/home/adom/
not ~/). Tilde doesn't expand through the exec chain.
POST /container-exec {"command":"ls /home/adom/project"}
Re-run a setup step after completion
The trigger file poller STOPS when all steps are done. POST /setup/run-all is
REFUSED as deprecated — don't use it. Re-run one step by id (or relaunch HD to
auto-run the cascade):
POST /setup/step/<id>
---
name: hd-api
description: >
Complete reference for Hydrogen Desktop control API endpoints. The control
API runs on a dynamic port (find it in the HD log or via port discovery). Use
these endpoints to drive HD programmatically: manage the workspace runtime (WSL2 distro or legacy Docker container), control
Claude Code, manipulate VS Code, manage workspace tabs, run setup steps, trigger
virgin reset, and more. ~200 endpoints. The COMPLETE, always-current catalog is
GET /_manifest (every route with a category + description; curated entries are
richer). The API Explorer window (Adom menu > API Explorer, or POST /api-explorer/open)
renders it with a runnable Try per endpoint. GET /test/help lists the gated /test/*
diagnostics. NEVER blind-call destructive endpoints (see the Safety section).
Trigger words: HD API, control API, HD endpoint, container exec, claude api,
iframe eval, workspace tabs, setup run-all, docker status, reload vscode,
browser profiles, open url, virgin reset api, reboot api, diagnostics,
ad health, test window-tree, ai cursor, setup panel, screenshot mode.
---
# HD Control API Reference
The control API exposes **~159 endpoints**. Don't trust a static count or list in
this file to stay exhaustive — the API is **self-documenting**, with two live
sources of truth you should hit first:
| Source | What it gives you | Where |
|---|---|---|
| `GET /_manifest` | **THE complete catalog** — every registered route (`path`, `method`, `args`, `category`, `description`, `visibility`). Curated entries carry richer descriptions; the rest are auto-generated from the router (`scripts/gen-manifest.py`), so it never drifts. **Discover here first — don't guess paths.** | `hd-control/src/lib.rs` `/_manifest` arm |
| **API Explorer window** | The human face of `/_manifest`: every endpoint grouped by category tab, with a runnable **Try** + Copy URL. Open from the Adom menu, or `POST /api-explorer/open`. | Adom menu ▸ API Explorer |
| `GET /test/help` | The `/test/*` diagnostics (window enumeration, UIA click, dialog classification, cascade reset). **Gated behind `HD_TEST_API_ENABLED=1`** — 404 by default (expected, not a bug). | `hd-control/src/lib.rs` `/test/help` arm |
## ⛔ Safety — the API enforces this, and tells you in-band
The API itself now labels, warns about, and gates dangerous endpoints (single source of
truth: `safety.rs` in hd-control). Three tiers:
- **`catastrophic`** — irreversibly destroys real user state: `/setup/virgin-reset`,
`/setup/panel/(run-virgin-reset|wipe-keep-auth)`, `/wsl/unregister` (deletes the whole
workspace distro), `/docker/(uninstall|clean-reinstall)`, `/system/reboot` (reboots the
user's COMPUTER), `/config-reset`, `/claude/wipe-creds`, `*-delete`.
**Headless calls (no browser Origin header — i.e. YOU) get HTTP 409 `confirmation_required`
and NOTHING happens** until you re-call with `?confirm=1`. Only add `?confirm=1` when the
user explicitly asked for exactly that action — never to make an error go away.
- **`destructive`** — kills/restarts/re-runs real state (recoverable but real):
`/app/(restart|relaunch-frontend)`, `/reload-vscode`, `/docker/(stop|restart)`,
`/container(-stop|-restart)`, `/workspace/apply-updates`, `/setup/(run-all|run-step|state|
force-close|trigger|countdown)`, `/claude/tabs/close-all`, `/updater/install`.
Never call these speculatively or in a sweep.
- **`disruptive`** — the user SEES it (window opens, focus moves, typing lands in their
real chat, screen recording starts): `/hdbw/open`, `/api-explorer/open`, `/open-url`,
`/open-in-profile`, `/window/foreground`, `/ai-cursor/*`, `/recording/native/start`,
`/ui/toast`, `/claude/(trigger-auth|authorize-in-browser|open-with-message|inject*|send|
submit|uia-*|new-chat-and-send|type-and-submit)` (the send/submit ones SPEND a real user
turn). Run only when the user actually wants that action, and clean up after yourself.
(Calling `/claude/authorize-in-browser` blindly is how you spray OAuth windows into Edge.)
How the API tells you:
- **Before calling:** every `GET /_manifest` entry carries `safety` + `safety_note`. Check
them before you POST anything unfamiliar. Unlabeled = safe.
- **When you call:** responses from these endpoints include a `_safety` block. READ IT —
it says exactly what you just did to the user's machine.
- **The 409 gate** is your last chance to stop; it is not an error to route around.
A safe GET/POST returns within a couple seconds with a 2xx or a clean 4xx `{ok:false,...}`.
If you must probe an endpoint you're unsure about, POST an empty `{}` body and treat a
graceful validation error as "it works" — a 500 or a hang is the bug.
**Base URL:** `http://127.0.0.1:<dynamic>` — the same loopback address from inside the
workspace (WSL2 mirrored networking shares loopback with the Windows host) and on the
Windows host. The port is dynamic per launch; read the live URL from
`~/.adom/hd-control-url` — see "Finding the Control Port" below.
## Finding the Control Port
The control API port is dynamic (default 47084, auto-resolves conflicts).
```bash
# From HD log
adom-desktop hd_log '{"tail":100}' | grep "control="
# From ports.json
type %APPDATA%\hydrogen-desktop\ports.json
# From inside the WSL2 workspace — USE THIS. HD writes its live control URL to a discovery
# file every launch (mirrored loopback), so you never hunt for the dynamic port:
CTRL="$(cat ~/.adom/hd-control-url)"; curl -sf "$CTRL/health"
# No file access (remote tool, browser)? Probe the DISCOVERY responder: fixed candidate
# ports tried in order (a single fixed port is not bindable on every Windows box; WSL2
# mirrored networking claims ports invisibly). Take the FIRST that answers with
# service == "hydrogen-desktop-discovery"; its control_url is the live control API:
for p in 47080 48080 49180 50280; do
R=$(curl -sf --max-time 2 "http://127.0.0.1:$p/") && echo "$R" | grep -q hydrogen-desktop-discovery && break
done
```
⭐ **From the workspace, read `~/.adom/hd-control-url`** (e.g. `http://127.0.0.1:<control>`) — a
file, NOT an env var (your non-interactive Bash shells don't source `.bashrc`/`profile.d`). HD
rewrites it with the live dynamic port every launch. This is the channel for the UI command bus,
the live ports map, Claude control, etc. — distinct from `adom-cli` (`ADOM_HYDROGEN_URL` → SSE),
which is unchanged for web-hydrogen parity.
## Calling gotchas (learned the hard way)
- **Never hardcode the port.** It's dynamic (47084 is only the default and it auto-resolves
conflicts). Read `~/.adom/hd-control-url` from the workspace, or `GET /ports`. Hardcoding
47084 is what makes tools show HD as "down" when it's actually up on another port.
- **Calling from a REMOTE container (hw/cloud)? Use `adom-desktop hd_api`,** the relay's
pass-through verb: `hd_api {"method":"POST","path":"/...","body":{...}}`. It auto-discovers
the port; never shell curl through the relay (quoting gets mangled). Long-blocking calls
(typed injection) can outlive the relay timeout: the work still completes on HD's side, so
verify state instead of re-firing.
- **Getting files OUT (recordings, screenshots): streamed binary, never base64.** Endpoints
return paths inside the WSL distro; a remote container pulls them with `adom-desktop
--target <desktop> pull_file {filePaths:["\\\\wsl$\\Adom-Workspace\\<path>"], saveTo:...}`
(1 MiB binary chunks, sha256-verified, no practical size limit). `/recording/native/stop`
returns the ready-made `fileWindows` UNC path. Full demo-video pipeline: the
`hd-demo-recording` skill.
- **Confirm it's really the control API, not the SPA.** In dev, the frontend file server
answers 200 with HTML for *any* path — so a bare 200 doesn't prove you hit HD's API. Check
`GET /health` returns JSON with `service:"hydrogen-desktop-control"`.
- **Driving HD from a page HD serves? Use relative fetches, not Tauri IPC.** Tauri `invoke`
is unavailable on remote http origins; a same-origin `fetch('/ports')` / `fetch('/_manifest')`
works and sidesteps port discovery entirely.
- **Driving from the CLOUD over the relay:** if two Adom Desktops are connected (laptop +
a VM), `desktop_*` verbs need `--target <name>` (e.g. `AdomLapper`) or they no-route and
look empty; `nbrowser_*`/`hd_*` auto-route. PowerShell emits **CRLF** — strip `\r` before
string-comparing its output. Long calls can exceed the relay's per-call timeout — run
detached + poll a marker (see `hd-cloud-relay-patterns`).
## Health & Status
| Method | Path | Description |
|---|---|---|
| GET | `/health` | HD alive check — returns `{ok, port, service}` |
| GET | `/buildinfo` | Running build git SHA + build metadata (the only trustworthy "did my build ship?" check) |
| GET | `/ports` | The 4 editable PortConfig ports (proxy, control, cdp, code_server) |
| GET | `/ports/all` | LIVE full port map — every host + WSL2 port HD has bound, with status + mirrored-mode info |
## AD-health diagnostics (`/ad/*`)
These verify the **adom-desktop / relay / embedded-AD** half of HD is alive (the
bridge that connects this cloud container to HD). All GET, all return `{ok, ...}`.
| Method | Path | Description |
|---|---|---|
| GET | `/ad/health` | Overall embedded-AD health roll-up |
| GET | `/ad/process` | Is the adom-desktop process running |
| GET | `/ad/embedded` | Embedded-AD module status |
| GET | `/ad/workspace-direct` | ⭐ The **converged** check — direct path to the workspace relay (use this one) |
| GET | `/ad/relay` | Relay (WS/HTTP) reachability |
| GET | `/ad/liveness-beacon` | Liveness beacon freshness |
| GET | `/ad/direct-api` | Direct-API reachability |
| GET | `/ad/discovery-file` | Discovery file present + parseable |
## Driving HD's UI (command bus)
Open/close/toggle any HD menu, dialog, or panel — the first-class alternative to CDP clicking. See the `hd-ui` skill.
| Method | Path | Body | Description |
|---|---|---|---|
| GET | `/ui/actions` | — | List every drivable UI action `{id,label,description,group}` (+ `_hints`) |
| POST | `/ui/invoke` | `{"id":"ports.open"}` | Run a UI action; returns `{ok,value}` only after the frontend ran it |
| GET | `/auth-token` | HD session token |
| GET | `/setup-status` | Overall setup completion state |
| GET | `/diagnostics/all` | Run all diagnostic checks |
| GET | `/diagnostics/{name}` | Run a single diagnostic check |
## Workspace / Container Management
These endpoints manage the active workspace runtime (a WSL2 distro by default, or a Docker container under `HD_RUNTIME=docker`).
| Method | Path | Body | Description |
|---|---|---|---|
| GET | `/container-status` | — | Workspace runtime available, image/distro present, workspace exists/running |
| GET | `/container-stats` | — | CPU, memory, disk usage |
| POST | `/container-restart` | — | Restart the active workspace runtime (5s timeout) |
| POST | `/container-stop` | — | Stop the active workspace runtime |
| POST | `/container-exec` | `{"command":"..."}` | Run command in the workspace as user `adom` |
| POST | `/container-delete` | — | Remove the workspace |
| POST | `/volume-delete` | — | Remove project volume (DESTRUCTIVE). **Docker runtime only** — no volume exists under WSL. |
| POST | `/image-delete` | — | Remove workspace image |
| GET | `/container-images` | — | List available workspace images |
| POST | `/container-switch-image` | `{"image":"..."}` | Switch to different image |
| POST | `/container-pull-image` | `{"image":"..."}` | Pull an image |
| GET | `/container/logs` | — | Workspace stdout/stderr |
| GET | `/container/env` | — | Workspace environment variables |
| GET | `/container/ports` | — | Published port mappings |
| GET | `/container/disk` | — | Disk usage inside the workspace |
| POST | `/container/restart` | — | Alias for `/container-restart` |
### WSL runtime endpoints (default runtime)
When HD runs the workspace as a WSL2 distro, it additionally exposes:
| Method | Path | Body | Description |
|---|---|---|---|
| GET | `/wsl/status` | — | WSL distro state (registered, running, code-server reachable) |
| GET | `/workspace/health` | — | Deep workspace health (distro + code-server + host reachability) |
| GET | `/virgin-reset-progress` | — | Live progress of an in-flight virgin reset |
| POST | `/workspace/terminal-profile` | — | Ensure the stable `Adom-Workspace` Windows Terminal profile (idempotent) |
| POST | `/workspace/terminal-profile/remove` | — | Remove the `Adom-Workspace` Windows Terminal profile(s) |
| POST | `/workspace/check-updates` | — | Check upstream for newer code-server + Claude extension; stage them |
| POST | `/workspace/apply-updates` | — | Apply staged code-server/extension updates + reload editor |
> ⛔ `POST /wsl/unregister` is **REFUSED as deprecated** — it was a headless
> destructive reset (deletes `/home/adom/project`). Use the Setup panel's Virgin
> Reset or `POST /setup/panel/run-virgin-reset` instead.
## Docker Desktop (legacy Docker runtime only — `HD_RUNTIME=docker`)
This `/docker/*` table applies only when HD is running the legacy Docker runtime.
| Method | Path | Body | Description |
|---|---|---|---|
| GET | `/docker/status` | — | Docker Desktop running state |
| GET | `/docker/version` | — | Docker engine version |
| GET | `/docker/containers` | — | List all Docker containers |
| GET | `/docker/images` | — | List all Docker images |
| POST | `/docker/start` | — | Launch Docker Desktop + start container |
| POST | `/docker/stop` | — | Stop Docker Desktop |
| POST | `/docker/restart` | — | Restart Docker Desktop |
| POST | `/docker/wait-ready` | — | Block until Docker daemon responds |
| POST | `/docker/install` | — | Install Docker Desktop (downloads ~730 MB) |
| POST | `/docker/uninstall` | — | Uninstall Docker Desktop |
| POST | `/docker/clean-reinstall` | — | Uninstall + reinstall Docker Desktop |
| GET | `/docker/install-progress` | — | Current install step + progress |
## WebView & VS Code
| Method | Path | Body | Description |
|---|---|---|---|
| POST | `/eval` | `{"js":"..."}` | JS eval in main HD webview |
| POST | `/iframe-eval` | `{"js":"...","contextIndex":N}` | JS eval in a specific iframe context (0-14) |
| POST | `/cdp-eval` | `{"expression":"..."}` | Direct Chrome DevTools Protocol eval |
| POST | `/click` | `{"x":N,"y":N}` | Click at coordinates in webview |
| POST | `/mouse-click` | `{"x":N,"y":N,"button":"left"}` | Mouse click with options |
| POST | `/query` | `{"selector":"..."}` | Query DOM elements |
| POST | `/reload-vscode` | — | Reload the VS Code iframe |
| POST | `/vscode/hide-activity-items` | — | Hide activity-bar items (persisted, overflow-proof) |
| GET | `/targets` | — | List CDP targets (all webview contexts) |
| GET | `/cdp-info` | — | CDP connection info (port, URL) |
| GET | `/devtools` | — | DevTools status |
### AI cursor (visible cursor for demos / clicking)
Distinct from `/click` and `/mouse-click` — this renders a **visible** AI cursor,
glides it, and can click. Use for demos or when the user should see where the AI clicks.
| Method | Path | Body | Description |
|---|---|---|---|
| POST | `/ai-cursor/move` | `{"x":N,"y":N}` | Move (glide) the visible AI cursor to coordinates |
| POST | `/ai-cursor/click` | `{"x":N,"y":N}` | Move the visible AI cursor there + click |
| POST | `/ai-cursor/hide` | — | Hide the visible AI cursor |
### Screenshot primitives
| Method | Path | Body | Description |
|---|---|---|---|
| GET | `/capture/viewport` | — | Capture the HD webview viewport (PNG) |
| POST | `/shot` | `{...}` | Screenshot primitive (see **hd-self-screenshot** for the full workflow) |
### Port hints
| Method | Path | Body | Description |
|---|---|---|---|
| GET | `/adom-port-hints` | — | Read the workspace port-hints map |
| POST | `/adom-port-hints` | `{...}` | Write/merge the workspace port-hints map |
## Claude Code
### Status / auth state (GET)
| Method | Path | Description |
|---|---|---|
| GET | `/claude/status` | Install state, auth, active session, version |
| GET | `/claude/auth-check` | Deep auth check — file exists, token, expiry |
| GET | `/claude/auth-status` | Auth-status detail |
| GET | `/claude/auth-page-state` | Which auth screen the panel shows: `continue-in-browser` \| `subscription-button` \| `chat`; returns `oauth_url` + `at_browser_auth_gate` |
| GET | `/claude/detect-auth-failure` | Scan webview contexts for auth button/errors |
| GET | `/claude/sessions` | List active Claude sessions |
### Auth actions (POST)
| Method | Path | Body | Description |
|---|---|---|---|
| POST | `/claude/find-auth-button` | — | Find + highlight the sign-in button WITHOUT clicking (validation) |
| POST | `/claude/start-auth` | — | Find + highlight + CLICK the "Claude.ai Subscription" button to begin OAuth |
| POST | `/claude/trigger-auth` | — | Alias for start-auth |
| POST | `/claude/detect-state` | — | Detect current auth/panel state |
| POST | `/claude/authorize-in-browser` | `{"timeout_ms":N,"dry_run":B,"fresh":B,"oauth_url":"..."}` | Auto-click the "Authorize" button on the claude.ai OAuth page (unattended sign-in) |
| POST | `/claude/authorize-tab-enter` | — | Approve the Adom OAuth tab via Enter (branded picker flow) |
| POST | `/claude/inject-creds` | — | Push host backup credentials into the workspace |
| POST | `/claude/wipe-creds` | `{"backup":B}` | Wipe Claude credentials (destructive); `backup:false` keeps the host backup so reinject can restore |
### Panel / conversation control (POST unless noted)
| Method | Path | Body | Description |
|---|---|---|---|
| POST | `/claude/open` | — | Open Claude Code panel (`claude-vscode.editor.open`) |
| POST | `/claude/ensure-open` | — | Ensure the panel is open (strict readiness; recovery if not rendered) |
| POST | `/claude/open-with-message` | `{"message":"..."}` | Open + pre-fill message |
| POST | `/claude/focus` | — | Focus Claude panel |
| POST | `/claude/new-conversation` | — | Start a fresh conversation (shows sign-in panel when unauthenticated) |
| POST | `/claude/new-chat-and-send` | `{"text":"...","typed":B,"submit":B,"delay_ms":N}` | Open fresh conversation, wait for compose box, type + (default) submit |
| POST | `/claude/submit` | — | Find + click send button |
| POST | `/claude/send` | `{"message":"..."}` | Send message to Claude |
| POST | `/claude/inject` | `{"message":"..."}` | Inject text into the compose box |
| POST | `/claude/inject-message` | `{"message":"..."}` | Inject into conversation |
| POST | `/claude/type-and-submit` | `{"message":"...","delay_ms":N}` | Type char-by-char (human-paced) + submit |
| POST | `/claude/remote-control` | `{"name":"..."}` | Turn on Claude Code Remote Control (submits `/remote-control` slash command) |
| POST | `/claude/highlight-open-button` | `{"duration":N}` | Glow animation on Claude button |
| POST | `/claude/clear-open-highlight` | — | Clear the open-button highlight |
## OAuth & Auth
| Method | Path | Body | Description |
|---|---|---|---|
| POST | `/claude-auth` | — | Full PKCE OAuth flow — opens browser, sets up callback |
| GET | `/oauth/callback` | — | OAuth callback handler (receives auth code) |
| POST | `/auth/proxy` | varies | Proxy auth requests to carbon.adom.inc |
| POST | `/open-url` | `{"url":"..."}` | Open URL (triggers browser picker) |
| POST | `/start-oauth-proxy` | `{"port":N}` | Start OAuth callback proxy on a port |
| GET | `/browser-profiles` | — | List browsers + profiles with avatars |
| POST | `/open-in-profile` | `{"url":"...","browser":"...","profileDir":"..."}` | Open URL in specific browser profile |
| POST | `/browser-picker/opened` | `{...}` | Frontend reports the browser picker was opened |
| GET | `/browser-picker/last-open` | — | Last browser-picker open event |
## Setup Steps & Setup Panel
### State / trigger / step control
| Method | Path | Body | Description |
|---|---|---|---|
| GET | `/setup/state` | — | Get current setup state (`never-run` \| `in-progress` \| `complete`) |
| POST | `/setup/state` | `{"state":"..."}` | Set setup state |
| POST | `/setup/step/<id>` | `{"continue_after":B}` | ⭐ Run one step by id (forces panel visible first); `continue_after:true` runs this step + every step after it |
| POST | `/setup/force-close` | — | Force-close setup panel |
| POST | `/setup/countdown` | `{"seconds":N,"message":"..."}` | Show countdown overlay |
| GET | `/setup/trigger` | — | Read trigger file |
| DELETE | `/setup/trigger` | — | Delete trigger file |
| POST | `/config-reset` | — | Reset HD config |
> ⛔ **REFUSED as deprecated** (headless runs with no visible UI):
> `POST /setup/run-all`, `POST /setup/run-step`, `POST /setup/virgin-reset`,
> `POST /wsl/unregister`. They return a refusal pointing at the UI-visible path.
> To run steps: use `POST /setup/step/<id>` or relaunch HD to auto-run the cascade.
### Setup panel control
| Method | Path | Body | Description |
|---|---|---|---|
| POST | `/setup/panel/show` | — | Force the setup panel visible |
| POST | `/setup/panel/hide` | — | Hide the setup panel |
| POST | `/setup/panel/show-virgin-reset` | — | Show panel + expand the Virgin Reset section |
| POST | `/setup/panel/virgin/show` | — | Show the Virgin Reset section only |
| POST | `/setup/panel/virgin/hide` | — | Hide the Virgin Reset section |
| POST | `/setup/panel/set-virgin-options` | `{"options":{...}}` | Set the virgin-reset checkboxes |
| POST | `/setup/panel/run-virgin-reset` | — | ⭐ The **ONLY** allowed virgin-reset trigger — UI-visible by construction (shows panel + checkboxes + progress before deleting anything) |
| POST | `/setup/panel/wipe-keep-auth` | — | Synchronous wipe that KEEPS Adom + Claude tokens |
| POST | `/setup/screenshot-mode` | `{"enabled":B}` | Toggle per-step screenshot capture (default OFF) |
| GET | `/setup/screenshot-mode` | — | Whether per-step screenshot mode is on |
## Workspace Tabs
| Method | Path | Body | Description |
|---|---|---|---|
| GET | `/workspace/tabs` | — | List all workspace tabs |
| GET | `/workspace/tabs/find?name=X` | — | Find tab by name (404 if not found) |
| POST | `/workspace/tabs` | `{tab spec}` | Add a new tab |
## System
| Method | Path | Body | Description |
|---|---|---|---|
| GET | `/system/nonce` | — | Get nonce for authenticated reboot |
| GET | `/system/reboot-state` | — | Check reboot pending state |
| POST | `/system/reboot` | `{"nonce":"..."}` | Reboot Windows (nonce-authenticated) |
| POST | `/system/cancel-reboot` | — | Cancel pending reboot |
## App lifecycle & window
| Method | Path | Body | Description |
|---|---|---|---|
| POST | `/app/restart` | — | Restart the HD app (kills + respawns the binary) |
| POST | `/app/relaunch-frontend` | — | Relaunch the HD GUI (kill+respawn) while it is responsive |
| POST | `/window/foreground` | — | Bring the HD window to the foreground (unminimize + focus) |
## Diagnostics (`/test/*`)
Window enumeration, UIA clicking, native-dialog classification, and cascade
diagnostics. **Gated behind `HD_TEST_API_ENABLED=1`** (return 404 / "test API
disabled" otherwise). `GET /test/help` self-lists the whole cluster.
| Method | Path | Body | Description |
|---|---|---|---|
| GET | `/test/help` | — | Self-lists every `/test/*` endpoint |
| GET | `/test/window-tree` | — | Enumerate all visible top-level windows |
| POST | `/test/focus` | `{"window":"..."}` | Focus HD (`self`) or a window by title substring |
| POST | `/test/click-uia` | `{...}` | Poll UIAutomation for a Button by Name; Invoke when found |
| GET | `/test/setup-steps` | — | Raw setup-steps.json + derived summary (alias: `/test/install-steps`) |
| POST | `/test/dump-state` | `{...}` | Write a diagnostic bundle (logs, install-steps, docker, tasklist, window-tree) |
| POST | `/test/reset-cascade` | — | Delete install-steps.json + emit setup-reset event |
| POST | `/test/screenshot-hd-window` | `{...}` | Capture HD's own window to a PNG file |
| GET | `/test/probe-dialogs` | — | Enumerate visible windows, classify against the cascade-blocker catalog (UAC, WSL2 update, MSI installer, Docker EULA, …); returns `kind` + `is_blocking` per window |
## Key Patterns
### iframe-eval context discovery
Contexts 0-14 cover all webview frames. Context numbers shift when panels open/close.
Always scan, never hardcode. The Claude Code extension webview IS reachable through
these contexts.
```
POST /iframe-eval {"js":"document.body.innerText.substring(0,100)","contextIndex":3}
```
### container-exec
Runs as user `adom` inside the workspace. Use full paths (`/home/adom/`
not `~/`). Tilde doesn't expand through the exec chain.
```
POST /container-exec {"command":"ls /home/adom/project"}
```
### Re-run a setup step after completion
The trigger file poller STOPS when all steps are done. `POST /setup/run-all` is
**REFUSED as deprecated** — don't use it. Re-run one step by id (or relaunch HD to
auto-run the cascade):
```
POST /setup/step/<id>
```