Skip to content

Drive step 04 / 05 ~7 min read

at the endThe dashboard on localhost:8090: live arm, camera, senses, chat agent, behind a token, and you know how each piece reaches the hardware.

Dashboard

One page that shows the arm, what it sees and what it senses, with a chat agent that drives it through the same guard as every other verb.

Why this page exists: the dashboard is a FastAPI server, a React page and two hardware readers, and the ways they can silently fall back (LAN to USB, WebSocket to polling to mock) are where a newcomer gets confused. This page names each path and what the header shows when it is taken.

strands-arm dashboard

pip install -e ".[dashboard]"
cd dashboard/frontend && npm i && npm run build && cd -
ARM_PORT=/dev/cu.usbmodem5AB01818061 NICLA_PORT=/dev/cu.usbmodem395A357630312 strands-arm dash --port 8090

Open http://localhost:8090. --no-hw runs it without an arm.

The panels

panel what it shows
the arm live the 3D arm mirroring the six servos at 10 Hz; arm.glb for the leader, follower.glb for a follower (robot.json, lib/model.ts). Drag a joint to send a guarded command
joints six rows: angle, temperature, volts, home marker
what the arm sees MJPEG from the Nicla, detection box, ToF distance as a range ring, snapshot and record
controls look pad, torque, shots orbit / dolly / reveal, watch face / person, teach and replay
the head's senses the Nicla board turning with its IMU, accel and gyro sparklines, BLE scan, Wi-Fi, memory, firmware
chat a Strands agent with the arm, head and Nicla tools. Text streams, tool calls show as they run

Header: port dots for the bus and the Nicla (USB or LAN), guard state, the job this dash is running (teleop robot.example · 12 s, a shot, a replay; a job that died stays there with its reason until the next one starts), who is teleoperating a follower, HOME and STOP always visible. STOP ends the job too.

How it is wired

browser  --WS /ws 10 Hz-->  server.py (FastAPI, 127.0.0.1:8090)
                            |-- arm_io.py     10 Hz poller on the Feetech bus, reads EEPROM windows at boot
                            |-- nicla_io.py   LAN-first reader of the head node, USB REPL fallback
                            |-- guard.py      the only writer to a servo
                            |-- jobs.py       one background job at a time: shot, teach, replay, watch, calibrate
                            |-- chat_agent.py Strands agent, tools call the same guard/jobs/nicla objects
                            +-- auth.py       token gate on everything that moves, records or calibrates

One state object is rebuilt at 10 Hz and pushed over /ws; GET /api/state returns the same document. Its shape is in dashboard/API.md, and the frontend's lib/normalize.ts fills every field with a default first, because a missing key once crashed the whole page.

Three ways the page gets its data

header says what is happening
ws the WebSocket is open, 10 Hz, the normal case
poll the socket dropped; the page polls /api/state at 2 Hz
mock three polls failed; the page runs on a canned state so the layout stays usable while the server is down

The header always tells the truth about which one you are looking at. A dashboard that looks alive on mock data is worse than an empty one.

Two ways to the head

nicla_io.py is LAN-first. It polls the head node's /health and /senses at 2 Hz with zero traffic on the USB cable. Only when /health has failed for 30 s continuously, and the USB device node is older than 5 s (the firmware holds the REPL open for 3 s at boot), does it open the USB REPL and read the board directly. state.nicla.transport is lan, usb or null, and the header's Nicla dot follows it.

The order matters: any process polling the USB REPL kills the node's HTTP loop, so an earlier dashboard that read USB first took the camera down every time it started. The node's URL comes from NICLA_URL, else the address the board last reported over USB (kept in ~/.strands-arm/nicla_url); no LAN address lives in the code.

The camera panel has the same fallback in miniature: MJPEG from the node with a 3 s watchdog, then /api/nicla/snapshot.jpg polling over the REPL.

The guard

Every command, from the chat or a button, passes strands_arm/guard.py: head inside its calibrated window, joints 1 to 4 at most 10° per step and inside their hand-measured range, 30 °/s ramp, torque off 2 s after the last command once folded. One job at a time; a second command while one runs gets a 409. Each command lands as one line in ~/.strands-arm/motion.log. The rules and the incidents behind them are on The guard.

