
Wiring, calibrating and recording two SO-100 arms as one 12-channel system in lerobot 0.6.2, and which of the five trainable policies accept the doubled action vector unchanged.
What you need to know
- •Two SO-100 arms are not one robot with twelve joints. They are two independent Feetech buses on two USB ports, calibrated separately and joined only in software.
- •In lerobot 0.6.2 the classes are bi_so_follower and bi_so_leader. Each wraps two ordinary SO arms and prefixes every key with left_ or right_.
- •The action vector goes from 6 floats to 12: left_shoulder_pan.pos through right_gripper.pos. Nothing else about the LeRobot dataset format changes.
- •ALOHA, the reference bimanual rig, uses 7 plus 7 equals 14 dimensions. An SO-100 has five joints and a gripper, so a pair gives 12.
- •ACT sizes itself from the dataset, so 12 works untouched. SmolVLA and Pi0.5 pad state and action to 32. GR00T needs a hand-written modality config and a v2.1 dataset.
- •Two traps eat the most time: calibration files under the wrong id, and per-arm camera names that get prefixed twice.
What bimanual means once you look at the code
A single SO-100 is six Feetech STS3215 bus servos on one serial port. LeRobot's SO-100 driver writes the motor table out by hand. Read it once: every key you later see in a dataset comes from here.
# lerobot 0.6.2, src/lerobot/robots/so_follower/so_follower.py
norm_mode_body = MotorNormMode.DEGREES if config.use_degrees else MotorNormMode.RANGE_M100_100
self.bus = FeetechMotorsBus(
port=self.config.port,
motors={
"shoulder_pan": Motor(1, "sts3215", norm_mode_body),
"shoulder_lift": Motor(2, "sts3215", norm_mode_body),
"elbow_flex": Motor(3, "sts3215", norm_mode_body),
"wrist_flex": Motor(4, "sts3215", norm_mode_body),
"wrist_roll": Motor(5, "sts3215", norm_mode_body),
"gripper": Motor(6, "sts3215", MotorNormMode.RANGE_0_100),
},
calibration=self.calibration,
)The bimanual class does not extend that table. It builds two complete SOFollower objects, one per port, and prefixes everything that comes out of them. The extra degrees of freedom arrive as string prefixes, not as a new kinematic model. There is no shared base frame, no cross-arm collision checking and no solver that knows the two arms are in the same room.
| Concept | Single SO-100 | bi_so_follower |
|---|---|---|
| Serial ports | 1 | 2, one per arm, set independently |
| Motor IDs | 1 to 6 | 1 to 6 on each bus, they do not clash |
| Observation keys | shoulder_pan.pos ... gripper.pos | the same six, prefixed left_ and right_ |
| Action keys | 6 | 12 |
| Calibration files | 1 | 2, written as <id>_left.json and <id>_right.json |
| Cameras | flat dict | per-arm keys get prefixed, top-level keys do not |
| is_connected | bus and cameras | left arm AND right arm, both must be up |
That last row is the design of LeRobot's BimanualMixin: connect, calibrate, configure and disconnect are delegated to both arms in sequence, and the pair reports itself connected only when both halves are. One loose cable gives you a failure, not a half-working robot. Correct behaviour, and also why the rig feels twice as fragile at the bench.
The doubled action vector
Record with a bimanual follower and every frame of your LeRobot dataset carries a 12-element action and a 12-element observation.state, left arm first, in the motor order above. The one published bimanual SO-100 dataset on the Hub, pepijn223/bimanual-so100-handover-cube, shows exactly that in its meta/info.json: 25 episodes, 13,220 frames, 30 fps, codebase_version v3.0, robot_type bi_so100_follower.
action (12,) float32
left_shoulder_pan.pos left_shoulder_lift.pos left_elbow_flex.pos
left_wrist_flex.pos left_wrist_roll.pos left_gripper.pos
right_shoulder_pan.pos right_shoulder_lift.pos right_elbow_flex.pos
right_wrist_flex.pos right_wrist_roll.pos right_gripper.pos
observation.state (12,) float32 # same twelve names, same order
observation.images.left (480, 640, 3) video, av1
observation.images.right (480, 640, 3) video, av1
observation.images.top (480, 640, 3) video, av1ALOHA, the rig every bimanual imitation-learning paper is measured against, uses two ViperX 6-DoF arms with a gripper each. The paper states the action space plainly: absolute joint positions for two robots, 7 plus 7 equals 14 dimensions, observed with four 480x640 cameras, teleoperated and recorded at 50 Hz. An SO-100 pair lands at 12 because the arm has five joints, not six, before the gripper.
| Rig | Arms | Per-arm DoF | Action dim | Cameras | Control rate |
|---|---|---|---|---|---|
| ALOHA (2023 paper) | 2 ViperX | 6 joints + gripper = 7 | 14 | 4 | 50 Hz |
| SO-100 single | 1 | 5 joints + gripper = 6 | 6 | 1 to 2 typical | 30 fps typical in LeRobot |
| SO-100 bimanual | 2 | 5 joints + gripper = 6 | 12 | 3 in the published dataset | 30 fps in the published dataset |
Widening a vector from 6 to 12 is arithmetic. The data is what gets harder: a two-arm task has a far larger space of joint configurations that all look correct on camera, and your demonstrations have to cover the coordinated ones. The ALOHA authors recorded 50 demonstrations for each of their six real-world tasks, except Thread Velcro which got 100. They report that this came to roughly 10 to 20 minutes of data per task but 30 to 60 minutes of wall-clock time, because of resets and teleoperator mistakes. Budget that, not the vector width.
Hardware: four arms, four ports, four power supplies
A conventional leader-follower bimanual rig is four arms: two followers do the work, two leaders are the input device. TheRobotStudio's parts list for one SO-100 class follower-plus-leader setup totals 12 STS3215 servos, 2 Waveshare motor control boards and 2 power supplies. Double every line.
| Item | One leader-follower pair | Bimanual (two pairs) |
|---|---|---|
| Arms | 2 | 4 |
| STS3215 servos | 12 | 24 |
| Waveshare motor control boards | 2 | 4 |
| Power supplies | 2 | 4 |
| USB serial devices to keep straight | 2 | 4 |
| Cameras | 1 to 2 | 3 or more (two wrist, one overhead) |
| Parts cost, AY-Robots figure per SO-100 arm | about 110 to 150 EUR | four arms, plus cameras and a powered hub |
SO-100 and SO-101 arms run Feetech STS3215 at 7.4 V. TheRobotStudio's parts list points out that the follower servo comes in two sizes: the 7.4 V version at 16.5 kg.cm stall torque, and a 12 V version at 30 kg.cm that needs its own 12 V supply. That is exactly why the mistake happens, because four arms means four barrel jacks on one bench and they all fit. Feeding 12 V into a 7.4 V STS3215 destroys it. Label every supply with tape before you power anything on, and see servo not responding if a joint has already gone quiet.
One detail that has nothing to do with software: the two followers must sit at a fixed, repeatable distance from each other. If the left base shifts 2 cm between sessions, demonstrations before and after teach different geometries and the policy cannot tell them apart. Bolt both bases to one plate. The handover point lives in the space between them.
Ports, motor IDs and calibration
With four devices, port discovery stops being a formality. LeRobot's helper lists the serial ports, asks you to unplug one cable, lists them again and prints the difference. Run it once per arm and write the answer on the arm.
- 1Find each of the four ports
The helper asks you to unplug that arm's USB cable, then prints the port that disappeared. It errors out if zero or more than one port changed, so unplug exactly one. Run it four times and tape the result to each base.
bashpip install 'lerobot[hardware]' # run this four times, once per arm, unplugging only that arm each time lerobot-find-port # -> The port of this MotorsBus is '/dev/ttyACM0' - 2Set motor IDs, one arm at a time
Each bus needs IDs 1 to 6 written to its servos. Do it per arm with the single-arm type. bi_so_follower does implement setup_motors and runs the two arms in sequence, but the prompts are per motor and it is easy to lose track of which arm you are on.
bashlerobot-setup-motors --robot.type=so100_follower --robot.port=/dev/ttyACM0 # left follower lerobot-setup-motors --robot.type=so100_follower --robot.port=/dev/ttyACM1 # right follower lerobot-setup-motors --teleop.type=so100_leader --teleop.port=/dev/ttyACM2 # left leader lerobot-setup-motors --teleop.type=so100_leader --teleop.port=/dev/ttyACM3 # right leader - 3Calibrate the pair, not the arms
This is the step people get wrong. Calibrate through the bimanual type so the files are written under the names the class will later look for. Calibrating the four arms individually also works, as long as the ids end in _left and _right and share the bimanual prefix.
bashlerobot-calibrate \ --robot.type=bi_so_follower \ --robot.left_arm_config.port=/dev/ttyACM0 \ --robot.right_arm_config.port=/dev/ttyACM1 \ --robot.id=bimanual_follower lerobot-calibrate \ --teleop.type=bi_so_leader \ --teleop.left_arm_config.port=/dev/ttyACM2 \ --teleop.right_arm_config.port=/dev/ttyACM3 \ --teleop.id=bimanual_leader - 4Verify where the files landed
Four JSON files with the suffixes the bimanual class appends. Robots go under robots/, teleoperators under teleoperators/, and in 0.6.2 the model folder is the shared so_follower and so_leader, not a per-model one.
bashls ~/.cache/huggingface/lerobot/calibration/robots/so_follower/ # bimanual_follower_left.json bimanual_follower_right.json ls ~/.cache/huggingface/lerobot/calibration/teleoperators/so_leader/ # bimanual_leader_left.json bimanual_leader_right.json
BiSOFollower constructs its sub-arms with id=f"{config.id}_left" and id=f"{config.id}_right", and the base Robot class writes calibration to <cache>/calibration/robots/<robot name>/<id>.json. Two consequences. Calibrate under unrelated ids like left_follower and the bimanual robot never finds them, because it looks for bimanual_follower_left.json. And the folder is so_follower, not so100_follower: in 0.6.2 SO100Follower and SO101Follower are plain aliases of SOFollower, so older tutorials print a path that no longer exists. The symptom either way is a recalibration prompt on every launch, with no error. It is the first row of the FAQ in Seeed's double-arm guide. Calibrate through the bimanual type, or copy the files across by hand.
Teleoperating both arms
Once the four calibration files exist, teleoperation is one command. Nested config fields are the only syntax difference from a single arm: --robot.left_arm_config.port instead of --robot.port.
lerobot-teleoperate \
--robot.type=bi_so_follower \
--robot.left_arm_config.port=/dev/ttyACM0 \
--robot.right_arm_config.port=/dev/ttyACM1 \
--robot.id=bimanual_follower \
--robot.left_arm_config.cameras='{
wrist: {"type": "opencv", "index_or_path": 1, "width": 640, "height": 480, "fps": 30},
}' --robot.right_arm_config.cameras='{
wrist: {"type": "opencv", "index_or_path": 2, "width": 640, "height": 480, "fps": 30},
}' \
--teleop.type=bi_so_leader \
--teleop.left_arm_config.port=/dev/ttyACM2 \
--teleop.right_arm_config.port=/dev/ttyACM3 \
--teleop.id=bimanual_leader \
--display_data=trueThe human factor is underrated here. One operator with two leaders can drive a handover, but coordinated two-handed motion at speed is hard to produce, and it shows up in the data as one arm waiting while the other works. Two operators, one arm each, give smoother trajectories and worse coordination. Decide before you record 50 episodes: mixing both styles teaches the policy that both are correct.
Recording a bimanual dataset
Recording uses the same nested syntax plus a dataset block, and a wider install extra: the record script asks for pip install 'lerobot[core_scripts]', not the hardware extra alone. The command below is verbatim from the lerobot-record docstring, the closest thing upstream has to bimanual SO-100 documentation: there is no bimanual page in the LeRobot docs tree. The dataset docs and the SO-100 setup guide both cover the single-arm path only.
lerobot-record \
--robot.type=bi_so_follower \
--robot.left_arm_config.port=/dev/tty.usbmodem5A460822851 \
--robot.right_arm_config.port=/dev/tty.usbmodem5A460814411 \
--robot.id=bimanual_follower \
--robot.left_arm_config.cameras='{
wrist: {"type": "opencv", "index_or_path": 1, "width": 640, "height": 480, "fps": 30},
top: {"type": "opencv", "index_or_path": 3, "width": 640, "height": 480, "fps": 30},
}' --robot.right_arm_config.cameras='{
wrist: {"type": "opencv", "index_or_path": 2, "width": 640, "height": 480, "fps": 30},
front: {"type": "opencv", "index_or_path": 4, "width": 640, "height": 480, "fps": 30},
}' \
--teleop.type=bi_so_leader \
--teleop.left_arm_config.port=/dev/tty.usbmodem5A460852721 \
--teleop.right_arm_config.port=/dev/tty.usbmodem5A460819811 \
--teleop.id=bimanual_leader \
--display_data=true \
--dataset.repo_id=${HF_USER}/bimanual-so-handover-cube \
--dataset.num_episodes=25 \
--dataset.single_task="Grab and handover the red cube to the other arm" \
--dataset.streaming_encoding=true \
--dataset.encoder_threads=2Camera naming is where the prefixes stop being uniform, and both public examples get bitten by it. BiSOFollower prefixes every camera declared on left_arm_config.cameras or right_arm_config.cameras with the side, and leaves top-level --robot.cameras alone. The upstream command above puts top under the left arm, so it writes left_top. Seeed's guide names the per-arm cameras left_wrist and right_wrist, so it writes left_left_wrist. A real collision between the two levels does raise a ValueError rather than shadowing silently.
| You declare | On | Key in the dataset | Note |
|---|---|---|---|
| top | --robot.cameras | top | Stays unprefixed. This is how you get one shared overhead view. |
| wrist | --robot.left_arm_config.cameras | left_wrist | The side prefix is added for you. |
| wrist | --robot.right_arm_config.cameras | right_wrist | Same rule on the right. |
| top | --robot.left_arm_config.cameras | left_top | What the upstream lerobot-record example produces. |
| left_wrist | --robot.left_arm_config.cameras | left_left_wrist | What Seeed's guide produces. Name it wrist, not left_wrist. |

