@fleetless/sdk 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,1217 @@
1
+ # @fleetless/sdk
2
+
3
+ The official TypeScript SDK for Fleetless client apps (spec §14.3):
4
+ framework-agnostic, TypeScript-first, ESM+CJS. It runs unchanged in a
5
+ browser and in Node — nothing in it assumes either one exists.
6
+
7
+ W3 scope: **auth** (login, silent refresh, logout) and **datapoints** (get,
8
+ subscribe with automatic reconnect). W4 adds the **command surface** —
9
+ **actions** (invoke, cancel, subscribe to feedback/progress/result),
10
+ **services** (call) and **publishers** (publish) — all correlated by a
11
+ `request_id` over the same realtime channel. W5 adds **cameras** (snapshot
12
+ bytes + age, and a refcounted LiveKit live session) — plain REST, no
13
+ realtime channel involved. W6 adds **history** (recorded samples and
14
+ window-aggregated buckets for a `retention: true` datapoint) — also plain REST.
15
+ W6b adds addressing: a cancel can name the job it means, a camera live
16
+ session can be released on its own, and a call can carry its own patience —
17
+ see each section below for what that changes and what it does not. W6c adds
18
+ self-registration and account recovery — `auth.register` /
19
+ `auth.confirmRegistration`, `auth.changePassword`, `auth.requestPasswordReset` /
20
+ `confirmPasswordReset` — and surfaces `rate_limited` on every unauthenticated
21
+ route, which this SDK never retries automatically. W7a adds
22
+ `assets.prepareUrdfScene`: authenticated loading for **everything** three.js
23
+ fetches to render a robot, not only meshes — a `<texture>` in the URDF and an
24
+ image a `.dae` references internally, both unreachable through
25
+ `createMeshLoader`'s per-loader hook.
26
+ Presence arrives in a later wave, built on the same client core.
27
+
28
+ **A slug names a place a job may be running, not the job itself.** Two
29
+ `invoke`s on the same slug, one after another, are two different jobs that
30
+ happen to share an address — `subscribe` and the plain `cancel()` both work
31
+ *by slug* on purpose (§11.3: state is observed by slug), and that is a
32
+ feature, not an oversight. But it means a caller who means one *specific*
33
+ job — the one whose id they were handed — has to say so explicitly, which is
34
+ what job-id addressing (below) is for. Conflating the two is how a cancel
35
+ sent a moment too late ends up stopping someone else's job instead of doing
36
+ nothing.
37
+
38
+ ## Install
39
+
40
+ ```sh
41
+ npm i @fleetless/sdk
42
+ ```
43
+
44
+ ## Quick start
45
+
46
+ Five minutes from install to a live value, assuming you already have an app
47
+ identifier and an end user's credentials from the [Fleetless
48
+ Console](https://console.fleetless.dev) (or your own dev stack).
49
+
50
+ ```ts
51
+ import { createClient } from '@fleetless/sdk'
52
+
53
+ const client = createClient({
54
+ apiUrl: 'https://api.fleetless.dev',
55
+ appIdentifier: 'fleet-ops', // the app's identifier, shown in the console
56
+ })
57
+
58
+ await client.auth.login('user@example.com', 'correct-horse-battery')
59
+
60
+ // One-shot read.
61
+ const battery = await client.datapoints.get('robot-id', 'battery-percentage')
62
+ console.log(battery.value, battery.timestamp_ms)
63
+
64
+ // Live updates. The current value arrives immediately, then every change.
65
+ const subscription = client.datapoints.subscribe('robot-id', 'battery-percentage', {
66
+ onEvent(event) {
67
+ console.log(event.value, event.timestamp_ms)
68
+ },
69
+ onError(error) {
70
+ console.error(error.code, error.message)
71
+ },
72
+ })
73
+
74
+ // Later:
75
+ subscription.unsubscribe()
76
+ await client.auth.logout()
77
+ ```
78
+
79
+ Against a local dev stack (`./infra/dev.sh` from the umbrella repo), point
80
+ `apiUrl` at `http://localhost:8080` — `ws://localhost:8080/realtime` is
81
+ derived automatically (see [Configuration](#configuration)).
82
+
83
+ ## Auth
84
+
85
+ Fleetless has two kinds of caller (spec §3.4), and the SDK supports both:
86
+
87
+ ### End users — `tokenStore`
88
+
89
+ ```ts
90
+ const client = createClient({ apiUrl, appIdentifier })
91
+ await client.auth.login(email, password) // -> stores a session
92
+ await client.auth.me() // -> ClientIdentity, without decoding a token yourself
93
+ const { revoked } = await client.auth.logout() // revokes server-side, then clears the local store
94
+ ```
95
+
96
+ - **Silent refresh is automatic.** A REST call that meets an expired access
97
+ token refreshes once and retries transparently. Concurrent calls made
98
+ while a refresh is already in flight share that one refresh instead of
99
+ each firing their own.
100
+ - **`logout()` never rejects.** It always clears the local store — a user
101
+ who presses "log out" ends up logged out locally regardless of the
102
+ network — but the server-side revoke can still fail (offline, a dead
103
+ cloud). `revoked: false` means the refresh family may still be alive
104
+ server-side even though this client has forgotten it; a kiosk or shared
105
+ workstation might want to warn the user or retry, everyone else can
106
+ ignore the return value.
107
+ - **What `logout()` does and does not invalidate.** It revokes the whole
108
+ refresh-token family server-side immediately (a stolen refresh token
109
+ stops working) and closes this client's live realtime connection, if it
110
+ has one. It does **not** invalidate the access token already handed out —
111
+ access-token checks are a signed JWT verified without a server-side
112
+ lookup, so logout has nothing on that token to flip. A token stolen
113
+ before logout keeps working on REST, and can still open a *new* realtime
114
+ connection, until it expires on its own — **at most 15 minutes.** That's
115
+ the same stateless-JWT tradeoff that lets a role change or a block take
116
+ effect on the very next request without forcing a fresh token — a
117
+ deliberate boundary, not a bug, but one a kiosk or shared workstation
118
+ needs to plan around.
119
+ - **Token storage is pluggable.** By default, sessions live in memory and
120
+ are lost on reload. Implement `TokenStore` to persist one — localStorage,
121
+ a cookie, a native keystore:
122
+
123
+ ```ts
124
+ import type { TokenStore, StoredSession } from '@fleetless/sdk'
125
+
126
+ const localStorageTokenStore: TokenStore = {
127
+ load: () => {
128
+ const raw = localStorage.getItem('fleetless-session')
129
+ return raw ? (JSON.parse(raw) as StoredSession) : null
130
+ },
131
+ save: (session) => {
132
+ if (session) localStorage.setItem('fleetless-session', JSON.stringify(session))
133
+ else localStorage.removeItem('fleetless-session')
134
+ },
135
+ }
136
+
137
+ const client = createClient({ apiUrl, appIdentifier, tokenStore: localStorageTokenStore })
138
+ ```
139
+
140
+ ### Hosted login (W7b)
141
+
142
+ A third way to authenticate an end user, alongside `login()` above: redirect
143
+ them to a Fleetless-hosted login page (spec §3.4, §17) instead of collecting
144
+ a password in your own app. The page can federate to your own IdP if you've
145
+ configured one for this app, and the flow is Authorization Code + PKCE
146
+ (OAuth 2.1 — no `implicit` grant, no `plain` challenge). It's also MCP's own
147
+ front door (§17), but nothing about using it from this SDK depends on that.
148
+
149
+ **Getting a `clientId`.** Register your app's own OAuth client in the
150
+ console, under **App Settings → OAuth clients** — name it and list the
151
+ redirect URIs it's allowed to use; the panel shows you the `client_id` you
152
+ need below. For scripting (a seed, a gate, CI) the same thing is
153
+ `POST /api/apps/:id/oauth-clients` (developer-authenticated:
154
+ `{ client_name, redirect_uris }`, answers with the `client_id`) — what the
155
+ console panel calls underneath. `clientId` is not your `appIdentifier` —
156
+ they're deliberately different identifiers.
157
+
158
+ ```ts
159
+ // 1. Send the browser there. beginHostedLogin makes no network call — it
160
+ // just builds the URL and generates the values PKCE and CSRF protection
161
+ // need.
162
+ const request = await client.auth.beginHostedLogin({
163
+ clientId: 'oauth-client-id-from-the-console',
164
+ redirectUri: 'https://your-app.example.com/callback',
165
+ })
166
+
167
+ // Persist these two values before navigating away — a full page redirect
168
+ // does not preserve any in-memory state, so this SDK hands them back to you
169
+ // instead of holding them itself. sessionStorage survives the round trip
170
+ // and clears itself when the tab closes.
171
+ sessionStorage.setItem('fleetless_state', request.state)
172
+ sessionStorage.setItem('fleetless_code_verifier', request.codeVerifier)
173
+ window.location.href = request.url
174
+
175
+ // 2. On the redirectUri page, once the hosted flow has finished. CHECK FOR
176
+ // AN ERROR FIRST — the callback can legitimately carry ?error=... instead
177
+ // of a code (RFC 6749 §5.2), and reading params.get('code')! unconditionally
178
+ // throws on that assertion instead of showing the user why they aren't
179
+ // signed in. Not a bug: an account with no membership in this app, or a
180
+ // federated IdP that's down, both land here as an error, not a crash.
181
+ const params = new URLSearchParams(window.location.search)
182
+
183
+ // Read once, clear once, before anything else — these are single-use
184
+ // values for this one attempt regardless of what happens below, and doing
185
+ // this first (rather than after an await that might throw) means the
186
+ // cleanup can't be skipped.
187
+ const expectedState = sessionStorage.getItem('fleetless_state')
188
+ const codeVerifier = sessionStorage.getItem('fleetless_code_verifier')
189
+ sessionStorage.removeItem('fleetless_state')
190
+ sessionStorage.removeItem('fleetless_code_verifier')
191
+
192
+ if (params.has('error')) {
193
+ // params.get('fleetless_code') is set for five W7b-specific outcomes with
194
+ // an actionable meaning (e.g. identity_not_provisioned: "ask an app owner
195
+ // to invite you"; idp_unavailable: "try again shortly") — absent for a
196
+ // plain protocol-shaped refusal (access_denied, ...), where
197
+ // error_description is all there is. See docs/recipes/hosted-login/ for
198
+ // the full table and what to tell the user for each one.
199
+ handleHostedLoginError(params.get('error')!, params.get('error_description'), params.get('fleetless_code'))
200
+ } else if (!expectedState || !codeVerifier) {
201
+ // Nothing was persisted for THIS attempt — a callback landing in a
202
+ // different tab or window, a re-opened redirect URL, or a restored
203
+ // session all read back as null here, and none of them is an attack.
204
+ // completeHostedLogin tells this apart from a real mismatch itself
205
+ // (no_hosted_login_attempt vs state_mismatch) — mirrored here because
206
+ // there's no codeVerifier to even attempt the call with.
207
+ handleHostedLoginError('no_hosted_login_attempt', 'This login was not started in this browser tab.', null)
208
+ } else {
209
+ try {
210
+ await client.auth.completeHostedLogin({
211
+ code: params.get('code')!,
212
+ state: params.get('state')!,
213
+ expectedState,
214
+ codeVerifier,
215
+ clientId: 'oauth-client-id-from-the-console',
216
+ redirectUri: 'https://your-app.example.com/callback',
217
+ })
218
+ // From here on client.auth.me() / logout() / silent refresh behave
219
+ // exactly as they do after login() — completeHostedLogin stores the
220
+ // identical session shape, through the same tokenStore.
221
+ } catch (error) {
222
+ // completeHostedLogin's own refusals (state_mismatch; invalid_grant for
223
+ // an expired or already-used code) surface as a thrown FleetlessError
224
+ // here, not a redirect — the one point in this flow where a thrown
225
+ // error, not a query parameter, is how a refusal reaches your app.
226
+ const code = error instanceof Error && 'code' in error ? String(error.code) : 'unknown'
227
+ handleHostedLoginError(code, error instanceof Error ? error.message : String(error), null)
228
+ }
229
+ }
230
+ ```
231
+
232
+ - **A `fleetless_code`-less `error` is a protocol-shaped outcome** (e.g.
233
+ `access_denied` for "the user declined consent") with no Fleetless-specific
234
+ remedy beyond `error_description`. The five `fleetless_code` values,
235
+ what each means and what your app can tell the user, are documented in
236
+ [`docs/recipes/hosted-login/`](../docs/recipes/hosted-login/README.md) —
237
+ not duplicated here to avoid two tables drifting apart.
238
+
239
+ - **`sessionStorage.getItem(...)!` is the same tell as `params.get('code')!`
240
+ above, and this snippet had both, found by Momus-W7b's review the same
241
+ day the first one was fixed.** A non-null assertion is where a writer
242
+ knows a value can be absent and decides not to say so — reach for a
243
+ presence check (`if (!expectedState || !codeVerifier)` above) instead of
244
+ `!` anywhere a value crossed a boundary this code doesn't control
245
+ (a query string, `sessionStorage`, a thrown error's `.message`), not just
246
+ in the one place a reviewer already looked.
247
+
248
+ - **This SDK never touches `sessionStorage`, or any storage, for you.**
249
+ `beginHostedLogin` returns `state`/`codeVerifier` rather than holding them
250
+ itself, because the redirect back is a fresh page load — nothing this SDK
251
+ keeps in memory survives it. Persist them however fits your app; a popup
252
+ flow that never truly navigates away can just keep them in a variable
253
+ instead.
254
+ - **`completeHostedLogin` refuses before any network call** (`state_mismatch`)
255
+ if `state` doesn't match what `beginHostedLogin` returned for this
256
+ attempt — it will not complete a login your app did not itself start.
257
+ - **Errors from this exchange use OAuth's own vocabulary, not this SDK's
258
+ usual `code`/`message` pairs.** `completeHostedLogin` can throw a
259
+ `FleetlessError` whose `.code` is e.g. `invalid_grant` (an expired or
260
+ already-used `code`) straight from RFC 6749 §5.2 — the exchange happens
261
+ against `/oauth/token`, a standards-compliant endpoint any OAuth client
262
+ can drive, not this SDK's own API.
263
+ - **Never call `completeHostedLogin` a second time for the same redirect
264
+ while a first call is still in flight** — no timeout, framework retry,
265
+ or double-click guard should ever resubmit it (W7c; `completeHostedLogin`
266
+ itself makes exactly one request and has no retry of its own, so this is
267
+ entirely a call-site discipline). A second presentation of the same
268
+ authorization code revokes the whole refresh-token family server-side,
269
+ on purpose — a plain race looks identical to theft on the wire, and the
270
+ platform treats it as theft. What actually happens: one of the two
271
+ requests gets back `200` with a refresh token that is already dead, the
272
+ access token keeps working for the rest of its short TTL, and the
273
+ failure only surfaces at this session's first silent refresh — as
274
+ `token_revoked`, possibly minutes later, with nothing pointing back at
275
+ the retry that caused it. Guard the call site (an in-flight flag,
276
+ disabling the button) — this method cannot protect you from itself being
277
+ called twice.
278
+
279
+ ### Registration and account recovery (W6c)
280
+
281
+ ```ts
282
+ // Self-registration into this app's pool — if the app has it enabled for
283
+ // this address's domain. Sets the password now; does NOT log you in — see
284
+ // below for why. Returns { mail } so you can show "check your email".
285
+ await client.auth.register('user@example.com', 'correct-horse-battery')
286
+ await client.auth.confirmRegistration(tokenFromTheEmailedLink) // -> now logged in
287
+
288
+ // Changing your own password while logged in.
289
+ await client.auth.changePassword('current-password', 'new-correct-horse-battery')
290
+
291
+ // Forgot it entirely: request a link, then use the token it contains.
292
+ await client.auth.requestPasswordReset('user@example.com')
293
+ await client.auth.confirmPasswordReset(tokenFromTheEmailedLink, 'new-correct-horse-battery')
294
+ ```
295
+
296
+ - **`register` does not log you in.** It sets the password and mails a
297
+ confirmation link; `confirmRegistration(token)` is the call that proves
298
+ you own the address and returns a session. This is deliberate, not a
299
+ missing convenience: a domain filter gates *which domains* may register,
300
+ never *whether the caller owns the address* — minting a session straight
301
+ out of `register` would let anybody who knows an allowed domain register
302
+ as somebody else at it and receive whatever role the app assigns, which on
303
+ this platform can mean permission to move a robot. **A deployment with no
304
+ SMTP configured cannot complete a self-registration at all** — unlike an
305
+ invitation, where a developer can hand the link over directly, there is no
306
+ other channel for the confirmation link. If you're testing against a
307
+ mail-less dev cloud, that is why nothing arrives.
308
+ - **`register` does not take a role.** The app's own self-registration
309
+ setting decides it (§3.2: a pool member gets exactly one role per app,
310
+ the app owner's to configure, not a self-registering stranger's to pick).
311
+ It fails the same way whether the app has self-registration turned off or
312
+ the address's domain isn't on the allowed list — **those two cases are
313
+ indistinguishable on purpose**, the same reasoning as the reset request
314
+ below. Don't build a UI that tries to tell them apart. And `register`
315
+ resolves identically for an address that is new and one that already has
316
+ an account — same account-enumeration reasoning as `requestPasswordReset`;
317
+ `mail` in the response describes this deployment's mail configuration, not
318
+ whether the address exists, so it's safe to read either way.
319
+ - **`confirmRegistration`'s token is single-use and expires**, and both
320
+ cases answer `token_spent` — same as `confirmPasswordReset` below.
321
+ - **`changePassword` re-issues your session rather than leaving it alone.**
322
+ It returns fresh tokens, which this method stores for you exactly like
323
+ `login` does — you don't need to do anything with the return value. Every
324
+ *other* session of this identity is revoked; only the one that made this
325
+ call keeps working, and it keeps working because it was just handed new
326
+ credentials, not because the old ones were spared. Make sure your UI
327
+ surfaces the "your other sessions are about to end" consequence *before*
328
+ the user confirms, not after they get a "why was I logged out?" support
329
+ ticket.
330
+ - **`requestPasswordReset` answers the same way for a known and an unknown
331
+ address, always.** This is not a bug and not something you can detect or
332
+ work around: it is the one place in this SDK where telling the two apart
333
+ would let a stranger enumerate accounts by their email address, so the
334
+ server (and this method) simply never says. Do not add a "no such
335
+ account" branch to your UI here — there is nothing in the response for
336
+ it to key off, on purpose. Show a generic "check your email" message
337
+ regardless of the outcome.
338
+ - **`confirmPasswordReset`'s token is single-use and expires**, and both
339
+ cases answer `token_spent` — deliberately one code for both, so a caller
340
+ cannot tell "already used" from "too late" and learn anything about the
341
+ token's history. The recovery is the same either way: request a new link.
342
+ Succeeding revokes every other session, exactly like `changePassword`.
343
+ - **`register` and `requestPasswordReset` are both behind the rate limiter**
344
+ (W6c) that guards every unauthenticated route — see
345
+ [Errors](#errors) below for how `rate_limited` surfaces and why this SDK
346
+ never retries it for you.
347
+ - **`register`, `confirmRegistration` and `changePassword` are not available
348
+ on a `serverKey` client** — they throw immediately, same as
349
+ `login`/`logout`. A server key has no "own password" to change; a
350
+ successful `confirmRegistration`/`changePassword` returns tokens with
351
+ nowhere to store them (no token store on a serverKey client); and
352
+ self-registration is specifically the act of a *user* signing themselves
353
+ up — a backend creating pool members programmatically is a different act
354
+ with a different audit trail. `requestPasswordReset`/`confirmPasswordReset`
355
+ work on either kind of client — neither touches a session or a token
356
+ store, so there is nothing structural to forbid.
357
+ - **These are the end-user routes (`/api/client/password/...`).** The
358
+ developer-facing console has its own equivalents under `/api/auth/`; this
359
+ SDK never speaks to that identity space.
360
+
361
+ ### Server-side callers — `serverKey`
362
+
363
+ For your own backend, automation, or CI — full app rights, no login step:
364
+
365
+ ```ts
366
+ const client = createClient({ apiUrl, appIdentifier, serverKey: 'flk_...' })
367
+ await client.datapoints.get('robot-id', 'battery-percentage') // works immediately
368
+ ```
369
+
370
+ A `serverKey` client has no session to manage: `auth.login`/`auth.logout`
371
+ throw if called. A rejected server key does not refresh — there is nothing
372
+ to refresh into — it just fails.
373
+
374
+ `tokenStore` and `serverKey` are mutually exclusive; pick one per client.
375
+
376
+ ## Datapoints
377
+
378
+ ```ts
379
+ client.datapoints.get(robotId, slug) // -> Promise<DatapointValue>
380
+ client.datapoints.subscribe(robotId, slug, { onEvent, onError? }) // -> { unsubscribe() }
381
+ ```
382
+
383
+ - **Your session survives a laptop lid being closed.** If a client reconnects
384
+ after being offline longer than the access token's lifetime, the
385
+ reconnect's authentication is refused as expired — the SDK refreshes
386
+ silently and retries the connection once before giving up, the same
387
+ courtesy a REST call gets. You do not need to detect this or re-login by
388
+ hand.
389
+ - `subscribe` shares one WebSocket connection across every subscription on a
390
+ client. It reconnects with exponential backoff on a network drop, and
391
+ every active subscription is re-established automatically once the
392
+ connection (and, if the access token has since expired, a silent refresh)
393
+ comes back — you do not need to resubscribe yourself.
394
+ - A subscription refused by the server (role does not grant the slug, an
395
+ unknown slug, ...) calls `onError` once with a `FleetlessError`; it never
396
+ calls `onEvent`. Note that Fleetless deliberately answers `forbidden` both
397
+ for "your role does not grant this" and for "no such slug" — the SDK does
398
+ not invent a distinction the API does not make.
399
+
400
+ ## Actions
401
+
402
+ ```ts
403
+ const job = await client.actions.invoke(robotId, 'dock', { 'target_pose.position.x': 1.0 }) // -> Job, id is informative
404
+ const sub = client.actions.subscribe(robotId, 'dock', {
405
+ onJob(event) {
406
+ console.log(event.job.state, event.feedback, event.progress) // feedback/progress arrive as the action reports them
407
+ if (event.job.state === 'succeeded') console.log(event.job.result)
408
+ },
409
+ onError(error) {
410
+ console.error(error.code, error.message)
411
+ },
412
+ })
413
+
414
+ // The operator's stop button: whatever is running on this slug, stop it.
415
+ const stopped = await client.actions.cancel(robotId, 'dock')
416
+
417
+ // Cancel the job you invoked, specifically — not whatever else might be
418
+ // running there by the time this arrives:
419
+ const cancelled = await client.actions.cancel(robotId, 'dock', job.id)
420
+ // cancelled is the Job that was actually stopped, or null if nothing
421
+ // matched. Naming a job.id that is no longer running rejects `not_found` —
422
+ // it never falls back to stopping whatever *is* running instead, because
423
+ // naming an id already said which one you meant.
424
+
425
+ sub.unsubscribe()
426
+ ```
427
+
428
+ > **Breaking in this release (W6b):** `actions.cancel`'s third argument
429
+ > changed. It used to be `cancel(robotId, slug, options?)`; it is now
430
+ > `cancel(robotId, slug, jobId?, options?)`, and `options` moved to the
431
+ > **fourth** argument. If your code passes an options object third —
432
+ > `cancel(robotId, slug, { timeoutMs: 5000 })` — that object now lands in
433
+ > the `jobId` position instead. In TypeScript this is a compile error. In
434
+ > plain JavaScript it throws `invalid_option` immediately, before anything
435
+ > is sent, naming the mistake in the message — it does not silently send a
436
+ > malformed request. Update the call to `cancel(robotId, slug, undefined,
437
+ > { timeoutMs: 5000 })`, or to `cancel(robotId, slug, job.id)` if you meant
438
+ > to address a specific job all along.
439
+
440
+ - **`invoke` resolves as soon as the job exists** — the returned job id is
441
+ informative (spec §11.3), not the result. Feedback, progress and the
442
+ eventual result arrive separately, over `subscribe`.
443
+ - **State is observed by slug, not by job id.** `subscribe(robotId, slug,
444
+ ...)` shows whatever job is currently running there — including a job
445
+ invoked by a different caller — which is also what makes late delivery
446
+ after a reconnect and two observers watching the same job both work
447
+ without any special-casing on your part. This does **not** change with
448
+ job-id cancel below: a slug is still a place a job may be running, not the
449
+ job itself, and `subscribe` still watches the place.
450
+ - **One job runs per action slug.** A second `invoke` while one is already
451
+ running rejects `busy`, and `error.details.running` is the `Job` that is
452
+ already in flight — enough to show the user what is happening, or to
453
+ `subscribe` and wait for it, without guessing.
454
+ - **`cancel` addresses either a slug or a specific job (W6b).**
455
+ `cancel(robotId, slug)` is the blunt operator form: stop whatever is
456
+ running there, whoever started it. `cancel(robotId, slug, job.id)` is a
457
+ caller cancelling the specific job it invoked — the difference matters
458
+ because a plain slug-cancel that arrives a moment after its own job ended
459
+ will happily stop whoever's job runs next on that slug, and a caller who
460
+ holds a `job.id` usually wants to rule that out. There is no automatic
461
+ fallback from the id form to the slug form on `not_found`: if you want
462
+ "stop whatever's there" behaviour, ask for it explicitly by omitting the id,
463
+ rather than relying on a refused id to degrade into it.
464
+ - `subscribe` is reference-counted per `(robotId, slug)`, exactly like
465
+ `datapoints.subscribe`: two callers on the same key share one wire
466
+ subscription, and unsubscribing one never affects the other.
467
+ - **`params` is flat, keyed by the exact `parameterSpec.name` the developer
468
+ declared** — `'target_pose.position.x'`, not `{ target_pose: { position: {
469
+ x: 1.0 } } }`. Do not nest it yourself: a field a spec did not declare
470
+ cannot be set at all (that is what §4.4 parameter checking *is*), and a
471
+ `parameter_invalid` refusal names the `field` it rejected using this same
472
+ flat key — nesting it client-side would make the refusal point at a path
473
+ you never typed.
474
+ - **`options.patienceMs` bounds goal *acceptance* only (W6b)**, not the whole
475
+ job: `client.actions.invoke(robotId, 'dock', params, { patienceMs: 5_000 })`
476
+ makes the platform give up if no action server accepts the goal within 5s
477
+ (`goal_timeout`), but once accepted the job runs as long as it runs — you
478
+ observe it via `subscribe`, you never await it. Omit it and you get the
479
+ platform default (currently 15s), unchanged from before this option
480
+ existed. Outside the platform's `[1s, 120s]` bounds the call is refused
481
+ with `validation_error` — this SDK does not clamp your number to fit, and
482
+ does not retry with a different one on your behalf. The floor exists
483
+ because a too-short patience does not just fail politely: it made the
484
+ bridge report `goal_timeout` and then issue a corrective cancel against a
485
+ goal an action server accepted a moment later, so an unreachable deadline
486
+ was causing a real cancellation on the robot, repeatably.
487
+
488
+ ## Services
489
+
490
+ ```ts
491
+ const result = await client.services.call(robotId, 'get-status', { verbose: true })
492
+
493
+ // patienceMs bounds the *whole* wait here — unlike an action, a service has
494
+ // no further state to observe once the platform gives up on it:
495
+ const status = await client.services.call(robotId, 'get-status', {}, { patienceMs: 5_000 })
496
+ ```
497
+
498
+ Same flat-key convention for `params` as `actions.invoke`.
499
+
500
+ A service call is a job underneath — the same disconnect survival as an
501
+ action (spec §6.1: "an action goal or a service call") — but that is
502
+ deliberately invisible here: `call` waits for it internally and resolves
503
+ with the result directly, or rejects with the typed error it failed with.
504
+ There is nothing to subscribe to for a service: no feedback, no progress,
505
+ no cancel.
506
+
507
+ `options.patienceMs` (W6b) is the platform's own patience for this call —
508
+ distinct from `options.timeoutMs`, which is this SDK's own local bound on the
509
+ **whole call** (default 30s): the ack that a job was created, plus however
510
+ much of that budget is left for the job to then reach a terminal state — not
511
+ two separate 30-second windows back to back (D10). Setting `timeoutMs: 5000`
512
+ bounds total latency at ~5s, once, not ~5s-then-another-5s if the ack happens
513
+ to arrive late. `patienceMs` and `timeoutMs` still bound different things and
514
+ neither derives from the other when both are set: a short `timeoutMs` with a
515
+ long `patienceMs` gives up locally while the platform keeps trying; a short
516
+ `patienceMs` with a long `timeoutMs` means the platform's own refusal
517
+ (`goal_timeout` or similar) usually arrives well before your local timeout
518
+ would have fired anyway.
519
+
520
+ ## Publishers
521
+
522
+ ```ts
523
+ await client.publishers.publish(robotId, 'cmd-vel', { 'linear.x': 0.2, 'angular.z': 0 })
524
+ ```
525
+
526
+ `message` follows the same flat, `parameterSpec.name`-keyed convention as
527
+ `actions.invoke`'s `params` — **not** a nested ROS message tree. (The
528
+ publisher's *failsafe* message, by contrast, is a whole nested message body —
529
+ but that one is authored once by the developer in the console, never sent by
530
+ a caller.)
531
+
532
+ **Publishing is a plain method call — no more, no less.** There is
533
+ deliberately no deadman switch, rate governor, or "takt" helper in this SDK
534
+ (spec §14.3): the safety pattern for *how often* and *when* to publish is
535
+ your app's responsibility, not something this library does for you. Believing
536
+ the SDK protects you here is more dangerous than knowing it does not.
537
+
538
+ What the platform *does* guarantee (spec §6.4, §7.2) is the bridge's own
539
+ failsafe: the developer configures a `timeout_ms` and a failsafe message when
540
+ exposing the publisher, and if messages stop arriving for any reason —
541
+ including your process crashing — **the bridge itself** publishes that
542
+ failsafe message on the topic. That is the platform's safety primitive, not
543
+ this SDK; build your app as if it is the only thing standing between a
544
+ dropped connection and a robot that keeps moving, because for the interval up
545
+ to `timeout_ms` it is.
546
+
547
+ Publishing is also implicitly exclusive: whoever last published holds the
548
+ publisher until they have been quiet for the configured `quiet_timeout_ms`.
549
+ A `publish` call while someone else holds it rejects `publisher_busy`.
550
+
551
+ ## Jobs
552
+
553
+ ```ts
554
+ const jobs = await client.jobs.list(robotId) // -> Job[]
555
+ ```
556
+
557
+ Every job the platform currently believes this robot has — at most one per
558
+ slug, the same shape `actions.subscribe`/`services.call` observe, just
559
+ gathered across the whole robot instead of one named slug at a time (W7,
560
+ register row 2k).
561
+
562
+ `actions`/`services` require knowing the slug first, which is not always
563
+ true: a bridge reconnecting can name a job the cloud only *adopted*, and a
564
+ configuration change can leave a job on a slug the published document no
565
+ longer contains. Both are jobs no slug can name up front — `jobs.list` is how
566
+ you find them anyway, e.g. to reconcile local UI state against "what is this
567
+ robot actually doing" on load, rather than polling every slug you happen to
568
+ know about.
569
+
570
+ Grant-filtered like everything else: an end user or server key sees only
571
+ jobs on slugs their role grants; a developer session sees every job on the
572
+ robot. `[]` means the robot is doing nothing, not "we did not look" — the
573
+ array is never `null`.
574
+
575
+ **Ordered newest first by `started_at`, `job.seq` as the tiebreaker for two
576
+ jobs minted in the same millisecond** — but for an adopted job, `started_at`
577
+ is adoption time, not when it actually started on the robot, so read this as
578
+ newest-*known*-first: a job the robot has been running for an hour can sit
579
+ above one started a minute ago if the hour-long one was only just adopted.
580
+
581
+ ## Cameras
582
+
583
+ ```ts
584
+ const cameras = await client.cameras.list(robotId) // -> CameraDescriptor[]
585
+ ```
586
+
587
+ ### Snapshot — always on, independent of live
588
+
589
+ ```ts
590
+ const snap = await client.cameras.snapshot(robotId, 'front')
591
+ if (snap.image === null) {
592
+ // Nothing captured yet (e.g. right after a fresh publish) — a state, not
593
+ // an error. Don't treat this as a failure.
594
+ } else {
595
+ console.log(`${snap.mime}, ${snap.age_ms}ms old`) // an <img> src, a file, whatever you need
596
+ }
597
+ ```
598
+
599
+ - **`age_ms` is the cloud's own figure — always trust it, never recompute
600
+ it** as `Date.now() - snap.timestamp_ms`. The cloud is the one clock that
601
+ knows how long it has actually held the frame; your own clock skew would
602
+ quietly turn "how old is this?" into a lie.
603
+ - A snapshot keeps updating on the camera's configured interval **whether or
604
+ not anyone is watching live** (spec §10), and keeps being served — with a
605
+ growing `age_ms` — even while the robot's bridge is offline. It never goes
606
+ stale silently: you always know exactly how old what you have is.
607
+ - Polling for "did a newer frame arrive?" without re-downloading the image
608
+ every time: use `client.cameras.snapshotMeta(robotId, slug)`, which returns
609
+ everything `snapshot` does except `image`.
610
+
611
+ ### Live — on demand, refcounted, and it costs you a release
612
+
613
+ ```ts
614
+ import { Room } from 'livekit-client' // your choice of LiveKit client SDK
615
+
616
+ const room = new Room()
617
+ const session = await client.cameras.live(robotId, 'front')
618
+ await room.connect(session.url, session.token)
619
+ // ... attach room's video track to your <video> element, however you render it
620
+
621
+ // When you're done watching — **both calls, together**:
622
+ await room.disconnect()
623
+ await session.release()
624
+ ```
625
+
626
+ - **The first `live()` call on a camera starts the robot publishing; the
627
+ last viewer leaving stops it** (spec §10) — refcounted in the cloud, not
628
+ in this SDK.
629
+ - **Each `session` releases only its own hold (W6b).** `session.session_id`
630
+ identifies *this* call's hold, and `release()` sends exactly that id.
631
+ Before W6b a `DELETE` carried no id and released **every** hold your
632
+ logged-in identity had on that camera — so two tabs open on the same
633
+ stream were one hold as far as the server could tell, and closing either
634
+ tab stopped the robot for both. The survivor kept rendering a frozen frame
635
+ rather than an ended session, because a LiveKit token is checked when a
636
+ `Room` joins and never again — see `expires_at` below. Two independent
637
+ `live()` calls (two tabs, two component instances, whatever your app's
638
+ shape is) now get two independent sessions, and releasing one never
639
+ touches the other.
640
+ - **`session.release()` alone does not stop the stream.** The cloud treats
641
+ LiveKit room *participation* as the source of truth for who is still
642
+ watching, not the `DELETE` this method sends — a browser tab that
643
+ crashes or is force-closed cannot be relied on to tell the cloud
644
+ anything, but LiveKit's own server notices a participant leaving without
645
+ needing its cooperation. `release()` is a courteous fast path that speeds
646
+ the stream stopping up; disconnecting your `Room` is what actually makes
647
+ it stop. **Always call both together**, as in the example above — a
648
+ cleanup that only releases while the `Room` stays connected leaks a
649
+ robot streaming to nobody.
650
+ - The same pairing applies to a framework's unmount hook:
651
+
652
+ ```ts
653
+ import type { CameraLiveSession } from '@fleetless/sdk'
654
+
655
+ useEffect(() => {
656
+ let cancelled = false
657
+ let session: CameraLiveSession | undefined
658
+ const room = new Room()
659
+
660
+ client.cameras.live(robotId, 'front').then(async (s) => {
661
+ if (cancelled) {
662
+ // Unmounted before the request resolved — release immediately,
663
+ // there is no UI left to show video in.
664
+ void s.release()
665
+ return
666
+ }
667
+ session = s
668
+ await room.connect(s.url, s.token)
669
+ })
670
+
671
+ return () => {
672
+ cancelled = true
673
+ void room.disconnect()
674
+ void session?.release()
675
+ }
676
+ }, [robotId])
677
+ ```
678
+
679
+ - **`session.expires_at` is a join deadline, not a session backstop.**
680
+ LiveKit checks a token when a `Room` connects, not while it stays
681
+ connected — so a `Room` that joined before `expires_at` keeps streaming
682
+ right past that timestamp, untouched. It only bounds how long an *unused*
683
+ token sits around; it does **not** bound a crashed process or a `kill -9`
684
+ after joining. What actually ends an already-joined session is the pair
685
+ of calls above, the cloud noticing (via its own reconciliation) that this
686
+ viewer's room participation ended, or revocation kicking the participant.
687
+ Do not design around this field as if it protected you from a forgotten
688
+ cleanup.
689
+ - `release()` is safe to call more than once and never rejects — it is a
690
+ courtesy notification, not the thing that actually stops the stream (see
691
+ above), so there is nothing more useful a caller could do with a failed
692
+ one. That also makes it safe to use directly as a cleanup callback
693
+ without wrapping it in a `try`/`catch`.
694
+ - **No video widget, no teleop-style helper here** (spec §14.3 keeps this
695
+ SDK thin): `live()` hands back exactly what a LiveKit client needs
696
+ (`url`, `token`) and stops — rendering the video, choosing a UI library,
697
+ and reconnect/backoff for the *media* connection are yours, the same way
698
+ `publishers.publish` hands you a plain method call with no rate governor
699
+ built in.
700
+
701
+ ## Assets
702
+
703
+ ```ts
704
+ const { assets, urdf, urdf_available } = await client.assets.list(robotId)
705
+ ```
706
+
707
+ The asset store (spec §4.6): a robot's URDF and the meshes it references,
708
+ each addressed by uuid, unmodifiable, and reachable only with the same
709
+ `Authorization` header as everything else — no signed URL, no token in the
710
+ query string. Syncing a URDF from the connected bridge is a console action,
711
+ Owner-tier, and out of scope for this SDK on purpose (§4.6 says so
712
+ explicitly) — this surface is for *reading* what has already been synced.
713
+
714
+ `urdf.missing` names the `package://` URIs the sync could not resolve. Show
715
+ the URIs, not just the count — "2 Meshes fehlen" sends a developer looking
716
+ through a workspace by hand; the URIs are what they can act on.
717
+ `urdf_available` is `true`/`false` when a bridge is connected and reporting,
718
+ `null` when none is — "no robot is online to ask" and "the robot has no
719
+ URDF" are different facts a caller needs to tell apart.
720
+
721
+ ```ts
722
+ const { body, mime } = await client.assets.get(robotId, assetId)
723
+ const xml = await client.assets.urdf(robotId) // -> string, ready for URDFLoader.parse()
724
+ ```
725
+
726
+ `get` answers one asset's raw bytes — a mesh, or anything else by id, the
727
+ same way `createMeshLoader` reaches one internally. `urdf` is a special case
728
+ of the same read: XML text with every `package://` mesh URI already
729
+ rewritten to an absolute Fleetless asset URL, decoded because every consumer
730
+ needs it as a string for `URDFLoader.parse(xml)`.
731
+
732
+ Neither absorbs a "nothing yet" state the way `cameras.snapshot` absorbs
733
+ `no_snapshot_yet` — a missing or too-large asset is a real failure here, not
734
+ a state a renderer should quietly treat as empty, so both throw a
735
+ `FleetlessError` (`asset_missing`, `asset_too_large`, `forbidden`) like
736
+ every other route.
737
+
738
+ ### Rendering a textured robot — `prepareUrdfScene`
739
+
740
+ `createMeshLoader` (below) only ever solved meshes, because `loadMeshCb` is
741
+ the one override hook `urdf-loader` has — and three.js's `TextureLoader` has
742
+ no equivalent hook at all. So a `<material><texture>` in the URDF, and an
743
+ image a `.dae` references *internally* (`<init_from>textures/skin.png`,
744
+ resolved by the renderer against wherever it loaded the `.dae` from), were
745
+ both unreachable: rewritten to a real asset URL, fetched by three.js with no
746
+ credential, and refused.
747
+
748
+ `prepareUrdfScene` covers all three cases — mesh, URDF-level texture,
749
+ `.dae`-internal texture — with one mechanism: three.js's
750
+ `LoadingManager.setURLModifier`. Every load the manager oversees, including
751
+ the images `ColladaLoader` fetches on its own account, is routed through it
752
+ first.
753
+
754
+ ```ts
755
+ import URDFLoader from 'urdf-loader'
756
+ import * as THREE from 'three'
757
+
758
+ const manager = new THREE.LoadingManager()
759
+ const { urdfText, missing, dispose } = await client.assets.prepareUrdfScene(robotId, manager)
760
+
761
+ if (missing.length > 0) {
762
+ // Same strings assets.list()'s urdf.missing reports — decide whether to
763
+ // warn, block, or just render what resolved. Not thrown.
764
+ console.warn(`This robot will render incompletely — unresolved: ${missing.join(', ')}`)
765
+ }
766
+
767
+ const loader = new URDFLoader(manager)
768
+ const robot = loader.parse(urdfText)
769
+ scene.add(robot)
770
+
771
+ // Once the scene has finished loading (or on unmount):
772
+ dispose()
773
+ ```
774
+
775
+ What it does: fetches the robot's asset list, fetches every mesh and texture
776
+ asset **with the `Authorization` header** into a `Blob`, maps each asset's
777
+ name to a `blob:` URL, and installs `manager.setURLModifier` to resolve
778
+ against that map.
779
+
780
+ **Only claims what it owns — everything else passes through unchanged.**
781
+ `manager` is frequently your own scene-wide `LoadingManager`, shared for an
782
+ HDRI, an environment map, a font atlas, a ground texture — none of which
783
+ have anything to do with this robot. So the rule is: a `package://`
784
+ reference, or a root-relative path whose leading segment names a ROS package
785
+ this robot's assets (or `missing`) actually mention, is this method's to
786
+ resolve or refuse. Anything else — a relative path, a root-relative path
787
+ under a different namespace, a `data:`/`blob:` URL — is left completely
788
+ alone, exactly as if `prepareUrdfScene` had never been called. **The one
789
+ exception is an absolute `http(s)` URL**, refused regardless of namespace: a
790
+ hostile URDF naming an attacker's host directly (bypassing `package://`
791
+ entirely) must never reach a real network request, so this is the one case
792
+ this method cannot afford to leave ambiguous.
793
+
794
+ Anything this method owns and cannot resolve — a reference the store never
795
+ got, or that attacker-named absolute URL — resolves to a shared, empty,
796
+ page-local `blob:` URL, never the original string. A loader handed empty
797
+ bytes fails to parse them, which is the same loud failure a genuinely
798
+ missing mesh already produces; nothing quietly renders wrong and nothing
799
+ quietly reaches the network. `dispose()` revokes every real asset `blob:`
800
+ URL this call created — not that shared one, which stays valid for the life
801
+ of the page, which is what lets the installed modifier keep refusing owned
802
+ references correctly even after `dispose()`, rather than reverting to raw,
803
+ unrefused passthrough.
804
+
805
+ This closes a real gap, not a hypothetical one: `URDFLoader.resolvePath()`
806
+ passes a non-`package://` filename through unchanged, so a `<mesh
807
+ filename="http://attacker.example/x.stl">` on a robot's ROS graph would
808
+ otherwise reach the browser's own `fetch` verbatim. No credential travels
809
+ with it — three.js does not carry the bearer token — but it is still a
810
+ real, attacker-named network request made by every viewer who renders the
811
+ URDF. A `blob:` URL created in this page cannot resolve to any other origin
812
+ by construction, so refusing this way removes the network request entirely
813
+ rather than merely declining to authenticate it.
814
+
815
+ **`urdf-loader` resolves `package://` itself, before the URL modifier ever
816
+ sees it.** `URDFLoader.parse()`'s own `resolvePath()` rewrites
817
+ `package://pkg/rel` to the root-relative `/pkg/rel` under its default
818
+ `packages: ''`, and *that* rewritten form is what actually reaches the URL
819
+ modifier — not the original string. `prepareUrdfScene` registers both forms
820
+ per asset, so the example above works with a plain `new URDFLoader(manager)`
821
+ and no extra configuration. If you set `loader.packages` to something other
822
+ than the default, results depend on what it produces; setting it to
823
+ `(pkg) => \`package://${pkg}\`` reconstructs the original name and is
824
+ covered too.
825
+
826
+ **A `.dae`'s own internal references get a second-chance, normalized lookup
827
+ too.** three.js builds the request for one by plain string concatenation
828
+ (`this.path + url`, no `..`/`.` collapsing), while `asset.name` carries the
829
+ *normalized* tail — so `../textures/skin.png` or `./textures/skin.png`
830
+ (ordinary exporter output; Blender writes `./` routinely) would otherwise
831
+ miss the direct lookup. Handled the same way for you: nothing to configure.
832
+
833
+ The browser **never** fetches an asset directly, for anything this method
834
+ owns — every read goes through this SDK with the bearer token first, same
835
+ as everything else in this package, and the cloud enforces `assets` exactly
836
+ as it does today. Pre-fetch
837
+ is unavoidable, not a design shortcut: a URL modifier cannot be asynchronous,
838
+ so bytes have to already be in memory before `URDFLoader.parse` runs. Fetches
839
+ are bounded to `options.concurrency` (default 6, must be a positive integer —
840
+ `0` or negative throws `invalid_option` rather than silently fetching nothing
841
+ and returning a scene that renders completely blank with no error to explain
842
+ why) and scoped to the mesh and texture assets in the robot's own list —
843
+ which already *is* what its current URDF references (a re-sync reconciles,
844
+ so a mesh the URDF stops naming stops belonging to the robot).
845
+
846
+ `options.signal` cancels the whole call (register row 244, W7c) — pass an
847
+ `AbortController`'s signal if the caller might navigate away or switch
848
+ robots mid-load. Checked before the first request and forwarded straight to
849
+ every underlying `fetch()`, so an abort tears down connections already in
850
+ flight rather than merely stopping new ones from starting; every `blob:`
851
+ URL already created before the abort is revoked, and the rejection is always
852
+ `FleetlessError('aborted', ...)` regardless of which stage the abort landed
853
+ in or which shape the runtime's own `fetch()` rejects an aborted request
854
+ with.
855
+
856
+ **Do not also install `createMeshLoader` on the same manager.** They read
857
+ two different URDF sources — this method fetches the URDF's *raw* bytes,
858
+ `createMeshLoader` is meant to pair with `urdf()`'s cloud-rewritten text.
859
+ **Not "double-fetches every mesh"** — a first version of this sentence said
860
+ that, and it was traced and found wrong (Momus-W7a review): what actually
861
+ happens is asymmetric breakage, not duplication. Paired with this method's
862
+ raw text, `createMeshLoader` gets urdf-loader's own `resolvePath()` output
863
+ rather than an absolute Fleetless URL and 404s every mesh while textures
864
+ still work; paired with `urdf()`'s rewritten text instead, meshes load and
865
+ every texture 401s, because `createMeshLoader` bypasses this method's URL
866
+ modifier entirely for meshes and there is no equivalent hook for a texture.
867
+ A developer who combined them by accident would have debugged the wrong
868
+ symptom either way — **enforced now, not only documented**: installing both
869
+ on the same manager fails loudly, before either ever touches the network,
870
+ rather than silently producing one of those two broken scenes. Use
871
+ `prepareUrdfScene` for anything rendered with `urdf-loader`/three.js;
872
+ `createMeshLoader` stays useful only for a mesh-only, non-three.js
873
+ `loadMeshCb`-shaped renderer.
874
+
875
+ ### The mesh callback — `createMeshLoader`
876
+
877
+ **Superseded by `prepareUrdfScene` above for three.js/`urdf-loader`
878
+ consumers — reach for it there first, especially for anything with
879
+ textures.** Kept because it is renderer-agnostic (any `loadMeshCb`-shaped
880
+ consumer, not only three.js) and mesh-only apps may not need the wider
881
+ mechanism. Do not install both on one load — see the warning above.
882
+
883
+ An `<img>` tag and the default three.js loaders cannot set an
884
+ `Authorization` header, and the platform deliberately has no signed URLs and
885
+ no token in the query string (see the reasoning in `@fleetless/contracts`'
886
+ `assets.ts`) — so every app would otherwise write this glue itself, and each
887
+ one differently. `createMeshLoader` is that glue, shaped to drop straight
888
+ into [`urdf-loader`](https://github.com/gkjohnson/urdf-loaders)'s own
889
+ `loadMeshCb` hook:
890
+
891
+ ```ts
892
+ import URDFLoader from 'urdf-loader'
893
+
894
+ const loader = new URDFLoader()
895
+ loader.loadMeshCb = client.assets.createMeshLoader(robotId, loader.defaultMeshLoader.bind(loader))
896
+
897
+ const xml = await client.assets.urdf(robotId)
898
+ const robot = loader.parse(xml)
899
+ scene.add(robot)
900
+ ```
901
+
902
+ `createMeshLoader(robotId, delegate, options?)` returns a function with
903
+ `loadMeshCb`'s own four-argument signature
904
+ (`path, manager, material, onComplete`) — assign it directly, do not wrap
905
+ it. `delegate` gets the same four arguments and does the actual
906
+ format-specific parsing (STL/OBJ/DAE/GLTF); in practice it is
907
+ `loader.defaultMeshLoader.bind(loader)`, urdf-loader's own built-in mesh
908
+ parser, not something an app writes from scratch.
909
+
910
+ What this method does that a plain loader can't: it fetches `path` with the
911
+ `Authorization` header attached, wraps the bytes in a `Blob`, and calls
912
+ `delegate` with an **object URL** substituted for `path` — so `delegate`
913
+ never touches the network, and every format-specific parser downstream
914
+ works unmodified against a same-origin `blob:` URL. The object URL is
915
+ revoked the moment `delegate` reports success or failure, or after
916
+ `options.timeoutMs` (default 30s) if it never reports at all — a URDF pulls
917
+ in thirty-odd meshes and this callback can run for days in a long-lived
918
+ dashboard, so a delegate that hangs or never calls back must not leak the
919
+ URL or stall the load forever. A delegate that throws synchronously is
920
+ caught the same way, resolving `onComplete(null, err)` rather than
921
+ propagating into whatever called this callback (typically three.js's own
922
+ URDF traversal).
923
+
924
+ **`path` is only ever fetched if its origin matches this client's own
925
+ `apiUrl`.** A URDF is ROS graph input, not first-party data — anything on a
926
+ robot's ROS graph can publish one — and the cloud only rewrites `package://`
927
+ URIs; a `<mesh filename="https://...">` naming a URL outright is served
928
+ back unchanged. Without this check, a hostile URDF could point a mesh
929
+ anywhere and this callback would fetch it with the caller's own bearer
930
+ token attached. A mismatched origin is refused before any network call —
931
+ `onComplete(null, err)` with `err.code === 'untrusted_absolute_url'` — never
932
+ fetched anonymously either, so a malicious reference fails loudly instead
933
+ of quietly making an unexpected request on the app's behalf.
934
+
935
+ ## History
936
+
937
+ Reads recorded data for a datapoint that was published with `retention: true`
938
+ (spec §8) — plain REST, no realtime channel, the same way `cameras.snapshot`
939
+ is. You do not need to learn the underlying query grammar; `history()` takes
940
+ a plain options object and hands back a typed, discriminated result.
941
+
942
+ ### Range — relative or absolute, always a string
943
+
944
+ ```ts
945
+ // Relative to now:
946
+ await client.datapoints.history(robotId, 'battery-percentage', { from: 'now-30s' })
947
+ await client.datapoints.history(robotId, 'battery-percentage', { from: 'now-1h', to: 'now-30m' })
948
+
949
+ // Absolute unix milliseconds, for a report over a fixed window:
950
+ await client.datapoints.history(robotId, 'battery-percentage', { from: '1700000000000', to: '1700003600000' })
951
+ ```
952
+
953
+ `from` (required) and `to` (optional, defaults to now) each accept `now-30s`
954
+ / `now-5m` / `now-1h`, or absolute unix milliseconds — **always as a
955
+ string**, whichever form you use. The SDK does not accept a `Date` or a
956
+ `number` and stringify it for you: that would be a decision about which of
957
+ the two forms you meant, made silently on your behalf, and the next person
958
+ reading the actual wire traffic would have no way to tell which of you made
959
+ it. Convert yourself, explicitly, at the call site.
960
+
961
+ ### Two result shapes, chosen by whether you asked for aggregation
962
+
963
+ ```ts
964
+ // Raw samples: leave `aggregate` out.
965
+ const samples = await client.datapoints.history(robotId, 'battery-percentage', { from: 'now-1h' })
966
+ console.log(samples.kind) // 'samples'
967
+ for (const s of samples.samples) console.log(s.timestamp_ms, s.value)
968
+ if (samples.truncated) {
969
+ // `limit` was hit — say so, rather than let a short array look like a quiet period.
970
+ }
971
+
972
+ // Aggregated buckets: pass `aggregate`. `window` and `agg` always travel
973
+ // together — the API refuses one without the other, so this SDK models them
974
+ // as one object instead of two independent optional fields you could set
975
+ // only one of.
976
+ const buckets = await client.datapoints.history(robotId, 'battery-percentage', {
977
+ from: 'now-1h',
978
+ aggregate: { window: '1m', agg: 'avg' },
979
+ })
980
+ console.log(buckets.kind) // 'buckets'
981
+ for (const b of buckets.buckets) {
982
+ if (b.sample_count === 0) {
983
+ // Genuinely empty — nothing was recorded in this bucket. Draw a break in
984
+ // the line here.
985
+ } else if (b.value === null) {
986
+ // Samples DID land here, none of them numeric — a string, a bool, a whole
987
+ // message. There is data in this interval; it just has no height. Do not
988
+ // draw this as a gap: that is the same lie as reporting an empty bucket,
989
+ // and it is what this branch exists to prevent.
990
+ console.log(b.bucket_start_ms, `${b.sample_count} samples, none numeric`)
991
+ } else {
992
+ console.log(b.bucket_start_ms, b.value, b.sample_count)
993
+ }
994
+ }
995
+ ```
996
+
997
+ `history` is overloaded on whether `aggregate` is present: passing it gets
998
+ you back a `HistoryBucketsResponse` (`kind: 'buckets'`) at the type level
999
+ already, no narrowing required, because you already know that's what you
1000
+ asked for; leaving it out gets you `HistorySamplesResponse` (`kind:
1001
+ 'samples'`). Both still carry `kind` at runtime, so code that holds the
1002
+ result in a variable typed as the union can branch on it the same way.
1003
+
1004
+ `aggregate.field` names a numeric field inside an object value (`'pose.x'`,
1005
+ the same flat-key convention as `actions.invoke`'s `params`) — only needed
1006
+ when the datapoint's own value isn't itself a number.
1007
+
1008
+ **Buckets align to wall-clock UTC, not to your `from`.** For a clean width
1009
+ like `10s`/`1m`/`1h`, `bucket_start_ms` lands on :00/:10/:20 seconds,
1010
+ top-of-minute, top-of-hour — never at `from`, `from + window`, `from +
1011
+ 2*window`, etc. Strictly the boundaries are counted from a fixed origin
1012
+ (2000-01-03) rather than from midnight, which is the same thing for any width
1013
+ that divides evenly into an hour; a width like `45s` is legal and its
1014
+ boundaries will not look like round clock times. This is deliberate: `from` is often relative (`now-30s`), so
1015
+ a chart re-polling the same query on an interval gets a slightly different
1016
+ absolute `from` on every poll — under `from`-aligned buckets that would
1017
+ reshuffle every boundary each time, drawing visibly jittery bars for no
1018
+ reason in the data. Wall-clock alignment stays stable across repeated polls
1019
+ of the same window width.
1020
+
1021
+ One consequence worth knowing: **the first and last bucket in a response can
1022
+ be partial.** `bucket_start_ms` is the wall-clock boundary that contains the
1023
+ earliest/latest in-range sample, and that boundary can fall before `from` or
1024
+ extend past `to` — but `sample_count`/`value` only ever reflect samples that
1025
+ actually matched the queried range. A low count on an edge bucket is that
1026
+ boundary effect, not a gap in what was recorded.
1027
+
1028
+ Two more things that are easy to assume and wrong:
1029
+
1030
+ - **A truncated samples response says *why*.** `truncated_by` is `'limit'`
1031
+ when the row cap cut it and `'bytes'` when the response-size budget did.
1032
+ The remedies differ: raising `limit` fixes the first and does nothing for
1033
+ the second, which needs a narrower range or a numeric `field` so whole
1034
+ messages are not carried. `null` when nothing was cut.
1035
+ - **`value * sample_count` is not a sum.** `value` aggregates only the numeric
1036
+ samples; `sample_count` counts all of them. The two agree only for a
1037
+ datapoint whose values are always numbers.
1038
+ - **The two paths do not currently share a range convention.** Raw samples are
1039
+ inclusive of `to`; buckets are half-open and exclude it. A sample landing
1040
+ exactly on an absolute `to` therefore appears in one and not the other. This
1041
+ is a known inconsistency, not a design: both become half-open, and this
1042
+ paragraph moves with them.
1043
+
1044
+ ### Errors specific to history
1045
+
1046
+ ```ts
1047
+ try {
1048
+ await client.datapoints.history(robotId, 'robot-details', { from: 'now-1h' })
1049
+ } catch (error) {
1050
+ if (error instanceof FleetlessError) {
1051
+ switch (error.code) {
1052
+ case 'not_recorded':
1053
+ // The slug is granted, but was never marked `retention: true`. An
1054
+ // empty samples array would look identical to "recorded, but
1055
+ // nothing happened in this window" — and the two need opposite
1056
+ // fixes, so the API refuses instead of answering with silence.
1057
+ break
1058
+ case 'not_aggregatable':
1059
+ // `aggregate` was requested on a value that isn't a number, and no
1060
+ // numeric `aggregate.field` was given. Refused rather than coerced
1061
+ // — an average of strings or booleans is a number that means
1062
+ // nothing, and it would chart as confidently as a real one.
1063
+ break
1064
+ }
1065
+ }
1066
+ }
1067
+ ```
1068
+
1069
+ Neither of these is absorbed into a quiet empty result the way
1070
+ `cameras.snapshot` absorbs `no_snapshot_yet` into `image: null` — that
1071
+ absorption is right for a snapshot, where an empty answer is the truth; here
1072
+ it would erase the distinction between "turn recording on" and "look at a
1073
+ different window," so both reach you as a thrown `FleetlessError` instead.
1074
+
1075
+ ## Errors
1076
+
1077
+ Every rejected call throws (or, for a subscription, hands to `onError`) a
1078
+ `FleetlessError`:
1079
+
1080
+ ```ts
1081
+ import { FleetlessError } from '@fleetless/sdk'
1082
+
1083
+ try {
1084
+ await client.datapoints.get(robotId, 'robot-details')
1085
+ } catch (error) {
1086
+ if (error instanceof FleetlessError) {
1087
+ switch (error.code) {
1088
+ case 'forbidden':
1089
+ // your role does not grant this slug
1090
+ break
1091
+ case 'token_expired':
1092
+ case 'token_revoked':
1093
+ // the session could not be refreshed — send the user back to login
1094
+ break
1095
+ default:
1096
+ console.error(error.code, error.message, error.details)
1097
+ }
1098
+ }
1099
+ }
1100
+ ```
1101
+
1102
+ `code` is the stable, machine-readable value from the spec's error culture
1103
+ (§11.5) — branch on it, never on `message`, which is for logs and humans
1104
+ only. It's typed as `ErrorCode | SdkErrorCode | (string & {})`, so a
1105
+ `switch` gets autocomplete on both sets while still accepting a code newer
1106
+ than your installed version.
1107
+
1108
+ **None of these codes ever come from the server** — `SDK_ERROR_CODES` lists
1109
+ them:
1110
+
1111
+ - `no_session` — a call was made, or a subscription attempted, with nobody
1112
+ logged in. Call `auth.login()` first.
1113
+ - `no_websocket` — no `WebSocket` implementation is available in this
1114
+ environment. Pass one via the `WebSocket` option.
1115
+
1116
+ Both fire *before* a request ever reaches the network, and are named
1117
+ apart from the server's own `unauthorized` so you can always tell "the
1118
+ server refused me" from "the SDK refused before asking."
1119
+
1120
+ - `unparseable_error` — the opposite direction: a response *did* come back,
1121
+ its body just wasn't shaped like §11.5's error format, so there is no
1122
+ server `code` to relay.
1123
+ - `command_timeout` — an `invoke`/`cancel`/`publish`/`services.call` got no
1124
+ reply within its timeout (default 10s, 30s for `services.call`; override
1125
+ with `{ timeoutMs }`). The server may still be working on it — this only
1126
+ means the SDK stopped waiting.
1127
+ - `command_outcome_unknown` — worse than a timeout, and told apart from it
1128
+ on purpose: the realtime connection was replaced by a reconnect before any
1129
+ reply to your command arrived, so a reply can now never come — the
1130
+ server, if it answered at all, answered a socket that no longer exists.
1131
+ **The SDK never retries automatically** (that could invoke an action
1132
+ twice); recover by reading the job — `actions.subscribe(robotId, slug,
1133
+ ...)` shows you whatever is actually running, regardless of which
1134
+ connection asked for it.
1135
+ - `unexpected_response` — the server said the command succeeded but left out
1136
+ something it is defined to always return (e.g. no job on a successful
1137
+ `invoke`, or W6c: a REST route documented as always returning a body —
1138
+ `login`, `me`, `register`, all the others except the handful documented as
1139
+ answering `204`/a bodyless `202` — coming back with an empty one). A
1140
+ contract violation the SDK noticed, not a refusal.
1141
+ - `untrusted_absolute_url` (W7) — `assets.createMeshLoader`'s callback
1142
+ received an absolute URL (from a URDF's rewritten mesh URIs) whose origin
1143
+ does not match this client's own `apiUrl`, and refused to fetch it. Fires
1144
+ before the network call, so no `Authorization` header is ever built for
1145
+ it, let alone attached — a URDF is ROS graph input, not first-party data,
1146
+ and this SDK will not send your credential to whatever host it happens to
1147
+ name. Surfaced through `createMeshLoader`'s `onComplete(null, err)`, the
1148
+ same channel as a network failure.
1149
+ - `no_urdf_synced` (W7a) — `assets.prepareUrdfScene` found no `kind: 'urdf'`
1150
+ row in `assets.list()`. Thrown before any asset fetch — the fix is to sync
1151
+ a URDF first (console, Owner-tier), which this error says directly rather
1152
+ than surfacing later as a confusing failure from `URDFLoader.parse`.
1153
+ - `aborted` (W7c) — `assets.prepareUrdfScene`'s `options.signal` fired,
1154
+ either before the call started or mid-flight. One code regardless of when
1155
+ the abort landed, rather than whatever shape the runtime's own `fetch()`
1156
+ happens to reject an aborted request with.
1157
+
1158
+ ### `rate_limited` — surfaced, never retried (W6c)
1159
+
1160
+ Unlike the codes above, `rate_limited` **does** come from the server — every
1161
+ unauthenticated route (`login`, `register`, `requestPasswordReset`, and the
1162
+ rest) sits behind a limiter that refuses before it ever checks a password,
1163
+ so a flood of attempts cannot be turned into CPU spent hashing them.
1164
+
1165
+ ```ts
1166
+ import type { RateLimitDetails } from '@fleetless/sdk'
1167
+
1168
+ try {
1169
+ await client.auth.login(email, password)
1170
+ } catch (error) {
1171
+ if (error instanceof FleetlessError && error.code === 'rate_limited') {
1172
+ const { retry_after_ms } = error.details as RateLimitDetails
1173
+ // wait retry_after_ms, then let the USER retry — see below
1174
+ }
1175
+ }
1176
+ ```
1177
+
1178
+ - **It carries exactly one number, `retry_after_ms`** — the same way `busy`
1179
+ carries `error.details.running` (see [Actions](#actions)): when to come
1180
+ back, and nothing else. No window, no attempt count, no ceiling — none of
1181
+ that changes what an honest caller does, and all of it would help a
1182
+ dishonest one, so the server never sends it and there is nothing more to
1183
+ read off `error.details`.
1184
+ - **This SDK never retries a `rate_limited` response for you, anywhere, and
1185
+ never will.** A client library that retries a rate limit automatically is
1186
+ exactly the client behaviour the limit exists to stop — building that in
1187
+ would turn every app built on this SDK into the attack. Back off using
1188
+ `retry_after_ms` yourself, and only after the person at the keyboard asks
1189
+ for it again — not on a timer that fires unattended.
1190
+
1191
+ ## Configuration
1192
+
1193
+ ```ts
1194
+ createClient({
1195
+ apiUrl: string, // e.g. 'https://api.fleetless.dev'
1196
+ appIdentifier: string, // the app's identifier, from the console
1197
+ tokenStore?: TokenStore, // default: in-memory
1198
+ serverKey?: string, // 'flk_...' — alternative to tokenStore
1199
+ realtimeUrl?: string, // default: derived from apiUrl (ws(s) + /realtime)
1200
+ fetch?: typeof fetch, // default: the global fetch
1201
+ WebSocket?: typeof WebSocket, // default: the global WebSocket
1202
+ })
1203
+ ```
1204
+
1205
+ `fetch`/`WebSocket` are injectable for testing and for runtimes without a
1206
+ global implementation of one of them — the SDK never assumes a browser.
1207
+
1208
+ ## Contributing
1209
+
1210
+ ```sh
1211
+ pnpm i
1212
+ pnpm test # vitest, against a fake fetch and a fake WebSocket — no cloud needed
1213
+ pnpm build # tsup -> dist/ (ESM + CJS + .d.ts)
1214
+ ```
1215
+
1216
+ `@fleetless/contracts` is the source of truth for every wire type — never
1217
+ redefine a shape the SDK reads from or writes to the API; import it.