# Device HTTP API

These routes belong to the device HTTP server, not the public MCP URL.


The device server uses port `8080`. Use the device's reachable local-network
address or a direct USB port forward from your computer. Send
`Content-Type: application/json` for JSON POST bodies and URL-encode query
parameters. Each route below lists its HTTP method.

### Connection and capability discovery

Browse the [HTTP endpoint reference](https://mcp.applesauceos.com/doc/http) for an individual page per method/path, with parameters, responses and curl examples.

Use a user-provided base URL, such as `http://DEVICE_LAN_IP:8080`. For direct
USB access, forward the device's HTTP port to a computer port; for example,
`iproxy 18080:8080`, then use `http://127.0.0.1:18080`. This does not require SSH.
Keep the forward running. Device pairing/trust and a working USB connection are
required. A computer's `localhost` is not the device unless a forward exists.

`GET /automation` returns `apiVersion: 1`, supported features, and size limits.
Use `GET /endpoints` to discover the installed endpoint set. The following
endpoints support the HTTP-only MCP and any client that can send HTTP requests.

### Apps, buttons, gestures, and full screenshots

| Method | Path | Input / result |
|---|---|---|
| GET | `/apps/list?query=...&limit=100` | Installed `{apps:[{bundleId,name}],limit}`; limit 1..300 |
| POST | `/apps/open` | `{bundleId,restart:false}`; returns `{opened,front,restarted}` |
| POST | `/device/button` | `{button:"home"}`; also `power`, `volume_up`, `volume_down` |
| POST | `/device/touch` | `{x,y}` for a tap; add `endX,endY,duration` for a swipe |
| GET | `/device/screenshot` | Full-device PNG, including overlays; available independently of an app's element tree |

A restart explicitly terminates an app and may discard unsaved state. Opening
an app does not prove its contents have loaded; verify the returned foreground
identity and wait for a meaningful element. Gesture coordinates use native
interface pixels from a fresh screenshot. `duration` is 0.05..3 seconds, default
0.35, and both swipe end coordinates are required. Buttons and gestures do not
bypass a passcode. Inspect the resulting screen after every action.

### Files and project directories

All file paths are relative to the user's Scripts folder. Absolute paths,
parent traversal, symbolic-link traversal, and managed execution files are
rejected. Do not use a device-specific absolute filesystem prefix. Parent
folders are created when writing a file, so writing `Demo.aso/lib/helper.js`
can create an organized project without a separate upload step.

| Method | Path | Input / result |
|---|---|---|
| GET | `/files/list?path=&limit=100&offset=0` | `{directory,entries,total,nextOffset}`; entries include `path`, `name`, `directory`, `size`, `modifiedAt` |
| GET | `/files/read?path=Demo.aso/main.js` | `{path,content,encoding,bytes,sha256,offset,truncated,nextOffset}` |
| GET | `/files/download?path=...` | Raw file bytes |
| POST | `/files/write` | `{path,content,encoding:"utf8",overwrite:false,expectedSha256?}`; returns `{path,bytes,sha256}` |
| POST | `/files/mkdir` | `{path}`; create a directory or project folder |
| POST | `/files/move` | `{path,destination,expectedSha256?}`; destination must not exist |
| POST | `/files/delete` | `{path,expectedSha256?}`; delete one file or an empty directory |

Files are limited to 8 MiB. Read/write encodings are `utf8` and `base64`; binary
assets should use Base64 or the download endpoint. Reads accept byte `offset`
and `maxBytes` (1..524288; default 262144). Follow `nextOffset` when `truncated`
is true. For UTF-8 text, use the returned offset rather than adding a character
count. Listing accepts limit 1..500 and offset 0..10000.

Creating a file never replaces an existing one. To replace it, first read its
current `sha256`, then send `overwrite:true` and that value as `expectedSha256`.
A changed or missing file returns HTTP 409. Deleting or moving a regular file
also requires its current hash. Empty directory deletion needs no hash and is
never recursive. Active script/project paths cannot be changed. Stop related
runs first, and delete retained automated runs before moving their project
folder. Preserve unrelated files and unsaved editor work.

```json
{
  "path": "Demo.aso/main.js",
  "content": "console.log('Ready');\n",
  "encoding": "utf8",
  "overwrite": false
}
```

### JavaScript execution with results

| Method | Path | Input / result |
|---|---|---|
| POST | `/automation/run` | `{id?,source?,path?,args?}`; returns `{runId,nativeRunId,state,sourcePath}` |
| GET | `/automation/runs?id=...` | One run's `state`, `running`, `result`, logs and metadata |
| GET | `/automation/runs?limit=20` | Retained run IDs, newest first; limit 1..64 |
| POST | `/automation/stop` | `{runId}`; request cancellation of that run only |
| POST | `/automation/delete` | `{runId}`; remove a finished run and its execution artifacts, preserving the original script |

Provide either an async JavaScript function body in `source`, or a saved `.js`
`path`. Bodies may use `return` and `await`. Saved scripts keep their original
relative-import base and are not overwritten. Use `/scripts/run` for the normal
`.js` or `.aso`/`.cso` launcher, including repeats; use `/automation/run` for a
structured returned value, per-run console output and an error stack.

Source is limited to 256 KiB. Results must be JSON-serializable and fit within
128 KiB of serialized text. Convert native proxies with `.toJS()` and exact
large integers to strings. Console output retains up to 200 events and 64 KiB
of text. The result contains `ok`, `value` or `error`, `runId`, timestamps,
`logs`, and `droppedLogEntries`. Errors include name, code, message, stack, line,
and source URL when available. Logs are also available while a run is active.

```json
{
  "source": "console.log('Test started'); return {foreground: frontMostAppId()};",
  "args": {"label": "example"}
}
```

Arguments use the same immutable string `args` / `at.args` contract as the
normal launcher. A submission acknowledges acceptance, not completion. Poll
its own `runId` until `running` is false. States are `running`, `finishing`,
`succeeded`, `failed`, `cancelled`, or `interrupted`; an initial acknowledgement
uses `submitted`. Cancellation is cooperative and does not undo completed work.
A run that finishes before cancellation may still report success.

Choose a UUID `id` before submission when recovering from a lost connection
matters. Both standard UUID notation and 32 hexadecimal characters are accepted;
the returned `runId` is canonical lowercase hexadecimal. A retained ID cannot
be submitted twice. After an uncertain submission, inspect that ID and the
actual screen before deciding what to do. Do not replay a mutation blindly.

Up to 64 execution records are retained until explicitly deleted. Records remain
available after reconnecting the MCP or restarting the device automation
service. An active run interrupted by a device/service restart may have no
completion record and reports `interrupted`; that is not success. Collect error
evidence before deleting a record. An active run cannot be deleted.

### Crash reports

| Method | Path | Result |
|---|---|---|
| GET | `/diagnostics/crashes?limit=20` | `{reports:[{reportId,name,modifiedAt,bytes}]}`; limit 1..100 |
| GET | `/diagnostics/crash?id=...&offset=0&maxBytes=65536` | `{reportId,content,offset,truncated,nextOffset}` |

Use the opaque `reportId` returned by the list; arbitrary system paths and
report deletion are not supported. Only reports accessible to ApplesauceOS are
listed. Report pages accept 1024..524288 bytes and offsets up to 64 MiB. Follow
`nextOffset` for large reports. Check the process name and timestamp before
attributing an existing report to a test. A JavaScript exception is not itself
an iOS process crash.

### HTTP failures

Check the HTTP status and JSON error response. Invalid inputs return 400,
unavailable/forbidden files 403 or 404, conflicts and active-file changes 409,
and oversized files 413. UI errors may include a specific `error` code and
`message`; file/execution errors include an `error` message. Re-observe after a
stale reference and re-read after a hash conflict. Fix invalid inputs rather
than repeatedly sending them.


### Status & Info

| Endpoint | Description | Example Response |
|---|---|---|
| `GET /` | Browser landing page with endpoint links | HTML |
| `GET /endpoints` | All registered method/path pairs, read directly from the router | `{port:8080, routes:[{method,path}], options}` |
| `GET /isBusy` | Script status; `busy`/`retV` exclude daemon scripts | `{"retV":false,"busy":false,"count":0,"scripts":[]}` |
| `GET /runningScripts` | List running script paths | `["MyAutomation.aso"]` |
| `GET /lastError` | Last JS error from engine | `{"lastError":"..."}` |
| `GET /deviceInfo` | Same object as `getDeviceInfo()` | `serialNumber`, `systemVersion`, `screen`, `battery`, etc. |
| `GET /SN` | Serial number (plain text) | `DEVICE_SERIAL` |
| `GET /IP` | Local WiFi IP (plain text) | `192.168.1.42` |
| `GET /tweakVersion` | Installed ApplesauceOS version | Plain text |
| `GET /appInfo?bundleId=...` | Same object as `appInfo(bundleId)` | App metadata or `installed:false` |
| `GET /temperature` | Device temperature; negative value if unavailable | `{"celsius":32.5}` |

Each `/isBusy` entry contains `path`, `fullPath`, `entryPath`, `daemon`, `stopRequested`, and
`runningSeconds`. `count` includes daemon scripts. `/runningScripts` returns the
original path strings used to start them.

### Script Control

| Endpoint | JSON body / query | Description |
|---|---|---|
| `POST /scripts/run` | `{path,args?,id?,repeat?,interval?}` | Start a relative `.js` file or `.aso`/`.cso` directory under Scripts; returns `{id,path,fullPath,entryPath}` |
| `GET /scripts/runs` | Optional `id` query | Without `id`: `{runs:[...]}`; with `id`: one execution journal, including output and errors |
| `POST /scripts/stop` | `{id}` | Request cancellation of that execution; returns `{stopRequested}` |
| `GET /stopAll` | — | Request cancellation of all non-daemon scripts |

```sh
curl 'http://127.0.0.1:8080/scripts/run' \
  -H 'Content-Type: application/json' \
  -d '{"path":"MyAutomation.aso","args":{"name":"Alex","count":5,"enabled":false},"repeat":1}'

curl 'http://127.0.0.1:8080/scripts/runs?id=RUN_UUID'

curl 'http://127.0.0.1:8080/scripts/stop' \
  -H 'Content-Type: application/json' \
  -d '{"id":"RUN_UUID"}'
```

`path` must be relative, remain inside Scripts, and end in `.js`, `.aso`, or `.cso`.
An `.aso` or `.cso` path is a directory: its entry is `main.js`, then `index.js`. A missing
entry fails instead of executing an arbitrary helper. Starting a project and its
entry concurrently is rejected as a duplicate. HTTP execution reads saved files;
App Run saves project drafts before submitting.

- `repeat`: integer 0..10000, default 1; 0 repeats until stopped.
- `interval`: 0..3600 seconds between iterations. When omitted, a directory's
  `info.plist` `RepeatInterval` may supply its default; otherwise 0.
- `id`: optional UUID chosen by a client before submission. IDs are canonicalized
  to uppercase UUID notation. A retained ID cannot be submitted again: inspect its
  journal instead of replaying an action after a lost response.
- `args`: optional object. Its keys are argument names; its values become strings
  in the script's reserved, immutable `args` namespace. `at.args` is the same object.

### Arguments in memory

Every run receives its own read-only argument object, available to main and all
required modules. Nothing automatically creates or reads an argument file.
Argument names do not become global variables, so they cannot replace automation
functions or launcher options. Use bracket notation for names with spaces.

| Submitted value | Value exposed to JavaScript |
|---|---|
| `"hello"`, `""`, `"007"` | Unchanged strings |
| `5`, `false`, `null` | `"5"`, `"false"`, `"null"` |
| JSON object / array | Compact JSON string; parse explicitly when needed |
| Missing key | `undefined` |

```js
console.log(args.name);             // "Alex"
const count = Number(args.count);   // 5
const enabled = args.enabled === "true"; // false
console.log(at.args === args);      // true
```

Do not use `Boolean(args.enabled)` to parse a boolean: `"false"` is a nonempty
string. `JSON.parse(args.config)` can reconstruct a structured argument. Assigning,
replacing, deleting, or extending the argument namespace is unsupported; strict
JavaScript throws for attempted mutation. A module can read `args` directly or
accept explicit function parameters. Concurrent runs have independent values.

Submission acknowledges startup, not successful completion. Invalid arguments
return HTTP 400. Missing entries, duplicate submissions, or launch failure return
409. Query the returned ID to determine the outcome. Stop is cooperative; a pure
busy loop without cancellation checks can delay stopping.

Journals retain 30 finished runs plus active runs in memory and reset when
SpringBoard restarts. Fields include `id`, `path`, `entryPath`, `state` (`running`,
`completed`, `failed`, `stopped`), `startedAt`, `finishedAt` after completion,
`logs`, and `droppedLogs`. Failures include `scriptError`, `errorSource`, and
`errorLine`. List summaries omit logs. At most eight journaled runs may be active.
The shared log is available through `/logs`; use a run ID for attributed results.

### Logs

| Endpoint | Params | Description |
|---|---|---|
| `GET /logs` | `lines=N` (default 200, 0=all) | Tail of script log (plain text) |
| `GET /logs/clear` | — | Clear the log |

Logs are shared by scripts. Use a unique run ID in your own completion/error
messages, poll that run's path, and verify the intended UI outcome. `/lastError`
is historical engine state, not a per-run success result. Clearing logs does not
clear that field.

### Settings

Synthetic touch indicators are always active and cannot be disabled through settings or JavaScript.

The installed ApplesauceOS app provides these controls without requiring a
computer. Its **Blacklist** disables the helper for selected bundle identifiers.
Fully close and reopen an app after changing its blacklist entry. UIHelper
and RemoteObjC/RemoteC calls are unavailable for blocked apps. Coordinate touch
and visual automation remain available. Obtain fresh elements and reconnect
native clients after enabling the helper again.

**Avoid safe mode** and **Show IP in status bar** are disabled by default.
Recovery changes apply after restarting SpringBoard. Other switches update
running processes through notifications and cached preferences.

**Keep screen awake** prevents automatic display sleep while enabled; it does
not unlock a device or prevent manually locking it. It is disabled by default.

`GET /settings` returns the preference snapshot; `POST /settings` applies a
validated partial update. Boolean keys are `avoidSafeMode`, `keepAwake`,
`debugLog`, `helperEnabled`, `serverEnabled`, `inspectorShortcut`,
`volumeStop`, `showIPInStatusBar`, `errorAlerts`, and `scriptToasts`.
`disabledHelperBundleIDs` is an array of bundle identifiers. Writes preserve
unrelated settings and are serialized across processes. Turning `serverEnabled`
off disconnects new HTTP/MCP requests; it can be enabled again from the app.

`GET /settings/logs` reads at most the last 128 KiB of the script log.
`POST /settings/action` accepts `{action:"inspector"}`, `{action:"stopScripts"}`,
or `{action:"respring",confirm:true}`. Restarting SpringBoard ends active scripts.

| Endpoint | Params | Description |
|---|---|---|
| `GET /setDebugLog` | `on=0\|1` | Toggle verbose [ApplesauceOS] system log |
| `GET /setErrorAlert` | `on=0\|1` | Toggle error alert popups |
| `GET /setScriptToasts` | `on=0\|1` | Toggle script start/stop toasts |
| `GET /setKeepAwake` | `on=0\|1` | Persist ApplesauceOS's keep-awake preference |
| `GET /setSafeMode` | `on=0\|1` | Set **avoidSafeMode** for the next SpringBoard launch; `on=1` enables avoidance, not safe mode |

Omitting `on` reads the current value for every setting above except
`/setErrorAlert`, which requires it. Temporary scripts should restore the
preferences they change. These settings are not performance or crash guarantees.

---


## UIHelper inspection and actions


Press **Volume Up + Volume Down together** to open or close the on-device
UIHelper inspector. A chord toggles once until both buttons are released.
Holding either button alone for 0.2 seconds still stops scripts. The HID monitor
observes the buttons; normal system volume behavior is not suppressed.

The inspector captures the foreground app and displays a frozen screenshot
at the available screen width. Drag to pan; pinch or double-tap
to zoom. UIHelper/OCR allow up to 4x; Coordinates allows substantially higher
zoom, reaching up to 32 interface points per source pixel (capped at 128x).
**Fit** shows the entire image. These gestures manipulate
the captured image, not the underlying app.
Zoom uses gestures; there are no `+`/`−` buttons.
Tap the image to select an element without shrinking the screenshot.
Picking prioritizes named controls and accessibility elements over decorative
views, including SwiftUI drawing layers. Within each priority, smaller and
deeper elements come first.
**Previous / Next** cycles through overlapping elements and containers.
**Details** opens a separate panel with larger text; **Back to screen** captures
the app again and clears the old selection. Zoom and position are preserved
when the image and viewport dimensions are unchanged; select an element from
the refreshed image. **Refresh** also captures again and resets the viewport.
The details show UIHelper attributes, supported operations, candidate selectors
and their match counts at capture time. **Test** queries the selected suggestion
against the live app; **Read** retrieves current text; **Copy JS** copies a
UIHelper snippet using a unique selector. When none is unique, it copies an
explanatory comment instead of selecting an arbitrary match. **Tap** hides the
inspector, acts through UIHelper and refreshes the snapshot. **Refresh** captures
again; **Close** dismisses it. **Test** only counts matches; it does not tap.
The selected name is followed by its center **x/y in native touch pixels**.
**Copy XY** copies `x, y`; zoom and panning do not alter those coordinates.
In OCR mode, the primary button is **Copy Rect** and the displayed geometry is
`x, y, width, height`. Copy Rect produces a JSON region in native pixels, with
edges rounded outward and clipped to the screen. The highlighted box uses
those same bounds. Pass the copied object as `region` with
`ocr({coordinates:"pixels", region: ...})`. A separate **Copy XY** button
between Previous and Next copies the text rectangle's center for touches.
The mode switch offers:

- **UIHelper**: the accessibility tree, selectors, and existing UIHelper actions.
- **Coordinates**: pick any native pixel, copy `tap(x, y)`, or send a simulated
  touch. Enlarged pixels use nearest-neighbor sampling, a grid appears at high
  zoom, and the selected pixel is outlined at its actual size. The four arrows
  replace Previous/Next and move exactly one native pixel per press. Double-tap
  jumps to pixel-level zoom. Refreshing captures only the screen, without a
  helper query. Coordinates identify image pixels: taps inside `[x,x+1)` and
  `[y,y+1)` select `(x,y)`, independently of the displayed magnification.
- **OCR**: recognize visible text in the current image once in the background,
  then select a text box. Copy its text or code that calls
  `ocr({level:0, coordinates:"pixels"})`, requires one exact text match, and
  taps its center. Results are reused until the next capture; errors are shown
  without disabling coordinate picking.

In Coordinates Details, **Copy XY** replaces Test. In OCR Details, **Copy Rect**
replaces Test and **Copy text** replaces Read. The separate Copy XY center
button remains available. **Tap** sends a simulated touch rather than a UIHelper
operation. Coordinate/OCR modes also work with a blacklisted app or no accessible
elements. **Insert code into script** offers coordinate taps for every selection,
OCR lookup/tap for recognized text, and UIHelper actions when a unique selector
is available. Inserted snippets have their own block scope.
Fixed coordinates depend on device size, orientation, and layout. OCR code
searches the live screen when executed; it does not reuse a saved text position.
Recognition locates text, not the tappable area of its associated control. Test
the center or choose the actual target in Coordinates mode (for example, an icon
instead of its label).

External UIHelper mutations are rejected while the inspector is visible, to
avoid sending injected taps into the overlay. Scripts may still read state.
The open inspector retains its current observation independently of the HTTP
cache, releasing it on refresh or close. It does not expire after 60 seconds
or disappear when HTTP clients request other observations. Element and app
identity checks still apply; refresh when the underlying UI changes or its
native reference is no longer available. The inspector window is excluded
from UIHelper's own hierarchy.

For script-side observation:

```js
const ui = UIHelper.forApp("com.apple.Preferences");
const observation = ui.observe({limit: 300, includeContainers: false});
console.log(JSON.stringify(observation));
```

For an AI or another runtime client, use the existing ApplesauceOS HTTP server:

Omit `bundleId` to use the foreground app, or provide it to require that app
to be foreground. `/ui/query` and `/ui/wait` require an **object** selector;
use `{name:"..."}` instead of the JavaScript string shorthand. Observation
flags default to `false`; screenshots are requested explicitly.

| Route | Behavior |
|---|---|
| `GET /ui` | Discover the UIHelper endpoint contract. |
| `GET /ui/observe` or `POST /ui/observe` | Capture structured foreground-app elements. Options: `bundleId`, `limit` (1..1000, default 300), `includeContainers`, `includeHidden`, `screenshot`. GET boolean flags accept `1` or `true`; POST uses JSON booleans. |
| `GET /ui/screenshot?observationId=...` | PNG for an observation requested with `screenshot: true`. |
| `POST /ui/query` | `{bundleId, selector}`; return live match count, uniqueness and up to 50 match summaries. Uses exactly UIHelper selector semantics. |
| `GET /ui/element?observationId=...&ref=e1` | Read fresh attributes for an observed element. |
| `POST /ui/action` | `{observationId, ref, action, text?, direction?}`; actions: `read`, `tap`, `setText`, `typeText`, `clear`, `scroll`, `scrollIntoView`. Mutations are never retried automatically. |
| `POST /ui/wait` | `{bundleId, selector, condition, timeoutMs, intervalMs}`; condition is `exists` or `absent`. Defaults: 5000/200 ms; limits: timeout 0..10000, interval 100..2000 ms. |
| `POST /ui/inspector` | `{action: "open"}` or `{action: "close"}` schedules the overlay operation. |

```sh
curl 'http://localhost:8080/ui/observe?includeContainers=0&screenshot=1'
curl -X POST 'http://localhost:8080/ui/query' \
  -H 'Content-Type: application/json' \
  -d '{"bundleId":"com.example.app","selector":{"id":"login"}}'
curl -X POST 'http://localhost:8080/ui/action' \
  -H 'Content-Type: application/json' \
  -d '{"observationId":"COPY_FROM_OBSERVE","ref":"e3","action":"tap"}'
```

Observations contain `schemaVersion`, `observationId`, `bundleId`, `pid`,
`processSessionId`, timestamps, screen geometry and flat `elements` with short
`ref` values, `parentRef`/`depth`, attributes, rectangles, action names and
selector suggestions. HTTP and script-side observations share the same server
cache and omit internal element IDs. A script can pass its observation ID and
references to an HTTP client. Short references are meaningful only together
with their observation ID. Copy a suggested selector into `findElement` or
`waitForElement` for a reusable script.

| Observation field | Meaning |
|---|---|
| `backend`, `schemaVersion` | `"UIHelper"`, currently `1` |
| `observationId`, `bundleId`, `pid`, `processSessionId` | Snapshot and process identity |
| `timestamp`, `screenshotTimestamp` | Unix seconds; screenshot timestamp is present only when requested |
| `captureDurationMs`, `expiresInSeconds` | Capture duration and nominal cache lifetime |
| `screen` | `{width, height, scale, coordinateSpace:"nativePixels"}` |
| `scanned`, `eligible`, `truncated`, `completeTraversal` | Traversal/output coverage |
| `screenshotURL` | Relative PNG endpoint, present only when requested |
| `elements` | Flat element records; `parentRef` is omitted when no included ancestor exists |

Each element includes `ref`, `depth`, `identifier`, `type`, `className`, `label`,
`text`, `value`, `rect`, `visible`, `enabled`, `accessible`, `selected`, `focused`,
`index`, `actions`, and `selectors`. Selector suggestions have
`{selector, matches, unique, verified, stability}`; `stability` is `identifier`,
`label`, or `class`. Use `suggestion.selector`, not the entire suggestion object,
as the query selector.

`/ui/element` and `/ui/action` return
`{backend, observationId, ref, action, value}`. Reads put fresh attributes in
`value`; successful mutations put `true` there. This acknowledges the action,
not completion of subsequent navigation. `/ui/wait` success returns
`{matched:true, condition, count, elapsedMs}`. A screenshot request returns PNG
bytes, or `404` if it expired or was not included in the observation.

The server retains observations for up to 60 seconds, at most eight entries;
screenshots also have a 12-million-pixel aggregate cache budget (one screenshot
may exceed that budget). Old entries may be evicted earlier. References retain
UIHelper's process and detached-view checks. Before a reference operation, the
helper checks the element's identifier, class, type, label and rectangle in its
main-thread action handler. A changed/recycled element, changed foreground app
or expired observation returns a stale-reference error; observe again.

Snapshots scan at most 5000 nodes and return at most the requested limit, within
the response size limit. `truncated` reports omitted output; `completeTraversal`
indicates whether selector counts covered the traversed tree completely.
Suggestions are marked unique only after a complete traversal. Match counts are
capture-time evidence, not a guarantee across navigation or app updates.
Visibility follows UIHelper's frame/clipping checks, not full occlusion testing.
Secure text field text/value are omitted from inspection responses.

Screenshots are opt-in. Screenshot and tree captures occur close together but
are not atomic; timestamps and capture duration are included. Foreground-app or
screen-geometry changes during capture reject the observation. Close the
inspector before requesting an HTTP screenshot. Rectangles use native pixels;
the inspector maps those coordinates through the screenshot's zoom and pan.

The endpoints do not launch apps, continuously capture screenshots, or poll the
full UI tree in the background. `/ui/wait` polls only its selector;
an individual helper request can outlast the polling deadline.
Errors return JSON with `error` and `message`, with HTTP 400 for invalid requests,
409 for stale/non-interactable observations, 408 for timeouts, and 503 for an
unavailable helper. A failed wait includes its last query for diagnosis.