Reasoned, not measured on this hardware, so treat it as a failure mode to rule out rather than a number to quote. Four MJPEG streams at 640x480 and 30 fps is a lot to ask of one USB 2.0 root hub, and the failure is not an exception: it is a camera repeating its last frame, or the loop quietly running under the requested rate. The published bimanual dataset uses three cameras, not four. Spread them across controllers, use a powered hub, and watch the recorded videos before you trust the run. A frozen wrist camera is worse than no wrist camera, because the policy still learns to condition the gripper on it. If one never shows up at all, camera not detected covers the causes.
Plan for throwing episodes away. Seeed's double-arm walkthrough records 50 episodes of a cube handover with a 30 second episode budget and a 10 second reset window, a sane starting shape for a two-arm task. Delete the bad ones rather than letting them teach: lerobot-edit-dataset rewrites the dataset in place and keeps the old copy next to it.
# drop episode index 24 (the 25th) from a recorded bimanual dataset
lerobot-edit-dataset \
--repo_id=${HF_USER}/bimanual-so-handover-cube \
--operation.type=delete_episodes \
--operation.episode_indices="[24]"
# the original is kept at <dataset>_old until you remove it yourselfWhich policies accept a doubled action vector
This is the question that decides whether the rig is worth building. The honest answer: three of the five trainable policies take 12 dimensions with no change at all, and the GR00T family takes a configuration file you write yourself.
| Policy | How it handles the action dim | What you change for 12 dims | Dataset format | Min episodes |
|---|---|---|---|---|
| ACT | Shapes read from the dataset features | Nothing | LeRobot v3.0 | 50 |
| SmolVLA | max_state_dim = 32, max_action_dim = 32, padded | Nothing | LeRobot v3.0 | 30 |
| Pi0.5 | max_state_dim = 32, max_action_dim = 32, padded | Nothing | LeRobot v3.0 | 50 |
| GR00T N1.7 | Named modality keys with index ranges | modality.json plus a modality config, registered under NEW_EMBODIMENT | LeRobot v2.0 or v2.1 | 50 |
| GR00T N1.5 | Same as N1.7 | Same as N1.7 | LeRobot v2.0 or v2.1 | 50 |

