| # Isaac Teleop |
|
|
| Control your robot with NVIDIA [Isaac Teleop](https://github.com/NVIDIA/IsaacTeleop), a |
| multi-modal teleoperation framework. Isaac Teleop drives a single `TeleopSession` from a range |
| of input devices — XR (VR) controllers, hand tracking, full-body tracking, Manus gloves, foot |
| pedals, and more. |
|
|
| In LeRobot, Isaac Teleop ships as a self-contained example under |
| [`examples/isaac_teleop_to_so101/`](https://github.com/huggingface/lerobot/tree/main/examples/isaac_teleop_to_so101). |
| Each Isaac Teleop input device is its own `Teleoperator` subclass in the example's |
| `isaac_teleop` package, sharing one session lifecycle (see `IsaacTeleopTeleoperator`). The |
| devices available today are the **XR controller** (`XRController`) and a back-drivable |
| **SO-101 leader arm** (`SO101LeaderArm`); Manus gloves and hand/full-body tracking are the |
| natural next devices. This guide focuses on the XR controller; the SO-101 leader is summarized |
| under [Run the example](#step-3-run-the-example). |
| |
| **In this guide you'll learn:** |
|
|
| - How an Isaac Teleop device drives a robot end‑effector (EE) target |
| - How the _clutch_ (squeeze/grip on the XR controller) engages teleoperation without jerking the arm |
| - How to run the SO‑101 teleoperation example and tune motion / gripper / IK |
|
|
| ## Installation |
|
|
| The example lives in the LeRobot repository (it is not part of the `lerobot` pip package), so |
| clone the repo and install from source. The canonical, always-up-to-date install and usage |
| reference is the example's |
| [`README.md`](https://github.com/huggingface/lerobot/tree/main/examples/isaac_teleop_to_so101/README.md); |
| in short: |
| |
| ```bash |
| git clone https://github.com/huggingface/lerobot.git |
| cd lerobot |
| uv pip install -e ".[feetech,kinematics,dataset]" "huggingface_hub>=1.5" |
| uv pip install "isaacteleop[cloudxr,retargeters-lite]~=1.3.131" "scipy>=1.14" |
| ``` |
| |
| `isaacteleop` is published on public PyPI (Linux only). The `cloudxr` extra brings the CloudXR |
| runtime bindings; `retargeters-lite` is the scipy-based retargeter path that resolves on both |
| x86_64 and ARM (on aarch64 — e.g. a DGX Spark — the full `retargeters` extra does not resolve |
| because of its `dex-retargeting`/`nlopt` pins, which is why it is not the default here). On |
| x86_64 you can additionally install the full retargeter stack: |
| |
| ```bash |
| uv pip install "isaacteleop[retargeters]~=1.3.131" |
| ``` |
| |
| ### Set up CloudXR and connect a headset |
| |
| Isaac Teleop streams the headset to your machine over **NVIDIA CloudXR**, which provides the |
| OpenXR runtime the session connects to. By default LeTeleop **auto-launches the CloudXR runtime |
| for you** when you call `teleop_device.connect()` — you no longer have to run `python -m |
| isaacteleop.cloudxr` and `source cloudxr.env` in a separate shell. All you need is a supported |
| headset connected and the CloudXR firewall ports open. Follow the Isaac Teleop |
| [Quick Start](https://nvidia.github.io/IsaacTeleop/main/getting_started/quick_start.html) for the |
| headset-pairing and firewall details. |
| |
| **First run (EULA).** The very first launch must accept the NVIDIA CloudXR EULA. The auto-launch |
| prompts for it **on stdin**, so on a headless machine it will hang waiting for input. Bootstrap |
| the EULA once, interactively, with: |
| |
| ```bash |
| python -m isaacteleop.cloudxr --accept-eula # one-time: accept the CloudXR EULA |
| ``` |
| |
| After that, `connect()` launches the runtime non-interactively. The launch **blocks for ~30s** |
| while the runtime comes up. |
| |
| **Configuration.** Two fields on `IsaacTeleopConfig` (shared by every device) control this: |
| |
| - `auto_launch_cloudxr` (default `True`) — whether `connect()` starts the runtime. Set `False` |
| when CloudXR is already running externally. |
| - `cloudxr_env_file` (default `None`) — an optional CloudXR device-profile `.env` selecting the |
| headset transport (e.g. an Apple Vision Pro profile). This is launcher **input**; it is not the |
| `~/.cloudxr/run/cloudxr.env` **output** file the old manual flow told you to `source`. `None` |
| keeps the default auto-WebRTC profile — though the SO-101 example overrides it to the |
| `default.env` shipped next to `teleoperate.py` unless you pass `--teleop.cloudxr_env_file`. |
| |
| **Opting out.** To skip the auto-launch (CloudXR already running), either set |
| `auto_launch_cloudxr=False` or export: |
| |
| ```bash |
| export LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1 |
| ``` |
| |
| The **env var takes precedence over the config field**: if `LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1` is |
| set, the auto-launch is skipped even when `auto_launch_cloudxr=True`. This variable is |
| **independent** of Isaac Lab's `ISAACLAB_CXR_SKIP_AUTOLAUNCH` — setting one does not affect the |
| other. |
|
|
| **One teleoperator per process.** The CloudXR runtime configures the environment process-wide (a |
| singleton), so run a single Isaac Teleop teleoperator per process. |
|
|
| **Shutting down.** Always call `teleop_device.disconnect()` on exit — including on Ctrl-C. Wrap |
| your teleoperation loop in `try/finally` and call `disconnect()` in the `finally`. This tears down |
| the OpenXR session **before** the CloudXR runtime, which is the required order; the launcher's |
| `atexit` hook only reaps the runtime and does not run the session's `__exit__`, so without an |
| explicit `disconnect()` an interrupted run shuts down in the wrong order. |
|
|
| ```python |
| teleop_device.connect() |
| try: |
| while True: |
| action = teleop_device.get_action() |
| # ... drive the robot ... |
| finally: |
| teleop_device.disconnect() |
| ``` |
|
|
| See [System Requirements](https://nvidia.github.io/IsaacTeleop/main/references/requirements.html) |
| for supported OS / GPU / CloudXR versions and headsets. |
|
|
| ## How it works |
|
|
| The XR controller is one Isaac Teleop **input** device. `XRController` is a deliberately thin |
| reader: it exposes the **raw** controller grip pose — already statically rebased into the robot |
| base frame — plus the squeeze and trigger analog values. It has **no** retargeters and **no** |
| clutch logic of its own. The clutch (engage latch + delta rebasing onto the EE) and the gripper |
| mapping live downstream in the example loop, which then feeds LeRobot's existing closed‑loop |
| Cartesian IK pipeline — the same one the phone teleoperator uses. The device‑specific pieces are |
| `XRController`, the loop's `Clutch`, and `MapXRControllerActionToRobotAction`; everything downstream |
| (`EEBoundsAndSafety`, `InverseKinematicsEEToJoints`) is shared, and a future device (e.g. Manus |
| gloves) would swap in its own `teleop_<device>.py` + processor while reusing the rest. |
|
|
| `XRController._build_pipeline` wires Isaac Teleop's `ControllersSource` — statically rebased into |
| the robot base frame by the native `ControllerTransform` (`base_T_anchor`) — and exposes the |
| transformed controller stream verbatim. `get_action()` reads the grip pose, squeeze, and trigger |
| straight off it; the session is always stepped `RUNNING` (there is no clutch retargeter to gate). |
| |
| The `Clutch` class (in `examples/isaac_teleop_to_so101/isaac_teleop/clutch.py`, driven by the |
| loop in `common.py`) mirrors Isaac Teleop's `SO101ClutchRetargeter`, but lives in-loop so the |
| device can stay a thin reader: |
|
|
| - It latches its engage origin on the squeeze **engage edge** (the frame the squeeze first crosses |
| `clutch_threshold`) and rebases both position and orientation around it, so engaging does not |
| teleport the arm. `Clutch.rebase` returns the absolute base-frame target as a `(pos, quat)` |
| pair, which the loop concatenates into the 7D `ee_pose` fed to the processor. |
| - The analog trigger becomes a gripper `closedness` in `[0, 1]` (0 = open, 1 = closed), |
| proportional to the trigger pull, which `MapXRControllerActionToRobotAction` maps to a jaw target. |
|
|
| See the Isaac Teleop |
| [Retargeting interface](https://nvidia.github.io/IsaacTeleop/main/references/retargeting/index.html) |
| and [architecture overview](https://nvidia.github.io/IsaacTeleop/main/overview/architecture.html) |
| for how source nodes and retargeters compose. |
|
|
| ```text |
| VR controller (OpenXR) |
| │ |
| ▼ |
| XRController.get_action() ── raw base-frame grip_pos / grip_quat + squeeze + trigger |
| │ (TeleopSession always stepped RUNNING; clutch lives downstream) |
| ▼ |
| Clutch.rebase(grip_pos, grip_quat) ── engage-relative delta applied to the EE home (pos + orient) |
| │ ee_pose (7) / closedness → absolute ee_pose; closedness = trigger |
| ▼ |
| MapXRControllerActionToRobotAction ── absolute ee.x/y/z; ee.w* = orientation rotvec target; |
| │ ee.x/y/z / ee.w* / ee.gripper_pos ee.gripper_pos = (1 - closedness) * 100 |
| ▼ |
| EEBoundsAndSafety ── workspace clip + per-frame step clamp (clamp+warn) |
| │ |
| ▼ |
| InverseKinematicsEEToJoints ── closed-loop Placo IK; position + soft-orientation |
| │ (orientation_weight=0.01) (passes ee.gripper_pos → gripper.pos) |
| ▼ |
| SO-101 follower joint targets |
| ``` |
|
|
| ### The clutch: owned by the example loop |
|
|
| Unlike the phone pipeline (which splits the clutch across `MapPhoneActionToRobotAction` and |
| `EEReferenceAndDelta`), the XR clutch lives entirely in the example loop's `Clutch` class. It emits |
| an **absolute** EE pose, so there is no `EEReferenceAndDelta` stage and no delta accumulation in the |
| processor — `MapXRControllerActionToRobotAction` is a pure, stateless per‑frame mapping. |
| |
| The clutch latches its engage origin on the squeeze **engage edge** (the moment the squeeze crosses |
| `clutch_threshold`) and drives the EE from the motion _relative_ to that origin, so the arm does not |
| teleport on engage. On **every** engage — startup and mid‑task re‑clutch alike — the home |
| _position_ is latched from forward kinematics on the arm's **measured joints**, so the home equals |
| where the arm physically is even if it moved while disengaged, and the engage is jump‑free. The |
| home _orientation_ keeps the last commanded rotation: the 5‑DOF arm tracks orientation only |
| softly, so latching the measured wrist orientation would inject its tracking offset into the |
| command on every re‑clutch. |
|
|
| ## Controls |
|
|
| - **Squeeze / grip** — the **clutch** (deadman). Hold it past `clutch_threshold` to engage |
| teleoperation; release to pause. Each engage re‑captures the origin, so you can reposition |
| your hand while paused and re‑engage without the arm jumping (index/clutch style). |
| - **Trigger** — the **gripper**, controlled **analog**. The jaw tracks the trigger |
| proportionally — a half‑pressed trigger leaves the jaw half‑closed — via a closedness in |
| `[0, 1]` (0 = open, 1 = closed) that maps to an absolute gripper joint target. |
| - **Controller orientation** — the **wrist**. The clutch rebases the controller orientation |
| (engage‑relative, base‑frame) into a soft IK orientation target the wrist tracks alongside |
| position. On the 5‑DOF SO‑101 the wrist follows the hand only partially by design — see |
| `orientation_weight` below. |
|
|
| ## Get started |
|
|
| ### Step 1: Create the teleoperator |
|
|
| ```python |
| # Run from the repo root so the `examples` package is importable. |
| from examples.isaac_teleop_to_so101.isaac_teleop import XRController, XRControllerConfig |
|
|
| teleop_config = XRControllerConfig( |
| hand_side="right", # "left" or "right" controller |
| clutch_threshold=0.5, # squeeze value above which the clutch engages |
| ) |
| teleop_device = XRController(teleop_config) |
| ``` |
|
|
| `XRController.get_action()` returns the **raw** base‑frame controller pose, not a clutch‑rebased |
| target: `grip_pos` (3,) `[x, y, z]` [m] and `grip_quat` (4,) `[qx, qy, qz, qw]` in the robot base |
| frame, plus scalar `squeeze` and `trigger` analog values in `[0, 1]`. The example loop's `Clutch` |
| turns these into the absolute `ee_pose`, and the squeeze is thresholded by the loop against |
| `clutch_threshold` to engage. |
| |
| ### Step 2: Connect |
| |
| Calling `teleop_device.connect()` first auto-launches the CloudXR runtime (unless you opted out — |
| see [Set up CloudXR and connect a headset](#set-up-cloudxr-and-connect-a-headset); this blocks for |
| ~30s and on the first run prompts for the EULA on stdin), then starts the Isaac Teleop |
| [`TeleopSession`](https://nvidia.github.io/IsaacTeleop/main/getting_started/teleop_session.html) |
| (opens the OpenXR session and discovers the controllers). XR controllers are self‑calibrating, so |
| there is no manual calibration step — the clutch handles re‑centering each time you engage. Pair |
| `connect()` with a `try/finally` that calls `disconnect()` so the session tears down before the |
| runtime on exit/Ctrl-C. |
| |
| ### Step 3: Run the example |
| |
| The example assumes you configured your robot (SO‑101 follower) and set the correct serial port. |
| |
| The **robot URDF and its meshes are fetched automatically** on first run: the XR device downloads |
| the SO-101 URDF from the |
| [`lerobot/robot-urdfs` Hugging Face bucket](https://huggingface.co/buckets/lerobot/robot-urdfs/tree/so101) |
| into the LeRobot cache (`HF_LEROBOT_HOME/robot-urdfs/so101/`) and reuses it after, so there is no |
| separate download step : |
| |
| ```bash |
| python -m examples.isaac_teleop_to_so101.teleoperate --robot.type=so101_follower --robot.port=/dev/ttyACM0 \ |
| --robot.id=so101_follower_arm --teleop.type=xr_controller |
| ``` |
| |
| The CLI is `lerobot-teleoperate`-style (draccus): `--robot.*` configures the SO-101 follower and |
| `--teleop.type` selects the Isaac input device (`xr_controller` | `so101_leader`), with |
| `--teleop.*` its device knobs. `--teleop.type=xr_controller` runs the XR-controller path described |
| above. The startup safety contract: by default it slews all joints to a default reset pose over |
| `--reset_duration` seconds (`--reset_to_origin=false` keeps the arm where it is), then seeds the |
| clutch home from the arm's measured pose so the first engage is jump-free; the follower is |
| commanded only while the clutch is engaged. |
|
|
| **Customizing the reset pose.** The reset pose ships as a built-in default (a comfortable mid-range |
| pose) and works out of the box — you do **not** need to record anything. To tailor it to your setup, |
| back-drive the arm to the pose you want and run |
| `python -m examples.isaac_teleop_to_so101.override_reset_pose |
| current joints to a per-arm file in the LeRobot cache |
| (`HF_LEROBOT_HOME/reset_poses/<robot.name>/<robot.id>.json`, keyed like calibration), which then takes |
| priority over the built-in default on the next run. Because it lives in the user-local cache (not |
| the repo), your override stays on your machine, and both `teleoperate` and `record` honor it |
| when launched with the same `--robot.id`. |
|
|
| The other device, `--teleop.type=so101_leader`, mirrors the follower 1:1 from a back-drivable |
| SO-101 _leader arm_ whose joints are streamed by Isaac Teleop's native `so101_leader` plugin (no |
| clutch, no IK — the leader and follower share the SO-101 kinematics). |
| |
| The `so101_leader_plugin` binary is a C++ plugin that is **not** part of the `isaacteleop` pip |
| package — you build it from the Isaac Teleop source tree. Follow |
| [Build Isaac Teleop from source](https://nvidia.github.io/IsaacTeleop/main/getting_started/build_from_source/index.html) |
| (in short, from your Isaac Teleop checkout: `cmake -B build && cmake --build build --parallel && |
| cmake --install build`); the build installs the plugins under `<IsaacTeleop>/install/plugins/`, so |
| the binary lands at `install/plugins/so101_leader/so101_leader_plugin` — the `--launch_plugin` path |
| below. See the plugin's own `README.md` (next to the binary) for its serial/calibration details. |
|
|
| Point ` |
| binary to have the script spawn it after CloudXR is up: |
|
|
| ```bash |
| python -m examples.isaac_teleop_to_so101.teleoperate |
| |
| |
| |
| ``` |
|
|
| (Note `so101_leader` here is the _Isaac_ leader, resolved against the Isaac Teleop device |
| registry, distinct from `lerobot-teleoperate`'s serial `so101_leader`.) When a `--teleop.port` is |
| set, the plugin's tick→radian calibration is inferred from ` |
| as its third positional arg — the LeRobot-format JSON at |
| `HF_LEROBOT_CALIBRATION/teleoperators/so_leader/<id>.json`, the same file the serial SO-101 leader |
| uses (`lerobot-calibrate |
| warns and the plugin uses built-in defaults. Run `python -m examples.isaac_teleop_to_so101.teleoperate |
| startup safety contract: by default the follower is |
| slewed to the leader's first reading over `--align_duration` seconds (`--align=false` to skip) so |
| the arm does not snap when the mirror begins, and while the leader stream is stale the follower is |
| held at its measured pose. |
| |
| The URDF fetch uses `huggingface_hub` (already a LeRobot dependency) against the public |
| `lerobot/robot-urdfs` bucket, so it needs no login. It is cached under |
| `HF_LEROBOT_HOME/robot-urdfs/so101/`; delete that folder to force a re‑download. |
| |
| Then, in your headset: squeeze and hold the grip to engage, move the controller to drive the |
| arm, twist/tilt it to orient the wrist, and press the trigger to close the gripper |
| (proportionally — release to open). |
| |
| To record a dataset (not just teleoperate), use `record.py` in the same folder. It dispatches on |
| `--teleop.type` (`xr_controller` | `so101_leader`) exactly like `teleoperate.py`, so either device |
| can drive the follower, and it saves the commanded joints to a LeRobot dataset (`lerobot-record`-style |
| `--dataset.*` flags). See its module docstring for the full CLI and the keyboard recording shortcuts. |
| |
| ## Important pipeline steps and options |
| |
| The clutch already produces an absolute base‑frame pose, so the processor side is a thin |
| **absolute‑pose** path — there is no frame remap, no delta accumulation, and no |
| `EEReferenceAndDelta` stage. |
| |
| - `MapXRControllerActionToRobotAction` is a stateless per‑frame mapping from the device output to |
| the IK input contract. It writes the absolute base‑frame position, encodes the absolute |
| orientation as a rotvec target, and inverts the closedness into a motor gripper target: |
| |
| ```python |
| action["ee.x"], action["ee.y"], action["ee.z"] = ee_pose[:3] # absolute, base frame [m] |
| action["ee.wx"], action["ee.wy"], action["ee.wz"] = orient_rotvec # orientation target (rotvec) |
| action["ee.gripper_pos"] = (1 - closedness) * 100 # motor units; SO-101 calibrates 100 = open |
| ``` |
| |
| The gripper polarity (`100 = open, 0 = closed`) is a hardware‑calibration convention in the source — flip it there if the jaw opens when it should close. |
| |
| - `EEBoundsAndSafety` clamps the EE to a workspace and rate‑limits per‑frame jumps. The clutch's |
| no‑teleport keeps frames small, so `max_ee_step_m` mostly catches transient controller tracking |
| glitches. The z floor is `0.0` (the table plane) so a stray target cannot drive the EE below the |
| table; x/y stay at the loose `[-1, 1]` m box. Set `raise_on_jump=False` so an over‑limit frame is |
| **clamped and warned** instead of raising — a crash mid‑loop would leave the arm uncontrolled: |
|
|
| ```python |
| EEBoundsAndSafety( |
| end_effector_bounds={"min": [-1.0, -1.0, 0.0], "max": [1.0, 1.0, 1.0]}, |
| max_ee_step_m=0.10, |
| raise_on_jump=False, |
| ) |
| ``` |
|
|
| - `InverseKinematicsEEToJoints(initial_guess_current_joints=False, orientation_weight=0.01)` solves |
| closed‑loop Placo IK. SO‑101 is a 5‑DOF arm, so the IK is position‑dominant; the small |
| `orientation_weight` lets it softly track the orientation target carried in `ee.w*` so the wrist |
| follows the hand, while the under‑determined roll stays partial by design. There is **no** |
| `GripperVelocityToJoint`: the absolute `ee.gripper_pos` is passed straight to `gripper.pos`. |
| `initial_guess_current_joints=False` warm‑starts each solve from the **previous IK solution** |
| rather than re‑seeding from the measured joints, so the joint trajectory stays continuous |
| frame‑to‑frame. Tune `orientation_weight` on hardware — too high fights position tracking, too |
| low ignores the orientation command. |
|
|
| The example also gates safety at the loop level: after the startup reset slew (on by default — |
| pass ` |
| the clutch is engaged**, and re‑sends the measured joints while disengaged, so releasing the |
| clutch freezes the arm in place. |
|
|
| See the [Processors for Robots and Teleoperators](./processors_robots_teleop) guide for more on |
| adapting the pipeline to other robots. |
|
|
| ## Troubleshooting |
|
|
| - **`ModuleNotFoundError: isaacteleop`** — the `isaacteleop` package is not installed in the |
| active environment. Re-run the install command at the top of this guide: |
| `uv pip install "isaacteleop[cloudxr,retargeters-lite]~=1.3.131"`. |
| - **No controllers found** — make sure the CloudXR runtime is running, the firewall ports are |
| whitelisted, and the headset is connected (see |
| [Set up CloudXR and connect a headset](#set-up-cloudxr-and-connect-a-headset) and the Isaac |
| Teleop [Quick Start](https://nvidia.github.io/IsaacTeleop/main/getting_started/quick_start.html)). |
| - **CloudXR auto-launch failed** — `connect()` raises a `RuntimeError` if the runtime does not |
| come up within its startup timeout. Check the launcher logs under `~/.cloudxr/logs`. Common |
| causes: the EULA was never accepted (run `python -m isaacteleop.cloudxr |
| interactively — the auto-launch prompts on stdin and hangs headless), or the runtime is already |
| running externally (set `LEROBOT_CLOUDXR_SKIP_AUTOLAUNCH=1` or `auto_launch_cloudxr=False` to |
| skip the auto-launch). |
| - **Arm does not move** — the clutch is a deadman: you must hold the squeeze/grip past |
| `clutch_threshold`. Lower the threshold if your controller's squeeze is reported softly. |
| - **Motion feels misaligned** — confirm the headset/play space orientation. The controller stream |
| is rebased into the robot base frame by the `base_T_anchor` transform on `XRControllerConfig` |
| (default: standard OpenXR → robot axis convention); adjust it if your anchor frame differs. |
| |
| ## Learn more |
| |
| NVIDIA Isaac Teleop documentation ([docs home](https://nvidia.github.io/IsaacTeleop/), |
| [GitHub](https://github.com/NVIDIA/IsaacTeleop)): |
| |
| - [Quick Start](https://nvidia.github.io/IsaacTeleop/main/getting_started/quick_start.html) — |
| install, run the CloudXR server, connect a headset, run a teleop example. |
| - [TeleopSession](https://nvidia.github.io/IsaacTeleop/main/getting_started/teleop_session.html) — |
| the session API `XRController` wraps. |
| - [Retargeting interface](https://nvidia.github.io/IsaacTeleop/main/references/retargeting/index.html) |
| and [architecture overview](https://nvidia.github.io/IsaacTeleop/main/overview/architecture.html) — |
| how source nodes and retargeters compose into a pipeline. |
| - [Build from source](https://nvidia.github.io/IsaacTeleop/main/getting_started/build_from_source/index.html) — |
| build `isaacteleop` (and its C++ plugins, including the `so101_leader` plugin used above) from a |
| local checkout. |
| - [System Requirements](https://nvidia.github.io/IsaacTeleop/main/references/requirements.html) and |
| the [CloudXR SDK docs](https://docs.nvidia.com/cloudxr-sdk) — supported platforms, GPUs, |
| CloudXR/OpenXR runtime versions, and headsets. |
| |