AI agents should fetch https://remote.ivjn.us/SKILL.md.

# HTTP API for Agent operations

Third-party clients and simple scripts can operate online devices over HTTPS without opening a WebSocket. Interactive streaming (PTY, file transfer, Android screen control) still uses the browser/operator WebSocket path.

## Base URL

Production Exchange:

```text
https://exchange.ivjn.us
```

Self-hosted deployments use your own HTTPS origin. All paths below are relative to that base. Send JSON with `Content-Type: application/json`.

## Authentication

Every `/api/*` route (except public auth/register helpers) requires:

```http
Authorization: Bearer <token>
```

`<token>` may be either:

1. **Password-login session token** from `POST /api/auth/login`
2. **Operator primary API key** (the long-lived token shown once when the operator account was created)

Both are accepted by the same `authenticate_operator` path used by the console and CLI.

### Password login

```bash
curl -sS -X POST 'https://exchange.ivjn.us/api/auth/login' \
  -H 'Content-Type: application/json' \
  -d '{"username":"alice","password":"your-password-here"}'
```

Example response:

```json
{
  "username": "alice",
  "tenant_id": "00000000-0000-0000-0000-000000000000",
  "token": "<prefix>.<secret>",
  "expires_in": 2592000
}
```

Use `token` as the Bearer credential. Call `POST /api/auth/logout` with the same header to revoke that session. Primary operator API keys are not revoked by logout.

### Operator API key (primary token)

```bash
TOKEN='<prefix>.<secret>'   # issued at operator creation; store offline
curl -sS 'https://exchange.ivjn.us/api/devices' \
  -H "Authorization: Bearer ${TOKEN}"
```

## List devices

```bash
curl -sS 'https://exchange.ivjn.us/api/devices' \
  -H "Authorization: Bearer ${TOKEN}"
```

Each item includes `id`, `name`, `online`, `enabled`, `info`, timestamps, and `active_sessions`. One-shot operations below require the device to be **online** on Exchange (or reachable via the Redis cluster hop).

## One-shot device operations

All of these reuse the same hub session path as WebSocket `OpenExec` / `OpenSensors` / `OpenNet` / `UpdateDevice` / `OpenUbus`. Readonly operators receive `403`. Offline devices receive `503`. Concurrent session limits on a device receive `429`.

| Method and path | Body | Success body |
| --- | --- | --- |
| `POST /api/devices/{id}/exec` | `{"command":"…","timeout_secs":60,"hold":true,"hold_timeout_secs"?}` | `{code, timed_out, stdout?, stderr?, job_id?}` |
| `GET /api/devices/{id}/exec/holds` | — | `{jobs:[…]}` |
| `POST /api/devices/{id}/exec/holds/{job_id}/kill` | — | `{job_id, killed}` |
| `GET /api/devices/{id}/exec/holds/{job_id}/cat` | — | `{job_id, status, stdout?, stderr?, …}` |
| `POST /api/devices/{id}/sensors` | _(empty / omitted)_ | `SensorReport` JSON |
| `POST /api/devices/{id}/net` | `NetAction` JSON (`action`: `ping` \| `http`) | `NetResult` JSON |
| `POST /api/devices/{id}/update` | `{"check_only":true,"source":"https://…"}` | `{phase, message}` |
| `POST /api/devices/{id}/ubus` | `{"object":"system","method":"board","params":{}}` | `{code, data}` |

`timeout_secs` of `0` (or omitted): with **hold** (default `true`) uses 3600s soft hold timeout (hard cap 86400); with `hold:false` uses Exchange default (`LUCI_EXEC_TIMEOUT_SECS`, typically 60, capped at 600). Optional `hold_timeout_secs` soft-overrides within the hard cap. Response may include `job_id` for later attach. **Console/operator foreground wait timeout must detach (not kill)** — use `detach_session` / hold list kill·tail·cat. Operator/HTTP disconnect detaches held jobs instead of killing them. Stdout/stderr are UTF-8 lossy strings; each stream is capped at 2 MiB.

### Exec

```bash
curl -sS -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/exec" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{"command":"uname -a","timeout_secs":30}'
```