The padding numbers are not guesses. In lerobot 0.6.2 both SmolVLA and Pi0.5 declare max_state_dim: int = 32 and max_action_dim: int = 32, and pad state and action to that width. A bimanual SO-100 uses 12 of the 32. That headroom is why those architectures needed no redesign when people pointed them at two-arm rigs. Nor is two-arm SO-100 data exotic to SmolVLA: the community dataset list in its paper's appendix already includes two-arm recordings such as ZCM5115/so100_2Arm3cameras_movebox.
ACT is not merely compatible with bimanual data, it was written for it. ACT is the policy from the ALOHA paper, trained on those 14-dimensional demonstrations, and it has no max_action_dim at all: the shapes come from the dataset features. Its action chunking defaults are still chunk_size 100 and n_action_steps 100, the values AY-Robots sends alongside a 100,000 step ceiling; Seeed's double-arm guide trains those same defaults for 60,000 steps. For the shortest path from a bimanual dataset to a moving robot, ACT on SO-100 is it: about 80 M parameters, 20 ms per action step, an RTX 4090 tier GPU and no base checkpoint to fight with.
GR00T is the one that needs work
GR00T does not read a flat vector, it reads named slices. NVIDIA's shipped SO-100 example splits the six channels into a single_arm key over indices 0 to 5 and a gripper key over 5 to 6, with one ActionConfig per key: relative for the arm, absolute for the gripper. For two arms you write four slices instead of two. NVIDIA ships no bimanual SO-100 config, so both files below are yours to write.
// meta/modality.json for a 12-channel bimanual SO-100 dataset.
// This is what you write; NVIDIA ships only the single-arm version
// at examples/SO100/modality.json, with single_arm 0-5 and gripper 5-6.
{
"state": {
"left_arm": {"start": 0, "end": 5},
"left_gripper": {"start": 5, "end": 6},
"right_arm": {"start": 6, "end": 11},
"right_gripper": {"start": 11, "end": 12}
},
"action": {
"left_arm": {"start": 0, "end": 5},
"left_gripper": {"start": 5, "end": 6},
"right_arm": {"start": 6, "end": 11},
"right_gripper": {"start": 11, "end": 12}
},
"video": {
"top": {"original_key": "observation.images.top"},
"left_wrist": {"original_key": "observation.images.left_wrist"},
"right_wrist": {"original_key": "observation.images.right_wrist"}
},
"annotation": {
"human.task_description": {"original_key": "task_index"}
}
}The matching Python config registers those keys under NEW_EMBODIMENT, which Isaac-GR00T's embodiment tag enum documents as the finetuning tag for custom robots. One ActionConfig per modality key, in the same order: NVIDIA warns that a mismatch is applied silently, so a misordered list hands a gripper the relative representation meant for an arm and nothing complains. Background on embodiment tags is in the VLA overview.
# Extends examples/SO100/so100_config.py from Isaac-GR00T to two arms.
from gr00t.configs.data.embodiment_configs import register_modality_config
from gr00t.data.embodiment_tags import EmbodimentTag
from gr00t.data.types import (
ActionConfig, ActionFormat, ActionRepresentation, ActionType, ModalityConfig,
)
KEYS = ["left_arm", "left_gripper", "right_arm", "right_gripper"]
bi_so100_config = {
"video": ModalityConfig(delta_indices=[0],
modality_keys=["top", "left_wrist", "right_wrist"]),
"state": ModalityConfig(delta_indices=[0], modality_keys=KEYS),
"action": ModalityConfig(
delta_indices=list(range(0, 16)), # 16-step horizon, as in the SO-100 example
modality_keys=KEYS,
action_configs=[
ActionConfig(rep=ActionRepresentation.RELATIVE, type=ActionType.NON_EEF,
format=ActionFormat.DEFAULT), # left_arm
ActionConfig(rep=ActionRepresentation.ABSOLUTE, type=ActionType.NON_EEF,
format=ActionFormat.DEFAULT), # left_gripper
ActionConfig(rep=ActionRepresentation.RELATIVE, type=ActionType.NON_EEF,
format=ActionFormat.DEFAULT), # right_arm
ActionConfig(rep=ActionRepresentation.ABSOLUTE, type=ActionType.NON_EEF,
format=ActionFormat.DEFAULT), # right_gripper
],
),
"language": ModalityConfig(delta_indices=[0],
modality_keys=["annotation.human.task_description"]),
}
register_modality_config(bi_so100_config, embodiment_tag=EmbodimentTag.NEW_EMBODIMENT)Recording with lerobot 0.6.2 produces a v3.0 dataset, and the published bimanual SO-100 dataset carries codebase_version: "v3.0". The GR00T loader takes v2.0 or v2.1 and a v3.0 dataset crashes it, so convert down before you rent a GPU rather than after; dataset rejected as v3 is the page for that failure. Second gotcha, from NVIDIA's own note: move delta_indices off the 16-step horizon and you must regenerate the statistics with gr00t/data/stats.py, because meta/relative_stats.json was computed at shape (16, D) and normalisation then fails with an IndexError about a dimension mismatch. Pass --modality-config-path too, so the custom tag is registered before the stats are written.

