@pollen-robotics/reachy-mini-sdk 1.7.3 → 1.8.0-main.54c2dcd
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/APP_CREATION_GUIDE.md +1795 -0
- package/README.md +65 -5
- package/dist/animation/distance.d.ts +87 -0
- package/dist/animation/distance.d.ts.map +1 -0
- package/dist/animation/distance.js +140 -0
- package/dist/animation/distance.js.map +1 -0
- package/dist/animation/index.d.ts +34 -0
- package/dist/animation/index.d.ts.map +1 -0
- package/dist/animation/index.js +33 -0
- package/dist/animation/index.js.map +1 -0
- package/dist/animation/pose.d.ts +40 -0
- package/dist/animation/pose.d.ts.map +1 -0
- package/dist/animation/pose.js +15 -0
- package/dist/animation/pose.js.map +1 -0
- package/dist/animation/presets.d.ts +85 -0
- package/dist/animation/presets.d.ts.map +1 -0
- package/dist/animation/presets.js +82 -0
- package/dist/animation/presets.js.map +1 -0
- package/dist/animation/safe-return.d.ts +90 -0
- package/dist/animation/safe-return.d.ts.map +1 -0
- package/dist/animation/safe-return.js +123 -0
- package/dist/animation/safe-return.js.map +1 -0
- package/dist/lib/math.d.ts +23 -0
- package/dist/lib/math.d.ts.map +1 -0
- package/dist/lib/math.js +40 -0
- package/dist/lib/math.js.map +1 -0
- package/dist/lib/reachy-mini.d.ts +193 -0
- package/dist/lib/reachy-mini.d.ts.map +1 -0
- package/dist/lib/reachy-mini.js +1495 -0
- package/dist/lib/reachy-mini.js.map +1 -0
- package/dist/lib/types.d.ts +384 -0
- package/dist/lib/types.d.ts.map +1 -0
- package/dist/lib/types.js +9 -0
- package/dist/lib/types.js.map +1 -0
- package/dist/lib/upload-helpers.d.ts +18 -0
- package/dist/lib/upload-helpers.d.ts.map +1 -0
- package/dist/lib/upload-helpers.js +44 -0
- package/dist/lib/upload-helpers.js.map +1 -0
- package/dist/lib/url-helpers.d.ts +63 -0
- package/dist/lib/url-helpers.d.ts.map +1 -0
- package/dist/lib/url-helpers.js +153 -0
- package/dist/lib/url-helpers.js.map +1 -0
- package/dist/reachy-mini-sdk.d.ts +69 -0
- package/dist/reachy-mini-sdk.d.ts.map +1 -0
- package/dist/reachy-mini-sdk.js +68 -0
- package/dist/reachy-mini-sdk.js.map +1 -0
- package/host/README.md +157 -0
- package/host/dist/ReachyHost.d.ts +40 -0
- package/host/dist/ReachyHost.d.ts.map +1 -0
- package/host/dist/assets/index.d.ts +16 -0
- package/host/dist/assets/index.d.ts.map +1 -0
- package/host/dist/chunks/index-CyLPysJS.js +400 -0
- package/host/dist/chunks/mountHost-zdJ99_oZ.js +48392 -0
- package/host/dist/chunks/reachy-mini-BV9nAnC1.js +1283 -0
- package/host/dist/components/ConnectingView.d.ts +13 -0
- package/host/dist/components/ConnectingView.d.ts.map +1 -0
- package/host/dist/components/EmbedFrame.d.ts +16 -0
- package/host/dist/components/EmbedFrame.d.ts.map +1 -0
- package/host/dist/components/ErrorView.d.ts +21 -0
- package/host/dist/components/ErrorView.d.ts.map +1 -0
- package/host/dist/components/LeavingView.d.ts +33 -0
- package/host/dist/components/LeavingView.d.ts.map +1 -0
- package/host/dist/components/PickerView.d.ts +51 -0
- package/host/dist/components/PickerView.d.ts.map +1 -0
- package/host/dist/components/PostOAuthSplash.d.ts +25 -0
- package/host/dist/components/PostOAuthSplash.d.ts.map +1 -0
- package/host/dist/components/ReachyHostShell.d.ts +21 -0
- package/host/dist/components/ReachyHostShell.d.ts.map +1 -0
- package/host/dist/components/SignInView.d.ts +32 -0
- package/host/dist/components/SignInView.d.ts.map +1 -0
- package/host/dist/components/StepsProgressIndicator.d.ts +41 -0
- package/host/dist/components/StepsProgressIndicator.d.ts.map +1 -0
- package/host/dist/components/TopBar.d.ts +52 -0
- package/host/dist/components/TopBar.d.ts.map +1 -0
- package/host/dist/components/WelcomeBackOverlay.d.ts +28 -0
- package/host/dist/components/WelcomeBackOverlay.d.ts.map +1 -0
- package/host/dist/embed/index.d.ts +152 -0
- package/host/dist/embed/index.d.ts.map +1 -0
- package/host/dist/entry/auto.d.ts +12 -0
- package/host/dist/entry/auto.d.ts.map +1 -0
- package/host/dist/entry/auto.js +6 -0
- package/host/dist/entry/embed.d.ts +12 -0
- package/host/dist/entry/embed.d.ts.map +1 -0
- package/host/dist/entry/embed.js +6 -0
- package/host/dist/hooks/useHfProfile.d.ts +7 -0
- package/host/dist/hooks/useHfProfile.d.ts.map +1 -0
- package/host/dist/hooks/useHostBridge.d.ts +37 -0
- package/host/dist/hooks/useHostBridge.d.ts.map +1 -0
- package/host/dist/hooks/useOAuth.d.ts +16 -0
- package/host/dist/hooks/useOAuth.d.ts.map +1 -0
- package/host/dist/hooks/useRobots.d.ts +17 -0
- package/host/dist/hooks/useRobots.d.ts.map +1 -0
- package/host/dist/hooks/useSdk.d.ts +16 -0
- package/host/dist/hooks/useSdk.d.ts.map +1 -0
- package/host/dist/index.d.ts +25 -0
- package/host/dist/index.d.ts.map +1 -0
- package/host/dist/index.js +14 -0
- package/host/dist/lib/centralListener.d.ts +73 -0
- package/host/dist/lib/centralListener.d.ts.map +1 -0
- package/host/dist/lib/centralRest.d.ts +35 -0
- package/host/dist/lib/centralRest.d.ts.map +1 -0
- package/host/dist/lib/protocol.d.ts +230 -0
- package/host/dist/lib/protocol.d.ts.map +1 -0
- package/host/dist/lib/protocol.js +47 -0
- package/host/dist/lib/sdk-types.d.ts +46 -0
- package/host/dist/lib/sdk-types.d.ts.map +1 -0
- package/host/dist/lib/settings.d.ts +70 -0
- package/host/dist/lib/settings.d.ts.map +1 -0
- package/host/dist/lib/signalingUrl.d.ts +28 -0
- package/host/dist/lib/signalingUrl.d.ts.map +1 -0
- package/host/dist/lib/theme.d.ts +4 -0
- package/host/dist/lib/theme.d.ts.map +1 -0
- package/host/dist/lib/themeMode.d.ts +9 -0
- package/host/dist/lib/themeMode.d.ts.map +1 -0
- package/host/dist/lib/tokens.d.ts +64 -0
- package/host/dist/lib/tokens.d.ts.map +1 -0
- package/host/dist/mountHost.d.ts +36 -0
- package/host/dist/mountHost.d.ts.map +1 -0
- package/package.json +79 -11
- package/reachy-mini-sdk.js +0 -2013
|
@@ -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". |
|