fleetlessfleetlessdocs
Reference/SDK/Overview

SDK Reference

@fleetless/sdk is the official TypeScript client for Fleetless apps: framework-agnostic, ESM and CJS, unchanged in a browser or in Node. Every symbol and type is on the generated pages — Client, Auth, Robots, Datapoints, Commands, Cameras, Assets — and this page is the narrative.

sh
npm i @fleetless/sdk

⚡ Quick start

You need an app identifier and an app user’s credentials. Your console login is a Fleetless user and will not sign in here.

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) },
})

subscription.unsubscribe()
await client.auth.logout()

A slug names a place a job may be running, not the job itself. Two invokes on one slug are two jobs sharing an address, and subscribe and the plain cancel() work by slug on purpose. See Actions.

🔐 Auth

Fleetless has two identity spaces and one non-person credential (see Manage Users and Roles); this SDK is the client for an app’s own users. Fleetless renders no page for an app user: every screen below is one your app serves, and the app starter is those screens as working code.

App users — tokenStore

login, me and logout are the whole of it.

  • Silent refresh is automatic, and concurrent calls share one refresh. Sessions live in memory unless you implement TokenStore.
  • logout() never rejects and answers nothing. It clears the local store whatever the network did, and revokes the refresh family server-side, but it does not end a session at your identity provider.
  • What logout() does not invalidate. An access token already handed out is a signed JWT verified without a lookup, so one stolen beforehand works until it expires, at most 15 minutes. That same statelessness is what lets a role change bite on the next request.

Password-free sign-in and two-factor

An app can let its users sign in with an emailed code instead of, or beside, a password (signInMethods() says which your app has on), and can ask for a TOTP authenticator on top. Every step that can mint a session answers a SignInResult, not a bare session: { status: 'signed_in' } once it is one, or { status: 'two_factor_required' | 'two_factor_setup_required', challenge } when the user has a confirmed authenticator, or your app requires one and they have none yet.

ts
import { createClient } from '@fleetless/sdk'

const client = createClient({ apiUrl: 'https://api.fleetless.dev', appIdentifier: 'warehouse_dash' })

await client.auth.requestLoginCode('someone@example.com') // always resolves; check your mail
const result = await client.auth.verifyLoginCode('someone@example.com', '123456')

if (result.status === 'signed_in') {
  // a session is already stored
} else if (result.status === 'two_factor_required') {
  await client.auth.verifyTwoFactor({ challenge: result.challenge, code: '654321' })
  // or: await client.auth.verifyTwoFactor({ challenge: result.challenge, recoveryCode: 'abcde-fghij' })
} else {
  // 'two_factor_setup_required' — your app requires a factor this user does not have yet
  const { secret, otpauthUrl } = await client.auth.beginTwoFactorSetup({ challenge: result.challenge })
  // show the QR code (otpauthUrl) or the plain secret, then:
  const { recoveryCodes } = await client.auth.confirmTwoFactorSetup({ challenge: result.challenge, code: '111222' })
  // show the ten recovery codes once — there is no second look
}
  • requestLoginCode resolves on the same policy-allowed 202 as register, whether or not the address has an account — nothing to branch on beyond “check your mail”.
  • login, verifyEmail, confirmPasswordReset and acceptInvitation answer the same SignInResult as verifyLoginCode — a caller that only ever saw void from them still compiles, but one that wants to handle a second factor reads status rather than assuming a session was stored. completeOidcLogin always answers { status: 'signed_in' }: the identity provider owns that sign-in.
  • beginTwoFactorSetup/confirmTwoFactorSetup work two ways: with a challenge from a two_factor_setup_required result, mid sign-in before any session exists, or with no challenge at all from inside an already signed-in session, to turn an optional factor on. disableTwoFactor(code) turns it back off, proving the caller still holds it.
  • register’s and acceptInvitation’s password is now optional. Required while your app’s password method is on, refused while it is off — an email-code-only app never asks for one.

Registration, invitations and recovery

ts
import { createClient } from '@fleetless/sdk'

const client = createClient({ apiUrl: 'https://api.fleetless.dev', appIdentifier: 'warehouse_dash' })

await client.auth.register({
  email: 'someone@example.com',
  password: 'a-password-of-at-least-twelve-characters',
  displayName: 'Sam Rivera', // optional
})

