~6 min readlong-form · reference, honestly labelled
Reference — the panel's API and deploy·
The part you look up, not read. fomo dash is FastAPI on 127.0.0.1:8091; it owns the serial port and
hot-attaches the bus within ~2 s of the adapter appearing.
Settings and the installable shell·
Settings (⚙): the panel key, thinker cadence and may_move, the agent's system prompt and model
(/api/agent/config, ~/.fomo/agent.json, applied to the next agent build — reopen the chat), the
tools the connected agent has, install-as-app. Esc closes anything.
Installable: a web manifest + service worker cache the shell, so the panel opens from the home screen without the network and says so plainly ("offline — the arm is not reachable") instead of a blank page. Live data always comes from the server.
Routes·
| method | path | auth | body / returns |
|---|---|---|---|
| GET | /api/health |
– | ok, version, arm.ok/error, camera sources |
| GET | /api/state |
– | source (bus/sim/none), t, age_s, joints, names, folded, busy, home, windows, calibrated, gate, poses, torque{id: bool}, voltage, nicla |
| WS | /ws |
– | state at 5 Hz |
| GET | /api/auth/me |
– | {required, ok, via} |
| POST | /api/control/move |
✓ | {"actions": [{joint, to}|{joint, by}|{joint, rel}|{look:{pan,tilt}}|{home}|{read}|{stop}|{torque}], speed?} → {ok, legs, s, reached}; {ok:false, stalled, error, folded_after_stall?}; {ok:false, stopped}; 422 with the rule; 409 busy; 503 no arm |
| POST | /api/control/pose |
✓ | {"name", "nudge"} |
| GET | /api/motions |
– | {name: {description, frames:[{rel, speed, hold_s}], stay, builtin, ok, why}} |
| POST | /api/control/motion |
✓ | {name, speed?, stay?} → {ok, frames:[…], frames_total, stopped, stay, home?, error?, s}; 422 unknown/unplayable; 409 busy |
| POST / DELETE | /api/control/motions, /api/control/motions/{name} |
✓ | save / delete a recorded motion |
| POST | /api/control/stop |
✓ | torque held unless folded |
| POST | /api/control/torque |
✓ | {"on": bool}; 422 rule 7 when upright |
| POST | /api/control/calibrate |
✓ | {"mode": "manual", "hold_seconds", "extremes", "write_eeprom"} |
| GET / POST / DELETE | /api/poses, /api/control/poses, /api/control/poses/{name} |
✓ on write | |
| GET | /api/servos |
– | raw servo registers (torque limit, temperature, load) for the joints |
| WS | /ws/agent |
✓ | {prompt} in → text{delta} · tool{id,name,input} · tool_result{id,status,text,photo} · done · error |
| GET / POST | /api/agent/thinker |
✓ | {enabled, every_s, may_move} + running, runs_last_hour, budget_per_hour |
| GET | /api/agent/thinker/log |
✓ | the last lines it said |
| WS | /ws/thinker |
✓ | thinker{…config, running, log} on connect, then thinking{text, photo, t}; send {"talking": true} to hold it |
| GET / POST | /api/agent/config |
✓ | {prompt, model} + default_prompt; applies to the next agent build |
| GET | /api/nicla |
– | the eyes: ok, ip, fw, rssi, mem_free, imu{ax..gz, roll, pitch}, tof_mm, stream{clients, open, fps} |
| GET | /api/nicla/stream |
– | MJPEG (boundary=fomoframe) — ONE connection to the board, fanned out to every viewer |
| GET | /api/nicla/snapshot.jpg |
– | one frame off that shared stream |
| GET | /api/camera/snapshot.jpg |
– | one frame; X-Fomo-Source header (inside the dash, lan uses the shared stream) |
| GET | /api/photos, /photos/{file} |
– | the archive |
The agent behind the panel·
/ws/agent is one Fomo per connection — the same persona and tools as fomo agent (move, photo,
pose, motion, calibrate, state). The tools run dash-first against this very process, so the guard
applies exactly as from the CLI; the agent runs in a worker thread because awaiting it on the server's
loop would deadlock the HTTP calls the tools make. Model credentials come from the launchd env
(AWS_BEARER_TOKEN_BEDROCK or AWS_PROFILE/AWS_REGION, optional FOMO_MODEL) — install.sh
copies them from your shell; without them the bar says "agent unavailable" instead of pretending.
Cross-origin reads·
GET routes carry Access-Control-Allow-Origin for https://cagataycali.github.io (the docs site's
"Fomo right now" widget), no credentials, GET only — a page on another origin can read /api/state and
/api/nicla, never POST a move. FOMO_CORS_ORIGINS=a,b overrides the list.
Twin — the arm in 3D·
/models/arm.glb + /models/arm.json (docs/models, the SO-101 leader with Fomo's head) posed from the
live servos at 5 Hz. The map is state.urdf: q = sign × wrap(deg − 180) — URDF zero is the servo
mid-range for every joint, not the folded home; that is why a folded arm renders folded and a fresh
home is only a marker. Signs +1 on 1–5, −1 on tilt. Read-only: orbit with the mouse, the guard owns motion.
Eyes — the head in the panel·
▶ live puts <img src=/api/nicla/stream> on the face — the fan-out opens the board's single upstream
stream while anyone watches and closes it a few seconds after the last viewer leaves; the header shows
fps and watchers. The /ws tick carries the full Nicla state at 5 Hz with a server-side age_s, so
"live / N s ago" is the board's freshness, not clock skew. With FOMO_NICLA_MOUNTED=0 the board is on
the bench: the twin ignores its attitude and the pill says so.
Flashing the eyes·
The head is an Arduino Nicla Vision running Fomo's own node (firmware/nicla/) — no IDE. A boxed board ships OpenMV 4.3
(no ml module); the node needs OpenMV ≥ 5.0. As flashed on this bench, 2026-09-08:
# double-tap RESET → bootloader 2341:035f, then from the OpenMV Nicla release folder:
dfu-util -a 0 -d 2341:035f -s 0x08040000:leave -D firmware.bin # the app
dfu-util -a 1 -d 2341:035f -s 0x90B00000 -D romfs0.img # the FOMO model bundle
dfu-util -a 1 -d 2341:035f -s 0x90F00000 -D <CYW4343 wifi blob> # without these two the radio
dfu-util -a 1 -d 2341:035f -s 0x90FC0000 -D <CYW4343 bt blob> # never starts: "could not find valid firmware"
Then copy firmware/nicla/main.py, fomo_node.py and a filled-in secrets.py (from
secrets.example.py) onto the board's USB drive and reset. The serial console prints
fomo_node on http://<ip>:8080 — that is FOMO_NICLA_URL. Any byte on USB in the first 3 s holds the
REPL instead. The node reboots itself when the radio wedges or the network is gone for 30 s. While the board
sits on the bench rather than the head, FOMO_NICLA_MOUNTED=0 tells the panel — its horizon goes dashed
and move --read says on the bench instead of pretending the bench's tilt is mine.
Eyes — the Nicla over the LAN·
FOMO_NICLA_URL=http://<nicla>:8080 points at Fomo's own node (firmware/nicla/). The dash polls
/senses at 2 Hz (the board's IMU rate) and derives roll/pitch from the accelerometer. One missed poll
keeps the last reading (its age grows); the second flips the pill to offline. The board serves
one /stream client, so the dash opens it once, on the first viewer, and closes it 3 s after the last
one leaves — photos taken inside the dash come off that same connection.
Sim mode — the driveable twin·
FOMO_SIM=1 (and no FOMO_PORT) boots the dash on six simulated servos parked at the calibrated
home from ~/.fomo/calib.json. Goals are reached over time at the guard's ramp (30 °/s), so every
control route works and the twin animates exactly as the metal would; state.source says sim.
Handover to the real arm = set FOMO_PORT=/dev/cu.usbmodem…, unset FOMO_SIM, kickstart.
FOMO_SIM_INSTANT=1 (or FOMO_FAKE=1, what the tests use) lands goals instantly.
Auth·
Three keys open the gate, in order: FOMO_TOKEN bearer; the owner's tiny.technology session
bearer (verified upstream, cached 5 min); loopback with no token configured (bench only). Fails
closed: any mutating route without a key is 401 from anywhere but 127.0.0.1.