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:
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):
Response (200 OK):
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
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):
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):
kind values — the unified 3-value vocabulary:
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):
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:
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 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):
Errors, in addition to Request Headers, use the shared error format:
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):
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:
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):
Errors — in addition to the shared fleet-scoping errors in Request Headers:
Error Format
Every WebUI API error — the 400 / 404 responses and request-validation
failures (422) alike — carries a single detail string: