Skip to content

Teleoperate and Record Datasets

Armnet lets you drive a managed cell's robot in real time from LeRobot leader arm(s) plugged into your own machine, and — when you're ready — record that teleoperation into a LeRobot dataset on the cell. Two paired commands cover the workflow:

Command What it does Records data?
armnet-lerobot-teleop Drive the remote follower and watch it live in Rerun. No
armnet-lerobot-record The same teleop loop, but the cell records each episode into a LeRobotDataset and pushes it to the Hub. Yes

Both stream the follower's cameras and joint state back into a live Rerun viewer so you can see what the remote arm is doing as you move the leader. Use teleop alone to validate a cell, line up a scene, or rehearse a manipulation; switch to record once the motion is good.

How it works

leader arm (your desk)   sampled at ~60Hz by the CLI
                           -> /jobs/{job_id}/teleop websocket (freshest-wins)
orchestrator             forwards each action to the job's cell
cell follower            applies the freshest teleop action each control tick
                           (armnet-lerobot-record also writes obs/action
                            pairs into a LeRobotDataset on the cell)
your machine             follower cameras + joints stream back over Rerun

The leader is sampled faster than the follower's control loop (60Hz vs ~20Hz) so the cell always has a near-current command — a dropped or late frame never makes the follower wait a full tick. Recorded data never touches your machine: the dataset is built on the cell and pushed to the Hub from there.

Prerequisites

  • A LeRobot leader arm (e.g. an SO-101 leader) plugged into your machine. For a bimanual YAM cell, plug in both GELLO leaders.
  • The teleop extra, which brings in LeRobot for the local leader:
pip install 'armnet-client[teleop]'   # or: uv pip install 'armnet-client[teleop]'
  • A configured API key (see Installation).
  • A leader arm that is already calibrated. The CLI does not recalibrate on connect; calibrate it once with LeRobot's standard tooling and pass its teleoperator id so the calibration is found.

The leader arm connection

Both commands take the same --teleop.* flags, which mirror what LeRobot needs to construct a teleoperator:

Flag Default Purpose
--teleop.port (none) Serial port of the local single-arm leader, e.g. /dev/ttyACM0.
--teleop.left-port (none) Serial port of the local left leader for bimanual teleop.
--teleop.right-port (none) Serial port of the local right leader for bimanual teleop.
--teleop.id (none) LeRobot teleoperator id, used to locate the leader's calibration.
--teleop.fps 60 Leader sampling rate in Hz (intentionally higher than the follower loop).

Provide either --teleop.port for a single-arm cell, or both --teleop.left-port and --teleop.right-port for a bimanual cell. Do not pass all three.

Find the leader's serial device with:

ls -l /dev/serial/by-id/

Remote teleop supports lerobot/so-101 and lerobot/bimanual_yam. YAM GELLO leaders use --teleop.type implicitly via --armnet.embodiment=lerobot/bimanual_yam.

Teleoperate (no recording)

armnet-lerobot-teleop \
  --teleop.port=/dev/ttyACM0 \
  --teleop.id=my_leader \
  --armnet.duration_s=300

Bimanual YAM / cell-biyam-01

uv run armnet-lerobot-teleop \
  --teleop.left-port=/dev/serial/by-id/usb-1a86_USB_Single_Serial_5B3D046055-if00 \
  --teleop.right-port=/dev/serial/by-id/usb-1a86_USB_Single_Serial_5B3D044141-if00 \
  --teleop.left-id=yam_gello_left \
  --teleop.right-id=yam_gello_right \
  --armnet.embodiment=lerobot/bimanual_yam \
  --armnet.duration_s=300

The cell applies each command through yam_follower.send_action. Follower Damiao PD (kp_gains / kd_gains) and lerobot_max_step live on the Pi in robot_cell_biyam_1.json, not in the GELLO leader plugin. The leader has torque off; it only reports XL330 ticks. lerobot_max_step is in LeRobot normalized units per control tick (not radians). Both arm and gripper step limits default to null (disabled) for responsive teleoperation. Set either limit to a positive number to opt back into per-cycle clamping. Joint range limits and the separate gripper force limit remain active. Shoulder/elbow PD is slightly softer so those big joints are less twitchy.