// The pages your app serves at verify_url, invite_url and reset_url.
await client.auth.verifyEmail('the-token-from-the-link') // stores a session
await client.auth.acceptInvitation({ token: 'tok', password: 'a-password-of-twelve' })
await client.auth.confirmPasswordReset('the-token-from-the-link', 'a-new-password')

// The forms behind them.
await client.auth.resendVerification('someone@example.com')
await client.auth.requestPasswordReset('someone@example.com')
await client.auth.changePassword('current-password', 'new-correct-horse-battery')
  • register, resendVerification and requestPasswordReset resolve on 202 whether or not the address exists. Render a sentence about the mailbox, never “welcome, your account is ready”.
  • The account cannot log in until the link is spent: it is created pending_verification, and a login answers the same invalid_credentials a wrong password would.
  • changePassword revokes every other session, confirmPasswordReset all of them.
  • Verification, reset and invitation tokens are single-use and last 24 hours, one hour and seven days. All three answer one refusal, token_spent.
  • Whether register is open is the app’s self_registration switch, and registration_closed, domain_not_allowed, not_found, plan_limit (the organisation is at its plan’s app-user limit) and quota_exceeded (it is at the app-user protection ceiling) come back as themselves.

Federated sign-in

  • listProviders draws the buttons; beginOidcLogin builds the URL and makes no network call. A full page redirect preserves no in-memory state, so it hands back the PKCE verifier and the state for your page to stash.
  • Your app runs its own PKCE against Fleetless, separate from the exchange Fleetless runs against the provider. The one-time code lives 60 seconds.
  • Check the callback for a refusal first: a turned-away sign-in arrives with error, not a code. completeOidcLogin refuses before any network call with state_mismatch, an empty expectedState included.
  • plan_limit and quota_exceeded come only from a sign-in that would create an account: the organisation is at its plan’s app-user limit, or at the protection ceiling above it. An SDK that predates plan_limit reads it as unexpected_response.

mcpInteraction reads it; approveMcpInteraction and denyMcpInteraction decide it, both answering a redirectTo your page assigns. listMcpGrants and revokeMcpGrant are a “connected apps” screen.

  • Sign the person in first. mcpInteraction takes an optional bearer and already_granted comes from it alone, so an anonymous read answers a permanent false with a 200 on it.
  • client_name is a claim from an unauthenticated registration, and client_name_verified is the literal false. scopes is always empty: what a session reaches is the user’s role, re-read per call.
  • The interaction lives ten minutes; anything that is not a live one of this app answers 410. Withdrawing a grant stops that client at its next call.

Server-side callers — serverKey

ts
const client = createClient({ apiUrl, appIdentifier, serverKey: 'flk_...' })
await client.datapoints.get('robot-id', 'battery_percentage') // works immediately

Full app rights, no login step. A serverKey client has no session, so every auth method that needs one throws invalid_option: there is no “self” for a key to sign in as. tokenStore and serverKey are mutually exclusive.

Nine methods still work: me(), listProviders(), signInMethods(), oidcErrorFromCallback() and mcpInteraction() read an app or the key itself; register(), resendVerification(), requestPasswordReset() and requestLoginCode() name their subject as an argument.

verifyEmail(), confirmPasswordReset() and acceptInvitation() stay refused although their routes are public: each answers a session this SDK is contracted to store, so the call would spend the token and leave nobody signed in.

Robots

Which robots may I name, and what may I do on one. Every other namespace takes a robotId from here.

ts
import { createClient } from '@fleetless/sdk'

const client = createClient({ apiUrl: 'https://api.fleetless.dev', appIdentifier: 'warehouse_dash' })
await client.auth.login('user@example.com', 'correct-horse-battery')

const robots = await client.robots.list()
const first = robots[0]
if (first) {
  console.log(first.name, first.bridge_state.online, first.published_version)
  const sheet = await client.robots.describe(first.id)
  for (const e of sheet.exposures) {
    // `input_schema` is a JSON Schema for an action, service or publisher,
    // and `null` for a datapoint or a camera.
    console.log(e.kind, e.slug, e.unit, e.input_schema)
  }
  console.log(sheet.capabilities.action_history, sheet.capabilities.assets)
}

