Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
392a4ca
feat(http): stream WAV audio to the speaker with POST /play
Mechazawa Oct 3, 2026
d6c996e
feat(anim): drive animation from an X-Anim timeline during POST /play
Mechazawa Oct 3, 2026
054a198
feat(anim): script the eyes from the X-Anim timeline
Mechazawa Oct 3, 2026
698c89c
feat(integrations): add a play command for agent-driven animations
Mechazawa Oct 4, 2026
4f31b08
docs(http): document POST /play and its X-Anim timeline
Mechazawa Oct 4, 2026
4650b34
feat(integrations): add a Claude Code skill for speaking and performing
Mechazawa Oct 4, 2026
dac754a
Merge remote-tracking branch 'origin/main' into feat/stream-audio
Mechazawa Oct 4, 2026
8611360
feat(scripts): speak in the robot's voice with word timings for /play
Mechazawa Oct 4, 2026
3410452
refactor(anim): reuse shared helpers in the /play timeline and script…
Mechazawa Oct 4, 2026
8cdee58
refactor(integrations): share robot fetch plumbing between anim and play
Mechazawa Oct 4, 2026
40d817a
refactor(scripts): load Kokoro only when say.py synthesizes
Mechazawa Oct 4, 2026
4571001
refactor(anim): run /play timelines through a Scripted mode and tickR…
Mechazawa Oct 4, 2026
882595a
refactor(integrations): ship say.py inside the tiny-engineer-play skill
Mechazawa Oct 4, 2026
17d9885
feat(integrations): perform a line with cues from one chordsheet string
Mechazawa Oct 4, 2026
3448bd2
refactor(firmware): one eye-mode accessor and tickRobot() in its own …
Mechazawa Oct 4, 2026
519659b
refactor(integrations): table-driven chordsheet checks and shared voi…
Mechazawa Oct 4, 2026
3da1f8f
feat(anim): add a talking animation
Mechazawa Oct 4, 2026
e7d3309
refactor(http): run one named animation during POST /play
Mechazawa Oct 4, 2026
2b9754b
Merge remote-tracking branch 'origin/main' into feat/stream-audio
Mechazawa Oct 4, 2026
508459a
fix(http): only read the /play path argument on the named route
Mechazawa Oct 4, 2026
cf775e2
refactor(http): derive the /play allowlist from the animation registry
Mechazawa Oct 4, 2026
68e0c0c
Merge remote-tracking branch 'origin/main' into feat/stream-audio
Mechazawa Oct 5, 2026
25c5be7
feat(integrations): add a tiny-engineer-play skill for every agent pl…
Mechazawa Oct 5, 2026
e8f32cd
fix(integrations): harden /play inputs and resume talking after a can…
Mechazawa Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Tiny Engineer is an open-source ESP32-C3 Wi-Fi desk robot: 3D-printed mechanics,
| --- | --- |
| `src/`, `include/`, `lib/`, `data/` | Firmware (PlatformIO) |
| `packages/` | HTTP / hook CLIs (Cursor, Antigravity, Claude Code, …) — scope `integrations` |
| `skills/` | Agent skills shared by Claude Code, Cursor and Antigravity — scope `integrations` |
| `3d_models/` | CAD and printables (CERN-OHL-S) |
| `mods/` | Optional mods; models under `mods/<name>/3d_models/` (CERN-OHL-S) |
| `hardware/` | KiCad boards (CERN-OHL-S) |
Expand Down Expand Up @@ -62,7 +63,7 @@ HTML index: one-line route descriptions only (except supported-parameter lists).
Format: `type(scope): summary` (imperative, lowercase type, no trailing period; PR title same).

Scopes: `firmware`, `http`, `settings`, `anim`, `servos`, `wifi`, `integrations`, `cad`, `mods`, `pcb`, `docs`, `scripts`, `ci`.
`integrations` = anything under `packages/`. Do not add a new scope per package.
`integrations` = anything under `packages/` or `skills/`. Do not add a new scope per package.
`mods` = anything under `mods/`. Name the mod in the summary. Do not add a new scope per mod.

