tfrere HF Staff Cursor commited on
Commit
761afbd
·
1 Parent(s): 9df2ea2

feat(skill): sync canonical daemon docs at build + modernize app template

Browse files

Pull the canonical Reachy Mini docs (APP_CREATION_GUIDE, javascript-sdk,
AGENTS + robot-knowledge skills) from pollen-robotics/reachy_mini@main on
every build via scripts/sync-daemon-docs.mjs, and resolve the canonical SDK
pin (max of npm dist-tags latest/rc) to keep the generated app template in
sync. Network-tolerant: keeps the committed snapshot on fetch failure.

Modernize the generated 3-file app template to the published npm SDK:
- import @pollen-robotics/reachy-mini-sdk from jsDelivr /+esm (no bundler),
replacing the stale gh feature-branch single-file build
- setHeadPose -> setHeadRpyDeg, setAntennas(rad) -> setAntennasDeg(deg),
add setBodyYawDeg
- point the agent at the auto-fetched daemon/ docs, with a guardrail that
the upstream npm/Vite/mountHost toolchain does NOT apply here

Make skill-loader discover docs recursively so daemon/*.md are exposed via
read_skill_doc. The auth/session lifecycle and 3-file/no-build constraints
are unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>

Dockerfile CHANGED
@@ -47,8 +47,11 @@ COPY --from=backend-builder /build/backend/node_modules /app/backend/node_modu
47
  COPY --from=backend-builder /build/backend/package.json /app/backend/package.json
48
 
49
  # Skills are loaded from source markdown at runtime (skill-loader.ts reads
50
- # them from disk). Ship them alongside the compiled JS.
51
- COPY backend/src/agent/skills /app/backend/dist/agent/skills
 
 
 
52
 
53
  # Frontend static build - server.ts detects this folder automatically.
54
  COPY --from=frontend-builder /build/frontend/dist /app/frontend/dist
 
47
  COPY --from=backend-builder /build/backend/package.json /app/backend/package.json
48
 
49
  # Skills are loaded from source markdown at runtime (skill-loader.ts reads
50
+ # them from disk). We copy them FROM the backend-builder stage (not the build
51
+ # context) so the build-time `sync-daemon-docs.mjs` output is included: the
52
+ # freshly fetched `daemon/*.md` canonical docs and the SDK pin substituted into
53
+ # SKILL.md.
54
+ COPY --from=backend-builder /build/backend/src/agent/skills /app/backend/dist/agent/skills
55
 
56
  # Frontend static build - server.ts detects this folder automatically.
57
  COPY --from=frontend-builder /build/frontend/dist /app/frontend/dist
backend/package.json CHANGED
@@ -5,7 +5,8 @@
5
  "type": "module",
6
  "scripts": {
7
  "dev": "tsx watch src/server.ts",
8
- "build": "tsc",
 
9
  "start": "node dist/server.js",
10
  "typecheck": "tsc --noEmit"
11
  },
 
5
  "type": "module",
6
  "scripts": {
7
  "dev": "tsx watch src/server.ts",
8
+ "sync-docs": "node scripts/sync-daemon-docs.mjs",
9
+ "build": "node scripts/sync-daemon-docs.mjs && tsc",
10
  "start": "node dist/server.js",
11
  "typecheck": "tsc --noEmit"
12
  },
backend/scripts/sync-daemon-docs.mjs ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+ // Sync canonical Reachy Mini docs from the daemon repo at build time.
3
+ //
4
+ // Why: the vibe-coder ships an agent skill that teaches an LLM how to build a
5
+ // Reachy Mini browser app. The robot knowledge and the JS SDK surface live in
6
+ // `pollen-robotics/reachy_mini`. Hand-copying them here drifts. Instead we pull
7
+ // the canonical markdown from the daemon `main` branch on every build and we
8
+ // resolve the canonical SDK pin from the npm dist-tags, then substitute it into
9
+ // the generated app template (SKILL.md).
10
+ //
11
+ // This runs inside the Docker `backend-builder` stage (via `npm run build`) and
12
+ // locally (`npm run sync-docs`). It is network-tolerant: if the registry or
13
+ // raw.githubusercontent is unreachable, it keeps the committed snapshot and
14
+ // exits 0 so the build never breaks.
15
+
16
+ import { mkdir, writeFile, readFile, readdir } from "node:fs/promises";
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
21
+ const SKILL_DIR = path.resolve(
22
+ __dirname,
23
+ "../src/agent/skills/reachy-mini-app",
24
+ );
25
+ const DAEMON_DIR = path.join(SKILL_DIR, "daemon");
26
+ const SKILL_HUB = path.join(SKILL_DIR, "SKILL.md");
27
+
28
+ const DAEMON_REPO = "pollen-robotics/reachy_mini";
29
+ // Defaults to `main` (the durable canonical ref). Override with
30
+ // DAEMON_DOCS_REF to track a branch/tag/SHA - e.g. an unmerged docs PR that
31
+ // already carries the modern bare-HTML + CDN guidance.
32
+ const DAEMON_REF = process.env.DAEMON_DOCS_REF || "main";
33
+ const RAW_BASE = `https://raw.githubusercontent.com/${DAEMON_REPO}/${DAEMON_REF}`;
34
+
35
+ const SDK_PKG = "@pollen-robotics/reachy-mini-sdk";
36
+ const NPM_REGISTRY = `https://registry.npmjs.org/${SDK_PKG}`;
37
+ const JSDELIVR_META = `https://data.jsdelivr.com/v1/packages/npm/${SDK_PKG}`;
38
+
39
+ // Canonical daemon docs to mirror. `remote` is relative to the repo root,
40
+ // `local` is the filename written under `daemon/` (auto-discovered by the
41
+ // skill-loader, exposed to the agent via `read_skill_doc`).
42
+ const DOCS = [
43
+ // The single source of truth for building a JS app. Its bare-HTML + CDN
44
+ // section is the pattern this vibe-coder uses; its robotics best-practices
45
+ // section covers safe teardown.
46
+ { remote: "ts/APP_CREATION_GUIDE.md", local: "APP_CREATION_GUIDE.md" },
47
+ // JS SDK runtime reference - the authoritative method/event names.
48
+ { remote: "docs/source/SDK/javascript-sdk.md", local: "javascript-sdk.md" },
49
+ // Top-level orientation hub.
50
+ { remote: "AGENTS.md", local: "AGENTS.md" },
51
+ // Robot-knowledge skills (behaviour, motion, interaction, low-level access).
52
+ { remote: "skills/motion-philosophy.md", local: "motion-philosophy.md" },
53
+ { remote: "skills/interaction-patterns.md", local: "interaction-patterns.md" },
54
+ { remote: "skills/control-loops.md", local: "control-loops.md" },
55
+ { remote: "skills/rest-api.md", local: "rest-api.md" },
56
+ { remote: "skills/safe-torque.md", local: "safe-torque.md" },
57
+ ];
58
+
59
+ const FETCH_TIMEOUT_MS = 15_000;
60
+
61
+ async function fetchText(url) {
62
+ const ctrl = new AbortController();
63
+ const timer = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
64
+ try {
65
+ const res = await fetch(url, { signal: ctrl.signal });
66
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
67
+ return await res.text();
68
+ } finally {
69
+ clearTimeout(timer);
70
+ }
71
+ }
72
+
73
+ async function fetchJson(url) {
74
+ return JSON.parse(await fetchText(url));
75
+ }
76
+
77
+ // ── semver (prerelease-aware) ───────────────────────────────────────────────
78
+ // Minimal comparator: numeric core dominates; a prerelease ranks below its own
79
+ // release but we only ever compare tags against each other, so the standard
80
+ // semver precedence rules are enough.
81
+ function parseSemver(v) {
82
+ const m = /^(\d+)\.(\d+)\.(\d+)(?:-(.+))?$/.exec(String(v).trim());
83
+ if (!m) return null;
84
+ return {
85
+ major: Number(m[1]),
86
+ minor: Number(m[2]),
87
+ patch: Number(m[3]),
88
+ prerelease: m[4] ?? null,
89
+ };
90
+ }
91
+
92
+ function comparePrerelease(a, b) {
93
+ // No prerelease ranks higher than a prerelease (1.8.0 > 1.8.0-rc1).
94
+ if (a === null && b === null) return 0;
95
+ if (a === null) return 1;
96
+ if (b === null) return -1;
97
+ const as = a.split(".");
98
+ const bs = b.split(".");
99
+ for (let i = 0; i < Math.max(as.length, bs.length); i++) {
100
+ const x = as[i];
101
+ const y = bs[i];
102
+ if (x === undefined) return -1;
103
+ if (y === undefined) return 1;
104
+ const xn = /^\d+$/.test(x);
105
+ const yn = /^\d+$/.test(y);
106
+ if (xn && yn) {
107
+ const d = Number(x) - Number(y);
108
+ if (d !== 0) return d < 0 ? -1 : 1;
109
+ } else if (xn !== yn) {
110
+ return xn ? -1 : 1; // numeric identifiers rank lower than alphanumeric
111
+ } else if (x !== y) {
112
+ return x < y ? -1 : 1;
113
+ }
114
+ }
115
+ return 0;
116
+ }
117
+
118
+ function compareSemver(a, b) {
119
+ const pa = parseSemver(a);
120
+ const pb = parseSemver(b);
121
+ if (!pa && !pb) return 0;
122
+ if (!pa) return -1;
123
+ if (!pb) return 1;
124
+ for (const key of ["major", "minor", "patch"]) {
125
+ if (pa[key] !== pb[key]) return pa[key] < pb[key] ? -1 : 1;
126
+ }
127
+ return comparePrerelease(pa.prerelease, pb.prerelease);
128
+ }
129
+
130
+ function semverMax(...versions) {
131
+ return versions
132
+ .filter(Boolean)
133
+ .reduce((best, v) => (best && compareSemver(best, v) >= 0 ? best : v), null);
134
+ }
135
+
136
+ // ── canonical pin resolution ────────────────────────────────────────────────
137
+ // Canonical = max(latest, rc): always the newest published release, including
138
+ // a release candidate when one is ahead of the stable `latest`.
139
+ async function resolveCanonicalPin() {
140
+ let tags = null;
141
+ try {
142
+ const meta = await fetchJson(NPM_REGISTRY);
143
+ tags = meta?.["dist-tags"] ?? null;
144
+ } catch (err) {
145
+ console.warn(`[sync-docs] npm registry unreachable (${err.message}), trying jsDelivr`);
146
+ }
147
+ if (!tags) {
148
+ try {
149
+ const meta = await fetchJson(JSDELIVR_META);
150
+ tags = meta?.tags ?? null;
151
+ } catch (err) {
152
+ console.warn(`[sync-docs] jsDelivr unreachable (${err.message})`);
153
+ }
154
+ }
155
+ if (!tags) return null;
156
+ const pin = semverMax(tags.latest, tags.rc);
157
+ if (pin) {
158
+ console.log(
159
+ `[sync-docs] dist-tags latest=${tags.latest} rc=${tags.rc} -> canonical pin ${pin}`,
160
+ );
161
+ }
162
+ return pin;
163
+ }
164
+
165
+ // ── doc sync ────────────────────────────────────────────────────────────────
166
+ function buildHeader(remote, pin) {
167
+ const stamp = new Date().toISOString().slice(0, 10);
168
+ const pinLine = pin ? ` · canonical SDK pin: \`${pin}\`` : "";
169
+ return (
170
+ `> **Auto-fetched** from [\`${DAEMON_REPO}@${DAEMON_REF}\`](https://github.com/${DAEMON_REPO}/blob/${DAEMON_REF}/${remote}) ` +
171
+ `on ${stamp}${pinLine}.\n` +
172
+ `> Do not edit by hand - run \`npm run sync-docs\` to refresh.\n\n`
173
+ );
174
+ }
175
+
176
+ async function syncDocs(pin) {
177
+ await mkdir(DAEMON_DIR, { recursive: true });
178
+ let fetched = 0;
179
+ let kept = 0;
180
+ for (const { remote, local } of DOCS) {
181
+ const dest = path.join(DAEMON_DIR, local);
182
+ try {
183
+ const body = await fetchText(`${RAW_BASE}/${remote}`);
184
+ await writeFile(dest, buildHeader(remote, pin) + body, "utf-8");
185
+ fetched++;
186
+ console.log(`[sync-docs] ✓ ${remote} -> daemon/${local}`);
187
+ } catch (err) {
188
+ // Keep whatever snapshot is already committed; never break the build.
189
+ console.warn(
190
+ `[sync-docs] ✗ ${remote} (${err.message}) - keeping committed snapshot`,
191
+ );
192
+ kept++;
193
+ }
194
+ }
195
+ return { fetched, kept };
196
+ }
197
+
198
+ // ── pin substitution in SKILL.md ────────────────────────────────────────────
199
+ // Rewrites every `@pollen-robotics/reachy-mini-sdk@<version>` occurrence (the
200
+ // CDN import and the API reference) to the resolved canonical pin.
201
+ async function substitutePin(pin) {
202
+ if (!pin) {
203
+ console.warn("[sync-docs] no canonical pin resolved - skipping SKILL.md pin update");
204
+ return;
205
+ }
206
+ let hub;
207
+ try {
208
+ hub = await readFile(SKILL_HUB, "utf-8");
209
+ } catch (err) {
210
+ console.warn(`[sync-docs] cannot read SKILL.md (${err.message}) - skipping pin update`);
211
+ return;
212
+ }
213
+ const pinRe = /(@pollen-robotics\/reachy-mini-sdk@)[^/"'\s]+/g;
214
+ const before = hub;
215
+ hub = hub.replace(pinRe, `$1${pin}`);
216
+ if (hub === before) {
217
+ console.log("[sync-docs] SKILL.md already pinned to the canonical version");
218
+ return;
219
+ }
220
+ await writeFile(SKILL_HUB, hub, "utf-8");
221
+ console.log(`[sync-docs] SKILL.md SDK pin updated to ${pin}`);
222
+ }
223
+
224
+ async function main() {
225
+ console.log("[sync-docs] syncing canonical daemon docs + SDK pin…");
226
+ const pin = await resolveCanonicalPin();
227
+ const { fetched, kept } = await syncDocs(pin);
228
+ await substitutePin(pin);
229
+ console.log(`[sync-docs] done (fetched ${fetched}, kept ${kept}/${DOCS.length}).`);
230
+ }
231
+
232
+ main().catch((err) => {
233
+ // Last-resort guard: still exit 0 so a transient failure never breaks build.
234
+ console.warn(`[sync-docs] unexpected error: ${err?.stack || err}`);
235
+ process.exit(0);
236
+ });
backend/src/agent/skill-loader.ts CHANGED
@@ -35,7 +35,13 @@ export function loadSkill(id: string): Skill | null {
35
  const dir = path.join(SKILLS_DIR, id);
36
  if (!existsSync(dir)) return null;
37
 
38
- const files = readdirSync(dir).filter((f) => f.endsWith(".md"));
 
 
 
 
 
 
39
  if (files.length === 0) return null;
40
 
41
  const hubFile = files.find((f) => f.toLowerCase() === "skill.md");
 
35
  const dir = path.join(SKILLS_DIR, id);
36
  if (!existsSync(dir)) return null;
37
 
38
+ // Recursive so canonical docs synced into a `daemon/` subfolder
39
+ // (scripts/sync-daemon-docs.mjs) are discovered too. Relative paths are
40
+ // kept as the doc `filename` (e.g. "daemon/javascript-sdk.md"), which is
41
+ // also how SKILL.md links to them and how `read_skill_doc` addresses them.
42
+ const files = readdirSync(dir, { recursive: true })
43
+ .map((f) => String(f).split(path.sep).join("/"))
44
+ .filter((f) => f.endsWith(".md"));
45
  if (files.length === 0) return null;
46
 
47
  const hubFile = files.find((f) => f.toLowerCase() === "skill.md");
backend/src/agent/skills/reachy-mini-app/SKILL.md CHANGED
@@ -44,7 +44,7 @@ that does.
44
  ## What this mode can do well
45
 
46
  - HF OAuth login and robot session management via the JS SDK.
47
- - Head and antenna motion (`setHeadPose`, `setAntennas`).
48
  - Live WebRTC video stream (`attachVideo`).
49
  - Preset robot sound playback (`playSound`).
50
  - Raw data-channel commands (`sendRaw`) for low-level motor targets.
@@ -323,12 +323,19 @@ nice-to-have, not a blocker.
323
  </script>
324
 
325
  <!--
326
- The ReachyMini SDK is a pure ES module served from jsDelivr. We expose
327
- it on `window` so main.js (classic script) can use it without fighting
328
- with static module resolution.
 
 
 
 
 
 
 
329
  -->
330
  <script type="module">
331
- import { ReachyMini } from "https://cdn.jsdelivr.net/gh/pollen-robotics/reachy_mini@feat/ehance-js-lib/js/reachy-mini.js";
332
  window.ReachyMini = ReachyMini;
333
  window.dispatchEvent(new Event("reachymini:ready"));
334
  </script>
@@ -494,7 +501,7 @@ nice-to-have, not a blocker.
494
  // Flow driven by the central button + direction pad:
495
  // signed-out → robot.login() (HF OAuth redirect)
496
  // authenticated → robot.connect() + robot.startSession(robotId)
497
- // ready → direction buttons fire robot.setHeadPose(roll, pitch, yaw)
498
  //
499
  // When the page is embedded (host shell, mobile app, or vibe-coder
500
  // preview), the bootstrap script in `index.html` has already seeded
@@ -684,7 +691,7 @@ function bindHeadControls() {
684
  if (!btn || !robot) return;
685
  const pose = HEAD_POSES[btn.dataset.pose];
686
  if (!pose) return;
687
- robot.setHeadPose(pose.roll, pose.pitch, pose.yaw);
688
  });
689
  }
690
 
@@ -846,6 +853,37 @@ the robot can do* or *how it should behave*.
846
  endpoints. Usually you use the JS SDK; read this when you need
847
  lower-level access or a debug dashboard.
848
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
849
  ## Things you should NOT do
850
 
851
  - Do **not** create a `package.json`, `tsconfig.json`, `vite.config.*`,
@@ -864,7 +902,10 @@ the robot can do* or *how it should behave*.
864
  They all need a CORS proxy and server-side secrets, which is
865
  explicitly out of scope for this vibe coder.
866
  - Do **not** rebuild the ReachyMini JS SDK; it handles HF auth,
867
- signaling and WebRTC negotiation. Import it from jsdelivr.
 
 
 
868
  - Do **not** call `attachVideo(ui.video)` AFTER `startSession()`. The
869
  remote video track is published during the SDP exchange inside
870
  `startSession`. If the `<video>` element isn't already attached by
@@ -919,7 +960,11 @@ in `index.html` already covers `reachy:app:ready` and
919
  `reachy:host:hello`. Wire the rest yourself only if your app needs
920
  graceful teardown beyond "the iframe was destroyed".
921
 
922
- ## API quick reference (`reachy_mini` JS SDK)
 
 
 
 
923
 
924
  ```typescript
925
  new ReachyMini({ clientId, appName })
@@ -942,11 +987,12 @@ robot.stopSession()
942
  // Video - attach BEFORE startSession, save the returned detach function
943
  robot.attachVideo(el) // returns () => void, call it to stop piping frames
944
 
945
- // Motion
946
- robot.setHeadPose(roll, pitch, yaw) // degrees
947
- robot.setAntennas(right, left) // radians
948
- robot.playSound(file) // preset robot SFX
949
- robot.sendRaw(jsonPayload) // escape hatch for custom commands
 
950
 
951
  // Events
952
  robot.addEventListener("robotsChanged", (e) => e.detail.robots)
 
44
  ## What this mode can do well
45
 
46
  - HF OAuth login and robot session management via the JS SDK.
47
+ - Head, body and antenna motion (`setHeadRpyDeg`, `setBodyYawDeg`, `setAntennasDeg`).
48
  - Live WebRTC video stream (`attachVideo`).
49
  - Preset robot sound playback (`playSound`).
50
  - Raw data-channel commands (`sendRaw`) for low-level motor targets.
 
323
  </script>
324
 
325
  <!--
326
+ The ReachyMini SDK is the published npm package, loaded as a pure ES
327
+ module from jsDelivr's `/+esm` endpoint (no bundler, no build step). We
328
+ expose it on `window` so main.js (classic script) can use it without
329
+ fighting with static module resolution.
330
+
331
+ The version is pinned to a specific release for reproducibility. The
332
+ vibe-coder keeps this pin in sync with the latest SDK release on every
333
+ build (scripts/sync-daemon-docs.mjs resolves max(latest, rc) from the
334
+ npm dist-tags). This is the "bare HTML + CDN, no bundler" variant
335
+ documented in §11.5 of the daemon's APP_CREATION_GUIDE.
336
  -->
337
  <script type="module">
338
+ import { ReachyMini } from "https://cdn.jsdelivr.net/npm/@pollen-robotics/reachy-mini-sdk@1.8.0-rc1/+esm";
339
  window.ReachyMini = ReachyMini;
340
  window.dispatchEvent(new Event("reachymini:ready"));
341
  </script>
 
501
  // Flow driven by the central button + direction pad:
502
  // signed-out → robot.login() (HF OAuth redirect)
503
  // authenticated → robot.connect() + robot.startSession(robotId)
504
+ // ready → direction buttons fire robot.setHeadRpyDeg(roll, pitch, yaw)
505
  //
506
  // When the page is embedded (host shell, mobile app, or vibe-coder
507
  // preview), the bootstrap script in `index.html` has already seeded
 
691
  if (!btn || !robot) return;
692
  const pose = HEAD_POSES[btn.dataset.pose];
693
  if (!pose) return;
694
+ robot.setHeadRpyDeg(pose.roll, pose.pitch, pose.yaw);
695
  });
696
  }
697
 
 
853
  endpoints. Usually you use the JS SDK; read this when you need
854
  lower-level access or a debug dashboard.
855
 
856
+ ### Canonical daemon docs (auto-fetched at build, `daemon/` subfolder)
857
+
858
+ These are pulled verbatim from `pollen-robotics/reachy_mini@main` on every
859
+ build (see `scripts/sync-daemon-docs.mjs`), so they are always the freshest
860
+ upstream truth. Prefer them over the condensed chapters above when you need
861
+ exact, current detail. Load via `read_skill_doc`:
862
+
863
+ - [daemon/javascript-sdk.md](daemon/javascript-sdk.md): **authoritative** JS
864
+ SDK method/event reference (the exact, current names: `setHeadRpyDeg`,
865
+ `setAntennasDeg`, `setBodyYawDeg`, lifecycle, events). Check here whenever
866
+ unsure about an API name.
867
+ - [daemon/APP_CREATION_GUIDE.md](daemon/APP_CREATION_GUIDE.md): the upstream
868
+ single source of truth. Look for its **bare HTML + CDN (no bundler)** section
869
+ - the pattern this vibe-coder uses - and its **robotics best-practices /
870
+ safe-teardown** section.
871
+ - [daemon/AGENTS.md](daemon/AGENTS.md): upstream orientation hub.
872
+ - [daemon/motion-philosophy.md](daemon/motion-philosophy.md),
873
+ [daemon/interaction-patterns.md](daemon/interaction-patterns.md),
874
+ [daemon/control-loops.md](daemon/control-loops.md),
875
+ [daemon/rest-api.md](daemon/rest-api.md),
876
+ [daemon/safe-torque.md](daemon/safe-torque.md): robot-behaviour knowledge.
877
+
878
+ > **Important - what does NOT apply here.** The upstream guide describes a full
879
+ > TypeScript + Vite + `mountHost()` toolchain with `npm install` and a build
880
+ > step. **Ignore those parts.** This vibe-coder produces exactly **3 files, no
881
+ > build, no npm** (see Hard constraints above). Only the runtime concepts apply:
882
+ > the JS SDK method/event names, the robot knowledge, the bare-HTML + CDN
883
+ > pattern, and the safe-teardown best practices. The auth/session lifecycle here
884
+ > stays `login()`/`connect()`/`startSession()`/`authenticate()` - do **not**
885
+ > switch to `mountHost()`/`connectToHost()`.
886
+
887
  ## Things you should NOT do
888
 
889
  - Do **not** create a `package.json`, `tsconfig.json`, `vite.config.*`,
 
902
  They all need a CORS proxy and server-side secrets, which is
903
  explicitly out of scope for this vibe coder.
904
  - Do **not** rebuild the ReachyMini JS SDK; it handles HF auth,
905
+ signaling and WebRTC negotiation. Import the published npm package
906
+ `@pollen-robotics/reachy-mini-sdk` from jsDelivr's `/+esm` endpoint
907
+ (no bundler). Do **not** pin to a git branch or the legacy single-file
908
+ `gh/.../reachy-mini.js` build.
909
  - Do **not** call `attachVideo(ui.video)` AFTER `startSession()`. The
910
  remote video track is published during the SDP exchange inside
911
  `startSession`. If the `<video>` element isn't already attached by
 
960
  `reachy:host:hello`. Wire the rest yourself only if your app needs
961
  graceful teardown beyond "the iframe was destroyed".
962
 
963
+ ## API quick reference (`@pollen-robotics/reachy-mini-sdk`)
964
+
965
+ > For the exhaustive, always-current reference read
966
+ > [daemon/javascript-sdk.md](daemon/javascript-sdk.md) (auto-fetched from
967
+ > upstream). The summary below is the day-to-day subset.
968
 
969
  ```typescript
970
  new ReachyMini({ clientId, appName })
 
987
  // Video - attach BEFORE startSession, save the returned detach function
988
  robot.attachVideo(el) // returns () => void, call it to stop piping frames
989
 
990
+ // Motion (all angles in degrees)
991
+ robot.setHeadRpyDeg(roll, pitch, yaw) // head orientation
992
+ robot.setBodyYawDeg(yaw) // body rotation around vertical axis
993
+ robot.setAntennasDeg(right, left) // antenna positions
994
+ robot.playSound(file) // preset robot SFX
995
+ robot.sendRaw(jsonPayload) // escape hatch for custom commands
996
 
997
  // Events
998
  robot.addEventListener("robotsChanged", (e) => e.detail.robots)
backend/src/agent/skills/reachy-mini-app/daemon/AGENTS.md ADDED
@@ -0,0 +1,305 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ > **Auto-fetched** from [`pollen-robotics/reachy_mini@main`](https://github.com/pollen-robotics/reachy_mini/blob/main/AGENTS.md) on 2026-05-29 · canonical SDK pin: `1.8.0-rc1`.
2
+ > Do not edit by hand - run `npm run sync-docs` to refresh.
3
+
4
+ # Reachy Mini Development Guide for AI Agents
5
+
6
+ This guide helps AI agents assist users in developing Reachy Mini applications.
7
+
8
+ ---
9
+
10
+ ## Agent Behavior
11
+
12
+ ### FIRST: Check for agents.local.md
13
+
14
+ **Before doing anything else**, search for `agents.local.md` in the current directory:
15
+
16
+ ```
17
+ IF agents.local.md exists:
18
+ Read it immediately
19
+ It contains user configuration and session context
20
+ ELSE:
21
+ → Run skills/setup-environment.md to set up the environment
22
+ ```
23
+
24
+ This file stores the user's robot type, preferences, and setup status. Always check it first.
25
+
26
+ ### Be a Teacher
27
+
28
+ Unless the user explicitly requests otherwise:
29
+ - Explain concepts as you go
30
+ - Encourage questions ("Let me know if you'd like more detail on any of this")
31
+ - Guide non-technical users through each step
32
+ - Don't assume prior knowledge
33
+
34
+ ### Two App Flavours — Default to JS, Python for developers
35
+
36
+ Reachy Mini supports two app types. **Default to a JS (Live/Web) app** unless the user explicitly wants on-robot Python, or has a need that only Python can cover (heavy on-robot compute, rich hardware access, deterministic real-time control loops, or bundled offline LAN tooling).
37
+
38
+ | Flavour | Default for | Where it runs |
39
+ |---|---|---|
40
+ | **JS app (recommended)** | End-users, shareable-by-URL experiences, anyone who wants "open a link, use the robot". Remote launch, zero-install, streaming A/V UIs. | Static HF Space; reaches the robot over WebRTC via the central signaling server. |
41
+ | **Python app** | Developer tools, on-robot control loops, heavy motion sequencing, offline/LAN. | Robot owner's machine (laptop / CM4), optionally with a bundled web UI. |
42
+
43
+ Both coexist — a Python app can bundle a browser UI, and a JS app can call the Python daemon's REST API.
44
+
45
+ **When unsure, start JS.** If the user later discovers they need on-robot compute they can graduate to a Python app; the reverse migration is rarely needed.
46
+
47
+ Confirm the choice with the user up front, then jump to the corresponding section:
48
+ - JS path → [Live/Web/JS Apps](#livewebjs-apps)
49
+ - Python path → instructions below
50
+
51
+ ---
52
+
53
+ **Python path (for developers):**
54
+
55
+ - **NEVER create app folders manually** — use `reachy-mini-app-assistant`.
56
+ - **If a command fails**, ask the user to run it in their terminal — don't attempt complex workarounds.
57
+
58
+ ```bash
59
+ # Default template (minimal app - good for most cases):
60
+ reachy-mini-app-assistant create <app_name> <path> --publish
61
+
62
+ # Conversation template (for LLM integration, speech, making robot talk):
63
+ reachy-mini-app-assistant create --template conversation <app_name> <path> --publish
64
+ ```
65
+
66
+ Python apps put web UIs in `static/`. See `skills/create-app.md` for details.
67
+
68
+ ### Always Create plan.md Before Coding
69
+
70
+ Before implementing any app:
71
+ 1. Create `plan.md` in the app directory
72
+ 2. Write your understanding of what the user wants
73
+ 3. List your technical approach
74
+ 4. Ask clarifying questions and provide answer fields inside `plan.md`
75
+ 5. Wait for answers before coding
76
+
77
+ ### Keep Notes in agents.local.md
78
+
79
+ Use `agents.local.md` to store:
80
+ - User's robot type (Lite/Wireless)
81
+ - Environment preferences
82
+ - Useful context for future sessions
83
+ - Keep it concise
84
+
85
+ ---
86
+
87
+ ## Robot Basics
88
+
89
+ **Reachy Mini** is a small expressive robot:
90
+
91
+ | Component | Description |
92
+ |-----------|-------------|
93
+ | **Head** | 6 DOF: x, y, z, roll, pitch, yaw (via Stewart platform) |
94
+ | **Body** | Rotation around vertical axis |
95
+ | **Antennas** | 2 motors, also usable as physical buttons |
96
+
97
+ **Hardware variants:**
98
+ - **Lite**: USB connection to laptop (full compute power)
99
+ - **Wireless**: Onboard CM4, connects via WiFi (limited compute)
100
+
101
+ ---
102
+
103
+ ## SDK Essentials
104
+
105
+ ### Connection
106
+
107
+ ```python
108
+ from reachy_mini import ReachyMini
109
+
110
+ with ReachyMini() as mini:
111
+ # Your code here
112
+ ```
113
+
114
+ ### Two Motion Methods
115
+
116
+ | Method | Use when |
117
+ |--------|----------|
118
+ | `goto_target()` | **Default** - smooth interpolation for gestures that last at least 0.5s each |
119
+ | `set_target()` | Real-time control loops (e.g. tracking) at 10Hz+ |
120
+
121
+ ### Basic Example
122
+
123
+ See and run `examples/minimal_demo.py` - demonstrates connection, head motion, and antenna control.
124
+
125
+ ### Before Writing Code
126
+
127
+ - Read `docs/source/SDK/python-sdk.md` for API overview
128
+ - Skim `src/reachy_mini/reachy_mini.py` for method signatures and docstrings
129
+ - Check `examples/` for runnable code patterns
130
+
131
+ ---
132
+
133
+ ## Live/Web/JS Apps
134
+
135
+ > ### START HERE: [`ts/APP_CREATION_GUIDE.md`](ts/APP_CREATION_GUIDE.md)
136
+ >
137
+ > That guide is the **single source of truth** for building a Reachy Mini JS app: scaffolding, `public/icon.svg`, host shell, `sdk: static` deploy, `mountHost()` / `connectToHost()` API, local dev, FAQ, and the host ↔ embed architecture reference. Everything that used to live in `SPEC.md` and `APP_AUTHOR_GUIDE.md` is folded in.
138
+ >
139
+ > **Today's SDK pin** (used by all three reference apps): `@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.fd4354c`. See [§10 SDK version pinning](ts/APP_CREATION_GUIDE.md#10-sdk-version-pinning).
140
+
141
+ Browser apps that drive a Reachy Mini over WebRTC, deployed as Hugging Face Spaces. Any HF-authenticated user opens the Space URL from anywhere and reaches any robot they have access to, through the central signaling server.
142
+
143
+ **What this flavour unlocks:**
144
+
145
+ - **Zero-install sharing** - the Space URL *is* the product. No LAN, no USB, no local daemon on the end-user side.
146
+ - **Off-robot compute** - work lives in the browser or the Space backend; the robot stays a pure IO device.
147
+ - **Bidirectional media** - robot camera/mic → browser; optionally user's mic → robot speaker.
148
+ - **Free OAuth + robot picker + top bar + leave flow** via the host shell (`@pollen-robotics/reachy-mini-sdk/host`). You only write the app's UI; use any framework you want inside the iframe.
149
+
150
+ ### Clone a reference app and trim
151
+
152
+ | Reference app | Stack | Use it for |
153
+ |---|---|---|
154
+ | [`pollen-robotics/reachy_mini_minimal_conversation`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_minimal_conversation) | **Vanilla TS + Vite** | Smallest runtime, zero framework. |
155
+ | [`pollen-robotics/reachy_mini_emotions`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_emotions) | React 19 + MUI 7 + Vite | UI-rich apps (rich components, theming, deep links). |
156
+ | [`pollen-robotics/reachy_mini_telepresence`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_telepresence) | React 19 + MUI 7 + Vite | Camera / media-stream apps. |
157
+
158
+ These are kept in lockstep with every SDK release. **Mimicking them is the fastest path to a working app.** The 3-file contract, deploy steps, gotchas, and SDK pin all live in [`ts/APP_CREATION_GUIDE.md`](ts/APP_CREATION_GUIDE.md) - read it before scaffolding anything non-trivial.
159
+
160
+ > **One non-negotiable**: write `plan.md` first (same rule as Python apps) and wait for user approval before scaffolding.
161
+
162
+ ### Mobile-first by default
163
+
164
+ Unless the user explicitly asks for a desktop-only / kiosk / dev-tool UI, **assume the app will be opened on a smartphone**. The Space URL is shareable, and "open this link on your phone and play with the robot" is the most common end-user flow. The reference apps are already responsive; cloning them gets you the mobile baseline for free - don't undo it by hardcoding desktop widths. Use the viewport meta with `width=device-width, initial-scale=1, viewport-fit=cover`, fluid `rem` / `vh` / `vw` units, touch hit targets ≥ 44×44 px, no hover-only affordances, and test in Chrome devtools' phone emulation before declaring the app done.
165
+
166
+ ### SDK runtime API (motion + media + daemon-side playback)
167
+
168
+ Once `connectToHost()` resolves you get a live `ReachyMini` instance (`handle.reachy`). The full method/event reference lives in [`docs/source/SDK/javascript-sdk.md`](docs/source/SDK/javascript-sdk.md). The quick mental model:
169
+
170
+ - **Motion (degrees)**: `setHeadRpyDeg(r, p, y)`, `setAntennasDeg(right, left)`, `setBodyYawDeg(yaw)`. Atomic raw-units: `setTarget({ head?: number[16], antennas?: [rRad, lRad], body_yaw?: rad })`. The head matrix is in world frame, so `body_yaw` alone pivots the body under the head — to make the head follow the body, ship a `head` matrix in the same call with the body delta added to the head yaw. Use your own last-commanded buffer as the baseline, not telemetry (lags by one RTT).
171
+ - **Recorded-move playback (daemon-side, single-clock A/V sync)**: `playMove(motion, { audioBlob?, audioLeadMs? = -100 })` → `{finished|cancelled|error}`. `cancelMove()` stops mid-play. For record-time flows: `uploadAudio(blob)` returns `uploadId`, then `playUploadedAudio(uploadId)` resolves on the daemon's `started` broadcast (sync anchor). **Use these instead of hand-rolling `sendRaw` chunked uploads.**
172
+ - **Audio**: `setAudioMuted(bool)`, `setMicMuted(bool)`, `getVolume()` / `setVolume(0-100)`, `getMicrophoneVolume()` / `setMicrophoneVolume(0-100)`. `playSound(file)`.
173
+ - **Wake / torque**: `setMotorMode("enabled"|"disabled"|"gravity_compensation")`, `wakeUp()` / `gotoSleep()` / `isAwake()` / `ensureAwake()`.
174
+ - **Media flow**: `<video>` passed to `reachy.attachVideo()` receives the **robot's** camera/mic over WebRTC. Do NOT call `navigator.mediaDevices.getUserMedia()` to read robot media - that grabs the user's *own* laptop camera. Bidirectional audio is automatic when `enableMicrophone: true` is passed to `mountHost()`.
175
+ - **Events**: `connected`, `disconnected`, `robotsChanged`, `streaming`, `sessionStopped`, `sessionRejected` (robot busy - inspect `e.detail.activeApp`), `state` (every ~500 ms), `videoTrack`, `micSupported`, `error`.
176
+ - **Math utilities**: `rpyToMatrix`, `matrixToRpy`, `degToRad`, `radToDeg`.
177
+ - **Motion utilities** (subpath `@pollen-robotics/reachy-mini-sdk/animation`): `Pose` / `PartialPose` types, `INIT_POSE` safe-rest constant, `distanceBetweenPoses` (per-channel raw distance, head in magic-mm), `scaledDuration` → `{ duration, limiter, perChannel }` (synchronous client-side duration math, mirrors the daemon so you can sync audio cues without an RPC), `safelyReturnToPose(reachy)` (canonical `onLeave` one-liner: enables torque safely, computes scaled duration, dispatches goto to `INIT_POSE`, returns synchronously after dispatch), `installShutdownHandler(reachy)` (**standalone apps only** - host-shell apps use `handle.onLeave()` instead, mixing both double-fires the goto). Full recipe and anti-patterns: [§14 of the JS App Creation Guide](ts/APP_CREATION_GUIDE.md#14-robotics-best-practices).
178
+
179
+ **The host owns all teardown** - never call `reachy.stopSession()` yourself, register an `onLeave` callback instead. For the canonical `onLeave` body, see [§14.3](ts/APP_CREATION_GUIDE.md#143-safe-return-to-home-pose-safelyreturntopose).
180
+
181
+ ### Legacy: minimal CDN-only path (`webrtc_example`)
182
+
183
+ Before the host shell, JS apps were `sdk: static` HF Spaces with a single `index.html` importing the SDK directly from jsDelivr and reimplementing OAuth + picker + session lifecycle by hand. The canonical example is [`cduss/webrtc_example`](https://huggingface.co/spaces/cduss/webrtc_example).
184
+
185
+ **Use this only** for one-off prototypes that don't need the host shell's surface (no top bar, no picker, no theme propagation, no mobile-catalog tile). For anything you'd share, **start from a reference app instead** - you get OAuth, picker, mobile-catalog discovery, mode-B handoff, and the entire `connectToHost()` API for free.
186
+
187
+ ---
188
+
189
+ ## REST API
190
+
191
+ The daemon exposes an HTTP/WebSocket API at `http://{daemon-ip}:8000/api`.
192
+
193
+ > REST and the JS SDK's WebRTC data channel are **sibling transports** into the same `process_command()` backend on the daemon — WebRTC is a JSON subset (motion, audio, state). Commands you send from JS (`setHeadRpyDeg`, `setAntennasDeg`, …) reach the same handler as the corresponding REST endpoints; picking one transport or the other is a deployment choice, not a functional one.
194
+
195
+ - **Lite**: `localhost:8000` (daemon runs on your machine)
196
+ - **Wireless**: `reachy-mini.local:8000` or the robot's IP address
197
+
198
+ **Use REST API for:** Web UIs, non-Python clients, remote control, AI/LLM integration via HTTP. => Note: for the app to be discoverable, it must be a python app for now, this will change in a future release.
199
+
200
+ **Interactive docs:** `http://{daemon-ip}:8000/docs` (when daemon is running)
201
+
202
+ See `skills/rest-api.md` for details.
203
+
204
+ ---
205
+
206
+ ## Platform Compatibility
207
+
208
+ | Setup | Compute | Camera | Notes |
209
+ |-------|---------|--------|-------|
210
+ | **Lite** | Full (laptop) | Direct USB | Most flexible, best for dev |
211
+ | **Wireless (local)** | Limited (CM4) | Direct | Memory/CPU constrained |
212
+ | **Wireless (streamed)** | Full (laptop) | Via network | Some tracking quality loss |
213
+ | **Simulation** | Full | N/A | Can't test camera features |
214
+
215
+ ---
216
+
217
+ ## Safety Limits
218
+
219
+ | Joint | Range |
220
+ |-------|-------|
221
+ | Head pitch/roll | [-40, +40] degrees |
222
+ | Head yaw | [-180, +180] degrees |
223
+ | Body yaw | [-160, +160] degrees |
224
+ | Yaw delta (head - body) | Max 65° difference |
225
+
226
+ Gentle collisions with body are safe. SDK clamps values automatically.
227
+
228
+ For coordinate systems and architecture details, see `docs/source/SDK/core-concept.md`.
229
+
230
+ ---
231
+
232
+ ## Example Apps
233
+
234
+ | App | Key Patterns | Source |
235
+ |-----|--------------|--------|
236
+ | **reachy_mini_conversation_app** | AI integration, control loops, LLM tools | [GitHub](https://github.com/pollen-robotics/reachy_mini_conversation_app) |
237
+ | **marionette** | Recording motion, safe torque, HF dataset | [HF Space](https://huggingface.co/spaces/RemiFabre/marionette) |
238
+ | **fire_nation_attacked** | Head-as-controller, leaderboards, games | [HF Space](https://huggingface.co/spaces/RemiFabre/fire_nation_attacked) |
239
+ | **spaceship_game** | Head-as-joystick, antenna buttons | [HF Space](https://huggingface.co/spaces/apirrone/spaceship_game) |
240
+ | **reachy_mini_radio** | Antenna interaction pattern | [HF Space](https://huggingface.co/spaces/pollen-robotics/reachy_mini_radio) |
241
+ | **reachy_mini_simon** | No-GUI pattern (antenna to start) | [HF Space](https://huggingface.co/spaces/apirrone/reachy_mini_simon) |
242
+ | **hand_tracker_v2** | Camera-based control loop | [HF Space](https://huggingface.co/spaces/pollen-robotics/hand_tracker_v2) |
243
+ | **reachy_mini_dances_library** | Symbolic motion definition | [GitHub](https://github.com/pollen-robotics/reachy_mini_dances_library) |
244
+
245
+ ---
246
+
247
+ ## Documentation
248
+
249
+ | Topic | File |
250
+ |-------|------|
251
+ | **Build a JS app (single source of truth)** | **[`ts/APP_CREATION_GUIDE.md`](ts/APP_CREATION_GUIDE.md)** |
252
+ | JavaScript SDK runtime API (motion, events, daemon-side playback) | [`docs/source/SDK/javascript-sdk.md`](docs/source/SDK/javascript-sdk.md) |
253
+ | Quickstart | `docs/source/SDK/quickstart.md` |
254
+ | Python SDK | `docs/source/SDK/python-sdk.md` |
255
+ | Core concepts | `docs/source/SDK/core-concept.md` |
256
+ | Media architecture (WebRTC / GStreamer) | `docs/source/SDK/media-architecture.md` |
257
+ | AI integration | `docs/source/SDK/integration.md` |
258
+ | Troubleshooting | `docs/source/troubleshooting.md` |
259
+
260
+ For platform-specific guides (Lite, Wireless, Simulation), see `docs/source/platforms/`.
261
+
262
+ ---
263
+
264
+ ## Skills Reference
265
+
266
+ Read these files in `skills/` when you need detailed knowledge:
267
+
268
+ | Skill | When to use |
269
+ |-------|-------------|
270
+ | **setup-environment.md** | First session, no `agents.local.md` exists |
271
+ | **create-app.md** | Creating a new app with `reachy-mini-app-assistant` |
272
+ | **control-loops.md** | Building real-time reactive apps (tracking, games) |
273
+ | **motion-philosophy.md** | Choosing between `goto_target` and `set_target` |
274
+ | **safe-torque.md** | Enabling/disabling motors without jerky motion |
275
+ | **ai-integration.md** | Building LLM-powered apps |
276
+ | **symbolic-motion.md** | Defining motion mathematically (dances, rhythms) |
277
+ | **interaction-patterns.md** | Using antennas as buttons, head as controller |
278
+ | **debugging.md** | App crashes, connectivity issues, basic checks |
279
+ | **testing-apps.md** | Testing before delivery (sim vs physical) |
280
+ | **rest-api.md** | HTTP/WebSocket API for non-Python clients |
281
+ | **deep-dive-docs.md** | When to read full SDK documentation |
282
+
283
+ ---
284
+
285
+ ## Quick Reference
286
+
287
+ **Motor names:** `body_rotation`, `stewart_1-6`, `right_antenna`, `left_antenna`
288
+
289
+ **Interpolation methods:** `linear`, `minjerk` (default), `ease_in_out`, `cartoon`
290
+
291
+ **Emotions library:**
292
+ ```python
293
+ from reachy_mini.motion.recorded_move import RecordedMoves
294
+ moves = RecordedMoves("pollen-robotics/reachy-mini-emotions-library")
295
+ mini.play_move(moves.get("happy"), initial_goto_duration=1.0)
296
+ ```
297
+
298
+ ---
299
+
300
+ ## Community
301
+
302
+ - **App guide**: https://huggingface.co/blog/pollen-robotics/make-and-publish-your-reachy-mini-apps
303
+ - **Source code**: https://github.com/pollen-robotics/reachy_mini
304
+ - **Community apps**: https://huggingface.co/spaces?q=reachy_mini
305
+ - **Discord**: https://discord.gg/Y7FgMqHsub
backend/src/agent/skills/reachy-mini-app/daemon/APP_CREATION_GUIDE.md ADDED
@@ -0,0 +1,1501 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ > **Auto-fetched** from [`pollen-robotics/reachy_mini@main`](https://github.com/pollen-robotics/reachy_mini/blob/main/ts/APP_CREATION_GUIDE.md) on 2026-05-29 · canonical SDK pin: `1.8.0-rc1`.
2
+ > Do not edit by hand - run `npm run sync-docs` to refresh.
3
+
4
+ # App Creation Guide
5
+
6
+ > ### Use `@pollen-robotics/reachy-mini-sdk@1.8.0-rc1` today
7
+ >
8
+ > Every reference app currently pins the same SDK release line:
9
+ > **`1.8.0-rc1`** (precisely `1.8.0-rc1-main.fd4354c`). The host shell,
10
+ > the embed adapter, the SDK runtime, and the daemon on the robot
11
+ > are validated end-to-end against that build. Mixing versions
12
+ > across these boundaries causes silent protocol drift - see
13
+ > [§10 SDK version pinning](#10-sdk-version-pinning).
14
+ >
15
+ > Add this to your `package.json`:
16
+ >
17
+ > ```json
18
+ > { "dependencies": { "@pollen-robotics/reachy-mini-sdk": "1.8.0-rc1-main.fd4354c" } }
19
+ > ```
20
+
21
+ **This is the single source of truth for building a Reachy Mini JS
22
+ app.** [`../AGENTS.md`](../AGENTS.md) at the repo root points here;
23
+ the JS SDK reference at [`../docs/source/SDK/javascript-sdk.md`](../docs/source/SDK/javascript-sdk.md)
24
+ documents the runtime API (motion, events, daemon-side playback) once
25
+ `connectToHost()` resolves. Everything else - scaffolding, deploy,
26
+ host shell, gotchas - lives here.
27
+
28
+ How to ship a Hugging Face Space that runs on a Reachy Mini robot,
29
+ using `@pollen-robotics/reachy-mini-sdk/host` for OAuth, robot picking, session
30
+ lifecycle, and a top bar - so your code stays focused on **your
31
+ app's UI** and nothing else.
32
+
33
+ | Doc | Purpose | Audience |
34
+ |------------|--------------------------------------|-----------------------------------|
35
+ | **This** | **Single source of truth for app authors** (scaffold, deploy, host contract, invariants) | **You, building a new app** |
36
+ | [`../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 |
37
+ | [`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 |
38
+
39
+ ## Table of contents
40
+
41
+ 1. [What you get for free](#1-what-you-get-for-free)
42
+ 2. [Quickstart: clone a reference app](#2-quickstart-clone-a-reference-app)
43
+ 3. [The app-author contract](#3-the-app-author-contract)
44
+ 4. [`mountHost()` API](#4-mounthost-api)
45
+ 5. [`connectToHost()` API](#5-connecttohost-api)
46
+ 6. [Visual identity: icon, name, emoji](#6-visual-identity-icon-name-emoji)
47
+ 7. [Receiving an external config (deep-link, mobile)](#7-receiving-an-external-config)
48
+ 8. [Cleaning up on leave](#8-cleaning-up-on-leave)
49
+ 9. [Local dev: HF token vs OAuth redirect](#9-local-dev)
50
+ 10. [SDK version pinning](#10-sdk-version-pinning)
51
+ 11. [Deploying to Hugging Face Spaces](#11-deploying-to-hugging-face-spaces)
52
+ 12. [FAQ and common pitfalls](#12-faq-and-common-pitfalls)
53
+ 13. [Architecture reference (host ↔ embed contract)](#13-architecture-reference-host--embed-contract)
54
+ 1. [Roles: app · host · embed](#131-roles-app--host--embed)
55
+ 2. [App identity & official apps](#132-app-identity--official-apps)
56
+ 3. [Two boot modes, one URL surface](#133-two-boot-modes-one-url-surface)
57
+ 4. [Host phase state machine + handoff sequence](#134-host-phase-state-machine--handoff-sequence)
58
+ 5. [Engineering invariants](#135-engineering-invariants)
59
+ 6. [Protocol v1 messages](#136-protocol-v1-messages)
60
+ 7. [Non-goals](#137-non-goals)
61
+ 8. [Threat model](#138-threat-model)
62
+ 14. [Robotics best practices](#14-robotics-best-practices) - `@pollen-robotics/reachy-mini-sdk/animation`
63
+ 1. [Pose types: `Pose` vs `PartialPose`](#141-pose-types-pose-vs-partialpose)
64
+ 2. [Distance & scaled duration](#142-distance--scaled-duration)
65
+ 3. [Safe return to home pose (`safelyReturnToPose`)](#143-safe-return-to-home-pose-safelyreturntopose)
66
+ 4. [Standalone exit hooks (`installShutdownHandler`)](#144-standalone-exit-hooks-installshutdownhandler)
67
+ 5. [Daemon parity warning](#145-daemon-parity-warning)
68
+ 6. [Anti-patterns](#146-anti-patterns)
69
+
70
+ ---
71
+
72
+ ## 1. What you get for free
73
+
74
+ By integrating `@pollen-robotics/reachy-mini-sdk/host`, your app **does not have to
75
+ write**:
76
+
77
+ - **Hugging Face OAuth**: sign-in screen, redirect handling,
78
+ token storage, sign-out menu - all in the host shell.
79
+ - **Robot discovery and picker**: list of online robots, live
80
+ online/offline/busy updates, click-to-pick.
81
+ - **Connection overlay**: the 3-step "Connecting / Starting
82
+ session / Waking up" view rendered on top of your iframe.
83
+ - **End-session button and tear-down**: a "Back to apps" affordance
84
+ in the top bar that cleanly closes the WebRTC session.
85
+ - **Dark / light theme switching**: respected from
86
+ `prefers-color-scheme` or HF settings, propagated to your iframe.
87
+
88
+ What **you** write:
89
+
90
+ - `index.html` (~30 lines of theme bootstrap + OAuth placeholders).
91
+ - `src/dispatch.ts` (~20 lines, picks shell vs embed mode and self-
92
+ assigns `window.ReachyMini`).
93
+ - `src/embed.{ts,tsx}` - **your app's actual code**. You receive a
94
+ live `ReachyMini` SDK handle and render whatever you want.
95
+ - `public/icon.svg` - one SVG that powers the top bar, the mobile
96
+ catalog tile, and the favicon.
97
+
98
+ You can use **any framework** inside your `embed` entry: React,
99
+ Svelte, Vue, vanilla TS. The host runs outside your iframe and
100
+ doesn't care.
101
+
102
+ ---
103
+
104
+ ## 2. Quickstart: clone a reference app
105
+
106
+ Pick the reference closest to your needs and clone its repo from Hugging Face:
107
+
108
+ | Reference app | Stack | Use it for |
109
+ |-------------------------------------|-----------------------------|---------------------------------------------|
110
+ | [`pollen-robotics/reachy_mini_minimal_conversation`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_minimal_conversation) | **Vanilla TS + Vite** | Smallest runtime, no framework |
111
+ | [`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 |
112
+ | [`pollen-robotics/reachy_mini_telepresence`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_telepresence) | React 19 + MUI 7 + Vite | App with camera / media streams |
113
+
114
+ ```bash
115
+ # Example: start from the vanilla TS template
116
+ git clone https://huggingface.co/spaces/pollen-robotics/reachy_mini_minimal_conversation my_new_app
117
+ cd my_new_app
118
+ # Edit package.json `name`, README frontmatter (`title`, `emoji`,
119
+ # `short_description`), public/icon.svg, and src/embed.ts to your app.
120
+ npm install
121
+ npm run dev
122
+ # → http://localhost:5173
123
+ ```
124
+
125
+ All three reference apps pin `@pollen-robotics/reachy-mini-sdk` to
126
+ the same RC version in their `package.json` - see [§10 SDK version pinning](#10-sdk-version-pinning).
127
+
128
+ ---
129
+
130
+ ## 3. The app-author contract
131
+
132
+ Exactly **three source files** are the entire integration surface,
133
+ plus `public/icon.svg`, a `package.json` (for the Vite build + the
134
+ SDK npm dep), and the Space `README.md` frontmatter
135
+ (see [§11 Deploying](#11-deploying-to-hugging-face-spaces)).
136
+
137
+ Don't add mandatory files outside this list - keep apps
138
+ interchangeable.
139
+
140
+ ### 3.1 `index.html`
141
+
142
+ The reference apps' `index.html` no longer loads the SDK from a CDN
143
+ `<script>` tag - `src/dispatch.ts` imports the SDK from npm and self-
144
+ assigns `window.ReachyMini` before any host code runs. This removes
145
+ the jsDelivr branch / cache-purge friction we used to live with.
146
+
147
+ ```html
148
+ <!doctype html>
149
+ <html lang="en">
150
+ <head>
151
+ <meta charset="UTF-8" />
152
+ <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
153
+ <title>My App</title>
154
+ <link rel="icon" href="/icon.svg" type="image/svg+xml" />
155
+
156
+ <!-- Theme bootstrap: paints the right palette BEFORE CSS lands so
157
+ there's no flash. Priority: `?theme=dark|light` query param
158
+ (set by the host when iframing us), then `prefers-color-scheme`. -->
159
+ <script>
160
+ (function () {
161
+ try {
162
+ var params = new URLSearchParams(window.location.search);
163
+ var raw = params.get("theme");
164
+ var mode;
165
+ if (raw === "dark" || raw === "light") {
166
+ mode = raw;
167
+ } else if (window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches === false) {
168
+ mode = "light";
169
+ } else {
170
+ mode = "dark";
171
+ }
172
+ document.documentElement.setAttribute("data-theme", mode);
173
+ } catch (_) {
174
+ document.documentElement.setAttribute("data-theme", "dark");
175
+ }
176
+ })();
177
+ </script>
178
+
179
+ <!-- HF Spaces helper variables.
180
+ - In production, HF substitutes these placeholders at file-
181
+ serve time when `hf_oauth: true` is set on the Space.
182
+ - In `npm run dev`, placeholders stay untouched; we detect
183
+ that and drop `window.huggingface` so the SDK falls back to
184
+ the clientId you supplied via `mountHost({ clientId })`. -->
185
+ <script>
186
+ (function () {
187
+ var clientId = "__OAUTH_CLIENT_ID__";
188
+ var scopes = "__OAUTH_SCOPES__";
189
+ var spaceHost = "__SPACE_HOST__";
190
+ var spaceId = "__SPACE_ID__";
191
+ var looksSubstituted = clientId && clientId.indexOf("__") !== 0;
192
+ if (looksSubstituted) {
193
+ window.huggingface = window.huggingface || {};
194
+ window.huggingface.variables = {
195
+ OAUTH_CLIENT_ID: clientId,
196
+ OAUTH_SCOPES: scopes && scopes.indexOf("__") !== 0 ? scopes : "openid profile",
197
+ SPACE_HOST: spaceHost && spaceHost.indexOf("__") !== 0 ? spaceHost : "",
198
+ SPACE_ID: spaceId && spaceId.indexOf("__") !== 0 ? spaceId : "",
199
+ };
200
+ }
201
+ })();
202
+ </script>
203
+ </head>
204
+ <body>
205
+ <div id="root"></div>
206
+ <!-- Single dispatcher. Picks the host shell (standalone visit) or
207
+ the embedded app (host's iframe) based on `?embedded=1`. -->
208
+ <script type="module" src="/src/dispatch.ts"></script>
209
+ </body>
210
+ </html>
211
+ ```
212
+
213
+ ### 3.2 `src/dispatch.ts`
214
+
215
+ The dispatcher does three things, in order:
216
+
217
+ 1. Imports `ReachyMini` from npm and self-assigns `window.ReachyMini` -
218
+ both the host shell and the embed wait for that global.
219
+ 2. Dispatches a `reachymini:ready` event so the embed's wait loop
220
+ takes its fast path.
221
+ 3. Branches on `?embedded=1` (legacy alias `?embed=1` also accepted):
222
+ embed bundle vs host shell bundle.
223
+
224
+ ```ts
225
+ import { ReachyMini } from "@pollen-robotics/reachy-mini-sdk";
226
+
227
+ (window as unknown as { ReachyMini: typeof ReachyMini }).ReachyMini = ReachyMini;
228
+ window.dispatchEvent(new Event("reachymini:ready"));
229
+
230
+ const params = new URLSearchParams(window.location.search);
231
+ const isEmbed =
232
+ params.get("embedded") === "1" || params.get("embed") === "1";
233
+
234
+ if (isEmbed) {
235
+ void import("./embed");
236
+ } else {
237
+ void import("@pollen-robotics/reachy-mini-sdk/host/auto").then(({ mountHost }) => {
238
+ mountHost({
239
+ appName: "My App",
240
+ appIconUrl: "/icon.svg",
241
+ appEmoji: "🤖",
242
+ enableMicrophone: false,
243
+ // Optional: forward dev shortcuts from .env.local. See §9.
244
+ devToken:
245
+ import.meta.env.VITE_HF_TOKEN && import.meta.env.VITE_HF_USERNAME
246
+ ? {
247
+ token: import.meta.env.VITE_HF_TOKEN as string,
248
+ userName: import.meta.env.VITE_HF_USERNAME as string,
249
+ }
250
+ : undefined,
251
+ clientId: import.meta.env.VITE_HF_OAUTH_CLIENT_ID as string | undefined,
252
+ });
253
+ });
254
+ }
255
+ ```
256
+
257
+ ### 3.3 `src/embed.ts` (vanilla example)
258
+
259
+ ```ts
260
+ import { connectToHost } from '@pollen-robotics/reachy-mini-sdk/host/embed';
261
+
262
+ interface MyConfig {
263
+ startingEmotion?: string;
264
+ }
265
+
266
+ async function main() {
267
+ const handle = await connectToHost<MyConfig>();
268
+ const { reachy, theme, config, onLeave } = handle;
269
+
270
+ document.body.innerHTML = '<h1>Connected!</h1>';
271
+
272
+ reachy.setHeadRpyDeg(0, 10, 0);
273
+
274
+ onLeave(async () => {
275
+ document.body.innerHTML = '<p>Bye!</p>';
276
+ });
277
+ }
278
+
279
+ void main().catch((err) => {
280
+ console.error('[my-app] boot failed', err);
281
+ window.parent.postMessage(
282
+ { source: 'reachy-mini', type: 'embed:error', version: 1,
283
+ message: String(err), fatal: true },
284
+ window.location.origin,
285
+ );
286
+ });
287
+ ```
288
+
289
+ ### 3.4 `public/icon.svg`
290
+
291
+ A single 24×24-friendly SVG at `public/icon.svg` powers **all three**
292
+ identity surfaces (host top bar, mobile catalog tile, browser
293
+ favicon). See [§6 Visual identity](#6-visual-identity-icon-name-emoji)
294
+ for the resolution path and PNG fallback notes.
295
+
296
+ ### 3.5 `package.json`
297
+
298
+ `npm`-managed; only mandatory bits are the Vite build script and the
299
+ SDK pin. See [§10 SDK version pinning](#10-sdk-version-pinning) for
300
+ the exact version string.
301
+
302
+ ---
303
+
304
+ ## 4. `mountHost()` API
305
+
306
+ Called once from `dispatch.ts` when the URL is **not** in embed mode.
307
+ Renders the shell into `#root`. The shell's visual theme (MUI
308
+ light/dark) is bundled and not overridable - the host owns its
309
+ look, apps own theirs inside the iframe.
310
+
311
+ ```ts
312
+ import { mountHost } from '@pollen-robotics/reachy-mini-sdk/host/auto';
313
+
314
+ mountHost({
315
+ appName: 'My App', // REQUIRED: passed to the SDK + shown in top bar
316
+ appIconUrl: '/icon.svg', // optional: top-bar logo (see §6)
317
+ appEmoji: '🤖', // optional: fallback when no icon.svg
318
+ enableMicrophone: false, // false unless you need WebRTC audio in
319
+ clientId: undefined, // optional: HF OAuth client ID; defaults to window.huggingface.variables.OAUTH_CLIENT_ID
320
+ devToken: undefined, // optional: { token, userName } - dev shortcut, see §9
321
+ target: undefined, // optional: HTMLElement | string CSS selector; default '#root'
322
+ });
323
+ ```
324
+
325
+ **Required**: `appName`. Everything else has sensible defaults.
326
+
327
+ **Return**: `{ dispose(): void }` - call to unmount cleanly. You
328
+ usually never need this; the page lifecycle handles it.
329
+
330
+ ---
331
+
332
+ ## 5. `connectToHost()` API
333
+
334
+ Called once from `embed.{ts,tsx}` to get a live SDK handle.
335
+
336
+ ```ts
337
+ import { connectToHost } from '@pollen-robotics/reachy-mini-sdk/host/embed';
338
+
339
+ interface MyConfig { /* whatever your app accepts */ }
340
+
341
+ const handle = await connectToHost<MyConfig>();
342
+ ```
343
+
344
+ Awaiting `connectToHost()` blocks until:
345
+
346
+ 1. The URL hash creds are parsed and wiped.
347
+ 2. The SDK script loaded (`window.ReachyMini` ready).
348
+ 3. The host posted `host:init` (Mode A only; Mode B times out
349
+ after 8 s and proceeds from hash alone).
350
+ 4. The SDK connected, started a session, and woke the robot.
351
+
352
+ The resolved `handle` exposes:
353
+
354
+ ```ts
355
+ interface ConnectedHandle<TConfig> {
356
+ // Live state at boot
357
+ reachy: ReachyMiniInstance; // SDK instance, session live, robot awake
358
+ theme: 'dark' | 'light';
359
+ config: TConfig | null;
360
+ appName: string;
361
+ hostName: string;
362
+ userName: string | null;
363
+
364
+ // Subscribe to live updates from the host (returns an unsub fn)
365
+ onLeave(cb: () => void | Promise<void>): () => void;
366
+ onThemeChange(cb: (theme: 'dark' | 'light') => void): () => void;
367
+ onConfigChange(cb: (config: TConfig | null) => void): () => void;
368
+
369
+ // Push state / requests back to the host
370
+ setAppState(s: { phase, connectingStep?, message? }): void;
371
+ requestLeave(): void; // ask host to end the session
372
+ reportError(msg: string, opts?: { fatal?, detail? }): void;
373
+ }
374
+ ```
375
+
376
+ The API is intentionally minimal. If you need a custom channel
377
+ between host and embed, file a feature request - we'll add it as
378
+ a typed message rather than expose a free-form sink.
379
+
380
+ ### Typing your config
381
+
382
+ `connectToHost<T>()` types the `config` field; runtime validation
383
+ is **your job**. An attacker controlling the URL can shape config
384
+ freely - cast is not enough.
385
+
386
+ ```ts
387
+ const handle = await connectToHost<MyConfig>();
388
+ const config = isValidConfig(handle.config) ? handle.config : null;
389
+ ```
390
+
391
+ ---
392
+
393
+ ## 6. Visual identity: icon, name, emoji
394
+
395
+ > **App identity is your HF Space ID** (`owner/space`). An app
396
+ > published at `huggingface.co/spaces/your-name/cool-thing` has
397
+ > identity `your-name/cool-thing` everywhere downstream (mobile
398
+ > catalog, "last opened", official badge). Apps in the
399
+ > `pollen-robotics/*` namespace are automatically tagged as
400
+ > official in the catalog; no extra config.
401
+
402
+ Your app's identity surfaces in **three independent places**, all
403
+ fed from the same sources but each by its own resolution path:
404
+
405
+ | Surface | What it shows | How it gets there |
406
+ |-------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------|
407
+ | Host top bar (in your iframe) | App logo + app name | You pass `appName` + `appIconUrl` + `appEmoji` to `mountHost()` in `dispatch.ts` |
408
+ | 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` |
409
+ | Browser tab / OS | Favicon | Your `index.html` `<link rel="icon" href="/icon.svg">` |
410
+
411
+ The host shell itself **does not discover or list other apps**.
412
+ It renders only the app it lives in. The catalog is owned by the
413
+ mobile and the website API; see §11 FAQ for the API spec if you
414
+ need to validate your app shows up.
415
+
416
+ ### Single source of truth: one `icon.svg`, one Space frontmatter
417
+
418
+ You ship **one** SVG file and **one** README frontmatter; both
419
+ the host and the mobile catalog read from them:
420
+
421
+ 1. **`public/icon.svg`** in your repo. Vite copies it verbatim to
422
+ `dist/icon.svg`, where nginx serves it at the root URL of your
423
+ Space. Reference it from `index.html`:
424
+
425
+ ```html
426
+ <link rel="icon" href="/icon.svg" type="image/svg+xml" />
427
+ ```
428
+
429
+ Pass it to `mountHost()` so the top bar renders it without a
430
+ probe:
431
+
432
+ ```ts
433
+ mountHost({ appName: 'My App', appIconUrl: '/icon.svg' });
434
+ ```
435
+
436
+ The catalog API also picks it up by listing the Space's
437
+ files (`siblings`) and matching `public/icon.svg` (or
438
+ `public/icon.png`), no live probe required.
439
+
440
+ 2. **HF Space frontmatter** in your `README.md`:
441
+
442
+ ```yaml
443
+ ---
444
+ title: My App
445
+ emoji: 🤖
446
+ colorFrom: yellow
447
+ colorTo: red
448
+ sdk: static
449
+ pinned: false
450
+ hf_oauth: true
451
+ short_description: One-line description shown in the catalog.
452
+ tags:
453
+ - reachy_mini
454
+ - reachy_mini_js_app
455
+ ---
456
+ ```
457
+
458
+ See [§11.1](#111-required-frontmatter) for the full annotated
459
+ frontmatter.
460
+
461
+ - `title` is the app name in the mobile catalog (the in-iframe
462
+ top bar uses `appName` from `mountHost()` instead).
463
+ - `emoji` is the fallback logo when no `icon.svg` is shipped.
464
+ Pass the same value to `mountHost({ appEmoji: '🤖' })` so the
465
+ top bar's fallback matches.
466
+ - `short_description` shows under the app tile in the catalog.
467
+ - The **`reachy_mini_js_app` tag is mandatory** to appear in
468
+ the mobile catalog. The catalog API filters on this exact
469
+ string. Don't remove it.
470
+ - `hf_oauth: true` makes HF auto-provision an OAuth client and
471
+ inject the ID at file-serve time.
472
+
473
+ ### Icon design recommendations
474
+
475
+ Your icon renders at three different sizes; design for all three:
476
+
477
+ - **Host top bar inside the iframe**: ~24x24 px square.
478
+ - **Mobile catalog tile**: ~64x64 px square card.
479
+ - **Browser tab favicon**: 16x16 px.
480
+
481
+ Practical guidance:
482
+
483
+ - Use a **square viewBox** (e.g. `viewBox="0 0 24 24"`) so the
484
+ three target sizes all crop identically.
485
+ - Keep the icon **readable at 16 px**: thick strokes, simple
486
+ silhouette, max 2-3 distinct shapes.
487
+ - Inline all colours; **don't reference external CSS**, the icon
488
+ is served standalone.
489
+ - Respect dark and light backgrounds: an icon that vanishes on
490
+ light should provide a `<style>` tag with `@media (prefers-color-scheme)`
491
+ rules or, simpler, use a neutral mid-tone palette.
492
+ - **Optimise the SVG**: target ~30 KB or less. Tools: `svgo`,
493
+ Figma's "Export SVG → optimise".
494
+
495
+ ### PNG fallback
496
+
497
+ If you can't ship SVG (heavy raster art, exported portrait, ...),
498
+ the catalog API also accepts `public/icon.png`. SVG wins when both
499
+ exist. The host top bar only renders the SVG variant - if your
500
+ `mountHost({ appIconUrl })` points at a PNG, it works, but you
501
+ lose the crisp upscale on hi-DPI screens.
502
+
503
+ ---
504
+
505
+ ## 7. Receiving an external config
506
+
507
+ The host accepts a base64-encoded JSON `config` from two sources:
508
+
509
+ 1. **URL parameter**: `https://<space>.hf.space/?config=eyJlbW90aW9uIjoiam95In0=`
510
+ (decoded once, passed verbatim).
511
+ 2. **Mobile handoff**: the mobile app embeds your Space with
512
+ `?embedded=1#creds=<base64-bundle-including-config>`.
513
+
514
+ Your app receives `config` typed as `unknown`; cast and validate.
515
+
516
+ ```ts
517
+ interface MyConfig { startingEmotion?: string; }
518
+
519
+ function isMyConfig(v: unknown): v is MyConfig {
520
+ return v != null && typeof v === 'object' && (
521
+ (v as MyConfig).startingEmotion === undefined ||
522
+ typeof (v as MyConfig).startingEmotion === 'string'
523
+ );
524
+ }
525
+
526
+ const handle = await connectToHost<MyConfig>();
527
+ const initial: MyConfig = isMyConfig(handle.config) ? handle.config : {};
528
+
529
+ handle.onConfigChange((next) => {
530
+ if (isMyConfig(next)) /* react to it */;
531
+ });
532
+ ```
533
+
534
+ If your app's UI state changes in a way the mobile would want to
535
+ remember (e.g. user picked a different emotion), persist it in
536
+ your app's storage. The host does **not** propagate state
537
+ upstream - apps don't push config to the host in v1.
538
+
539
+ ---
540
+
541
+ ## 8. Cleaning up on leave
542
+
543
+ The host fires a tear-down sequence in three scenarios:
544
+
545
+ - User clicks "End session" / "Back to apps" in the top bar.
546
+ - Your app calls `handle.requestLeave()`.
547
+ - The page is unloaded (`pagehide`, e.g. user closes the tab).
548
+
549
+ In all three cases your `onLeave` callbacks fire. You have **~1.5-2 s**
550
+ before the host force-unmounts the iframe; use that to:
551
+
552
+ ```ts
553
+ import { safelyReturnToPose } from "@pollen-robotics/reachy-mini-sdk/animation";
554
+
555
+ handle.onLeave(async () => {
556
+ safelyReturnToPose(handle.reachy); // canonical safe-rest one-liner, see §14.3
557
+ player.cancel(); // stop streaming motion frames
558
+ audioCtx?.close(); // release audio
559
+ ws?.close(); // close any side channels
560
+ await flushTelemetry(); // your async hooks
561
+ });
562
+ ```
563
+
564
+ `safelyReturnToPose` is the recommended first call in every `onLeave` -
565
+ it re-enables torque, computes a scaled duration, and dispatches a
566
+ goto to `INIT_POSE`. Full recipe in
567
+ [§14.3](#143-safe-return-to-home-pose-safelyreturntopose).
568
+
569
+ You do **not** need to call `reachy.stopSession()` yourself - the
570
+ host does. You also don't need to navigate away; the iframe is
571
+ unmounted by the host.
572
+
573
+ ---
574
+
575
+ ## 9. Local dev
576
+
577
+ You have two options, picked by the `devToken` and `clientId`
578
+ props passed to `mountHost()`. Reference apps support both via
579
+ `.env.local`.
580
+
581
+ ### Option A: personal access token (no OAuth)
582
+
583
+ Fastest for local dev. Skips the OAuth redirect entirely.
584
+
585
+ 1. Get a token at <https://huggingface.co/settings/tokens> (read
586
+ scope is enough).
587
+ 2. Create `.env.local`:
588
+
589
+ ```
590
+ VITE_HF_TOKEN=hf_xxx
591
+ VITE_HF_USERNAME=your-handle
592
+ ```
593
+
594
+ 3. In `dispatch.ts`, forward both to `mountHost`:
595
+
596
+ ```ts
597
+ mountHost({
598
+ appName: 'My App',
599
+ devToken: import.meta.env.VITE_HF_TOKEN && import.meta.env.VITE_HF_USERNAME
600
+ ? { token: import.meta.env.VITE_HF_TOKEN, userName: import.meta.env.VITE_HF_USERNAME }
601
+ : undefined,
602
+ });
603
+ ```
604
+
605
+ 4. `npm run dev` → you're signed in on page load.
606
+
607
+ `.env.local` must be gitignored. **Never commit the token.**
608
+
609
+ ### Option B: real OAuth client ID
610
+
611
+ Use this when you're touching the OAuth / logout paths.
612
+
613
+ 1. Go to <https://huggingface.co/settings/applications/new>.
614
+ 2. Homepage URL: `http://localhost:5173` · Redirect URIs:
615
+ `http://localhost:5173` · Scopes: at least `openid`, `profile`.
616
+ 3. Copy the client ID into `.env.local`:
617
+
618
+ ```
619
+ VITE_HF_OAUTH_CLIENT_ID=...
620
+ ```
621
+
622
+ 4. Forward to `mountHost`:
623
+
624
+ ```ts
625
+ mountHost({
626
+ appName: 'My App',
627
+ clientId: import.meta.env.VITE_HF_OAUTH_CLIENT_ID,
628
+ });
629
+ ```
630
+
631
+ ---
632
+
633
+ ## 10. SDK version pinning
634
+
635
+ Every reference app pins the same exact SDK version in `package.json`.
636
+ Pin yours the same way - mixing versions across `@pollen-robotics/reachy-mini-sdk`,
637
+ `@pollen-robotics/reachy-mini-sdk/host`, and the daemon on the robot
638
+ produces hard-to-debug protocol drift.
639
+
640
+ The current pinned version across all three reference apps:
641
+
642
+ ```json
643
+ {
644
+ "dependencies": {
645
+ "@pollen-robotics/reachy-mini-sdk": "1.8.0-rc1-main.fd4354c"
646
+ }
647
+ }
648
+ ```
649
+
650
+ This is the `1.8.0-rc1` release line, with the `-main.fd4354c` suffix
651
+ identifying the commit-tagged prerelease build that's been validated
652
+ end-to-end against the host shell + daemon. **Use the same string in
653
+ your `package.json`** unless you're explicitly tracking a newer RC.
654
+
655
+ > When a newer RC is published, the source of truth is whichever
656
+ > string is currently shared by [`reachy_mini_minimal_conversation`'s
657
+ > `package.json`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_minimal_conversation/blob/main/package.json),
658
+ > [`reachy_mini_emotions`'s `package.json`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_emotions/blob/main/package.json),
659
+ > and [`reachy_mini_telepresence`'s `package.json`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_telepresence/blob/main/package.json).
660
+ > If those three diverge, fall back to whatever this guide says.
661
+
662
+ ### Why pin a specific build (not `^1.8.0` or a major like `@1`)?
663
+
664
+ The host shell, the embed adapter (`connectToHost`), the SDK, and the
665
+ robot daemon negotiate over a versioned WebRTC data-channel protocol.
666
+ A patch bump on one side that crosses a protocol boundary will
667
+ silently fall back (or noisily fail) at runtime - well past the type
668
+ checker.
669
+
670
+ Pin to the exact build string the reference apps use, upgrade
671
+ intentionally, and re-test against a live robot before shipping.
672
+
673
+ ---
674
+
675
+ ## 11. Deploying to Hugging Face Spaces
676
+
677
+ > Reachy Mini JS apps ship as **`sdk: static`** Hugging Face Spaces.
678
+ > HF serves the Vite build straight from its CDN and replaces
679
+ > `__OAUTH_CLIENT_ID__` and friends at file-serve time, because
680
+ > `hf_oauth: true` is set in the README frontmatter.
681
+
682
+ ### 11.1 Required frontmatter
683
+
684
+ ```yaml
685
+ ---
686
+ title: My Reachy Mini App
687
+ emoji: 🤖
688
+ colorFrom: yellow
689
+ colorTo: red
690
+ sdk: static
691
+ pinned: false
692
+ hf_oauth: true
693
+ short_description: One-line description shown in the mobile catalog.
694
+ tags:
695
+ - reachy_mini
696
+ - reachy_mini_js_app # mandatory: mobile-catalog discovery filters on this exact string
697
+ ---
698
+ ```
699
+
700
+ - `sdk: static` is what makes HF serve your `dist/` from its CDN.
701
+ - `hf_oauth: true` is what triggers `__OAUTH_CLIENT_ID__` substitution.
702
+ - The **`reachy_mini_js_app` tag is mandatory** for mobile-catalog
703
+ discovery. The catalog API filters on this exact string.
704
+ - Apps in the `pollen-robotics/*` namespace are automatically tagged
705
+ as "official" in the catalog (see [§13.2 App identity & official apps](#132-app-identity--official-apps)); no extra config.
706
+
707
+ ### 11.2 Build and push
708
+
709
+ ```bash
710
+ # 1. Build locally
711
+ npm install
712
+ npm run build
713
+ # → dist/ (contains index.html with the __OAUTH_CLIENT_ID__ placeholder
714
+ # intact, your bundled JS, and dist/icon.svg copied from public/)
715
+
716
+ # 2. Create the Space and clone it next to your source tree
717
+ hf repos create <app-name> --repo-type space --space-sdk static
718
+ git clone https://huggingface.co/spaces/<username>/<app-name> ../<app-name>-space
719
+
720
+ # 3. Stage the Space contents:
721
+ # - README.md (the frontmatter)
722
+ # - dist/... (served at the Space root by HF's CDN)
723
+ # - public/icon.svg duplicated at the repo path the mobile catalog
724
+ # API matches (HF's `siblings` listing only sees committed files,
725
+ # not the Vite-emitted dist/icon.svg).
726
+ cp README.md ../<app-name>-space/
727
+ cp -R dist/. ../<app-name>-space/
728
+ mkdir -p ../<app-name>-space/public
729
+ cp public/icon.svg ../<app-name>-space/public/
730
+ # (also cp public/icon.png if you ship a raster fallback)
731
+
732
+ # 4. Push
733
+ cd ../<app-name>-space
734
+ git add -A && git commit -m "Initial deploy" && git push
735
+ ```
736
+
737
+ ### 11.3 What HF does at serve time
738
+
739
+ - Substitutes `__OAUTH_CLIENT_ID__`, `__OAUTH_SCOPES__`,
740
+ `__SPACE_HOST__`, and `__SPACE_ID__` inside any `.html` file at the
741
+ Space root (because `hf_oauth: true`).
742
+ - Serves the rest of `dist/` as static assets via its CDN (immutable
743
+ caching honours the hashed filenames Vite emits).
744
+ - Indexes `siblings` for the mobile catalog probe (which is why you
745
+ need `public/icon.svg` committed at the repo path, not just inside
746
+ `dist/`).
747
+
748
+ ### 11.4 Cache busting
749
+
750
+ If a push doesn't take effect, push an empty commit to force HF to
751
+ re-resolve the Space:
752
+
753
+ ```bash
754
+ git commit --allow-empty -m "chore: bust HF Spaces cache" && git push
755
+ ```
756
+
757
+ ---
758
+
759
+ ## 12. FAQ and common pitfalls
760
+
761
+ ### "I see a `Robot is busy` error even though no one is using the robot"
762
+
763
+ The host's SDK and the embed's SDK both claim a peer at the
764
+ central. The host **must** disconnect when the embed boots; if it
765
+ doesn't, the central sees two peers with the same `appName` and
766
+ rejects the embed.
767
+
768
+ This is handled automatically by `@pollen-robotics/reachy-mini-sdk/host`
769
+ (see [§13.5.1 Single live SDK per tab](#1351-single-live-sdk-per-tab)).
770
+ If you see this in dev, you likely have **two tabs** open on the
771
+ same Space - that's expected behaviour.
772
+
773
+ ### "My app loads React + MUI even though I wrote vanilla TS"
774
+
775
+ The **host shell** is React + MUI. It runs **only outside your
776
+ iframe** (sign-in screen, picker, top bar). Once your app is live,
777
+ the host's React tree is idle.
778
+
779
+ Your iframe content is whatever you wrote. Vanilla TS apps stay
780
+ slim inside the iframe.
781
+
782
+ ### "Vite warns about React being installed in two places"
783
+
784
+ You're using the legacy `file:./vendor/reachy-mini-host` dep
785
+ pattern (now unsupported — the host ships from npm as part of
786
+ `@pollen-robotics/reachy-mini-sdk`). Migrate to the npm dep and,
787
+ if you still see the warning, add to your `vite.config.ts`:
788
+
789
+ ```ts
790
+ export default defineConfig({
791
+ resolve: {
792
+ dedupe: ['react', 'react-dom', 'react/jsx-runtime',
793
+ '@emotion/react', '@emotion/styled',
794
+ '@mui/material', '@mui/icons-material'],
795
+ },
796
+ optimizeDeps: {
797
+ include: ['@pollen-robotics/reachy-mini-sdk',
798
+ '@pollen-robotics/reachy-mini-sdk/host',
799
+ '@pollen-robotics/reachy-mini-sdk/host/auto',
800
+ '@pollen-robotics/reachy-mini-sdk/host/embed'],
801
+ },
802
+ });
803
+ ```
804
+
805
+ ### "I want a different sign-in flow"
806
+
807
+ Not supported in v1. The host owns OAuth. If you need a custom
808
+ flow, the standalone shell isn't for you - publish your Space
809
+ with the host disabled (just don't call `mountHost()`) and roll
810
+ your own.
811
+
812
+ ### "I want a different theme than the bundled MUI one"
813
+
814
+ The host shell's look is fixed (light + dark MUI bundle). Apps
815
+ own their own theme **inside the iframe** - use the
816
+ `handle.theme` value as your mode signal and wrap your app in
817
+ whatever ThemeProvider you want.
818
+
819
+ ```ts
820
+ const handle = await connectToHost();
821
+ // Mirror `handle.theme` ('dark' | 'light') in your own
822
+ // ThemeProvider. The host pushes updates via onThemeChange().
823
+ ```
824
+
825
+ ### "The icon doesn't show up in the top bar"
826
+
827
+ Check the three sources in priority order (§6):
828
+
829
+ 1. Is `/icon.svg` reachable at the deployed URL? Open
830
+ `https://<space>.hf.space/icon.svg` directly.
831
+ 2. Is the file's MIME type `image/svg+xml`? The host's probe
832
+ checks the response's `content-type`.
833
+ 3. Did you pass `appIconUrl: '/icon.svg'` to `mountHost()`?
834
+
835
+ If 1 + 2 + 3 are correct and it still fails, file a bug.
836
+
837
+ ### "My Space serves the bundle but the OAuth login redirects loop"
838
+
839
+ HF only substitutes `__OAUTH_CLIENT_ID__` when `hf_oauth: true` is
840
+ set in the README frontmatter **and** the file is HTML. Common
841
+ mistakes:
842
+
843
+ - `hf_oauth: true` missing → placeholder stays as literal
844
+ `"__OAUTH_CLIENT_ID__"`; the SDK falls back to no client ID and
845
+ the login never resolves.
846
+ - You pushed a built `dist/index.html` that already had the
847
+ placeholder replaced locally (e.g. you ran with `.env.local` and
848
+ some bundler hardcoded the value). HF only substitutes
849
+ `__...__` literals; if the file already has a real ID baked in
850
+ for a different OAuth client, the redirect targets the wrong app.
851
+ - The Space pre-dates the `hf_oauth` substitution (very old
852
+ Spaces). Re-create the Space.
853
+
854
+ ### "My app doesn't appear in the mobile catalog"
855
+
856
+ Three things must be true simultaneously:
857
+
858
+ 1. The Space tags include the exact string `reachy_mini_js_app` (see
859
+ the [§11.1 frontmatter](#111-required-frontmatter)).
860
+ 2. `public/icon.svg` exists in the **committed repo tree** (not just
861
+ in `dist/`). The catalog probe inspects `siblings`, which is a
862
+ listing of committed files, not served URLs.
863
+ 3. The Space is public (or the requesting user has access).
864
+
865
+ ### "Where do I see if my app crashed at boot?"
866
+
867
+ Three places, in order:
868
+
869
+ 1. Browser console of the standalone Space tab (mountHost errors).
870
+ 2. Browser console of the iframe (embed errors). The embed
871
+ `postMessage`s any boot error back to the host as
872
+ `embed:error` - the host surfaces fatal ones via a banner.
873
+ 3. HF Space "Logs" tab - only build-time errors show up here for
874
+ static Spaces (no runtime container).
875
+
876
+ ### "How do I test the mobile-handoff mode locally?"
877
+
878
+ Hit your dev server at:
879
+
880
+ ```
881
+ 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"})>
882
+ ```
883
+
884
+ The dispatcher will skip the shell and go straight to your embed.
885
+ Useful for testing the embed path without spinning up the mobile
886
+ app. The exact bundle shape is documented at
887
+ [§13.3 Two boot modes](#133-two-boot-modes-one-url-surface).
888
+
889
+ ---
890
+
891
+ ## 13. Architecture reference (host ↔ embed contract)
892
+
893
+ > **You don't need this section to ship an app.** §1-§12 plus §14
894
+ > (Robotics best practices) are enough. This appendix is the canonical
895
+ > contract between the **app**, the **host shell**, and the **embed
896
+ > adapter** - useful when you're debugging a weird boot, considering an
897
+ > unusual deployment, or contributing to the host shell itself.
898
+
899
+ ### 13.1 Roles: app · host · embed
900
+
901
+ Three actors, one app repository:
902
+
903
+ | Actor | Lives in | Owns |
904
+ |------------|-------------------------------------------------------|----------------------------------------------------------------------------|
905
+ | **App** | `index.html` + `src/dispatch.ts` + `src/embed.{ts,tsx}` | UI, app-specific UX, **full freedom over framework / tooling choices** |
906
+ | **Host** | `@pollen-robotics/reachy-mini-sdk/host/auto` | OAuth, robot discovery, robot picker, connecting overlay, end-session flow |
907
+ | **Embed** | `@pollen-robotics/reachy-mini-sdk/host/embed` | SDK lifecycle inside the iframe (`startSession`, `ensureAwake`, teardown) |
908
+
909
+ The **App** consumes the Reachy Mini SDK (imported in
910
+ `src/dispatch.ts` and self-assigned to `window.ReachyMini`) plus the
911
+ `@pollen-robotics/reachy-mini-sdk/host` subpath exports. It contains
912
+ **zero auth code, zero picker code, zero session-lifecycle code**.
913
+
914
+ #### Why React + MUI for the host shell (and only the host)
915
+
916
+ The host shell needs a real component library: sign-in forms, robot
917
+ picker lists, connecting overlays, top bar, dark-mode toggles. It's
918
+ built with **React 19 + MUI 7 + Emotion**.
919
+
920
+ - The shell renders **only outside your iframe** and only between
921
+ sessions; once your app is live, the shell's React tree is idle.
922
+ - Apps written in another framework still load the shell's bundle
923
+ for sign-in / picker UI. That's the cost of the iframe model and
924
+ we accept it.
925
+
926
+ The trade-off favours **fast host iteration + tech freedom for
927
+ apps** over a slimmer host shell.
928
+
929
+ ### 13.2 App identity & official apps
930
+
931
+ A Reachy Mini app is uniquely identified by its **Hugging Face Space
932
+ ID**, of the form `owner/space` (e.g. `pollen-robotics/emotions`).
933
+ Everything downstream of identity flows from this single string:
934
+
935
+ - The catalog API filters and dedupes apps by `space.id`.
936
+ - The mobile app stores and recalls "last opened" apps by
937
+ `owner/space`.
938
+ - The host shell does **not** need this ID at runtime (it lives
939
+ inside the app it renders); it's used solely by the discovery
940
+ surface.
941
+
942
+ **An app is "official" if and only if its Space ID starts with
943
+ `pollen-robotics/`.** No allowlist, no separate registry, no
944
+ `official: true` field. Adding `pollen-robotics/` to your URL is the
945
+ entire qualification. Where the distinction surfaces:
946
+
947
+ | Surface | Behaviour |
948
+ |------------------------|-----------------------------------------------------------------|
949
+ | Mobile catalog | "Official" badge / sort priority on `pollen-robotics/*` Spaces |
950
+ | Website `/api/js-apps` | Returns `isOfficial: true` for `pollen-robotics/*` |
951
+ | Host shell | **No notion of "official"**. Renders the app the same way always |
952
+
953
+ ### 13.3 Two boot modes, one URL surface
954
+
955
+ The same `index.html` is served for both modes. The dispatcher
956
+ (`src/dispatch.ts`) picks between them based on the URL.
957
+
958
+ | Mode | URL shape | What happens |
959
+ |-----------------------|----------------------------------------------------------------------|------------------------------------------------------|
960
+ | **A. Hub standalone** | `https://<space>.hf.space/` | Full host shell (OAuth → picker → iframe with app) |
961
+ | **B. Mobile handoff** | `https://<space>.hf.space/?embedded=1#creds=<base64(CredsBundle)>` | Skip shell; app boots directly, creds come via hash |
962
+
963
+ #### Dispatch rule
964
+
965
+ ```
966
+ if (URL.searchParams.has("embedded") && URL.hash.startsWith("#creds=")):
967
+ boot embed → import("./embed")
968
+ else:
969
+ boot host → import("@pollen-robotics/reachy-mini-sdk/host/auto").mountHost({...})
970
+ ```
971
+
972
+ `?embedded=1` without creds is an invalid mode - the embed shows an
973
+ `ErrorView`.
974
+
975
+ #### `CredsBundle` (lives only in the URL hash, never in search)
976
+
977
+ The bundle has the shape:
978
+
979
+ ```ts
980
+ {
981
+ hfToken: string; // short-lived HF bearer (15 min TTL)
982
+ userName: string;
983
+ robotPeerId: string;
984
+ signalingUrl: string;
985
+ theme: 'dark' | 'light';
986
+ config: unknown | null;
987
+ hostName: string;
988
+ appName: string;
989
+ }
990
+ ```
991
+
992
+ The hash is **never sent to a server**. The embed wipes it with
993
+ `history.replaceState` on the very first synchronous tick of
994
+ `connectToHost()`, **before any `await`** - see
995
+ [§13.5.2 Hash-only creds + immediate wipe](#1352-hash-only-creds--immediate-wipe).
996
+
997
+ #### Mode A standalone flow (the long story)
998
+
999
+ Phase machine inside the host:
1000
+
1001
+ ```
1002
+ booting → (signed-out | authenticated) → connecting → connected →
1003
+ picking → handing-off → live → stopping → picking
1004
+ ```
1005
+
1006
+ - `booting`: wait for `window.ReachyMini`, instantiate the SDK,
1007
+ call `authenticate()`.
1008
+ - `signed-out`: render the OAuth sign-in screen.
1009
+ - `authenticated` → `connecting` → `connected` (SSE welcome) →
1010
+ `picking`.
1011
+ - During `picking`, the robot list reacts live to the SDK's
1012
+ `robotsChanged` event.
1013
+ - On robot selection, the host mounts the iframe at
1014
+ `<same-origin>?embedded=1#creds=<base64>` and overlays
1015
+ `ConnectingView` (3-step stepper: `link` → `session` → `wake`).
1016
+ - When the embed reaches `phase: 'live'`, the overlay fades out.
1017
+
1018
+ Top bar layout while `live`:
1019
+
1020
+ ```
1021
+ [icon] [app name] ........ [robot status] [end-session] [oauth menu]
1022
+ ```
1023
+
1024
+ The top bar stays rendered through every phase of Mode A and does
1025
+ **not** render at all in Mode B.
1026
+
1027
+ End-session flow:
1028
+
1029
+ 1. Triggered by the End-session button, `embed:request-leave`, or
1030
+ `pagehide`.
1031
+ 2. Host → phase `stopping`, `LeavingView` overlay, posts
1032
+ `host:leaving`.
1033
+ 3. Embed runs `onLeave` callbacks → `reachy.stopSession()` → acks.
1034
+ 4. Host receives ack (or hits `timeoutMs`) → unmounts iframe →
1035
+ phase `picking`.
1036
+
1037
+ #### Mode B mobile handoff flow
1038
+
1039
+ The mobile app opens the Space in a WebView with a pre-built URL
1040
+ containing creds in the hash. The dispatcher loads `./embed`
1041
+ directly. **No host shell is mounted.** The user sees:
1042
+
1043
+ - No sign-in view (mobile already authenticated).
1044
+ - No robot picker (mobile already picked).
1045
+ - No welcome-back animation.
1046
+ - No host top bar - if your app wants one, it draws it itself.
1047
+
1048
+ There is **no end-session button** in Mode B. Closing the WebView
1049
+ triggers `pagehide`, which fires `onLeave` and stops the session.
1050
+
1051
+ ### 13.4 Host phase state machine + handoff sequence
1052
+
1053
+ #### Host phase machine (Mode A only)
1054
+
1055
+ ```mermaid
1056
+ stateDiagram-v2
1057
+ [*] --> booting
1058
+ booting --> sdk_missing: SDK load timeout
1059
+ booting --> signed_out: authenticate() false
1060
+ booting --> authenticated: authenticate() true
1061
+ booting --> error: ctor / clientId error
1062
+
1063
+ signed_out --> booting: user clicks Sign in
1064
+ authenticated --> connecting: auto
1065
+ connecting --> connected: SSE welcome
1066
+ connecting --> error: HTTP / network fail
1067
+ connected --> picking
1068
+ picking --> handing_off: selectRobot()
1069
+ handing_off --> live: embed reports phase=live
1070
+ handing_off --> error: embed:error { fatal: true }
1071
+
1072
+ live --> stopping: endSession() OR embed:request-leave
1073
+ stopping --> picking: leave-ack OR timeout
1074
+
1075
+ error --> picking: retry() if SDK authed
1076
+ error --> booting: retry() if no SDK
1077
+ error --> signed_out: retry() if auth expired
1078
+ ```
1079
+
1080
+ #### Handoff sequence (host → embed)
1081
+
1082
+ Showcases the single-SDK-per-tab invariant
1083
+ ([§13.5.1](#1351-single-live-sdk-per-tab)).
1084
+
1085
+ ```mermaid
1086
+ sequenceDiagram
1087
+ participant U as User
1088
+ participant H as Host
1089
+ participant HS as Host SDK
1090
+ participant C as Central
1091
+ participant E as Embed (iframe)
1092
+ participant ES as Embed SDK
1093
+
1094
+ U->>H: click robot card
1095
+ H->>H: phase = handing-off, mount iframe
1096
+ E->>E: parse #creds, replaceState wipe
1097
+ E->>H: embed:ready
1098
+ H->>HS: disconnect() [§13.5.1]
1099
+ H->>E: host:init
1100
+ E->>ES: connect() → startSession() → ensureAwake()
1101
+ ES->>C: claim robot
1102
+ C-->>ES: session live
1103
+ E->>H: embed:app-state { phase: 'live' }
1104
+ H->>H: hide ConnectingView overlay
1105
+ ```
1106
+
1107
+ ### 13.5 Engineering invariants
1108
+
1109
+ Four hard invariants. A failure here is a regression in the host
1110
+ shell - it must produce a defined observable behaviour and must
1111
+ stay covered by tests.
1112
+
1113
+ #### 13.5.1 Single live SDK per tab
1114
+
1115
+ **Contract**: at any instant, exactly one SDK instance per tab is
1116
+ registered at the central.
1117
+
1118
+ **Mechanism**:
1119
+ - Host mounts the SDK in `booting` and uses it for the picker.
1120
+ - As soon as the embed posts `embed:ready`, the host calls
1121
+ `disconnect()` on its SDK and zeroes its references.
1122
+ - On `leave-ack` (or timeout), the host calls `connect()` again to
1123
+ refresh the fleet and lands back on `picking`.
1124
+
1125
+ **Why**: removes the entire class of "Robot is busy" false
1126
+ positives where the central thinks the shell still owns the robot.
1127
+
1128
+ **Defence in depth**: the host's SDK registers as `<appName> (shell)`
1129
+ at the central; the embed keeps the clean `appName`. This protects
1130
+ the narrow window where both SDKs overlap (between `embed:ready`
1131
+ and `disconnect()`).
1132
+
1133
+ #### 13.5.2 Hash-only creds + immediate wipe
1134
+
1135
+ **Contract**: HF tokens never appear in URL search, never in
1136
+ referer, never in HF Spaces access logs.
1137
+
1138
+ **Mechanism**:
1139
+ - Creds are serialised as base64 JSON in the URL hash fragment
1140
+ (`#creds=...`).
1141
+ - The embed wipes the hash with `history.replaceState` on the
1142
+ first synchronous tick of `connectToHost()`, before any `await`.
1143
+ - On `host:leaving`, the embed clears `sessionStorage.hf_*` keys
1144
+ before sending the `leave-ack`.
1145
+
1146
+ **Token TTL**: `hf_token_expires` is set to **15 min** at seed
1147
+ time. The SDK refreshes on demand.
1148
+
1149
+ #### 13.5.3 Bundle and SDK pinning
1150
+
1151
+ **Contract**: a fix in the host reaches every Space within one
1152
+ cache cycle, with no per-app rebuild. A fix in the SDK can be
1153
+ rolled out by the SDK team, not by every app team.
1154
+
1155
+ **Pinning rules**:
1156
+ - App bundles (`index-<hash>.js`): hashed by Vite, cache-busted
1157
+ on deploy.
1158
+ - `@pollen-robotics/reachy-mini-sdk` in `package.json`: pinned to
1159
+ an **exact prerelease build** (today: `1.8.0-rc1-main.fd4354c`),
1160
+ not a range. See [§10 SDK version pinning](#10-sdk-version-pinning).
1161
+ - `@pollen-robotics/reachy-mini-sdk/host` subpath imports: same
1162
+ pin, same package.
1163
+
1164
+ On detected mismatch (e.g. unknown protocol version, structurally
1165
+ invalid `host:init`), the host's `ErrorView` primary button calls
1166
+ `window.location.reload(true)` to bypass any intermediary cache.
1167
+
1168
+ #### 13.5.4 React Strict Mode safety
1169
+
1170
+ **Contract**: every effect in the host package survives a double
1171
+ mount in `<React.StrictMode>` without doubling network I/O, SDK
1172
+ instances, or postMessage traffic.
1173
+
1174
+ **Why this matters**: React 18+ in dev intentionally mounts every
1175
+ component, runs every effect, runs every cleanup, then mounts and
1176
+ runs effects again. Code that "looked fine" in prod will fire two
1177
+ parallel `connect()` calls, leak two SSE subscribers, post
1178
+ `embed:ready` twice. In Mode A this surfaces as ghost sessions at
1179
+ the central; in Mode B as two competing WebRTC peer connections.
1180
+
1181
+ **Mechanisms**:
1182
+ - **Boot guard refs**: `useReachyHost()` uses a `bootStartedRef`
1183
+ set on first mount; the second StrictMode invocation early-
1184
+ returns instead of re-instantiating the SDK.
1185
+ - **Subscription cleanup**: every `useEffect` returns its tear-
1186
+ down. `robotsChanged`, `phaseChanged`, `welcome` are all
1187
+ unsubscribed in cleanup.
1188
+ - **Idempotent SDK calls**: `connect()` is a no-op when the SDK is
1189
+ already `connected` / `streaming`; the host relies on this rather
1190
+ than gating with a flag.
1191
+ - **`connectToHost()` is one-shot**: a module-level
1192
+ `bootPromiseRef` returns the same promise for a second call,
1193
+ rather than re-running the handshake.
1194
+
1195
+ ### 13.6 Protocol v1 messages
1196
+
1197
+ Full type definitions in
1198
+ [`ts/host/src/lib/protocol.ts`](./host/src/lib/protocol.ts).
1199
+ Envelopes are JSON, carry `source: 'reachy-mini'` and `version: 1`.
1200
+ Both sides validate `event.origin` against `window.location.origin`
1201
+ before trusting the payload.
1202
+
1203
+ | Direction | Type | Purpose |
1204
+ |-----------------|-------------------------------|-----------------------------------------------|
1205
+ | embed → host | `embed:ready` | "I'm alive, send creds" |
1206
+ | host → embed | `host:init` | Theme, signaling URL, hfToken, robotPeerId, config |
1207
+ | embed → host | `embed:app-state` | Lifecycle phase + connecting sub-step |
1208
+ | host → embed | `host:theme-changed` | Theme switched (no reload) |
1209
+ | host → embed | `host:config-changed` | Config updated (no reload, mobile-driven) |
1210
+ | host → embed | `host:leaving` | Tear-down request with `timeoutMs` |
1211
+ | embed → host | `embed:request-leave` | App requests end-of-session |
1212
+ | embed → host | `embed:error` | Error report (`{ message, fatal, detail? }`) |
1213
+
1214
+ Intentionally **not** in the v1 protocol:
1215
+
1216
+ - `embed:request-config-update` (apps don't push config upstream).
1217
+ - `host:custom` / `embed:custom` (no free-form channel; new needs
1218
+ land as typed messages via a major bump).
1219
+ - Any heartbeat / ping-pong messages.
1220
+
1221
+ #### Versioning policy
1222
+
1223
+ - `version: 1` is the only version today.
1224
+ - **Additive changes** (new optional field, new message type) ship
1225
+ without a version bump.
1226
+ - **Breaking changes** (removed field, changed semantics, removed
1227
+ message type) bump to `version: 2`. The host MAY support both
1228
+ versions for one release cycle, then drop v1.
1229
+ - On unknown version, the receiver logs a warning and ignores the
1230
+ message. No negotiation handshake.
1231
+
1232
+ #### Idempotency
1233
+
1234
+ - `host:leaving` may arrive twice; the embed runs `onLeave`
1235
+ callbacks **once** (gated by a `pendingLeaveTokenRef`) and acks
1236
+ every time.
1237
+ - `host:init` may arrive twice (rare: bridge re-arm); the embed
1238
+ treats the latest as authoritative and re-applies theme / config.
1239
+
1240
+ ### 13.7 Non-goals
1241
+
1242
+ To stay simple and auditable, the host shell explicitly does NOT do:
1243
+
1244
+ - **App discovery / listing / catalog**. The shell renders exactly
1245
+ **one** app: the one it ships with. Listing apps lives in the
1246
+ Reachy Mini mobile app, fed by the website's `/api/js-apps`
1247
+ endpoint (filtered on `reachy_mini_js_app`).
1248
+ - **"Official app" badging in the shell**. Officialness is derived
1249
+ from the Space ID prefix and surfaces only in the mobile catalog.
1250
+ - **Multi-robot per tab**. One robot at a time per Space session.
1251
+ - **Hot reload of the app code without reloading the iframe**.
1252
+ Code updates require unmounting + remounting.
1253
+ - **App ↔ app communication**. Apps are sandboxed by design.
1254
+ - **Offline mode**. The central is required for every session.
1255
+ - **Automatic WebRTC retry**. On a session drop, the user goes
1256
+ back to the picker manually.
1257
+ - **Queue or persistence of postMessage events** if the bridge is
1258
+ down. The bridge is best-effort; both sides re-converge on the
1259
+ next message.
1260
+ - **Server-side rendering**. The host is a CSR shell, deliberately.
1261
+ - **Cross-origin iframe**. The embed is same-origin with the host
1262
+ (both served by the Space); the origin check relies on this.
1263
+ - **Imposing a framework on app authors**. Apps inside the iframe
1264
+ are completely free of framework constraints.
1265
+
1266
+ ### 13.8 Threat model
1267
+
1268
+ The host runs in a HF Spaces container; the embed runs in a
1269
+ same-origin iframe within the same Space.
1270
+
1271
+ | Asset | Threat | Mitigation |
1272
+ |--------------------------|------------------------------------------|------------------------------------------------|
1273
+ | HF bearer token | Leak via URL log / referer | Hash-only + immediate wipe (§13.5.2), 15 min TTL |
1274
+ | HF bearer token | Leak via sessionStorage to other origin | Same-origin embed, no cross-origin postMessage |
1275
+ | `config` payload | Attacker controls URL → malformed JSON | App MUST validate at the boundary (typed cast is not enough) |
1276
+ | postMessage channel | Random extension posts a forged message | Receivers check `source === 'reachy-mini'` AND `event.origin === window.location.origin` |
1277
+ | Central session | Tab crashes, robot stays claimed | `pagehide` triggers `stopSession()`; central also enforces idle timeout |
1278
+
1279
+ **Out of scope** for this iteration:
1280
+ - Defence against a malicious app that the user explicitly loaded.
1281
+ We trust apps published under the `pollen-robotics/*` namespace.
1282
+ - Defence against a compromised central. The signaling URL is
1283
+ configurable per-Space; the central is the trust root.
1284
+
1285
+ ---
1286
+
1287
+ ## 14. Robotics best practices
1288
+
1289
+ Subpath import: `@pollen-robotics/reachy-mini-sdk/animation`
1290
+
1291
+ Pure-TypeScript helpers that every Reachy Mini JS app used to copy-paste
1292
+ from Rémi's `reachy-mini-js-practices` bench (the same source referenced
1293
+ inline in `ts/animation/safe-return.ts`, `distance.ts`, `presets.ts`).
1294
+ **No daemon changes** - everything routes through existing data-channel
1295
+ commands (`setMotorMode`, `setTarget`, `gotoTarget`). Three concrete
1296
+ callers today (`reachy_mini_emotions`, `reachy_mini_marionette` v1 + v2)
1297
+ still carry their own copies; consolidate onto this lib for new apps.
1298
+
1299
+ See [`ts/animation/DESIGN.md`](./animation/DESIGN.md) for the two-phase
1300
+ roadmap: Phase 1 (everything below) ships now; Phase 2 is the video-game-style
1301
+ animation graph (named layers, masking, crossfades, procedural clips) - not
1302
+ in this release.
1303
+
1304
+ ### 14.1 Pose types: `Pose` vs `PartialPose`
1305
+
1306
+ ```ts
1307
+ import type { Pose, PartialPose } from "@pollen-robotics/reachy-mini-sdk/animation";
1308
+
1309
+ interface Pose { head: number[]; antennas: [number, number]; body_yaw: number }
1310
+ interface PartialPose { head?: number[]; antennas?: [number, number] | number[]; body_yaw?: number }
1311
+ ```
1312
+
1313
+ - **`Pose`**: all three channels required. Use for hand-authored targets
1314
+ where you want the type system to nag if you forget a channel.
1315
+ - **`PartialPose`**: any subset. Matches the SDK's `setTarget` /
1316
+ `gotoTarget` partial-update semantics, and is the shape of
1317
+ `reachy.robotState` (fields appear only after the daemon has emitted
1318
+ them).
1319
+
1320
+ Wire-format units, everywhere: `head` is a flat 16-float row-major 4×4
1321
+ homogeneous matrix, `antennas` is `[right, left]` in **radians**,
1322
+ `body_yaw` is a scalar in **radians**.
1323
+
1324
+ > Avoid carrying degrees around in your motion code. Convert at the UI
1325
+ > boundary (`degToRad` / `radToDeg` from the SDK root) so everything
1326
+ > below the boundary speaks wire units. Apps that drift between deg and
1327
+ > rad ship subtle off-by-57 bugs.
1328
+
1329
+ ### 14.2 Distance & scaled duration
1330
+
1331
+ The pure math behind "how big is this move" and "how long should it
1332
+ take", computed **synchronously client-side** so apps can sync audio
1333
+ cues, schedule streamer ticks, and live-tune constants without an extra
1334
+ round-trip to the daemon.
1335
+
1336
+ ```ts
1337
+ import {
1338
+ distanceBetweenPoses,
1339
+ scaledDuration,
1340
+ DEFAULT_SCALED_DURATION_PRESET,
1341
+ } from "@pollen-robotics/reachy-mini-sdk/animation";
1342
+
1343
+ const dist = distanceBetweenPoses(reachy.robotState, target);
1344
+ // { head?: number /* magic-mm */,
1345
+ // antennas?: { right: number, left: number } /* deg */,
1346
+ // body_yaw?: number /* deg */ }
1347
+
1348
+ const plan = scaledDuration(reachy.robotState, target);
1349
+ // { duration: number,
1350
+ // limiter: "head"|"antennaR"|"antennaL"|"body_yaw"|null,
1351
+ // perChannel: { head?, antennaR?, antennaL?, body_yaw? } }
1352
+
1353
+ reachy.gotoTarget({ ...target, duration: plan.duration });
1354
+ ```
1355
+
1356
+ The head metric is **magic-mm** - `translation_mm + rotation_deg` fused
1357
+ into a single scalar that's monotonic with "how big does this move
1358
+ feel". Mirror of the daemon's
1359
+ `utils/interpolation.distance_between_poses`.
1360
+
1361
+ **Log `plan.limiter` in your motion paths.** It answers "why does the
1362
+ home return take 1.2 s?" instantly without bisecting through three
1363
+ channels by hand.
1364
+
1365
+ The default preset is calibrated on a real Reachy Mini (May 2026 bench):
1366
+
1367
+ | Field | Default | Rationale |
1368
+ |---|---|---|
1369
+ | `headSecPerMagicMm` | `0.015` | 0.02 reads as noticeably slow on the head. |
1370
+ | `antennaSecPerDeg` | `0.005` | Antennas are light; PR [#952](https://github.com/pollen-robotics/reachy_mini/pull/952) resonance window penalises too-fast moves. |
1371
+ | `bodyYawSecPerDeg` | `0.015` | Body sits between head (heavy) and antennas (light). |
1372
+ | `minDurationSec` | `0.01` | Low floor so small in-app corrections feel snappy. |
1373
+ | `maxDurationSec` | `1.5` | Matches the host shell's leave-protocol budget so `safelyReturnToPose` fits inside `onLeave`. |
1374
+
1375
+ Derive a custom preset by spreading (don't mutate - the constant is
1376
+ deep-frozen):
1377
+
1378
+ ```ts
1379
+ const SLOWER_HEAD = {
1380
+ ...DEFAULT_SCALED_DURATION_PRESET,
1381
+ headSecPerMagicMm: 0.03,
1382
+ };
1383
+ const plan = scaledDuration(current, target, SLOWER_HEAD);
1384
+ ```
1385
+
1386
+ Edge case: when no channel overlaps between `current` and `target`,
1387
+ `scaledDuration` returns `{ duration: minDurationSec, limiter: null,
1388
+ perChannel: {} }`. The resulting `gotoTarget` is a safe no-op held over
1389
+ the minimum dwell. Pass an explicit `duration` if you need a longer
1390
+ dwell.
1391
+
1392
+ ### 14.3 Safe return to home pose (`safelyReturnToPose`)
1393
+
1394
+ The canonical "return to safe rest" recipe in one call. Use it as your
1395
+ `onLeave` body (see [§8 Cleaning up on leave](#8-cleaning-up-on-leave)):
1396
+
1397
+ ```ts
1398
+ import { safelyReturnToPose } from "@pollen-robotics/reachy-mini-sdk/animation";
1399
+
1400
+ handle.onLeave(() => safelyReturnToPose(handle.reachy));
1401
+ ```
1402
+
1403
+ What it does, in order:
1404
+
1405
+ 1. `reachy.setMotorMode("enabled")` - safe since daemon PR
1406
+ [#1138](https://github.com/pollen-robotics/reachy_mini/pull/1138)
1407
+ (torque-on now pins all targets to the present pose before flipping).
1408
+ 2. Reads `reachy.robotState` for the current pose snapshot.
1409
+ 3. Computes `scaledDuration(current, target, preset)`.
1410
+ 4. Dispatches `reachy.gotoTarget({ ...target, duration })`.
1411
+
1412
+ Returns the `ScaledDurationResult` **synchronously after dispatching** -
1413
+ does NOT await completion. If you need to await the motion finishing,
1414
+ subscribe to the `state` event or `await sleep(plan.duration * 1000)`.
1415
+
1416
+ Default target is `INIT_POSE`:
1417
+
1418
+ ```ts
1419
+ import { INIT_POSE } from "@pollen-robotics/reachy-mini-sdk/animation";
1420
+
1421
+ // INIT_POSE.head : identity 4×4 matrix (np.eye(4) in daemon parlance)
1422
+ // INIT_POSE.antennas : [-0.1745, 0.1745] ≈ ±10° outward (anti-resonance offset, PR #952)
1423
+ // INIT_POSE.body_yaw : 0
1424
+ ```
1425
+
1426
+ Override for app-specific rest poses:
1427
+
1428
+ ```ts
1429
+ handle.onLeave(() =>
1430
+ safelyReturnToPose(handle.reachy, {
1431
+ target: { head: customHeadMatrix, antennas: [0, 0], body_yaw: 0 },
1432
+ })
1433
+ );
1434
+ ```
1435
+
1436
+ **Safe to call when the data channel is closed.** Every underlying SDK
1437
+ call swallows "channel closed" errors silently; the planned
1438
+ `ScaledDurationResult` is still returned so you can log the move that
1439
+ would have happened.
1440
+
1441
+ ### 14.4 Standalone exit hooks (`installShutdownHandler`)
1442
+
1443
+ > **Host-shell apps: stop. Use `handle.onLeave()` from `connectToHost()`
1444
+ > instead** - it integrates with the host's leave-protocol budget and
1445
+ > avoids double-firing. Mixing the two dispatches `safelyReturnToPose`
1446
+ > twice on close.
1447
+
1448
+ `installShutdownHandler` is for **standalone apps** (test benches,
1449
+ custom dashboards, anything that doesn't go through `mountHost()`):
1450
+
1451
+ ```ts
1452
+ import { installShutdownHandler } from "@pollen-robotics/reachy-mini-sdk/animation";
1453
+
1454
+ const reachy = new ReachyMini({ appName: "my-bench" });
1455
+ await reachy.authenticate();
1456
+ await reachy.connect();
1457
+ // ...pick robot, startSession...
1458
+ installShutdownHandler(reachy);
1459
+ ```
1460
+
1461
+ What it does:
1462
+
1463
+ - Wires `pagehide` AND `beforeunload`. Both can fire on a real tab
1464
+ close, but in different scenarios - mobile Safari only fires
1465
+ `pagehide`. Wiring both is necessary for cross-browser coverage.
1466
+ - Reentry-guards so `safelyReturnToPose` doesn't dispatch twice when
1467
+ both handlers fire on the same close.
1468
+ - Defaults to `onlyWhenStreaming: true`: skips the goto when no session
1469
+ is live, so a stale tab where the user never picked a robot doesn't
1470
+ command anything on close.
1471
+
1472
+ ### 14.5 Daemon parity warning
1473
+
1474
+ `INIT_POSE` and the magic-mm head coefficient mirror constants in the
1475
+ **Python daemon**:
1476
+
1477
+ - `INIT_POSE.head` ↔ `Backend.INIT_HEAD_POSE` (`np.eye(4)`) in
1478
+ `src/reachy_mini/daemon/backend/abstract.py`.
1479
+ - `INIT_POSE.antennas` ↔ `Backend.INIT_ANTENNAS_JOINT_POSITIONS`
1480
+ (`[-0.1745, 0.1745]`).
1481
+ - The magic-mm head distance ↔
1482
+ `src/reachy_mini/daemon/utils/interpolation.py:distance_between_poses`.
1483
+
1484
+ **If a daemon PR changes any of these, the JS side must follow in the
1485
+ same release.** `ts/animation/presets.ts` calls this out at the top of
1486
+ each constant; respect it. Both `INIT_POSE` and
1487
+ `DEFAULT_SCALED_DURATION_PRESET` are deep-frozen on the JS side so apps
1488
+ can't accidentally drift via `INIT_POSE.head[0] = 0` (mutations throw in
1489
+ strict mode, silently no-op otherwise).
1490
+
1491
+ ### 14.6 Anti-patterns
1492
+
1493
+ | Don't | Do |
1494
+ |---|---|
1495
+ | Re-implement `distanceBetweenPoses` / `scaledDuration` in your app. | Import from `/animation`. The lib exists exactly because three apps did this and drifted. |
1496
+ | Mix `installShutdownHandler` and `onLeave` in a host-shell app. | `onLeave` only. They both dispatch `safelyReturnToPose` and step on each other. |
1497
+ | Call `reachy.stopSession()` inside your `onLeave`. | Let the host tear down. Do app-specific cleanup (return-to-pose, close audio, close sockets) and return. |
1498
+ | Mutate `INIT_POSE` or `DEFAULT_SCALED_DURATION_PRESET`. | They're deep-frozen; spread to derive a variant. |
1499
+ | `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. |
1500
+ | Carry degrees through your motion code. | Convert at the UI boundary; speak radians + magic-mm everywhere below it. |
1501
+ | 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". |
backend/src/agent/skills/reachy-mini-app/daemon/control-loops.md ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ > **Auto-fetched** from [`pollen-robotics/reachy_mini@main`](https://github.com/pollen-robotics/reachy_mini/blob/main/skills/control-loops.md) on 2026-05-29 · canonical SDK pin: `1.8.0-rc1`.
2
+ > Do not edit by hand - run `npm run sync-docs` to refresh.
3
+
4
+ # Skill: Control Loops
5
+
6
+ ## When to Use
7
+
8
+ - Building an app that needs real-time reactivity (face tracking, games, joystick control)
9
+ - User asks about `set_target()` or continuous motion control
10
+ - App needs to respond to sensor input at > 10Hz (ideally 50Hz+)
11
+
12
+ ## Quick Check
13
+
14
+ If the app only needs choreographed, predetermined motions, use `goto_target()` instead (see `motion-philosophy.md`).
15
+
16
+ ---
17
+
18
+ ## The Core Rule
19
+
20
+ **One control loop, one place calling `set_target()`, running at fixed frequency (~100Hz).**
21
+
22
+ ```python
23
+ import time
24
+ from reachy_mini import ReachyMini
25
+
26
+ with ReachyMini() as mini:
27
+ while not stop_event.is_set():
28
+ # Compute target pose (can change every tick!)
29
+ pose = compute_current_target_pose()
30
+
31
+ # Send it
32
+ mini.set_target(head=pose, antennas=antennas)
33
+
34
+ # Maintain frequency
35
+ time.sleep(0.01) # ~100Hz
36
+ ```
37
+
38
+ ## Why This Matters
39
+
40
+ - **Single control point** prevents race conditions and jerky motion
41
+ - **Continuous targets** maintain "illusion of life" - even when idle, keep sending
42
+ - **Modify the pose, not where you call set_target** - complex behavior = change what pose you compute, don't add more set_target calls
43
+
44
+ ---
45
+
46
+ ## Reference Implementation: moves.py
47
+
48
+ **Best example:** `~/reachy_mini_resources/reachy_mini_conversation_app/src/reachy_mini_conversation_app/moves.py`
49
+
50
+ > If this folder doesn't exist, run `skills/setup-environment.md` to clone reference apps.
51
+
52
+ This file demonstrates control with:
53
+
54
+ ### Primary vs Secondary Moves
55
+
56
+ - **Primary moves** (emotions, dances, goto, breathing) - mutually exclusive, run sequentially
57
+ - **Secondary moves** (face tracking, speech sync) - additive offsets layered on top
58
+ - **Pose fusion** - combining primary pose with secondary offsets
59
+
60
+ ### Phase-Aligned Timing
61
+
62
+ Uses `time.monotonic()` to measure elapsed time accurately:
63
+ - Avoids wall-clock jumps from NTP sync, sleep/wake, etc.
64
+ - Ensures consistent timing regardless of system load
65
+
66
+ ### Threading Model
67
+
68
+ - Dedicated worker thread owns robot output
69
+ - Other threads communicate via queues
70
+ - Never call `set_target()` from multiple threads
71
+
72
+ ---
73
+
74
+ ## Basic Control Loop Template
75
+
76
+ ```python
77
+ import time
78
+ import threading
79
+ import numpy as np
80
+ from reachy_mini import ReachyMini
81
+ from reachy_mini.utils import create_head_pose
82
+
83
+ class MyController:
84
+ def __init__(self):
85
+ self.stop_event = threading.Event()
86
+ self.target_yaw = 0.0
87
+ self.target_pitch = 0.0
88
+
89
+ def control_loop(self, mini: ReachyMini):
90
+ """Main control loop - only place that calls set_target."""
91
+ while not self.stop_event.is_set():
92
+ # Build pose from current targets
93
+ pose = create_head_pose(
94
+ yaw=self.target_yaw,
95
+ pitch=self.target_pitch,
96
+ degrees=True
97
+ )
98
+
99
+ # Send to robot
100
+ mini.set_target(head=pose)
101
+
102
+ # ~100Hz
103
+ time.sleep(0.01)
104
+
105
+ def update_target(self, yaw: float, pitch: float):
106
+ """Called from other threads/callbacks to update targets."""
107
+ self.target_yaw = yaw
108
+ self.target_pitch = pitch
109
+
110
+ # Usage
111
+ controller = MyController()
112
+ with ReachyMini() as mini:
113
+ loop_thread = threading.Thread(target=controller.control_loop, args=(mini,))
114
+ loop_thread.start()
115
+
116
+ # Update targets from elsewhere (e.g., tracking callback)
117
+ controller.update_target(yaw=30, pitch=10)
118
+
119
+ # ... eventually
120
+ controller.stop_event.set()
121
+ loop_thread.join()
122
+ ```
123
+
124
+ ---
125
+
126
+ ## Common Patterns
127
+
128
+ ### Adding Idle Motion (Breathing)
129
+
130
+ ```python
131
+ def compute_breathing_offset(t: float) -> float:
132
+ """Subtle pitch oscillation for 'alive' feeling."""
133
+ return 2.0 * np.sin(2 * np.pi * 0.2 * t) # 0.2 Hz, 2 degree amplitude
134
+
135
+ # In control loop:
136
+ t = time.monotonic()
137
+ breathing = compute_breathing_offset(t)
138
+ pose = create_head_pose(yaw=target_yaw, pitch=target_pitch + breathing, degrees=True)
139
+ ```
140
+
141
+ ---
142
+
143
+ ## Frequency Considerations
144
+
145
+ | Frequency | Use case |
146
+ |-----------|----------|
147
+ | 100 Hz | Real-time tracking, games |
148
+ | 50 Hz | Most interactive apps |
149
+ | 30 Hz | Minimum for smooth motion |
150
+ | < 30 Hz | Might look jerky |
backend/src/agent/skills/reachy-mini-app/daemon/interaction-patterns.md ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ > **Auto-fetched** from [`pollen-robotics/reachy_mini@main`](https://github.com/pollen-robotics/reachy_mini/blob/main/skills/interaction-patterns.md) on 2026-05-29 · canonical SDK pin: `1.8.0-rc1`.
2
+ > Do not edit by hand - run `npm run sync-docs` to refresh.
3
+
4
+ # Skill: Interaction Patterns
5
+
6
+ ## When to Use
7
+
8
+ - Designing how users will interact with the robot
9
+ - Building games or interactive experiences
10
+ - Creating apps without a traditional GUI
11
+
12
+ ---
13
+
14
+ ## Antennas as Buttons
15
+
16
+ The antenna motors use low P in PID - they're semi-passive and safe to push. This makes them natural physical buttons.
17
+
18
+ ```python
19
+ ANTENNA_THRESHOLD = 0.3 # radians, tune as needed
20
+
21
+ def check_antenna_press(mini):
22
+ _, antennas = mini.get_current_joint_positions()
23
+ if antennas is None:
24
+ return None
25
+
26
+ left, right = antennas
27
+ if abs(left) > ANTENNA_THRESHOLD:
28
+ return "left"
29
+ if abs(right) > ANTENNA_THRESHOLD:
30
+ return "right"
31
+ return None
32
+ ```
33
+
34
+ ### Use Cases
35
+
36
+ - **Start/stop**: Press antenna to begin game
37
+ - **Selection**: Left = option A, Right = option B
38
+ - **Confirmation**: Any antenna = "yes"
39
+
40
+ **Reference:** `~/reachy_mini_resources/reachy_mini_radio/` (change radio station with antennas)
41
+
42
+ > If `~/reachy_mini_resources/` doesn't exist, run `skills/setup-environment.md` to clone reference apps.
43
+
44
+ ---
45
+
46
+ ## Head as Controller
47
+
48
+ The head has 6 DOF - it's a powerful input device for games or recording.
49
+
50
+ ### Reading Head Position
51
+
52
+ ```python
53
+ def get_head_as_joystick(mini):
54
+ """Map head orientation to joystick-like values."""
55
+ pose = mini.get_current_head_pose()
56
+ # Extract orientation (implementation depends on pose format)
57
+ # Typically you'd extract yaw and pitch
58
+
59
+ # Normalize to -1 to 1 range
60
+ yaw_normalized = pose_yaw / 45.0 # Assuming ±45° range
61
+ pitch_normalized = pose_pitch / 30.0 # Assuming ±30° range
62
+
63
+ return {
64
+ "x": np.clip(yaw_normalized, -1, 1),
65
+ "y": np.clip(pitch_normalized, -1, 1)
66
+ }
67
+ ```
68
+
69
+ ### Use Cases
70
+
71
+ - **Games**: Head tilt controls spaceship, character, cursor
72
+ - **Recording**: Capture head motion for playback
73
+ - **Puppeteering**: Control another robot or avatar
74
+
75
+ **References:**
76
+ - `~/reachy_mini_resources/fire_nation_attacked/` - Head as joystick in game
77
+ - `~/reachy_mini_resources/spaceship_game/` - Head controls spaceship
78
+ - `~/reachy_mini_resources/marionette/` - Record and playback head motion
79
+
80
+ ---
81
+
82
+ ## No-GUI Pattern
83
+
84
+ For simple apps, skip the web UI. Use antenna interactions:
85
+
86
+ ### Basic Flow
87
+
88
+ ```python
89
+ def run(self, mini):
90
+ # 1. Signal readiness (twitch antennas)
91
+ self.twitch_antennas(mini)
92
+
93
+ # 2. Wait for user to press antenna
94
+ print("Press an antenna to start...")
95
+ while True:
96
+ press = check_antenna_press(mini)
97
+ if press:
98
+ break
99
+ time.sleep(0.1)
100
+
101
+ # 3. Run the actual app
102
+ self.main_loop(mini)
103
+ ```
104
+
105
+ ### Signaling States
106
+
107
+ Use antenna motion to communicate without GUI:
108
+
109
+ | State | Antenna behavior |
110
+ |-------|------------------|
111
+ | Ready/waiting | Gentle twitch |
112
+ | Processing | Slow wave |
113
+ | Success | Quick double bounce |
114
+ | Error | Shake rapidly |
115
+
116
+ **Reference:** `~/reachy_mini_resources/reachy_mini_simon/` (full game using only antennas)
117
+
118
+ ---
119
+
120
+ ## Combining Patterns
121
+
122
+ Many apps combine multiple patterns:
123
+
124
+ ```python
125
+ class InteractiveApp:
126
+ def run(self, mini):
127
+ # Start with antenna press (no-GUI)
128
+ self.wait_for_start(mini)
129
+
130
+ while self.running:
131
+ # Use head as input
132
+ joystick = self.get_head_as_joystick(mini)
133
+
134
+ # Check for antenna presses
135
+ antenna = check_antenna_press(mini)
136
+ if antenna == "left":
137
+ self.action_a()
138
+ elif antenna == "right":
139
+ self.action_b()
140
+
141
+ # Update game/app state based on head position
142
+ self.update(joystick)
143
+
144
+ time.sleep(0.01)
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Tips
150
+
151
+ - **Debounce antenna presses** - Add cooldown to prevent multiple triggers
152
+ - **Provide feedback** - Move antennas or play emotion when input detected
153
+ - **Consider accessibility** - Not everyone can push antennas easily
154
+ - **Test threshold values** - Different users push with different force
backend/src/agent/skills/reachy-mini-app/daemon/javascript-sdk.md ADDED
@@ -0,0 +1,241 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ > **Auto-fetched** from [`pollen-robotics/reachy_mini@main`](https://github.com/pollen-robotics/reachy_mini/blob/main/docs/source/SDK/javascript-sdk.md) on 2026-05-29 · canonical SDK pin: `1.8.0-rc1`.
2
+ > Do not edit by hand - run `npm run sync-docs` to refresh.
3
+
4
+ # JavaScript SDK runtime reference
5
+
6
+ > **Building a Reachy Mini JS app?** The single source of truth is
7
+ > [`ts/APP_CREATION_GUIDE.md`](../../../ts/APP_CREATION_GUIDE.md) at
8
+ > the repo root. It covers scaffolding, `public/icon.svg`, the host
9
+ > shell, `sdk: static` deploy,
10
+ > `mountHost()` / `connectToHost()` API, local dev, FAQ, and the host
11
+ > ↔ embed contract. **Pin the SDK to
12
+ > `@pollen-robotics/reachy-mini-sdk@1.8.0-rc1-main.fd4354c`** (the
13
+ > version validated against the host shell + daemon).
14
+ >
15
+ > **This file** is the runtime API surface of the `ReachyMini` class
16
+ > you receive from `handle.reachy` once `connectToHost()` resolves:
17
+ > methods, events, properties, state machine, and the daemon-side
18
+ > recorded-move playback API. Bookmark it after you've shipped a
19
+ > first app from the guide.
20
+
21
+ Reachy Mini ships a browser SDK that drives a robot over WebRTC.
22
+ The npm package `@pollen-robotics/reachy-mini-sdk` exposes:
23
+
24
+ - The `ReachyMini` class (the SDK runtime documented below).
25
+ - The host shell + embed adapter under the `./host*` subpath
26
+ exports (`./host`, `./host/auto`, `./host/embed`, `./host/protocol`).
27
+ See [`ts/APP_CREATION_GUIDE.md`](../../../ts/APP_CREATION_GUIDE.md)
28
+ for the integration recipe.
29
+
30
+ ## Architecture
31
+
32
+ ```
33
+ ┌─────────────────────────────────┐
34
+ │ Browser │
35
+ │ (your app + reachy-mini-sdk.js)│
36
+ └───────┬────────────┬────────────┘
37
+ │ SSE/HTTP │ WebRTC (peer-to-peer)
38
+ │ signaling │ video + audio + data
39
+ ┌───────▼──────┐ │
40
+ │ Signaling │ │
41
+ │ Server │ │
42
+ │ (HF Space) │ │
43
+ └───────┬──────┘ │
44
+ │ │
45
+ ┌───────▼────────────▼────────────┐
46
+ │ Robot │
47
+ │ GStreamer WebRTC daemon │
48
+ │ camera · mic · motors │
49
+ └─────────────────────────────────┘
50
+ ```
51
+
52
+ 1. Your app is a static HTML/JS page hosted on Hugging Face Spaces.
53
+ 2. The SDK handles authentication, signaling, and WebRTC negotiation.
54
+ 3. The signaling server relays SDP offers/answers and ICE candidates
55
+ and validates Hugging Face OAuth tokens.
56
+ 4. Once the WebRTC connection is established, video, audio, and
57
+ commands flow peer-to-peer; the signaling server is no longer in
58
+ the path.
59
+
60
+ When you use the host shell (`mountHost()` + `connectToHost()`,
61
+ documented in [`ts/APP_CREATION_GUIDE.md`](../../../ts/APP_CREATION_GUIDE.md)),
62
+ the steps below are handled for you. The class-level API documented
63
+ here is what you use **after** `connectToHost()` resolves, or what
64
+ you call directly if you opted out of the host shell.
65
+
66
+ ## API Reference
67
+
68
+ ### Constructor
69
+
70
+ ```js
71
+ new ReachyMini({
72
+ signalingUrl: "https://pollen-robotics-reachy-mini-central.hf.space", // default
73
+ enableMicrophone: true, // default — request mic on startSession()
74
+ })
75
+ ```
76
+
77
+ ### State Machine
78
+
79
+ ```
80
+ 'disconnected' ──connect()──▸ 'connected' ──startSession()──▸ 'streaming'
81
+ ▴ disconnect() ▴ stopSession()
82
+ └─────────────────────────────┘
83
+ ```
84
+
85
+ ### Properties (read-only)
86
+
87
+ | Property | Type | Description |
88
+ | :--- | :--- | :--- |
89
+ | `state` | `string` | `"disconnected"`, `"connected"`, or `"streaming"` |
90
+ | `robots` | `Array` | Available robots: `[{ id, meta: { name } }]` |
91
+ | `robotState` | `Object` | Latest `state` event detail — `{ head: number[16], antennas: [rRad, lRad], body_yaw, motor_mode, is_move_running }` (wire shape) |
92
+ | `username` | `string\|null` | HF username after `authenticate()` |
93
+ | `isAuthenticated` | `boolean` | True if a valid HF token is available |
94
+ | `micSupported` | `boolean` | True if robot offers bidirectional audio |
95
+ | `micMuted` | `boolean` | Your microphone mute state |
96
+ | `audioMuted` | `boolean` | Robot speaker mute state (local only) |
97
+
98
+ ### Methods
99
+
100
+ | Method | Returns | Description |
101
+ | :--- | :--- | :--- |
102
+ | `authenticate()` | `Promise<boolean>` | Check for existing HF OAuth token |
103
+ | `login()` | — | Redirect to HF login page |
104
+ | `connect()` | `Promise` | Open SSE connection, receive robot list |
105
+ | `startSession(robotId)` | `Promise` | Negotiate WebRTC, resolves when video + data ready |
106
+ | `stopSession()` | `Promise` | End session, back to `connected` |
107
+ | `disconnect()` | — | Close signaling (keeps auth) |
108
+ | `logout()` | — | Clear HF credentials |
109
+ | `attachVideo(videoEl)` | `() => void` | Bind video stream to element; returns cleanup function |
110
+ | `setTarget({ head?, antennas?, body_yaw? })` | `boolean` | Atomic raw-units update — `head` is `number[16]` (flat 4×4), `antennas` is `[rRad, lRad]`, `body_yaw` is radians |
111
+ | `setHeadRpyDeg(roll, pitch, yaw)` | `boolean` | Set head orientation in degrees (wraps `setTarget`) |
112
+ | `setAntennasDeg(right, left)` | `boolean` | Set antenna positions in degrees (wraps `setTarget`) |
113
+ | `setBodyYawDeg(yaw)` | `boolean` | Set body yaw in degrees (wraps `setTarget`) |
114
+ | `playSound(filename)` | `boolean` | Play a sound file on the robot |
115
+ | `sendRaw(data)` | `boolean` | Send arbitrary JSON via data channel |
116
+ | `requestState()` | `boolean` | Request a state snapshot |
117
+ | `setAudioMuted(muted)` | — | Mute/unmute robot speaker (local) |
118
+ | `setMicMuted(muted)` | — | Mute/unmute your microphone |
119
+ | `playMove(motion, opts?)` | `Promise<{finished?, cancelled?, error?, has_audio?}>` | Upload + play a recorded move (optionally with audio) on the daemon's local clock; resolves when playback ends — see [Daemon-side recorded-move playback](#daemon-side-recorded-move-playback) |
120
+ | `cancelMove()` | `boolean` | Cancel an in-flight `playMove` |
121
+ | `uploadAudio(blob, opts?)` | `Promise<string>` | Upload a standalone audio slot, returns `uploadId` — pair with `playUploadedAudio` for record-time sync |
122
+ | `playUploadedAudio(uploadId, opts?)` | `Promise<{started: true, ...}>` | Trigger daemon-side standalone audio playback; resolves on the daemon's `started` broadcast (use as a sync anchor) |
123
+ | `cancelAudio()` | `boolean` | Cancel an in-flight `playUploadedAudio` |
124
+
125
+ > **`setTarget` head-vs-body coupling.** The `head` matrix is in the
126
+ > world frame. Sending `setTarget({ body_yaw })` alone rotates the
127
+ > body *but not the head's commanded world yaw* — the head's gaze
128
+ > stays fixed in world frame, so visually it appears to counter-rotate
129
+ > as the body turns. For tank-style "head follows body", add the body
130
+ > yaw delta to the head RPY's yaw and ship `head` + `body_yaw` in the
131
+ > same `setTarget` call. The baseline for the head yaw must be the
132
+ > last *commanded* value you tracked yourself, not `state.head` from
133
+ > the telemetry event — telemetry lags one WebRTC RTT and cumulative
134
+ > deltas computed against it stall under rapid input.
135
+
136
+ ### Events
137
+
138
+ Use `robot.addEventListener(name, handler)` — the SDK extends `EventTarget`.
139
+
140
+ | Event | Detail | Description |
141
+ | :--- | :--- | :--- |
142
+ | `connected` | `{ peerId }` | Signaling connection established |
143
+ | `disconnected` | `{ reason }` | Signaling connection lost |
144
+ | `robotsChanged` | `{ robots }` | Robot list updated |
145
+ | `streaming` | `{ sessionId, robotId }` | WebRTC session active |
146
+ | `sessionStopped` | `{ reason }` | Session ended |
147
+ | `state` | `{ head, antennas, body_yaw, motor_mode, is_move_running }` | Robot state update (~500ms; wire shape — see "Receive robot state" above) |
148
+ | `videoTrack` | `{ track, stream }` | Video track available |
149
+ | `micSupported` | `{ supported }` | Bidirectional audio availability |
150
+ | `error` | `{ source, error }` | Error from `signaling`, `webrtc`, or `robot` |
151
+
152
+ ### Math Utilities
153
+
154
+ ```js
155
+ import { rpyToMatrix, matrixToRpy, degToRad, radToDeg } from "@pollen-robotics/reachy-mini-sdk";
156
+
157
+ rpyToMatrix(roll, pitch, yaw) // degrees → 4×4 rotation matrix (ZYX)
158
+ matrixToRpy(matrix) // 4×4 matrix → { roll, pitch, yaw } in degrees
159
+ ```
160
+
161
+ ## Daemon-side recorded-move playback
162
+
163
+ Long recorded moves (and any move with audio) should play **server-side on the daemon's local clock**, not by streaming `set_target` frames from the browser. The browser uploads the move once over the WebRTC data channel and the daemon ticks the inner loop at the requested frequency — no per-frame round-trip, smooth on wireless robots. When audio is attached the daemon plays it on the same GStreamer pipeline, so motion and audio share a single clock (no cross-network drift).
164
+
165
+ ### Combined motion + audio
166
+
167
+ ```js
168
+ const result = await robot.playMove(motion, {
169
+ audioBlob, // optional, 16 kHz mono PCM WAV
170
+ audioLeadMs: -100, // system-wide default
171
+ description: "happy wave",
172
+ onProgress: (p) => console.log(p.phase, p.sent, p.total),
173
+ onStarted: ({ duration_s, has_audio }) => { /* sync anchor */ },
174
+ });
175
+ // result is { finished: true } | { cancelled: true } | { error: "..." }
176
+
177
+ // Cancel at any time from another code path:
178
+ robot.cancelMove();
179
+ ```
180
+
181
+ `motion` is the shape the Python `RecordedMove` parser expects:
182
+ ```js
183
+ { time: [0, 0.01, 0.02, …], set_target_data: [{ head, antennas, body_yaw }, …] }
184
+ ```
185
+
186
+ `audioLeadMs` shifts audio relative to motion at the daemon:
187
+ - **Positive** — audio fires N ms BEFORE motion (compensates motor pickup).
188
+ - **Negative** — motion fires N ms BEFORE audio (compensates GStreamer playbin warmup).
189
+ - **Default `-100`** is the empirical system-wide constant (combined motor + pipeline). Tune only after measuring.
190
+
191
+ The encoded wire form defaults to `gzip+base64` (typically ~3× smaller for recorded-move JSON). Falls back to plain JSON if the browser lacks `CompressionStream`.
192
+
193
+ ### Record-time audio (sync anchor)
194
+
195
+ For recording flows that want the SAME audio pipeline at capture AND replay (so pipeline latency cancels out and one `audioLeadMs` works for all recordings):
196
+
197
+ ```js
198
+ // 1. During the countdown — upload the source audio.
199
+ const audioId = await robot.uploadAudio(audioBlob, { description: "song" });
200
+
201
+ // 2. At the GO! moment — kick off daemon-side playback, await the
202
+ // started broadcast, then start motion capture.
203
+ await robot.playUploadedAudio(audioId);
204
+ const captureT0 = performance.now();
205
+ startMyMotionCapture();
206
+
207
+ // 3. On stop / cancel / restart — stop the audio.
208
+ robot.cancelAudio();
209
+ ```
210
+
211
+ The daemon does NOT emit a `finished` event for standalone audio; callers know the duration from the WAV header and call `cancelAudio()` when done.
212
+
213
+ ### Audio format
214
+
215
+ Audio must be canonical **16 kHz mono 16-bit PCM WAV**. Apps are responsible for normalizing before upload — the daemon does not transcode. Format mismatch is a frequent cause of "audio is silent / wrong speed" on inherited datasets.
216
+
217
+ ### Backpressure & cancellation
218
+
219
+ `playMove` and `uploadAudio` pace chunk sends on the data channel's `bufferedAmount` so multi-megabyte uploads (a 3-min song's WAV is ~6 MB base64) don't degrade other channels on the same peer connection. There's no separate `pause` — to stop a long upload mid-way, close the session.
220
+
221
+ ## Security
222
+
223
+ - Authentication goes through Hugging Face OAuth — only users logged in to HF can access the signaling server.
224
+ - By default, you can only connect to robots registered under your own HF account.
225
+ - WebRTC connections are encrypted (DTLS/SRTP).
226
+
227
+ ## Prerequisites
228
+
229
+ - Your robot must be running the wireless firmware and connected to the central signaling server.
230
+ - The robot must have a valid Hugging Face token configured (see [Usage](../platforms/reachy_mini/usage)).
231
+ - Currently supported on **wireless versions** only.
232
+
233
+ ## Working examples
234
+
235
+ The three reference apps maintained alongside the SDK are the canonical worked examples. They all use the host shell pattern and the current SDK pin:
236
+
237
+ - [`pollen-robotics/reachy_mini_minimal_conversation`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_minimal_conversation) — vanilla TS + Vite.
238
+ - [`pollen-robotics/reachy_mini_emotions`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_emotions) — React 19 + MUI 7 + Vite.
239
+ - [`pollen-robotics/reachy_mini_telepresence`](https://huggingface.co/spaces/pollen-robotics/reachy_mini_telepresence) — React 19 + MUI 7 + Vite with camera + media streams.
240
+
241
+ Clone the closest one and trim. See [`ts/APP_CREATION_GUIDE.md`](../../../ts/APP_CREATION_GUIDE.md) for the step-by-step.
backend/src/agent/skills/reachy-mini-app/daemon/motion-philosophy.md ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ > **Auto-fetched** from [`pollen-robotics/reachy_mini@main`](https://github.com/pollen-robotics/reachy_mini/blob/main/skills/motion-philosophy.md) on 2026-05-29 · canonical SDK pin: `1.8.0-rc1`.
2
+ > Do not edit by hand - run `npm run sync-docs` to refresh.
3
+
4
+ # Skill: Motion Control Philosophy
5
+
6
+ ## When to Use
7
+
8
+ - Deciding between `goto_target()` and `set_target()`
9
+ - Planning how motion will work in your app
10
+ - User asks about smooth motion or interpolation
11
+
12
+ ---
13
+
14
+ ## The Two Methods
15
+
16
+ | Method | Behavior | Use when |
17
+ |--------|----------|----------|
18
+ | `goto_target()` | Smooth interpolation over duration | **Default choice** - gestures, choreography, transitions |
19
+ | `set_target()` | Immediate, no interpolation | Real-time control requiring continuous reactivity |
20
+
21
+ ---
22
+
23
+ ## goto_target(): The Default Choice
24
+
25
+ Use this for most motion. It handles interpolation automatically.
26
+
27
+ ```python
28
+ from reachy_mini import ReachyMini
29
+ from reachy_mini.utils import create_head_pose
30
+
31
+ with ReachyMini() as mini:
32
+ pose = create_head_pose(yaw=30, pitch=10, degrees=True)
33
+ mini.goto_target(head=pose, duration=1.0, method="minjerk")
34
+ ```
35
+
36
+ ### Interpolation Methods
37
+
38
+ | Method | Character |
39
+ |--------|-----------|
40
+ | `linear` | Constant speed |
41
+ | `minjerk` | Natural, smooth (default) |
42
+ | `ease_in_out` | Slow start/end |
43
+ | `cartoon` | Exaggerated, bouncy |
44
+
45
+ ### Key Insight
46
+
47
+ During `goto_target()` interpolation, **you cannot react to external stimuli**. The robot commits to completing the movement. This is fine for:
48
+ - Playing emotions
49
+ - Choreographed sequences
50
+ - Transitioning between states
51
+
52
+ ---
53
+
54
+ ## set_target(): For Real-Time Control
55
+
56
+ Use this when you need to react every frame (face tracking, games, joystick).
57
+
58
+ **Requirements:**
59
+ - Must run in a control loop at 50-100 Hz
60
+ - Single place in code calling `set_target()`
61
+ - Keep sending targets even when "idle"
62
+
63
+ See `control-loops.md` for implementation details.
64
+
65
+ ---
66
+
67
+ ## Decision Flowchart
68
+
69
+ ```
70
+ Does your app need to react to input/sensors in real-time?
71
+ ├── NO → Use goto_target()
72
+ │ - Simpler code
73
+ │ - Guaranteed smooth motion
74
+ │ - Good for: emotions, dances, scripted sequences
75
+
76
+ └── YES → Use set_target() in a control loop
77
+ - More complex but fully reactive
78
+ - You control smoothing
79
+ - Good for: tracking, games, recording
80
+ ```
81
+
82
+ ---
83
+
84
+ ## Common Mistake
85
+
86
+ Don't do this:
87
+
88
+ ```python
89
+ # BAD: Multiple set_target calls scattered in code
90
+ def on_face_detected(face):
91
+ mini.set_target(head=look_at_face(face))
92
+
93
+ def on_button_press():
94
+ mini.set_target(head=neutral_pose)
95
+
96
+ def idle_behavior():
97
+ mini.set_target(head=breathing_pose)
98
+ ```
99
+
100
+ Do this instead:
101
+
102
+ ```python
103
+ # GOOD: Single control loop, update target variables
104
+ def control_loop():
105
+ while running:
106
+ pose = compute_final_pose() # Combines all inputs
107
+ mini.set_target(head=pose)
108
+ time.sleep(0.01)
109
+
110
+ def on_face_detected(face):
111
+ controller.face_target = look_at_face(face)
112
+
113
+ def on_button_press():
114
+ controller.override = neutral_pose
115
+ ```
backend/src/agent/skills/reachy-mini-app/daemon/rest-api.md ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ > **Auto-fetched** from [`pollen-robotics/reachy_mini@main`](https://github.com/pollen-robotics/reachy_mini/blob/main/skills/rest-api.md) on 2026-05-29 · canonical SDK pin: `1.8.0-rc1`.
2
+ > Do not edit by hand - run `npm run sync-docs` to refresh.
3
+
4
+ # Skill: REST API
5
+
6
+ ## When to Use
7
+
8
+ - Building web UIs or dashboards (JavaScript/TypeScript)
9
+ - Creating clients in non-Python languages
10
+ - Controlling robot from a different machine
11
+ - Building AI applications that connect via HTTP
12
+ - Creating MCP (Model Context Protocol) servers
13
+
14
+ ---
15
+
16
+ ## Overview
17
+
18
+ The Reachy Mini daemon exposes a **FastAPI-based REST API** with HTTP and WebSocket endpoints. This allows control from any language or platform.
19
+
20
+ **Base URL:** `http://localhost:8000/api` (when daemon is running)
21
+
22
+ **Interactive docs:** `http://localhost:8000/docs` (Swagger UI)
23
+
24
+ ---
25
+
26
+ ## Key Endpoints
27
+
28
+ ### Movement Control
29
+
30
+ | Endpoint | Method | Description |
31
+ |----------|--------|-------------|
32
+ | `/api/move/goto` | POST | Move to target (head pose, antennas, body yaw) |
33
+ | `/api/move/set_target` | POST | Set instant target (high-frequency control) |
34
+ | `/api/move/play/wake_up` | POST | Wake up the robot |
35
+ | `/api/move/play/goto_sleep` | POST | Put robot to sleep |
36
+ | `/api/move/play/recorded-move-dataset/{dataset}/{move}` | POST | Play recorded moves |
37
+ | `/api/move/running` | GET | List running move tasks |
38
+ | `/api/move/stop` | POST | Stop a running move |
39
+
40
+ ### State Queries
41
+
42
+ | Endpoint | Method | Description |
43
+ |----------|--------|-------------|
44
+ | `/api/state/full` | GET | Complete robot state |
45
+ | `/api/state/present_head_pose` | GET | Current head pose |
46
+ | `/api/state/present_body_yaw` | GET | Current body rotation |
47
+ | `/api/state/present_antenna_joint_positions` | GET | Antenna positions |
48
+ | `/api/state/doa` | GET | Direction of arrival (microphones) |
49
+
50
+ ### Motor Control
51
+
52
+ | Endpoint | Method | Description |
53
+ |----------|--------|-------------|
54
+ | `/api/motors/status` | GET | Motor status/control mode |
55
+ | `/api/motors/set_mode/{mode}` | POST | Change mode (enabled, disabled, gravity_compensation) |
56
+
57
+ ### WebSocket Endpoints
58
+
59
+ | Endpoint | Description |
60
+ |----------|-------------|
61
+ | `ws://localhost:8000/api/state/ws/full` | Real-time state streaming |
62
+ | `ws://localhost:8000/api/move/ws/updates` | Movement event streaming |
63
+ | `ws://localhost:8000/api/move/ws/set_target` | Stream target commands |
64
+
65
+ ---
66
+
67
+ ## When to Use REST API vs Python SDK
68
+
69
+ **Use REST API when:**
70
+ - Building web frontends
71
+ - Using non-Python languages (JavaScript, Go, Rust, etc.)
72
+ - Running on a different machine from the robot
73
+ - Need language-agnostic access
74
+
75
+ **Use Python SDK when:**
76
+ - Building Python apps on the same machine
77
+ - Need direct kinematic calculations
78
+ - Working with media streams (camera, audio)
79
+ - Building high-frequency control loops (better latency)
80
+ - Working with recorded moves
81
+
82
+ ---
83
+
84
+ ## Example: JavaScript Fetch
85
+
86
+ ```javascript
87
+ // Move head to position
88
+ const response = await fetch('http://localhost:8000/api/move/goto', {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({
92
+ head_pose: { yaw: 30, pitch: 10 },
93
+ duration: 1.0
94
+ })
95
+ });
96
+
97
+ // Get current state
98
+ const state = await fetch('http://localhost:8000/api/state/full')
99
+ .then(r => r.json());
100
+ console.log(state);
101
+ ```
102
+
103
+ ---
104
+
105
+ ## Example: WebSocket State Streaming
106
+
107
+ ```javascript
108
+ const ws = new WebSocket('ws://localhost:8000/api/state/ws/full');
109
+
110
+ ws.onmessage = (event) => {
111
+ const state = JSON.parse(event.data);
112
+ console.log('Head pose:', state.head_pose);
113
+ };
114
+ ```
115
+
116
+ ---
117
+
118
+ ## Source Code Reference
119
+
120
+ For implementation details (in this repository):
121
+ - **Main FastAPI app:** `src/reachy_mini/daemon/app/main.py`
122
+ - **Router modules:** `src/reachy_mini/daemon/app/routers/`
123
+ - **API docs:** `docs/source/troubleshooting.md` (REST API FAQ section)
backend/src/agent/skills/reachy-mini-app/daemon/safe-torque.md ADDED
@@ -0,0 +1,120 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ > **Auto-fetched** from [`pollen-robotics/reachy_mini@main`](https://github.com/pollen-robotics/reachy_mini/blob/main/skills/safe-torque.md) on 2026-05-29 · canonical SDK pin: `1.8.0-rc1`.
2
+ > Do not edit by hand - run `npm run sync-docs` to refresh.
3
+
4
+ # Skill: Safe Torque Handling
5
+
6
+ ## Motor Control Modes
7
+
8
+ Three modes selected via `setMotorMode(...)` (JS) or `enable_motors()` / `disable_motors()` / `enable_gravity_compensation()` (Python):
9
+
10
+ - **`enabled`** — torque on, position control. Motors hold the commanded pose. Default working state.
11
+ - **`disabled`** — torque off. The head is fully compliant; it falls under gravity. Used during motion recording so the user can move the head by hand.
12
+ - **`gravity_compensation`** — torque on, current control. Motors actively cancel gravity, so the head feels weightless and can be pushed around by hand without falling. Requires the Placo kinematics engine.
13
+
14
+ `enable_motors()` pins all targets to the present pose before flipping torque on, so the head never snaps to an old goal. Set targets *after* enabling, not before — targets set before are overwritten.
15
+
16
+ ## When to Use
17
+
18
+ - Enabling or disabling motor torque
19
+ - Recording motion (compliance mode)
20
+ - Any app that toggles motors on/off
21
+
22
+ ---
23
+
24
+ ## The Problem
25
+
26
+ When you toggle motor torque carelessly:
27
+ - **Disabling torque**: Head falls (gravity)
28
+ - **Enabling torque**: Head jumps to old goal position
29
+
30
+ Both cause jerky, unpleasant motion.
31
+
32
+ ---
33
+
34
+ ## Before Disabling Torque
35
+
36
+ Go to a safe position first (head will fall when torque is off):
37
+
38
+ ```python
39
+ from reachy_mini.reachy_mini import SLEEP_HEAD_POSE
40
+ import numpy as np
41
+
42
+ # Move to sleep position before disabling
43
+ antenna_angle = np.deg2rad(15)
44
+ reachy_mini.goto_target(
45
+ SLEEP_HEAD_POSE,
46
+ antennas=[-antenna_angle, antenna_angle],
47
+ duration=1.0,
48
+ )
49
+ reachy_mini.disable_motors()
50
+ ```
51
+
52
+ ---
53
+
54
+ ## Before Enabling Torque
55
+
56
+ Set goal to current position first (prevents jump to old goal):
57
+
58
+ ```python
59
+ def _goto_current_pose(reachy_mini, duration=0.05):
60
+ """Set goal to current position to prevent jumps."""
61
+ head_pose = reachy_mini.get_current_head_pose()
62
+ _, antennas = reachy_mini.get_current_joint_positions()
63
+
64
+ reachy_mini.goto_target(
65
+ head=head_pose,
66
+ antennas=list(antennas) if antennas is not None else None,
67
+ duration=duration,
68
+ )
69
+ ```
70
+
71
+ ---
72
+
73
+ ## Known Bug: Partial Motor Enable/Disable
74
+
75
+ There is a known edge case when enabling/disabling only a **subset** of motors. The workaround is to call a full disable before enabling:
76
+
77
+ ```python
78
+ def _safe_enable_motors(self, reachy_mini: ReachyMini) -> None:
79
+ """Enable motors safely, handling edge cases."""
80
+ self._goto_current_pose(reachy_mini, duration=0.05)
81
+ reachy_mini.disable_motors() # Needed to handle edge case with mixed motor states
82
+ reachy_mini.enable_motors()
83
+ ```
84
+
85
+ **Reference:** See `~/reachy_mini_resources/fire_nation_attacked/fire_nation_attacked/main.py` for a working implementation of this pattern.
86
+
87
+ > If `~/reachy_mini_resources/` doesn't exist, run `skills/setup-environment.md` to clone reference apps.
88
+
89
+ ---
90
+
91
+ ## Complete Safe Enable Pattern
92
+
93
+ ```python
94
+ def safe_enable_motors(reachy_mini: ReachyMini) -> None:
95
+ """Enable motors without jerky motion."""
96
+ # 1. Read current position
97
+ head_pose = reachy_mini.get_current_head_pose()
98
+ _, antennas = reachy_mini.get_current_joint_positions()
99
+
100
+ # 2. Set goal to current (very short duration)
101
+ reachy_mini.goto_target(
102
+ head=head_pose,
103
+ antennas=list(antennas) if antennas is not None else None,
104
+ duration=0.05,
105
+ )
106
+
107
+ # 3. Full disable (handles mixed motor state edge case)
108
+ reachy_mini.disable_motors()
109
+
110
+ # 4. Enable all motors
111
+ reachy_mini.enable_motors()
112
+ ```
113
+
114
+ ---
115
+
116
+ ## Reference Implementation
117
+
118
+ **Best example:** `~/reachy_mini_resources/marionette/marionette/main.py`
119
+
120
+ This app toggles torque for motion recording and demonstrates the full safe pattern.