The AY-Robots tutorial page for recording your first LeRobot dataset with an SO-100 arm
LeRobotSO-100Dataset RecordingTeleoperationImitation Learning

Record Your First LeRobot Dataset With an SO-100

AY-Robots ResearchAugust 23, 202616 min read

Record a usable LeRobot dataset with an SO-100: calibration, leader-follower teleoperation, the real lerobot-record flags and defaults, camera setup, episode counts, and the defects that ruin a run.

An SO-100 follower, a leader arm of the same design and two USB cameras can fine-tune a policy in an afternoon. The same bench can as easily produce sixty episodes that look healthy in a file browser and waste a six-hour GPU run. The difference is rarely the model; it is what happened between the servos and the parquet file.

Here is the manual route, then the shorter one. Every command is from lerobot 0.6.1, released 3 August 2026 and current on PyPI. It moved to console entry points, so tutorials that run python lerobot/scripts/control_robot.py describe a file that no longer exists.

The short version

  • lerobot 0.6.1 records v3.0; GR00T N1.7 and N1.5 want v2.1. Settle the format before you press record.
  • Four commands: lerobot-find-port, lerobot-setup-motors, lerobot-calibrate, lerobot-record. Carry the same --robot.id and --teleop.id from calibration into the recording session.
  • Real defaults: 30 fps, 60 s per episode, 60 s reset, 50 episodes, about 100 minutes of wall clock.
  • Minimum episodes here: 30 for SmolVLA, 50 for the rest.
  • Diversity beats volume. Datasets die of four things: swapped camera indices, dropped or frozen frames, a joint parked at its limit, an unreadable task string.

What a recording session captures

A LeRobot dataset is not a folder of videos but a time-indexed table with video attached: every control-loop tick writes one row holding the commanded action, the state the follower reached, one frame per camera, a timestamp and indices. The policy sees only those columns. The schema of lerobot/svla_so100_pickplace, read from its meta/info.json.

FeaturedtypeShapeWhat it is
actionfloat32[6]joint targets from the leader arm
observation.statefloat32[6]joint positions the follower reached
observation.images.topvideo[480, 640, 3]scene camera, MP4 (av1 here)
observation.images.wristvideo[480, 640, 3]wrist camera, same rate
timestampfloat32[1]seconds since episode start
frame_index, episode_index, index, task_indexint64[1]auto-populated bookkeeping

The joints are main_shoulder_pan, main_shoulder_lift, main_elbow_flex, main_wrist_flex, main_wrist_roll and main_gripper: the SO-100's six degrees of freedom. Action and state share a shape because leader-follower teleoperation records a target and the position reached one step later. That gap is information: where the arm fought gravity or a stuck object. Those strings are that dataset's. A session recorded with 0.6.1 today writes shoulder_pan.pos through gripper.pos, ids 1 to 6 on the bus: same six joints, different keys, which matters the moment a config addresses a feature by name.

A yardstick from a real dataset

That dataset holds 50 episodes and 19,631 frames at 30 fps: about 393 frames, or 13 seconds, per episode. If yours average a minute, you are doing something harder or recording dead time at both ends.

The AY-Robots glossary entry for the LeRobot dataset format, showing the directory layout and metadata files
What lives in data/, videos/ and meta/, and which policies read which version.

What you need on the bench

ItemDetailNote
Follower armSO-100, six Feetech STS3215 servosabout 110 to 150 EUR in parts
Leader arma second SO-100, gears removedgears stripped from all six leader motors: encoder only, less friction
Powermatched to the 7.4 V STS3215 variant in the bill of materialssee the warning below
Camerastwo USB cameras, 640x480 at 30 fpsone scene view, one on the wrist
HostPython 3.12 or newer, ffmpegrequires-python >= 3.12
Hub accountHugging Face write tokenoptional with --dataset.push_to_hub=false
7.4 V, not 12 V

