@pollen-robotics/reachy-mini-sdk 1.8.0 → 1.8.1

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.
Files changed (67) hide show
  1. package/APP_CREATION_GUIDE.md +1795 -0
  2. package/README.md +2 -5
  3. package/dist/animation/distance.d.ts +87 -0
  4. package/dist/animation/distance.d.ts.map +1 -0
  5. package/dist/animation/distance.js +140 -0
  6. package/dist/animation/distance.js.map +1 -0
  7. package/dist/animation/index.d.ts +34 -0
  8. package/dist/animation/index.d.ts.map +1 -0
  9. package/dist/animation/index.js +33 -0
  10. package/dist/animation/index.js.map +1 -0
  11. package/dist/animation/pose.d.ts +40 -0
  12. package/dist/animation/pose.d.ts.map +1 -0
  13. package/dist/animation/pose.js +15 -0
  14. package/dist/animation/pose.js.map +1 -0
  15. package/dist/animation/presets.d.ts +85 -0
  16. package/dist/animation/presets.d.ts.map +1 -0
  17. package/dist/animation/presets.js +82 -0
  18. package/dist/animation/presets.js.map +1 -0
  19. package/dist/animation/safe-return.d.ts +90 -0
  20. package/dist/animation/safe-return.d.ts.map +1 -0
  21. package/dist/animation/safe-return.js +123 -0
  22. package/dist/animation/safe-return.js.map +1 -0
  23. package/dist/lib/reachy-mini.d.ts +99 -0
  24. package/dist/lib/reachy-mini.d.ts.map +1 -1
  25. package/dist/lib/reachy-mini.js +314 -9
  26. package/dist/lib/reachy-mini.js.map +1 -1
  27. package/dist/lib/types.d.ts +27 -0
  28. package/dist/lib/types.d.ts.map +1 -1
  29. package/host/README.md +14 -12
  30. package/host/dist/ReachyHost.d.ts +2 -1
  31. package/host/dist/ReachyHost.d.ts.map +1 -1
  32. package/host/dist/chunks/index-lMs2sCXG.js +439 -0
  33. package/host/dist/chunks/{mountHost-D7cgkU9Q.js → mountHost-DjE5zE9R.js} +11142 -11014
  34. package/host/dist/chunks/{reachy-mini-B1hlBmDQ.js → reachy-mini-GHNS4GSp.js} +494 -307
  35. package/host/dist/components/PickerView.d.ts.map +1 -1
  36. package/host/dist/components/PostOAuthSplash.d.ts +25 -0
  37. package/host/dist/components/PostOAuthSplash.d.ts.map +1 -0
  38. package/host/dist/components/ReachyHostShell.d.ts.map +1 -1
  39. package/host/dist/components/TopBar.d.ts +8 -1
  40. package/host/dist/components/TopBar.d.ts.map +1 -1
  41. package/host/dist/components/WelcomeBackOverlay.d.ts.map +1 -1
  42. package/host/dist/embed/index.d.ts +3 -2
  43. package/host/dist/embed/index.d.ts.map +1 -1
  44. package/host/dist/entry/auto.js +2 -2
  45. package/host/dist/entry/embed.js +2 -2
  46. package/host/dist/hooks/useHostBridge.d.ts +6 -0
  47. package/host/dist/hooks/useHostBridge.d.ts.map +1 -1
  48. package/host/dist/hooks/useSdk.d.ts.map +1 -1
  49. package/host/dist/index.js +3 -3
  50. package/host/dist/lib/protocol.d.ts +17 -3
  51. package/host/dist/lib/protocol.d.ts.map +1 -1
  52. package/host/dist/lib/settings.d.ts +3 -2
  53. package/host/dist/lib/settings.d.ts.map +1 -1
  54. package/host/dist/mountHost.d.ts.map +1 -1
  55. package/host/dist/ui/design/IdentityChipBar.d.ts +46 -0
  56. package/host/dist/ui/design/IdentityChipBar.d.ts.map +1 -0
  57. package/host/dist/ui/design/LinkQualityBars.d.ts +32 -0
  58. package/host/dist/ui/design/LinkQualityBars.d.ts.map +1 -0
  59. package/host/dist/ui/design/MetaPill.d.ts +41 -0
  60. package/host/dist/ui/design/MetaPill.d.ts.map +1 -0
  61. package/host/dist/ui/design/TransportChip.d.ts +39 -0
  62. package/host/dist/ui/design/TransportChip.d.ts.map +1 -0
  63. package/package.json +7 -4
  64. package/CHANGELOG.md +0 -188
  65. package/host/APP_AUTHOR_GUIDE.md +0 -646
  66. package/host/SPEC.md +0 -618
  67. package/host/dist/chunks/index-CyLPysJS.js +0 -400
