Run a hosted SO-101 policy through LeRobot's control loop. Start with a dry run, not motor execution.
Before connecting hardware#
- Set up and calibrate your SO-101 through LeRobot. Check serial port, joint order, camera positions, resolution and local limits.
- Known metadata mismatch: the legacy
so101-degrees-gripper-percent-v1label (still answered bystock-*rows;agi-*leases answerso101-normalized-v1) does not describe these checkpoints' numeric values. Arm state/actions are normalized −100…100 and gripper 0…100, not degrees. Preserve the handshake identifier, but verify your calibrated input/output transforms before motion. Do not bypass admission checks. - ACT is a pick-and-place checkpoint, not language-conditioned.
agi-smolvla/so101is a fine-tune on one real-teleoperation orange-to-plate task (the third-partystock-smolvla/so101is the simulation-trained one), not a demonstrated general instruction follower or guaranteed real-arm transfer. - Keep a clear workspace and physical stop. Interactive mode delays policy playback, but connecting hardware can energize motors. Ctrl-C is not an emergency-stop system.
Inspect the configuration#
Install agi-diy in a dedicated Python 3.12 environment (LeRobot needs 3.12+; lerobot-rollout fails on 3.14 — agi rollout refuses both before leasing) and agi login using a dashboard key (quickstart). Replace the serial port and camera indices below. This command does not lease, install dependencies or move hardware.
agi rollout agi-act/so101 --dry-run -- \
--robot.type=so101_follower --robot.use_degrees=false --robot.port=/dev/tty.usbmodemYOUR_ARM \
--robot.cameras= '{"top":{"type":"opencv","index_or_path":0,"width":640,"height":480,"fps":30},"wrist":{"type":"opencv","index_or_path":1,"width":640,"height":480,"fps":30}}' \
--interactive=true --return_to_initial_position=false
After verifying the setup, remove --dry-run to start the hardware process. /start begins policy playback. Keep --return_ to avoid a commanded return-to-start motion on exit.
The first real rollout installs the pinned LeRobot client and robot extras into the interpreter agi runs in. This can change dependency versions; use an isolated environment and allow time/disk space. AGI_NO_INSTALL=1 prints the install command instead.
What the lease fills in#
agi rollout <model> leases one slot, then runs
python -m agi._rollout_main (= lerobot-rollout, same interpreter as agi, never a PATH lookup) with these
flags in front of yours:
| flag | from the lease | why |
|---|---|---|
--inference.type=remote | — | no local policy, no --policy.path, no --device |
--inference.endpoint= | zenoh.endpoint (tcp/…) | the endpoint advertised by this lease; do not hard-code it |
--inference.deployment= | deployment (d_…) | the slot's capability name; valid for this lease only |
--inference. | zenoh.mode | connect to the router; no multicast scouting |
--inference.encoding=jpeg | zenoh.encoding | two raw 480×640 frames do not cross the public wire |
--inference.semantics= | semantics | the runner refuses a different profile |
--inference.*_timeout_s=30 | — | LeRobot's 5–10 s defaults trip on a cold first call |
--inference. | — | LeRobot's 1.0 s grace is LAN-tuned; the public relay needs slack |
--inference. | — | ask for the next chunk with 0.75 s still queued (real-time chunking, chunk_merge=aligned); LeRobot's 0.5 s is LAN-tuned — the public wire's measured tail (smolvla 527 ms) held the arm. Override allowed |
--inference. | action_horizon, fps | how long an observation may still be driving the arm: its own chunk + the 3 s starvation grace + one 0.75 s refill window — 7.083 for agi-act/so101 (3.333 s chunk), 5.883 for agi-wfm/so101, never below LeRobot's own 5.0 s default. LeRobot revokes motion with a terminal fault the moment a dispatched action's source observation is older than this, so a control loop that slips (a loaded laptop, a slow camera) would otherwise end the run — and before agi-diy 0.5.9 the flag was refused, leaving no recourse. Override allowed |
--fps= | fps | the runner checks 1/fps against the policy's interval |
--robot.use_degrees=false / =true | units of the lease (so101/so100 follower) | catalog checkpoints are normalized → false; a custom hf: checkpoint whose own stats are degrees (units: so101-degrees-gripper-percent-v1) → true. agi-diy ≥ 0.5.10 reads units and refuses the opposite flag; 0.4.6–0.5.9 always injected false and refused true; ≤ 0.4.5 injected nothing (upgrade) |
Everything after -- is yours and goes to lerobot-rollout unchanged. If you pass a flag the lease also sets,
yours wins — except --fps, which must equal the lease's or admission fails, so agi rollout says so before
leasing. The lease ends when the process exits, however it exits.
What you give:
| flag | what |
|---|---|
--robot.type= | so101_follower for the currently hosted checkpoints |
--robot.port= | the serial port (lerobot-find-port prints it) |
--robot.cameras= | JSON: name → {type, index_or_path, width, height, fps} |
--task= | the instruction; needed by language-conditioned models (smolvla/*) |
--duration= | seconds; 0 runs until /stop or Ctrl-C (default) |
--interactive=true | hardware warm, robot still until you type /start |
Cameras#
The deployment names its cameras. agi-act/so101 expects top and wrist; agi-smolvla/so101 expects front and
wrist — agi models --json lists them under features, and the lease carries the same list.
Name yours the same and nothing else is needed. Otherwise agi rollout matches by name, never by order
(agi-diy 0.4.9 and later):
- a camera whose name contains a model camera name as a whole word keeps that slot —
wrist_cam→ wrist,top_cam→ top,FrontCam→ front (butlaptopis nottop); - one camera left over takes the one slot left over — unless its own name says it is a different view (it contains
top,front,wrist,side,overheadorgripper, or a model camera name, but not the free slot's) or names two views (top_wrist): then it stops and prints the--rename_mapfor you to confirm; - two or more cameras it cannot place → it stops before leasing and prints the exact
--rename_mapto pass, so you decide which physical camera is which:
cameras: top_cam → top, wrist_cam → wrist
error: cannot tell which of your cameras (cam0, cam1) is agi-act/so101's top / wrist. Name them like the model's (top, wrist) or pass the mapping yourself, e.g. --rename_map='{"observation.images.cam0": "observation.images.top", "observation.images.cam1": "observation.images.wrist"}' (check that each one really is that view).
What that gives for agi-act/so101 (top, wrist) — every row is run against agi/rollout.py in CI:
Your --robot.cameras names | agi rollout |
|---|---|
top, wrist | nothing to rename |
top_cam, wrist_cam | top_cam → top, wrist_cam → wrist |
TOP, Wrist | TOP → top, Wrist → wrist |
top, cam2 | cam2 → wrist |
wrist, table | table → top |
top, front | refuses: front names another view, the free slot is wrist |
wrist, wrist2 | refuses: wrist2 names the wrist, the free slot is top |
top, gripper | refuses: gripper is not the model's word — pass the --rename_map it prints |
top_wrist, wrist | refuses: top_wrist names two views |
front, wrist | refuses: ACT has no front — rename it top if it is the scene camera |
cam0, cam1 | refuses: two cameras it cannot place |
gripper, laptop | refuses: two cameras it cannot place (laptop is not top) |
agi-diy 0.4.8 kept exact names but mapped the rest by order, and 0.4.7 and earlier mapped everything by order —
both can swap the wrist and scene views silently (wrist_cam → front). Upgrade before connecting hardware. A mapping only renames; it cannot tell which camera points where. Look at
each view before motion.
If the count differs it stops before leasing:
error: agi-act/so101 expects 2 cameras (top, wrist); --robot.cameras names 1 (front).
Resolution must match too: the deployment's shapes are 480×640; declare "width": 640, "height": 480 on
every camera.
SO-101#
--robot. needs LeRobot's Feetech driver. agi rollout installs lerobot[feetech] with the
rest when the robot type asks for it:
--robot.type | extra installed |
|---|---|
so100_follower so101_follower bi_so_follower koch_follower hope_jr_* | feetech |
lekiwi | lekiwi (feetech + zmq) |
openarm_follower bi_openarm_follower | openarms (CAN) |
| anything else | none beyond remote + dataset |
Calibration is LeRobot's and unchanged: run
lerobot-calibrate --robot.type=so101_follower --robot.port=…
once per arm; the file is reused on every rollout. The model's joint order (shoulder_pan, shoulder_lift, elbow_flex, wrist_flex, wrist_roll, gripper) is the SO-101's; still verify calibration, normalized units and joint limits.
What you will see#
+ python -m agi._rollout_main --inference.type=remote --inference.endpoint=tcp/… --inference.deployment=d_… …
Remote server deployment=d_… instance=… protocol=1
Connecting robot (so101_follower)... Robot connected
Remote session ready: deployment=d_… mode=chunk merge=aligned action_rate=30.0 Hz horizon=3.333s; contract verified
Remote rollout admitted
Base strategy control loop started
Remote progress: accepted=2 rejected=0 … turnaround mean/max=0.197/0.329s … queued playback=1.900s
Without interactive mode, policy playback starts after admission. On exit LeRobot may command a return to the initial pose unless --return_ was set. Ctrl-C requests shutdown; it is not a guaranteed physical stop. With --interactive=true the arm holds still until
you type /start; /subtask <text> changes the instruction, /reset returns to the starting pose, /stop
ends the run. Every chunk the policy returns is billed at one token per action, visible in agi usage after metering updates.
Historical transport measurements (2026-10-24, laptop → public endpoint, two JPEG cameras). These are not physical-task success rates or latency guarantees:
| model | actions per call | turnaround (mean / max) | first call |
|---|---|---|---|
agi-act/so101 | 100 | 197 / 329 ms | ≤ 300 ms |
agi-smolvla/so101 | 50 | 425 / 494 ms | ≤ 900 ms |
Read those turnarounds against 0.75 s, not against the chunk. agi rollout asks for the next chunk when 0.75 s of motion
is left (--inference.refill_seconds, set because the public wire's measured tail — smolvla 527 ms, 2026-10-25 — exceeded LeRobot's 0.5 s default), so that is the whole budget for a round trip — a 3.3 s chunk buys no more time than a 1.7 s
one, it only means you pay for a call less often. Against that threshold, on the measurements above: agi-act/so101
has roughly 170 ms of margin at its worst observed turnaround, and agi-smolvla/so101 has almost none (494 ms of 500).
A turnaround past it does not break the run — the arm holds its last target until the next chunk lands — but it holds
on every chunk, and nothing arriving within the 3 s starvation grace faults the run.
The other way a run ends is the arm playing a chunk for too long. Every action in a chunk comes from ONE
observation, and LeRobot revokes motion — a terminal fault, "Dispatched action source observation is too old" —
once that observation passes --inference.. Its own default is 5.0 s, which against
agi-act/so101's 3.333 s chunk leaves about a second, so agi rollout derives the value from the lease instead
(chunk + grace + refill: 7.083 s for act, 5.883 s for the flagship, never less than LeRobot's 5.0 s). This matters
when your control loop runs slower than the model's rate: at half rate a 100-step chunk takes 6.7 s to play, and
the observation that produced it is stale long before the end. If you see that error, your loop is not keeping
--fps; check that first, then widen the flag.
agi-wfm/so101 is the exception, and it is the one that can starve the queue. Its chunk is 32 actions at
15 Hz — half the rate of the others, so agi rollout injects --fps=15 and the chunk is 2.13 s of motion
rather than 3.3 s. The turnaround is the problem, and it is not close: measured on the serving box on 2026-10-25, the policy alone takes
643 ms at the 4 denoise steps the service runs — past the 500 ms budget before any network — against 418 ms at 1
step, which would leave about 80 ms. Round trips measured from a client have been 1.4–2.1 s. So at the served
setting this policy holds the arm briefly on every chunk; it moves with where you call from, so read the current
measurement on /models/agi-wfm-so101 before you plan a loop. What that means at the arm: LeRobot's
look-ahead is 0.75 s of playback under agi rollout (LeRobot's default is 0.5 s), so if a call takes longer than the remaining queue the arm holds its last target
and, if nothing arrives within the 3 s starvation grace, the run faults (the same path as a dropped link: what LeRobot does) —
the hold and the release of torque are the same path as a network drop, not a different one. Keep a request in flight
ahead of the one that is playing, or run the flagship in --fake / simulation and keep real hardware on the 30 Hz
default tier until your own measurement says otherwise.
Without an arm#
agi rollout <model> --fake -- --task="…" runs the real lerobot-rollout against the real policy with a robot
that has no hardware: it holds position, reports a still state and black frames at the deployment's camera
shapes, and accepts every action. Use it to check your install, your network and your bill before plugging in
— the actions are real and billed.
agi rollout <model> --dry-run -- <your flags> prints the composed command and the camera mapping and leases
nothing:
$ agi rollout agi-act/so101 --dry-run -- --robot.type=so101_follower --robot.use_degrees=false --robot.port=/dev/tty.usbmodem1 --robot.cameras='{"top_cam": …, "wrist_cam": …}'
cameras: top_cam → top, wrist_cam → wrist
installs: lerobot[remote,dataset,feetech] if missing
/…/bin/python -m agi._rollout_main --inference.type=remote '--inference.endpoint=<leased>' '--inference.deployment=<leased>' --inference.zenoh_mode=client --inference.encoding=jpeg --inference.semantics=so101-degrees-gripper-percent-v1 --inference.handshake_timeout_s=30 --inference.action_timeout_s=30 --inference.startup_timeout_s=30 --inference.action_starvation_grace_s=3.0 --inference.refill_seconds=0.75 --inference.max_observation_age_s=7.083 '--rename_map={"observation.images.top_cam": "observation.images.top", "observation.images.wrist_cam": "observation.images.wrist"}' --fps=30 --robot.type=so101_follower --robot.use_degrees=false --robot.port=/dev/tty.usbmodem1 '--robot.cameras={…}'
Errors#
One sentence, naming the fix. From agi rollout before anything is leased:
| you see | do |
|---|---|
agi-act/so101 expects 2 cameras (top, wrist); --robot.cameras names 1 | declare both cameras |
--fps=25 differs from the model's 30 | drop --fps or set it to 30 |
--robot.type is required after \--` …, or use --fake` | add --robot. after -- |
camera 'top' is 1280×720; agi-act/so101 expects 640×480 | set width 640, height 480 on that camera |
no_slots: no free slot for agi-act/so101 (409) | agi models; retry after retry_after_s; another model may require different cameras/task |
From LeRobot once the hardware is up (its own text, unchanged):
| you see | means |
|---|---|
Remote observation features differ: expected {…} | camera names or shapes differ from the deployment's |
Robot action component names differ from the deployment | --robot.type is not the arm the model was trained on |
Robot/action semantic profile differs | a hand-set --inference.semantics; drop it |
Policy action interval differs | --fps differs from the model's |
Remote inference request deadline exceeded | the wire stalled; agi models shows whether the runner is up |
Could not open port /dev/tty… | wrong --robot.port; lerobot-find-port |
No such file or directory: '/dev/tty…' | wrong --robot.port; lerobot-find-port prints the right one |
… is not calibrated | run lerobot-calibrate --robot.type=… --robot.port=… once, then retry |
Specifying 'width' is required | every camera needs width, height and fps in --robot.cameras |
OpenCVCamera … | a camera did not open; check index_or_path in --robot.cameras |
Connection refused | the endpoint did not answer; agi models shows whether the runner is up |
action starvation grace expired | the client stopped asking before the buffer ran dry — a Ctrl-C mid-chunk or a quiet wire; the runner answered every query it received (see the trace) |