3
15 REST API Reference
Dai Ha edited this page 2026-09-09 07:40:48 +07:00

REST API Reference

fleetd exposes 15 HTTP routes, built once in FleetApp.build() (fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java:126-160). No other page collects them in one place — each route is currently documented only where it happens to matter for some other topic, spread across seven pages.

Why this page exists

The MCP tools (their contracts are defined in FleetMcp.java, not yet collected on their own wiki page) and the REST routes on this page are siblings that both sit on top of the same service objects — they are not a wrapper around each other in either direction. fleetd's startup code builds one MessageService and one SessionManager and hands the same instances to both faces: the MCP server (Fleetd.java:524) and the REST app (Fleetd.java:607), from a MessageService built once at Fleetd.java:449-450. The MCP server's own file header says this directly: its tools are "thin adapters over the same MessageService/ Rendezvous the REST routes use — so the two are validated by parity, not by re-implementing behaviour" (FleetMcp.java:54-57); FleetApp.java:38-41 makes the same claim from the REST side.

This matters for a reader in a concrete way: you cannot infer REST behaviour from the MCP tool contract. The two surfaces read from the same source of truth, but each has its own request shape, its own field names, and — as the table below shows — its own coverage. Two MCP tools have no REST route at all, and several REST routes have no MCP tool.

All routes

Every route below is wired in FleetApp.build() (FleetApp.java:126-160). The "Role" column is the Authz.Action each handler checks (Authz.java:44-71) — "open" means the handler makes no allow() call at all, so it needs no Authorization header.

Method Path What it does Role required Matching MCP tool
GET /healthz Liveness + herdr reachability open (no auth check) none
GET /metrics Prometheus scrape (only when metrics are configured) any authenticated caller (METRICS) none
GET /sessions Raw herdr workspace list, one row per workspace any authenticated caller (READ) none
GET /agents Raw herdr agent list (every agent herdr tracks) any authenticated caller (READ) none
GET /members The fleetd-owned worker roster, joined with live herdr status any authenticated caller (READ) fleet_list (its members section only — fleet_list also adds peer leads and capacity data this route does not have)
GET /profiles Configured worker profiles and the default one any authenticated caller (READ) fleet_profiles
GET /member-credentials The member credential policy: which names it knows, which it allows, and how many it blocks — names and counts only, never a value any authenticated caller (READ) none
POST /members Spawn a worker primary only (SPAWN) fleet_spawn
DELETE /members/{paneId} Tear a worker down by pane id primary only (STOP) fleet_stop
POST /sessions/{id}/message Deliver a turn to a session, or answer a worker's fleet_ask primary or architect (SEND) fleet_send
POST /sessions/{id}/reply A worker's (or peer lead's) structured reply for its own turn caller must own the session (REPLY) fleet_reply
GET /sessions/{id}/replies Drain a session's held-reply inbox primary only (DRAIN) fleet_poll (with target set)
POST /sessions/{id}/ask A worker's mid-turn question to the primary caller must own the session (ASK) fleet_ask
GET /sessions/{id}/status A session's live lifecycle status and readiness any authenticated caller (READ) fleet_status
GET /tasks/{ticket} Poll an async (wait:false) send by its ticket any authenticated caller (READ) fleet_poll (with ticket set)

MCP tools with no REST route: fleet_ack (FleetMcp.java:1137-1146, calls messages.ackReply(target, msgId) directly — no FleetApp handler exists for it) and fleet_whoami (FleetMcp.java:1233-1246 — identity is resolved per-connection by ConnectionIdentity/CallerResolver, so there is nothing for a REST caller to query; each REST request already carries the resolved Principal under the fleetd.caller context attribute, FleetApp.java:56,138-142).

Per-route detail

POST /sessions/{id}/message

Handler: sendMessage, FleetApp.java:431-474. This is fleet_send's REST face, and it also carries fleet_send's "answer a worker's fleet_ask" mode.

Body fields (FleetApp.java:441-445):

Field Type Default Meaning
content string required The message to deliver, or your answer when turnId is set. A blank value is a 400 bad_request.
turnId string none When set, this call answers a worker's open fleet_ask instead of starting a new delegation, and it always blocks.
timeoutMs integer 25000 Max time to wait for a reply. Clamped to [1, 120000] (FleetApp.java:49-50,454).
wait boolean true false returns a ticket immediately (fire-and-poll); the caller then polls GET /tasks/{ticket}.