Two routes to a trained bimanual policy
- Install lerobot with the hardware extras, run
lerobot-find-portfour times, label the arms. - Set motor IDs per arm, then calibrate through
bi_so_followerandbi_so_leaderso the _left and _right files land under the right id. - Teleoperate to confirm both arms track their leaders before recording anything.
- Record 30 to 50 episodes with
lerobot-record, check the videos, delete the bad ones withlerobot-edit-dataset. - Rent or own a GPU: ACT and SmolVLA fit a 24 GB card, GR00T N1.7 and Pi0.5 want an 80 GB A100 or H100.
- For GR00T: convert v3.0 down to v2.1, write modality.json and a modality config, register NEW_EMBODIMENT, regenerate stats.
- Serve the checkpoint next to the robot and write the client that splits the 12-vector back into left_ and right_ commands.
Nothing in licence fees, everything in time. The hours go into port discovery, calibration file archaeology, camera naming and the GR00T conversion. None of it is interesting and all of it has to be right.
- Build and wire the rig yourself. That part does not go away: the platform's teleoperation and desktop client are built around a single leader-follower SO-100.
- Record with
lerobot-recordas above. You end up with a LeRobot dataset, the unit the rest of the platform works in. - Point the training form at it, by Hugging Face repo id or from your own machine, and pick one of the five policies.
- The backend rents a GPU on the spot market by required VRAM, runs the trainer and writes checkpoints to object storage.
- Serve the checkpoint from an auto-provisioned inference pod. It carries an idle watchdog and destroys itself, so nothing keeps billing silently.
It helps on the second half: GPU rental, trainer defaults, checkpoints and a served endpoint. It does not build your rig, does not drive two leader arms, and the arm on /live is a single SO-100. For a turnkey bimanual product, this is not it. To skip the GPU and MLOps half, it is.
Compute cost does not change when you add the second arm. The same tiers apply: about 4 to 12 USD for a 3 to 6 hour run on the A100 or H100 tier that GR00T N1.7, GR00T N1.5 and Pi0.5 need, and about 1 to 3 USD for a 2 to 5 hour run on the RTX 4090 tier SmolVLA and ACT use. The breakdown is on the pricing page. What doubles is the hardware bill and the recording time.
- Tasks a single 5-DoF arm cannot do at all: holding a bag open while placing into it, stabilising an object while the other hand works on it, any handover.
- The reference literature is bimanual. ACT and ALOHA assume two arms, so your rig matches the papers instead of approximating them.
- No model-side change for ACT, SmolVLA or Pi0.5. Three of the five take the wider vector unmodified.
- The same 30 to 50 episode minimums and GPU tiers apply, so the training half does not get more expensive.
- Four arms, four power supplies, four serial ports, three or more cameras. Each is something that can be unplugged, mislabelled or fed the wrong voltage.
- Coordinated demonstrations are hard to produce consistently. The data problem grows faster than the action vector.
- GR00T needs a hand-written modality config and a v2.1 conversion, so the largest pretrained prior comes with the most setup.
- AY-Robots has no bimanual teleoperation or recording product. The rig and the operator workflow are yours.
What actually goes wrong
- Ports swap after a reboot.
/dev/ttyACM0is assigned in enumeration order, so the left arm becomes the right arm and everything you record is mirrored. Pin the devices with udev rules by serial number. See arm not detected. - A recalibration prompt on every launch. The calibration ids do not carry the bimanual prefix, or you are looking in the old per-model folder. Both covered above.
- Camera keys come out doubled.
left_left_wristin the dataset means you named the per-arm cameraleft_wrist. Rename and re-record, or the modality config will not match. - One arm sags on connect. Torque is disabled during calibration, and with four arms the odds that one is not in its rest position go up. Arm twitches then sags covers it.
- The policy moves one arm and parks the other. Usually the data: long idle stretches for one arm teach the policy that idling is correct. The bimanual version of the problem in collecting high-quality VLA training data.
- Joint indices scrambled in a GR00T run. The modality.json ranges do not match the left-then-right order the recorder wrote. Training converges on nonsense without raising an error.

