Project ~6 min read
at the endWhat the robot is made of, which arm is which, what one file changes when the same six servo ids mean a different arm, and what comes next.
The robot¶
Three bodies, one package. The direction is ROBOT.md at the repo root, in the owner's words; this page is the same picture with the code that carries it.
| part | what it is |
|---|---|
| the base | a printed three-wheel omni body on the LeKiwi kit: three STS3215 on their own 12 V bus (ids 7, 8, 9), 60 mm omni wheels, the 3S pack, an Arduino UNO Q as the computer; three pods and two decks, 311 g, 14 h 44 min, no supports |
| the follower | the SO-101 follower rebuilt on the upper deck, Base_SO101 on four heat-set bosses, a Nicla Vision fixed to the arm as the robot's eyes; its body is printing from hardware/print/so101_follower |
| the leader | the SO-101 leader with the printed pan/tilt head: the teleop device and the cameraman, driven from the phone through the guard |
Two arms, six servo ids each, one codebase. The same id 6 is a tilt servo on one arm and a gripper on the other. What tells the code which arm it is holding is one file.
robot.json¶
~/.strands-arm/robot.json, next to calib.json. Every key is optional; a missing file means a leader with the pan/tilt head and no base.
{
"arm_role": "leader",
"head": { "mount": "pan_tilt" },
"base": false,
"nicla_url": ""
}
Write it by hand or let the CLI do it; unspecified keys keep their value, and the dashboard, guard and agents pick the change up on their next tick without a restart:
strands-arm robot # what this arm is, and how it introduces itself
strands-arm robot follower --head wrist --base --nicla http://<nicla-ip>:8080
strands-arm robot leader --head pan_tilt --no-base
| key | values | what changes |
|---|---|---|
arm_role |
leader, follower |
joint 6 is tilt or gripper in the CLI, the agent tools, /api/state and the frontend label; the guard's head rule (servos 5 and 6 inside the head window) applies only to a leader with a head; a follower holds torque while a hold lease is alive |
head.mount |
pan_tilt, wrist, mast, none |
pan_tilt means servos 5 and 6 carry the printed head and get the head rule; anything else means the camera is fixed to the arm and 5 and 6 are plain joints. look, the orbit and reveal shots and watch refuse on such a robot ("no pan/tilt head (follower, camera mount wrist)"): they would have twisted the wrist and swept the jaw. dolly (joints 2-4) still runs. The dashboard greys the look pad, orbit, reveal and watch out on such a robot with the same words, before the click. The chat, voice and thinker agents introduce themselves from this file too (robot.persona()), so the follower never calls itself the cameraman |
base |
true, false |
whether doctor, the dashboard and the agent expect a wheel bus on BASE_PORT |
nicla_url |
a URL or "" |
this robot's Nicla node on the LAN (the follower's is a second board at a second IP). "" = NICLA_URL, else the address the node last reported over USB; the USB fallback is used when it does not answer |
STRANDS_ARM_ROLE=follower overrides the role for one process, which is how tests and the second bus of teleop run. A bad value falls back to the default and never raises (strands_arm/robot.py, normalize). /api/state.robot republishes the file, re-read when its mtime changes, so the dashboard relabels joint 6 live.
What the role changes in the guard¶
Same guard, same eight rules, read with the role in hand (strands_arm/guard.py, The guard).
| leader | follower | |
|---|---|---|
| servos 5 and 6 | pan and tilt: the head window (calibrated zero ±160° pan, ±80° tilt), look in degrees relative to home |
wrist roll and gripper: 10° steps from the current reading like joints 1 to 4, no head window |
| idle | torque off two seconds after the last motion, once folded (rules 6 and 7) | torque held while a hold lease is alive; POST /api/control/hold?seconds=0.3 renews it every tick |
stop |
halts the ramp; torque off if folded, otherwise keeps holding with held: true. Dropping the arm is the accident, not the fix |
same |
release |
walks home in ≤ 10° legs first when joints 1 to 4 are more than 15° from home, then releases | same |
| gripper | none | 0 closed to 100 open, LeRobot's unit, mapped onto the servo 6 window in calib.json; None until that servo is calibrated, no guessing a jaw |
Why the split: a leader is held by a hand and must give way to it, so it lets go when idle. A follower is holding something and nobody's hand is on it, so it holds as long as the hand on the other end asks. Neither is ever dropped above the fold: rule 7 is the same for both since the 02:50Z drop.
Ports and buses¶
Three USB adapters on one computer, eventually: the leader's, the follower's, the wheels'. Each is an environment variable, none is guessed.
| variable | bus | servos | voltage |
|---|---|---|---|
ARM_PORT |
the arm this process drives | ids 1 to 6 | the 7.4 V class of STS3215 (6 to 8.4 V); under 6.5 V the guard lets it fold and hold, never rise (rule 8) |
FOLLOWER_PORT |
the follower, for teleop |
ids 1 to 6 | same class as the leader |
BASE_PORT |
the wheels | ids 7, 8, 9 | 12 V from the pack |
WheelBus refuses to open ARM_PORT and refuses any bus where ids 1 to 6 answer, so a wrong cable cannot put 12 V commands on an arm. strands-arm doctor prints a row per bus. Wheel ids and the voltage split are in The base.
The follower does not have to be on the same host. strands-arm teleop --to https://robot.example reads the leader from ARM_PORT here and sends each tick to the robot's dashboard: POST /api/control/follow {goals, hold_s} is one checked write through that guard plus its hold lease, /api/state supplies its joints and calibration table, /api/control/release ends it. 10 Hz instead of 30 because every tick is a round trip; the robot's dash must say arm_role: follower or the command refuses. The same run is a job on the leader's dashboard: the teleop agent tool takes to=URL, or set STRANDS_ARM_ROBOT_URL once on the leader host and "teleop start" from the chat, Telegram or the phone drives the robot without a follower adapter on this side; gripper sets the jaw mid-run and stop releases through the robot's guard. While ticks land, the robot's /api/state carries arm.teleop {on, hz, source} and its header shows a pulsing teleop 10 Hz · <leader host> badge, so whoever is looking at the robot knows a hand is on it; one second without a tick and the badge is gone. This is the Mac-and-UNO-Q layout: the leader stays on the desk, the robot drives away.
What's next¶
The follower goes onto the upper deck with its Nicla at the wrist (head.mount: wrist) and its adapter as FOLLOWER_PORT. The base's first roll at 0.1 speed writes each wheel's sign into ~/.strands-arm/base.json; the UNO Q takes over from the Mac and the three port variables move with it. The leader keeps its head, with a second Nicla on the follower at a second nicla_url. Then the cameraman demo: a follower rollout filmed gripper-in-frame, saved as a LeRobot episode with two views and base velocities. After that, other idle leaders (Koch, Moss, SO-100, GELLO) through a parametric horn cradle.
Why the pair · The base · Calibrate · The guard