Response shape depends on the outcome (writeReply, FleetApp.java:481-511):

Outcome Status Body
Worker asked a question 202 {sessionId, status:"question", question, turnId}
Answering a stale/expired turn 409 {sessionId, error:"stale_turn", detail}
Worker replied normally 200 {sessionId, reply, replySource:"reply"}
Worker finished without calling fleet_reply 200 {sessionId, reply, replySource:"transcript"} — a scrape of the worker's terminal tail, not a structured reply
Timed out, worker still working 202 {sessionId, status:"working", detail}
Timed out, message still queued 202 {sessionId, status:"queued", detail}
Worker busy on another turn 202 {sessionId, status:"busy", detail}
Worker failed 202 {sessionId, status:"failed", detail}
Backend credential exhausted 202 {sessionId, status:"backend_exhausted", detail}

A herdr-level failure during the blocking send (not the turnId path) maps through herdrError (FleetApp.java:650-656): a ..._not_found herdr error code becomes 404 session_not_found, anything else becomes 502 herdr_error.

POST /sessions/{id}/reply

Handler: replyMessage, FleetApp.java:554-571. This is fleet_reply's REST face.

Body: {"content": "..."}. Unlike sendMessage, there is no blank check on content — a missing or empty value is delivered as "". Response: 200 {sessionId, delivered, outcome}.

delivered used to be unconditionally true. Since fleetd #365 it says whether anything was actually waiting: true when the reply resolved an open send or a parked async ticket, false when it was only queued in the inbox for a later drain. Both are still 200 — a queued reply is a success, not an error. outcome names which of the three happened: resolved_send, resolved_async_ticket or queued.

The path's session id is the caller's own identity claim, and this is the one place that claim is checked over REST — over MCP a worker's identity already comes from its connection, never an argument, so it could never reply as another worker. Over REST the id in the URL used to be trusted outright; Authz.Action.REPLY (caller.ownsSession(targetSession), Authz.java:66) is what closes that (FleetApp.java:556-561).

messages.reply() either resolves an open blocking POST /sessions/{id}/message, or — when no send is currently open for that session — queues the reply into the session's inbox (FleetApp.java:550-553). See the warning below about draining that inbox.

POST /sessions/{id}/ask

Handler: askMessage, FleetApp.java:518-548. This is fleet_ask's REST face: a worker's mid-turn question to whichever primary has a blocking send open on it.

Body fields (FleetApp.java:526-528): question (string, required — blank is 400 bad_request), timeoutMs (default 55000, clamped to [1, 115000], FleetApp.java:52-53,537).

Response:

Outcome Status Body
Primary answered 200 {sessionId, answered:true, answer}
No primary is waiting on this session 409 {sessionId, error:"no_pending_send", detail}
Primary did not answer in time 202 {sessionId, status:"no_answer", detail}

GET /sessions/{id}/replies

Handler: drainReplies, FleetApp.java:578-588. Response: 200 {sessionId, replies:[{msgId, content}, ...]}.

Warning — this call drains the inbox on its first read. The javadoc calls the semantics "at-least-once": reading removes the messages, so a second call returns nothing even if the first caller never processed the result (FleetApp.java:573-577). If you need to keep the response, write it to a file the first time you call this route — a second call will not give it back to you.

GET /tasks/{ticket}

Handler: taskStatus, FleetApp.java:622-647. Polls an async (wait:false) send by the ticket POST /sessions/{id}/message returned.

Response: 200 {ticket, phase, reply?, replySource?, detail?, turnId?} — reply/replySource are present once the phase is terminal with a result, turnId is present while the phase is asking (the worker opened a fleet_ask mid-turn and this ticket now needs an answer, FleetApp.java:641-645). An unknown or expired ticket returns 404 {error:"unknown_ticket", detail}.

GET /members

Handler: listMembers, FleetApp.java:309-329. The fleetd-owned roster, joined against live herdr status.

The response key is workers, not members (FleetApp.java:322). The route was renamed from /workers to /members in CB-557, but the body's key was left alone. A caller that reads body["members"] gets nothing and sees an empty fleet rather than an error, so read body["workers"]. fleet-manager already does (fleet_manager/probe.py:134).

The join is on the terminal id, not the pane coordinate: the registry key is a host-unique id while a pane coordinate is a per-daemon counter, so joining on the pane would mismatch as soon as a second herdr daemon is configured (FleetApp.java:314-317).

