# 15 — Fleet API

The daemon's HTTP surface for **operator clients**: dashboards, menu-bar apps, scripts, future web gateways. It serves three things: a **snapshot** of the fleet state, a **live event stream**, and **commands** over the existing management verbs. Everything an `iapeer list`-style client needs — without parsing CLI output.

This is a **normative contract**: fleet clients (SwiftBar plugins, native apps, web pages) are written against this document, not against the daemon source. The MUST/MUST NOT rules in «Client obligations» below are load-bearing — a client that skips them will break on a future daemon update.

## Surface and transport

- Base path: **`/fleet/v1`**, plain HTTP + JSON (+ SSE for events). This is *not* MCP: MCP (`/mcp`) is the agent surface and deliberately carries a single tool; fleet clients are GUIs and scripts that consume HTTP natively.
- Served on **both** daemon listeners:
  - the unix socket `~/.iapeer/state/iapeer/router.sock` — `curl --unix-socket ~/.iapeer/state/iapeer/router.sock http://iapeer/fleet/v1/snapshot`
  - TCP loopback — `http://127.0.0.1:8765/fleet/v1/snapshot`
- **Discovery:** read `~/.iapeer/state/iapeer/router.json`. A daemon that serves this API advertises `"fleet": 1` there, next to the active `sock`/`tcp` addresses and the daemon `version`. Absent `fleet` key ⇒ pre-fleet daemon ⇒ the client must degrade (hide, or fall back to CLI).
- **Auth:** when the daemon is configured with a bearer token (`IAPEER_BEARER_TOKEN`), every fleet request must carry `Authorization: Bearer <token>` — the same gate as MCP, checked before dispatch. Without a configured token, the surface is open to the local user (same trust class as the CLI: loopback + same-uid socket).
- **No CORS headers, deliberately.** A browser page must not be able to drive loopback fleet commands cross-origin. A web gateway is a separate future decision with mandatory auth.

## Client obligations (normative)

1. **Unknown event kinds MUST be ignored.** The `ev` vocabulary grows without a version bump (e.g. future `approval-request` / `approval-resolved`). A client that throws on an unknown `ev` is broken by design.
2. **Unknown JSON fields MUST be ignored.** The snapshot and every response evolve **additively** within `/fleet/v1`: new fields appear, existing fields keep their meaning and type. Removals or type changes would be a `/fleet/v2`.
3. **Events are at-least-once.** After an SSE reconnect (and around log rotation) a client may see an event twice. The SSE `id:` is the event's epoch-ms timestamp — use it (plus field equality) for display-level dedup. There is no exactly-once replay cursor; on reconnect, re-fetch the snapshot and resume from live events.
4. **The snapshot is the state; events are change hints.** The intended loop: fetch `/snapshot` once, subscribe to `/events`, re-fetch `/snapshot` when an event arrives (cheap, coalesce bursts). Do not reconstruct fleet state from events alone.
5. **Ages are client-rendered.** `last_active_ms` is an epoch timestamp; render the ticking «4m ago» yourself instead of polling the snapshot per second.

## GET /fleet/v1/snapshot

The full fleet state. The peer rows are produced by the **same in-process function that renders `iapeer list`** — agreement with the CLI is by construction.

```json
{
  "api": 1,
  "version": "0.4.63",
  "ts": "2026-07-05T18:00:00.000Z",
  "host": {
    "version": "0.4.63",
    "pid": 12345,
    "startedAt": "2026-07-05T09:00:00.000Z",
    "uptimeSecs": 32400,
    "memory": { "provider": "iapeer-memory", "version": "1.2.3", "heartbeatAgeSecs": 12 },
    "voice":  { "provider": "voice-connect", "version": "0.3.1", "heartbeatAgeSecs": null },
    "fda": true,
    "agent_runtimes": ["claude", "codex", "opencode"]
  },
  "peers": [
    {
      "personality": "boris",
      "description": "Напарник Артура по мультиагентной системе",
      "intelligence": "artificial",
      "default_runtime": "claude",
      "cwd": "/Users/me/.iapeer/peers/boris",
      "runtimes": [
        { "runtime": "claude", "status": "live", "agent": true, "attached": true, "activity": "working" },
        { "runtime": "codex",  "status": "asleep", "agent": true }
      ],
      "last_active_runtime": "claude",
      "last_active_ms": 1751731100000,
      "attached": true,
      "launchd_managed": false,
      "wake_policy": "warm",
      "approval_mode": "yolo"
    }
  ],
  "approvals": [
    { "id": "a1", "personality": "boris", "runtime": "claude", "kind": "circuit-breaker",
      "tool": "dangerous-rm", "summary": "rm -rf /tmp/x",
      "content": "cmd=\"rm -rf /tmp/x\" target=\"/tmp/x\"", "createdMs": 1751731100000, "expiresMs": 1751731400000 }
  ],
  "notices": [
    { "id": "n1", "personality": "boris", "runtime": "claude", "kind": "peer-mute",
      "errorType": "rate_limit", "model": "Fable 5", "summary": "boris · claude — rate_limit (Fable 5)",
      "content": "You've reached your Fable 5 limit. Run /usage-credits to continue or switch models with /model.",
      "sessionId": "85ea79c9-…", "createdMs": 1751731100000, "lastMs": 1751731160000,
      "expiresMs": 1751734700000, "count": 3 }
  ]
}
```