The STS3215 comes in two versions: the SO-ARM100 README rates the 7.4 V version at 16.5 kg.cm stall torque measured at 6 V and the 12 V version at 30 kg.cm, and notes that taking the 12 V motors also means buying a 12 V 5 A+ supply instead of the 5 V one. The bill of materials lists 7.4 V servos. Feeding 12 V to servos rated 7.4 V destroys them, so read the motor label before you wire anything. Servo not responding.

If the arm is not built yet, that is a separate evening: start at SO-100 getting started and the complete SO-100 setup guide. If you have not bought anything, read the SO-100 against SO-101 comparison first: the SO-101 is the newer revision with improved wiring and no gear-removal step, and the recording workflow is identical.

Install lerobot 0.6.1

bash
conda create -y -n lerobot python=3.12
conda activate lerobot

# TorchCodec is the default video decoder and needs ffmpeg
conda install ffmpeg -c conda-forge

# core_scripts = dataset + hardware + viz extras (record, replay, calibrate)
# feetech     = SDK for the STS3215 bus servos in the SO-100
pip install 'lerobot[core_scripts,feetech]'

lerobot-info
lerobot-info prints a system summary, including the ffmpeg version it can find on PATH.

Extras trip people up most. pip install lerobot installs core ML dependencies only, nothing that talks to a robot. Koch arms need dynamixel instead of feetech. If your shell has never heard of lerobot-record, this is why.

Ports, motor IDs and calibration

Three one-off steps stand between parts and a working teleop loop. Calibration makes a policy trained on your arm run on somebody else's, mapping raw encoder counts onto a shared joint convention.

  1. 1
    Find the USB port of each arm

    Run it with both arms plugged in, unplug the one you are identifying when prompted, and note which port disappears. On Linux you may need sudo chmod 666 /dev/ttyACM0.

    bash
    lerobot-find-port
    # Finding all available ports for the MotorsBus.
    # Ports before disconnecting: ['/dev/ttyACM0', '/dev/ttyACM1']
    # Remove the USB cable from your MotorsBus and press Enter when done.
    # The port of this MotorsBus is '/dev/ttyACM1'
    # Reconnect the USB cable.
  2. 2
    Write motor IDs and baudrates

    Ids are written one motor at a time, and the docs are strict about how: connect exactly one motor to the controller board, not yet daisy-chained to any other. The script walks the chain backwards, prompting for the gripper first and giving it id 6, then wrist_roll as 5, down to shoulder_pan as 1. Do it before assembly.

    bash
    lerobot-setup-motors \
        --robot.type=so100_follower \
        --robot.port=/dev/ttyACM0
    
    lerobot-setup-motors \
        --teleop.type=so100_leader \
        --teleop.port=/dev/ttyACM1
  3. 3
    Calibrate both arms

    Move every joint to the middle of its range, press Enter, then sweep each through its full range. The id becomes the profile filename.

    bash
    lerobot-calibrate \
        --robot.type=so100_follower \
        --robot.port=/dev/ttyACM0 \
        --robot.id=my_so100_follower
    
    lerobot-calibrate \
        --teleop.type=so100_leader \
        --teleop.port=/dev/ttyACM1 \
        --teleop.id=my_so100_leader
  4. 4
    Teleoperate before recording anything

    The acceptance test for everything above. If teleoperation is jerky, mirrored, or one joint will not follow, recording preserves that in 50 episodes.

    bash
    lerobot-teleoperate \
        --robot.type=so100_follower \
        --robot.port=/dev/ttyACM0 \
        --robot.id=my_so100_follower \
        --teleop.type=so100_leader \
        --teleop.port=/dev/ttyACM1 \
        --teleop.id=my_so100_leader \
        --display_data=true
Where calibration lands, and why the id matters

Profiles go to $HF_LEROBOT_CALIBRATION, default ~/.cache/huggingface/lerobot/calibration, and the id is the lookup key. Give lerobot-record a calibrated id and it offers Enter to reuse the profile or c to redo it. Give it an unknown id and there is no file, so it drops into calibration mid-session.

Cameras decide what the policy sees

bash
lerobot-find-cameras opencv   # or: lerobot-find-cameras realsense

