Skip to content

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.

~/.strands-arm/robot.json
{
  "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