Example response:

```json
{
  "code": 0,
  "timed_out": false,
  "stdout": "Linux …\n"
}
```

### Sensors

```bash
curl -sS -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/sensors" \
  -H "Authorization: Bearer ${TOKEN}"
```

Returns the same on-demand snapshot shape as WebSocket `OpenSensors` (`collected_at_ms`, `sensors`, optional `battery` / `unavailable` / `notes`).

### Net (ping / HTTP)

```bash
# Ping from the device (count 1–20, timeout_ms 1–120000)
curl -sS -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/net" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{"action":"ping","host":"1.1.1.1","count":4,"timeout_ms":5000}'

# HTTP request from the device (body capped at 1 MiB; redirects bounded)
curl -sS -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/net" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{"action":"http","method":"GET","url":"https://example.com/","timeout_ms":10000,"max_redirects":5}'
```

Ping prefers ICMP (`ping`); if unavailable the agent falls back to TCP connect RTT and reports `method` accordingly. HTTP uses the agent HTTP client (not a shell `curl`), validates `http(s)` URLs, returns at most 1 MiB of response body (`body_truncated` when cut; also bounded by `timeout_ms`), and never logs `Authorization` header values.

### Update

```bash
# Check only
curl -sS -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/update" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{"check_only":true}'

# Apply (optional custom package URL)
curl -sS -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/update" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{"check_only":false,"source":"https://example.com/remote-agent.tar.gz"}'
```

Waits until a terminal `UpdatePhase` (`up_to_date`, `available`, `applied`, or `error`). Intermediate progress frames are not returned over HTTP.

### Ubus (OpenWrt)

```bash
curl -sS -X POST "https://exchange.ivjn.us/api/devices/${DEVICE_ID}/ubus" \
  -H "Authorization: Bearer ${TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{"object":"system","method":"board","params":{}}'
```

## When to use WebSocket vs HTTP one-shot

| Need | Use |
| --- | --- |
| Run a command / sensors / net / update / ubus and wait for the final result | **HTTP one-shot** above |
| Interactive PTY, streaming output while typing, file up/download, Android screen/tree/tap | **WebSocket**: `POST /api/ws-ticket` then `GET /ws/browser` (console) or `GET /ws/operator` (CLI/native) |
| Live device presence push / long-lived session | WebSocket |

Browser ticket flow (credentials never go in the WS URL):

1. `POST /api/ws-ticket` with `Authorization: Bearer …`
2. `GET /ws/browser` and send first JSON frame `{"ticket":"…"}`

See [Web console and Web Push](web-console.md) and [protocol](protocol.md).

## Error status cheat sheet

| Status | Typical cause |
| --- | --- |
| `401` | Missing/invalid Bearer token |
| `403` | Readonly role, or disabled account |
| `400` | Empty command / invalid `source` URL |
| `429` | Device session concurrency limit |
| `503` | Device offline or disconnected mid-flight |
| `504` | Exchange waited past the HTTP deadline |
| `502` | Device-side error / unexpected control reply |

## Manual smoke plan (live Exchange + online agent)

Unit tests cover validation, readonly rejection, offline mapping, and result collection without a live agent. End-to-end still needs PostgreSQL plus an online device:

```bash
export BASE=https://exchange.ivjn.us
export TOKEN=…          # login or primary API key
export DEVICE_ID=…      # online device id

curl -sS -X POST "$BASE/api/auth/login" -H 'Content-Type: application/json' \
  -d '{"username":"…","password":"…"}'   # optional if using API key

curl -sS "$BASE/api/devices" -H "Authorization: Bearer $TOKEN" | jq .

curl -sS -X POST "$BASE/api/devices/$DEVICE_ID/exec" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"command":"echo ok","timeout_secs":15}'

curl -sS -X POST "$BASE/api/devices/$DEVICE_ID/sensors" \
  -H "Authorization: Bearer $TOKEN"

curl -sS -X POST "$BASE/api/devices/$DEVICE_ID/update" \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"check_only":true}'
```

Expect JSON success bodies for online devices; `503` with a Chinese/English offline message when the agent is disconnected; `403` when using a readonly account.
