Table of Contents
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.
📖 fleet
Home — overview & the decision
Chapters
- Architecture — system · 2 invariants · 2 modes
- Message Server — the
fleetddesign - Approaches — transports compared, why herdr
- Setup — ⚫ superseded by 13
- Operations — ⚫ superseded by 13
- Team — orchestrating a mixed fleet
- Use Cases — the review scenario + mechanisms
- Roadmap — delivery record: what is live, what is off, what was dropped
- Implementation — as-built code map · classes · flows · state machines
- Cross-Host Messaging — broker topology · exchanges · queues per entity
- Features — what it can do · the knob that turns it on · why · the gotcha
- Claude → OpenCode — porting a workspace to a second host
- User Guide — 🟢 install · configure · run · delegate · the traps
- Fleet Manager — many fleets on one host, over REST
- REST API Reference — all 14 routes, roles, and bodies
- Security & Trust Boundary — the guard · authz · what a member inherits
Design proposals (not built)
- CB-548 Lead Quorum — a deterministic decision procedure around a lead's judgment
🟢 herdr-centric fleetd · AgentAPI = research, never built