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.
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.
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.
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
}
requestLoginCoderesolves on the same policy-allowed202asregister, whether or not the address has an account — nothing to branch on beyond “check your mail”.login,verifyEmail,confirmPasswordResetandacceptInvitationanswer the sameSignInResultasverifyLoginCode— a caller that only ever sawvoidfrom them still compiles, but one that wants to handle a second factor readsstatusrather than assuming a session was stored.completeOidcLoginalways answers{ status: 'signed_in' }: the identity provider owns that sign-in.beginTwoFactorSetup/confirmTwoFactorSetupwork two ways: with achallengefrom atwo_factor_setup_requiredresult, mid sign-in before any session exists, or with nochallengeat 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 andacceptInvitation’spasswordis 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
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,resendVerificationandrequestPasswordResetresolve on202whether 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 sameinvalid_credentialsa wrong password would. changePasswordrevokes every other session,confirmPasswordResetall 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
registeris open is the app’sself_registrationswitch, andregistration_closed,domain_not_allowed,not_found,plan_limit(the organisation is at its plan’s app-user limit) andquota_exceeded(it is at the app-user protection ceiling) come back as themselves.
Federated sign-in
listProvidersdraws the buttons;beginOidcLoginbuilds 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.completeOidcLoginrefuses before any network call withstate_mismatch, an emptyexpectedStateincluded. plan_limitandquota_exceededcome 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 predatesplan_limitreads it asunexpected_response.
MCP consent, when your app serves an MCP endpoint
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.
mcpInteractiontakes an optional bearer andalready_grantedcomes from it alone, so an anonymous read answers a permanentfalsewith a200on it. client_nameis a claim from an unauthenticated registration, andclient_name_verifiedis the literalfalse.scopesis 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
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.
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
client.datapoints.get(robotId, slug) // -> Promise<DatapointValue>
client.datapoints.subscribe(robotId, slug, { onEvent, onError? }) // -> { unsubscribe }
subscribeshares 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
onErroronce and neveronEvent. Fleetless answersforbiddenboth for “your role does not grant this” and for “no such slug”; the SDK does not invent the distinction.
Actions
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`
invokeresolves as soon as the job exists; the result arrives oversubscribe.- 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.
cancelresolves once the robot’s action server accepted it, with the job usually stillrunning: the end arrives oversubscribe. A refusal rejectscancel_rejected,error.details.goalsnaming each goal and its return code;bridge_timeoutmeans 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
invokeon a busy slug rejectsbusy, witherror.details.runningcarrying theJob. paramsis flat, keyed by the exactparameterSpec.namethe developer declared —'target_pose.position.x', never a nested tree, and aparameter_invalidrefusal names thefieldthe same way.options.patienceMsbounds goal acceptance only, ending ingoal_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 goesunknown(bridge_timeoutwhile connected,bridge_disconnectedoffline) and only the bridge’s own word — still running, an outcome, or “I don’t know this job” — resolves it,lostincluded. Omit it for 15s; outside[1s, 120s]it is refused, not clamped.
Services
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
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
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.
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_historycapability, or the call rejectscapability_required; an app user sees only runs their role grants. next_cursor === nullis 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,beforeSeqand the half-open windowfromMs/toMs. An oversizedlimitis 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
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_msis the cloud’s own figure — trust it, never recompute it asDate.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.
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 theRoomtoo.session.expires_atis a join deadline, not a backstop: LiveKit checks a token when aRoomconnects, not while it stays connected.
🗂️ Assets
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
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.
// `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 relativefromshifts on every poll, so wall-clock alignment keeps a re-polled chart stable, at the cost of a partial bucket at each edge. tois exclusive for both shapes — always[from, to), which lets adjacent windows tile without double-counting.value * sample_countis not a sum:valueaggregates only the numeric samples.aggregate.fieldnames a numeric field inside an object value ('pose.x'), needed only when the value is not a number.not_recordedandnot_aggregatableare 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.
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
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 LiveKiturlandtoken, 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
Authorizationheader, so there is no token-in-query-string to cache, log or leak through aReferer. MCP is the one exception: its asset tools mint short-lived bearer-free links — see URDF & meshes.