Skip to content

~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.

Deploy (macOS)·

FOMO_PORT=/dev/cu.usbmodemXXXX FOMO_TOKEN=$(openssl rand -hex 24) sh dashboard/deploy/install.sh
launchctl kickstart -k gui/$(id -u)/my.cagatay.fomo.dash        # after git pull
cloudflared tunnel create fomo && cp dashboard/deploy/cloudflared-fomo.yml ~/.cloudflared/fomo.yml