The datasheet is the role, made visible. describe lists every exposure the role grants, with an input_schema built from the configuration’s own min_value/max_value/enum/regex/default rules — enough to draw a form without knowing the robot. It also names the two capabilities that gate features rather than slugs.

Reach is a grant, not an attachment. list answers the robots the app attaches on which the role grants at least one slug or capability, so a fresh app user resolves [] until somebody opens the Roles tab. An unreachable robot answers not_found, never forbidden, which would say it is there.

Datapoints

ts
client.datapoints.get(robotId, slug) // -> Promise<DatapointValue>
client.datapoints.subscribe(robotId, slug, { onEvent, onError? }) // -> { unsubscribe }
  • subscribe shares one WebSocket across every subscription on a client, reconnects with backoff, and re-establishes each subscription once the connection is back. A reconnect refused as expired refreshes silently and retries once.
  • A refused subscription calls onError once and never onEvent. Fleetless answers forbidden both for “your role does not grant this” and for “no such slug”; the SDK does not invent the distinction.

Actions

ts
const job = await client.actions.invoke(robotId, 'dock', { 'target_pose.position.x': 1.0 })
const sub = client.actions.subscribe(robotId, 'dock', {
  onJob(event) { console.log(event.job.state, event.feedback, event.progress) },
  onError(error) { console.error(error.code) },
})

await client.actions.cancel(robotId, 'dock')          // whatever runs there
await client.actions.cancel(robotId, 'dock', job.id)  // that job, or `not_found`
  • invoke resolves as soon as the job exists; the result arrives over subscribe.
  • State is observed by slug, not by job id — including a job somebody else invoked, which is what makes late delivery after a reconnect and two observers of one job work with no special-casing.
  • cancel resolves once the robot’s action server accepted it, with the job usually still running: the end arrives over subscribe. A refusal rejects cancel_rejected, error.details.goals naming each goal and its return code; bridge_timeout means nobody knows whether the cancel arrived.
  • A slug-cancel stops whoever’s job runs there, and the id form never falls back to it. A second invoke on a busy slug rejects busy, with error.details.running carrying the Job.
  • params is flat, keyed by the exact parameterSpec.name the developer declared — 'target_pose.position.x', never a nested tree, and a parameter_invalid refusal names the field the same way.
  • options.patienceMs bounds goal acceptance only, ending in goal_timeout; once accepted, a job runs as long as it runs, on bridge 6.0.0 or later (the minimum every bridge now speaks). Silence from the bridge does not fail it outright: the job goes unknown (bridge_timeout while connected, bridge_disconnected offline) and only the bridge’s own word — still running, an outcome, or “I don’t know this job” — resolves it, lost included. Omit it for 15s; outside [1s, 120s] it is refused, not clamped.

Services

ts
const status = await client.services.call(robotId, 'get-status', { verbose: true }, { patienceMs: 5_000 })

A service call is a job underneath, with an action’s disconnect survival, and that is deliberately invisible: call waits through running and unknown alike and settles once the job reaches a real end. It resolves with the result on succeeded; failed, cancelled and lost reject with a FleetlessError whose code is the job’s error.code, or its state when it has none. No feedback, no progress, no cancel.

options.patienceMs bounds the whole wait here, unlike an action. options.timeoutMs is this SDK’s own bound, 30s by default — the ack plus what is left of that budget, not two windows in a row.

Publishers

ts
await client.publishers.publish(robotId, 'cmd-vel', { 'linear.x': 0.2, 'angular.z': 0 })

message uses the same flat convention as actions.invoke’s params.

Publishing is a plain method call — no more, no less. There is deliberately no deadman switch, rate governor or cadence helper; how often and when to publish is your app’s responsibility. Believing the SDK protects you is more dangerous than knowing it does not.

The platform’s own guarantee is the bridge’s failsafe: a configured timeout_ms and message the bridge sends itself when publishes stop arriving for any reason.

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 job holding it — Fleetless’s own if there is one, otherwise the newest goal started outside Fleetless (origin: 'external') — or, with nothing holding it, the last job that ran there. actions and services need the slug first, which is not always known: a reconnecting bridge can name a job the cloud only adopted, and a configuration change can leave one on a slug the document no longer names. Grant-filtered, newest first, and [] means the robot is doing nothing.

