Skip to content

Drive ~8 min read

at the endAn agent that films, tracks, teaches and records from one sentence.

Talk to it

arm, head, nicla are Strands tools. One sentence films, tracks, teaches or records.

from strands import Agent
from strands_arm import arm, head
from strands_nicla import nicla

Agent(tools=[arm, head, nicla])("track the gripper, then take a snapshot and describe what the follower is holding")

Verbs

All strands-arm <verb>. strands-arm --help prints the same list grouped read-only / motion / agent, with the motion gate's live state on its first line (motion gate: CLOSED (no ok:true in ~/.strands-arm/bench.json); motion verbs need --i-know). Every row below is what the verb does; tests/test_docs_truth.py fails when a documented verb or flag stops existing.

The two gates it talks about:

  • motion gate = teach.motion_allowed(): --i-know (tool: i_know=true), or STRANDS_ARM_I_KNOW=1, or ~/.strands-arm/bench.json with {"ok": true}. No file means closed. doctor shows its state.
  • guard = guard.Guard: per-command limits (joints 1-4 max 10° per step and inside their measured window, head inside its window, tick wrap, EEPROM window, the 6.5 V lift floor), one motion.log line per accepted or refused command. The dashboard and its API own one; pose builds its own over the tool's bus when no dash is running, teleop one for the follower.
