For AI agents: the complete documentation index is available at /cafleet/llms.txt, the full documentation bundle is available at /cafleet/llms-full.txt, and this page is available as Markdown at /cafleet/spec/webui-api.md.

WebUI API

Base path: /api

Request Headers

The WebUI does not require authentication. Fleet-scoped endpoints require an X-Fleet-Id header. The header value is the integer fleet id, sent as a string over HTTP and coerced by the backend with int(...). A missing or non-integer value returns 400. The backend verifies the fleet exists in the fleets table.

These fleet-scoping errors apply to every fleet-scoped endpoint:

StatusdetailTrigger
400X-Fleet-Id header requiredThe X-Fleet-Id header is missing or empty
400X-Fleet-Id must be an integerThe header value is not an integer
404Fleet not foundThe header names a fleet id that does not exist

No server-side session cookies. The SPA stores the active fleet_id client-side via hash-based routing and sends it in the X-Fleet-Id header on each request.

Fleet selection and asynchronous reads

Hash routes select fleets and members. Requests use a client bound to that fleet and are cancelled when the selected resource changes. Stale responses cannot update the new view. Refresh retains existing data; failures show an error with Retry. Missing fleets return to the picker, while transport errors retain the route. Polling and manual refresh update each resource independently.

Response compatibility

HTTP presenters convert typed broker records to the response shapes below. Preserve nulls and field names; invalid stored values or missing required member names return a 500 detail response.

Message responses

The three message endpoints compare as follows (this table owns their row-selection, exclusion, ordering, and cap attributes):

EndpointRows returnedExcludedOrderingRow cap
GET /api/members/{member_id}/inboxMessages where owner_member_id = member_idtype == "broadcast_summary"status_timestamp DESC, message_id DESC (newest status update first; id breaks ties)unbounded
GET /api/members/{member_id}/sentMessages where from_member_id = member_idtype == "broadcast_summary"status_timestamp DESC, message_id DESC (newest status update first; id breaks ties)unbounded
GET /api/timelinetype == "unicast" deliveries, scoped through the owning member joinAll non-delivery rows, including broadcast_summarystatus_timestamp DESC, message_id DESC (newest status update first; id breaks ties)SQL limit of 200 delivery rows, applied after filtering; may split a broadcast group; no pagination

Response (200 OK):

{
  "messages": [
    {
      "message_id": 42,
      "from_member_id": 4,
      "from_member_name": "Member A",
      "to_member_id": 5,
      "to_member_name": "Member B",
      "type": "unicast",
      "status": "input_required",
      "created_at": "2026-03-29T10:00:00+00:00",
      "status_timestamp": "2026-03-29T10:00:00+00:00",
      "origin_message_id": null,
      "body": "Hello, Member B!"
    }
  ]
}

All three endpoints share this row formatter and messages wrapper. status_timestamp changes on ACK; created_at stays fixed. The timeline uses these differently for API selection and client display, as described below.

The wire type distinguishes unicast deliveries, with non-null to_member_id and to_member_name, from broadcast_summary rows, whose recipient id and name are null. The frontend models this distinction and narrows inbox, sent, and timeline data to delivery rows. Existing response keys and envelopes are preserved.

The body field is the message's text column.

Status values: input_required (Pending), completed (Acknowledged).

Endpoints

MethodPathReturnsX-Fleet-Id required
GET/api/fleetsNon-soft-deleted fleets with member countsno
GET/api/membersThe fleet's rosteryes
GET/api/monitorLiveness of the fleet's cafleet monitor process plus per-member pending-delivery countsyes
PATCH/api/monitorThe updated Director wake intervalyes
POST/api/monitor/wakeThe timestamp of the recorded immediate-wake requestyes
GET/api/members/{member_id}/inboxMessages received by the memberyes
GET/api/members/{member_id}/sentMessages sent by the memberyes
GET/api/timelineThe fleet's unified message timelineyes
POST/api/messages/sendThe new message's id and statusyes

GET /api/fleets — List Fleets

Returns non-soft-deleted fleets (deleted_at IS NULL) with member counts, ordered newest-first by created_at DESC, fleet_id DESC (higher id first when timestamps tie). No headers required.

Response (200 OK):

[
  {
    "fleet_id": 1,
    "director_member_id": 2,
    "name": "PR-42 review",
    "created_at": "2026-04-12T10:00:00+00:00",
    "member_count": 3
  }
]

GET /api/members — List Members

Returns the selected fleet's roster: every active registry entry plus deregistered members that still own messages (so their message history stays inspectable). Every row carries a kind discriminator so the frontend can locate the root Director without matching on its name.

Rows are ordered by member_id ASC. Holder inclusion checks messages.owner_member_id = members.member_id; a sender-only reference does not include a deregistered member. The lean query preserves this condition and the existing response while omitting unused message activity aggregates. CLI member listing retains its separate activity query; see query and activity contracts.

Request: X-Fleet-Id: <fleet_id> header.

Response (200 OK):