GELLO IDs 5 and 6 are labeled wrist_roll and wrist_yaw in the leader plugin. On our hardware those names are swapped relative to the physical axes. Do not rename the keys: calibration files, follower CAN IDs, and dataset columns all use the same labels. Mapping is by motor ID.

GELLO leaders plug into this machine (USB serial). The Docker image that armnet-lerobot-teleop builds is only the cell runtime (linux/amd64 YAM followers). It does not wrap the GELLOs.

GELLO gravity and trigger assistance

The trlc_dk1_v1 hardware profile is the default for bimanual YAM teleop. Both leaders therefore start with identified gravity assistance and a gentle spring-open squeeze trigger without any sysid flags:

uv run armnet-lerobot-teleop \
  --teleop.left-port=/dev/cu.usbserial-LEFT \
  --teleop.right-port=/dev/cu.usbserial-RIGHT \
  --teleop.left-id=yam_gello_left \
  --teleop.right-id=yam_gello_right \
  --armnet.embodiment=lerobot/bimanual_yam

The profile is versioned in the lerobot_yam package's gravity_profiles.py; it keeps the complete identified system together: signs, physical ranges, chain-composed offsets, effective link masses, assisted joints (shoulder_lift, elbow_flex, wrist_flex), gain 0.8, current cap 250 mA, and trigger current 80 mA. These are effective model parameters for the TRLC-DK1 build, not generic YAM constants and not literal measured part masses. The active YAM GELLO URDF from FACTR still supplies the serial-chain kinematics and COM locations.

For a differently built GELLO, add and select another named profile once its sysid is stable; a profile may also point at a different packaged URDF. Individual CLI overrides remain available for bring-up, but normal operation should select one profile rather than copying a long list of coupled constants. Use --teleop.left-gravity-profile / --teleop.right-gravity-profile when a bimanual pair contains different leader builds. Use --teleop.gravity-profile=passive (or --teleop.no-gravity-assist --teleop.no-gripper-return) for read-only leaders.

The trigger runs in current-based position mode: its target is the calibration high-tick endpoint (physical open), while the current limit keeps it easy to squeeze and hold closed. Releasing it makes it spring open again. Active assistance uses a 200 ms motor-side bus watchdog, zeroes current and disables torque on disconnect, and latches off on control errors, motor hardware faults, or a temperature of 50 °C.

Identify a new build before adding its profile

Use gello_gravity_sysid, gello_gravity_hold, and --teleop.gravity-dry-run for new hardware. Validate one side/joint at a time with --teleop.assist-side / --teleop.gravity-joints; never copy offsets without their matching signs and ranges.

On first use, calibrate each GELLO once (range of motion). The teleop CLI does not recalibrate; it looks up files by --teleop.left-id / --teleop.right-id under LeRobot's calibration dir (~/.cache/huggingface/lerobot/calibration/teleoperators/yam_leader/ by default):

uv run lerobot-calibrate \
  --teleop.type=yam_leader \
  --teleop.port=/dev/cu.usbserial-XXXX \
  --teleop.id=yam_gello_left

On macOS the documented /dev/serial/by-id/... paths do not exist. Use /dev/cu.wchusbserial* or /dev/cu.usbserial* (cu, not tty).

The same command builds on Apple Silicon. Cells are x86, so a non-x86 machine builds linux/amd64 without ARMNET_DOCKER_PLATFORM. Set that variable only to override the detected platform. You can still build on a workstation and pass --image from the Mac:

# workstation, no GELLOs needed:
uv run armnet-lerobot-teleop \
  --armnet.embodiment=lerobot/bimanual_yam \
  --build-only

# Mac, GELLOs plugged in:
uv run armnet-lerobot-teleop \
  --teleop.left-port=/dev/cu.usbserial-LEFT \
  --teleop.right-port=/dev/cu.usbserial-RIGHT \
  --teleop.left-id=yam_gello_left \
  --teleop.right-id=yam_gello_right \
  --armnet.embodiment=lerobot/bimanual_yam \
  --image=<printed registry ref>

A Rerun viewer opens, the job is submitted to a free cell of that embodiment, and once it starts you drive the remote follower by moving your leader.