verb moves motion gate guard + motion.log dry by default result
doctor no table
scan, pose no JSON (tool result)
photo [--describe] no text, exit 0/1
record --lerobot no files under --root
memory, ecot, serve --mcp no JSON / text
look PAN TILT, center head yes (--i-know) no no one line, exit 1 on refusal
teach NAME, replay NAME torque off / all joints yes no no text
pose list\|show\|save\|delete no text (tool result)
pose goto NAME [--seconds S], pose nudge JOINT DEG all joints in <=10 deg legs / one joint <=10 deg yes (--i-know) yes (the dash's job, or a local Guard) no text, exit 1 on refusal
shot orbit\|dolly\|reveal all joints on --go yes, on --go no yes plan text
watch face\|person head, arm re-bases yes no no text
drive wheels 7/8/9 yes, on --go BaseGuard (own lease + log) yes text
thinker head, one step own --go (not the motion gate) no yes text
calibrate all joints; EEPROM on --open-limits/--rezero --i-know for EEPROM only no --dry-run report
telegram, voice, agent "..." whatever the agent's tools do per tool (table below) no chat
dash via /api/control/* bench gate yes HTTP

Strands tools, the same surface for an agent or an MCP client. What a client reads as the tool's description is the function's docstring, verbatim; there is no second copy of the wording (tests/test_mcp_descriptions.py fails if one appears, or if a mover's docstring stops saying i_know):

tool · action moves motion gate guard + motion.log result
arm · list_devices, scan, read_pose, telemetry no ok + ids / pose / telemetry
arm · torque, move, move_many, set_id yes, any joint, any angle in 0..359.9 yes (i_know=true) no ok text with the tick actually commanded, head-limit warning only; refusal names the band and the head window
head · pose, snapshot, calibrate_* no (calibrate_zero reads the pose) ok + stored/derived
head · look, center, track head yes (i_know=true) no ok + targets, clamped to limits() and says so; refusal names the allowed window
keyframes · list, show no ok
keyframes · record, replay torque off / all joints yes no ok / REFUSAL
shot all joints on execute=true yes no plan, or REFUSAL
pose · list, show, read_all, save, delete no ok + poses / targets / deg
pose · goto, nudge all joints in <=10 deg legs at 30 deg/s / one joint, <=10 deg yes (i_know=true) yes: the dashboard's pose <name> job when it answers on :8090 (stop aborts), else a local Guard on the bus ok + legs/after, or the refusal; an out-of-band pose is refused before any write, never clamped
watch_tool head + arm yes no ok / REFUSAL
photo, look_describe, notify_owner, memory tools no ok
dashboard agent · teleop the follower, joints 1-5 + gripper (a job; stop folds it first) yes (dry=true previews) no ticks / written / refusals, or the refusal
dashboard agent · gripper follower servo 6, 0 closed .. 100 open through the running teleop, or the guard on a follower dash no deg, legs
dashboard agent · drive the omni base, <= 3 s, re-sent inside the 300 ms lease then stopped; renews a follower's hold lease yes + armed wheels no sends, odom

Every mover is behind the one motion gate. The raw arm tool has no step limit and writes no motion.log: it is the bus, for calibration and repair; the guard is where the 10° step lives.

A gate refusal has one shape, from result.refused: what was asked + teach.REFUSAL (how to open the gate) + what would be allowed once it is open:

look pan=+200 tilt=-90 refused: this moves or releases the arm. pass --i-know, set STRANDS_ARM_I_KNOW=1, or set {"ok": true} in ~/.strands-arm/bench.json (docs/safety.md). once open, allowed: pan -160..+160, tilt -80..+80 relative to neutral
move joint=6 deg=400.0 refused: ... once open, allowed: any joint 0..359.912 deg (absolute), head wrist_roll 20..340, tilt 100..260

keyframes record|replay, shot execute=true, watch_tool and strands-arm watch answer the same way (keyframes replay wave refused: ... once open, allowed: the saved frames of wave, ramped in, at speed 1x).

Every tool returns {"status": "success"|"error", "content": [{"text": ...}]} plus named fields (result.py).

Torque is off by default on every joint: a leader is geared for backdrivability, not load, and someone may be holding it. A move turns torque on for that joint only; the dashboard's guard releases it 2 s after the last command, the bare tools do not release it.

Fingers

center, look, shot --go, watch, replay and the agent drive servos. Fingers out of the yoke, cable clear of the horn.

What record --lerobot writes

One episode in LeRobot v3.0 layout, written with pyarrow alone (strands_arm/lerobot.py).

feature value
observation.state, action on the leader (robot.json says leader): float32[6], the six servo angles in degrees, and the hand is the action so both columns hold the same numbers. On the robot (robot.json says follower, or base: true): the LeKiwi shape, float32[9] named arm_shoulder_pan.pos … arm_gripper.pos, x.vel, y.vel, theta.vel, arm in degrees and base in m/s and rad/s from wheel odometry; action is what was commanded: the follow goals of a running teleop (the dash's job or /api/control/follow from another host, guard.last_goals() younger than 1 s) and the Drive command for the wheels, else a copy of the state
observation.images.cameraman the leader's view, when the leader is a second host: record with {cameraman: <leader dash URL>} (strands-arm record --lerobot --cameraman URL, or STRANDS_ARM_CAMERAMAN) and its /api/nicla/stream is tapped for the recording behind the bearer token, one mp4 per camera. Unreachable leader: the episode is written without that camera and state.record.cameraman says why
observation.images.* the Nicla stream, QVGA 320 × 240, JPEG q50 at the source; h264 mp4 when ffmpeg is present, inline JPEG bytes in the parquet when it is not. The key follows the head mount: .head on the pan/tilt leader, .wrist or .front on the follower; the leader's shots ride along as .cameraman when both arms record
timestamp frame_index / fps, default 10 fps (--fps)
robot_type so101_leader_strands_arm, so101_follower_strands_arm, or lekiwi_strands_arm with a base
task "film the follower" unless you pass one

Two facts a paper needs are not in the dataset: the lens field of view and the frame-to-joint sync error are unmeasured. Treat the head as a second, moving viewpoint, not a calibrated camera.

Ports

Set ARM_PORT for the Feetech bus and NICLA_PORT for the board when both are on USB; with them unset the driver may pick the Nicla as the bus. Or bind once by USB serial, which survives a reboot that renumbers ttyUSB0/1: strands-arm ports lists what is plugged in, strands-arm ports bind arm|follower|base|nicla SERIAL stores it in ~/.strands-arm/ports.json; the env vars still win when set. doctor shows which adapter has which role.

Bench

Folded home on the bus right now: 176.0 / 83.9 / 262.4 / 251.5 for shoulder pan, lift, elbow, wrist flex; pan 178.9 and tilt 179.1, both re-zeroed to 180 in EEPROM. 5.6 V, 28 to 29 °C, torque off.

What runs on the Nicla.