{
  "members": [
    {
      "member_id": 2,
      "name": "Director",
      "description": "Root Director for this fleet",
      "status": "active",
      "registered_at": "2026-04-15T09:59:00+00:00",
      "kind": "director",
      "placement": null
    },
    {
      "member_id": 4,
      "name": "alice",
      "description": "Ordinary member",
      "status": "active",
      "registered_at": "2026-04-15T10:06:00+00:00",
      "kind": "member",
      "placement": {"backend": "tmux", "mux_session": "main", "mux_window_id": "@1", "mux_pane_id": "%13", "coding_agent": "claude", "created_at": "2026-04-15T10:06:00+00:00"}
    }
  ]
}

kind values — the unified 3-value vocabulary:

ValueMeaning
"director"The fleet's root Director (member_id == fleets.director_member_id). Exactly one per fleet.
"monitor"The fleet's monitor member — its member_card_json carries the $.cafleet.kind = 'monitor' marker. At most one active per fleet.
"member"Any other member.

The discriminator is derived at read time — the fleets join supplies "is this the root Director" and the member-card marker supplies "is this the monitor member"; there is no dedicated column (see Data model).

GET /api/monitor — Fleet Monitor Runtime

Returns the liveness of the fleet's cafleet monitor process, derived from the monitor_runtime heartbeat (true even when the process died silently), plus a members array with each member's pending-delivery counts. Lets the members page show a "monitor running / stopped" indicator. See Monitoring.

Request: X-Fleet-Id: <fleet_id> header.

Response (200 OK):

{
  "running": true,
  "pid": 4821,
  "tick_seconds": 5,
  "wake_interval_seconds": 600,
  "last_tick_at": "2026-06-13T04:51:02+00:00",
  "last_tick_age_seconds": 2,
  "started_at": "2026-06-13T04:50:00+00:00",
  "last_wake_at": "2026-06-13T04:50:30+00:00",
  "last_wake_age_seconds": 32,
  "members": [
    {
      "member_id": 4,
      "name": "drafter",
      "pending_count": 2,
      "oldest_pending_ts": "2026-08-03T09:00:00.000000+00:00",
      "oldest_pending_age_seconds": 120
    }
  ]
}

The members array is the wake roster: every active placed member excluding the Director and the monitor member, ordered by member_id ascending. Each element carries the member's count of input_required unicast deliveries (pending_count) and the timestamp and age of the oldest one (null when there is none).

When no monitor is running — no runtime row, or a stale or cleared heartbeat — the runtime fields take these values:

FieldNo runtime row has ever existedStale or cleared heartbeat row
runningfalsefalse
pidnullnull
started_atnullnull
last_tick_atnullnull
last_tick_age_secondsnullnull
last_wake_atnullnull
last_wake_age_secondsnullnull
tick_secondsnullpreserved — the cadence the monitor last ran at
wake_interval_secondsnullpreserved — the wake interval the monitor last ran at; null when the row predates the column and was never re-stamped

This is a presenter projection, not a serialization of the stored MonitorRuntime record. Stored null, zero, and a positive wake interval remain distinct: zero disables scheduled wakes but still allows forced wake, while a legacy null interval remains null until claimed. A normal clear retains the stored last_wake_at and pending wake_requested_at even though stopped-process timestamps are hidden here; the request field is not added to this response. The complete field lifecycle is in the data model.

Launching the loop is CLI-only (cafleet monitor), and the monitor member owns it as a long-lived execution resolved by its backend. It has no POST/DELETE counterpart and no CLI stop command — deleting the monitor member kills the pane hosting the loop, and a still-running loop self-terminates after fleet delete.

PATCH /api/monitor — Update the Wake Interval

Updates the fleet's wake interval. The running loop re-reads the stored value on every tick, so the edit changes the cadence within one scan tick; the next cafleet monitor start re-stamps the interval from the CLI/env resolution. See Monitoring.

Request: X-Fleet-Id: <fleet_id> header.

{"wake_interval_seconds": 300}

wake_interval_seconds must be a JSON integer in 0..=i64::MAX — floats, stringified integers, negatives, and numbers above i64::MAX are rejected, not coerced, mirroring the send endpoint's strictness. 0 disables the wake; there is no application-level cap below i64::MAX.

Response (200 OK):

{"wake_interval_seconds": 300}

Errors, in addition to Request Headers, use the shared error format:

StatusdetailTrigger
422invalid JSON body: <parse error>The request body is not parsable JSON
422wake_interval_seconds must be a non-negative integerwake_interval_seconds is missing, or not an integer in 0..=i64::MAX
404monitor has never run for this fleetThe fleet has no monitor_runtime row

Resolution order: shared header errors, then body validation, then the fleet check, then the row update — matching POST /api/messages/send, whose body parse likewise precedes the fleet check, so an unknown fleet plus an invalid body yields 422 on both endpoints. A no-row 404 means the fleet's monitor has never run — monitor_runtime rows are removed only by fleet delete. The two monitor write endpoints gate their 404s differently: this endpoint's gate is row existence (the interval is a durable setting), while POST /api/monitor/wake below gates on liveness — a wake request needs a live consumer; against a dead loop it would silently never fire.