Breaking rules, SemVer mapping, and examples: [CONTRIBUTING.md](CONTRIBUTING.md).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ A small robot on your desk. It moves, changes its face, and rings a bell as your

Open source. Print the body, plug the ESP32, servos, OLED, and speaker into the [main control board](docs/hardware/main-control-board.md), flash it, and it joins your Wi-Fi.

**Works with** [Claude Code](docs/integration.md#4-claude-code-dedicated-script) · [Cursor](docs/hooks.md) · [Antigravity](docs/integration.md#3-antigravity-cli-dedicated-script) — or Codex, Windsurf, and any script that can POST ([integration guide](docs/integration.md)).
**Works with** [Claude Code](docs/integration.md#4-claude-code-dedicated-script) · [Cursor](docs/hooks.md) · [Antigravity](docs/integration.md#3-antigravity-cli-dedicated-script) — or Codex, Windsurf, and any script that can POST ([integration guide](docs/integration.md)), including speech through [`POST /play`](docs/api.md#post-play).

## Build your own

Expand Down
52 changes: 44 additions & 8 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,20 +457,21 @@ curl http://tiny-engineer.local/anim

| Field | Meaning |
| --- | --- |
| `animation` | `none`, `typing`, `reading`, `thinking`, `ring`, `welcome`, `attention`, `error`, `abort`, `dead`, `wakeup`, or `sleep` |
| `animation` | `none`, `typing`, `reading`, `thinking`, `talking`, `ring`, `welcome`, `attention`, `error`, `abort`, `dead`, `wakeup`, or `sleep` |

### `POST /anim`

Request an animation switch. Each animation runs at least **1s**; if the current one is shorter than that, the switch waits until the hold ends. Rapid posts during the hold keep only the **latest** request. Response `animation` is the currently playing name (may still be the old one until the hold ends). Hand moves use speed-limited servo wrappers.

| Param | Type | Values |
| --- | --- | --- |
| `name` | string | `none`, `typing`, `reading`, `thinking`, `ring`, `welcome`, `attention`, `error`, `abort`, `dead`, `wakeup`, `sleep` |
| `name` | string | `none`, `typing`, `reading`, `thinking`, `talking`, `ring`, `welcome`, `attention`, `error`, `abort`, `dead`, `wakeup`, `sleep` |

```bash
curl -X POST "http://tiny-engineer.local/anim?name=typing"
curl -X POST "http://tiny-engineer.local/anim?name=reading"
curl -X POST "http://tiny-engineer.local/anim?name=thinking"
curl -X POST "http://tiny-engineer.local/anim?name=talking"
curl -X POST "http://tiny-engineer.local/anim?name=ring"
curl -X POST "http://tiny-engineer.local/anim?name=welcome"
curl -X POST "http://tiny-engineer.local/anim?name=wakeup"
Expand All @@ -496,6 +497,7 @@ Durations and quoted lines below are the stock clips in [`assets/`](../assets/).
| `typing` | **Continuous.** Alternating hands with randomness (15° band from hand limits). Head nods slowly on lowest 10° of head limits. Body sways ±5° around mid; neck counters opposite so head stays put. Runs until replaced, or until `continuous_timeout` triggers `attention`. Same-name re-`POST` refreshes that timeout without restarting motion. |
| `reading` | **Continuous.** Hands/body park as in `none`. Head nods on same lowest 10° band as typing, but slower. Neck sweeps ±10° around mid; right (angle down) slower than left (angle up). Occasional right-hand down-arrow bursts (1–3 presses, same 15° band as typing). Runs until replaced, or until `continuous_timeout` triggers `attention`. Same-name re-`POST` refreshes that timeout without restarting motion. |
| `thinking` | **Continuous.** Hands/body park as in `none`. Head (pitch) and neck (yaw) ease from the current pose into thinking poses (up + slight left/right). Move → pause → optional micro-adjust (sometimes chained) → pause; nearby pose drift with occasional larger shifts after ~2.2 s, more often over time. Axes stagger start/duration; no periodic sway. Runs until replaced, or until `continuous_timeout` triggers `attention`. Same-name re-`POST` refreshes that timeout without restarting motion. |
| `talking` | **Continuous.** Speaking fidget with no sound of its own, used by [`POST /play`](#post-play) while a clip plays. Hands and body park as in `none` except for an occasional right-hand gesture. The head faces the user just above mid and nods quickly on random beats, then settles; the neck drifts slowly. **Classic eyes:** a little taller than idle, mostly on the user with short glances away. Runs until replaced, or until `continuous_timeout` triggers `attention`. |
| `ring` | **One-shot** service-bell gesture; does not loop. Wind-up (body `min`, neck mid, head mid+10°, both hands `max`) → fast right-hand strike to `min+5°` with head to `min` → plays `bell.wav` once on strike (LittleFS; same `uploadfs` requirement as `/test/audio/bell`) → slower bounce to `min+20°` → return to `none` pose and stop. After completion, `GET /anim` reports `none`. |
| `welcome` | **One-shot** hello gesture synced to `welcome.wav` (~2.7 s). Right hand raises during "Hello, human.", holds through the pause, wiggles during "What are we building today?", then lowers. Head nods to mid+10° and returns. Plays automatically after successful Wi-Fi connect at boot; also via API. Requires `welcome.wav` on LittleFS (same `uploadfs` flow as `bell.wav`). After completion, `GET /anim` reports `none`. |
| `attention` | Friendly input-request gesture synced to `attention.wav` (~3.0 s, "pst... human.... you might want to take a look"). Moves into a calm prompt pose first (centered body/neck, head slightly up, right hand raised partway), waits until all servos stop, then plays audio with light neck/head/hand motion during playback (whisper hold → lean toward user → glance/point on "take a look"). **Classic eyes:** phased blink/look cues during audio. After audio ends (or if audio fails to start), holds a gentle waiting loop for **1 minute** (soft head/neck drifts plus occasional slight right-hand waves), then returns to `none`. Requires `attention.wav` on LittleFS (same `uploadfs` flow as `bell.wav`). |
Expand All @@ -515,7 +517,7 @@ When `eyes_style=kaomoji`, the OLED shows looping faces from the expression libr
| `typing`, `thinking` | Thinking |
| `reading`, `attention` | Curious |
| `ring` | Surprise |
| `welcome` | Happy |
| `welcome`, `talking` | Happy |
| `error` | Angry |
| `abort` | Panic |
| `wakeup` | Sleepy |
Expand All @@ -536,28 +538,62 @@ Wrong params return **400**:
| `error` | When |
| --- | --- |
| `missing name` | Query param `name` absent |
| `unknown animation` | `name` not `none`, `typing`, `reading`, `thinking`, `ring`, `welcome`, `attention`, `error`, `abort`, `dead`, `wakeup`, or `sleep` |
| `unknown animation` | `name` not `none`, `typing`, `reading`, `thinking`, `talking`, `ring`, `welcome`, `attention`, `error`, `abort`, `dead`, `wakeup`, or `sleep` |

### `POST /play`

Streams a WAV to the speaker while one animation runs, then returns to the continuous animation (`typing`, `reading`, `thinking`, `talking`) that was running before the request, otherwise to `none`. The body is played as it arrives, so clips of any length need no storage on the robot. The response comes when the clip ends.

| Part | Value |
| --- | --- |
| Path | `/play` runs `talking`; `/play/{name}` runs `talking`, `typing`, `reading`, `thinking`, or `none`. Animations with their own sound are rejected so two clips never overlap |
| Body | WAV, 16-bit mono PCM at 22050 Hz (the firmware `SAMPLE_RATE`). `Content-Length` is required. Chunks other than `fmt ` and `data` are skipped |
| `Authorization` | `Bearer <token>` when `access_token` is set |

The animation starts with the first audio sample, so a rejected request never moves the robot. While the clip plays the robot keeps its eyes, LED and sleep timer running, and the clip counts as activity.

```bash
curl -X POST http://tiny-engineer.local/play -H 'Content-Type: audio/wav' --data-binary @clip.wav
curl -X POST http://tiny-engineer.local/play/thinking -H 'Content-Type: audio/wav' --data-binary @hmm.wav
```

```json
{ "ok": true, "played_ms": 3000 }
```

The Claude Code CLI wraps this for agents: `tiny-engineer-claude-code play clip.wav [--name thinking]` ([integration.md](integration.md#4-claude-code-dedicated-script)).

Errors arrive before any audio plays:

| Status | `error` | When |
| --- | --- | --- |
| `400` | `name must be talking, typing, reading, thinking or none` | `{name}` is unknown or has its own sound |
| `415` | `expected 16-bit mono PCM at 22050 Hz` | WAV format or sample rate differs |
| `415` | `not a WAV file`, `no audio data` | Body is not a RIFF/WAVE file, or has no `data` chunk |

## Errors

| Status | Body | When |
| --- | --- | --- |
| `400` | `{"ok":false,"error":"..."}` | Bad `/test/servo`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, or `/anim` params (see tables above) |
| `400` | `{"ok":false,"error":"..."}` | Bad `/test/servo`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, `/anim`, or `/play` params (see tables above) |
| `401` | `{"ok":false,"error":"unauthorized"}` | Access token configured and `Authorization: Bearer` missing or wrong |
| `404` | `{"ok":false,"error":"not found"}` | Unknown path |
| `405` | `{"ok":false,"error":"method not allowed"}` | Wrong method on a `/test/*`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, `/settings`, `/settings/reset`, `/anim`, or `/auth` path |
| `415` | `{"ok":false,"error":"..."}` | `/play` body is not a supported WAV |
| `405` | `{"ok":false,"error":"method not allowed"}` | Wrong method on a `/test/*`, `/setup/servo`, `/setup/led`, `/setup/audio`, `/setup/oled`, `/settings`, `/settings/reset`, `/anim`, `/play`, or `/auth` path |

Test routes are **POST**. GET/prefetch would move hardware. `/anim` allows **GET** (read) and **POST** (set). `/auth` is **GET** only and always public.

## Behaviour

Test handlers **block** until the test finishes. The client waits. After each test, OLED returns to `ROBOT READY` plus IP (or `WIFI FAIL`).

`/anim` responses return immediately; typing motion runs in the main loop via non-blocking servo updates. Animation switches may defer up to 1s so the active animation holds its minimum duration; only the latest pending request is applied. Continuous animations (`typing`, `reading`, `thinking`) that stay active longer than `continuous_timeout` minutes switch to `attention`, which holds ~1 minute then finishes to `none`. Re-`POST`ing the same continuous animation (e.g. `typing` while already `typing`) does not restart motion, but resets the continuous-timeout clock.
`/anim` responses return immediately; typing motion runs in the main loop via non-blocking servo updates. Animation switches may defer up to 1s so the active animation holds its minimum duration; only the latest pending request is applied. Continuous animations (`typing`, `reading`, `thinking`, `talking`) that stay active longer than `continuous_timeout` minutes switch to `attention`, which holds ~1 minute then finishes to `none`. Re-`POST`ing the same continuous animation (e.g. `typing` while already `typing`) does not restart motion, but resets the continuous-timeout clock.

The onboard RGB LED follows the active animation (see [RGB LED](#rgb-led)).

One request at a time — the Arduino `WebServer` is single-threaded.
`/play` holds the connection until the clip ends.

One request at a time — the Arduino `WebServer` is single-threaded. While `/play` streams, other requests wait.

## RGB LED

Expand Down
1 change: 1 addition & 0 deletions docs/hardware/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ curl -X POST "http://tiny-engineer.local/test/servo?index=0&angle=90"
| `GET` | `/auth` | Auth status (`ok`, `required`, `wifi_configured`, `provisioning`) — always public |
| `GET` | `/health` | Health JSON — full field list in [api.md](../api.md#get-health) |
| `GET` / `POST` | `/anim` | Current animation / start one (`name`, optional `interrupt`) — full params in [api.md](../api.md#post-anim) |
| `POST` | `/play` | Stream a WAV while `talking` (or `/play/{name}`) runs; answers when the clip ends — full params in [api.md](../api.md#post-play) |
| `GET` | `/settings` | Persistent settings (`sleep_timeout`, `hostname`, `volume`, `welcome`, `serial_log`, `continuous_timeout`, `loading`, `eyes_style`, `access_token_set`, `wifi_configured`, `wifi_ssid`, `wifi_password_set`, `servo_mins`, `servo_maxs`, `rgb_order`, `oled_rotate_180`) |
| `POST` | `/settings?...&wifi_ssid=&wifi_password=` | Update NVS settings; WiFi params setup-AP-only and tested before save; `reboot_required` if hostname changed |
| `POST` | `/settings?...&servo_mins=&servo_maxs=` | Servo min/max comma lists; setup-AP-only |
Expand Down
21 changes: 21 additions & 0 deletions docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,27 @@ Inside this firmware repository, [`.claude/settings.json`](../.claude/settings.j

To use it in every project, copy the `hooks` block into `~/.claude/settings.json` and replace `$CLAUDE_PROJECT_DIR/packages/…` with the absolute path to this repo's bin.

### Speaking through the robot

The same CLI has a `play` command an agent can run to speak through the robot. It streams a WAV (16-bit mono PCM at 22050 Hz, from any text-to-speech) to [`POST /play`](api.md#post-play) while the robot runs `talking`, prints the robot's reply when the clip ends, and exits 1 with the reason on any error:

```bash
espeak-ng -w /tmp/raw.wav "Build passed." && ffmpeg -y -loglevel error -i /tmp/raw.wav -ar 22050 -ac 1 -c:a pcm_s16le /tmp/clip.wav
node packages/tiny-engineer-claude-code/bin/tiny-engineer-claude-code.js play /tmp/clip.wav
```

`--name thinking` (or `typing`, `reading`, `none`) runs another animation for the clip.

To let an agent do this on its own, install the shared [`tiny-engineer-play`](../skills/tiny-engineer-play/SKILL.md) skill. It needs only `curl` and works the same in Claude Code, Cursor and Antigravity:

```bash
mkdir -p ~/.claude/skills && ln -s "$PWD/skills/tiny-engineer-play" ~/.claude/skills/ # Claude Code
mkdir -p ~/.cursor/skills && ln -s "$PWD/skills/tiny-engineer-play" ~/.cursor/skills/ # Cursor
mkdir -p ~/.gemini/antigravity/skills && ln -s "$PWD/skills/tiny-engineer-play" ~/.gemini/antigravity/skills/ # Antigravity
```

Then ask the agent something like "have the robot tell me the build passed".

### Smoke test

```bash
Expand Down
52 changes: 52 additions & 0 deletions packages/tiny-engineer-claude-code/src/cli.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import { readFileSync } from "node:fs";

import { DEFAULT_URL, getBaseUrl, getToken, loadDotEnv } from "./env.js";
import { animationForEvent } from "./map.js";
import { parsePlayArgs, postPlay } from "./play.js";
import { postAnim } from "./post.js";

function printHelp() {
console.log(`Usage: tiny-engineer-claude-code [options]
tiny-engineer-claude-code play <clip.wav> [--name <animation>] [--url <base>]

Claude Code hook helper: read event JSON from stdin, pick an animation, POST to the robot.

Expand Down Expand Up @@ -44,7 +48,15 @@ Event map:
Stop → ring
StopFailure → error

Play mode:
Streams a 16-bit mono PCM WAV (at the robot's sample rate) to POST /play and
runs one animation while it plays: talking (default), typing, reading,
thinking or none. The robot then returns to the loop it was in.
Prints the robot's JSON reply when the clip ends; exits 1 on any error.

Examples:
tiny-engineer-claude-code play hello.wav
tiny-engineer-claude-code play hmm.wav --name thinking
echo '{"hook_event_name":"Stop"}' | tiny-engineer-claude-code
echo '{"hook_event_name":"PreToolUse","tool_name":"Read"}' | tiny-engineer-claude-code --url http://192.168.1.10
`);
Expand Down Expand Up @@ -95,12 +107,52 @@ function readStdin() {
});
}

/**
* @param {string[]} argv arguments after `play`
*/
async function runPlay(argv) {
const opts = parsePlayArgs(argv);
if (opts.error) {
console.error(opts.error);
process.exitCode = 1;
return;
}

let wav;
try {
wav = readFileSync(opts.wavPath);
} catch (err) {
console.error(`cannot read ${opts.wavPath}: ${err.message}`);
process.exitCode = 1;
return;
}

try {
const reply = await postPlay({
baseUrl: opts.url ?? getBaseUrl(),
wav,
name: opts.name,
token: getToken(),
});
(reply.ok ? console.log : console.error)(reply.body);
process.exitCode = reply.ok ? 0 : 1;
} catch (err) {
console.error(`robot unreachable: ${err.cause?.message ?? err.message}`);
process.exitCode = 1;
}
}

/**
* @param {string[]} argv
*/
export async function run(argv) {
loadDotEnv();

if (argv[0] === "play") {
await runPlay(argv.slice(1));
return;
}

const opts = parseArgs(argv);
if (opts.error) {
console.error(opts.error);
Expand Down
50 changes: 50 additions & 0 deletions packages/tiny-engineer-claude-code/src/play.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { robotFetch } from "./post.js";

// The robot answers once the clip has finished playing.
const PLAY_TIMEOUT_MS = 10 * 60 * 1000;

/**
* @param {string[]} argv arguments after `play`
* @returns {{ wavPath?: string, name?: string, url?: string, error?: string }}
*/
export function parsePlayArgs(argv) {
/** @type {{ wavPath?: string, name?: string, url?: string }} */
const opts = {};

for (let i = 0; i < argv.length; i++) {
const arg = argv[i];

if (arg === "--name" || arg === "--url") {
const value = argv[++i];
if (!value || value.startsWith("-")) return { error: `${arg} requires a value` };
opts[arg.slice(2)] = value;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
continue;
}
if (arg.startsWith("-") || opts.wavPath) return { error: `Unknown argument: ${arg}` };
opts.wavPath = arg;
}

if (!opts.wavPath) return { error: "play needs a WAV file" };
return opts;
}

/**
* POST a WAV to /play (or /play/<name>). Resolves with the robot's reply.
* @param {{ baseUrl: string, wav: Uint8Array, name?: string, token?: string | null }} request
* @returns {Promise<{ ok: boolean, body: string }>}
*/
export async function postPlay({ baseUrl, wav, name, token }) {
const response = await robotFetch(
baseUrl,
name ? `/play/${encodeURIComponent(name)}` : "/play",
{
method: "POST",
headers: { "Content-Type": "audio/wav" },
body: wav,
signal: AbortSignal.timeout(PLAY_TIMEOUT_MS),
},
token,
);

return { ok: response.ok, body: await response.text() };
}
Loading
Loading