# --- Detected Cameras ---
# Camera #0:
#   Name: OpenCV Camera @ 0
#   Type: OpenCV
#   Id: 0
#   Backend api: AVFOUNDATION
#   Default stream profile:
#     Format: 16.0
#     Width: 1920
#     Height: 1080
#     Fps: 15.0
Run this every session: the docs warn these identifiers may change after a reboot or a replug, depending on the operating system.

Two views, and where they sit matters: a fixed scene camera covering the workspace, and a wrist camera near the end-effector showing what the gripper is about to touch. The LeRobot community-datasets checklist asks for preferably two views at 480x640 / 720p or better, a static background, neutral stable lighting, and the leader arm and human limbs out of frame. The recording guide adds the rule of thumb: you should be able to do the task yourself by only looking at the camera images.

The camera index is not a stable identity

OpenCV indices come from enumeration order, so a reboot or replug can make index 0 and 2 trade places and put the wrist view in the top slot for a whole session. lerobot says so itself: its camera class takes a device path as well as an integer, and warns that indices are unstable across reboots or port changes, especially on Linux. Point index_or_path at the udev symlink under /dev/v4l/by-id/, which follows the device rather than the enumeration order. This is the most common way a dataset ends up internally inconsistent, and training cannot repair it. Camera not detected.

The record command and every flag

bash
HF_USER=$(NO_COLOR=1 hf auth whoami | awk -F': *' 'NR==1 {print $2}')

lerobot-record \
    --robot.type=so100_follower \
    --robot.port=/dev/ttyACM0 \
    --robot.id=my_so100_follower \
    --robot.cameras="{ top: {type: opencv, index_or_path: 0, width: 640, height: 480, fps: 30}, wrist: {type: opencv, index_or_path: 2, width: 640, height: 480, fps: 30}}" \
    --teleop.type=so100_leader \
    --teleop.port=/dev/ttyACM1 \
    --teleop.id=my_so100_leader \
    --display_data=true \
    --dataset.repo_id=${HF_USER}/so100_pick_cube \
    --dataset.single_task="Pick the red cube and drop it in the box" \
    --dataset.num_episodes=50 \
    --dataset.fps=30 \
    --dataset.episode_time_s=25 \
    --dataset.reset_time_s=10 \
    --dataset.streaming_encoding=true \
    --dataset.encoder_threads=2
The camera dict is one shell-quoted string; the nested braces are not shell syntax.

The defaults below come from src/lerobot/configs/dataset.py on main, not a tutorial. Several are not what people assume.

FlagDefaultWhat it does
--dataset.repo_idemptyname; timestamp appended by default
--dataset.single_taskemptytask string stored with every episode
--dataset.root$HF_LEROBOT_HOME/repo_idwrite path, default ~/.cache/huggingface/lerobot/
--dataset.fps30control loop rate and dataset frame rate
--dataset.episode_time_s60seconds before an episode auto-advances
--dataset.reset_time_s60scene reset; arm moves, nothing stored
--dataset.num_episodes50episodes recorded this session
--dataset.push_to_hubtrueupload at session end; false stays local
--dataset.streaming_encodingfalse in the dataclass, true in the docs tableencode during capture; set it explicitly
--dataset.encoder_queue_maxsize30buffered frames per camera, ~1 s at 30 fps
--dataset.encoder_threadsnull (codec decides)threads per encoder; lower if capture stutters
--dataset.no_stampfalsekeep repo_id exactly as typed
--resumefalseappend to an existing dataset; needs --dataset.root
Two flags that cost an hour by surprise

Your dataset is not called what you typed. lerobot appends a date-time tag, so so100_pick_cube becomes so100_pick_cube_20260823_141530. Use --dataset.no_stamp=true for a stable name. Resume counts additions, not totals. With --resume=true, --dataset.num_episodes counts additional episodes and --dataset.root becomes mandatory. Ask for 50 on a 30-episode dataset and you get 80.