POST /api/monitor/wake — Request an Immediate Wake

Records a durable request for an immediate monitor wake on the fleet's runtime row. The running loop honors the request on its next tick, so the wake lands within one scan tick (default 5 s) — bypassing a disabled schedule (wake_interval_seconds = 0) and a not-yet-due one alike. Repeat requests overwrite the stored timestamp, coalescing into a single wake. A delivered wake — scheduled or forced — stamps the last-wake timestamp and clears the request in one write, so a forced wake resets the schedule baseline. See Monitoring.

Request: X-Fleet-Id: <fleet_id> header. No request body; any body is ignored.

Response (200 OK):

{"wake_requested_at": "2026-06-13T04:52:00+00:00"}

Errors, in addition to Request Headers, use the shared error format:

After header validation and fleet lookup, a non-live loop (no runtime row, a cleared slot or stale heartbeat), or a row that vanishes between the liveness check and write, returns 404 with monitor is not running for this fleet.

GET /api/members/{member_id}/inbox — Inbox Messages

Returns messages received by the member. Consumed by the member detail view's Inbox tab in the admin WebUI.

Returns the shared message response, with the inbox selection defined there.

GET /api/members/{member_id}/sent — Sent Messages

Returns messages sent by the member, using the shared selection and response. Consumed by the member detail view's Sent tab in the admin WebUI.

Request: X-Fleet-Id: <fleet_id> header.

Inbox and sent both return full history, without the timeline cap.

GET /api/timeline — Unified Fleet Timeline

Returns the fleet's unified message timeline, using the shared selection and response. Consumed by the Discord-style admin dashboard, which groups delivery rows sharing an origin_message_id into a single broadcast entry client-side.

Request: X-Fleet-Id: <fleet_id> header.

Fleet scoping follows messages.owner_member_id → members.member_id → members.fleet_id. Only delivery rows whose owning member belongs to the header fleet are returned. SQL selects type = 'unicast' before ordering and applying the 200-row cap.

Response (200 OK): the shared message response.

The frontend orders the returned entries by creation time, ascending for newest-at-bottom chat rendering. This is distinct from the API's selection by most recent status update: an ACK updates status_timestamp but leaves created_at unchanged.

Exclusions: broadcast_summary rows never enter the timeline response or consume its row cap. They remain stored and accessible through message show and the broadcast command's result. A summary is created in the completed state; that state is not a recipient ACK. The frontend also ignores summary rows defensively before grouping if they appear in its input.

Broadcast grouping uses the durable origin relationships. The client groups delivery rows by origin_message_id using an explicit null check: non-null rows sharing a value form one broadcast entry; null rows are standalone unicast entries. Each broadcast entry's sort key is the minimum created_at among its returned delivery rows. A standalone unicast uses its own created_at.

Partial groups and counts: the cap applies to delivery rows, not whole broadcasts. It can omit some recipients of a group. Recipient counts and the ReactionBar's ACK indicators describe only the returned deliveries; the UI explains this limit and does not present them as a whole-broadcast completion rate. Omitted recipients are not fetched to complete a group.

For two pending broadcast deliveries and their stored summary, the timeline shows two recipients and zero ACKs. Acknowledging one delivery produces one ACK; acknowledging both produces two. The summary contributes neither a recipient nor an ACK. Empty or summary-only input produces no timeline entries.

ACK timestamps use each completed delivery's status_timestamp, per Data model § Broadcast Grouping.

POST /api/messages/send — Send Message

Sends a message from a same-fleet active member. Supports both unicast (to_member_id=<int>) and broadcast (to_member_id="*").

Request:

X-Fleet-Id: <fleet_id>
{
  "from_member_id": 2,
  "to_member_id": 4,
  "text": "Hello!"
}

to_member_id accepts an integer (unicast) or the string "*" (broadcast). from_member_id is always an integer.

Unicast (to_member_id is an integer): the server verifies both the sender and the destination belong to the caller's fleet and that the destination is active.

Broadcast (to_member_id == "*"): the server skips destination validation (no specific recipient to verify) and fans out to every active member in the fleet except the sender, plus a summary message. The sender is still required to be active and in the caller's fleet. The response's message_id is the summary message's id.

Sender identity: The Admin WebUI always submits from_member_id = director.member_id (the fleet's root Director). The endpoint itself is sender-agnostic — it accepts any active member in the fleet — but no UI path lets the operator pick a different sender.

Response (200 OK):

{
  "message_id": 42,
  "status": "input_required"
}

Errors — in addition to the shared fleet-scoping errors in Request Headers:

StatusdetailTrigger
422A detail string — see Error FormatMissing or invalid from_member_id, to_member_id, or text
400from_member not in fleetfrom_member_id is not an active member in the caller's fleet
404Member not foundto_member_id does not resolve to an active member in the fleet (unknown, cross-fleet, or deregistered)

Error Format

Every WebUI API error — the 400 / 404 responses and request-validation failures (422) alike — carries a single detail string:

{"detail": "Error message"}