# Start here: a Brodie coach on LiveKit What the starter coach is, how to set it up, your first call on the phone page, and where to take it beyond reps. You are a team with a LiveKit Cloud project of your own (cloud.livekit.io, the free tier is enough), a laptop running macOS or Linux (no Windows), Python 3.12 with uv for the coach, and a phone (or a Mac with Safari) to stand in front of. The caller joins a room of your project through our static phone page and your worker joins the same room (section 3): that is the main path for every team. Three teams also have one of the three Chert FaceTime lines; for them the same worker answers a real FaceTime call (section 5, with the Chert CLI facts from docs.trychert.com/facetime-cli). This page is the whole read-me; the event schema is in [/hackathon/events](/hackathon/events) and what you can build is in [/hackathon/build](/hackathon/build). ## 1. What you are getting, and what you change A working real-time video coach in one Python process, one LiveKit participant, one audio track. It runs the same whether the caller arrives through the phone page in a room of your own LiveKit project or through a Chert line (a Chert room has exactly two seats, the caller's bridge and your agent, so there everything you add runs inside this process; in your own room there is no such limit, but keep one audio publisher). Its eyes are `facetime/perception.py`: MediaPipe pose on every sampled frame at 10 fps, a rep counter and a fault detector, turned into a versioned stream of events (`rep`, `exercise_seen`, `fault`, `cue`, `visibility`, `gesture`, `probe`, `session`, `status`, and `voice` for the model's turns with `--voice gemini`). Its fast mouth is the pre-rendered Brodie cue bank (`data/cues_audio_gemini`, 379 clips, Gemini TTS, voice Puck; an Australian-direction rendering of the same strings, `data/cues_audio_gemini_au`, is optional via `CUE_AUDIO_DIR`): counts, corrections, praise and announcements are heard about 0.2 s after the decision (145 to 225 ms measured over the LiveKit SFU). Its conversation is Gemini Live in the same Puck voice, gated to the gaps between sets (`--voice gemini`, `facetime/voice.py`, section 2c of [/hackathon/events](/hackathon/events)); the raw event stream is also there for your own layer (`@facetime.worker.consumer`, section 2 of that page). You change three files in `starter/` (you write coaching, not perception): - `starter/persona.md`: the system prompt of the conversational voice (who Brodie is, how it talks between sets). - `starter/plan.txt`: the session script, in `--plan` syntax: `squats:10,pushups:8,jumping jacks:20,lunges:10` (27 names are accepted; these four count and are validated on a device, nine more count untested, the rest run as timed correction blocks: [/hackathon/build](/hackathon/build) section 1). - `starter/handlers.py` (loaded by default whenever the file exists, with or without `--voice gemini`; `--handlers none` turns it off): `def on_event(event, say_now, ask_voice)`, called once per event with the event dict (every event carries `v`, `t`, `t_wall`, `seq`, `type` and, except `status`, `frame`). Two verbs with two latencies: `say_now(text)` plays a bank line through the fast path's priority slots, heard about 0.2 s later, only strings in the bank (`data/cues_audio_gemini/manifest.json`, keyed on the exact string); `ask_voice(prompt)` hands the LLM a prompt and is heard 1 to 2 s later; the shipped handler asks for one sentence at each `rest` and a warm close at `done`. Call `ask_voice` only when the session opens a slot: `session.state` is `rest` or `done`, a `gesture` `tpose`, or a caller's question (a visibility pause never opens the floor; a line asked during the `wait` before a set is cut by its first count). Never during `active`: the fast path owns the set, the first count of a set cuts any reply still playing, an ask queued during a set is dropped at the next set start or after 15 s, and `cue` events are the transcript of what the fast path already said, so do not repeat them. The same events are read raw with `@facetime.worker.consumer async def fn(job): ev = await job.events.get()`. ## 2. Setup Ten minutes of typing, 25 to 45 minutes of waiting. ``` git clone https://github.com/seraphimlabs-dev/brodie-starter.git brodie-rt-coach && cd brodie-rt-coach git checkout -b team- # work on a branch of your own uv venv --python 3.12 .venv && uv pip install -r requirements.txt mkdir -p models && for m in lite full heavy; do [ -f models/pose_landmarker_$m.task ] || curl -sSfL -o models/pose_landmarker_$m.task https://storage.googleapis.com/mediapipe-models/pose_landmarker/pose_landmarker_$m/float16/latest/pose_landmarker_$m.task; done ``` `uv pip install` is the long step: torch, mediapipe, transformers, modal and livekit (on Linux torch pulls about 3 GB of CUDA wheels; run `uv pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu` first to skip them), then the tarballs and the pose models (about 120 MB); the three Chert teams also install Node and the Chert CLI and run the `chert login` browser flow (section 5). No `uv` yet: `pip install uv` or `brew install uv`. You do not need Modal or any cloud of ours: the worker runs on your laptop (a Mac processed 10.7 fps with pose at 10 ms), the `modal` package in requirements.txt is a dependency of our demo path and stays unused, and nothing here calls our deployment. The two shared conveniences are the phone page (section 3), whose link `mint_token --url` prints is served from our host but is a static page with the url and token of YOUR LiveKit project in it (opening `facetime/phone_test.html` as a file in Safari on a Mac works the same), and the Gemini relay (section 4), which stands in for a Google account. The data folder is never in git. The starter bundle is three tarballs (`rtcoach_starter_part1_models_recordings`, `part2_bank_au`, `part3_bank_puck`, dated 2026-10-09); unpack part 1 and part 3 at the repo root (part 2 is optional) and everything lands under `data/`: `data/models/clf_pose.pt` and `trigger_lr.json`, the voice (`data/cues_audio_gemini/`, plain Puck, 379 clips, part 3, the bank the demo uses), the optional Australian-direction bank (`data/cues_audio_gemini_au/`, 378 clips, part 2; select it with `CUE_AUDIO_DIR=data/cues_audio_gemini_au`), the sample recordings (section 6) and `data/STARTER_BUNDLE_README.txt`. The player prefers the plain Puck bank; use it for both the bank and the conversation, since mixing voices sounds like two speakers. The thresholds, `cues.json` and `cue_prior.json` are in `src/rtcoach/` in git. Check: ``` ls data/models/clf_pose.pt data/models/trigger_lr.json data/cues_audio_gemini/manifest.json models/pose_landmarker_full.task # plus data/cues_audio_gemini_au/manifest.json if you unpacked part 2 ``` Before the first call, prove the install on a bundle recording (about 60 s; expect squats 9 of 10 on the 75 s timeout and the first pushup at 137.8 s), then the contract test (PASS in about 30 s) and the offline voice tests (83 checks, no key needed): ``` python -m facetime.replay_local --video data/tmp/mac_handoff/crops/run3.mp4 --plan "squats:10,pushups:8,jumping jacks:20,lunges:10" --fast --events data/tmp/run3.events.jsonl python scripts/test_perception.py --video data/tmp/mac_handoff/segments/run3_squats.mp4 --plan squats:10 python scripts/test_voice.py ``` Then read [/hackathon/build](/hackathon/build): what you can build on this and how. Create `.env.local` in the repo folder (never commit it). The three LiveKit values come from one LiveKit Cloud project of your own: create it at cloud.livekit.io (the free tier is enough), then Settings -> Keys gives the URL (`wss://.livekit.cloud`), the key and the secret: ``` LIVEKIT_URL=wss://.livekit.cloud LIVEKIT_API_KEY= LIVEKIT_API_SECRET= AGENT_NAME= GEMINI_API_KEY= GEMINI_BASE_URL=https://james-72765--rtcoach-gemini.modal.run ``` `AGENT_NAME=` is set EMPTY on purpose: an empty name is automatic dispatch, the worker joins every new room of your project, which is what the phone page needs (section 3). A named worker (the default `brodie-coach` when the variable is missing) waits for a named dispatch and never joins a phone room; only the three Chert teams set `AGENT_NAME=`, and only for the line (section 5). The worker itself needs nothing but these three LiveKit values and the bundle on disk; `GEMINI_API_KEY` and `GEMINI_BASE_URL` are for `--voice gemini` and for re-rendering the bank, through our relay (section 4). You re-render the bank with `python -m facetime.cues_tts --backend gemini` only if you add strings to `cues.json` (plain Puck into `data/cues_audio_gemini`, only the strings still missing; about 4 minutes and $0.16 for the whole bank). For the optional Australian bank add `--style "(Australian accent, warm, upbeat trainer) " --out-dir data/cues_audio_gemini_au` (the bracketed direction is not read aloud, a spoken prefix would be). ## 3. First call on the phone page (LiveKit) The phone (or a Mac in Safari) joins a room of your LiveKit project through `facetime/phone_test.html`, publishes its camera and plays the coach; your worker joins the same room by automatic dispatch. Nothing loads `.env.local` for you (the LiveKit Agents CLI does not read it, and `mint_token` needs the key and secret exported), so export it in both terminals. Terminal A, the coach: ``` cd brodie-rt-coach && source .venv/bin/activate && set -a && source .env.local && set +a python -m facetime.worker dev # the plan in starter/plan.txt (ships as squats:10,pushups:8,jumping jacks:20,lunges:10) python -m facetime.worker --plan "squats:10,pushups:8" dev # your plan python -m facetime.worker --voice gemini dev # plus Gemini Live (Puck): talks only between sets, needs GEMINI_API_KEY and GEMINI_BASE_URL (section 4) ``` `AGENT_NAME=` empty in `.env.local` (or `--agent-name ""` before `dev`) is what makes the worker join your room; with a dispatch name set it registers and then waits forever. Terminal B, the link (same folder, same exports; a fresh room name every attempt): ``` cd brodie-rt-coach && source .venv/bin/activate && set -a && source .env.local && set +a python -m facetime.mint_token --room team1-try1 --url ``` It prints THREE lines: the token, the query string, and the https link on our host, `https://james-72765--rtcoach-webhook.modal.run/phone?url=...&token=...&room=...`. The THIRD line is what the phone opens: AirDrop it, send it in Messages, or make a QR code of it. Add `&facing=user` for the front camera (the default is the back camera) and `&autojoin=1` to join without a tap; `&agent=` changes which participant the page calls the coach (below). The page is static: our host only serves the HTML, and the `url` and `token` in the link are your own LiveKit project (the `room` in the link is informational, the token carries the room). On a Mac, open `facetime/phone_test.html` as a file in Safari with the same query string: it works the same, because `file://` is a secure context for the camera, which is also why the phone needs the https link and not http from your laptop. The page loads `livekit-client` from jsdelivr, publishes one camera track named `facetime-remote-video` asking for 1280x720 at 30 fps (a phone held in portrait delivers 720x1280; the worker samples at 10 fps), plays every audio track it subscribes to, and shows "Tap to enable audio" when the browser blocks autoplay: tap it. Order matters: start Terminal A before the phone joins, and use a new room name per attempt. Automatic dispatch fires when a room is CREATED (the phone's join creates it), so a room the phone opened while the worker was down, or a room reused from an earlier attempt, never gets a job. The job ends 2 s after the phone leaves (`--leave-grace-s -1` keeps it; a page reload within 2 s binds again). Prop the phone (section 7) and step in. What it prints when it works. Terminal A: `[worker] agent ''; mode plan (...); audio cue player; data channel off; events -> data/tmp/worker_events.jsonl`, `[worker] prewarm: loaded in 5.x s (pose full, counter v2, trigger data/models/trigger_lr.json, ...)`, the LiveKit line `registered worker` (the notices `dev mode is deprecated ... use lk agent dev` and `in-process auto-reload has been removed` are livekit-agents 1.8.5 talking; ignore them); when the phone joins `[worker] job ...: joined room 'team1-try1'`, `[worker] published audio track coach-audio`, `[worker] participant joined: chert-bridge`, `[worker] bound to 'chert-bridge' (call id n/a): video 'facetime-remote-video' 720x1280`, `[worker] first frame 720x1280`, `[cue] HH:MM:SS [announce] "Let's do squats"`, then `[event] rep squats 1 (agree 1.0, ...)` and `[cue] ... [count] 'One' decision->push N ms` per rep, and a `[status]` line every 5 s (expect processed about 10 fps, pose about 20 ms, underruns 0). On the phone: "Let's do squats" from the speaker, then the counts. The page's "Coach present" indicator is green ONLY for a participant whose identity equals `agent` exactly (default `brodie-agent`). The worker under `dev` joins as `agent-`, so the indicator stays "no" on a working call; the real signal is the page's "Coach audio: playing from agent-..." line, and the audio itself. To make it green, join one room directly under that identity instead of waiting for a dispatch: `python -m facetime.worker connect --room team1-try1 --participant-identity brodie-agent` (`connect` is a LiveKit Agents CLI subcommand). On this path the caller's participant is plain `chert-bridge` and there is no call id: `call_id` is null in your logs, and your hand-in is keyed on the room name and the time of the call (section 10). In your own room there is no two-seat limit (a second phone or a dashboard participant can join; the worker binds to the first non-agent participant that publishes video and ignores other video), but keep one audio publisher. `--voice gemini` on a call: on a FaceTime call over the Chert CLI the model session started 0.9 s after the worker bound to the caller, the caller's words were transcribed but not logged, and the worker ended 252 ms after the hang-up. The first count of a set cuts the voice, and the persona keeps it in English. It waits for a clip to end instead of playing under it, the caller's audio reaches the model only when no Brodie audio has played for 0.3 s, the rest clock pauses while either side is talking (up to 25 s), and only one Brodie line plays at a time (an ask waits while any Brodie audio plays); prompts are never read aloud. These rules pass 61 offline and 63 live checks but have not been verified on a device yet, so listen for overlapping voices on your first call. ## 13. Your own coach: beyond reps The worker has two modes. **Reps (as shipped).** `facetime/perception.py` watches the caller at 10 fps (pose, rep counter, fault detector, the session script from `starter/plan.txt`), the fast path speaks counts and corrections from the bank about 0.2 s after the decision, and the Gemini voice is gated to the gaps between sets. Everything in sections 1 to 12 is about this mode. **Voice plus vision, perception off.** `--perception off` runs no pose model, no counter and no script: the worker still joins, publishes its audio track, binds to the first caller that publishes video (or audio, when there is no video), starts the Gemini Live session on that bind, and from then on the model is always allowed to talk (`--floor open`, the default here): the caller's audio is always forwarded, `ask_voice` is spoken at once, nothing is ever cut. `--vision` hands the caller's video to the model at about 1 fps (`--vision-fps`, at most 2): each frame goes out as a JPEG scaled by the plugin to fit 1024x1024 (a phone-page 720x1280 frame as 576x1024, a FaceTime 1280x720 frame as 1024x576), which is what the Live API wants for a scene that changes slowly, like a kitchen. The plan file is appended to the persona as free text (a recipe, a checklist), not parsed as exercises. Your `handlers.py` sees ONE synthetic `session` event, `{state: "open", exercise: null, count: 0, mode: "voice"}`, when the model session is up (greet there), a `status` event every 5 s (frames received, frames sent to the model, no pose fields) and the `voice` events; no `rep`, `cue`, `fault` or `visibility` events. The meal-prep example in `starter/examples/meal_prep/` (a persona that names what it sees before it instructs, one step at a time, safety first with knives and heat; a 15-minute garlic butter pasta with greens as numbered, timed steps; a handler that greets on the session open event and a `later(seconds, prompt, ask_voice)` helper for timed nudges on the worker's event loop): ``` .venv/bin/python -m facetime.worker --perception off --vision --voice gemini --persona starter/examples/meal_prep/persona.md --plan-file starter/examples/meal_prep/recipe.md --handlers starter/examples/meal_prep/handlers.py dev ``` Tell the caller: prop the phone so the camera sees the counter and the stove (landscape, about an arm's length away, not pointed at the ceiling), keep your hands in view when you cut or stir, and say "ready" or hold the board up to the camera when you want the next step. The coach asks you to show the pan or the board when it is unsure. What to expect. The first audio arrives about 1 s after the model decides to speak (measured 0.9 to 1.3 s to first audio on a text turn with `gemini-3.8-live`). Video goes at 1 fps, so the model sees a step change (the pasta going in, the garlic colouring) within a second or two; it only sees what the phone camera sees, so framing is everything. `[vision] forwarding video to the model at 1 fps (1280x720)` is logged once on the first forwarded frame and the `[status]` line carries the count of frames sent. The greeting comes from your handler on the session open event, about 1 to 2 s after the caller is bound. Limits. There is no fast path for cooking: every word goes through the model (about 1 s), the bank `say_now` still plays the strings that are in the bank (`data/cues_audio_gemini/manifest.json`: counts, "Nice work", "Rest for a moment" and the like), and the arms-up probe and the T-pose gesture do nothing in this mode (no pose model runs). The model cannot read a thermometer or taste; it works from what it sees at one frame per second and what the caller says. This mode has been tested offline only (fake room, fake frames, a no-room model session with one pushed frame); it has not been on a call, phone page or FaceTime. Expect to tune the persona on the first call: if the model narrates too much, tell it so in `persona.md`. # What you can build on Brodie, and how The skills a team can add to Brodie in a day, with the commands and the evidence loop for each one. Read this after section 2 of [/hackathon/start](/hackathon/start) (the setup). Every command below runs from the repo root with the virtualenv activated (`source .venv/bin/activate`, so `python` is `.venv/bin/python`) and the starter bundle unpacked under `data/`. Nothing here needs a call until it says "on a call": a call is the phone page in a room of your own LiveKit project (README section 3; a fresh room name per attempt, nothing to wait for) or, for the three teams that have one, the Chert FaceTime line (README section 5; Offline for a few minutes after every call). Calls are for checking, recordings are the inner loop. The event schema is [/hackathon/events](/hackathon/events). ## Brodie in five sentences 1. **Eyes.** `facetime/perception.py` runs MediaPipe pose on the caller's video at 10 fps, a rep counter (`src/rtcoach/repcount2.py`) and a fault classifier, and turns them into a stream of events: `rep`, `fault`, `cue`, `visibility`, `gesture`, `probe`, `exercise_seen`, `session`, `status` (plus `voice` for the model's turns). Your code reads events; it never touches frames. 2. **Fast mouth.** The bank: 377 pre-rendered strings (379 clips, Gemini TTS, voice Puck, `data/cues_audio_gemini/manifest.json`) pushed into the one audio track by the cue player about 0.2 s after a decision (145 to 225 ms measured). Counts, corrections, praise, announcements and your `say_now(text)` lines all come from the bank, and only strings in the bank can be said. 3. **Conversation.** Gemini Live (same Puck voice, `facetime/voice.py`) in the same process, mixed into the same track, allowed to speak only when the session opens a slot (the rest after a set, the wait before one, the end, a gesture, a caller's question during a rest). About 1 s from a prompt to the first audio, so it never counts. 4. **The session script.** `starter/plan.txt` (or `--plan`) is a list `name:count,...`: for each item the coach announces it, waits for movement (up to 3 s), counts to the target with corrections, says "That's ten, nice work", rests 5 s, moves on, and ends with "All done, great session"; a set that has not reached its target after 75 s is closed with the count reached (`--set-timeout`). 5. **One participant, one audio track.** Everything you build runs inside `python -m facetime.worker` (`facetime/worker.py`): on a Chert line the room has exactly two seats, the caller's bridge and your worker, so there is no second process on that call and no third seat; in your own LiveKit room there is no such limit, but keep one audio publisher. ## Twenty minutes before any call, before anything else Prove the install with the bundle, then build against recordings. Times below are on the bundle files, on 4 CPU cores: ``` python -m facetime.replay_local --video data/tmp/mac_handoff/crops/run3.mp4 --plan "squats:10,pushups:8,jumping jacks:20,lunges:10" --fast --events data/tmp/run3.events.jsonl ``` About 60 s. It prints every cue with its clip time (`t=11.90s [count] 'One'`) and writes 263 events. Expect squats 9 of 10 (the set closes on the 75 s timeout), the first pushup at 137.8 s. The shipped `data/tmp/mac_handoff/crops/run3.events.jsonl` was written by an earlier policy version: it has 27 cue events where the current code writes 18 (the nine that went are a standing "Sink lower" before the first count and idle praise during the uncounted pushup set). Reps and session states are identical. Regenerate the logs rather than diffing against the shipped ones. ``` python scripts/test_perception.py --video data/tmp/mac_handoff/segments/run3_squats.mp4 --plan squats:10 # 32 s, prints PASS python scripts/test_voice.py # 31 s, 83/83 offline, no key needed ``` (`test_perception.py` without `--video` looks for `data/tmp/squats_clip.mp4`, which is not in the bundle.) Hear the coach on a recording, no call: `--audio-out` mixes the bank clips at their cue times into a wav. ``` python -m facetime.replay_local --video data/tmp/mac_handoff/segments/run3_squats.mp4 --plan "squats:10" --fast --audio-out data/tmp/squats.wav ``` **Your own phone video must be 10 fps first.** The replay treats every file as 10 fps and loads every frame into RAM (a raw 30 fps 1080p clip plays as 3x slow motion, counts land late, sets time out, and two minutes of it is 20 GB of frames). Convert once; the pipeline resizes to short side 288 itself, this just keeps the file small and the clock right: ``` ffmpeg -i IMG_1234.mov -vf "fps=10,scale='if(gt(iw,ih),-2,288)':'if(gt(iw,ih),288,-2)'" -an -c:v libx264 -preset fast -crf 23 data/tmp/mine.mp4 python -m facetime.replay_local --video data/tmp/mine.mp4 --plan "squats:10" --fast --events data/tmp/mine.events.jsonl ``` (A 30 fps 1080 copy of `run3_squats` replayed as 111.9 s and timed out at seven; after the one-liner it gave the same nine reps at the same clip times as the original.) Film in portrait, phone propped at knee height 2 to 3 m away, whole body in frame, stand still a second before the set (README section 7). ## Decision table | I want to ... | change this file / use this flag | latency class | |---|---|---| | run a different workout | `starter/plan.txt` or `--plan "name:count,..."` (names in section 1) | fast path, 0.2 s | | change what a correction says | the `cue` strings in `src/rtcoach/cues.json`, then `python -m facetime.cues_tts --backend gemini` | fast path | | say something new at a moment of my choosing | a new entry in `cues.json` (any key) + re-render, then `say_now(text)` in `starter/handlers.py` | fast path | | speak at halfway, after a correction streak, at the end | `starter/handlers.py` `on_event` with `say_now` (bank) or `ask_voice` (model) | 0.2 s / 1 s | | change how Brodie talks between sets | `starter/persona.md`, `ask_voice(prompt)` in handlers, `--voice gemini` | model, 1 s | | let the caller skip ahead with a gesture | `--gesture-next` (T-pose 1.5 s), a `gesture` event, 4 s of open floor | perception event | | drop the plan and let the coach guess | `--auto` (`--auto-classes all` for every class) | perception, 3 s to declare | | develop without calling | `python -m facetime.replay_local --events ...` on a crop, then the harness in section 3 | offline | | coach something that is not reps (a recipe, a checklist) | `--perception off --vision --voice gemini --persona ... --plan-file ...` (section 8) | model, 1 s; video 1 fps | | a different voice or accent for the bank | `cues_tts --voice NAME` / `--style "(direction) "` into `--out-dir`, then `CUE_AUDIO_DIR=...` | fast path | | a longer rest, shorter sets, fewer corrections | `--rest-s`, `--set-timeout`, `--tempo`, `--params '{"gap": 6}'`, `--no-announce` | no code | | count one exercise with no target | `--exercise squats` (single mode) | fast path | | a live dashboard of events | `--publish-data` in a LiveKit room you own (not a Chert room) + your own listener | events | | a laptop-only demo with no phone | `python -m facetime.webcam_local --camera 0 --plan ... --show` (no voice, no handlers) | fast path | | hand in your call logs | leave `data/tmp/worker_events.jsonl` and `worker_cues.jsonl` on (default) | - | ## The catalogue Each entry: what it is, the extension point, the command, the latency class, a first version you can have in 30 minutes, and the limits as measured, not as hoped. ### 1. Your own session plan **What.** A different list of exercises and targets, in `starter/plan.txt` (read whenever `--plan` and `PLAN` are absent) or on the command line. Syntax `name:count,name:count`; a typo exits at start with the list of accepted names. **Names the plan accepts** (27; `facetime/pipeline.py accepted_names()`), and what the counter does with each: - Counted, validated on a device (the four demo exercises; masks and gates in `src/rtcoach/repcount2.py`): `squats` (vote over hip height, knee angle, knee width, shoulder height, shoulder to hip; upright gate; settle rule; walk veto), `pushups` (vote over elbow, elbow width, shoulder height and width, wrist height; prone gate), `lunges` or `walking lunges` (upright gate, settle rule, the first count must come from a leg or torso tracker; one count per leg), `jumping jacks` (full five-signal vote, no gate). - Counted, QEVD-trained only, never on a device: `squat jumps`, `squat kicks`, `boxing squat punches`, `lunge jumps`, `good mornings`, `good morning beginner`, `plank taps`, `puddle jumps`, `floor touches`, `toe touchers`. These use the full top-5 vote with no body-state gate, so any repetitive movement counts (`squat jumps:5` on the jumping-jacks segment counts five). - Accepted but not counted (corrections only; the set runs to `--set-timeout` and closes with "Nice work"): `high knees`, `butt kickers`, `quick feet`, `mountain climbers`, `moving plank`, `air jump rope`, `shoulder gators`, `standing kicks`, `quad stretch left`, `quad stretch right`, `armcrosschest left`, `armcrosschest right` (the four stretches get the side cues). There is no `name:30s` syntax; a timed block is `high knees:1 --set-timeout 30` and the timeout applies to every item. **Corrections the bank has for the demo four** (`cues.json`, 556 QEVD fault labels over 50 exercises, each mapped to a short string): squats "Sink lower", "Squat deeper", "Hips back", "Keep back straight"; pushups "Go lower", "Hips down", "Keep going", "Keep knees up", "Stay in plank"; jumping jacks "Use your arms", "Bring arms up", "Reach arms higher", "Jump feet wider", "Step feet wider", "Add the jump", "Move your legs", "Sync arms and legs", "Keep movements sharp", "Pick up the pace"; lunges "Lunge lower", "Sink deeper", "Step deeper", "Keep chest up", "Bend back knee", "Alternate your legs", "Pick up pace"; plus the tempo cues "Slow down" and "Pick up the pace" for all four. **Command.** ``` python -m facetime.replay_local --video data/tmp/mac_handoff/crops/run2.mp4 --plan "pushups:8,jumping jacks:20,lunges:10" --fast --events data/tmp/plan.events.jsonl python -m facetime.worker --plan "pushups:8,jumping jacks:20,lunges:10" dev # on a call (README section 3) ``` **Latency.** Fast path throughout (counts 0.81 s p50 after the rep bottom on the phone page; announces and milestones 0.2 s). **30 minutes.** Write the plan, replay it on `run2.mp4` or `live.mp4`, read the `session` events to see each set open, close and rest, and set `--set-timeout 45` if a set you expect to be short keeps running to 75 s (pushups did on a test call). **Limits.** Shallow reps are undercounted (exact 0.87 on MM-Fit, 0.59 against coaches' spoken numbers on home sessions); pushups from a propped front camera can read +2; jacks +1 on hands to the head; a squat within a second of walking in is lost; the first count needs 0.8 s of standing still; lunges count one per leg; the non-demo countable names have no device numbers at all. ### 2. New cue wording and new bank strings **What.** Change what a correction says, or add a string of your own that `say_now` can play. The bank is keyed on the exact string: nothing is synthesised at run time. **Extension point.** `src/rtcoach/cues.json` (`{" -