🧩 Create an App
An app is what a client signs into: one identifier, its own users, its own roles, and the robots you assign to it.
What an app is
The identifier is the handle every client sends at login — lowercase,
underscore-separated, and globally unique rather than per organisation. A
login request carries no organisation, so there is nothing to disambiguate a
collision with; a taken identifier is refused 409 identifier_taken.
Six identifiers are reserved: mcp, oauth, welcome, healthz,
realtime and bridge. Each is a fixed segment of the MCP host, so an app
holding one would be listed in the console with an MCP endpoint nobody can
reach. Creation refuses them with 400 validation_error naming identifier.
There is no rename. PATCH /api/apps/:id changes the display name, the
robots and the default role, and the identifier is not among them. Getting
a different one means a different app.
An app owns its own users and its own roles, and nothing is shared between apps. An app user belongs to exactly one app, so one address may be two unrelated accounts in two apps of the same organisation. Your own console login is a Fleetless user and signs into no app at all — see Manage users and roles.
Assign robots
An app reaches only the robots listed on it, each referenced individually.
Pass robot_ids when you create the app, or attach a robot later on its page
in the console.
A robot that is not assigned does not exist for that app. Every route
addressing it answers 404 not_found — the same answer a robot in another
organisation gets. An app cannot tell the two apart.
Assignment is not a filter. Once a robot is attached, every exposure it declares is reachable in principle, and the role narrows it. See Exposing a Robot.
Auth configuration
One row per app, read whole at /api/apps/:id/auth-config and written in
five slices — .../auth-config/registration, .../auth-config/sign-in,
.../auth-config/urls, .../auth-config/mcp and .../auth-config/look —
plus the logo as a raw body at .../auth-config/logo. Each PUT replaces
only its own slice, every field of that slice required, and refuses a field
it does not know rather than dropping it quietly; the merge against the
stored row happens server-side, so writing one slice never disturbs the
others. Each answers the whole document back, same as the GET.
| Slice | Field | What it does |
|---|---|---|
registration |
self_registration |
Whether a stranger may create an account. Off refuses register with 403 registration_closed. |
registration |
allowed_domains |
The address domains self-registration accepts. Empty means no restriction, not nobody — the switch above is what closes the door. |
registration |
allowed_origins |
The origins CORS is answered for on every route an app-user bearer reaches, refusals included, and the only origins an OIDC redirect_uri may name. Bare origins: scheme, host, port, no path. Up to 20. |
sign-in |
sign_in_methods |
Which credential-based methods your users may sign in with: password, email_code, or both. At least one. An email code is six digits, valid ten minutes, and spent after five wrong attempts. |
sign-in |
two_factor |
off, optional or required. See two-factor, below. |
urls |
app_url |
Your app’s own home page. The hosted “done” pages offer Open <app> when it is set, and “You can close this tab” when it is null. |
urls |
verify_url |
The page in your app that confirms a new address, carrying {token}. Unset, a Fleetless-hosted page does it instead — see hosted pages, below. |
urls |
reset_url |
The page that takes a new password, carrying {token}. Same fallback. |
urls |
invite_url |
The page that accepts an invitation, carrying {token}. Same fallback. |
urls |
mcp_login_url |
The page an MCP authorization sends the browser to, carrying {interaction}. Same fallback. Moved here from the mcp slice; a PUT .../mcp naming it is refused. |
mcp |
mcp_enabled |
Whether the app serves MCP at /mcp/<identifier>. Off refuses the whole OAuth surface, not only the tool calls. |
look |
hosted_accent |
The accent colour of the hosted pages, a lowercase #rrggbb. null keeps the neutral default. |
| — | hosted_logo_url |
Read-only; set with PUT/cleared with DELETE /api/apps/:id/auth-config/logo (raw body, PNG or SVG, at most 100 KB). null until you upload one. |
| — | hosted_pages |
Read-only: the Fleetless-hosted URL for each of invite_url, verify_url, reset_url and mcp_login_url, whether or not you have set your own. |
| — | oidc_callback_url |
Read-only, minted by the cloud. The one callback to register at every provider, and refused in every slice’s write for the same reason. |
The CORS decision is answered on every route an app-user session reaches,
refusals included, not only under /api/client/. An app reads its robots
through routes that carry no client prefix.
An origin missing from allowed_origins does not produce a 403. It
produces a failure with no status and no body, because the browser discards
the answer before your code sees it. This is the first thing to check when
every call fails at once.
Every one of the four link URLs is optional. Leave invite_url,
verify_url, reset_url or mcp_login_url unset and nothing is refused for
its absence any more: Fleetless serves its own page at the matching
hosted_pages URL instead, so self-registration, mailed invitations, password
resets and MCP sign-in all work before your app has a page of its own — handy
in local development, and for an app with no web UI at all. A URL you do set
still wins, exactly as before.
A set URL is https, or http on loopback while you build locally, and
carries its placeholder exactly once: substitution replaces the first
occurrence, so naming it twice mails a half-substituted link and naming it not
at all mails every recipient the same one. The check cannot see whether the
URL resolves, or whether your page knows what to do with the token.
Hosted pages
A Fleetless-hosted page is a plain server-rendered form on the auth portal,
under /app/<identifier>/…; it calls the same /api/client/* routes your
own page would, so there is one policy, not a second copy. It carries your
app’s name, the logo and accent colour from the look slice, and
Secured by Fleetless at the bottom.
The logo and the accent colour need the Plus plan or above; on Basic the page carries the name only — never your users’ Fleetless-branded console. It is not a login for your app’s own web UI: it hands nobody a session at your origin, only at Fleetless’s own routes.
Two-factor for your app
two_factor asks for a TOTP authenticator on top of whatever sign_in_methods
lets a user sign in with, with ten single-use recovery codes as the fallback —
there are no passkeys on this side (those are a Fleetless-user thing, see
Sign-in and Two-Factor).
off— never asked.optional— a user turns it on from your app’s own account settings (the hosted pages have none); once on, every sign-in asks for it.required— a user with no authenticator sets one up right after their next sign-in, before any session exists; nobody already signed in is signed out when you switch it on.
Sign-ins through an OIDC provider skip this, whatever the policy says: the
provider owns that sign-in. Everywhere else, no path hands out a session
without the second factor once a user has a confirmed authenticator — even
under optional, for that one user.
Identity providers. An app may carry any number of OpenID Connect
providers under /api/apps/:id/oidc-providers, and discovery runs when you
write one. Identity providers need the Plus plan or above; below it a write
answers 403 plan_required. A client_secret is required on create and is echoed back by no
route, not even the write’s own answer.
Mail templates. The three mails Fleetless sends on your behalf —
invite, verify and reset — can be replaced with your own Liquid
templates. Liquid runs in strict mode, so an unknown variable fails at save
time rather than rendering empty in somebody’s inbox; the permitted set is
app.name, org.name, user.email, user.display_name, role.name,
link and expires_in_hours.
The default role
default_role_id is the role an app user gets when they are created or
invited without an explicit one. A new app has none, and null is the normal
state: every app exists before its roles are configured.
Until it is set, self-registration cannot complete: POST /api/client/register answers 409 target_state_conflict naming
default_role_id. Creating or inviting an app user without a role_id is
refused the same way, naming the same field, rather than producing a user
with no role.
register asks two questions in order, and verify_url is the first. A fresh
app whose verification URL is unset is refused before the role is even looked
at, with the same 409 target_state_conflict naming verify_url — so read
which field the refusal names rather than assuming which setting is missing.
An invitation resolves its role when it is issued and stores it. Changing the default therefore never re-aims an invitation already in somebody’s mailbox.
Server keys
A server key is a per-app secret carrying the app’s full permission set, for your own backend, automation or continuous integration. No role narrows it. The app’s robot list still does: a key reaches exactly the robots assigned to its app, and nothing else.
The secret is returned once, by the call that mints or rotates it, and only
its hash is kept. rotate replaces the secret and keeps the key; deleting the
key removes the row itself. Minting, rotating and deleting all need Owner
tier, because all three decide who may speak for the whole app.
The old secret stops working immediately, and what is already holding it is cut rather than left running: an open realtime socket, and any live camera session.
A server key never goes into a client. It holds every right the app has, and a browser bundle is a place where secrets are published, not stored.
Talk to it
Install the SDK with npm i @fleetless/sdk, then sign in as an app user and
read a value.
import { createClient } from '@fleetless/sdk'
const client = createClient({
apiUrl: 'https://api.fleetless.dev',
appIdentifier: 'warehouse_dash',
})
await client.auth.login('someone@example.com', 'correct-horse-battery')
const battery = await client.datapoints.get('robot-id', 'battery_percentage')
console.log(battery.value, battery.timestamp_ms)
The same two steps over plain REST are POST /api/client/login and GET /api/robots/:id/datapoints/:slug with the returned access token as a bearer.
See the SDK reference and the
API reference.
Next
- Manage users and roles — who gets in, and what their role lets them touch.
- The app starter — a working app on this SDK, every auth screen already built.