@@ -0,0 +1,1795 @@
1
+ # App Creation Guide
2
+
3
+ > ### Use `@pollen-robotics/reachy-mini-sdk@1.8.0` today
4
+ >
5
+ > Every reference app pins the same SDK release: **`1.8.0`** (the
6
+ > stable npm release; npm versions are immutable, so this is fully
7
+ > reproducible — no commit suffix needed). The host shell, the embed
8
+ > adapter, the SDK runtime, and the daemon on the robot are validated
9
+ > end-to-end against that release. Mixing versions across these
10
+ > boundaries causes silent protocol drift - see
11
+ > [§10 SDK version pinning](#10-sdk-version-pinning).
12
+ >
13
+ > Add this to your `package.json`:
14
+ >
15
+ > ```json
16
+ > { "dependencies": { "@pollen-robotics/reachy-mini-sdk": "1.8.0" } }
17
+ > ```
18
+
19
+ **This is the single source of truth for building a Reachy Mini JS
20
+ app.** [`../AGENTS.md`](../AGENTS.md) at the repo root points here;
21
+ the JS SDK reference at [`../docs/source/SDK/javascript-sdk.md`](../docs/source/SDK/javascript-sdk.md)
22
+ documents the runtime API (motion, events, daemon-side playback) once
23
+ `connectToHost()` resolves. Everything else - scaffolding, deploy,
24
+ host shell, gotchas - lives here.
25
+
26
+ How to ship a Hugging Face Space that runs on a Reachy Mini robot,
27
+ using `@pollen-robotics/reachy-mini-sdk/host` for OAuth, robot picking, session
28
+ lifecycle, and a top bar - so your code stays focused on **your
29
+ app's UI** and nothing else.
30
+
31
+ | Doc | Purpose | Audience |
32
+ |------------|--------------------------------------|-----------------------------------|
33
+ | **This** | **Single source of truth for app authors** (scaffold, deploy, host contract, invariants) | **You, building a new app** |
34
+ | [`../docs/source/SDK/javascript-sdk.md`](../docs/source/SDK/javascript-sdk.md) | Runtime SDK API reference (methods, events, state machine, daemon-side playback) | App authors after `connectToHost()` resolves |
35
+ | [`host/README.md`](./host/README.md) | One-page tour of the `@pollen-robotics/reachy-mini-sdk/host` package layout | First-time visitors to the host source |
36
+
37
+ ## Table of contents
38
+
39
+ 1. [What you get for free](#1-what-you-get-for-free)
40
+ 2. [Quickstart: clone a reference app](#2-quickstart-clone-a-reference-app)
41
+ 3. [The app-author contract](#3-the-app-author-contract)
42
+ 4. [`mountHost()` API](#4-mounthost-api)
43
+ 5. [`connectToHost()` API](#5-connecttohost-api)
44
+ 6. [Visual identity: icon, name, emoji](#6-visual-identity-icon-name-emoji)
45
+ 7. [Receiving an external config (deep-link, mobile)](#7-receiving-an-external-config)
46
+ 8. [Cleaning up on leave](#8-cleaning-up-on-leave)
47
+ 9. [Local dev: HF token vs OAuth redirect](#9-local-dev)
48
+ 10. [SDK version pinning](#10-sdk-version-pinning)
49
+ 11. [Deploying to Hugging Face Spaces](#11-deploying-to-hugging-face-spaces)
50
+ 1. [Required frontmatter](#111-required-frontmatter)
51
+ 2. [Build and push](#112-build-and-push)
52
+ 3. [What HF does on build + serve](#113-what-hf-does-on-build--serve)
53
+ 4. [Cache busting](#114-cache-busting)
54
+ 5. [Alternative: bare HTML + CDN, no bundler](#115-alternative-bare-html--cdn-no-bundler)
55
+ 12. [FAQ and common pitfalls](#12-faq-and-common-pitfalls)
56
+ 13. [Architecture reference (host ↔ embed contract)](#13-architecture-reference-host--embed-contract)
57
+ 1. [Roles: app · host · embed](#131-roles-app--host--embed)
58
+ 2. [App identity & official apps](#132-app-identity--official-apps)
59
+ 3. [Two boot modes, one URL surface](#133-two-boot-modes-one-url-surface)
60
+ 4. [Host phase state machine + handoff sequence](#134-host-phase-state-machine--handoff-sequence)
61
+ 5. [Engineering invariants](#135-engineering-invariants)
62
+ 6. [Protocol v1 messages](#136-protocol-v1-messages)
63
+ 7. [Non-goals](#137-non-goals)
64
+ 8. [Threat model](#138-threat-model)
65
+ 14. [Robotics best practices](#14-robotics-best-practices) - `@pollen-robotics/reachy-mini-sdk/animation`
66
+ 1. [Pose types: `Pose` vs `PartialPose`](#141-pose-types-pose-vs-partialpose)
67
+ 2. [Distance & scaled duration](#142-distance--scaled-duration)
68
+ 3. [Safe return to home pose (`safelyReturnToPose`)](#143-safe-return-to-home-pose-safelyreturntopose)
69
+ 4. [Standalone exit hooks (`installShutdownHandler`)](#144-standalone-exit-hooks-installshutdownhandler)
70
+ 5. [Daemon parity warning](#145-daemon-parity-warning)
71
+ 6. [Anti-patterns](#146-anti-patterns)
72
+
73
+ ---
74
+
75
+ ## 1. What you get for free
76
+
77
+ By integrating `@pollen-robotics/reachy-mini-sdk/host`, your app **does not have to
78
+ write**:
79
+
80
+ - **Hugging Face OAuth**: sign-in screen, redirect handling,
81
+ token storage, sign-out menu - all in the host shell.
82
+ - **Robot discovery and picker**: list of online robots, live
83
+ online/offline/busy updates, click-to-pick.
84
+ - **Connection overlay**: the 3-step "Connecting / Starting
85
+ session / Waking up" view rendered on top of your iframe.
86
+ - **End-session button and tear-down**: a "Back to apps" affordance
87
+ in the top bar that cleanly closes the WebRTC session.
88
+ - **Dark / light theme switching**: respected from
89
+ `prefers-color-scheme` or HF settings, propagated to your iframe.
90
+
91
+ What **you** write:
92
+
93
+ - `index.html` (~30 lines of theme bootstrap + OAuth placeholders).
94
+ - `src/dispatch.ts` (~20 lines, picks shell vs embed mode and self-
95
+ assigns `window.ReachyMini`).
96
+ - `src/embed.{ts,tsx}` - **your app's actual code**. You receive a
97
+ live `ReachyMini` SDK handle and render whatever you want.
98
+ - `public/icon.svg` - one SVG that powers the top bar, the mobile
99
+ catalog tile, and the favicon.
100
+
101
+ You can use **any framework** inside your `embed` entry: React,
102
+ Svelte, Vue, vanilla TS. The host runs outside your iframe and
103
+ doesn't care.
104
+
105
+ ---
106
+
107
+ ## 2. Quickstart: clone a reference app
108
+
109
+ Pick the reference closest to your needs and clone its repo from Hugging Face:
110
+
111
+ | Reference app | Stack | Use it for |
112
+ |-------------------------------------|-----------------------------|---------------------------------------------|
113
+ | [`pollen-robotics/reachy_mini_minimal_conversation`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_minimal_conversation) | **Vanilla TS + Vite** | Smallest bundled runtime, no framework |
114
+ | [`pollen-robotics/reachy_mini_emotions`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_emotions) | React 19 + MUI 7 + Vite | UI-rich app with rich components / theming |
115
+ | [`pollen-robotics/reachy_mini_telepresence`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_telepresence) | React 19 + MUI 7 + Vite | App with camera / media streams |
116
+
117
+ ```bash
118
+ # Example: start from the vanilla TS template
119
+ git clone https://huggingface.co/spaces/pollen-robotics/reachy_mini_minimal_conversation my_new_app
120
+ cd my_new_app
121
+ # Edit package.json `name`, README frontmatter (`title`, `emoji`,
122
+ # `short_description`), public/icon.svg, and src/embed.ts to your app.
123
+ npm install
124
+ npm run dev
125
+ # → http://localhost:5173
126
+ ```
127
+
128
+ All three reference apps pin `@pollen-robotics/reachy-mini-sdk` to
129
+ the same RC version in their `package.json` - see [§10 SDK version pinning](#10-sdk-version-pinning).
130
+
131
+ > **Prefer a no-build path?** You can also ship the app as a single
132
+ > `index.html` with the SDK loaded from a CDN. No `package.json`, no
133
+ > build step. See [§11.5](#115-alternative-bare-html--cdn-no-bundler).
134
+
135
+ ---
136
+
137
+ ## 3. The app-author contract
138
+
139
+ Exactly **three source files** are the entire integration surface,
140
+ plus `public/icon.svg`, a `package.json` (for the Vite build + the
141
+ SDK npm dep), and the Space `README.md` frontmatter
142
+ (see [§11 Deploying](#11-deploying-to-hugging-face-spaces)).
143
+
144
+ Don't add mandatory files outside this list - keep apps
145
+ interchangeable.
146
+
147
+ ### 3.1 `index.html`
148
+
149
+ The reference apps' `index.html` no longer loads the SDK from a CDN
150
+ `<script>` tag - `src/dispatch.ts` imports the SDK from npm and self-
151
+ assigns `window.ReachyMini` before any host code runs. This removes
152
+ the jsDelivr branch / cache-purge friction we used to live with.
153
+
154
+ ```html
155
+ <!doctype html>
156
+ <html lang="en">
157
+ <head>
158
+ <meta charset="UTF-8" />
159
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
160
+ <title>My App</title>
161
+ <link rel="icon" href="/icon.svg" type="image/svg+xml" />
162
+
163
+ <!-- Theme bootstrap: paints the right palette BEFORE CSS lands so
164
+ there's no flash. Priority: `?theme=dark|light` query param
165
+ (set by the host when iframing us), then `prefers-color-scheme`. -->
166
+ <script>
167
+ (function () {
168
+ try {
169
+ var params = new URLSearchParams(window.location.search);
170
+ var raw = params.get("theme");
171
+ var mode;
172
+ if (raw === "dark" || raw === "light") {
173
+ mode = raw;
174
+ } else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches === false) {
175
+ mode = "light";
176
+ } else {
177
+ mode = "dark";
178
+ }
179
+ document.documentElement.setAttribute("data-theme", mode);
180
+ } catch (_) {
181
+ document.documentElement.setAttribute("data-theme", "dark");
182
+ }
183
+ })();
184
+ </script>
185
+
186
+ <!-- HF Spaces helper variables.
187
+ - In production, HF substitutes these placeholders at file-
188
+ serve time when `hf_oauth: true` is set on the Space.
189
+ - In `npm run dev`, placeholders stay untouched; we detect
190
+ that and drop `window.huggingface` so the SDK falls back to
191
+ the clientId you supplied via `mountHost({ clientId })`. -->
192
+ <script>
193
+ (function () {
194
+ var clientId = "__OAUTH_CLIENT_ID__";
195
+ var scopes = "__OAUTH_SCOPES__";
196
+ var spaceHost = "__SPACE_HOST__";
197
+ var spaceId = "__SPACE_ID__";
198
+ var looksSubstituted = clientId && clientId.indexOf("__") !== 0;
199
+ if (looksSubstituted) {
200
+ window.huggingface = window.huggingface || {};
201
+ window.huggingface.variables = {
202
+ OAUTH_CLIENT_ID: clientId,
203
+ OAUTH_SCOPES: scopes && scopes.indexOf("__") !== 0 ? scopes : "openid profile",
204
+ SPACE_HOST: spaceHost && spaceHost.indexOf("__") !== 0 ? spaceHost : "",
205
+ SPACE_ID: spaceId && spaceId.indexOf("__") !== 0 ? spaceId : "",
206
+ };
207
+ }
208
+ })();
209
+ </script>
210
+ </head>
211
+ <body>
212
+ <div id="root"></div>
213
+ <!-- Single dispatcher. Picks the host shell (standalone visit) or
214
+ the embedded app (host's iframe) based on `?embedded=1`. -->
215
+ <script type="module" src="/src/dispatch.ts"></script>
216
+ </body>
217
+ </html>
218
+ ```
219
+
220
+ ### 3.2 `src/dispatch.ts`
221
+
222
+ The dispatcher does three things, in order:
223
+
224
+ 1. Imports `ReachyMini` from npm and self-assigns `window.ReachyMini` -
225
+ both the host shell and the embed wait for that global.
226
+ 2. Dispatches a `reachymini:ready` event so the embed's wait loop
227
+ takes its fast path.
228
+ 3. Branches on `?embedded=1` (legacy alias `?embed=1` also accepted):
229
+ embed bundle vs host shell bundle.
230
+
231
+ ```ts
232
+ import { ReachyMini } from "@pollen-robotics/reachy-mini-sdk";
233
+
234
+ (window as unknown as { ReachyMini: typeof ReachyMini }).ReachyMini = ReachyMini;
235
+ window.dispatchEvent(new Event("reachymini:ready"));
236
+
237
+ const params = new URLSearchParams(window.location.search);
238
+ const isEmbed =
239
+ params.get("embedded") === "1" || params.get("embed") === "1";
240
+
241
+ if (isEmbed) {
242
+ void import("./embed");
243
+ } else {
244
+ void import("@pollen-robotics/reachy-mini-sdk/host/auto").then(({ mountHost }) => {
245
+ mountHost({
246
+ appName: "My App",
247
+ appIconUrl: "/icon.svg",
248
+ appEmoji: "🤖",
249
+ enableMicrophone: false,
250
+ // Optional: forward dev shortcuts from .env.local. See §9.
251
+ devToken:
252
+ import.meta.env.VITE_HF_TOKEN && import.meta.env.VITE_HF_USERNAME
253
+ ? {
254
+ token: import.meta.env.VITE_HF_TOKEN as string,
255
+ userName: import.meta.env.VITE_HF_USERNAME as string,
256
+ }
257
+ : undefined,
258
+ clientId: import.meta.env.VITE_HF_OAUTH_CLIENT_ID as string | undefined,
259
+ });
260
+ });
261
+ }
262
+ ```
263
+
264
+ ### 3.3 `src/embed.ts` (vanilla example)
265
+
266
+ ```ts
267
+ import { connectToHost } from '@pollen-robotics/reachy-mini-sdk/host/embed';
268
+
269
+ interface MyConfig {
270
+ startingEmotion?: string;
271
+ }
272
+
273
+ async function main() {
274
+ const handle = await connectToHost<MyConfig>();
275
+ const { reachy, theme, config, onLeave } = handle;
276
+
277
+ document.body.innerHTML = '<h1>Connected!</h1>';
278
+
279
+ reachy.setHeadRpyDeg(0, 10, 0);
280
+
281
+ onLeave(async () => {
282
+ document.body.innerHTML = '<p>Bye!</p>';
283
+ });
284
+ }
285
+
286
+ void main().catch((err) => {
287
+ console.error('[my-app] boot failed', err);
288
+ window.parent.postMessage(
289
+ { source: 'reachy-mini', type: 'embed:error', version: 1,
290
+ message: String(err), fatal: true },
291
+ window.location.origin,
292
+ );
293
+ });
294
+ ```
295
+
296
+ ### 3.4 `public/icon.svg`
297
+
298
+ A single 24×24-friendly SVG at `public/icon.svg` powers **all three**
299
+ identity surfaces (host top bar, mobile catalog tile, browser
300
+ favicon). See [§6 Visual identity](#6-visual-identity-icon-name-emoji)
301
+ for the resolution path and PNG fallback notes.
302
+
303
+ ### 3.5 `package.json`
304
+
305
+ `npm`-managed; only mandatory bits are the Vite build script and the
306
+ SDK pin. See [§10 SDK version pinning](#10-sdk-version-pinning) for
307
+ the exact version string.
308
+
309
+ ---
310
+
311
+ ## 4. `mountHost()` API
312
+
313
+ Called once from `dispatch.ts` when the URL is **not** in embed mode.
314
+ Renders the shell into `#root`. The shell's visual theme (MUI
315
+ light/dark) is bundled and not overridable - the host owns its
316
+ look, apps own theirs inside the iframe.
317
+
318
+ ```ts
319
+ import { mountHost } from '@pollen-robotics/reachy-mini-sdk/host/auto';
320
+
321
+ mountHost({
322
+ appName: 'My App', // REQUIRED: passed to the SDK + shown in top bar
323
+ appIconUrl: '/icon.svg', // optional: top-bar logo (see §6)
324
+ appEmoji: '🤖', // optional: fallback when no icon.svg
325
+ enableMicrophone: false, // false unless you need WebRTC audio in
326
+ clientId: undefined, // optional: HF OAuth client ID; defaults to window.huggingface.variables.OAUTH_CLIENT_ID
327
+ devToken: undefined, // optional: { token, userName } - dev shortcut, see §9
328
+ target: undefined, // optional: HTMLElement | string CSS selector; default '#root'
329
+ });
330
+ ```
331
+
332
+ **Required**: `appName`. Everything else has sensible defaults.
333
+
334
+ **Return**: `{ dispose(): void }` - call to unmount cleanly. You
335
+ usually never need this; the page lifecycle handles it.
336
+
337
+ ---
338
+
339
+ ## 5. `connectToHost()` API
340
+
341
+ Called once from `embed.{ts,tsx}` to get a live SDK handle.
342
+
343
+ ```ts
344
+ import { connectToHost } from '@pollen-robotics/reachy-mini-sdk/host/embed';
345
+
346
+ interface MyConfig { /* whatever your app accepts */ }
347
+
348
+ const handle = await connectToHost<MyConfig>();
349
+ ```
350
+
351
+ Awaiting `connectToHost()` blocks until:
352
+
353
+ 1. The URL hash creds are parsed and wiped.
354
+ 2. The SDK script loaded (`window.ReachyMini` ready).
355
+ 3. The host posted `host:init` (Mode A only; Mode B times out
356
+ after 8 s and proceeds from hash alone).
357
+ 4. The SDK connected, started a session, and woke the robot.
358
+
359
+ The resolved `handle` exposes:
360
+
361
+ ```ts
362
+ interface ConnectedHandle<TConfig> {
363
+ // Live state at boot
364
+ reachy: ReachyMiniInstance; // SDK instance, session live, robot awake
365
+ theme: 'dark' | 'light';
366
+ config: TConfig | null;
367
+ appName: string;
368
+ hostName: string;
369
+ userName: string | null;
370
+
371
+ // Subscribe to live updates from the host (returns an unsub fn)
372
+ onLeave(cb: () => void | Promise<void>): () => void;
373
+ onThemeChange(cb: (theme: 'dark' | 'light') => void): () => void;
374
+ onConfigChange(cb: (config: TConfig | null) => void): () => void;
375
+
376
+ // Push state / requests back to the host
377
+ setAppState(s: { phase, connectingStep?, message? }): void;
378
+ requestLeave(): void; // ask host to end the session
379
+ reportError(msg: string, opts?: { fatal?, detail? }): void;
380
+ }
381
+ ```
382
+
383
+ The API is intentionally minimal. If you need a custom channel
384
+ between host and embed, file a feature request - we'll add it as
385
+ a typed message rather than expose a free-form sink.
386
+
387
+ ### Typing your config
388
+
389
+ `connectToHost<T>()` types the `config` field; runtime validation
390
+ is **your job**. An attacker controlling the URL can shape config
391
+ freely - cast is not enough.
392
+
393
+ ```ts
394
+ const handle = await connectToHost<MyConfig>();
395
+ const config = isValidConfig(handle.config) ? handle.config : null;
396
+ ```
397
+
398
+ ---
399
+
400
+ ## 6. Visual identity: icon, name, emoji
401
+
402
+ > **App identity is your HF Space ID** (`owner/space`). An app
403
+ > published at `huggingface.co/spaces/your-name/cool-thing` has
404
+ > identity `your-name/cool-thing` everywhere downstream (mobile
405
+ > catalog, "last opened", official badge). Apps in the
406
+ > `pollen-robotics/*` namespace are automatically tagged as
407
+ > official in the catalog; no extra config.
408
+
409
+ Your app's identity surfaces in **three independent places**, all
410
+ fed from the same sources but each by its own resolution path:
411
+
412
+ | Surface | What it shows | How it gets there |
413
+ |-------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------|
414
+ | Host top bar (in your iframe) | App logo + app name | You pass `appName` + `appIconUrl` + `appEmoji` to `mountHost()` in `dispatch.ts` |
415
+ | Hugging Face mobile catalog | App tile (icon + title + description) | The mobile reads the catalog API; the API reads HF Spaces' frontmatter + probes `public/icon.svg` |
416
+ | Browser tab / OS | Favicon | Your `index.html` `<link rel="icon" href="/icon.svg">` |
417
+
418
+ The host shell itself **does not discover or list other apps**.
419
+ It renders only the app it lives in. The catalog is owned by the
420
+ mobile and the website API; see §11 FAQ for the API spec if you
421
+ need to validate your app shows up.
422
+
423
+ ### Single source of truth: one `icon.svg`, one Space frontmatter
424
+
425
+ You ship **one** SVG file and **one** README frontmatter; both
426
+ the host and the mobile catalog read from them:
427
+
428
+ 1. **`public/icon.svg`** in your repo. Vite copies it verbatim to
429
+ `dist/icon.svg`, where nginx serves it at the root URL of your
430
+ Space. Reference it from `index.html`:
431
+
432
+ ```html
433
+ <link rel="icon" href="/icon.svg" type="image/svg+xml" />
434
+ ```
435
+
436
+ Pass it to `mountHost()` so the top bar renders it without a
437
+ probe:
438
+
439
+ ```ts
440
+ mountHost({ appName: 'My App', appIconUrl: '/icon.svg' });
441
+ ```
442
+
443
+ The catalog API also picks it up by listing the Space's
444
+ files (`siblings`) and matching `public/icon.svg` (or
445
+ `public/icon.png`), no live probe required.
446
+
447
+ 2. **HF Space frontmatter** in your `README.md`:
448
+
449
+ ```yaml
450
+ ---
451
+ title: My App
452
+ emoji: 🤖
453
+ colorFrom: yellow
454
+ colorTo: red
455
+ sdk: static
456
+ pinned: false
457
+ hf_oauth: true
458
+ short_description: One-line description shown in the catalog.
459
+ tags:
460
+ - reachy_mini
461
+ - reachy_mini_js_app
462
+ ---
463
+ ```
464
+
465
+ See [§11.1](#111-required-frontmatter) for the full annotated
466
+ frontmatter.
467
+
468
+ - `title` is the app name in the mobile catalog (the in-iframe
469
+ top bar uses `appName` from `mountHost()` instead).
470
+ - `emoji` is the fallback logo when no `icon.svg` is shipped.
471
+ Pass the same value to `mountHost({ appEmoji: '🤖' })` so the
472
+ top bar's fallback matches.
473
+ - `short_description` shows under the app tile in the catalog.
474
+ - The **`reachy_mini_js_app` tag is mandatory** to appear in
475
+ the mobile catalog. The catalog API filters on this exact
476
+ string. Don't remove it.
477
+ - `hf_oauth: true` makes HF auto-provision an OAuth client and
478
+ inject the ID at file-serve time.
479
+
480
+ ### Icon design recommendations
481
+
482
+ Your icon renders at three different sizes; design for all three:
483
+
484
+ - **Host top bar inside the iframe**: ~24x24 px square.
485
+ - **Mobile catalog tile**: ~64x64 px square card.
486
+ - **Browser tab favicon**: 16x16 px.
487
+
488
+ Practical guidance:
489
+
490
+ - Use a **square viewBox** (e.g. `viewBox="0 0 24 24"`) so the
491
+ three target sizes all crop identically.
492
+ - Keep the icon **readable at 16 px**: thick strokes, simple
493
+ silhouette, max 2-3 distinct shapes.
494
+ - Inline all colours; **don't reference external CSS**, the icon
495
+ is served standalone.
496
+ - Respect dark and light backgrounds: an icon that vanishes on
497
+ light should provide a `<style>` tag with `@media (prefers-color-scheme)`
498
+ rules or, simpler, use a neutral mid-tone palette.
499
+ - **Optimise the SVG**: target ~30 KB or less. Tools: `svgo`,
500
+ Figma's "Export SVG → optimise".
501
+
502
+ ### PNG fallback
503
+
504
+ If you can't ship SVG (heavy raster art, exported portrait, ...),
505
+ the catalog API also accepts `public/icon.png`. SVG wins when both
506
+ exist. The host top bar only renders the SVG variant - if your
507
+ `mountHost({ appIconUrl })` points at a PNG, it works, but you
508
+ lose the crisp upscale on hi-DPI screens.
509
+
510
+ ---
511
+
512
+ ## 7. Receiving an external config
513
+
514
+ The host accepts a base64-encoded JSON `config` from two sources:
515
+
516
+ 1. **URL parameter**: `https://<space>.hf.space/?config=eyJlbW90aW9uIjoiam95In0=`
517
+ (decoded once, passed verbatim).
518
+ 2. **Mobile handoff**: the mobile app embeds your Space with
519
+ `?embedded=1#creds=<base64-bundle-including-config>`.
520
+
521
+ Your app receives `config` typed as `unknown`; cast and validate.
522
+
523
+ ```ts
524
+ interface MyConfig { startingEmotion?: string; }
525
+
526
+ function isMyConfig(v: unknown): v is MyConfig {
527
+ return v != null && typeof v === 'object' && (
528
+ (v as MyConfig).startingEmotion === undefined ||
529
+ typeof (v as MyConfig).startingEmotion === 'string'
530
+ );
531
+ }
532
+
533
+ const handle = await connectToHost<MyConfig>();
534
+ const initial: MyConfig = isMyConfig(handle.config) ? handle.config : {};
535
+
536
+ handle.onConfigChange((next) => {
537
+ if (isMyConfig(next)) /* react to it */;
538
+ });
539
+ ```
540
+
541
+ If your app's UI state changes in a way the mobile would want to
542
+ remember (e.g. user picked a different emotion), persist it in
543
+ your app's storage. The host does **not** propagate state
544
+ upstream - apps don't push config to the host in v1.
545
+
546
+ ---
547
+
548
+ ## 8. Cleaning up on leave
549
+
550
+ The host fires a tear-down sequence in three scenarios:
551
+
552
+ - User clicks "End session" / "Back to apps" in the top bar.
553
+ - Your app calls `handle.requestLeave()`.
554
+ - The page is unloaded (`pagehide`, e.g. user closes the tab).
555
+
556
+ In all three cases your `onLeave` callbacks fire. You have **~1.5-2 s**
557
+ before the host force-unmounts the iframe; use that to:
558
+
559
+ ```ts
560
+ import { safelyReturnToPose } from "@pollen-robotics/reachy-mini-sdk/animation";
561
+
562
+ handle.onLeave(async () => {
563
+ safelyReturnToPose(handle.reachy); // canonical safe-rest one-liner, see §14.3
564
+ player.cancel(); // stop streaming motion frames
565
+ audioCtx?.close(); // release audio
566
+ ws?.close(); // close any side channels
567
+ await flushTelemetry(); // your async hooks
568
+ });
569
+ ```
570
+
571
+ `safelyReturnToPose` is the recommended first call in every `onLeave` -
572
+ it re-enables torque, computes a scaled duration, and dispatches a
573
+ goto to `INIT_POSE`. Full recipe in
574
+ [§14.3](#143-safe-return-to-home-pose-safelyreturntopose).
575
+
576
+ You do **not** need to call `reachy.stopSession()` yourself - the
577
+ host does. You also don't need to navigate away; the iframe is
578
+ unmounted by the host.
579
+
580
+ ---
581
+
582
+ ## 9. Local dev
583
+
584
+ You have two options, picked by the `devToken` and `clientId`
585
+ props passed to `mountHost()`. Reference apps support both via
586
+ `.env.local`.
587
+
588
+ ### Option A: personal access token (no OAuth)
589
+
590
+ Fastest for local dev. Skips the OAuth redirect entirely.
591
+
592
+ 1. Get a token at <https://huggingface.co/settings/tokens> (read
593
+ scope is enough).
594
+ 2. Create `.env.local`:
595
+
596
+ ```
597
+ VITE_HF_TOKEN=hf_xxx
598
+ VITE_HF_USERNAME=your-handle
599
+ ```
600
+
601
+ 3. In `dispatch.ts`, forward both to `mountHost`:
602
+
603
+ ```ts
604
+ mountHost({
605
+ appName: 'My App',
606
+ devToken: import.meta.env.VITE_HF_TOKEN && import.meta.env.VITE_HF_USERNAME
607
+ ? { token: import.meta.env.VITE_HF_TOKEN, userName: import.meta.env.VITE_HF_USERNAME }
608
+ : undefined,
609
+ });
610
+ ```
611
+
612
+ 4. `npm run dev` → you're signed in on page load.
613
+
614
+ `.env.local` must be gitignored. **Never commit the token.**
615
+
616
+ ### Option B: real OAuth client ID
617
+
618
+ Use this when you're touching the OAuth / logout paths.
619
+
620
+ 1. Go to <https://huggingface.co/settings/applications/new>.
621
+ 2. Homepage URL: `http://localhost:5173` · Redirect URIs:
622
+ `http://localhost:5173` · Scopes: at least `openid`, `profile`.
623
+ 3. Copy the client ID into `.env.local`:
624
+
625
+ ```
626
+ VITE_HF_OAUTH_CLIENT_ID=...
627
+ ```
628
+
629
+ 4. Forward to `mountHost`:
630
+
631
+ ```ts
632
+ mountHost({
633
+ appName: 'My App',
634
+ clientId: import.meta.env.VITE_HF_OAUTH_CLIENT_ID,
635
+ });
636
+ ```
637
+
638
+ ---
639
+
640
+ ## 10. SDK version pinning
641
+
642
+ Every reference app pins the same exact SDK version in `package.json`.
643
+ Pin yours the same way - mixing versions across `@pollen-robotics/reachy-mini-sdk`,
644
+ `@pollen-robotics/reachy-mini-sdk/host`, and the daemon on the robot
645
+ produces hard-to-debug protocol drift.
646
+
647
+ The current pinned version across all three reference apps:
648
+
649
+ ```json
650
+ {
651
+ "dependencies": {
652
+ "@pollen-robotics/reachy-mini-sdk": "1.8.0"
653
+ }
654
+ }
655
+ ```
656
+
657
+ This is the stable `1.8.0` npm release, validated end-to-end against
658
+ the host shell + daemon. npm versions are immutable, so pinning the
659
+ exact version is fully reproducible — no commit suffix needed. **Use
660
+ the same string in your `package.json`** unless you're explicitly
661
+ tracking a newer release.
662
+
663
+ > When a newer release is published, the source of truth is whichever
664
+ > string is currently shared by [`reachy_mini_minimal_conversation`'s
665
+ > `package.json`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_minimal_conversation/blob/main/package.json),
666
+ > [`reachy_mini_emotions`'s `package.json`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_emotions/blob/main/package.json),
667
+ > and [`reachy_mini_telepresence`'s `package.json`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_telepresence/blob/main/package.json).
668
+ > If those three diverge, fall back to whatever this guide says.
669
+
670
+ ### Why pin a specific build (not `^1.8.0` or a major like `@1`)?
671
+
672
+ The host shell, the embed adapter (`connectToHost`), the SDK, and the
673
+ robot daemon negotiate over a versioned WebRTC data-channel protocol.
674
+ A patch bump on one side that crosses a protocol boundary will
675
+ silently fall back (or noisily fail) at runtime - well past the type
676
+ checker.
677
+
678
+ Pin to the exact build string the reference apps use, upgrade
679
+ intentionally, and re-test against a live robot before shipping.
680
+
681
+ ---
682
+
683
+ ## 11. Deploying to Hugging Face Spaces
684
+
685
+ > Reachy Mini JS apps ship as **`sdk: static`** Hugging Face Spaces.
686
+ > Two equally valid paths, pick based on your needs:
687
+ >
688
+ > 1. **Bundled (Vite + TypeScript, HF-side build)** - §11.1-§11.4. The
689
+ > default for production. You push source only; HF runs
690
+ > `app_build_command` on its builder, serves the resulting `app_file`
691
+ > from its CDN, and replaces `__OAUTH_CLIENT_ID__` and friends at
692
+ > file-serve time (because `hf_oauth: true`).
693
+ > 2. **Bare HTML + CDN (no bundler, no Node toolchain)** - §11.5. The
694
+ > fastest path for prototypes, learners, and single-file UIs. One
695
+ > `index.html`, SDK imported from jsDelivr, three files total.
696
+ >
697
+ > Both end up as `sdk: static` Spaces with `hf_oauth: true`. The host
698
+ > shell, the `connectToHost()` API, and the OAuth substitution work
699
+ > identically in both. Pick whichever fits your project; the rest of
700
+ > this guide focuses on the bundled path because it's what production
701
+ > apps standardise on.
702
+
703
+ ### 11.1 Required frontmatter
704
+
705
+ ```yaml
706
+ ---
707
+ title: My Reachy Mini App
708
+ emoji: 🤖
709
+ colorFrom: yellow
710
+ colorTo: red
711
+ sdk: static
712
+ app_build_command: npm ci && npm run build # HF runs this on its builder
713
+ app_file: dist/index.html # HF serves the build output as entry
714
+ pinned: false
715
+ hf_oauth: true
716
+ short_description: One-line description shown in the mobile catalog.
717
+ tags:
718
+ - reachy_mini
719
+ - reachy_mini_js_app # mandatory: mobile-catalog discovery filters on this exact string
720
+ ---
721
+ ```
722
+
723
+ - `sdk: static` is the HF Space type. Combined with `app_build_command`
724
+ it triggers a one-shot build container on every push.
725
+ - `app_build_command` is the shell command HF runs on its builder. The
726
+ canonical value is `npm ci && npm run build`. Build logs are visible
727
+ in the Space's "Logs" tab.
728
+ - `app_file` tells HF which file to serve as the entry point **after the
729
+ build completes**. For a Vite-built SPA that's `dist/index.html`.
730
+ - `hf_oauth: true` is what triggers `__OAUTH_CLIENT_ID__` substitution
731
+ inside HTML files in the served output (post-build).
732
+ - The **`reachy_mini_js_app` tag is mandatory** for mobile-catalog
733
+ discovery. The catalog API filters on this exact string.
734
+ - Apps in the `pollen-robotics/*` namespace are automatically tagged
735
+ as "official" in the catalog (see [§13.2 App identity & official apps](#132-app-identity--official-apps)); no extra config.
736
+
737
+ > **`short_description` is hard-capped at 60 characters** by the Hub's
738
+ > YAML validator. The pre-receive hook rejects the push with a clear
739
+ > error if you overshoot; trim and re-commit.
740
+
741
+ **Minimum `.gitignore` for a Space repo.** Because the build runs on
742
+ HF, the source tree must NOT commit build artifacts:
743
+
744
+ ```
745
+ node_modules/
746
+ dist/
747
+ .env
748
+ .env.local
749
+ .DS_Store
750
+ ```
751
+
752
+ > The legacy two-folder, "build-locally-and-push-dist" pattern (one
753
+ > source repo + one separate `*-space` clone) is **deprecated**.
754
+ > Source-only push is now the supported path. The reference apps
755
+ > already follow it.
756
+
757
+ ### 11.2 Build and push
758
+
759
+ Your source tree IS your Space repo. One folder, one git history.
760
+
761
+ ```bash
762
+ # 1. Sanity-check that the build works locally before pushing.
763
+ # HF will replay this exact command on its builder.
764
+ npm ci
765
+ npm run build
766
+ # → dist/ contains the build output. It stays gitignored.
767
+
768
+ # 2. Create the Space (idempotent; skip on "already exists").
769
+ hf repos create <app-name> --repo-type space --space-sdk static
770
+
771
+ # 3. Add the Space as a git remote and push the source tree.
772
+ git init -b main # if your source tree isn't a git repo yet
773
+ git remote add space git@hf.co:spaces/<username>/<app-name>
774
+ git add -A
775
+ git commit -m "feat: initial deploy"
776
+ git push -u space main
777
+ ```
778
+
779
+ What you just pushed:
780
+
781
+ - `README.md` (the frontmatter, including `app_build_command` + `app_file`).
782
+ - `index.html` (the source entry, references `/src/dispatch.ts`).
783
+ - `package.json`, `package-lock.json`, `tsconfig.json`, `vite.config.ts`.
784
+ - `src/` (your `dispatch.ts`, `embed.{ts,tsx}`, styles, helpers).
785
+ - `public/icon.svg` (and optional `public/icon.png` fallback).
786
+
787
+ What HF does on receive:
788
+
789
+ 1. Runs `npm ci && npm run build` in a one-shot build container.
790
+ 2. Captures the build output at `dist/`.
791
+ 3. Substitutes `__OAUTH_CLIENT_ID__` and friends in any `.html` inside the
792
+ served output (because `hf_oauth: true`).
793
+ 4. Serves `dist/index.html` as the entry point at the Space root URL;
794
+ serves the rest of `dist/` (the hashed bundles in `dist/assets/`) as
795
+ static assets via the CDN.
796
+
797
+ Build logs are streamed to the Space's "Logs" tab. If the build fails,
798
+ the Space stays on the previous successful build.
799
+
800
+ > **Don't commit `dist/`.** If you push a stale local build, HF still
801
+ > runs `app_build_command` and overwrites it - but you've polluted the
802
+ > git history with megabytes of build artifacts. Keep `dist/` gitignored.
803
+
804
+ ### 11.3 What HF does on build + serve
805
+
806
+ On `git push`, HF runs (in this order):
807
+
808
+ 1. **Build step**: spins up a one-shot Node container, runs
809
+ `app_build_command` (the canonical `npm ci && npm run build`) on
810
+ the committed source tree. The container is discarded on completion;
811
+ only the build output survives, captured at the path your build tool
812
+ emits (`dist/` for Vite).
813
+ 2. **OAuth placeholder substitution**: in every `.html` file inside the
814
+ served output, replaces `__OAUTH_CLIENT_ID__`, `__OAUTH_SCOPES__`,
815
+ `__SPACE_HOST__`, and `__SPACE_ID__` with the values HF provisioned
816
+ for the Space (because `hf_oauth: true`).
817
+ 3. **CDN serve**: serves `app_file` (typically `dist/index.html`) as
818
+ the Space root URL; serves the rest of the build output as static
819
+ assets (immutable caching honours the hashed filenames Vite emits).
820
+ 4. **Catalog indexing**: indexes the committed `siblings` (the source
821
+ files in git, NOT the build output) for the mobile-catalog probe.
822
+ That's why `public/icon.svg` must be committed at the source repo
823
+ path - the catalog API lists git-tracked files, not the CDN.
824
+
825
+ Build failures don't take down a working Space: HF keeps serving the
826
+ previous successful build until a new build succeeds.
827
+
828
+ ### 11.4 Cache busting
829
+
830
+ If a push doesn't take effect, push an empty commit to force HF to
831
+ re-resolve the Space:
832
+
833
+ ```bash
834
+ git commit --allow-empty -m "chore: bust HF Spaces cache" && git push
835
+ ```
836
+
837
+ ### 11.5 Alternative: bare HTML + CDN, no bundler
838
+
839
+ If you don't want a Node toolchain, prefer plain JS, or are shipping a
840
+ one-off prototype, you can deploy a single `index.html` that imports
841
+ the SDK from a CDN. No `package.json`, no `app_build_command`, no
842
+ build step. HF serves the repo root as-is.
843
+
844
+ **Trade-off summary**
845
+
846
+ | | Bundled (default, §11.1-§11.4) | Bare HTML + CDN |
847
+ |---|---|---|
848
+ | Toolchain on your machine | Node + npm | Browser only |
849
+ | TypeScript | Yes | No (or self-hosted `tsc` build) |
850
+ | Cold start | Faster (pre-bundled, hashed CDN cache) | Slower (jsDelivr bundles JS on first hit) |
851
+ | Iteration loop in prod | `git push` -> wait for HF build (~30-60 s) | `git push` -> live immediately |
852
+ | Best for | Production, multi-file TS apps, npm deps | Prototypes, learners, single-file UIs, demos |
853
+
854
+ **Two sub-variants of the bare-HTML path**
855
+
856
+ There are two patterns in the wild, both `sdk: static`, both no-build:
857
+
858
+ | Sub-variant | SDK import | Host shell | OAuth handled by |
859
+ |---|---|---|---|
860
+ | **Modern (recommended for new apps)** | `cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@<sha>/+esm` - same npm package as the bundled path, just served from the CDN | Yes (`mountHost` + `connectToHost`) | The host shell (sign-in, picker, top bar, end-session - all free) |
861
+ | **Legacy single-file (pre-host-shell)** | `cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@<tag>/js/reachy-mini.js` - single-file SDK bundle from a GitHub tag | No | The SDK itself (`robot.login()` / `robot.authenticate()`) plus your own picker and gate UI |
862
+
863
+ Both sub-variants share **everything that matters at runtime** - the
864
+ motion API (`setTarget`, `setMotorMode`, `gotoTarget`, `setHeadRpyDeg`,
865
+ `setAntennasDeg`, etc.), the WebRTC media flow, the OAuth scopes -
866
+ because they target the same robot daemon over the same data-channel
867
+ protocol. They differ only in who renders the sign-in screen, the
868
+ robot picker, and the top bar: the host shell does it for you on the
869
+ modern sub-variant; you draw it yourself on the legacy sub-variant.
870
+
871
+ For **new** apps, default to the modern sub-variant: less code, free
872
+ top bar, mobile-catalog ready out of the box. The legacy sub-variant
873
+ remains valid if you want full control over the pre-session UI
874
+ (custom welcome screen, animated splash, branded gate, etc.) and are
875
+ willing to maintain that surface yourself.
876
+
877
+ **Minimum frontmatter** (drop `app_build_command` and `app_file`):
878
+
879
+ ```yaml
880
+ ---
881
+ title: My Bare App
882
+ emoji: 🤖
883
+ colorFrom: yellow
884
+ colorTo: red
885
+ sdk: static
886
+ pinned: false
887
+ hf_oauth: true
888
+ short_description: Reachy Mini app, no bundler.
889
+ tags:
890
+ - reachy_mini
891
+ - reachy_mini_js_app
892
+ ---
893
+ ```
894
+
895
+ **Minimum file layout** (3 files):
896
+
897
+ ```
898
+ my-bare-app/
899
+ ├── README.md # frontmatter above
900
+ ├── index.html # your app
901
+ └── style.css # optional
902
+ ```
903
+
904
+ **Importing the SDK from a CDN**
905
+
906
+ Pin to an exact build SHA - **the same string you would use in
907
+ `package.json`** (see [§10 SDK version pinning](#10-sdk-version-pinning)).
908
+ jsDelivr's `/+esm` suffix tells the CDN to bundle the package to ESM at
909
+ the edge:
910
+
911
+ ```html
912
+ <script type="module">
913
+ import { ReachyMini } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0/+esm";
914
+ import { mountHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0/host/dist/entry/auto.js";
915
+ import { connectToHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0/host/dist/entry/embed.js";
916
+
917
+ window.ReachyMini = ReachyMini;
918
+ window.dispatchEvent(new Event("reachymini:ready"));
919
+
920
+ const params = new URLSearchParams(window.location.search);
921
+ if (params.get("embedded") === "1") {
922
+ const handle = await connectToHost();
923
+ /* render your app, use handle.reachy.setHeadRpyDeg(...) etc. */
924
+ } else {
925
+ mountHost({ appName: "My Bare App", appEmoji: "🤖" });
926
+ }
927
+ </script>
928
+ ```
929
+
930
+ Pair with `<link rel="modulepreload" href="...">` tags in `<head>` for
931
+ both module URLs to warmup the CDN fetch during HTML parse. This shaves
932
+ a few hundred ms off cold-start on mobile networks.
933
+
934
+ The `__OAUTH_CLIENT_ID__` substitution block from
935
+ [§3.1](#31-indexhtml) is **still required** in your `index.html` -
936
+ that's what wires HF OAuth into the SDK at runtime. The host shell
937
+ (when reached via `mountHost`) handles the rest of the OAuth flow
938
+ identically to the bundled path.
939
+
940
+ **Scaling up without a bundler**
941
+
942
+ When a single-file `index.html` becomes hard to navigate, split your
943
+ code into local ES modules under `lib/` and/or `views/` and let the
944
+ browser resolve the `import` graph natively. Common, lightweight
945
+ patterns that scale well at this size:
946
+
947
+ - A tiny custom state store (`state.js` exposing a mutable object plus
948
+ a `render()` pointer the views call after mutating it) - avoids
949
+ pulling in a framework just for re-render.
950
+ - Factory functions that close over a shared SDK handle
951
+ (`createMotorSubsystem(robot)`, `createGate({ robot })`, etc.) so
952
+ views don't have to thread the handle through props.
953
+ - `IndexedDB` for any persistence the app needs (recorded moves,
954
+ user settings); no Node deps required.
955
+ - The `@huggingface/hub` ESM build from jsDelivr if you want Hub
956
+ dataset upload / download without npm.
957
+
958
+ No transpile, no bundle, just static `import` statements between
959
+ sibling files. The architecture transfers cleanly between the two
960
+ sub-variants - swap the SDK import URL and route through
961
+ `connectToHost` instead of instantiating the SDK directly (see the
962
+ migration recipe below).
963
+
964
+ **Migrating a legacy single-file SDK app to the modern host shell**
965
+
966
+ If your existing app imports the SDK as
967
+ `https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@<tag>/js/reachy-mini.js`
968
+ and rolls its own sign-in and picker UI, you can graduate to the host
969
+ shell without touching your motion code. The four-step recipe:
970
+
971
+ 1. **Swap the SDK import URL.** Replace your `/gh/.../js/reachy-mini.js`
972
+ import with the npm-CDN equivalent, and add the two host imports:
973
+
974
+ ```js
975
+ // BEFORE (legacy single-file)
976
+ import { ReachyMini } from "https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@v1.7.2/js/reachy-mini.js";
977
+
978
+ // AFTER (modern host shell, same SDK runtime API)
979
+ import { ReachyMini } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0/+esm";
980
+ import { mountHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0/host/dist/entry/auto.js";
981
+ import { connectToHost } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0/host/dist/entry/embed.js";
982
+ ```
983
+
984
+ 2. **Branch on `?embedded=1`.** Wrap your existing app boot in:
985
+
986
+ ```js
987
+ window.ReachyMini = ReachyMini;
988
+ window.dispatchEvent(new Event("reachymini:ready"));
989
+
990
+ if (new URLSearchParams(location.search).get("embedded") === "1") {
991
+ // Inside the host iframe: the robot is already authenticated,
992
+ // picked, and streaming. Skip your own gate / picker entirely.
993
+ const handle = await connectToHost();
994
+ const robot = handle.reachy; // same object your app already uses
995
+ // ...rest of your app code, unchanged
996
+ handle.onLeave(() => { /* your cleanup */ });
997
+ } else {
998
+ // Standalone visit: render the shell. It will iframe this same
999
+ // page with ?embedded=1 once the user picks a robot.
1000
+ mountHost({ appName: "Your App", appEmoji: "🤖" });
1001
+ }
1002
+ ```
1003
+
1004
+ 3. **Delete the dead UI**: your sign-in card, your robot picker, any
1005
+ "Sign out" / "End session" affordance, any `robotsChanged` listener
1006
+ that drove a picker list. The host shell renders all of these
1007
+ outside your iframe.
1008
+
1009
+ 4. **Keep your motion code exactly as-is.** `robot.setTarget(...)`,
1010
+ `robot.setMotorMode(...)`, `robot.gotoTarget(...)`, `robot.setHeadRpyDeg(...)`,
1011
+ `robot.setAntennasDeg(...)` - identical method signatures on both
1012
+ SDKs. The recorded-move API (`playMove`, `uploadAudio`, `cancelMove`)
1013
+ only exists on the modern SDK; if you weren't using it, nothing
1014
+ changes. If you reached into private fields like `robot._pc`
1015
+ (RTCPeerConnection), prefer the public alternatives:
1016
+ `robot.attachVideo(videoEl)` for video, `enableMicrophone: true` on
1017
+ `mountHost` for bidirectional audio.
1018
+
1019
+ Your `README.md` frontmatter doesn't need any changes: `sdk: static`
1020
+ and `hf_oauth: true` work for both variants. You can still skip
1021
+ `app_build_command` / `app_file` since you're not adding a build step.
1022
+
1023
+ **When to graduate to Vite + TS (§11.1-§11.4)**
1024
+
1025
+ Switch to the bundled path when any of these become true:
1026
+ - You want TypeScript with strict checks.
1027
+ - The app loads more than ~10 source files (the CDN waterfall adds up).
1028
+ - You want hot reload in dev (`npm run dev`).
1029
+ - You need npm packages other than the SDK (jsDelivr doesn't ship every package well).
1030
+ - Page-weight matters and you want tree-shaking + Brotli on hashed bundles.
1031
+
1032
+ ---
1033
+
1034
+ ## 12. FAQ and common pitfalls
1035
+
1036
+ ### "I see a `Robot is busy` error even though no one is using the robot"
1037
+
1038
+ The host's SDK and the embed's SDK both claim a peer at the
1039
+ central. The host **must** disconnect when the embed boots; if it
1040
+ doesn't, the central sees two peers with the same `appName` and
1041
+ rejects the embed.
1042
+
1043
+ This is handled automatically by `@pollen-robotics/reachy-mini-sdk/host`
1044
+ (see [§13.5.1 Single live SDK per tab](#1351-single-live-sdk-per-tab)).
1045
+ If you see this in dev, you likely have **two tabs** open on the
1046
+ same Space - that's expected behaviour.
1047
+
1048
+ ### "My app loads React + MUI even though I wrote vanilla TS"
1049
+
1050
+ The **host shell** is React + MUI. It runs **only outside your
1051
+ iframe** (sign-in screen, picker, top bar). Once your app is live,
1052
+ the host's React tree is idle.
1053
+
1054
+ Your iframe content is whatever you wrote. Vanilla TS apps stay
1055
+ slim inside the iframe.
1056
+
1057
+ ### "Vite warns about React being installed in two places"
1058
+
1059
+ You're using the legacy `file:./vendor/reachy-mini-host` dep
1060
+ pattern (now unsupported — the host ships from npm as part of
1061
+ `@pollen-robotics/reachy-mini-sdk`). Migrate to the npm dep and,
1062
+ if you still see the warning, add to your `vite.config.ts`:
1063
+
1064
+ ```ts
1065
+ export default defineConfig({
1066
+ resolve: {
1067
+ dedupe: ['react', 'react-dom', 'react/jsx-runtime',
1068
+ '@emotion/react', '@emotion/styled',
1069
+ '@mui/material', '@mui/icons-material'],
1070
+ },
1071
+ optimizeDeps: {
1072
+ include: ['@pollen-robotics/reachy-mini-sdk',
1073
+ '@pollen-robotics/reachy-mini-sdk/host',
1074
+ '@pollen-robotics/reachy-mini-sdk/host/auto',
1075
+ '@pollen-robotics/reachy-mini-sdk/host/embed'],
1076
+ },
1077
+ });
1078
+ ```
1079
+
1080
+ ### "I want a different sign-in flow"
1081
+
1082
+ Not supported in v1. The host owns OAuth. If you need a custom
1083
+ flow, the standalone shell isn't for you - publish your Space
1084
+ with the host disabled (just don't call `mountHost()`) and roll
1085
+ your own.
1086
+
1087
+ ### "I want a different theme than the bundled MUI one"
1088
+
1089
+ The host shell's look is fixed (light + dark MUI bundle). Apps
1090
+ own their own theme **inside the iframe** - use the
1091
+ `handle.theme` value as your mode signal and wrap your app in
1092
+ whatever ThemeProvider you want.
1093
+
1094
+ ```ts
1095
+ const handle = await connectToHost();
1096
+ // Mirror `handle.theme` ('dark' | 'light') in your own
1097
+ // ThemeProvider. The host pushes updates via onThemeChange().
1098
+ ```
1099
+
1100
+ ### "The icon doesn't show up in the top bar"
1101
+
1102
+ Check the three sources in priority order (§6):
1103
+
1104
+ 1. Is `/icon.svg` reachable at the deployed URL? Open
1105
+ `https://<space>.hf.space/icon.svg` directly.
1106
+ 2. Is the file's MIME type `image/svg+xml`? The host's probe
1107
+ checks the response's `content-type`.
1108
+ 3. Did you pass `appIconUrl: '/icon.svg'` to `mountHost()`?
1109
+
1110
+ If 1 + 2 + 3 are correct and it still fails, file a bug.
1111
+
1112
+ ### "My Space serves the bundle but the OAuth login redirects loop"
1113
+
1114
+ HF only substitutes `__OAUTH_CLIENT_ID__` when `hf_oauth: true` is
1115
+ set in the README frontmatter **and** the file is HTML. Common
1116
+ mistakes:
1117
+
1118
+ - `hf_oauth: true` missing → placeholder stays as literal
1119
+ `"__OAUTH_CLIENT_ID__"`; the SDK falls back to no client ID and
1120
+ the login never resolves.
1121
+ - You pushed a built `dist/index.html` that already had the
1122
+ placeholder replaced locally (e.g. you ran with `.env.local` and
1123
+ some bundler hardcoded the value). HF only substitutes
1124
+ `__...__` literals; if the file already has a real ID baked in
1125
+ for a different OAuth client, the redirect targets the wrong app.
1126
+ - The Space pre-dates the `hf_oauth` substitution (very old
1127
+ Spaces). Re-create the Space.
1128
+
1129
+ ### "My app doesn't appear in the mobile catalog"
1130
+
1131
+ Three things must be true simultaneously:
1132
+
1133
+ 1. The Space tags include the exact string `reachy_mini_js_app` (see
1134
+ the [§11.1 frontmatter](#111-required-frontmatter)).
1135
+ 2. `public/icon.svg` exists in the **committed repo tree** (not just
1136
+ in `dist/`). The catalog probe inspects `siblings`, which is a
1137
+ listing of committed files, not served URLs.
1138
+ 3. The Space is public (or the requesting user has access).
1139
+
1140
+ ### "My HF build failed - where are the logs?"
1141
+
1142
+ The Space's **Logs** tab. With `sdk: static` + `app_build_command`, HF
1143
+ streams the build container's stdout/stderr there in real time. Common
1144
+ failures and where to look:
1145
+
1146
+ - **`npm ci` peer / lockfile mismatch**: regenerate `package-lock.json`
1147
+ locally (`rm package-lock.json && npm install`), commit, push again.
1148
+ - **`tsc` errors**: same TS errors as `npm run build` locally - reproduce
1149
+ by running the exact `app_build_command` on your laptop.
1150
+ - **Missing `app_file` after build**: HF can't find what you told it to
1151
+ serve. Verify `app_file: dist/index.html` matches your Vite output
1152
+ path; if you customised `build.outDir`, update `app_file` too.
1153
+ - **`short_description` over 60 chars**: rejected by the pre-receive
1154
+ hook before the build even starts. Trim and re-push.
1155
+
1156
+ While the build is failing, HF keeps serving the **previous** successful
1157
+ build. Your URL doesn't 404 unless you've never successfully built.
1158
+
1159
+ ### "Where do I see if my app crashed at boot?"
1160
+
1161
+ Three places, in order:
1162
+
1163
+ 1. Browser console of the standalone Space tab (mountHost errors).
1164
+ 2. Browser console of the iframe (embed errors). The embed
1165
+ `postMessage`s any boot error back to the host as
1166
+ `embed:error` - the host surfaces fatal ones via a banner.
1167
+ 3. HF Space "Logs" tab - only build-time errors show up here for
1168
+ static Spaces (no runtime container).
1169
+
1170
+ ### "How do I test the mobile-handoff mode locally?"
1171
+
1172
+ Hit your dev server at:
1173
+
1174
+ ```
1175
+ http://localhost:5173/?embedded=1#creds=<base64({"hfToken":"hf_xxx","userName":"you","robotPeerId":"abc","signalingUrl":"https://...","theme":"dark","config":null,"hostName":"Reachy Mini","appName":"My App"})>
1176
+ ```
1177
+
1178
+ The dispatcher will skip the shell and go straight to your embed.
1179
+ Useful for testing the embed path without spinning up the mobile
1180
+ app. The exact bundle shape is documented at
1181
+ [§13.3 Two boot modes](#133-two-boot-modes-one-url-surface).
1182
+
1183
+ ---
1184
+
1185
+ ## 13. Architecture reference (host ↔ embed contract)
1186
+
1187
+ > **You don't need this section to ship an app.** §1-§12 plus §14
1188
+ > (Robotics best practices) are enough. This appendix is the canonical
1189
+ > contract between the **app**, the **host shell**, and the **embed
1190
+ > adapter** - useful when you're debugging a weird boot, considering an
1191
+ > unusual deployment, or contributing to the host shell itself.
1192
+
1193
+ ### 13.1 Roles: app · host · embed
1194
+
1195
+ Three actors, one app repository:
1196
+
1197
+ | Actor | Lives in | Owns |
1198
+ |------------|-------------------------------------------------------|----------------------------------------------------------------------------|
1199
+ | **App** | `index.html` + `src/dispatch.ts` + `src/embed.{ts,tsx}` | UI, app-specific UX, **full freedom over framework / tooling choices** |
1200
+ | **Host** | `@pollen-robotics/reachy-mini-sdk/host/auto` | OAuth, robot discovery, robot picker, connecting overlay, end-session flow |
1201
+ | **Embed** | `@pollen-robotics/reachy-mini-sdk/host/embed` | SDK lifecycle inside the iframe (`startSession`, `ensureAwake`, teardown) |
1202
+
1203
+ The **App** consumes the Reachy Mini SDK (imported in
1204
+ `src/dispatch.ts` and self-assigned to `window.ReachyMini`) plus the
1205
+ `@pollen-robotics/reachy-mini-sdk/host` subpath exports. It contains
1206
+ **zero auth code, zero picker code, zero session-lifecycle code**.
1207
+
1208
+ #### Why React + MUI for the host shell (and only the host)
1209
+
1210
+ The host shell needs a real component library: sign-in forms, robot
1211
+ picker lists, connecting overlays, top bar, dark-mode toggles. It's
1212
+ built with **React 19 + MUI 7 + Emotion**.
1213
+
1214
+ - The shell renders **only outside your iframe** and only between
1215
+ sessions; once your app is live, the shell's React tree is idle.
1216
+ - Apps written in another framework still load the shell's bundle
1217
+ for sign-in / picker UI. That's the cost of the iframe model and
1218
+ we accept it.
1219
+
1220
+ The trade-off favours **fast host iteration + tech freedom for
1221
+ apps** over a slimmer host shell.
1222
+
1223
+ ### 13.2 App identity & official apps
1224
+
1225
+ A Reachy Mini app is uniquely identified by its **Hugging Face Space
1226
+ ID**, of the form `owner/space` (e.g. `pollen-robotics/emotions`).
1227
+ Everything downstream of identity flows from this single string:
1228
+
1229
+ - The catalog API filters and dedupes apps by `space.id`.
1230
+ - The mobile app stores and recalls "last opened" apps by
1231
+ `owner/space`.
1232
+ - The host shell does **not** need this ID at runtime (it lives
1233
+ inside the app it renders); it's used solely by the discovery
1234
+ surface.
1235
+
1236
+ **An app is "official" if and only if its Space ID starts with
1237
+ `pollen-robotics/`.** No allowlist, no separate registry, no
1238
+ `official: true` field. Adding `pollen-robotics/` to your URL is the
1239
+ entire qualification. Where the distinction surfaces:
1240
+
1241
+ | Surface | Behaviour |
1242
+ |------------------------|-----------------------------------------------------------------|
1243
+ | Mobile catalog | "Official" badge / sort priority on `pollen-robotics/*` Spaces |
1244
+ | Website `/api/js-apps` | Returns `isOfficial: true` for `pollen-robotics/*` |
1245
+ | Host shell | **No notion of "official"**. Renders the app the same way always |
1246
+
1247
+ ### 13.3 Two boot modes, one URL surface
1248
+
1249
+ The same `index.html` is served for both modes. The dispatcher
1250
+ (`src/dispatch.ts`) picks between them based on the URL.
1251
+
1252
+ | Mode | URL shape | What happens |
1253
+ |-----------------------|----------------------------------------------------------------------|------------------------------------------------------|
1254
+ | **A. Hub standalone** | `https://<space>.hf.space/` | Full host shell (OAuth → picker → iframe with app) |
1255
+ | **B. Mobile handoff** | `https://<space>.hf.space/?embedded=1#creds=<base64(CredsBundle)>` | Skip shell; app boots directly, creds come via hash |
1256
+
1257
+ #### Dispatch rule
1258
+
1259
+ ```
1260
+ if (URL.searchParams.has("embedded") && URL.hash.startsWith("#creds=")):
1261
+ boot embed → import("./embed")
1262
+ else:
1263
+ boot host → import("@pollen-robotics/reachy-mini-sdk/host/auto").mountHost({...})
1264
+ ```
1265
+
1266
+ `?embedded=1` without creds is an invalid mode - the embed shows an
1267
+ `ErrorView`.
1268
+
1269
+ #### `CredsBundle` (lives only in the URL hash, never in search)
1270
+
1271
+ The bundle has the shape:
1272
+
1273
+ ```ts
1274
+ {
1275
+ hfToken: string; // short-lived HF bearer (15 min TTL)
1276
+ userName: string;
1277
+ robotPeerId: string;
1278
+ signalingUrl: string;
1279
+ theme: 'dark' | 'light';
1280
+ config: unknown | null;
1281
+ hostName: string;
1282
+ appName: string;
1283
+ }
1284
+ ```
1285
+
1286
+ The hash is **never sent to a server**. The embed wipes it with
1287
+ `history.replaceState` on the very first synchronous tick of
1288
+ `connectToHost()`, **before any `await`** - see
1289
+ [§13.5.2 Hash-only creds + immediate wipe](#1352-hash-only-creds--immediate-wipe).
1290
+
1291
+ #### Mode A standalone flow (the long story)
1292
+
1293
+ Phase machine inside the host:
1294
+
1295
+ ```
1296
+ booting → (signed-out | authenticated) → connecting → connected →
1297
+ picking → handing-off → live → stopping → picking
1298
+ ```
1299
+
1300
+ - `booting`: wait for `window.ReachyMini`, instantiate the SDK,
1301
+ call `authenticate()`.
1302
+ - `signed-out`: render the OAuth sign-in screen.
1303
+ - `authenticated` → `connecting` → `connected` (SSE welcome) →
1304
+ `picking`.
1305
+ - During `picking`, the robot list reacts live to the SDK's
1306
+ `robotsChanged` event.
1307
+ - On robot selection, the host mounts the iframe at
1308
+ `<same-origin>?embedded=1#creds=<base64>` and overlays
1309
+ `ConnectingView` (3-step stepper: `link` → `session` → `wake`).
1310
+ - When the embed reaches `phase: 'live'`, the overlay fades out.
1311
+
1312
+ Top bar layout while `live`:
1313
+
1314
+ ```
1315
+ [icon] [app name] ........ [robot status] [end-session] [oauth menu]
1316
+ ```
1317
+
1318
+ The top bar stays rendered through every phase of Mode A and does
1319
+ **not** render at all in Mode B.
1320
+
1321
+ End-session flow:
1322
+
1323
+ 1. Triggered by the End-session button, `embed:request-leave`, or
1324
+ `pagehide`.
1325
+ 2. Host → phase `stopping`, `LeavingView` overlay, posts
1326
+ `host:leaving`.
1327
+ 3. Embed runs `onLeave` callbacks → `reachy.stopSession()` → acks.
1328
+ 4. Host receives ack (or hits `timeoutMs`) → unmounts iframe →
1329
+ phase `picking`.
1330
+
1331
+ #### Mode B mobile handoff flow
1332
+
1333
+ The mobile app opens the Space in a WebView with a pre-built URL
1334
+ containing creds in the hash. The dispatcher loads `./embed`
1335
+ directly. **No host shell is mounted.** The user sees:
1336
+
1337
+ - No sign-in view (mobile already authenticated).
1338
+ - No robot picker (mobile already picked).
1339
+ - No welcome-back animation.
1340
+ - No host top bar - if your app wants one, it draws it itself.
1341
+
1342
+ There is **no end-session button** in Mode B. Closing the WebView
1343
+ triggers `pagehide`, which fires `onLeave` and stops the session.
1344
+
1345
+ ### 13.4 Host phase state machine + handoff sequence
1346
+
1347
+ #### Host phase machine (Mode A only)
1348
+
1349
+ ```mermaid
1350
+ stateDiagram-v2
1351
+ [*] --> booting
1352
+ booting --> sdk_missing: SDK load timeout
1353
+ booting --> signed_out: authenticate() false
1354
+ booting --> authenticated: authenticate() true
1355
+ booting --> error: ctor / clientId error
1356
+
1357
+ signed_out --> booting: user clicks Sign in
1358
+ authenticated --> connecting: auto
1359
+ connecting --> connected: SSE welcome
1360
+ connecting --> error: HTTP / network fail
1361
+ connected --> picking
1362
+ picking --> handing_off: selectRobot()
1363
+ handing_off --> live: embed reports phase=live
1364
+ handing_off --> error: embed:error { fatal: true }
1365
+
1366
+ live --> stopping: endSession() OR embed:request-leave
1367
+ stopping --> picking: leave-ack OR timeout
1368
+
1369
+ error --> picking: retry() if SDK authed
1370
+ error --> booting: retry() if no SDK
1371
+ error --> signed_out: retry() if auth expired
1372
+ ```
1373
+
1374
+ #### Handoff sequence (host → embed)
1375
+
1376
+ Showcases the single-SDK-per-tab invariant
1377
+ ([§13.5.1](#1351-single-live-sdk-per-tab)).
1378
+
1379
+ ```mermaid
1380
+ sequenceDiagram
1381
+ participant U as User
1382
+ participant H as Host
1383
+ participant HS as Host SDK
1384
+ participant C as Central
1385
+ participant E as Embed (iframe)
1386
+ participant ES as Embed SDK
1387
+
1388
+ U->>H: click robot card
1389
+ H->>H: phase = handing-off, mount iframe
1390
+ E->>E: parse #creds, replaceState wipe
1391
+ E->>H: embed:ready
1392
+ H->>HS: disconnect() [§13.5.1]
1393
+ H->>E: host:init
1394
+ E->>ES: connect() → startSession() → ensureAwake()
1395
+ ES->>C: claim robot
1396
+ C-->>ES: session live
1397
+ E->>H: embed:app-state { phase: 'live' }
1398
+ H->>H: hide ConnectingView overlay
1399
+ ```
1400
+
1401
+ ### 13.5 Engineering invariants
1402
+
1403
+ Four hard invariants. A failure here is a regression in the host
1404
+ shell - it must produce a defined observable behaviour and must
1405
+ stay covered by tests.
1406
+
1407
+ #### 13.5.1 Single live SDK per tab
1408
+
1409
+ **Contract**: at any instant, exactly one SDK instance per tab is
1410
+ registered at the central.
1411
+
1412
+ **Mechanism**:
1413
+ - Host mounts the SDK in `booting` and uses it for the picker.
1414
+ - As soon as the embed posts `embed:ready`, the host calls
1415
+ `disconnect()` on its SDK and zeroes its references.
1416
+ - On `leave-ack` (or timeout), the host calls `connect()` again to
1417
+ refresh the fleet and lands back on `picking`.
1418
+
1419
+ **Why**: removes the entire class of "Robot is busy" false
1420
+ positives where the central thinks the shell still owns the robot.
1421
+
1422
+ **Defence in depth**: the host's SDK registers as `<appName> (shell)`
1423
+ at the central; the embed keeps the clean `appName`. This protects
1424
+ the narrow window where both SDKs overlap (between `embed:ready`
1425
+ and `disconnect()`).
1426
+
1427
+ #### 13.5.2 Hash-only creds + immediate wipe
1428
+
1429
+ **Contract**: HF tokens never appear in URL search, never in
1430
+ referer, never in HF Spaces access logs.
1431
+
1432
+ **Mechanism**:
1433
+ - Creds are serialised as base64 JSON in the URL hash fragment
1434
+ (`#creds=...`).
1435
+ - The embed wipes the hash with `history.replaceState` on the
1436
+ first synchronous tick of `connectToHost()`, before any `await`.
1437
+ - On `host:leaving`, the embed clears `sessionStorage.hf_*` keys
1438
+ before sending the `leave-ack`.
1439
+
1440
+ **Token TTL**: `hf_token_expires` is set to **15 min** at seed
1441
+ time. The SDK refreshes on demand.
1442
+
1443
+ #### 13.5.3 Bundle and SDK pinning
1444
+
1445
+ **Contract**: a fix in the host reaches every Space within one
1446
+ cache cycle, with no per-app rebuild. A fix in the SDK can be
1447
+ rolled out by the SDK team, not by every app team.
1448
+
1449
+ **Pinning rules**:
1450
+ - App bundles (`index-<hash>.js`): hashed by Vite, cache-busted
1451
+ on deploy.
1452
+ - `@pollen-robotics/reachy-mini-sdk` in `package.json`: pinned to
1453
+ an **exact version** (today: `1.8.0`), not a range. See
1454
+ [§10 SDK version pinning](#10-sdk-version-pinning).
1455
+ - `@pollen-robotics/reachy-mini-sdk/host` subpath imports: same
1456
+ pin, same package.
1457
+
1458
+ On detected mismatch (e.g. unknown protocol version, structurally
1459
+ invalid `host:init`), the host's `ErrorView` primary button calls
1460
+ `window.location.reload(true)` to bypass any intermediary cache.
1461
+
1462
+ #### 13.5.4 React Strict Mode safety
1463
+
1464
+ **Contract**: every effect in the host package survives a double
1465
+ mount in `<React.StrictMode>` without doubling network I/O, SDK
1466
+ instances, or postMessage traffic.
1467
+
1468
+ **Why this matters**: React 18+ in dev intentionally mounts every
1469
+ component, runs every effect, runs every cleanup, then mounts and
1470
+ runs effects again. Code that "looked fine" in prod will fire two
1471
+ parallel `connect()` calls, leak two SSE subscribers, post
1472
+ `embed:ready` twice. In Mode A this surfaces as ghost sessions at
1473
+ the central; in Mode B as two competing WebRTC peer connections.
1474
+
1475
+ **Mechanisms**:
1476
+ - **Boot guard refs**: `useReachyHost()` uses a `bootStartedRef`
1477
+ set on first mount; the second StrictMode invocation early-
1478
+ returns instead of re-instantiating the SDK.
1479
+ - **Subscription cleanup**: every `useEffect` returns its tear-
1480
+ down. `robotsChanged`, `phaseChanged`, `welcome` are all
1481
+ unsubscribed in cleanup.
1482
+ - **Idempotent SDK calls**: `connect()` is a no-op when the SDK is
1483
+ already `connected` / `streaming`; the host relies on this rather
1484
+ than gating with a flag.
1485
+ - **`connectToHost()` is one-shot**: a module-level
1486
+ `bootPromiseRef` returns the same promise for a second call,
1487
+ rather than re-running the handshake.
1488
+
1489
+ ### 13.6 Protocol v1 messages
1490
+
1491
+ Full type definitions in
1492
+ [`ts/host/src/lib/protocol.ts`](./host/src/lib/protocol.ts).
1493
+ Envelopes are JSON, carry `source: 'reachy-mini'` and `version: 1`.
1494
+ Both sides validate `event.origin` against `window.location.origin`
1495
+ before trusting the payload.
1496
+
1497
+ | Direction | Type | Purpose |
1498
+ |-----------------|-------------------------------|-----------------------------------------------|
1499
+ | embed → host | `embed:ready` | "I'm alive, send creds" |
1500
+ | host → embed | `host:init` | Theme, signaling URL, hfToken, robotPeerId, config |
1501
+ | embed → host | `embed:app-state` | Lifecycle phase + connecting sub-step |
1502
+ | host → embed | `host:theme-changed` | Theme switched (no reload) |
1503
+ | host → embed | `host:config-changed` | Config updated (no reload, mobile-driven) |
1504
+ | host → embed | `host:leaving` | Tear-down request with `timeoutMs` |
1505
+ | embed → host | `embed:request-leave` | App requests end-of-session |
1506
+ | embed → host | `embed:error` | Error report (`{ message, fatal, detail? }`) |
1507
+
1508
+ Intentionally **not** in the v1 protocol:
1509
+
1510
+ - `embed:request-config-update` (apps don't push config upstream).
1511
+ - `host:custom` / `embed:custom` (no free-form channel; new needs
1512
+ land as typed messages via a major bump).
1513
+ - Any heartbeat / ping-pong messages.
1514
+
1515
+ #### Versioning policy
1516
+
1517
+ - `version: 1` is the only version today.
1518
+ - **Additive changes** (new optional field, new message type) ship
1519
+ without a version bump.
1520
+ - **Breaking changes** (removed field, changed semantics, removed
1521
+ message type) bump to `version: 2`. The host MAY support both
1522
+ versions for one release cycle, then drop v1.
1523
+ - On unknown version, the receiver logs a warning and ignores the
1524
+ message. No negotiation handshake.
1525
+
1526
+ #### Idempotency
1527
+
1528
+ - `host:leaving` may arrive twice; the embed runs `onLeave`
1529
+ callbacks **once** (gated by a `pendingLeaveTokenRef`) and acks
1530
+ every time.
1531
+ - `host:init` may arrive twice (rare: bridge re-arm); the embed
1532
+ treats the latest as authoritative and re-applies theme / config.
1533
+
1534
+ ### 13.7 Non-goals
1535
+
1536
+ To stay simple and auditable, the host shell explicitly does NOT do:
1537
+
1538
+ - **App discovery / listing / catalog**. The shell renders exactly
1539
+ **one** app: the one it ships with. Listing apps lives in the
1540
+ Reachy Mini mobile app, fed by the website's `/api/js-apps`
1541
+ endpoint (filtered on `reachy_mini_js_app`).
1542
+ - **"Official app" badging in the shell**. Officialness is derived
1543
+ from the Space ID prefix and surfaces only in the mobile catalog.
1544
+ - **Multi-robot per tab**. One robot at a time per Space session.
1545
+ - **Hot reload of the app code without reloading the iframe**.
1546
+ Code updates require unmounting + remounting.
1547
+ - **App ↔ app communication**. Apps are sandboxed by design.
1548
+ - **Offline mode**. The central is required for every session.
1549
+ - **Automatic WebRTC retry**. On a session drop, the user goes
1550
+ back to the picker manually.
1551
+ - **Queue or persistence of postMessage events** if the bridge is
1552
+ down. The bridge is best-effort; both sides re-converge on the
1553
+ next message.
1554
+ - **Server-side rendering**. The host is a CSR shell, deliberately.
1555
+ - **Cross-origin iframe**. The embed is same-origin with the host
1556
+ (both served by the Space); the origin check relies on this.
1557
+ - **Imposing a framework on app authors**. Apps inside the iframe
1558
+ are completely free of framework constraints.
1559
+
1560
+ ### 13.8 Threat model
1561
+
1562
+ The host runs in a HF Spaces container; the embed runs in a
1563
+ same-origin iframe within the same Space.
1564
+
1565
+ | Asset | Threat | Mitigation |
1566
+ |--------------------------|------------------------------------------|------------------------------------------------|
1567
+ | HF bearer token | Leak via URL log / referer | Hash-only + immediate wipe (§13.5.2), 15 min TTL |
1568
+ | HF bearer token | Leak via sessionStorage to other origin | Same-origin embed, no cross-origin postMessage |
1569
+ | `config` payload | Attacker controls URL → malformed JSON | App MUST validate at the boundary (typed cast is not enough) |
1570
+ | postMessage channel | Random extension posts a forged message | Receivers check `source === 'reachy-mini'` AND `event.origin === window.location.origin` |
1571
+ | Central session | Tab crashes, robot stays claimed | `pagehide` triggers `stopSession()`; central also enforces idle timeout |
1572
+
1573
+ **Out of scope** for this iteration:
1574
+ - Defence against a malicious app that the user explicitly loaded.
1575
+ We trust apps published under the `pollen-robotics/*` namespace.
1576
+ - Defence against a compromised central. The signaling URL is
1577
+ configurable per-Space; the central is the trust root.
1578
+
1579
+ ---
1580
+
1581
+ ## 14. Robotics best practices
1582
+
1583
+ Subpath import: `@pollen-robotics/reachy-mini-sdk/animation`
1584
+
1585
+ Pure-TypeScript helpers that every Reachy Mini JS app used to copy-paste
1586
+ from Rémi's `reachy-mini-js-practices` bench (the same source referenced
1587
+ inline in `ts/animation/safe-return.ts`, `distance.ts`, `presets.ts`).
1588
+ **No daemon changes** - everything routes through existing data-channel
1589
+ commands (`setMotorMode`, `setTarget`, `gotoTarget`). Three concrete
1590
+ callers today (`reachy_mini_emotions`, `reachy_mini_marionette` v1 + v2)
1591
+ still carry their own copies; consolidate onto this lib for new apps.
1592
+
1593
+ See [`ts/animation/DESIGN.md`](./animation/DESIGN.md) for the two-phase
1594
+ roadmap: Phase 1 (everything below) ships now; Phase 2 is the video-game-style
1595
+ animation graph (named layers, masking, crossfades, procedural clips) - not
1596
+ in this release.
1597
+
1598
+ ### 14.1 Pose types: `Pose` vs `PartialPose`
1599
+
1600
+ ```ts
1601
+ import type { Pose, PartialPose } from "@pollen-robotics/reachy-mini-sdk/animation";
1602
+
1603
+ interface Pose { head: number[]; antennas: [number, number]; body_yaw: number }
1604
+ interface PartialPose { head?: number[]; antennas?: [number, number] | number[]; body_yaw?: number }
1605
+ ```
1606
+
1607
+ - **`Pose`**: all three channels required. Use for hand-authored targets
1608
+ where you want the type system to nag if you forget a channel.
1609
+ - **`PartialPose`**: any subset. Matches the SDK's `setTarget` /
1610
+ `gotoTarget` partial-update semantics, and is the shape of
1611
+ `reachy.robotState` (fields appear only after the daemon has emitted
1612
+ them).
1613
+
1614
+ Wire-format units, everywhere: `head` is a flat 16-float row-major 4×4
1615
+ homogeneous matrix, `antennas` is `[right, left]` in **radians**,
1616
+ `body_yaw` is a scalar in **radians**.
1617
+
1618
+ > Avoid carrying degrees around in your motion code. Convert at the UI
1619
+ > boundary (`degToRad` / `radToDeg` from the SDK root) so everything
1620
+ > below the boundary speaks wire units. Apps that drift between deg and
1621
+ > rad ship subtle off-by-57 bugs.
1622
+
1623
+ ### 14.2 Distance & scaled duration
1624
+
1625
+ The pure math behind "how big is this move" and "how long should it
1626
+ take", computed **synchronously client-side** so apps can sync audio
1627
+ cues, schedule streamer ticks, and live-tune constants without an extra
1628
+ round-trip to the daemon.
1629
+
1630
+ ```ts
1631
+ import {
1632
+ distanceBetweenPoses,
1633
+ scaledDuration,
1634
+ DEFAULT_SCALED_DURATION_PRESET,
1635
+ } from "@pollen-robotics/reachy-mini-sdk/animation";
1636
+
1637
+ const dist = distanceBetweenPoses(reachy.robotState, target);
1638
+ // { head?: number /* magic-mm */,
1639
+ // antennas?: { right: number, left: number } /* deg */,
1640
+ // body_yaw?: number /* deg */ }
1641
+
1642
+ const plan = scaledDuration(reachy.robotState, target);
1643
+ // { duration: number,
1644
+ // limiter: "head"|"antennaR"|"antennaL"|"body_yaw"|null,
1645
+ // perChannel: { head?, antennaR?, antennaL?, body_yaw? } }
1646
+
1647
+ reachy.gotoTarget({ ...target, duration: plan.duration });
1648
+ ```
1649
+
1650
+ The head metric is **magic-mm** - `translation_mm + rotation_deg` fused
1651
+ into a single scalar that's monotonic with "how big does this move
1652
+ feel". Mirror of the daemon's
1653
+ `utils/interpolation.distance_between_poses`.
1654
+
1655
+ **Log `plan.limiter` in your motion paths.** It answers "why does the
1656
+ home return take 1.2 s?" instantly without bisecting through three
1657
+ channels by hand.
1658
+
1659
+ The default preset is calibrated on a real Reachy Mini (May 2026 bench):
1660
+
1661
+ | Field | Default | Rationale |
1662
+ |---|---|---|
1663
+ | `headSecPerMagicMm` | `0.015` | 0.02 reads as noticeably slow on the head. |
1664
+ | `antennaSecPerDeg` | `0.005` | Antennas are light; PR [#952](https://github.com/pollen-robotics/reachy_mini/pull/952) resonance window penalises too-fast moves. |
1665
+ | `bodyYawSecPerDeg` | `0.015` | Body sits between head (heavy) and antennas (light). |
1666
+ | `minDurationSec` | `0.01` | Low floor so small in-app corrections feel snappy. |
1667
+ | `maxDurationSec` | `1.5` | Matches the host shell's leave-protocol budget so `safelyReturnToPose` fits inside `onLeave`. |
1668
+
1669
+ Derive a custom preset by spreading (don't mutate - the constant is
1670
+ deep-frozen):
1671
+
1672
+ ```ts
1673
+ const SLOWER_HEAD = {
1674
+ ...DEFAULT_SCALED_DURATION_PRESET,
1675
+ headSecPerMagicMm: 0.03,
1676
+ };
1677
+ const plan = scaledDuration(current, target, SLOWER_HEAD);
1678
+ ```
1679
+
1680
+ Edge case: when no channel overlaps between `current` and `target`,
1681
+ `scaledDuration` returns `{ duration: minDurationSec, limiter: null,
1682
+ perChannel: {} }`. The resulting `gotoTarget` is a safe no-op held over
1683
+ the minimum dwell. Pass an explicit `duration` if you need a longer
1684
+ dwell.
1685
+
1686
+ ### 14.3 Safe return to home pose (`safelyReturnToPose`)
1687
+
1688
+ The canonical "return to safe rest" recipe in one call. Use it as your
1689
+ `onLeave` body (see [§8 Cleaning up on leave](#8-cleaning-up-on-leave)):
1690
+
1691
+ ```ts
1692
+ import { safelyReturnToPose } from "@pollen-robotics/reachy-mini-sdk/animation";
1693
+
1694
+ handle.onLeave(() => safelyReturnToPose(handle.reachy));
1695
+ ```
1696
+
1697
+ What it does, in order:
1698
+
1699
+ 1. `reachy.setMotorMode("enabled")` - safe since daemon PR
1700
+ [#1138](https://github.com/pollen-robotics/reachy_mini/pull/1138)
1701
+ (torque-on now pins all targets to the present pose before flipping).
1702
+ 2. Reads `reachy.robotState` for the current pose snapshot.
1703
+ 3. Computes `scaledDuration(current, target, preset)`.
1704
+ 4. Dispatches `reachy.gotoTarget({ ...target, duration })`.
1705
+
1706
+ Returns the `ScaledDurationResult` **synchronously after dispatching** -
1707
+ does NOT await completion. If you need to await the motion finishing,
1708
+ subscribe to the `state` event or `await sleep(plan.duration * 1000)`.
1709
+
1710
+ Default target is `INIT_POSE`:
1711
+
1712
+ ```ts
1713
+ import { INIT_POSE } from "@pollen-robotics/reachy-mini-sdk/animation";
1714
+
1715
+ // INIT_POSE.head : identity 4×4 matrix (np.eye(4) in daemon parlance)
1716
+ // INIT_POSE.antennas : [-0.1745, 0.1745] ≈ ±10° outward (anti-resonance offset, PR #952)
1717
+ // INIT_POSE.body_yaw : 0
1718
+ ```
1719
+
1720
+ Override for app-specific rest poses:
1721
+
1722
+ ```ts
1723
+ handle.onLeave(() =>
1724
+ safelyReturnToPose(handle.reachy, {
1725
+ target: { head: customHeadMatrix, antennas: [0, 0], body_yaw: 0 },
1726
+ })
1727
+ );
1728
+ ```
1729
+
1730
+ **Safe to call when the data channel is closed.** Every underlying SDK
1731
+ call swallows "channel closed" errors silently; the planned
1732
+ `ScaledDurationResult` is still returned so you can log the move that
1733
+ would have happened.
1734
+
1735
+ ### 14.4 Standalone exit hooks (`installShutdownHandler`)
1736
+
1737
+ > **Host-shell apps: stop. Use `handle.onLeave()` from `connectToHost()`
1738
+ > instead** - it integrates with the host's leave-protocol budget and
1739
+ > avoids double-firing. Mixing the two dispatches `safelyReturnToPose`
1740
+ > twice on close.
1741
+
1742
+ `installShutdownHandler` is for **standalone apps** (test benches,
1743
+ custom dashboards, anything that doesn't go through `mountHost()`):
1744
+
1745
+ ```ts
1746
+ import { installShutdownHandler } from "@pollen-robotics/reachy-mini-sdk/animation";
1747
+
1748
+ const reachy = new ReachyMini({ appName: "my-bench" });
1749
+ await reachy.authenticate();
1750
+ await reachy.connect();
1751
+ // ...pick robot, startSession...
1752
+ installShutdownHandler(reachy);
1753
+ ```
1754
+
1755
+ What it does:
1756
+
1757
+ - Wires `pagehide` AND `beforeunload`. Both can fire on a real tab
1758
+ close, but in different scenarios - mobile Safari only fires
1759
+ `pagehide`. Wiring both is necessary for cross-browser coverage.
1760
+ - Reentry-guards so `safelyReturnToPose` doesn't dispatch twice when
1761
+ both handlers fire on the same close.
1762
+ - Defaults to `onlyWhenStreaming: true`: skips the goto when no session
1763
+ is live, so a stale tab where the user never picked a robot doesn't
1764
+ command anything on close.
1765
+
1766
+ ### 14.5 Daemon parity warning
1767
+
1768
+ `INIT_POSE` and the magic-mm head coefficient mirror constants in the
1769
+ **Python daemon**:
1770
+
1771
+ - `INIT_POSE.head` ↔ `Backend.INIT_HEAD_POSE` (`np.eye(4)`) in
1772
+ `src/reachy_mini/daemon/backend/abstract.py`.
1773
+ - `INIT_POSE.antennas` ↔ `Backend.INIT_ANTENNAS_JOINT_POSITIONS`
1774
+ (`[-0.1745, 0.1745]`).
1775
+ - The magic-mm head distance ↔
1776
+ `src/reachy_mini/daemon/utils/interpolation.py:distance_between_poses`.
1777
+
1778
+ **If a daemon PR changes any of these, the JS side must follow in the
1779
+ same release.** `ts/animation/presets.ts` calls this out at the top of
1780
+ each constant; respect it. Both `INIT_POSE` and
1781
+ `DEFAULT_SCALED_DURATION_PRESET` are deep-frozen on the JS side so apps
1782
+ can't accidentally drift via `INIT_POSE.head[0] = 0` (mutations throw in
1783
+ strict mode, silently no-op otherwise).
1784
+
1785
+ ### 14.6 Anti-patterns
1786
+
1787
+ | Don't | Do |
1788
+ |---|---|
1789
+ | Re-implement `distanceBetweenPoses` / `scaledDuration` in your app. | Import from `/animation`. The lib exists exactly because three apps did this and drifted. |
1790
+ | Mix `installShutdownHandler` and `onLeave` in a host-shell app. | `onLeave` only. They both dispatch `safelyReturnToPose` and step on each other. |
1791
+ | Call `reachy.stopSession()` inside your `onLeave`. | Let the host tear down. Do app-specific cleanup (return-to-pose, close audio, close sockets) and return. |
1792
+ | Mutate `INIT_POSE` or `DEFAULT_SCALED_DURATION_PRESET`. | They're deep-frozen; spread to derive a variant. |
1793
+ | `await safelyReturnToPose(...)` expecting the move to complete. | It resolves after **dispatch**, not after motion finishes. `await sleep(plan.duration * 1000)` or subscribe to `state` if you need to wait. |
1794
+ | Carry degrees through your motion code. | Convert at the UI boundary; speak radians + magic-mm everywhere below it. |
1795
+ | Pass `null` head / antennas / body_yaw to opt a channel out of a `gotoTarget`. | Use `PartialPose` and **omit** the channel. The SDK treats omission as "hold previous target". |