Keyboard control during a session

  • Right arrow or n: end the episode or reset phase early. The key you use most, because a clean grasp rarely needs 25 seconds.
  • Left arrow or r: discard the episode and redo it. A bad take costs nothing now and a lot later.
  • Escape or q: stop the session, finish encoding, upload.
  • These work on X11, Wayland and headless SSH: with no global key backend, lerobot-record reads the same keys from the controlling terminal. The letters survive laggy SSH links, where arrow sequences split.
  • Keyboard teleoperation is different and does need a global backend: X11, Windows, or macOS with Accessibility.

How many episodes, and what a good one looks like

The recording guide suggests at least 50 episodes for a first task, about 10 per object location. The policy pages list a minimum per model, below which a run is not worth the GPU time.

PolicyMin episodesDataset formatGPU tierCost per run
SmolVLA30LeRobot v3.0RTX 4090 or any 24 GB cardabout 1 to 3 USD
ACT50LeRobot v3.0RTX 4090 or any 24 GB cardabout 1 to 3 USD
GR00T N1.750LeRobot v2.0 or v2.1A100 80 GB or H100 80 GBabout 4 to 12 USD
GR00T N1.550LeRobot v2.0 or v2.1A100 80 GB or H100 80 GBabout 4 to 12 USD
Pi0.550LeRobot v3.0A100 80 GB or H100 80 GBabout 4 to 12 USD

The better question is how many of what. Data Scaling Laws in Imitation Learning for Robotic Manipulation (Lin et al., 2024) collected over 40,000 demonstrations and ran more than 15,000 real-world rollouts. Generalisation followed a roughly power-law relationship with the number of environments and objects, and past a threshold per environment or object, additional demonstrations had minimal effect. On one bench: move the object, change the lighting, swap the cube, rather than repeat one take.

Leader-follower teleoperation as a data source
Advantages
  • Continuous joint trajectories a servo can reproduce, unlike keyboard or gamepad
  • Action and state share a coordinate convention, so the policy learns a target it can command directly
  • A 25 second episode plus a 10 second reset is roughly 100 episodes an hour
  • The operator feels the follower stall or bind, so faults surface before data is committed
Trade-offs
  • A second arm roughly doubles the parts cost
  • Demonstrations inherit operator habits; Mandlekar et al. found policy quality depends heavily on demonstration quality
  • The leader is sampled at loop rate, so pauses become near-identical rows that teach the policy to wait
  • Nothing enforces consistency between sessions: a camera nudged 5 cm is a hidden distribution shift

A good episode is boring: repeatable home pose, one thing done, ended once the object is in the bin, task string in the 25 to 50 characters the checklist recommends. Pick the red cube and drop it in the box is a task string; task1 is the anti-pattern the checklist names explicitly. Vague annotations head its list of problems, and they matter most for vision-language-action models, where the string is a model input, not a filename.

Defects that quietly ruin a dataset

None throw an exception. All survive into training, surfacing as a loss curve that looks fine and a robot that does nothing. Check while the scene is set up.

DefectWhat it looks likeWhere it comes fromHow to catch it
Swapped camera viewswrist image under the top keyindex reassignment after a repluglerobot-find-cameras each session; by-id paths
Frozen framesthe same image for dozens of rowscamera stops delivering; loop repeats the last framescrub it in lerobot-dataset-viz
Dropped framesrow count below fps times secondsqueue overflows, drops rather than blocks'Encoder queue full' in the log; rows vs fps times duration
Joint at its limitone joint flat at min or maxleader range exceeds the follower's, or a bad middle poseper-joint min/max in ds.meta.stats; lerobot-find-joint-limits beforehand
Image and action out of stepthe policy anticipates or lagscameras at a different fps than the loopkeep every camera at --dataset.fps
Dead timelong runs of identical action rowsoperator paused with the recorder runningshare of consecutive identical action rows
Unusable task stringtask1, demo2, testtyping fastmeta/tasks.parquet in v3.0 (it was meta/tasks.jsonl in v2.1); fix with lerobot-edit-dataset modify_tasks
Dropped frames hide themselves

