The AY-Robots SO-100 hub page, the starting point for building the arm this article doubles
bimanualso-100lerobotactdata-collectionteleoperation

Bimanual SO-100: Running Two Arms as One System

AY-Robots ResearchAugust 23, 202619 min read

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.

python
# 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,
)
Six motors, one bus. The action feature names are just these keys with .pos appended.

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.

ConceptSingle SO-100bi_so_follower
Serial ports12, one per arm, set independently
Motor IDs1 to 61 to 6 on each bus, they do not clash
Observation keysshoulder_pan.pos ... gripper.posthe same six, prefixed left_ and right_
Action keys612
Calibration files12, written as <id>_left.json and <id>_right.json
Camerasflat dictper-arm keys get prefixed, top-level keys do not
is_connectedbus and camerasleft 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.

text
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, av1
Feature layout copied out of meta/info.json of the published bimanual SO-100 dataset.

ALOHA, 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.

RigArmsPer-arm DoFAction dimCamerasControl rate
ALOHA (2023 paper)2 ViperX6 joints + gripper = 714450 Hz
SO-100 single15 joints + gripper = 661 to 2 typical30 fps typical in LeRobot
SO-100 bimanual25 joints + gripper = 6123 in the published dataset30 fps in the published dataset
Twelve dimensions is not the hard part

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.

ItemOne leader-follower pairBimanual (two pairs)
Arms24
STS3215 servos1224
Waveshare motor control boards24
Power supplies24
USB serial devices to keep straight24
Cameras1 to 23 or more (two wrist, one overhead)
Parts cost, AY-Robots figure per SO-100 armabout 110 to 150 EURfour arms, plus cameras and a powered hub
7.4 V servos, 7.4 V supply, every time

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.

  1. 1
    Find 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.

    bash
    pip 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'
  2. 2
    Set 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.

    bash
    lerobot-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
  3. 3
    Calibrate 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.

    bash
    lerobot-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
  4. 4
    Verify 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.

    bash
    ls ~/.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
The trap that eats a day: calibration file names

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.

bash
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=true
Adapted from the bimanual example in the docstring of lerobot_teleoperate.py, lerobot 0.6.2, with the macOS port names replaced by Linux ones.

The 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.

bash
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=2
Verbatim from the lerobot-record docstring, lerobot 0.6.2 on main. Copy the shape, but read the camera note below before you copy the camera names.

Camera 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 declareOnKey in the datasetNote
top--robot.camerastopStays unprefixed. This is how you get one shared overhead view.
wrist--robot.left_arm_config.camerasleft_wristThe side prefix is added for you.
wrist--robot.right_arm_config.camerasright_wristSame rule on the right.
top--robot.left_arm_config.camerasleft_topWhat the upstream lerobot-record example produces.
left_wrist--robot.left_arm_config.camerasleft_left_wristWhat Seeed's guide produces. Name it wrist, not left_wrist.
The AY-Robots recording tutorial page explaining how to capture a LeRobot dataset from a teleoperation session
The recording tutorial on AY-Robots. The episode discipline it describes is the same for one arm or two; only the command grows.
Four USB cameras on one controller is where frames go missing

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.

bash
# 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 yourself
Pruning is cheaper than retraining on a bad demonstration.

Which 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.

PolicyHow it handles the action dimWhat you change for 12 dimsDataset formatMin episodes
ACTShapes read from the dataset featuresNothingLeRobot v3.050
SmolVLAmax_state_dim = 32, max_action_dim = 32, paddedNothingLeRobot v3.030
Pi0.5max_state_dim = 32, max_action_dim = 32, paddedNothingLeRobot v3.050
GR00T N1.7Named modality keys with index rangesmodality.json plus a modality config, registered under NEW_EMBODIMENTLeRobot v2.0 or v2.150
GR00T N1.5Same as N1.7Same as N1.7LeRobot v2.0 or v2.150
The AY-Robots policies page comparing the five trainable models by parameter count, GPU tier, inference latency and minimum episodes
The five policies side by side. The minimum-episode column is the one that decides how long your bimanual recording session is.

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.

json
// 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 index ranges follow the left-then-right ordering bi_so_follower writes. The original_key values must match the camera keys your recorder actually produced, prefixes included.

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.

python
# 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)
Four modality keys, four ActionConfig entries, same order. The relative-for-arm, absolute-for-gripper split is NVIDIA's own choice in the single-arm SO-100 example.
GR00T wants v2.1, and your bimanual dataset is v3.0

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.

The AY-Robots training matrix with five policies as rows and four supported arms as columns, each cell linking to a specific guide
The training matrix on /train. The columns are single arms; a bimanual dataset still goes through the same five model rows.

Two routes to a trained bimanual policy

  1. Install lerobot with the hardware extras, run lerobot-find-port four times, label the arms.
  2. Set motor IDs per arm, then calibrate through bi_so_follower and bi_so_leader so the _left and _right files land under the right id.
  3. Teleoperate to confirm both arms track their leaders before recording anything.
  4. Record 30 to 50 episodes with lerobot-record, check the videos, delete the bad ones with lerobot-edit-dataset.
  5. 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.
  6. For GR00T: convert v3.0 down to v2.1, write modality.json and a modality config, register NEW_EMBODIMENT, regenerate stats.
  7. Serve the checkpoint next to the robot and write the client that splits the 12-vector back into left_ and right_ commands.
What this costs you

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.

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.

Is a second arm worth it for your task?
What the second arm buys you
  • 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.
What it costs you
  • 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/ttyACM0 is 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_wrist in the dataset means you named the per-arm camera left_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 AY-Robots failure-mode index listing common robot arm, camera and training problems with a fix page for each
The failure-mode index. Most of these are single-arm problems that simply happen twice as often on a bimanual rig.

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.

A reasonable first bimanual project

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 policies
Do 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.

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started