Run history — what has run

One row per run — actor, outcome, duration_ms — kept 90 days and surviving a cloud restart. An external job is never in it: it has no run row, only the live entry jobs.list shows.

ts
import { createClient } from '@fleetless/sdk'

const client = createClient({ apiUrl: 'https://api.fleetless.dev', appIdentifier: 'warehouse_dash' })
const robotId = '4f2c1a90-7b3e-4d51-9c86-0a1b2c3d4e5f'

let cursor: number | undefined = undefined
do {
  const page = await client.jobs.history(robotId, { limit: 50, beforeSeq: cursor })
  for (const run of page.runs) console.log(run.state, run.duration_ms, run.actor.label)
  cursor = page.next_cursor ?? undefined
} while (cursor !== undefined)
  • It needs the action_history capability, or the call rejects capability_required; an app user sees only runs their role grants.
  • next_cursor === null is the only end-of-data signal. That grant filter runs after the page is read and the cursor is computed before it, so an empty page can carry a good non-null cursor.
  • Filters are slug, state, kind, limit, beforeSeq and the half-open window fromMs/toMs. An oversized limit is rejected, not reduced.
  • It lags realtime by a moment: outcomes are recorded off the request path so a history write can never fail an invoke.

The org-wide view of the same table is developer-only and REST-only: a run row names the actor who invoked it. See the API reference.

📷 Cameras

ts
const cameras = await client.cameras.list(robotId)
const snap = await client.cameras.snapshot(robotId, 'front')
// `snap.image === null` is "nothing captured yet" — a state, not an error.
console.log(snap.mime, snap.age_ms) // `snapshotMeta` omits the image
  • age_ms is the cloud’s own figure — trust it, never recompute it as Date.now() - snap.timestamp_ms. The cloud is the one clock that knows how long it held the frame.
  • A snapshot keeps updating on its configured interval whether or not anyone watches live (see Cameras), and is still served, with a growing age_ms, while the bridge is offline.
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)

// Done watching — both calls, together:
await room.disconnect()
await session.release()
  • The first live() starts the robot publishing; the last viewer leaving stops it, refcounted in the cloud. Each session releases only its own hold, so two tabs are two independent sessions.
  • session.release() alone does not stop the stream. The cloud treats LiveKit room participation as the truth about who is watching, because a crashed tab says nothing. Always disconnect the Room too.
  • session.expires_at is a join deadline, not a backstop: LiveKit checks a token when a Room connects, not while it stays connected.

🗂️ Assets

ts
const { assets, urdf, urdf_available } = await client.assets.list(robotId)
const { body, mime } = await client.assets.get(robotId, assetId)
const xml = await client.assets.urdf(robotId) // ready for URDFLoader.parse()

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. Syncing from the bridge is a console action. See URDF & meshes.

urdf.missing names the package:// URIs the sync could not resolve — show them, not just the count. urdf_available is true or false while a bridge is connected and reporting, null when none is.

store reports the robot’s one-gigabyte budget as { bytes, used_bytes }, which counts meshes and textures — the URDF itself is never charged. joint_state_slug is the datapoint the console’s URDF page drives the model from, or null.

assets.syncStatus(robotId, syncId) reads a sync already running, by an id from list()'s active_sync or a busy refusal — without it, a reload mid-sync looks like nothing happening.

Rendering a textured robot — prepareUrdfScene

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) {
  // `{ uri, element }`, where `element` is 'mesh' or 'texture'. Not thrown:
  // decide whether to warn, block, or render what resolved.
  console.warn(missing.map((m) => `${m.element} ${m.uri}`).join(', '))
}

const loader = new URDFLoader(manager)
scene.add(loader.parse(urdfText))

dispose() // once the scene has loaded, or on unmount

It fetches every mesh and texture with the Authorization header into a Blob and installs manager.setURLModifier to resolve against that map, so the browser never fetches an asset directly.

It claims only what it owns. A package:// reference, or a root-relative path naming a ROS package this robot’s assets mention, is its to resolve or refuse; a relative path or a data: URL is left alone. The one exception is an absolute http(s) URL, refused whatever its namespace, because a hostile URDF naming an attacker’s host must never reach a request. What it cannot resolve becomes an empty blob: URL, so a loader fails loudly rather than quietly reaching the network.