Teleop and record on a manual cell do not wait in the FMS queue. Pass --armnet.task for that cell's task and the job starts immediately when a matching cell is free. It is refused if none is free. Policy evals of the same task still wait until an operator presses Dispatch.

Useful flags:

Flag Default Purpose
--armnet.embodiment lerobot/so-101 Robot embodiment.
--armnet.task (none) Cell task slug; omit to let any cell of the embodiment pick the job up.
--armnet.fps 20 Follower control rate on the cell.
--armnet.duration_s 300 How long the teleop session runs.
--goal-velocity-limit cell JSON Per-job SO-101 Goal_Velocity cap in raw STS3215 units (1–3250).
--acceleration-limit cell JSON Per-job SO-101 Acceleration cap in raw STS3215 units (1–254).
--lightbox-brightness cell JSON Lightbox brightness percent 0–100. Omit to use the cell default.
--lightbox-frequency cell JSON Lightbox PWM frequency in Hz. Omit to use the cell default.
--secret ENV=SECRET_NAME — Inject a stored secret as an env var in the cell container (repeatable).
--timeout-seconds (derived) Wall-clock cap on the job; derived from the duration when omitted.

The servo-limit flags are also available on armnet-lerobot-record. They use raw register values and can only tighten the cell-configured caps; both are rejected for bimanual YAM. A torque re-enable restores the cell caps, so these job overrides are intended for uninterrupted sweep runs rather than persistent safety configuration.

Record a dataset

armnet-lerobot-record runs the same teleop loop, but the cell records each episode into a LeRobotDataset and pushes it to the Hugging Face Hub when the session ends:

armnet-lerobot-record \
  --teleop.port=/dev/ttyACM0 \
  --teleop.id=my_leader \
  --dataset.repo_id=my_user/so101_pick_place \
  --dataset.single_task="Grab the black cube" \
  --dataset.num_episodes=10 \
  --secret HF_TOKEN=huggingface-token

For BiYAM, use the same GELLO ports and calibration IDs as teleop and set the dataset rate to the YAM follower's 20 Hz loop. --build-only builds and pushes the record image without connecting leaders, the same split as teleop. On Apple Silicon the linux/amd64 image is selected automatically:

uv run armnet-lerobot-record \
  --armnet.embodiment=lerobot/bimanual_yam \
  --build-only
uv run armnet-lerobot-record \
  --teleop.left-port=/dev/serial/by-id/usb-1a86_USB_Single_Serial_5B3D046055-if00 \
  --teleop.right-port=/dev/serial/by-id/usb-1a86_USB_Single_Serial_5B3D044141-if00 \
  --teleop.left-id=yam_gello_left \
  --teleop.right-id=yam_gello_right \
  --armnet.embodiment=lerobot/bimanual_yam \
  --dataset.fps=20 \
  --dataset.repo_id=<hf-user>/<biyam-dataset> \
  --dataset.single_task="<instruction>" \
  --dataset.num_episodes=2 \
  --secret HF_TOKEN=huggingface-token

Store your Hugging Face token as a secret first (armnet secret create huggingface-token hf_...) and request it with --secret HF_TOKEN=huggingface-token so the cell can push the dataset.

Recording controls

LeRobot's standard dataset-recording keyboard shortcuts work during the session and are forwarded to the cell over the teleop channel. On Linux the listener reads the kernel input device, so it works under Wayland as well as Xorg. The user running the command must be allowed to read those devices (sudo usermod -aG input "$USER", then log in again). macOS keeps the previous listener.

  • Right Arrow — save the current episode. The remote arm returns to its rest position and recording resumes once the workspace is reset. On an automated environment that reset happens by itself where the task allows it; otherwise an operator at the cell confirms it. Pass --armnet.disable-automated-reset to require the confirmation either way. A manual cell also asks for that reset before the first episode, and the FMS draws the object layout on the top camera. --armnet.variation-seed picks the sequence (default 7, not the eval default of 42).
  • Left Arrow — discard the current episode and re-record it (same rest + reset flow).
  • Esc — stop the session. The dataset is finalized on the cell and pushed to the Hub (unless --no-push-to-hub is set).

You end episodes yourself. To also let the workspace end one once it scores the task complete, pass --armnet.finish-on-task-completion; the same flag stops armnet-lerobot-teleop instead of running out its --armnet.duration_s.