The encoder holds a bounded queue per camera, 30 frames by default. When it cannot keep up, frames are dropped rather than blocked: capture continues and nothing crashes. You get Encoder queue full for {camera}, dropped N frame(s) and a per-camera total at episode end. The lerobot threshold: about 5 percent missing means an overloaded system, 2 percent is expected startup load. Fixes in order: --display_data=false, lower --dataset.encoder_threads, vcodec=h264, streaming off.

One caveat: the streaming-encoding guide's table lists the default as True, while the dataclass on main reads streaming_encoding: bool = False. Docs and code disagree, so set it explicitly; lerobot logs a hint recommending it whenever it starts with the flag off.

Check the dataset before renting a GPU

The acceptance test from the docs: compare video duration against the episode duration the CLI reported, and confirm the row count equals fps times duration. Per episode, not on the total.

python
from lerobot.datasets import LeRobotDataset

ds = LeRobotDataset("your-user/so100_pick_cube_20260823_141530")
print("fps:", ds.fps, "frames:", ds.num_frames)

# In v3.0 the per-episode records live in meta/episodes/ as chunked parquet:
# lengths, tasks and offsets into the shared parquet and mp4 shards.
# They load through the datasets stack, so this is a datasets.Dataset --
# use .column_names and integer indexing, not pandas .columns / .head().
eps = ds.meta.episodes
print(eps.column_names)
print(eps[0])

# Global feature statistics, including per-joint min and max.
# A joint whose min equals its max never moved. A joint sitting at a
# hard limit for most of the run is the one that will stall the policy.
print(ds.meta.stats["observation.state"])
An episode far off fps times duration is a candidate for deletion, not training.

Then look at it. lerobot-dataset-viz replays an episode frame by frame with joint traces beside the camera views, in Rerun or Foxglove. Swapped cameras and frozen frames show up in ten seconds. People skip this step.

bash
# Replay one episode with camera views and joint traces
lerobot-dataset-viz \
    --repo-id your-user/so100_pick_cube_20260823_141530 \
    --episode-index 0

# Foxglove instead, for a seekable, scrubbable timeline
lerobot-dataset-viz \
    --repo-id your-user/so100_pick_cube_20260823_141530 \
    --episode-index 0 \
    --display-mode foxglove

# Drop the episodes that did not survive review
lerobot-edit-dataset \
    --repo_id your-user/so100_pick_cube_20260823_141530 \
    --new_repo_id your-user/so100_pick_cube_clean \
    --operation.type delete_episodes \
    --operation.episode_indices "[3, 17, 41]"
lerobot-edit-dataset also does split, merge, remove_feature, modify_tasks and stats recomputation. Delete generously: a bad episode costs one episode; keeping it costs every run trained on it.
The AY-Robots dataset directory listing public LeRobot datasets with episode counts and formats
How comparable datasets are sized and annotated.

v2.1 or v3.0: decide before you record

v2.1 wrote one parquet and one MP4 per episode. v3.0 concatenates many episodes into shared shards and rebuilds boundaries from metadata, so info.json carries path templates like data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet instead of an episode number. The upstream justification is fewer, larger files: faster initialisation and less file-system pressure at scale.

LeRobot v2.1LeRobot v3.0
Layoutone parquet and one MP4 per episodemany episodes per shard
Episode metadataJSONL fileschunked parquet under meta/episodes/, via the datasets stack
Streaming from the Hubnoyes, via StreamingLeRobotDataset
Written by lerobot 0.6.1noyes, what you get today
Read by GR00T N1.7 and N1.5yesno, must be converted down
Recording today, training GR00T tomorrow

lerobot 0.6.1 writes v3.0, but GR00T N1.7 and N1.5 read v2.0 or v2.1 and crash on it. Note the direction of travel: src/lerobot/scripts/ holds convert_dataset_v21_to_v30.py and nothing going the other way. Settle this before the session. Fix: dataset rejected as v3.

bash
# Upgrade an older v2.1 dataset to v3.0
python -m lerobot.scripts.convert_dataset_v21_to_v30 \
    --repo-id=your-user/so100_pick_cube