Field notes:

- `runtimes[].status` — `live` (runtime passed readiness in this hosting) · `booting` (supervisor up, runtime never reached ready) · `asleep` (wakeable on demand) · `stopped` (operator stop flag; the daemon won't wake it). Same glyph semantics as the CLI: `●` / `◐` / `○` / `✕`. `booting` is not a working session or a successful delivery; clients must not display it as `live`. Process controls can still address it. Added as a status value without changing the snapshot structure.
- `runtimes[].agent` — **agent-endpoint discovery**: `true` iff this declared runtime is an agent runtime the running core can host (listed in `host.agent_runtimes`). A channel that offers a personality's agent endpoints (a Telegram menu, a web picker) offers exactly its `agent: true` runtimes — never a consumer-side runtime list, never a `which`/PATH probe. A declared runtime the core cannot host (a newer peer profile on an older core) is `false` and is not offered. A channel runtime (`telegram`, `web`, …) is always `false`. Additive: a pre-discovery daemon omits it — clients MUST then fall back to treating no runtime as discoverable rather than guessing. Which endpoint a send reaches is a separate rule: an omitted runtime is always `default_runtime` (config-anchored, never «the live one»); a control action for the same dialogue must name the same runtime its send used.
- `runtimes[].private_dir` — the core-owned private directory of this endpoint (docs/20: the OpenCode journal, state and provider fragments), present only for a runtime that has one (today `opencode`). An observer that the core does not launch (a console, a collector) reads the path HERE or from `iapeer private-dir <peer> [--runtime r]` — it never derives the layout from the root. Additive.
- `runtimes[].model` — the `provider/model` the endpoint ACTUALLY used for its last assistant turn, as the runtime itself reports it (today `opencode`, from its state; docs/20 §Provider pinning). Absent until a first turn. Lets an operator see an implicit default provider. Additive.
- `runtimes[].warnings` — the endpoint's current-session launch self-check findings (today `opencode`: a missing or failed plugin, an MCP server not connected; docs/20 §Launch self-check). Absent when clean. Additive.
- `host.agent_runtimes` — the agent runtimes this core build can host, in preference order. It grows when an agent adapter ships (e.g. `opencode`, docs/20).
- `runtimes[].attached` — present (`true`) only when a **human operator** is attached to that live hosted session.
- `runtimes[].activity` — **turn activity**: is this runtime *in a turn* right now. See «Activity» below. Additive; absent on a pre-activity daemon, on every non-`live` runtime, and whenever the daemon has no evidence — clients MUST treat absence as **no claim**, never as `idle`.
- `attached` — any runtime attached (peer-level convenience).
- `launchd_managed` — launchd owns this peer's lifecycle (H4): the daemon never wakes/reaps it, and wake/stop/start commands are guarded accordingly.
- `wake_policy` — `warm` (default: wake-on-message, idle-reap, resume) or `ephemeral` (stateless worker: serial task queue, die-after-reply).
- `approval_mode` — the peer's human-approval mode (docs/17-approval): `yolo` (default — launched with the runtime bypass flag, the supervisor auto-confirms circuit-breakers) or `gated` (launched without bypass; the runtime's blocking approval requests are routed to a human through the daemon approval broker). Additive field; a pre-approval daemon omits it — clients MUST treat its absence as `yolo`.
- `queue_depth` — ephemeral peers only: pending tasks across the peer's serial queues.
- `host.memory` / `host.voice` — the provider slots (`null` = empty slot); `heartbeatAgeSecs: null` with a non-null slot = provider declared but its daemon isn't heartbeating.
- `host.fda` — Full Disk Access of the binary: `true` / `false` / `null` (undeterminable).
- The daemon answering **is** the health signal: there is no `healthy` boolean. A dashboard that cannot reach any advertised address should render the daemon as down (this is also why a purely API-fed dashboard is insufficient when the daemon itself is broken — the built-in TUI keeps its direct in-process read path for exactly that case).
- The peer's **LLM model is deliberately absent**: the effective model of a live session is not an observable registry fact (static launch pins exist, but reporting a pin as «the model» would lie under runtime auto-switching). If it ever becomes observable, it will appear as an additive field.
- `approvals` (top-level, docs/17-approval) — the pending human-approval queue, the SAME items `GET /fleet/v1/approvals` returns, carried in the snapshot so a client that already re-fetches `/snapshot` on every event renders the queue with no extra call. **ADDITIVE + OMITTED when the queue is empty** — a client MUST treat its absence as an empty queue (the same rule as `approval_mode`). Each item: `id` (broker id, the target of `POST /fleet/v1/approvals/<id>/(approve|deny)`), `personality` + `runtime` (whose action), `kind` (`tool`|`plan`|`question`|`circuit-breaker`), `tool` (the tool / breaker name), `summary` (a one-line badge string), `content` (the FULL verbatim action — command / diff / plan; criterion #7), `createdMs` / `expiresMs`. The broker's item is a superset — unknown extra fields (`title`, `approvers`) MUST be ignored (obligation 2).
- `notices` (top-level, docs/19-notices) — live OWNER-facing notices: things the daemon noticed that the peer itself could not report, the v1 case being a structural API error that left it MUTE (`kind: peer-mute`). The SAME items `GET /fleet/v1/notices` returns. **ADDITIVE + OMITTED when the board is empty** — absence ⇒ empty board (the same rule as `approvals`). **READ-ONLY**: a notice is one-way, there is nothing to approve or deny. Each item: `id`, `personality` + `runtime` (who went mute), `kind` (`peer-mute`; treat unknown kinds as renderable), `errorType` (the RUNTIME's own value — `rate_limit`, `overloaded`, `rate_limit_reached`, … — free-form, do not switch exhaustively), `model?`, `resetsAtMs?`, `summary`, `content` (verbatim for claude), `sessionId?`, `createdMs` / `lastMs` / `expiresMs`, `count` (detections folded in — render as `×N`). **`model` and `resetsAtMs` are ABSENT when the runtime did not state them** — absence means *the runtime did not say*, NOT *there is no limit*; a client MUST render unknown and MUST NOT substitute the 5h/7d reset, which belongs to a different limit (docs/19 §3).

## Activity — is this runtime in a turn?

`runtimes[].activity` answers the operator's actual question, which `status` never did: a peer
idling with an unfinished job and a peer grinding through a turn are BOTH `live`.

**Activity is not liveness, and the two never merge.** `status` keeps its exact prior meaning.
`activity` is an additive sibling on the same per-runtime object.

| value | meaning |
|---|---|
| `working` | a turn is in flight on this runtime |
| `idle` | live, but not in a turn |
| `unknown` | live; the turn was seen to start, never seen to finish, and the evidence has gone stale |
| *absent* | **no claim** — see below |

`activity` is **ABSENT** (never `idle`) when any of these hold, and a client MUST NOT infer
idleness from absence:

- the daemon predates this field (obligation 1);
- `status !== 'live'` — an asleep/stopped session has no turn to be in;
- the daemon has no evidence for that live runtime yet.

### Where the value comes from

The **runtime's own turn marker**, read from its session file. Never CPU, never a pane-log
mtime, never a reconstruction:

- **codex** — the rollout's `event_msg` payloads `task_started` → `working`, `task_complete` →
  `idle`; last one wins. Codex emits `task_complete` for a turn that ends on an error too, so
  the terminal-error path needs no separate marker. An INTERRUPTED turn ends on `turn_aborted`
  (no `task_complete` follows) → `idle`.
- **claude** — the transcript's assistant `stop_reason`. **Only `tool_use` means the turn
  continues**; every other terminal reason (`end_turn`, `stop_sequence`, `max_tokens`, a future
  value, `null`) means it ended. The polarity is deliberately asymmetric: an unrecognised reason
  must never invent work. A genuine `user` line also reads `working` (a prompt to answer or a
  tool_result to consume), but compaction summaries (`isCompactSummary`), sub-agent sidechains
  (`isSidechain`) and injected meta lines (`isMeta`) are excluded — each is a measured
  false-`working` vector, the worst being a `/compact` run while idle leaving a phantom
  `working` forever. An operator interrupt ends the turn with a user line whose text is
  `[Request interrupted by user]` / `[Request interrupted by user for tool use]` → `idle` (it
  used to read `working` forever on a stopped session, 18.10.2026).
- **Attribution** — a session is the peer whose registered cwd is the session's cwd or its
  nearest ancestor (a peer that `cd`-ed into a subdirectory stays itself).

### Bounds a client can rely on

- **Lag** — the daemon re-reads changed session files every **~3 s**, so an `idle`↔`working`
  flip appears within roughly one interval plus fs latency. A turn shorter than the interval may
  never be observed at all; activity is a *state* to render, not an event ledger to count.
- **Stale bound** — a `working` claim whose evidence is older than **10 min** decays to
  `unknown`. `idle` NEVER decays: a session that ended its turn stays idle until something pokes
  it, and age proves nothing against that.
- **Restart** — the board is in-memory and re-seeds from the session files (a 48 h look-back) on
  daemon start, so a restart cannot preserve a phantom `working`; a stale one decays by the rule
  above, and a dead session drops out via `status` first.

### Events

`turns.log` → the SSE stream, `src: "turns"`, `ev`: **`turn-started`** / **`turn-ended`**, with
`personality`, `runtime`, `from` (the previous state, absent on first observation), `at` +
`at_iso` (the MARKER's own timestamp, not the sweep's). Emitted only on a real state **change** —
two lines per turn per runtime, not one per sweep. Treat the snapshot as the source of truth and
these as a push hint (obligation: the stream is at-least-once).

## GET /fleet/v1/peers/&lt;personality&gt;

One peer's card: the same object as the snapshot row, plus its recent history.

```json
{ "api": 1, "version": "0.4.63", "ts": "…", "peer": { …snapshot row… },
  "events": [ { "src": "lifecycle", "ev": "wake", "ts": "…", "personality": "boris", "mode": "resume" } ] }
```

`events` — the newest ≤50 events concerning this peer (matched on personality / `<runtime>-<personality>` identity fields), merged across the logs by timestamp, oldest first. `404` for an unregistered peer.

## GET /fleet/v1/events — SSE stream

`text/event-stream` of fleet events, sourced by tail-following the daemon's **durable logs** (`lifecycle.log`, `delivery.log`, `exits.log`, `approvals.log`, `notices.log`). Those files are written by *every* participating process — the daemon, CLI verbs (attach/stop/wake), the pty supervisors — so the stream covers events an in-daemon bus would miss.

```
GET /fleet/v1/events?replay=50
```

- `replay=N` (0…500, default 0) — emit the newest N historical events before going live.
- Each event:

  ```
  event: wake
  id: 1751731100123
  data: {"src":"lifecycle","ev":"wake","ts":"2026-07-05T18:00:00.123Z","personality":"boris","mode":"resume"}
  ```

  `data` is the full parsed log line: `src` (`lifecycle` | `delivery` | `exits` | `approvals` | `notices`) + `ev` + every logfmt field with quoted values decoded. `id` = epoch-ms of the event.
- A comment `: connected` marks the transition from replay to live; `: hb` comments every ~15 s keep the connection alive.
- Latency: live events surface within ~0.5 s (log-tail poll).

Current `ev` vocabulary (grows over time — see obligation 1): `wake`, `supervise` (with `action`: `reaped-idle` = parked, `reaped-ephemeral`, `dead`→fresh/resume classification, `eager-orphan-fresh`, `skipped-error`…), `stopped` / `started` (the stop/start verbs — an operator changed a peer's ✕/○ state; `action` is `stopped`/`started` for warm runtimes, `bootout`/`bootstrap` for always-on, with `reason` on failure), `delivery`, `topic-note`, `ephemeral-drain`, `attach` / `attach-end`, `hosted-deliver`, `pty-host-failed`, `supervise-error`, `composer-queue-failed-notify`, `memory-provision`, `session-exit` (from `exits.log`: `cause=child-exit` with `dead_status`/`dead_signal` when the session's process died on its own, `cause=shutdown-sigterm`/`shutdown-sigint` when the supervisor was torn down — reap/stop/new), `supervisor-uncaught` (a survivable supervisor fault left as forensics; the session keeps serving), `notice-raised` (docs/19 — the daemon told the OWNER something a peer could not tell him itself: a structural API error left it mute. One line per condition, not per occurrence — repeats fold into the live notice silently), `turn-started` / `turn-ended` (from `turns.log`, `src: turns` — a runtime entered/left a turn; see «Activity». One line per real state CHANGE, never one per sweep).

What the headline states look like on the stream: a peer **waking** is `ev=wake`; a peer **parked to sleep** is `ev=supervise action=reaped-idle`; an operator **stop/start** is `ev=stopped` / `ev=started`; a **death** is `ev=session-exit` (immediately, from the supervisor) followed by the daemon's classification on its next pass.

## Commands

`POST /fleet/v1/peers/<personality>/<command>` with an optional JSON body `{ "runtime": "claude", "topic": "…" }` (both optional; `topic` is used by `wake` only). The commands are the **existing management verbs called in-process** — every guard (H4 launchd read-only, the foreign-plist fleet guard, the crash-loop guard, wake serialization) lives inside those verbs and cannot be bypassed via the API.

| Command | Verb semantics | Success |
|---|---|---|
| `wake` | bring the peer up (no message; fresh-vs-resume decided by the daemon) | `200` + wake outcome, `502` + `status:"FAILED"` |
| `stop` | set the stop flag / bootout an always-on peer | `200` + `outcomes[]`, `502` if a bootout failed or a foreign plist was refused |
| `start` | clear the stop flag (warm peers become wakeable; no session is launched) / bootstrap always-on | `200` / `502` mirror of `stop` |
| `new` | **unconditional** fresh restart (the emergency lever) | `200` + `action:"fresh"` |
| `refresh` | lazy soft-reload: fresh on next natural wake, no kill | `200` + `outcomes[]` |
| `interrupt` | interrupt the current turn. **No `runtime` in the body → every live runtime of the peer, in parallel** (response `controlledAll`, partial failures in `failed`); with `runtime` → that endpoint only. An operator `/stop` must send NO runtime | `200` / `502` |
| `compact` | compact the resumable dialogue (honest failure when none) | `200` + `action:"compacted"` / `502` |

`POST /fleet/v1/send` — deliver a message through the same routing path as the `send_to_peer` tool (wake-on-miss, queueing, delivery log — identical semantics):

```json
{ "personality": "boris", "message": "…", "runtime": "claude", "topic": "…", "attachments": ["/tmp/report.pdf"], "from": "nova" }
```

`from` is optional: a full `<runtime>-<personality>`, or a bare personality (its default runtime). Default: the **single natural-intelligence peer** of the registry — a GUI-originated message is the human's. With zero or several natural peers the request is refused (`400`) with an instruction to pass `from`. `attachments`, when present, is the same array of absolute source paths as the MCP tool; the shared `routeSend` path copies them into the recipient inbox before acceptance.

Not part of the *command* API, deliberately: **attach** (a terminal handoff — a client opens the system terminal with `iapeer attach <peer>`), registry/host **admin verbs** (`create`/`remove`/`update`/`uninstall` — will be added when a client actually needs them). The **human-approval broker** now lives under `/fleet/v1/approvals*` (`GET` the queue / one item, `POST /<id>/(approve|deny)`) with the `approval-request` / `approval-resolved` SSE `ev` kinds and the `approvals` snapshot field above — the full contract is **docs/17-approval**. The **notice board** lives under `/fleet/v1/notices*` (`GET` the board / one item — **read-only**: a notice is one-way and has nothing to resolve) with the `notice-raised` SSE `ev` kind and the `notices` snapshot field above — the full contract is **docs/19-notices**.

Errors are always `{"error": "…"}` with an honest status: `400` malformed request, `401` bearer required, `404` unknown peer/endpoint, `405` wrong method, `502` the verb itself failed, `500` internal.

## Compatibility

- `/fleet/v1` evolves **additively**; `api: 1` in every snapshot. Breaking changes mean `/fleet/v2` served alongside.
- The capability marker in `router.json` (`fleet: 1`) is the feature-detect: clients must treat its absence as «no fleet API» rather than probing endpoints.