Either way it keeps going for a second after that verdict, so the episode ends on you bringing the arm clear rather than on the frame the goal was met — a demonstration that stops with the gripper still in the goal is a poor one to train on. Tune it with --armnet.completion-grace-s (0 ends immediately). The verdict is latched when it fires, so the window can't change the outcome, and the arrow keys still work during it.

Pass --armnet.show-instrumentation (or the shorter --show-instrumentation) to either teleop or record when you want the job log to report live environment changes, such as a BusyBox switch flipping. It is off by default.

Keyboard controls need a local keyboard

Linux reads /dev/input through evdev, so Wayland and Xorg both work, but the user must be in the input group. macOS uses pynput. A headless or SSH session with no keyboard device still has no shortcuts.

Dataset options

Flag Default Purpose
--dataset.repo_id (generated) Hub repo id, e.g. user/so101_pick_place.
--dataset.single_task (none) Language instruction stored on every frame.
--dataset.num_episodes 5 Number of episodes to record.
--dataset.episode_time_s 120 Per-episode cap; Right Arrow ends one early.
--dataset.fps 30 Recording/control rate on the cell.
--dataset.vcodec libsvtav1 Video codec on the cell. Software libsvtav1 honours the g=2 keyframe interval (fast random-access training reads); auto uses a hardware encoder when available but emits a large GOP unsuitable for training.
--dataset.encoder-threads 2 Threads per camera video encoder on the cell.
--dataset.no-streaming-encoding off Encode at episode save instead of in real time (slower saves).
--no-push-to-hub off Keep the dataset on the cell; skip the Hub upload.
--robot-telemetry full Robot telemetry sidecar level (off / context / safety / full).
--armnet.show-instrumentation off Report live workspace instrumentation changes while recording.
--armnet.variation-seed 7 Seed for manual-reset object placement. Different from the eval default (42) so collected layouts are another draw from the same distribution. Does not move the rail or the lights.
--private-to-hub off Upload the dataset as private (default: public).
--hf-user (none) Hub namespace used when generating a repo id.

Camera frames are encoded to video in real time on the cell (LeRobot streaming encoding), so saving an episode is near-instant.

The arm-current safety gate

Bimanual sessions watch each joint's motor current and stop the arms if one of them pulls too hard for too long — the usual cause is a gripper caught on the scene or an arm pushing into the table. A trip reports which joint tripped, returns both arms to rest, and blocks further commands until the next job reset.

Cell configuration owns this gate. By default it has two tiers, because a brief hard pull and a moderate one held for a minute damage a motor differently: a joint trips above 275 raw current counts (1.79 A nominal) for 500 ms, or above 120 counts (0.78 A nominal) for 5 s. The first catches a stall quickly; the second sits near the motor's continuous rating and catches sustained overload, while leaving room for the second-long force spike a real manipulation needs. Operators may instead configure a per-joint percentile mode or explicitly turn the gate off.

These client flags only request a tighter threshold when the cell is configured for percentile mode; they cannot switch or disable the cell-owned mode:

Flag Default Purpose
--current-gate-percent-above 0 Optional margin above the selected normal-current percentile. 0 sends no job override.
--current-gate-duration-ms 200 Requested dwell; the job cannot extend the cell-owned dwell.
--current-gate-percentile 99 Which reference percentile to use when requesting an override: 95 or 99.

Headless machines

armnet-lerobot-teleop and armnet-lerobot-record open a native Rerun window by default. On an SSH / headless host, Rerun automatically falls back to its web viewer and prints how to reach it; see Visualize a Job with Rerun for the port-forwarding details. Note that the recording keyboard shortcuts still require a local display.

macOS: CERTIFICATE_VERIFY_FAILED on the teleop websocket

Job submit and armnet whoami go through HTTP and already trust Cloud Run's certificate. The teleop / Rerun / log streams use a separate websocket library that, on macOS Homebrew and uv CPython, often has an empty default CA store — so you can see unable to get local issuer certificate even though REST works.

Current armnet-client points those websockets at the same certifi bundle httpx uses. If you are on an older install, either upgrade the client or set:

export SSL_CERT_FILE=$(python -c "import certifi; print(certifi.where())")

then rerun teleop. You do not need to install extra Armnet certificates.

See Also