Each row is a SessionManager.rosterView, carrying at least sessionId, paneId, profile, role, state, worktree, branch, owner and liveStatus.

The body also carries an optional wipRefs object (count, costBytes) describing the refs/wip snapshot store. It is absent until a worktree session has established the repo, so a fleet that has never snapshotted reports nothing rather than zero (FleetApp.java:324-327).

POST /members

Handler: spawnMember, FleetApp.java:346-396. Spawns a worker.

Accepts role, profile, cwd, worktree, ticket either as query parameters or as a JSON body — the body is only parsed when profile, cwd or worktree are missing from the query string (FleetApp.java:355-370). role defaults to dev (MemberRole.parse, values are architect, dev, reviewer); an unrecognised role is 400 {error:"unknown_role", detail}. worktree is either "true" (requires ticket) or a ticket-slug string (worktreeRequest, FleetApp.java:398-409).

On success: 201 with the new MemberSession view — {terminalId, paneId, profile, cwd, ownerTerminal, state, worktree?, branch?} (FleetApp.java:672-687).

Error mapping (FleetApp.java:379-396):

Condition Status Body
Subscription-boundary guard tripped 403 {error:"subscription_boundary", detail}
No profile has spawn capacity right now 503 {error:"no_capacity", detail}
Unknown profile name 400 {error:"unknown_profile", detail}
The peer never became reachable 502 {error:"spawn_timeout", detail}

One case is not covered by this table: worktreeRequest (FleetApp.java:398-409) is called before the try block that catches IllegalArgumentException, so worktree=true with no ticket throws past every one of these handlers rather than becoming a 400. This is a gap found while writing this page — it has not been reported as a bug, and no other caller of this route has been checked for the same pattern.

DELETE /members/{paneId}

Handler: stopMember, FleetApp.java:416-423. No body. Calls sessions.release(paneId) and always answers 204, with no path that surfaces an unknown paneId back to the caller. Whether sessions.release itself does anything for an id it does not recognise was not checked for this page.

GET /member-credentials

Handler: memberCredentials, FleetApp.java:378-391. No body. Answers 200 with the live credential policy, read fresh on every call rather than from a snapshot:

Field Meaning
present whether a memberCredentials policy is configured at all
policy the policy mode, e.g. allow-list; empty string when none is set
known the credential names the policy knows about
allowed the names a member is allowed to inherit
knownCount, allowedCount, blockedCount the three counts

It returns names and counts, never a value. That is the whole point of the route: a probe can check what a member would inherit without the check itself becoming a way to read secrets. Note blockedCount is the policy's own blocked set, not knownCount - allowedCount — the two can differ, and the field is the one to trust.

GET /healthz

Handler: healthz, FleetApp.java:229-266. The only route with no allow() call at all — it takes no Authorization header and answers any caller that can reach the port.

It pings the lead herdr daemon and, when a member daemon is separately configured (memberHerdrSocket, FleetApp.java:210-227), pings that one too:

Configuration Success body Failure
One daemon (the common case) 200 {status:"ok", herdr:{version, protocol}} 503 {status:"degraded", herdr:"unreachable", detail}
Two daemons configured adds "member":{version, protocol}, and "protocolMismatch": true when the two protocol numbers differ a member-daemon failure is its own 503 {status:"degraded", herdr:"member unreachable", detail}, reported separately from a lead-daemon failure so it is never masked by a healthy lead

Warning — a green /healthz does not mean spawning works. The socket can connect while the herdr wire protocol on the other end has moved, so ping succeeds and every spawn still fails (FleetApp.java:218-227). Check the protocol number in the response, and — where a member daemon is configured — check the member key and the protocolMismatch flag specifically, since every spawn goes through the member daemon, not the lead one.

What the REST surface is for

Every capability behind an MCP tool is also reachable here, so the system can be driven and tested with plain HTTP — no Claude session and no MCP client in the loop. This is what makes the routes on this page an acceptance-test surface in their own right, not only a debugging aid: a script can spawn a worker, send it a turn, and drain its replies using nothing but curl and the field names on this page.

This page does not cover the rendezvous flows themselves (the blocking-send / fleet_ask / detached-delivery / turn-done sequences) — those are diagrammed in docs/MCP-Contract.md §6. That section's diagrams describe the flow shapes correctly, but its field names (turn_id, block=false, outcome) predate the code and do not match the request and response fields documented on this page — use the names given here, not the ones in that document.