FleetlessFleetlessdocs
Reference/SDK Reference

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.

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

ts
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

ts
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: false means 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_logout is null exactly when revoked is false — the server never got to answer it, so there is nothing to relay.

  • idp_logout says 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 no end_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_idp and hint_unavailable both mean the IdP session survives and this platform cannot end it — treat them the same in your UI, and do not fold either one into not_federated; that would report a session as fully ended when it isn’t.
    • session_unknown is 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 not not_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. revoked cannot 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 the null above.
  • 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 TokenStore to persist one — localStorage, a cookie, a native keystore:

    ts
    import 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.

ts
// 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. beginHostedLogin returns state/codeVerifier rather 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.
  • completeHostedLogin refuses before any network call (state_mismatch) if state doesn’t match what beginHostedLogin returned 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/message pairs. completeHostedLogin can throw a FleetlessError whose .code is e.g. invalid_grant (an expired or already-used code) 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 completeHostedLogin a 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/confirmRegistration for 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.

ts
// 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')
  • changePassword re-issues your session rather than leaving it alone. It returns fresh tokens, which this method stores for you exactly like login does. 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.
  • requestPasswordReset answers 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 answer token_spent for the same reason as above. Succeeding revokes every other session, exactly like changePassword.
  • requestPasswordReset is behind the rate limiter that guards every unauthenticated route — see Errors for how rate_limited surfaces and why this SDK never retries it for you.
  • changePassword is not available on a serverKey client — it throws immediately, same as login/logout. requestPasswordReset / confirmPasswordReset work 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:

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

ts
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 idsclient_name, app_name and role_name are what was actually on screen when the user consented, so they can recognise what they’re revoking. client_id is what revoke() addresses.
  • client_name is 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 serverKey client — a server key never went through a consent screen, so there is no “self” here to list or revoke.

Datapoints

ts
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.
  • subscribe shares 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 onError once with a FleetlessError; it never calls onEvent. Fleetless deliberately answers forbidden both 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

ts
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()
  • invoke resolves as soon as the job exists — the returned job id is informative, not the result. Feedback, progress and the eventual result arrive separately, over subscribe.
  • 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 invoke while one is already running rejects busy, and error.details.running is the Job that is already in flight — enough to show the user what is happening, or to subscribe and wait for it.
  • cancel addresses 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 on not_found.
  • subscribe is reference-counted per (robotId, slug), exactly like datapoints.subscribe: two callers on the same key share one wire subscription, and unsubscribing one never affects the other.
  • params is flat, keyed by the exact parameterSpec.name the 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 a parameter_invalid refusal names the field it rejected using this same flat key.
  • options.patienceMs bounds 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 via subscribe, you never await it. Omit it for the platform default (currently 15s). Outside the platform’s [1s, 120s] bounds the call is refused with validation_error — this SDK does not clamp your number to fit.

Services

ts
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

ts
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 paramsnot 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

ts
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

ts
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:

ts
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

ts
const cameras = await client.cameras.list(robotId) // -> CameraDescriptor[]

Snapshot — always on, independent of live

ts
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_ms is the cloud’s own figure — always trust it, never recompute it as Date.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 everything snapshot does except image.

Live — on demand, refcounted, and it costs you a release

ts
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 session releases only its own hold. session.session_id identifies this call’s hold, and release() sends exactly that id — two independent live() 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 your Room is what actually makes it stop. Always call both together, as in the example above — a cleanup that only releases while the Room stays connected leaks a robot streaming to nobody.

  • The same pairing applies to a framework’s unmount hook:

    ts
    import 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_at is a join deadline, not a session backstop. LiveKit checks a token when a Room connects, not while it stays connected — so a Room that joined before expires_at keeps streaming right past that timestamp. It only bounds how long an unused token sits around; it does not bound a crashed process or a kill -9 after 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 a try/catch.

  • No video widget, no teleop-style helper herelive() hands back exactly what a LiveKit client needs (url, token) and stops; rendering the video and choosing a UI library are yours.

Assets

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

ts
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

ts
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 whyunresolvable (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.

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

ts
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

ts
// 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

ts
// 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_by is 'limit' when the row cap cut it and 'bytes' when the response-size budget did — the remedies differ. null when nothing was cut.
  • value * sample_count is not a sum. value aggregates only the numeric samples; sample_count counts all of them.
  • to is exclusive for both raw samples and buckets — always [from, to). A sample landing exactly on an absolute to belongs 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

ts
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:

ts
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. Call auth.login() first.
  • no_websocket — no WebSocket implementation is available in this environment. Pass one via the WebSocket option.
  • unparseable_error — a response came back whose body wasn’t shaped like the platform’s error format, so there is no server code to relay.
  • command_timeout — an invoke/cancel/publish/services.call got no reply within its timeout (default 10s, 30s for services.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_urlassets.createMeshLoader’s callback received an absolute URL whose origin does not match this client’s own apiUrl, and refused to fetch it before any network call.
  • no_urdf_syncedassets.prepareUrdfScene found no kind: 'urdf' row in assets.list(). The fix is to sync a URDF first (console, Owner-tier).
  • abortedassets.prepareUrdfScene’s options.signal fired, 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.

ts
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_limited response 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 using retry_after_ms yourself, and only after the person at the keyboard asks for it again — not on a timer that fires unattended.

Configuration

ts
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.publish is 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 LiveKit url and token and 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 Authorization header attached — there is deliberately no token-in-query-string escape hatch a browser could cache, log, or leak through a Referer header.

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.