API Reference
This page maps the routes: method, path, who may call it. Request and response shapes are the SDK’s exported types; where this page and the SDK disagree, the SDK is right.
Two APIs, one command surface
- REST (
api.fleetless.dev) — polling, history, action/service calls, publishes, snapshots, URDF/assets, users/presence. - Realtime (WebSocket, same host,
/realtime) — subscriptions on datapoints, action state,bridge_state, presence, and full command parity: everything REST can do (start an action, call a service, publish, cancel) also works over the same connection.
All realtime subscribers of one datapoint get the same rate — the datapoint’s configured send rate, not one per subscriber.
Errors
Every rejection is machine-readable: a stable error code plus a human message; validation failures name the field and the violated rule.
Health
| Method | Path | Auth |
|---|---|---|
| GET | /healthz |
none |
Developer auth
| Method | Path | Auth |
|---|---|---|
| POST | /api/auth/signup |
none (rate-limited) |
| POST | /api/waitlist |
none (rate-limited) |
| POST | /api/auth/refresh |
none (rate-limited) |
| POST | /api/auth/logout |
none |
| GET | /api/auth/me |
developer |
| PATCH | /api/auth/me |
developer — own display name |
| POST | /api/auth/password/change |
developer |
| POST | /api/auth/password/reset |
none (rate-limited) |
| POST | /api/auth/password/reset/confirm |
none (rate-limited) |
POST /api/waitlist is the landing page’s waiting list; it answers 202
whether or not the address was already listed.
There is no POST /api/auth/login; a developer signs in at the auth
portal — one origin, one page, one implementation of the password check —
and the console is an OAuth client of it:
| Method | Path | Auth |
|---|---|---|
| GET | /console/oauth/authorize |
none — starts a console sign-in |
| GET | /console/oauth/interaction/:id |
none — the identify card |
| POST | /console/oauth/identify |
none (rate-limited) |
| POST | /console/oauth/login |
none (rate-limited) — the password step |
| GET | /console/oauth/signup/:id |
none — sign-up step 1 |
| POST | /console/oauth/signup |
none (rate-limited) — the account |
| POST | /console/oauth/signup/organization |
none (rate-limited) — the organization |
| POST | /console/oauth/token |
none — the code exchange, PKCE S256 |
While the platform is in closed beta, POST /api/auth/signup and the
portal’s sign-up pages answer 403 signup_closed.
These answer a <form> post with a page and a JSON post with JSON, decided on
the request body’s content type. The token endpoint answers a sessionTokens
body; POST /api/auth/refresh, GET /api/auth/me and everything else below
take it from there.
A console sign-in is always a Fleetless password. It never routes through
a group’s OIDC provider, however that group is configured — the console flow
has no federation step at all. Single sign-on applies to an app’s end users
(GET /oauth/authorize) and to the central MCP server, not to the console.
PATCH /api/auth/me changes only the caller’s own display name — the
caller’s own session, no Owner tier, since a developer session can only ever
act on its own row. display_name: null clears it; a save to the name already
held is a no-op — no store write and no audit event, the same discipline
PATCH /api/org and PATCH /api/org/users/:id follow below.
End-user (client) auth
| Method | Path | Auth |
|---|---|---|
| POST | /api/client/login |
none (rate-limited) |
| POST | /api/client/refresh |
none (rate-limited) |
| POST | /api/client/logout |
none |
| POST | /api/client/password/change |
developer or client |
| GET | /api/client/me |
developer or client |
| GET | /api/client/grants |
client — the app user’s own consent grants |
| DELETE | /api/client/grants/:client_id |
client — revokes the consent and every refresh family behind it |
Org
| Method | Path | Auth |
|---|---|---|
| PATCH | /api/org |
developer, Owner tier — rename the org |
| GET | /api/org/quotas |
developer |
| GET | /api/org/health |
developer |
| GET | /api/org/federation |
developer |
| PUT | /api/org/federation |
developer, Owner tier |
| GET | /api/org/jobs |
developer — run history across the org, newest first, cursor-paged on seq |
| GET | /api/org/jobs/summary |
developer — running/started/failed since ?since_ms= |
| GET | /api/org/latency |
developer — one-minute bridge latency buckets per robot over ?from_ms=/?to_ms= |
| GET | /api/org/usage |
developer — what the org consumed per day over ?from_day=/?to_day= |
| GET | /api/audit |
developer |
| GET | /api/audit/export |
developer — the same filters as /api/audit, everything they match, as a CSV download |
PATCH /api/org treats a no-op value as a no-op. Renaming an org to the
name it already has writes nothing and records no audit event — the org
activity stream reaches every developer with the console overview open, and a
logged event for a change that did not happen would misreport that something
did. PATCH /api/org/users/:id (below) applies the same discipline to a
display-name save.
The last-Owner guard and the group/assignment cascade live on the user
routes in Users, groups and assignments
below: PUT /api/org/users/:id/tier refuses 409 last_owner on a demotion,
DELETE /api/org/users/:id refuses it on a removal, and moving a user or
relinking an app cascades to now-invalid assignments behind a counted
confirmation.
GET/PUT /api/org/federation read and set whether a federated login
may join an existing account by verified email. This is org-scoped rather
than per-group: the thing the policy decides about — whether an org lets a
verified email attach to an account it already holds — is an org-wide fact.
The per-group OIDC providers are a separate mechanism; see the note under
Users, groups and assignments.
Three of the four annotated reads above — /api/org/jobs, /api/org/jobs/summary
and /api/org/latency — are the durable counterpart of what /api/robots/:id/jobs
answers about right now, and they have three properties worth knowing before
you build against them:
next_cursor === nullis the only end-of-data signal on/api/org/jobs. Page until you get it — not until a page looks short, and not until one comes back empty.- An over-large
limitis refused, not capped. Above the platform’s page maximum the request answers400 validation_errornaming the field, rather than quietly returning a smaller page. - Window parameters are required and have no default.
/api/org/jobs/summaryneedssince_ms,/api/org/latencyneeds bothfrom_msandto_ms; each missing one is a400that names it. “Today” is a question only your browser can answer, and a default latency window would be a query size chosen by whoever forgot to pass one. Both routes echo the window back so a rendered tile or sparkline can say which one it is describing.
There is no client-facing equivalent of these three, deliberately: a run row names the actor who invoked it, so an org-wide read would tell one end user which other end users have been driving the machine.
/api/org/usage answers a different question from the three above: not how
the fleet is running, but what the org has consumed. It returns one row per
metric, per day, and — for the two metrics that have an app dimension — per
app: { app_id, app_name, metric, day, value }, over
?from_day=/?to_day=. Three of the five metrics have no app dimension at
all and are one row per day each; see the app_id note below. The route has
its own set of things worth knowing before you build against it:
-
Five metrics, three units.
api_callscounts requests,live_session_msandrobot_online_msare milliseconds,retention_bytesandasset_bytesare bytes.api_callscounts work actually performed: a429(the platform’s own refusal) and any5xx(the platform’s own fault) are never counted — billing a caller for either would be indefensible, so a request log and this counter can disagree by exactly those responses. -
app_id: nullmeans the org itself, and what that implies depends on the metric — check it before you read the row.api_callsandlive_session_mshave an app dimension, so anullrow on either is the developer console’s own traffic, deliberately not billable.retention_bytes,asset_bytesandrobot_online_mshave no app dimension at all: every row for those three carriesapp_id: nullunconditionally, and every one is billable org-level consumption. Treatingapp_id === nullas “not billable” across the board is wrong for three of the five metrics. -
The two byte metrics are each day’s peak, not a daily total. Both are read from a live running total and written as the greatest value sampled that day, sampled every 15 minutes — never an end-of-day snapshot and never a sum of the day’s activity. That is deliberate: an end-of-day snapshot would let an org that deletes everything at 23:00 pay for nothing on that day.
-
retention_bytesis an estimate;asset_bytesis not. The running total behindretention_bytesis built by adding, at write time, roughly the JSON size of each recorded value plus a fixed per-row overhead — not what Postgres actually stores after TOAST — and it floor-clamps at zero rather than drift negative, which biases it low. It is the same running counter the platform’s storage quota uses to protect itself, and it is fit for that job, not for an invoice.asset_bytesis counted from real stored blob sizes as each is written and carries no such caveat. A usage page has no other way to tell a reader which of its two byte columns to trust. -
The window is inclusive at both ends, unlike the
from_ms/to_mswindows above, which are half-open. A calendar day is a unit, not an instant, and a person asking for July writesfrom_day=2026-07-01&to_day=2026-07-31; a half-open day window would silently drop the 31st. Both parameters are required with no default and are echoed back, the same rule/api/org/jobs/summaryand/api/org/latencyfollow. An extra query parameter,from_dayafterto_day, or a shape-valid calendar date that does not exist (2026-02-30) each answer a400 validation_errornaming the field, the same as any other malformed query on this page. -
A window over 366 days is refused with
400, not capped — the same rule/api/org/jobs’slimitfollows. -
Freshness differs by metric, and by up to 15 minutes.
api_callsandlive_session_msare aggregated in memory and written every 30 seconds; a hard restart loses whatever had not yet reached the database when it died — and never against the caller, since an unflushed count is simply never billed. A shutdown that completes its final flush loses nothing from those two.retention_bytes,asset_bytesandrobot_online_msnever go through that 30-second path at all — they are read directly from other tables on their own 15-minute sampling timer, so a number for “today” on those three can lag by up to that long. -
Recovery differs by metric too, and it is the half that surprises people. Lag says how old a number can be; recovery says whether a failed write ever comes back, and the five metrics answer differently:
api_callsandlive_session_msretry. A write that fails is kept in memory and attempted again on the next 30-second tick, for as long as the process lives, so a database blip costs nothing but freshness. Two consequences: while writes are failing there is no “at most 30 seconds” bound at all — a hard kill during an outage loses everything counted since the last successful write — and a row the database rejects permanently (its org has been deleted since the count) is written off rather than retried, reported with a count, and never billed.retention_bytesandasset_bytesare not retried, and do not need to be: each pass reads a current running total and stores the day’s maximum, so the next pass 15 minutes later reconstructs the right answer on its own. A failed sample costs one sample’s resolution out of the 96 taken per day.robot_online_msis not retried and only partly self-heals. Every pass rewrites both today and yesterday from the underlying rows, so a failure is repaired by the next pass — but only while the day in question is still “today” or “yesterday”. Once it is older than that, nothing recomputes it, and the rows it was derived from are swept on their own retention schedule. A day that failed every pass while it was current stays wrong.
So: a failing meter shows up as a stale
api_calls/live_session_msfigure that catches up in one tick, and as a byte or uptime figure that quietly skipped a sample. Neither ever bills you for more than happened. -
app_name: nullhas two causes, not one. It isnullwheneverapp_idis — which, forretention_bytes,asset_bytesandrobot_online_ms, is every row — and it is alsonullwhen the app that produced the row has since been deleted. The usage row outlives a deleted app deliberately, because an org still owes for what it used; checkapp_idbefore reading anullapp_nameas “this app was deleted.”
/api/audit accepts action (exact match, e.g. config.published) or
action_prefix (e.g. robot., matching robot.deleted, robot.created,
…) — never both: passing both is a 400 validation_error, because a
query naming an exact action and a prefix in the same request is a caller
mistake, not two constraints that quietly narrow to one. % and _ in
either parameter are matched literally, not as SQL wildcards, so an
action name or prefix containing either character behaves exactly as typed
rather than matching more than it says.
Users, groups and assignments
There is one user pool per org. A user belongs to exactly one group; the Org Admins group is the only one that grants console access, and its members carry a tier (Owner or Developer). App access is explicit: a user reaches an app only through an assignment that names a role. See Identity for the model.
| Method | Path | Auth |
|---|---|---|
| GET | /api/org/groups |
developer |
| POST | /api/org/groups |
developer |
| GET | /api/org/groups/:id |
developer |
| PATCH | /api/org/groups/:id |
developer — the Org Admins group is renamable here |
| DELETE | /api/org/groups/:id |
developer — group_not_deletable for Org Admins, group_in_use while it holds users or apps |
| GET | /api/org/groups/:id/usage |
developer |
| GET | /api/org/groups/:id/oidc-provider |
developer — the group’s OIDC provider without the secret; 404 when none |
| PUT | /api/org/groups/:id/oidc-provider |
developer — set/rotate the provider; Org Admins group refused, first write needs client_secret |
| DELETE | /api/org/groups/:id/oidc-provider |
developer — remove it; 404 when none |
| GET | /api/org/users |
developer — ?group_id= narrows |
| GET | /api/org/users/:id |
developer |
| PATCH | /api/org/users/:id |
developer — display name only; no email, group or tier |
| DELETE | /api/org/users/:id |
developer — and every session of that user ends |
| GET | /api/org/users/:id/usage |
developer — the move-group blast-radius preview, ?group_id= |
| POST | /api/org/users/:id/move-group |
developer — cascades assignments, behind the acknowledgement |
| PUT | /api/org/users/:id/tier |
developer, Owner tier — last-owner guarded (409 last_owner) |
| GET | /api/org/users/:id/assignments |
developer |
| PUT | /api/org/users/:id/assignments/:appId |
developer — assign the user to an app with a role |
| DELETE | /api/org/users/:id/assignments/:appId |
developer |
| POST | /api/org/users/invitations |
developer — one invitation flow for every user now |
| GET | /api/org/users/invitations |
developer — pending only, no tokens |
| DELETE | /api/org/users/invitations/:id |
developer |
| POST | /api/org/users/invitations/:id/reissue |
developer, Owner tier for an owner-tier invitation — same gate as creating one |
| POST | /api/org/users/invitations/accept |
none (rate-limited) — answers sessionTokens |
Promotion to Owner is the one Owner-gated write here. PUT /api/org/users/:id/tier requires the caller to hold Owner tier; the rest are
developer-tier. It refuses 409 last_owner on a demotion that would leave the
org with no Owner, the same guard DELETE /api/org/users/:id applies to a
removal.
Re-issuing an invitation mints a fresh link onto the same row, rather than
creating a second one — the old link stops resolving the instant this
returns. Re-issuing a pending owner-tier invitation needs Owner tier,
identical to POST /api/org/users/invitations minting one, since a fresh
accept_url for that row is the same value a new one would be. Rate-limited
server-side to once a minute per invitation, measured from the last
re-issue, not the invitation’s age: refused with 429, code: 'rate_limited', and details.retry_after_ms — the remaining cooldown in
milliseconds, so a client can show a live countdown without keeping its own
timer.
Moving a user or relinking an app is a cascade behind a confirmation.
POST /api/org/users/:id/move-group and PUT /api/apps/:id/group (below)
both delete the assignments the move invalidates; each is preceded by a
usage preview (GET /api/org/users/:id/usage, GET /api/apps/:id/group-usage)
that counts what will go, the same blast-radius pattern as a slug rename.
A group may federate its logins to your own OIDC provider. The three
/api/org/groups/:id/oidc-provider routes above configure it (Org Admins only,
secret write-only). Once set, a user your IdP vouches for is provisioned into
the group just-in-time on first login, with the app assignments the provider’s
JIT grants name; an email collision is refused rather than merged, and
an unknown identity on a group with no provider or with JIT disabled is turned
away. The login flow itself lives under /oauth/ (see the OAuth 2.1 / MCP
section below); the model is in Identity. The
central MCP server’s routes are in that same section, and its access rules
in MCP.
Apps
| Method | Path | Auth |
|---|---|---|
| POST | /api/apps |
developer |
| GET | /api/apps |
developer |
| GET | /api/apps/:id |
developer |
| PATCH | /api/apps/:id |
developer — name, robots, default role |
| POST | /api/apps/:id/roles |
developer |
| GET | /api/apps/:id/roles |
developer |
| PUT | /api/apps/:id/roles/:roleId/permissions |
developer |
| GET | /api/apps/:id/roles/:roleId/permissions |
developer |
| GET | /api/apps/:id/roles/:roleId/mcp-tools |
developer — the per-role MCP datasheet the console shows |
| POST | /api/apps/:id/server-keys |
developer |
| GET | /api/apps/:id/server-keys |
developer |
| POST | /api/apps/:id/server-keys/:keyId/rotate |
developer |
| DELETE | /api/apps/:id/server-keys/:keyId |
developer, Owner tier — revokes the key |
| GET | /api/apps/:id/group-usage |
developer — the relink blast-radius preview, ?group_id= |
| PUT | /api/apps/:id/group |
developer — link the app to a group; cascades assignments behind the acknowledgement |
| GET | /api/apps/:id/branding |
developer |
| PUT | /api/apps/:id/branding |
developer |
| DELETE | /api/apps/:id/branding |
developer |
| POST | /api/apps/:id/oauth-clients |
developer |
| GET | /api/apps/:id/oauth-clients |
developer |
| DELETE | /api/apps/:id/oauth-clients/:clientId |
developer |
| DELETE | /api/apps/:id/oauth-clients/:clientId/consent |
developer |
DELETE /api/apps/:id/server-keys/:keyId removes the row, not just the
secret. rotate already invalidates the old secret but keeps the key
around; DELETE is for a key — a CI credential, say — that should stop
existing. After it, the next call authenticated with that key’s secret is
401. The audit event server_key.revoked carries the evidence the deleted
row can no longer hold: the key’s name and its last_used_at, in details.
And because an already-open /realtime socket authenticated with that key is
exactly who a developer is acting against, revocation closes it — the same
thing rotate does to a socket holding the old secret, only more final: a
rotated key still belongs to someone, a revoked one belongs to nobody.
Robots
| Method | Path | Auth |
|---|---|---|
| POST | /api/robots |
developer |
| GET | /api/robots |
developer |
| GET | /api/robots/:id |
developer |
| PATCH | /api/robots/:id |
developer — rename the robot |
| GET | /api/robots/:id/deletion-preview |
developer |
| DELETE | /api/robots/:id |
developer |
| PUT | /api/robots/:id/details |
developer |
| GET | /api/robots/:id/datapoints |
developer or client |
| GET | /api/robots/:id/datapoints/:slug |
developer or client |
| GET | /api/robots/:id/datapoints/:slug/history |
developer or client — recorded samples over ?from=/?to=, optionally bucketed by ?window=/?agg= |
| GET | /api/robots/:id/exposures |
developer |
| GET | /api/robots/:id/introspection |
developer |
| POST | /api/robots/:id/introspection/refresh |
developer |
| GET | /api/robots/:id/types |
developer |
| POST | /api/robots/:id/types/fetch |
developer |
GET /api/robots carries exposes on every robot — five non-negative
counts, one per kind (datapoints, actions, services, publishers,
cameras), read from that robot’s latest published configuration, never
the draft. The field is always present, not omitted for a robot that has
never published: such a robot reports five zeros, because “nothing published
yet” and “published, and it configures nothing” both answer the question a
list row asks — what does this robot currently offer — the same way.
robotDetailResponse carries it too, for the same reason.
This deliberately disagrees with GET /api/robots/:id/exposures, and both
numbers are correct. /exposures lists every grantable slug — the roles
matrix’s material — and prepends the three built-in datapoints every robot
has regardless of configuration: bridge_state, robot_details and
bridge_pressure. exposes counts only what was
configured, excluding all of them, because it answers a different question:
what did a developer configure, not what can a role be granted. So a robot
with an empty published configuration reports exposes.datapoints: 0 while
/exposures returns three entries for it — the two counts differ by exactly
the built-in datapoints, and that is a set the platform can add to (it went
from two to three when bridge_pressure arrived), not a fixed number to
hard-code against.
PATCH /api/robots/:id, like PATCH /api/org above, treats a rename to the
name already held as a no-op — no store write, no robot.renamed audit
event.
Configuration (draft/publish)
| Method | Path | Auth |
|---|---|---|
| GET | /api/robots/:id/config/draft |
developer |
| PUT | /api/robots/:id/config/draft |
developer |
| POST | /api/robots/:id/config/publish |
developer |
| GET | /api/robots/:id/config/versions |
developer |
| GET | /api/robots/:id/config/versions/:v |
developer |
| POST | /api/robots/:id/config/versions/:v/restore |
developer |
| GET | /api/robots/:id/config/slug-usage/:slug |
developer — what a rename of this slug would touch |
| POST | /api/robots/:id/config/rename-slug |
developer — atomic slug rename across draft, grants, alerts, chart-display config and history |
A configuration is written as text. PUT .../config/draft takes
{ "source": "<the fleetless.yaml>" } and nothing else — deliberately no
parsed document beside it, because the two could then disagree. The server
parses, so there is one account of what the configuration says. See the
fleetless.yaml reference.
Reads give you both: doc is the parsed document every consumer should read,
and source is the text as its author wrote it — comments and key order
intact. source is never null; for a draft nobody has edited yet the server
renders the document, so a reader always has text to show. A published version
carries its source too, which is what makes a version diff readable and a
restore honest. On a draft, doc can be null — the three sections below
are what that means and when it happens.
Saving a draft asks one question: is this YAML?
Saving does not ask whether the text is a fleetless configuration.
Publish asks that. A file is at its least finished — halfway between two
shapes, a section half rewritten — exactly when losing it costs most, so the
draft PUT stores what it is given and lets the developer keep working.
Two refusals survive, and neither is about content:
| Status | Code | When |
|---|---|---|
422 |
invalid_yaml |
the text is not YAML at all — the parser could not read it |
422 |
unstorable_yaml |
it parses, but a YAML anchor makes it refer to itself, so there is nothing that can be written down |
They carry distinct codes because the work differs — fix line 3, versus remove
a self-reference — and one code for both could not tell you which you had.
Both answer details.parse_error, the parser’s own sentence, and both answer
details.issues: []: there is no value, so there is no path to attribute a
finding to.
invalid_yaml also carries details.line and details.column, and both can
be null. They are the parser’s position, and not every failure has one —
an unresolved alias is raised after the tokens have been read, with no
position attached. A client that places an editor marker from them will meet a
refusal it cannot place, and has to be able to render it anyway.
Anything past that is stored, including valid YAML that is not a fleetless
document at all — a file missing its fleetless: 1 header, a datapoints
entry with no topic yet, a mapping that is not this format in any way. The
response is 200, and it carries doc: null with issues saying why the text
is not a document yet.
What doc: null means
It means this draft’s text does not currently parse to a configuration. It
does not mean “nothing is configured”: the last published version is
untouched, the robot is still running it, and every role grant still resolves
against it. A consumer that builds a view out of doc has to tell those two
apart before it draws anything — otherwise a robot with thirty datapoints
renders as empty for as long as somebody has an unfinished draft open.
The alternative — putting the raw parsed YAML value in doc — was rejected
because a reader could then no longer tell whether what it holds is a
document, and would have to re-validate to find out. null forces the
question at the point of reading.
The pair doc: null with source: null cannot occur. source is never
null, so a reader always has either the author’s text or, for a draft nobody
has touched, the server’s rendering of the empty document. A version’s doc
is never null either, because publish refuses a draft that has none: only the
draft endpoints carry this case at all.
The issues of a draft with no document
A doc-less draft is not reported as clean. Its issues hold what the format
alone can say about the stored text, plus one warning:
| Code | Severity | Meaning |
|---|---|---|
robot_checks_skipped |
warning |
the checks that compare the configuration against the robot — unknown topics, unknown field paths, message types — did not run, because they need a document |
draft_not_a_document |
error |
the text is valid YAML and not a configuration, and re-reading it produced no finding naming a key at fault. A fallback, so a draft that cannot be published never looks clean |
Both sit at the path (document) — the spelling for a finding about the file
rather than about a key in it. robot_checks_skipped exists because its
absence would be a lie by omission: a panel that simply showed no
unknown_topic could not tell a developer whether that meant checked, and
fine or nobody looked.
Publish refuses twice, in two words
| Status | Code | When |
|---|---|---|
422 |
draft_not_a_document |
the draft is valid YAML and not a configuration, so there is nothing to publish. details.issues carries the whole finding list, robot_checks_skipped included |
422 |
validation_error |
the draft is a document and has findings of severity error. details.issues carries exactly those — warnings never block a publish |
This file is not a configuration yet and there are errors to fix in your
configuration call for different work, and a single code would leave you
unable to tell which you had. draft_not_a_document is the same word the two
rename routes answer for the same state, as 409 rather than 422 — see
below.
Renaming a slug
POST /api/robots/:id/config/rename-slug is one atomic transaction across
five owners of the same fact: the draft config’s matching entry, every
app-role grant naming {robot_id, from}, the alert rows and chart-display
config keyed by (robot_id, from), and the recorded history rows all move
together, or none do. It answers 409 draft_not_a_document if the draft has
no document to rename anything in — asked before everything else, because a
draft that is not a configuration has no slugs to be unknown and answering
unknown_slug would be false about text that may well name it. Then
404 unknown_slug if from names nothing in the draft, 409 reserved_slug
if to is a built-in name, and 409 duplicate_slug if to already names an
entry (including from === to).
The published config is immutable, so this route never touches it — the
response’s requires_publish: true is not a suggestion:
- Job runs and audit events keep the old slug permanently — this is not a window that closes at publish. They are historical fact by design and are never migrated, publish or not: a run recorded before the rename stays attributed to the name that was current when it happened, indefinitely.
- Samples arriving between the rename and the applied publish still land
under the old slug, and are never migrated either — this one is a
window, and it closes at publish. The rename’s own transaction only
moves history rows recorded before it ran; anything the bridge reports
in between still carries
from, because the published config — what the bridge and any grant-checked reader actually see — still saysfromuntil the publish lands. Once it does, the new slug’s history has a gap for that interval and the samples that arrived during it stay orphaned under the old slug permanently. - Between the rename and the applied publish, the draft and every grant
already name
to, but the still-published config exposes onlyfrom— an end user’s app has no working name for the datapoint at all for however long that gap lasts, sincetoisn’t published yet andfromno longer has a grant behind it. Nothing server-side closes this window; the console must publish immediately after a rename to keep it short.
A fourth residual has nothing to do with publishing: saving a draft is
last-write-wins, with no version check or lock held across the request. A
caller that read the draft before this call and saves it after silently
reverts the draft’s slug back to from, while the grants and history this
call already moved stay on to. Reload the draft after any rename before
saving it again — the console does this automatically; a direct REST client
must do the same itself.
GET /api/robots/:id/config/slug-usage/:slug answers what a prospective
rename would touch — grant count, the app identifiers holding those grants,
how many alerts are watching the slug (alert_count), and whether any
history has been recorded under the slug — so the console can show a confirm
dialog before calling rename-slug. It never checks that slug actually
names an entry in the draft, so a slug that does not exist there answers
200 with every field zeroed (grant_count: 0, no app identifiers,
alert_count: 0, has_recorded_history: false) rather than 404 — unlike
rename-slug, which does 404 as unknown_slug in that case, so a caller
must not read a zeroed usage response as “safe to rename” without separately
confirming the slug exists.
It does refuse one state, with the rename’s own 409 draft_not_a_document:
a draft with no document has no alerts section to count, and answering
alert_count: 0 for it would show a smaller blast radius than the rename
actually has. The other three fields would still have been real — grants and
history do not live in the draft — but a preview whose whole purpose is to be
complete is not worth a partial answer.
Alerts
| Method | Path | Auth |
|---|---|---|
| GET | /api/robots/:id/alerts |
developer — the runtime state of this robot’s alerts |
| GET | /api/org/alerts?state=firing |
developer — what is firing across the org |
Alerts are defined in the configuration document, not through this API.
An alert is a key under the datapoint it watches, and it reaches the platform
by publishing — the same door as everything else in the file. There is no
create, no patch and no delete route, because there is nothing for them to
write to: renaming an alert is editing the file, and removing one is deleting
a key. See the fleetless.yaml reference.
Chart bounds moved the same way. y_min and y_max are the datapoint’s
chart group in the document; the display endpoints they used to need are
gone.
What these two routes return is runtime state, which is the half the
document cannot hold: whether an alert is ok or firing, since when, and
the value at its last transition.
An alert is a state machine (ok ⇄ firing), not a fire-once event. Every
accepted sample on its datapoint is evaluated against its condition. Without
resolve_at the condition is an equality — it fires while the value equals
fire_at, for numbers, booleans and strings alike. With resolve_at it is a
threshold, and the gap between the two is the hysteresis that keeps a noisy
signal from chattering at the boundary. A sample the condition cannot compare
neither transitions nor errors; the alert holds its last state.
Every transition publishes an org event, and these are never throttled —
a transition already is the event rather than a sample of a fast stream.
A firing event carries the alert’s own severity; a resolved event is always
info.
No mail is sent. recipients, cooldown_minutes and notify_on_resolve
are gone with the mail path they existed for. Webhooks will be how an alert
reaches another system.
Runtime state across a publish, compared per alert against the previously
published version: an unchanged condition keeps the state and its
state_since; a changed condition resets to ok and discards the last
value; a new alert starts ok; and a key that disappeared takes its state
with it. Renaming an alert is therefore delete plus create — an alert that is
still true will fire again.
There is no orphaned any more. An alert lives inside the datapoint it
watches, so one pointing at a slug that does not exist cannot be written
down. That field, and the special case for a robot with nothing published,
both went with the shape that made them necessary.
Commands (jobs, publishers)
See Disconnect Behaviour for the semantics behind cancel, timeouts and failsafes.
| Method | Path | Auth |
|---|---|---|
| GET | /api/robots/:id/jobs |
developer or client |
| GET | /api/robots/:id/jobs/history |
developer or client — what has run; an end user needs the action_history capability |
| POST | /api/robots/:id/jobs/:slug |
developer or client — invoke an action/call a service |
| GET | /api/robots/:id/jobs/:slug |
developer or client — poll job state by slug |
| POST | /api/robots/:id/jobs/:slug/cancel |
developer or client |
| POST | /api/robots/:id/publishers/:slug |
developer or client |
GET .../jobs/history is the per-robot half of /api/org/jobs and shares its
query and its paging rules — next_cursor === null as the only end-of-data
signal, an over-large limit refused rather than capped. Two things are
specific to it:
- An end user’s role must carry the
action_historycapability. Without it the read answers403 capability_required, naming the capability, so the developer knows which switch to flip rather than hunting for a grant that is already there. A developer session and a server key are not in the role system and are never capability-checked. - An end user sees only runs on the slugs their role grants, and that filter runs after the page is read — so a narrow role routinely gets a page of two where it asked for fifty, or an empty one, with a perfectly good non-null cursor and more history behind it.
Cameras
See Cameras for snapshot vs. live semantics.
| Method | Path | Auth |
|---|---|---|
| GET | /api/robots/:id/cameras |
developer or client |
| GET | /api/robots/:id/cameras/:slug/snapshot |
developer or client |
| GET | /api/robots/:id/cameras/:slug/snapshot/meta |
developer or client |
| POST | /api/robots/:id/cameras/:slug/live |
developer or client — start/join a live session |
| DELETE | /api/robots/:id/cameras/:slug/live |
developer or client — release a live session (?session_id=) |
Assets (URDF, meshes)
See URDF & Meshes for the authorization model and why this is not a CDN.
| Method | Path | Auth |
|---|---|---|
| GET | /api/robots/:id/assets |
developer or client |
| GET | /api/robots/:id/assets/:assetId |
developer or client — fetch an asset by UUID |
| GET | /api/robots/:id/urdf |
developer or client — re-serialized, mesh URIs rewritten |
| GET | /api/robots/:id/assets/missing |
developer or client (?name=) |
| POST | /api/robots/:id/assets/sync |
developer, Owner tier — spends the org’s asset quota |
| GET | /api/robots/:id/assets/sync/:syncId |
developer or client — poll sync status |
| GET | /api/asset-links/:token |
none — a signed link minted by asset_get / urdf_get, valid 15 minutes |
| GET | /api/asset-links/missing |
none — the placeholder a rewritten URDF points at for a mesh that never arrived |
The two /api/asset-links routes take no bearer at all: the token is the
authorization. An MCP session token is refused on REST by design, so the two
asset tools mint a link an agent can fetch instead — a base64url payload
naming robot, asset and expiry under an HMAC, good for 15 minutes. A URDF
served that way has its mesh and texture URIs rewritten to links of the same
expiry. A link is a bearer capability for its lifetime, and the assets
capability is checked when it is minted, not when it is fetched; a mint is
audited as mcp.asset_link_minted, a fetch is not, because nothing
identifies the fetcher.
OAuth 2.1 / MCP
See Apps, End Users & Roles for the hosted login flow and MCP for the two tool catalogs.
| Method | Path | Auth |
|---|---|---|
| GET | /.well-known/oauth-authorization-server |
none |
| GET | /.well-known/oauth-authorization-server/:appIdentifier |
none — per-app metadata |
| GET | /.well-known/oauth-protected-resource |
none |
| GET | /oauth/authorize |
none (rate-limited) |
| GET | /oauth/consent |
none — serves the consent page |
| POST | /oauth/consent |
none (rate-limited) — accepts a consent decision |
| POST | /oauth/token |
none (rate-limited) |
| POST | /oauth/register |
none (rate-limited) — RFC 7591 dynamic client registration, opt-in per app |
| GET | /oauth/idp-callback |
none — the return leg of a group’s federated login; validates the IdP’s id_token (issuer, audience, expiry, nonce, JWKS signature) before minting anything |
| GET | /oauth/impersonate |
authenticated org-admin interaction — serves the “sign in as” interstitial |
| POST | /oauth/impersonate |
authenticated org-admin interaction (rate-limited) — accepts the role/user choice, mints an act-attributed session |
Federation is per group, and Fleetless is the authorization server. A
client app never talks to the IdP: it runs the ordinary flow against
/oauth/authorize, and when the user’s group federates, Fleetless redirects out to the group’s IdP and takes
the return on its own /oauth/idp-callback, validating the id_token before it
provisions or logs anyone in. An org admin, who holds no app assignment of
their own, is instead sent to the /oauth/impersonate interstitial to sign in
as a role or as an assigned user; the minted session carries an act claim
naming the real admin. See Identity for both
flows.
The central MCP server
One server for the whole platform, at POST /mcp. No field on an app gates
or configures MCP. Access is the caller’s
group mcp_enabled crossed with their own mcp_access override, with org
admins always allowed — see MCP.
| Method | Path | Auth |
|---|---|---|
| POST | /mcp |
MCP access token — the one MCP endpoint; Origin, if sent, must be the cloud’s own |
| GET | /.well-known/oauth-protected-resource/mcp |
none — RFC 9728 metadata for that resource |
| GET | /.well-known/oauth-authorization-server/mcp |
none — RFC 8414 metadata for its login |
| GET | /mcp/oauth/authorize |
none — starts the identifier-first login |
| POST | /mcp/oauth/identify |
none — the email step; resolves the person, their group and its provider |
| POST | /mcp/oauth/login |
none — the Fleetless password step |
| GET | /mcp/oauth/idp-callback |
none — the return leg of a group’s federated MCP login |
| POST | /mcp/oauth/token |
none — PKCE code exchange |
| POST | /mcp/oauth/register |
none (rate-limited) — RFC 7591 dynamic client registration |
| GET | /mcp/welcome |
none — a static page for a person who pastes the URL into a browser |
Dynamic client registration is supported here. The AS advertises a
registration_endpoint, so a tool registers itself and nobody configures a
client id by hand. Registration is public-clients-only — a request asking for
any
token_endpoint_auth_method other than none is refused with
invalid_client_metadata rather than quietly downgraded, and no secret is
ever issued. It is also not a grant of anything: a registered client
still walks the whole identifier-first login and is still refused by the
same mcp_access gate. The central client keeps working unchanged, which is
the fallback for a tool without DCR. (POST /oauth/register in the app flow
above is a different endpoint against a different table and is opt-in per
app; the two do not share clients.)
One difference from the app flow remains: no impersonation — an org admin
acts as themselves at the MCP server. Consent works as it does there: the
well-known fleetless-central-mcp client is consent-free, and every client
that registered itself through DCR passes a consent screen naming it before a
code is minted. Consent is asked on every connection and nothing is stored,
because a remembered grant with no way to revoke it would be the worse answer.
Hand out https://mcp.fleetless.dev — pathless. That vhost rewrites
POST / onto /mcp and the root discovery documents onto their suffixed
forms, so the bare host is a working MCP endpoint and is the shortest true
thing to paste. https://api.fleetless.dev/mcp stays the canonical
resource identifier: every issuer, resource and token audience is minted
from the api. base URL and checked against it, never against the host a
request arrived on. Both reach the same process; use the canonical one when
a tool insists on a full path, or when debugging an audience mismatch.
Gating is checked at login and again on every single request, always
against the group and user rows as they are now and never against a value
cached off the token — so revoking someone’s access bites at their next
call rather than their next refresh. A caller who is gated out gets 403 mcp_access_denied, and at /mcp no WWW-Authenticate challenge with it:
re-authenticating would not help.
The two tool catalogs
Every session is offered the same tools, and what a caller’s roles permit
decides what a call answers rather than which tools appear. Fourteen robot
tools for everyone; seventy-nine console_* tools additionally for an org
admin, and for nobody else — a caller who is not an org admin is offered none
of them and is refused forbidden for calling one anyway. The REST twin
column names the route a tool dispatches onto; it is the same function, never
a second implementation, so validation, refusals and audit are identical.
Optional inputs are marked ?.
| Robot tool | Input | REST twin | Annotations |
|---|---|---|---|
robots_list |
— | — | read-only |
robot_describe |
robot_id |
GET /api/robots/:id/datapoints, GET /api/robots/:id/cameras |
read-only |
datapoint_read |
robot_id, slug |
GET /api/robots/:id/datapoints/:slug |
read-only |
datapoint_history |
robot_id, slug, from, to?, window?, agg?, field?, limit? |
GET /api/robots/:id/datapoints/:slug/history |
read-only |
service_call |
robot_id, slug, args?, patience_ms? |
POST /api/robots/:id/jobs/:slug |
destructive |
action_start |
robot_id, slug, args?, patience_ms? |
POST /api/robots/:id/jobs/:slug |
destructive |
jobs_list |
robot_id, history?, slug?, state?, kind?, from_ms?, to_ms?, limit?, before_seq? |
GET /api/robots/:id/jobs, GET /api/robots/:id/jobs/history |
read-only |
job_get |
robot_id, slug |
GET /api/robots/:id/jobs/:slug |
read-only |
job_cancel |
robot_id, slug, job_id? |
POST /api/robots/:id/jobs/:slug/cancel |
destructive |
publish |
robot_id, slug, message |
POST /api/robots/:id/publishers/:slug |
destructive |
camera_snapshot |
robot_id, slug |
GET /api/robots/:id/cameras/:slug/snapshot, GET /api/robots/:id/cameras/:slug/snapshot/meta |
read-only |
assets_list |
robot_id |
GET /api/robots/:id/assets |
read-only |
asset_get |
robot_id, asset_id |
GET /api/robots/:id/assets/:assetId |
read-only |
urdf_get |
robot_id |
GET /api/robots/:id/urdf |
read-only |
robots_list has no REST twin because a REST client always names the robot it
holds, so “which robots do I reach” is a question only an MCP session asks.
robot_describe answers a datasheet — every exposure the role grants, a missing description as description: null — and not the
console’s own per-role preview route, which belongs to the console family
below. A datapoint’s exposure carries its numeric.unit and its
numeric.decimals as fields of their own, so a client knows what the number
means and how many fraction digits to show it with. Both are null for a
datapoint that sets neither and for every kind that has no numeric value.
| Family | Console tool | REST twin | Annotations |
|---|---|---|---|
| Robots | console_robots_list |
GET /api/robots |
read-only |
| Robots | console_robot_get |
GET /api/robots/:id |
read-only |
| Robots | console_robot_create |
POST /api/robots |
— |
| Robots | console_robot_update |
PATCH /api/robots/:id |
idempotent |
| Robots | console_robot_details_put |
PUT /api/robots/:id/details |
idempotent |
| Robots | console_robot_exposures |
GET /api/robots/:id/exposures |
read-only |
| Robots | console_robot_assets_sync |
POST /api/robots/:id/assets/sync |
— |
| Robots | console_robot_asset_sync_status |
GET /api/robots/:id/assets/sync/:syncId |
read-only |
| Robots | console_robot_delete_preview |
GET /api/robots/:id/deletion-preview |
read-only |
| Robots | console_robot_delete |
DELETE /api/robots/:id |
destructive |
| Configuration | console_config_draft_get |
GET /api/robots/:id/config/draft |
read-only |
| Configuration | console_config_draft_put |
PUT /api/robots/:id/config/draft |
idempotent |
| Configuration | console_config_publish |
POST /api/robots/:id/config/publish |
destructive |
| Configuration | console_config_versions_list |
GET /api/robots/:id/config/versions |
read-only |
| Configuration | console_config_version_get |
GET /api/robots/:id/config/versions/:v |
read-only |
| Configuration | console_config_version_restore |
POST /api/robots/:id/config/versions/:v/restore |
destructive |
| Configuration | console_config_slug_usage |
GET /api/robots/:id/config/slug-usage/:slug |
read-only |
| Configuration | console_config_slug_rename |
POST /api/robots/:id/config/rename-slug |
— |
| Introspection | console_introspection_get |
GET /api/robots/:id/introspection |
read-only |
| Introspection | console_introspection_refresh |
POST /api/robots/:id/introspection/refresh |
— |
| Introspection | console_types_get |
GET /api/robots/:id/types |
read-only |
| Introspection | console_types_fetch |
POST /api/robots/:id/types/fetch |
— |
| Apps | console_apps_list |
GET /api/apps |
read-only |
| Apps | console_app_get |
GET /api/apps/:id |
read-only |
| Apps | console_app_create |
POST /api/apps |
— |
| Apps | console_app_update |
PATCH /api/apps/:id |
idempotent |
| Apps | console_app_group_usage |
GET /api/apps/:id/group-usage |
read-only |
| Apps | console_app_move_group |
PUT /api/apps/:id/group |
destructive |
| Apps | console_app_branding_get |
GET /api/apps/:id/branding |
read-only |
| Apps | console_app_branding_put |
PUT /api/apps/:id/branding |
idempotent |
| Apps | console_app_branding_delete |
DELETE /api/apps/:id/branding |
destructive |
| Roles | console_roles_list |
GET /api/apps/:id/roles |
read-only |
| Roles | console_role_create |
POST /api/apps/:id/roles |
— |
| Roles | console_role_permissions_get |
GET /api/apps/:id/roles/:roleId/permissions |
read-only |
| Roles | console_role_permissions_put |
PUT /api/apps/:id/roles/:roleId/permissions |
destructive, idempotent |
| Roles | console_role_mcp_preview |
GET /api/apps/:id/roles/:roleId/mcp-tools |
read-only |
| Server keys | console_server_keys_list |
GET /api/apps/:id/server-keys |
read-only |
| Server keys | console_server_key_create |
POST /api/apps/:id/server-keys |
— |
| Server keys | console_server_key_rotate |
POST /api/apps/:id/server-keys/:keyId/rotate |
destructive |
| Server keys | console_server_key_revoke |
DELETE /api/apps/:id/server-keys/:keyId |
destructive |
| App OAuth clients | console_app_oauth_clients_list |
GET /api/apps/:id/oauth-clients |
read-only |
| App OAuth clients | console_app_oauth_client_create |
POST /api/apps/:id/oauth-clients |
— |
| App OAuth clients | console_app_oauth_client_delete |
DELETE /api/apps/:id/oauth-clients/:clientId |
destructive |
| App OAuth clients | console_app_oauth_client_consent_revoke |
DELETE /api/apps/:id/oauth-clients/:clientId/consent |
destructive |
| Groups | console_groups_list |
GET /api/org/groups |
read-only |
| Groups | console_group_get |
GET /api/org/groups/:id |
read-only |
| Groups | console_group_create |
POST /api/org/groups |
— |
| Groups | console_group_update |
PATCH /api/org/groups/:id |
idempotent |
| Groups | console_group_usage |
GET /api/org/groups/:id/usage |
read-only |
| Groups | console_group_delete |
DELETE /api/org/groups/:id |
destructive |
| Group identity providers | console_group_idp_get |
GET /api/org/groups/:id/oidc-provider |
read-only |
| Group identity providers | console_group_idp_put |
PUT /api/org/groups/:id/oidc-provider |
destructive, idempotent |
| Group identity providers | console_group_idp_delete |
DELETE /api/org/groups/:id/oidc-provider |
destructive |
| Users | console_users_list |
GET /api/org/users |
read-only |
| Users | console_user_get |
GET /api/org/users/:id |
read-only |
| Users | console_user_update |
PATCH /api/org/users/:id |
idempotent |
| Users | console_user_usage |
GET /api/org/users/:id/usage |
read-only |
| Users | console_user_move_group |
POST /api/org/users/:id/move-group |
destructive |
| Users | console_user_delete |
DELETE /api/org/users/:id |
destructive |
| Users | console_user_assignments_get |
GET /api/org/users/:id/assignments |
read-only |
| Users | console_user_assign |
PUT /api/org/users/:id/assignments/:appId |
idempotent |
| Users | console_user_unassign |
DELETE /api/org/users/:id/assignments/:appId |
destructive |
| Invitations | console_invitations_list |
GET /api/org/users/invitations |
read-only |
| Invitations | console_invite |
POST /api/org/users/invitations |
— |
| Invitations | console_invitation_reissue |
POST /api/org/users/invitations/:id/reissue |
— |
| Invitations | console_invitation_revoke |
DELETE /api/org/users/invitations/:id |
destructive |
| Org | console_org_get |
— | read-only |
| Org | console_org_update |
PATCH /api/org |
idempotent |
| Org | console_quotas_get |
GET /api/org/quotas |
read-only |
| Org | console_usage_get |
GET /api/org/usage |
read-only |
| Federation | console_federation_get |
GET /api/org/federation |
read-only |
| Federation | console_federation_put |
PUT /api/org/federation |
idempotent |
| Audit | console_audit_list |
GET /api/audit |
read-only |
| Fleet health | console_alerts_list |
GET /api/org/alerts |
read-only |
| Fleet health | console_robot_alerts |
GET /api/robots/:id/alerts |
read-only |
| Fleet health | console_health_get |
GET /api/org/health |
read-only |
| Fleet health | console_jobs_list |
GET /api/org/jobs |
read-only |
| Fleet health | console_jobs_summary |
GET /api/org/jobs/summary |
read-only |
| Fleet health | console_latency_get |
GET /api/org/latency |
read-only |
console_org_get is the only console tool with no REST twin: the console
reads the org off GET /api/auth/me, which is a session read, and an MCP
session has no browser session to describe. robots_list is the other tool in
either catalog without one, for the reason given above.
Three things this catalog returns exactly once, in the tool result, with no
tool that reads them back: the bridge token from console_robot_create, and
the key from console_server_key_create and console_server_key_rotate. A
lost secret is replaced by rotating. Per-app OAuth clients are public clients
with no secret, so console_app_oauth_client_create has nothing to hand over
once.
Realtime and bridge transports
| Path | Direction | Notes |
|---|---|---|
wss://api.fleetless.dev/realtime |
client apps ↔ cloud | subscriptions + full command parity (see above) |
wss://api.fleetless.dev/bridge |
robot bridge ↔ cloud | the versioned bridge protocol — not a client-facing surface |