# By default it pushes the converted dataset back to the hub and tags it v3.0.
# To convert a local copy and keep it off the hub:
python -m lerobot.scripts.convert_dataset_v21_to_v30 \
    --repo-id=your-user/so100_pick_cube \
    --root=/path/to/dataset/directory \
    --push-to-hub=false
Quick for 50 episodes. Scale is a different job: lerobot's porting guide, for raw DROID into v3.0, budgets 7+ days of local processing and about 400 GB.

Two routes to the same dataset

Everything above, on your own machine: you own the USB enumeration, the ffmpeg build, the encoder tuning and the calibration files. The right route to understand the pipeline, run an unusual camera rig, or keep data local.

What this route costs

Time: an evening per arm to assemble, a fiddly first calibration, and a first session you throw away because a camera was in the wrong slot.

Record LeRobot datasets without wiring the pipeline yourself

The AY-Robots desktop client records episodes, camera streams and joint states in LeRobot format from a teleoperation session, then hands the dataset to the trainer.

Get the desktop client

From dataset to policy

Fifty clean episodes feeds every imitation learning run here. ACT trains from scratch on your task alone, about 80 M parameters at roughly 20 ms per action step, the only one of the five comfortable with fast motion. SmolVLA is about 450 M parameters on a 24 GB card. GR00T N1.7 is a roughly 3 B parameter foundation model where fine-tuning touches about 40 M parameters, needs an A100 or H100, and wants that v2.1 dataset.

Next, the guide for your combination: ACT on SO-100, SmolVLA on SO-100 or GR00T N1.7 on SO-100; for a first run, train your first policy is shorter. When the policy works on the bench but collapses the moment you move the table, that is a data problem: policy only works in one setup and collecting high-quality VLA training data go deeper on diversity.

How many episodes do I really need for a first working policy?

Thirty for SmolVLA, fifty for ACT, Pi0.5, GR00T N1.5 and N1.7, the minimums the AY-Robots trainers enforce. The LeRobot guide independently recommends at least 50 for a first task, around 10 per object location. Data-scaling work found generalisation scales with environments and objects rather than demonstration count, so a hundred takes of one scene is worse than fifty across five placements.

Do I need a leader arm, or can I teleoperate with a keyboard?

lerobot ships keyboard and gamepad teleoperators, so a leader arm is not strictly required, but it is strongly preferable: leader-follower gives continuous joint trajectories in the coordinate convention of the recorded action, while keyboard input produces stepped motion a policy learns as jerk. Keyboard teleoperation also needs a global key backend, so it fails on Wayland and headless.

Can I record on a Raspberry Pi or a small mini PC?

Yes, with tuning. The streaming-encoding guide has a low-resource bracket covering modern 4-core machines and the Raspberry Pi 5, and puts two cameras at 640x480 and 30 fps in its requires-some-tuning column. Its advice: stop the encoder competing with the capture loop, via --dataset.rgb_encoder.vcodec=h264 and --dataset.streaming_encoding=false. It rates two cameras at 640x480 as about 55 million pixels per second and two at 1920x1080 as about 373 million.

How do I know the dataset I just recorded is actually healthy?

Three cheap checks. Compare each episode's video duration against the duration the CLI reported and confirm the row count equals fps times that duration, per episode rather than on the total; that is the acceptance test lerobot's encoding guide gives. Read ds.meta.stats, where a joint whose min equals its max never moved. Then replay two or three episodes in lerobot-dataset-viz, the only way swapped views and frozen frames show up. On dropped frames the guide draws the line at roughly 5 percent missing; about 2 percent is normal transient load, often just startup.

My training job rejected the dataset as v3.0. What now?

GR00T N1.7 and N1.5 read LeRobot v2.0 or v2.1 and crash on v3.0, which is what lerobot 0.6.1 records. Either settle the format before training, or use a policy that reads v3.0 natively: Pi0.5, SmolVLA or ACT. lerobot ships a v2.1 to v3.0 converter and nothing in reverse.

Ready for high-quality robotics data?

AY-Robots connects your robots to skilled operators worldwide.

Get Started