SDK Reference
@fleetless/sdk (npm) is the official TypeScript client for Fleetless apps: framework-agnostic, ESM+CJS, and it runs unchanged in a
browser or in Node — nothing in it assumes either one exists. The code
samples are the same as the package README’s.
npm i @fleetless/sdk
Quick start
Five minutes from install to a live value, assuming you already have an app identifier and an end user’s credentials from the Fleetless Console — see Getting Started.
import { createClient } from '@fleetless/sdk'
const client = createClient({
apiUrl: 'https://api.fleetless.dev',
appIdentifier: 'warehouse_dash', // the app's identifier, shown in the console
})
await client.auth.login('user@example.com', 'correct-horse-battery')
// One-shot read.
const battery = await client.datapoints.get('robot-id', 'battery_percentage')
console.log(battery.value, battery.timestamp_ms)
// Live updates. The current value arrives immediately, then every change.
const subscription = client.datapoints.subscribe('robot-id', 'battery_percentage', {
onEvent(event) {
console.log(event.value, event.timestamp_ms)
},
onError(error) {
console.error(error.code, error.message)
},
})
// Later:
subscription.unsubscribe()
await client.auth.logout()
A slug names a place a job may be running, not the job itself. Two
invokes on the same slug, one after another, are two different jobs that
happen to share an address — subscribe and the plain cancel() both work
by slug on purpose (state is observed by slug), and that is a
feature, not an oversight. A caller who means one specific job — the one
whose id it was handed — has to say so explicitly; see Actions
for job-id addressing. Conflating the two is how a cancel sent a moment too
late ends up stopping someone else’s job instead of doing nothing.
Auth
Fleetless has two kinds of caller (the platform — see also Apps, End Users & Roles), and the SDK supports both.
End users — tokenStore
const client = createClient({ apiUrl, appIdentifier })
await client.auth.login(email, password) // -> stores a session
await client.auth.me() // -> ClientIdentity, without decoding a token yourself
const { revoked, idp_logout } = await client.auth.logout() // revokes server-side, then clears the local store
if (idp_logout?.status === 'redirect') {
window.location.href = idp_logout.url // ends the session at the IdP too — this SDK does not navigate for you
}
-
Silent refresh is automatic. A REST call that meets an expired access token refreshes once and retries transparently. Concurrent calls made while a refresh is already in flight share that one refresh instead of each firing their own.
-
logout()never rejects. It always clears the local store — a user who presses “log out” ends up logged out locally regardless of the network — but the server-side revoke can still fail (offline, a dead cloud).revoked: falsemeans the refresh family may still be alive server-side even though this client has forgotten it; a kiosk or shared workstation might want to warn the user or retry, everyone else can ignore the return value.idp_logoutisnullexactly whenrevokedisfalse— the server never got to answer it, so there is nothing to relay. -
idp_logoutsays one of five things, and they are not interchangeable. Ending the Fleetless session and ending a federated session at the developer’s IdP are different actions.{ status: 'redirect', url }— send the browser here (as above) to end the IdP session too.{ status: 'not_federated' }— this session never came from an IdP; there is nothing else to end.{ status: 'unsupported_by_idp' }— it did, and the IdP publishes noend_session_endpoint(RP-initiated logout is optional in OIDC).{ status: 'hint_unavailable' }— it did, the IdP can end it, and Fleetless can’t ask: what it needs to ask with (id_token_hint) could not be decrypted, or was never kept for this session.{ status: 'session_unknown' }— the server did not find this session at all (unknown, superseded, revoked or expired token), so it can say nothing about an IdP.unsupported_by_idpandhint_unavailableboth mean the IdP session survives and this platform cannot end it — treat them the same in your UI, and do not fold either one intonot_federated; that would report a session as fully ended when it isn’t.session_unknownis a different kind of nothing, and it is what a second logout gets — a double click, a repeated POST, an app that logs out on unmount and on a route change: the last call wins. It is deliberately notnot_federated, which would be a positive claim about an IdP the server never looked up, and would let an app skip the redirect the first call returned — leaving the user signed in at the IdP after clicking log out.revokedcannot tell them apart: it reports whether the HTTP call succeeded, not whether a session was found, so this case arrives as{ revoked: true, idp_logout: { status: 'session_unknown' } }, never as thenullabove.
-
What
logout()does and does not invalidate. It revokes the whole refresh-token family server-side immediately (a stolen refresh token stops working) and closes this client’s live realtime connection, if it has one. It does not invalidate the access token already handed out — access-token checks are a signed JWT verified without a server-side lookup, so logout has nothing on that token to flip. A token stolen before logout keeps working on REST, and can still open a new realtime connection, until it expires on its own — at most 15 minutes. That’s the same stateless-JWT tradeoff that lets a role change or a block take effect on the very next request without forcing a fresh token — a deliberate boundary, not a bug, but one a kiosk or shared workstation needs to plan around. -
Token storage is pluggable. By default, sessions live in memory and are lost on reload. Implement
TokenStoreto persist one — localStorage, a cookie, a native keystore:tsimport type { TokenStore, StoredSession } from '@fleetless/sdk' const localStorageTokenStore: TokenStore = { load: () => { const raw = localStorage.getItem('fleetless-session') return raw ? (JSON.parse(raw) as StoredSession) : null }, save: (session) => { if (session) localStorage.setItem('fleetless-session', JSON.stringify(session)) else localStorage.removeItem('fleetless-session') }, } const client = createClient({ apiUrl, appIdentifier, tokenStore: localStorageTokenStore })
Hosted login
A second way to authenticate an end user, alongside login() above: redirect
them to a Fleetless-hosted login page instead of collecting
a password in your own app. The flow is Authorization Code + PKCE
(OAuth 2.1 — no implicit grant, no plain challenge). When the end user’s
group carries an OIDC provider, the hosted page federates out to it and
returns through Fleetless; your app never sees a password or talks to the IdP.
The per-app IdP configuration of earlier versions has been replaced by these
per-group providers. It’s also MCP’s own
front door (see MCP), but nothing about using it from
this SDK depends on that. The full walkthrough, including every
fleetless_code value and what to tell the user for each, lives in the
Hosted Login recipe — this section covers the
SDK calls themselves.
Getting a clientId. Register your app’s own OAuth client in the
console, under App Settings → OAuth clients — name it and list the
redirect URIs it’s allowed to use; the panel shows you the client_id you
need below. clientId is not your appIdentifier — they’re deliberately
different identifiers.
// 1. Send the browser there. beginHostedLogin makes no network call — it
// just builds the URL and generates the values PKCE and CSRF protection
// need.
const request = await client.auth.beginHostedLogin({
clientId: 'oauth-client-id-from-the-console',
redirectUri: 'https://your-app.example.com/callback',
})
// Persist these two values before navigating away — a full page redirect
// does not preserve any in-memory state, so this SDK hands them back to you
// instead of holding them itself. sessionStorage survives the round trip
// and clears itself when the tab closes.
sessionStorage.setItem('fleetless_state', request.state)
sessionStorage.setItem('fleetless_code_verifier', request.codeVerifier)
window.location.href = request.url
// 2. On the redirectUri page, once the hosted flow has finished. Check for
// an error first — the callback can legitimately carry ?error=... instead
// of a code (RFC 6749 §5.2); an account with no membership in this app,
// or a federated IdP that's down, both land here as an error, not a code.
const params = new URLSearchParams(window.location.search)
const expectedState = sessionStorage.getItem('fleetless_state')
const codeVerifier = sessionStorage.getItem('fleetless_code_verifier')
sessionStorage.removeItem('fleetless_state')
sessionStorage.removeItem('fleetless_code_verifier')
if (params.has('error')) {
handleHostedLoginError(params.get('error')!, params.get('error_description'), params.get('fleetless_code'))
} else if (!expectedState || !codeVerifier) {
// Nothing was persisted for THIS attempt — a callback landing in a
// different tab or window, or a re-opened redirect URL, reads back as
// null here, and neither is an attack.
handleHostedLoginError('no_hosted_login_attempt', 'This login was not started in this browser tab.', null)
} else {
try {
await client.auth.completeHostedLogin({
code: params.get('code')!,
state: params.get('state')!,
expectedState,
codeVerifier,
clientId: 'oauth-client-id-from-the-console',
redirectUri: 'https://your-app.example.com/callback',
})
// From here on client.auth.me() / logout() / silent refresh behave
// exactly as they do after login() — completeHostedLogin stores the
// identical session shape, through the same tokenStore.
} catch (error) {
const code = error instanceof Error && 'code' in error ? String(error.code) : 'unknown'
handleHostedLoginError(code, error instanceof Error ? error.message : String(error), null)
}
}
- This SDK never touches
sessionStorage, or any storage, for you.beginHostedLoginreturnsstate/codeVerifierrather than holding them itself, because the redirect back is a fresh page load — nothing this SDK keeps in memory survives it. A popup flow that never truly navigates away can just keep them in a variable instead. completeHostedLoginrefuses before any network call (state_mismatch) ifstatedoesn’t match whatbeginHostedLoginreturned for this attempt — it will not complete a login your app did not itself start.- Errors from this exchange use OAuth’s own vocabulary, not this SDK’s
usual
code/messagepairs.completeHostedLogincan throw aFleetlessErrorwhose.codeis e.g.invalid_grant(an expired or already-usedcode) straight from RFC 6749 §5.2 — the exchange happens against/oauth/token, a standards-compliant endpoint any OAuth client can drive, not this SDK’s own API. - Never call
completeHostedLogina second time for the same redirect while a first call is still in flight. No timeout, framework retry, or double-click guard should ever resubmit it — a second presentation of the same authorization code revokes the whole refresh-token family server-side, on purpose (a plain race looks identical to theft on the wire, and the platform treats it as theft). Guard the call site — this method cannot protect you from itself being called twice. - Reach for a presence check (
if (!expectedState || !codeVerifier)) rather than a non-null assertion anywhere a value crossed a boundary this code doesn’t control — a query string,sessionStorage, a thrown error’s.message.
Account recovery
Self-registration is gone. Earlier versions of this SDK exposed
auth.register/confirmRegistrationfor signing up into an app’s own user pool. The org-central identity redesign removed per-app pools and per-app self-registration with no successor: users now belong to one pool per organisation and a group grows by invitation, or by OIDC just-in-time provisioning when the group federates to your own IdP. There is no client-side sign-up call any more.
// Changing your own password while logged in.
await client.auth.changePassword('current-password', 'new-correct-horse-battery')
// Forgot it entirely: request a link, then use the token it contains.
await client.auth.requestPasswordReset('user@example.com')
await client.auth.confirmPasswordReset(tokenFromTheEmailedLink, 'new-correct-horse-battery')
changePasswordre-issues your session rather than leaving it alone. It returns fresh tokens, which this method stores for you exactly likelogindoes. Every other session of this identity is revoked; only the one that made this call keeps working. Surface the “your other sessions are about to end” consequence before the user confirms.requestPasswordResetanswers the same way for a known and an unknown address, always. This is the one place in this SDK where telling the two apart would let a stranger enumerate accounts by email address, so the server never says. Show a generic “check your email” message regardless of the outcome — do not add a “no such account” branch.confirmPasswordReset’s token is single-use and expires, and both cases answertoken_spentfor the same reason as above. Succeeding revokes every other session, exactly likechangePassword.requestPasswordResetis behind the rate limiter that guards every unauthenticated route — see Errors for howrate_limitedsurfaces and why this SDK never retries it for you.changePasswordis not available on aserverKeyclient — it throws immediately, same aslogin/logout.requestPasswordReset/confirmPasswordResetwork on either kind of client, since neither touches a session or a token store.- These are the end-user routes (
/api/client/password/...). The developer-facing console has its own equivalents; this SDK never speaks to that identity space.
Server-side callers — serverKey
For your own backend, automation, or CI — full app rights, no login step:
const client = createClient({ apiUrl, appIdentifier, serverKey: 'flk_...' })
await client.datapoints.get('robot-id', 'battery_percentage') // works immediately
A serverKey client has no session to manage: auth.login/auth.logout
throw if called. A rejected server key does not refresh — there is nothing
to refresh into — it just fails. tokenStore and serverKey are mutually
exclusive; pick one per client.
Consent grants
const grants = await client.grants.list()
// -> [{ client_id, client_name, app_id, app_name, role_id, role_name, scope, granted_at }, ...]
const { revoked, tokens_revoked } = await client.grants.revoke(grants[0].client_id)
An end user’s own record of every client they have ever authorized, and the means to take one back without touching any of the others.
list()carries names, not only ids —client_name,app_nameandrole_nameare what was actually on screen when the user consented, so they can recognise what they’re revoking.client_idis whatrevoke()addresses.client_nameis not trusted. A third-party OAuth client chooses its own display name — don’t render it as if Fleetless vouched for it.revoke()tells “nothing matched” from “matched and ended.”revoked: false(an id already gone, a double click) is a no-op, not an error.- Revocation is immediate, not eventual. The cloud re-checks every
request for a revoked grant, so any currently-valid access token minted
through that client for that end user is refused on its very next
request — it does not wait out its TTL. Scoped to that one client; a plain
auth.login()session is untouched. - Not available on a
serverKeyclient — a server key never went through a consent screen, so there is no “self” here to list or revoke.
Datapoints
client.datapoints.get(robotId, slug) // -> Promise<DatapointValue>
client.datapoints.subscribe(robotId, slug, { onEvent, onError? }) // -> { unsubscribe() }
- Your session survives a laptop lid being closed. If a client reconnects after being offline longer than the access token’s lifetime, the reconnect’s authentication is refused as expired — the SDK refreshes silently and retries the connection once before giving up, the same courtesy a REST call gets. You do not need to detect this or re-login by hand.
subscribeshares one WebSocket connection across every subscription on a client. It reconnects with exponential backoff on a network drop, and every active subscription is re-established automatically once the connection (and, if the access token has since expired, a silent refresh) comes back.- A subscription refused by the server (role does not grant the slug, an
unknown slug, …) calls
onErroronce with aFleetlessError; it never callsonEvent. Fleetless deliberately answersforbiddenboth for “your role does not grant this” and for “no such slug” — the SDK does not invent a distinction the API does not make.
Actions
const job = await client.actions.invoke(robotId, 'dock', { 'target_pose.position.x': 1.0 }) // -> Job, id is informative
const sub = client.actions.subscribe(robotId, 'dock', {
onJob(event) {
console.log(event.job.state, event.feedback, event.progress) // feedback/progress arrive as the action reports them
if (event.job.state === 'succeeded') console.log(event.job.result)
},
onError(error) {
console.error(error.code, error.message)
},
})
// The operator's stop button: whatever is running on this slug, stop it.
const stopped = await client.actions.cancel(robotId, 'dock')
// Cancel the job you invoked, specifically — not whatever else might be
// running there by the time this arrives:
const cancelled = await client.actions.cancel(robotId, 'dock', job.id)
// cancelled is the Job that was actually stopped, or null if nothing
// matched. Naming a job.id that is no longer running rejects `not_found` —
// it never falls back to stopping whatever *is* running instead.
sub.unsubscribe()
invokeresolves as soon as the job exists — the returned job id is informative, not the result. Feedback, progress and the eventual result arrive separately, oversubscribe.- State is observed by slug, not by job id.
subscribe(robotId, slug, ...)shows whatever job is currently running there — including a job invoked by a different caller — which is also what makes late delivery after a reconnect and two observers watching the same job both work without any special-casing on your part. - One job runs per action slug. A second
invokewhile one is already running rejectsbusy, anderror.details.runningis theJobthat is already in flight — enough to show the user what is happening, or tosubscribeand wait for it. canceladdresses either a slug or a specific job.cancel(robotId, slug)is the blunt operator form: stop whatever is running there, whoever started it.cancel(robotId, slug, job.id)is a caller cancelling the specific job it invoked — a plain slug-cancel that arrives a moment after its own job ended will happily stop whoever’s job runs next on that slug. There is no automatic fallback from the id form to the slug form onnot_found.subscribeis reference-counted per(robotId, slug), exactly likedatapoints.subscribe: two callers on the same key share one wire subscription, and unsubscribing one never affects the other.paramsis flat, keyed by the exactparameterSpec.namethe developer declared —'target_pose.position.x', not{ target_pose: { position: { x: 1.0 } } }. Do not nest it yourself: a field a spec did not declare cannot be set at all, and aparameter_invalidrefusal names thefieldit rejected using this same flat key.options.patienceMsbounds goal acceptance only, not the whole job:client.actions.invoke(robotId, 'dock', params, { patienceMs: 5_000 })makes the platform give up if no action server accepts the goal within 5s (goal_timeout), but once accepted the job runs as long as it runs — you observe it viasubscribe, you never await it. Omit it for the platform default (currently 15s). Outside the platform’s[1s, 120s]bounds the call is refused withvalidation_error— this SDK does not clamp your number to fit.
Services
const result = await client.services.call(robotId, 'get-status', { verbose: true })
// patienceMs bounds the *whole* wait here — unlike an action, a service has
// no further state to observe once the platform gives up on it:
const status = await client.services.call(robotId, 'get-status', {}, { patienceMs: 5_000 })
Same flat-key convention for params as actions.invoke.
A service call is a job underneath — the same disconnect survival as an
action — but that is deliberately invisible here: call waits for it
internally and resolves with the result directly, or rejects with the typed
error it failed with. There is nothing to subscribe to for a service: no
feedback, no progress, no cancel.
options.patienceMs is the platform’s own patience for this call — distinct
from options.timeoutMs, this SDK’s own local bound on the whole call
(default 30s): the ack that a job was created, plus however much of that
budget is left for the job to then reach a terminal state — not two separate
30-second windows back to back. patienceMs and timeoutMs bound different
things and neither derives from the other when both are set.
Publishers
await client.publishers.publish(robotId, 'cmd-vel', { 'linear.x': 0.2, 'angular.z': 0 })
message follows the same flat, parameterSpec.name-keyed convention as
actions.invoke’s params — not a nested ROS message tree.
Publishing is a plain method call — no more, no less. There is deliberately no deadman switch, rate governor, or “takt” helper in this SDK (the platform, and see What this SDK deliberately does not do): the safety pattern for how often and when to publish is your app’s responsibility, not something this library does for you. Believing the SDK protects you here is more dangerous than knowing it does not.
What the platform does guarantee is the bridge’s own
failsafe: the developer configures a timeout_ms and a failsafe message
when exposing the publisher, and if messages stop arriving for any reason —
including your process crashing — the bridge itself publishes that
failsafe message on the topic. Build your app as if it is the only thing
standing between a dropped connection and a robot that keeps moving, because
for the interval up to timeout_ms it is.
Publishing is also implicitly exclusive: whoever last published holds the
publisher until they have been quiet for the configured quiet_timeout_ms.
A publish call while someone else holds it rejects publisher_busy.
Jobs
const jobs = await client.jobs.list(robotId) // -> Job[]
Every job the platform currently believes this robot has — at most one per
slug, the same shape actions.subscribe/services.call observe, just
gathered across the whole robot instead of one named slug at a time.
actions/services require knowing the slug first, which is not always
true: a bridge reconnecting can name a job the cloud only adopted, and a
configuration change can leave a job on a slug the published document no
longer contains. jobs.list is how you find them anyway — e.g. to reconcile
local UI state against “what is this robot actually doing” on load.
Grant-filtered like everything else: an end user or server key sees only
jobs on slugs their role grants; a developer session sees every job on the
robot. [] means the robot is doing nothing, not “we did not look” — the
array is never null.
Ordered newest first by started_at, job.seq as the tiebreaker for two
jobs minted in the same millisecond — but for an adopted job, started_at
is adoption time, not when it actually started on the robot.
Run history — what has run
const page = await client.jobs.history(robotId, { slug: 'dock', limit: 50 })
for (const run of page.runs) {
console.log(run.state, run.duration_ms, run.actor.label)
}
The durable counterpart of jobs.list. That one answers what is running
now and carries at most one entry per slug; this one is one row per run,
with its actor, its outcome and its duration_ms, kept for 90 days and
surviving both the job leaving the live registry and the cloud restarting.
It needs the action_history capability on the caller’s role. Without it
the call rejects capability_required, and the message names the capability
so you know which switch to flip in the console rather than hunting for a
grant that is already there. A developer session sees the whole robot; an end
user sees only runs on the slugs their role grants.
Page until nextCursor is null — never until a page looks short, and
never until one comes back empty:
let cursor = undefined
do {
const page = await client.jobs.history(robotId, { limit: 50, beforeSeq: cursor })
render(page.runs)
cursor = page.nextCursor ?? undefined
} while (cursor !== undefined)
The cloud applies your role’s slug grants to the page it already read, so a
role granted one slug in ten routinely gets a page of two where it asked for
fifty — with plenty more history behind it. A page where every run was
filtered out comes back runs: [], and because nextCursor is computed
before that filtering, an empty page can carry a perfectly good non-null
cursor. nextCursor === null is the only end-of-data signal there is;
runs.length === 0 is not one, and it is the reading most likely to be
mistaken for “that’s all there is”.
Filters: slug, state, kind, limit, beforeSeq, and the half-open
window fromMs/toMs (to exclusive, so two adjacent windows never both
contain the run on their boundary). limit is rejected above the
platform’s page cap, not quietly reduced to it — an oversized limit answers
validation_error rather than a smaller page. Newest first, by the run’s own
durable seq — not job.seq, which is a per-process counter that restarts
with the cloud.
It lags realtime by a moment, deliberately. The cloud records outcomes off
the request path so a history write can never fail an invoke, which means a
client that has just watched a job finish over realtime and reads here
immediately can see that run still running, ended_at and duration_ms
both null. Render the outcome from the realtime job you already have; use
this for what you were not watching.
The org-wide view of the same table — every robot at once, plus the
running/started/failed tile and the bridge-latency series behind the console’s
fleet overview — is developer-only and REST-only (GET /api/org/jobs,
/api/org/jobs/summary, /api/org/latency; see the API
reference). It has no SDK method because it has no
client-facing route: a run row names the actor who invoked it.
Cameras
const cameras = await client.cameras.list(robotId) // -> CameraDescriptor[]
Snapshot — always on, independent of live
const snap = await client.cameras.snapshot(robotId, 'front')
if (snap.image === null) {
// Nothing captured yet (e.g. right after a fresh publish) — a state, not
// an error. Don't treat this as a failure.
} else {
console.log(`${snap.mime}, ${snap.age_ms}ms old`) // an <img> src, a file, whatever you need
}
age_msis the cloud’s own figure — always trust it, never recompute it asDate.now() - snap.timestamp_ms. The cloud is the one clock that knows how long it has actually held the frame; your own clock skew would quietly turn “how old is this?” into a lie.- A snapshot keeps updating on the camera’s configured interval whether or
not anyone is watching live (see Cameras), and
keeps being served — with a growing
age_ms— even while the robot’s bridge is offline. - Polling for “did a newer frame arrive?” without re-downloading the image
every time: use
client.cameras.snapshotMeta(robotId, slug), which returns everythingsnapshotdoes exceptimage.
Live — on demand, refcounted, and it costs you a release
import { Room } from 'livekit-client' // your choice of LiveKit client SDK
const room = new Room()
const session = await client.cameras.live(robotId, 'front')
await room.connect(session.url, session.token)
// ... attach room's video track to your <video> element, however you render it
// When you're done watching — both calls, together:
await room.disconnect()
await session.release()
-
The first
live()call on a camera starts the robot publishing; the last viewer leaving stops it — refcounted in the cloud, not in this SDK. -
Each
sessionreleases only its own hold.session.session_ididentifies this call’s hold, andrelease()sends exactly that id — two independentlive()calls (two tabs, two component instances) get two independent sessions, and releasing one never touches the other. -
session.release()alone does not stop the stream. The cloud treats LiveKit room participation as the source of truth for who is still watching, not the request this method sends — a browser tab that crashes cannot be relied on to tell the cloud anything, but LiveKit’s own server notices a participant leaving without needing its cooperation.release()is a courteous fast path; disconnecting yourRoomis what actually makes it stop. Always call both together, as in the example above — a cleanup that only releases while theRoomstays connected leaks a robot streaming to nobody. -
The same pairing applies to a framework’s unmount hook:
tsimport type { CameraLiveSession } from '@fleetless/sdk' useEffect(() => { let cancelled = false let session: CameraLiveSession | undefined const room = new Room() client.cameras.live(robotId, 'front').then(async (s) => { if (cancelled) { // Unmounted before the request resolved — release immediately, // there is no UI left to show video in. void s.release() return } session = s await room.connect(s.url, s.token) }) return () => { cancelled = true void room.disconnect() void session?.release() } }, [robotId]) -
session.expires_atis a join deadline, not a session backstop. LiveKit checks a token when aRoomconnects, not while it stays connected — so aRoomthat joined beforeexpires_atkeeps streaming right past that timestamp. It only bounds how long an unused token sits around; it does not bound a crashed process or akill -9after joining. Do not design around this field as if it protected you from a forgotten cleanup. -
release()is safe to call more than once and never rejects — it is a courtesy notification, not the thing that actually stops the stream, so it’s safe to use directly as a cleanup callback without wrapping it in atry/catch. -
No video widget, no teleop-style helper here —
live()hands back exactly what a LiveKit client needs (url,token) and stops; rendering the video and choosing a UI library are yours.
Assets
const { assets, urdf, urdf_available } = await client.assets.list(robotId)
The asset store: a robot’s URDF and the meshes it references,
each addressed by uuid, unmodifiable, and reachable only with the same
Authorization header as everything else — no signed URL, no token in the
query string. Syncing a URDF from the connected bridge is a console action,
Owner-tier, and out of scope for this SDK on purpose — this surface is for
reading what has already been synced. See URDF &
Meshes for the full model.
urdf.missing names the package:// URIs the sync could not resolve. Show
the URIs, not just the count. urdf_available is true/false when a
bridge is connected and reporting, null when none is.
const { body, mime } = await client.assets.get(robotId, assetId)
const xml = await client.assets.urdf(robotId) // -> string, ready for URDFLoader.parse()
get answers one asset’s raw bytes; urdf returns XML text with every
package:// mesh URI already rewritten to an absolute Fleetless asset URL,
ready for URDFLoader.parse(xml). Neither absorbs a “nothing yet” state the
way cameras.snapshot absorbs no_snapshot_yet — a missing or too-large
asset is a real failure here, so both throw a FleetlessError
(asset_missing, asset_too_large, capability_required) like every other
route. capability_required is the answer when the end user’s role does not
grant the assets capability; the message names the capability. The cloud
answers the same code for every capability a role can withhold, so one branch
covers all of them.
Reconnecting to a running sync — syncStatus
const status = await client.assets.syncStatus(robotId, syncId)
Starting a sync stays console-only, Owner-tier, on purpose.
syncStatus is a read for a sync already running, addressed by a
sync_id you already have — from list()'s active_sync (null when
nothing is running), or from a busy refusal’s details. Without it, a page
reload during a sync has no way back to it and looks identical to “nothing is
happening” until the sync finishes or times out on its own.
state is 'running' | 'succeeded' | 'failed'; failed names which
references did not make it and why — unresolvable (permanent),
upload_failed/refused (transient, a later sync will retry them), or
too_large (the file exceeds the bridge’s per-file ceiling; carries a
fourth field, details: { limit_bytes, size_bytes }).
The rest of the sync keeps running past a too_large entry. Poll on
whatever interval suits your UI — this is a cheap read, not a subscription.
Rendering a textured robot — prepareUrdfScene
The recommended way to load a robot into three.js/urdf-loader. It resolves
everything three.js fetches to render a robot with credentials attached
— meshes, a <material><texture> in the URDF, and an image a .dae
references internally — with one mechanism: three.js’s
LoadingManager.setURLModifier.
import URDFLoader from 'urdf-loader'
import * as THREE from 'three'
const manager = new THREE.LoadingManager()
const { urdfText, missing, dispose } = await client.assets.prepareUrdfScene(robotId, manager)
if (missing.length > 0) {
// Same entries assets.list()'s urdf.missing reports — `{ uri, element }`,
// where `element` is 'mesh' or 'texture' — a missing texture is not a
// missing mesh. Decide whether to warn, block, or just render what resolved.
// Not thrown.
const meshes = missing.filter((m) => m.element === 'mesh').map((m) => m.uri)
const textures = missing.filter((m) => m.element === 'texture').map((m) => m.uri)
console.warn(`This robot will render incompletely — ${meshes.length} mesh(es), ${textures.length} texture(s): ` +
`${missing.map((m) => m.uri).join(', ')}`)
}
const loader = new URDFLoader(manager)
const robot = loader.parse(urdfText)
scene.add(robot)
// Once the scene has finished loading (or on unmount):
dispose()
What it does: fetches the robot’s asset list, fetches every mesh and texture
asset with the Authorization header into a Blob, maps each asset’s
name to a blob: URL, and installs manager.setURLModifier to resolve
against that map.
Only claims what it owns — everything else passes through unchanged.
manager is frequently your own scene-wide LoadingManager, shared for an
HDRI, an environment map, a font atlas, none of which have anything to do
with this robot. A package:// reference, or a root-relative path whose
leading segment names a ROS package this robot’s assets actually mention, is
this method’s to resolve or refuse. Anything else — a relative path, a
data:/blob: URL — is left completely alone. The one exception is an
absolute http(s) URL, refused regardless of namespace: a hostile URDF
naming an attacker’s host directly must never reach a real network request.
Anything this method owns and cannot resolve resolves to a shared, empty,
page-local blob: URL, never the original string — a loader handed empty
bytes fails to parse them loudly, exactly like a genuinely missing mesh,
rather than quietly rendering wrong or reaching the network. dispose()
revokes every real asset blob: URL this call created.
The browser never fetches an asset directly, for anything this method
owns — every read goes through this SDK with the bearer token first. Fetches
are bounded to options.concurrency (default 6) and scoped to the mesh and
texture assets in the robot’s own list. options.signal cancels the whole
call — pass an AbortController’s signal if the caller might navigate away
or switch robots mid-load; the rejection is always FleetlessError('aborted', ...).
Do not also install createMeshLoader on the same manager — they read
two different URDF sources and combining them by accident produces a scene
where either every mesh or every texture fails. This is enforced: installing
both on the same manager fails loudly, before either touches the network.
The mesh callback — createMeshLoader
Superseded by prepareUrdfScene above for three.js/urdf-loader consumers
— reach for it there first, especially for anything with textures. Kept
because it is renderer-agnostic (any loadMeshCb-shaped consumer, not only
three.js) and mesh-only apps may not need the wider mechanism.
import URDFLoader from 'urdf-loader'
const loader = new URDFLoader()
loader.loadMeshCb = client.assets.createMeshLoader(robotId, loader.defaultMeshLoader.bind(loader))
const xml = await client.assets.urdf(robotId)
const robot = loader.parse(xml)
scene.add(robot)
createMeshLoader(robotId, delegate, options?) returns a function with
loadMeshCb’s own four-argument signature — assign it directly, do not wrap
it. It fetches path with the Authorization header attached, wraps the
bytes in a Blob, and calls delegate with an object URL substituted for
path, so delegate never touches the network.
path is only ever fetched if its origin matches this client’s own
apiUrl. A URDF is ROS graph input, not first-party data — a <mesh filename="https://..."> naming a URL outright is served back unchanged by
the cloud, so this check exists specifically to stop this callback from
fetching an attacker-named URL with the caller’s own bearer token attached.
A mismatched origin is refused before any network call —
onComplete(null, err) with err.code === 'untrusted_absolute_url'.
History
Reads recorded data for a datapoint that was published with retention.enabled: true — plain REST, no realtime channel, the same way cameras.snapshot
is.
Range — relative or absolute, always a string
// Relative to now:
await client.datapoints.history(robotId, 'battery_percentage', { from: 'now-30s' })
await client.datapoints.history(robotId, 'battery_percentage', { from: 'now-1h', to: 'now-30m' })
// Absolute unix milliseconds, for a report over a fixed window:
await client.datapoints.history(robotId, 'battery_percentage', { from: '1700000000000', to: '1700003600000' })
from (required) and to (optional, defaults to now) each accept now-30s
/ now-5m / now-1h, or absolute unix milliseconds — always as a string.
Convert a Date or number yourself, explicitly, at the call site.
Two result shapes, chosen by whether you asked for aggregation
// Raw samples: leave `aggregate` out.
const samples = await client.datapoints.history(robotId, 'battery_percentage', { from: 'now-1h' })
console.log(samples.kind) // 'samples'
for (const s of samples.samples) console.log(s.timestamp_ms, s.value)
if (samples.truncated) {
// `limit` was hit — say so, rather than let a short array look like a quiet period.
}
// Aggregated buckets: pass `aggregate`. `window` and `agg` always travel
// together — the API refuses one without the other.
const buckets = await client.datapoints.history(robotId, 'battery_percentage', {
from: 'now-1h',
aggregate: { window: '1m', agg: 'avg' },
})
console.log(buckets.kind) // 'buckets'
for (const b of buckets.buckets) {
if (b.sample_count === 0) {
// Genuinely empty — nothing was recorded in this bucket. Draw a break in the line here.
} else if (b.value === null) {
// Samples DID land here, none of them numeric. There is data in this
// interval; it just has no height. Do not draw this as a gap.
console.log(b.bucket_start_ms, `${b.sample_count} samples, none numeric`)
} else {
console.log(b.bucket_start_ms, b.value, b.sample_count)
}
}
aggregate.field names a numeric field inside an object value ('pose.x',
the same flat-key convention as actions.invoke’s params) — only needed
when the datapoint’s own value isn’t itself a number.
Buckets align to wall-clock UTC, not to your from. For a clean width
like 10s/1m/1h, bucket_start_ms lands on round clock times, never at
from + n*window. This is deliberate: from is often relative (now-30s),
so a chart re-polling the same query gets a slightly different absolute
from on every poll — wall-clock alignment stays stable across repeated
polls of the same window width, where from-aligned buckets would reshuffle
every boundary each time.
One consequence: the first and last bucket in a response can be partial.
bucket_start_ms is the wall-clock boundary that contains the
earliest/latest in-range sample, and that boundary can fall before from or
extend past to — a low count on an edge bucket is that boundary effect,
not a gap in what was recorded.
Two more things that are easy to assume and wrong:
- A truncated samples response says why.
truncated_byis'limit'when the row cap cut it and'bytes'when the response-size budget did — the remedies differ.nullwhen nothing was cut. value * sample_countis not a sum.valueaggregates only the numeric samples;sample_countcounts all of them.tois exclusive for both raw samples and buckets — always[from, to). A sample landing exactly on an absolutetobelongs to the next window, not this one, for both shapes alike. One boundary rule, defined once, is what lets adjacent windows tile without double-counting a sample.
Errors specific to history
try {
await client.datapoints.history(robotId, 'robot_details', { from: 'now-1h' })
} catch (error) {
if (error instanceof FleetlessError) {
switch (error.code) {
case 'not_recorded':
// The slug is granted, but was never marked `retention.enabled: true`.
break
case 'not_aggregatable':
// `aggregate` was requested on a value that isn't a number, and no
// numeric `aggregate.field` was given.
break
}
}
}
Neither is absorbed into a quiet empty result — that would erase the distinction between “turn recording on” and “look at a different window.”
Errors
Every rejected call throws (or, for a subscription, hands to onError) a
FleetlessError:
import { FleetlessError } from '@fleetless/sdk'
try {
await client.datapoints.get(robotId, 'robot_details')
} catch (error) {
if (error instanceof FleetlessError) {
switch (error.code) {
case 'forbidden':
// your role does not grant this slug
break
case 'token_expired':
case 'token_revoked':
// the session could not be refreshed — send the user back to login
break
default:
console.error(error.code, error.message, error.details)
}
}
}
code is the stable, machine-readable value — branch on it, never on
message, which is for logs and humans only.
These codes never come from the server — they are this SDK’s own:
no_session— a call was made, or a subscription attempted, with nobody logged in. Callauth.login()first.no_websocket— noWebSocketimplementation is available in this environment. Pass one via theWebSocketoption.unparseable_error— a response came back whose body wasn’t shaped like the platform’s error format, so there is no servercodeto relay.command_timeout— aninvoke/cancel/publish/services.callgot no reply within its timeout (default 10s, 30s forservices.call; override with{ timeoutMs }). The server may still be working on it.command_outcome_unknown— worse than a timeout: the realtime connection was replaced by a reconnect before any reply to your command arrived, so a reply can now never come. The SDK never retries automatically (that could invoke an action twice); recover by reading the job —actions.subscribe(robotId, slug, ...)shows you whatever is actually running, regardless of which connection asked for it.unexpected_response— the server said the command succeeded but left out something it is defined to always return. A contract violation the SDK noticed, not a refusal.untrusted_absolute_url—assets.createMeshLoader’s callback received an absolute URL whose origin does not match this client’s ownapiUrl, and refused to fetch it before any network call.no_urdf_synced—assets.prepareUrdfScenefound nokind: 'urdf'row inassets.list(). The fix is to sync a URDF first (console, Owner-tier).aborted—assets.prepareUrdfScene’soptions.signalfired, either before the call started or mid-flight.
rate_limited — surfaced, never retried
Unlike the codes above, rate_limited does come from the server — every
unauthenticated route (login, requestPasswordReset, and the
rest) sits behind a limiter that refuses before it ever checks a password,
so a flood of attempts cannot be turned into CPU spent hashing them.
import type { RateLimitDetails } from '@fleetless/sdk'
try {
await client.auth.login(email, password)
} catch (error) {
if (error instanceof FleetlessError && error.code === 'rate_limited') {
const { retry_after_ms } = error.details as RateLimitDetails
// wait retry_after_ms, then let the USER retry — see below
}
}
- It carries exactly one number,
retry_after_ms— when to come back, and nothing else. No window, no attempt count, no ceiling: none of that changes what an honest caller does, and all of it would help a dishonest one, so the server never sends it. - This SDK never retries a
rate_limitedresponse for you, anywhere, and never will. A client library that retries a rate limit automatically is exactly the client behaviour the limit exists to stop. Back off usingretry_after_msyourself, and only after the person at the keyboard asks for it again — not on a timer that fires unattended.
Configuration
createClient({
apiUrl: string, // e.g. 'https://api.fleetless.dev'
appIdentifier: string, // the app's identifier, from the console
tokenStore?: TokenStore, // default: in-memory
serverKey?: string, // 'flk_...' — alternative to tokenStore
realtimeUrl?: string, // default: derived from apiUrl (ws(s) + /realtime)
fetch?: typeof fetch, // default: the global fetch
WebSocket?: typeof WebSocket, // default: the global WebSocket
})
fetch/WebSocket are injectable for testing and for runtimes without a
global implementation of one of them — the SDK never assumes a browser.
What this SDK deliberately does not do
This SDK’s scope is fixed on purpose, and the exclusions matter as much as the inclusions:
- No teleop helpers. Deadman switches, safety gates and control-loop
cadence are your app’s responsibility —
publishers.publishis a plain method call with no rate governor, no minimum-frequency keepalive, and no automatic stop-on-silence built in. The bridge’s own failsafe message (configured per publisher) is the platform’s safety primitive here, not this SDK — see Publishers. - No video rendering.
cameras.live()hands back a LiveKiturlandtokenand stops there; choosing a player, a UI library, and handling the media connection’s own reconnect/backoff are yours. - No copying a robot’s configuration. Exposing services, defining parameters and setting up cameras happens once per robot in the console; this SDK is a read/command surface for an already-configured robot, not a provisioning tool.
- No signed URLs, ever, anywhere. Every asset and camera read goes
through this SDK with the
Authorizationheader attached — there is deliberately no token-in-query-string escape hatch a browser could cache, log, or leak through aRefererheader.
A reference that quietly omitted these would read as an oversight rather than a decision — if you came here looking for one of them, it is not missing by accident.