When you drag a joint in the 3D view the page draws a ghost at the requested angle, sends it, and the real arm mirrors back through state. lib/joints.ts reads the same calib.json table the kinematics use, so the mirror is 1:1 with the arm and not a second opinion.

The agent

WS /ws/chat is one Strands agent (Bedrock, STRANDS_ARM_MODEL overrides the id) with the tools arm_state, look, joint, home, stop, torque, shot, teach, watch, nicla, teleop, gripper, drive, photo, look_describe, notify_owner, memory (the list is chat_agent.tools()). Each tool calls the guard, jobs and Nicla objects the buttons call; a sentence has exactly the permissions a click has. The client sends {type: "user", text}, the server streams text, tool, done and error frames, and the chat band shows tool calls as they run.

Around it, all opt-in and all behind the token:

  • memory: ~/.strands-arm/mem.db, every turn from chat, voice and Telegram, plus facts; every chat turn rebuilds the system prompt from it.
  • reasoning: every thought, tool call and result of every agent, one row each; record --lerobot stamps them onto the episode's frames and ecot.py exports them as an ECoT document next to the dataset.
  • thinker: one agent turn every 30 s with the newest photo, dry by default; look capped at 15° per axis, joints 1 to 4 not offered.
  • telegram: the owner's bot as a chat surface; a photo the agent takes comes back as a picture. Listening is opt-in because Telegram allows one poller per token.
  • voice: a bidi speech session on the Mac's mic and speakers with the same tools.

Behind a token

Set STRANDS_ARM_TOKEN and everything that moves, records or calibrates needs it: /api/control/*, /api/record/*, /api/calibrate/*, the Nicla exec and reset routes, /ws/chat, episodes and snapshots. Bearer header, the arm_session cookie from POST /api/auth/login, or ?token= for WebSockets. Through the tunnel a passkey does the same job: once in with the token, POST /api/auth/passkey/register enrols Touch ID, Face ID or a key, and /api/auth/passkey/login mints the same cookie (strands_arm/dashboard/passkeys.py; the store fails closed, never regenerates). Reading (/api/state, /ws, /api/health, the stream, the page itself) stays open. bash dashboard/deploy/run.sh starts the dash with the token from ~/.strands-arm/token and a Cloudflare tunnel; bash dashboard/deploy/launchd/install.sh makes both survive a reboot as launchd agents. The server binds 127.0.0.1 by default; STRANDS_ARM_HOST=0.0.0.0 opens it to the LAN, where arm.local is announced over mDNS.

On the robot's own computer

A Mac drives the leader; the robot carries an Arduino UNO Q (Debian arm64) with the two Feetech adapters and the Nicla on its hub. git clone the repo there and run bash dashboard/deploy/unoq/install.sh: a venv, pip install -e .[dashboard,lerobot], a udev rule that makes the adapters dialout and names each one after its USB serial, ~/.strands-arm/env for the token and model credentials, and two systemd --user units (strands-arm-dash, strands-arm-tunnel) with Restart=always and linger on, so the dash is up after a power cycle with nobody logged in. The page is a build artifact the wheel does not carry; without node on the board, bash dashboard/deploy/unoq/push.sh user@host from the Mac builds it and copies it over. The installer also writes ~/.strands-arm/robot.json on first run (follower, Nicla at the wrist, base on; STRANDS_ARM_ROBOT / STRANDS_ARM_HEAD change the default, strands-arm robot ... changes it later, a re-run never overwrites it). Then strands-arm ports bind arm|follower|base SERIAL once: /dev/ttyUSB0 and ttyUSB1 swap with plug order, the serial does not, and strands-arm doctor shows which adapter got which role. ARM_PORT, FOLLOWER_PORT and BASE_PORT still win when set. bash dashboard/deploy/unoq/check_wheels.sh asks pip for every runtime dependency as a linux/aarch64 CPython 3.12 wheel, so a dependency with no ARM wheel fails on the Mac and not on the robot; with a docker daemon (colima start), bash dashboard/deploy/unoq/docker_check.sh installs the package inside python:3.12 for linux/arm64 and runs the imports, strands-arm ports, doctor and a dash --no-hw answering /api/state. On the board, strands-arm doctor counts the two systemd units as supervision the way it counts launchd agents on the Mac.

The whole HTTP and WebSocket contract is in dashboard/API.md.