FleetlessFleetlessdocs
Reference/API Reference

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 === null is 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 limit is refused, not capped. Above the platform’s page maximum the request answers 400 validation_error naming the field, rather than quietly returning a smaller page.
  • Window parameters are required and have no default. /api/org/jobs/summary needs since_ms, /api/org/latency needs both from_ms and to_ms; each missing one is a 400 that 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_calls counts requests, live_session_ms and robot_online_ms are milliseconds, retention_bytes and asset_bytes are bytes. api_calls counts work actually performed: a 429 (the platform’s own refusal) and any 5xx (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: null means the org itself, and what that implies depends on the metric — check it before you read the row. api_calls and live_session_ms have an app dimension, so a null row on either is the developer console’s own traffic, deliberately not billable. retention_bytes, asset_bytes and robot_online_ms have no app dimension at all: every row for those three carries app_id: null unconditionally, and every one is billable org-level consumption. Treating app_id === null as “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_bytes is an estimate; asset_bytes is not. The running total behind retention_bytes is 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_bytes is 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_ms windows above, which are half-open. A calendar day is a unit, not an instant, and a person asking for July writes from_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/summary and /api/org/latency follow. An extra query parameter, from_day after to_day, or a shape-valid calendar date that does not exist (2026-02-30) each answer a 400 validation_error naming 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’s limit follows.

  • Freshness differs by metric, and by up to 15 minutes. api_calls and live_session_ms are 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_bytes and robot_online_ms never 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_calls and live_session_ms retry. 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_bytes and asset_bytes are 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_ms is 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_ms figure 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: null has two causes, not one. It is null whenever app_id is — which, for retention_bytes, asset_bytes and robot_online_ms, is every row — and it is also null when 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; check app_id before reading a null app_name as “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 says from until 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 only from — an end user’s app has no working name for the datapoint at all for however long that gap lasts, since to isn’t published yet and from no 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_history capability. Without it the read answers 403 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