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), orSTRANDS_ARM_I_KNOW=1, or~/.strands-arm/bench.jsonwith{"ok": true}. No file means closed.doctorshows 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), onemotion.logline per accepted or refused command. The dashboard and its API own one;posebuilds its own over the tool's bus when no dash is running,teleopone 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.