fleetlessfleetlessdocs
Concepts/Create an App

🧩 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.

ts
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