Spaces:
Sleeping
feat(skill): sync canonical daemon docs at build + modernize app template
Browse filesPull 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 +5 -2
- backend/package.json +2 -1
- backend/scripts/sync-daemon-docs.mjs +236 -0
- backend/src/agent/skill-loader.ts +7 -1
- backend/src/agent/skills/reachy-mini-app/SKILL.md +60 -14
- backend/src/agent/skills/reachy-mini-app/daemon/AGENTS.md +305 -0
- backend/src/agent/skills/reachy-mini-app/daemon/APP_CREATION_GUIDE.md +1501 -0
- backend/src/agent/skills/reachy-mini-app/daemon/control-loops.md +150 -0
- backend/src/agent/skills/reachy-mini-app/daemon/interaction-patterns.md +154 -0
- backend/src/agent/skills/reachy-mini-app/daemon/javascript-sdk.md +241 -0
- backend/src/agent/skills/reachy-mini-app/daemon/motion-philosophy.md +115 -0
- backend/src/agent/skills/reachy-mini-app/daemon/rest-api.md +123 -0
- backend/src/agent/skills/reachy-mini-app/daemon/safe-torque.md +120 -0
|
@@ -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).
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -5,7 +5,8 @@
|
|
| 5 |
"type": "module",
|
| 6 |
"scripts": {
|
| 7 |
"dev": "tsx watch src/server.ts",
|
| 8 |
-
"
|
|
|
|
| 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 |
},
|
|
@@ -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 |
+
});
|
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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");
|
|
@@ -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 (`
|
| 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
|
| 327 |
-
|
| 328 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 329 |
-->
|
| 330 |
<script type="module">
|
| 331 |
-
import { ReachyMini } from "https://cdn.jsdelivr.net/
|
| 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.
|
| 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.
|
| 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
|
|
|
|
|
|
|
|
|
|
| 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 (`
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.
|
| 947 |
-
robot.
|
| 948 |
-
robot.
|
| 949 |
-
robot.
|
|
|
|
| 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)
|
|
@@ -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
|
|
@@ -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". |
|
|
@@ -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 |
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -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 |
+
```
|
|
@@ -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)
|
|
@@ -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.
|