# aid client API — contract
The HTTP surface `aid web` exposes for the AID Command desktop client (macOS + iPadOS).
This document is the contract between the server (`src/web/`) and the client
(`client/`). Both sides are built against it; neither invents a field.
Status: design. Server work lands on `feat/client-api`, client work on `feat/fleet-client`.
## 1. Principles
- **One namespace.** The client API extends the existing `/api/` surface additively.
No `/api/v1/` fork, no parallel API, nothing deprecated. Existing endpoints keep
their current response shape so the embedded web dashboard keeps working.
- **One round trip for a screen.** The client should never issue N+1 requests to
paint a view. `/api/fleet` returns everything the main window needs.
- **Unknown stays unknown.** A field aid does not know is `null`, never a plausible
substitute. No `0.0` for an unmeasured cost, no `"auto"` for an unobserved model,
no `0` for an unknown progress. The client renders `null` as `—`.
- **The server does not invent progress.** aid has no percentage for a running task.
It reports the facts it has (`started_at`, `duration_ms`, `latest_milestone`) and
the client derives any bar it wants. Do not add a `progress` field.
## 2. Transport, binding and auth
Today `aid web` binds `127.0.0.1` with no auth. An iPad on the LAN cannot reach that,
so:
```
aid web [--port 8080] [--host 127.0.0.1] [--token <token>]
```
- `--host` defaults to `127.0.0.1` (unchanged). `--host 0.0.0.0` (or any non-loopback
address) binds for LAN access.
- **Auth is mandatory whenever the bind address is not loopback.** Starting with a
non-loopback host and no token is an error, not a warning — refuse to start rather
than exposing the dispatch surface unauthenticated. `--token` may be supplied, or
aid generates one (32 bytes, base64url) and persists it at `~/.aid/web_token`
(mode 0600), reusing it across restarts.
- On a loopback bind the token is optional; if one is configured it is still accepted.
- Credentials travel as `Authorization: Bearer <token>`. For SSE, `?token=<token>` is
also accepted, because not every client can set headers on an event stream.
- A failed or missing token returns `401` with `{"error":"unauthorized"}`. Never
reveal whether the token merely expired vs. was wrong.
- On startup with a non-loopback bind, print the reachable URL and the token once:
```
[aid] web listening on http://192.168.1.24:8080
[aid] client token: 8sJd… (also at ~/.aid/web_token)
```
- Rate-limit failed auth attempts (e.g. 10/min per peer) so the token cannot be
brute-forced over a LAN.
## 3. Endpoints
### 3.1 Existing (unchanged response shape)
| GET | `/api/tasks?filter=` | `[Task]` |
| GET | `/api/tasks/{id}` | `Task` |
| GET | `/api/tasks/{id}/events` | `[Event]` |
| GET | `/api/tasks/{id}/output` | `{output}` |
| GET | `/api/tasks/{id}/diff` | `{diff}` |
| POST | `/api/tasks/{id}/stop` | `{ok, error?}` |
| POST | `/api/tasks/{id}/retry` | body `{feedback?}` → `{ok, new_task_id?, error?}` |
| POST | `/api/tasks/{id}/merge` | `{ok, error?}` |
| GET | `/api/usage` | `{agents:[…]}` |
| GET | `/api/events` | SSE |
`Task` gains fields (see §4); it loses none.
### 3.2 New
| GET | `/api/fleet` | one snapshot: server info, summary, sectors with their tasks, agent roster |
| GET | `/api/agents` | agent roster + quota, standalone |
| GET | `/api/tasks/{id}/result` | the saved `result.md` report if the task wrote one |
| POST | `/api/tasks/{id}/steer` | body `{message}` — mid-flight course correction |
| POST | `/api/tasks/{id}/respond` | body `{message}` — answer an `awaiting_input` task |
| POST | `/api/tasks/{id}/accept` | principal accepts the delivery |
| POST | `/api/tasks/{id}/reject` | principal rejects; artifacts preserved |
Every action endpoint returns `{ok: bool, error: string?}` and is **idempotent in
effect**: stopping a stopped task, accepting an accepted one, is `ok: true` with no
side effect, not a 500. An action that is illegal for the task's current state
returns `409` with `{ok:false, error:"<why>"}` — the client shows the reason and
does not retry.
`steer` and `respond` must reuse the same code paths as `aid steer` / `aid respond`,
including their guards. Note the known hazard recorded in this repo: steering a
buffered agent (grok, agy) can kill it — if that guard exists in the CLI path, it
must apply here too. Do not build a second, weaker implementation.
## 4. Payloads
### 4.1 `GET /api/fleet`
```jsonc
{
"server": { "version": "10.37.0", "host": "192.168.1.24", "port": 8080,
"started_at": "2026-08-20T07:00:00Z", "aid_home": "/Users/…/.aid" },
"summary": { "running": 3, "done": 12, "failed": 4, "stopped": 2,
"spend_usd": 4.31, "tokens": 28400000, "memory_mb": 411,
"window": "today" },
"sectors": [
{ "id": "uniswapx-filler", // project_id, or last path component of repo_path
"name": "uniswapx-filler",
"repo_path": "/Users/…/uniswapx-filler",
"workgroup_id": "wg-8937e74c",
"tasks": [ /* Task, see 4.2 */ ] }
],
"agents": [ /* Agent, see 4.3 */ ]
}
```
`summary.window` states what the counts cover (`today` by default; `?window=7d`
accepts `today|24h|7d|30d|all`). A count with no stated window is a lie the client
would render as a total.
Tasks in `/api/fleet` are the same `Task` object as `/api/tasks/{id}`, minus
`prompt`/`resolved_prompt` (they can be large) — the detail view fetches those.
Include `prompt_excerpt`: the first 160 characters, so a list row has something to show.
### 4.2 `Task`
Current fields are kept verbatim. Added:
| `started_at` | `string?` | when the agent actually started, distinct from `created_at` |
| `prompt_excerpt` | `string` | first 160 chars of the prompt, for list rows |
| `sector_id` | `string?` | the grouping key used by `/api/fleet` |
| `difficulty` | `string?` | declared profile: `trivial\|simple\|moderate\|complex` |
| `rigor` | `string?` | declared profile: `draft\|standard\|critical` |
| `budget_class` | `string?` | declared profile: `free\|cheap\|standard\|premium` |
| `urgency` | `string?` | declared profile: `background\|normal\|urgent` |
| `memory_mb` | `i64?` | resident memory of the running agent, if measured |
| `has_result` | `bool` | whether `/result` will return a report |
| `has_diff` | `bool` | whether the task has a non-empty diff |
| `awaiting_reason` | `string?` | why the task is `awaiting_input` |
| `latest_events` | `[Event]` | the last 3 events, so a list row can show live activity |
The four profile fields are **`null` when undeclared** — aid stores them as null and
does not infer them. The client must render an undeclared profile as blank, never as
a default value.
### 4.3 `Agent`
Assembled from the same source as `aid agent list --json` — do not write a second
agent-inspection path.
```jsonc
{ "name": "codex", "kind": "builtin", "installed": true, "disabled": false,
"provider": "openai", "metering": "subscription",
"quota": { "state": "ok", "recovery_at": null, "message": null, "source": "marker" },
"default_model": "gpt-5.6", "observed_model": "gpt-5.6",
"busy": true, "running_task_ids": ["t-0c41aa19"],
"success_rate": 0.82, "task_count": 41, "avg_cost_usd": 0.29 }
```
`quota.state` is aid's own hold state (`ok | limited | held | unknown`). The client
renders `unknown` as unknown — it never guesses `ok`.
### 4.4 SSE `/api/events`
Existing events keep their names. `task_update` gains `outcome`, `verify_status`,
`sector_id`, `latest_error`. Two new events:
- `agent_update` — `{name, quota, busy, running_task_ids}` when a hold is taken or
released, so the client's crew roster is live.
- `fleet_summary` — the `summary` block, at most once per 2s, so gauges move without
polling.
`heartbeat` stays as is; the client uses it to detect a dead link and show the
LINK lamp as red.
## 5. What is explicitly out of scope
- Dispatching new tasks from the client (`aid run`). Read, monitor and act on
existing tasks only. Dispatch is the orchestrator's job and needs the full profile
declaration; a phone-sized form would produce undeclared profiles, which this repo
treats as a defect.
- Editing files or viewing the worktree tree.
- Multi-user accounts. One token, one commander.
## 6. Server acceptance
- `aid web --host 0.0.0.0` without a token refuses to start, with a message naming
`--token`.
- With a token, every endpoint rejects a missing/incorrect bearer with `401`,
proven by a test that asserts a wrong token fails and the right one succeeds.
- `/api/fleet` is one query round; assert it does not issue a per-task query in a
loop (the store already has `*_batch` helpers — use them).
- A task with no cost serialises `"cost_usd": null`, not `0.0`. Assert this.
- `steer`/`respond`/`accept`/`reject` reuse the CLI code paths; a test asserts the
same guard rejects the same illegal transition through both entry points.