The honest limits
Two things are worth saying plainly before you spend a weekend on this. First, latency. The control loop runs at 20 ms per action step for ACT up to 485 ms for Pi0.5, and inference latency has to sit next to the servos for anything fast. Public-internet round trips turn a working policy into a hesitant one, and two grippers in contact with the same object punish hesitation harder than one arm reaching for a static cube. Remote inference is viable for slow pick-and-place, not for a fast handover.
Second, this platform has no bimanual product. The five vision-language-action models on the training matrix are listed against four single arms, the browser teleoperation workflow drives one arm, and the arm at /live is a single SO-100. What transfers is everything downstream of the dataset: GPU rental, trainer defaults and a served checkpoint. A 12-channel LeRobot dataset is still a LeRobot dataset. If you wanted to click a button and get a two-armed robot, that is not on offer here.
Record 30 to 50 episodes of a cube handover, the same task as the published bimanual SO-100 dataset, and train SmolVLA on it. SmolVLA has the lowest minimum on the platform at 30 episodes, needs no format conversion, and pads your 12 channels into its 32-dimensional slot untouched. A run is 2 to 5 hours on a 24 GB card for roughly 1 to 3 USD. If it works, the harder tasks and the GR00T setup are worth it. If not, you learned that for the price of a coffee.
Five policies, compared on numbers that matter here
Parameters, GPU tier, inference latency and the minimum episode count for ACT, SmolVLA, Pi0.5, GR00T N1.5 and GR00T N1.7. Those four columns decide which model takes your 12-channel dataset without a rewrite.
Compare the five policiesDo I need four arms, or can I do bimanual with two?▾
You need two followers. The leaders are optional in that any input device works, but leader-follower is the only teleoperation mode LeRobot ships for SO arms, and joint-space demonstrations recorded from leader arms are what ACT was designed around. Two followers driven by something else is possible, just undocumented for SO-100.
Is the bimanual class called bi_so100_follower or bi_so_follower?▾
Both are real, at different times. The published bimanual SO-100 dataset still records robot_type bi_so100_follower in its metadata. In lerobot 0.6.2 on main, checked 24 August 2026, the class is BiSOFollower registered as bi_so_follower, with bi_so_leader on the teleoperator side. The name lost the 100 because one implementation now covers SO-100, SO-101 and SO-10X: SO100Follower and SO101Follower are plain aliases of SOFollower. The key layout did not change.
Does a bimanual dataset need twice as many episodes?▾
No one has measured this on SO-100 hardware, so any specific number would be a guess. What is documented: AY-Robots requires 30 episodes for SmolVLA and 50 for ACT, Pi0.5 and both GR00T versions, and the ALOHA paper recorded 50 demonstrations per real task, except Thread Velcro which got 100. Start at the minimum, check whether the policy coordinates or alternates, and add episodes for the failure you actually see.
Can I use one wrist camera instead of two?▾
Yes, and for many tasks you should. The SmolVLA paper reports two views for its SO-100 benchmarks, top and wrist, and says unused extra views were dropped during pretraining. More cameras means more USB bandwidth, more to go wrong and more input the policy has to learn to ignore. Add the second wrist camera when both grippers have contact events the overhead view cannot see.
Will a policy trained on one arm transfer to the bimanual rig?▾
Not directly. For ACT the shapes do not even match, because ACT takes its dimensions from the dataset. For SmolVLA and Pi0.5 the padded 32-dimensional slot is the same width either way, but channels 6 to 11 carried nothing during single-arm training, so the second arm has no learned behaviour. Treat the rig as a new embodiment and fine-tune on bimanual data.
Can I train a bimanual dataset on AY-Robots today?▾
The training side is dataset-driven, so a 12-channel LeRobot dataset from a Hugging Face repo id or your own machine goes through the same form as a single-arm one, under the same format rules: v3.0 for ACT, SmolVLA and Pi0.5, v2.0 or v2.1 for the GR00T family. What is missing is bimanual teleoperation and a bimanual recording client. You build and drive the rig; the platform handles training and serving.
Sources
- lerobot 0.6.2: BiSOFollower, the _left/_right id suffixes, left_/right_ key prefixing and top-level camera handling
- lerobot 0.6.2: SOFollower motor table, and SO100Follower / SO101Follower as aliases of it
- lerobot 0.6.2: BimanualMixin, lifecycle delegation to both arms
- lerobot-record: the canonical bimanual SO recording command, in the module docstring
- lerobot-teleoperate: the bimanual SO teleoperation example
- SmolVLAConfig: max_state_dim = 32, max_action_dim = 32, chunk_size 50
- PI05Config: max_state_dim = 32, max_action_dim = 32, state and action padded to that width
- ACTConfig: chunk_size 100, n_action_steps 100, dim_model 512, and no max action dimension
- bimanual-so100-handover-cube: 25 episodes, 13,220 frames, 30 fps, v3.0, 12-channel action, robot_type bi_so100_follower
- Zhao et al., Learning Fine-Grained Bimanual Manipulation with Low-Cost Hardware (ALOHA and ACT, arXiv 2304.13705)
- Shukor et al., SmolVLA: A Vision-Language-Action Model for Affordable and Efficient Robotics (arXiv 2506.01844)
- SO-ARM100 / SO-ARM101: two-arm parts list, and STS3215 7.4 V at 16.5 kg.cm versus 12 V at 30 kg.cm
- Isaac-GR00T: modality configuration, the ActionConfig ordering rule and the stats regeneration requirement
- Isaac-GR00T: the shipped single-arm SO-100 modality config, next to its meta/modality.json
- Seeed Studio: Complete Guide to Double-Arm SO-ARM Training, including the _left/_right calibration FAQ entry
Ready for high-quality robotics data?
AY-Robots connects your robots to skilled operators worldwide.
Get Started