A complete component that does this, with cleanup and joint states, is the app starter’s 3D tab: ModelView.client.vue.

Do not also install createMeshLoader — the renderer-agnostic predecessor — on the same manager. They read two different URDF sources, and installing both fails loudly before either touches the network.

History

Reads a datapoint published with retention.enabled: true — plain REST, no realtime channel.

ts
// `from` is required, `to` defaults to now. Both are always strings: either
// `now-30s` / `now-5m` / `now-1h`, or absolute unix milliseconds.
const samples = await client.datapoints.history(robotId, 'battery_percentage', { from: 'now-1h' })

// `window` and `agg` always travel together; one without the other is refused.
const buckets = await client.datapoints.history(robotId, 'battery_percentage', {
  from: 'now-1h',
  aggregate: { window: '1m', agg: 'avg' },
})
// In a bucket, `sample_count === 0` is genuinely empty — draw a break in the
// line. `value === null` with a count means samples landed, none numeric.
  • Buckets align to wall-clock UTC, not to your from. A relative from shifts on every poll, so wall-clock alignment keeps a re-polled chart stable, at the cost of a partial bucket at each edge.
  • to is exclusive for both shapes — always [from, to), which lets adjacent windows tile without double-counting.
  • value * sample_count is not a sum: value aggregates only the numeric samples.
  • aggregate.field names a numeric field inside an object value ('pose.x'), needed only when the value is not a number. not_recorded and not_aggregatable are thrown, never absorbed into an empty result.

⚠️ Errors

Every rejected call throws a FleetlessError, or hands one to onError. Branch on code, not on message.

ts
import { FleetlessError } from '@fleetless/sdk'

try {
  await client.datapoints.get(robotId, 'robot_details')
} catch (error) {
  if (error instanceof FleetlessError) {
    // 'forbidden' — your role does not grant this slug.
    // 'token_expired' / 'token_revoked' — no refresh left; back to login.
    console.error(error.code, error.message, error.details)
  }
}

These codes never come from the server; they are this SDK’s own:

Code Means
no_session a call or subscription with nobody logged in
no_websocket no WebSocket here; pass one via the option
unparseable_error a body not shaped like the error format
invalid_option an SDK-level argument that cannot mean what it looks like: timeoutMs < patienceMs, a non-string jobId on cancel, a concurrency that is not a positive integer, or an auth method needing a session on a serverKey client
command_timeout no reply in time (10s, 30s for services.call)
command_outcome_unknown the connection was replaced before a reply
unexpected_response success, minus something always returned
state_mismatch a wrong, missing or empty state on the callback
untrusted_absolute_url createMeshLoader got a foreign origin
no_urdf_synced prepareUrdfScene found no kind: 'urdf' row
aborted prepareUrdfScene’s options.signal fired

command_outcome_unknown is worse than a timeout: a reply can now never come, and the SDK never retries, because that could invoke an action twice. Read the job with actions.subscribe instead.

rate_limited does come from the server, and this SDK never retries it either. Back off by its one number, retry_after_ms, and only when the person at the keyboard asks again.

⚙️ Configuration

ts
createClient({
  apiUrl,             // 'https://api.fleetless.dev'
  appIdentifier,      // the app's identifier, from the console
  tokenStore,         // optional; default in-memory
  serverKey,          // optional 'flk_...' — alternative to tokenStore
  realtimeUrl,        // optional; default derived from apiUrl
  fetch, WebSocket,   // optional; default the globals
})

fetch and WebSocket are injectable for testing and for runtimes missing one: this SDK never assumes a browser. Every field is typed on the Client page.

What this SDK deliberately does not do

  • No teleop helpers. Deadman switches, safety gates and control-loop cadence are yours; the bridge’s failsafe message is the platform’s safety primitive.
  • No video rendering. cameras.live() hands back a LiveKit url and token, and stops.
  • No copying a robot’s configuration. That happens once, in the console.
  • No signed URLs on this path. Every asset and camera read the SDK makes carries the Authorization header, so there is no token-in-query-string to cache, log or leak through a Referer. MCP is the one exception: its asset tools mint short-lived bearer-free links — see URDF & meshes.