> ## Documentation Index
> Fetch the complete documentation index at: https://docs.zeroruntime.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Quickstart

> Write a follower and a leader, connect them over a room, and move a joint.

Two scripts, one per machine, joined by a room. The follower runs on
the robot. The leader drives it.

Already using LeRobot or ROS 2? See the
[integration quickstarts](#integration-quickstarts) instead.

## Install

```bash theme={null}
pip install zeroruntime-teleops
```

## Credentials

Both machines need a token and the same room id.

```bash theme={null}
export ZERORUNTIME_AUTH_TOKEN="your-token-here"
export ZERORUNTIME_MEETING_ID="abcd-efgh-ijkl"
```

Get a token from [the dashboard](https://app.videosdk.live/signup). See
[Installation](/robotics/python/installation#environment) for the full list.

## Follower

Runs on the machine with the robot. Three methods: what the joints are called,
how to read them, and how to write them.

```python follower.py highlight={13-15,20-21,23-24} theme={null}
import zeroruntime.teleops as teleop
from zeroruntime.teleops import Follower, SafetyConfig, single_group_descriptor

JOINTS = ("shoulder", "elbow", "wrist", "gripper")


class MyArm(Follower):
    def __init__(self, **kw):
        self.robot_id = "my-arm"
        self.position = {j: 0.0 for j in JOINTS}
        super().__init__(**kw)              # LAST

    def descriptor(self):
        return single_group_descriptor(self.robot_id, "my-arm", JOINTS,
                                       control_hz=30)

    def limits(self):
        return {j: (-90.0, 90.0) for j in JOINTS}

    def read_joints(self):
        return dict(self.position)         # replace with your driver

    def write_joints(self, joints):
        self.position.update(joints)       # replace with your driver


follower = MyArm(
    safety=SafetyConfig(slew=2.0, watchdog_timeout_s=0.5),
    cameras=[teleop.CameraConfig("platform-3610000.usb-usb-0:2.2:1.0-video-index0", label="wrist")],
    control_hz=30,
    observation_hz=10)

@follower.on_ready
def _(descriptor):
    print("follower ready:", descriptor.robot_id, descriptor.flat_names())

@follower.on_safe_state
def _(state, reason):
    print("state:", state.value + (f" ({reason.value})" if reason else ""))

@follower.on_control_granted
def _(lease, holder):
    print("control granted to", holder)

@follower.on_applied
def _(action):
    print("applied", action.command_id, "<- observation",
          action.observation_id, action.action)

follower.run(hz=30)
```

`run()` joins the room and paces the loop for you. On every pass it applies
whatever command arrived, and holds position if the leader goes quiet. Ten times
a second it sends the joint positions along with a camera frame.

`control_hz` is the loop rate and `observation_hz` the publish rate, so an
observation goes out every third tick here. `slew` caps how far a joint may move
in one tick. See [Configuration](/robotics/api-reference/python/safety-config) for the
rest.

<Note>
  Swap the two dictionary lines for your own driver calls and nothing else
  changes. `write_joints()` receives values that have already passed the limits
  and speed cap in `SafetyConfig`.
</Note>

Every constructor argument and callback is in the
[Follower reference](/robotics/api-reference/python/follower), and the safety fields in
[Configuration](/robotics/api-reference/python/safety-config).

## Leader

Runs on the machine with the controller. Two methods, and no writes.

```python leader.py highlight={13-15,17-18} theme={null}
import math

from zeroruntime.teleops import Leader, single_group_descriptor

JOINTS = ("shoulder", "elbow", "wrist", "gripper")


class MyController(Leader):
    def __init__(self, **kw):
        self.robot_id = "my-controller"
        self.target = {j: 0.0 for j in JOINTS}
        super().__init__(**kw)             # LAST

    def descriptor(self):
        return single_group_descriptor(self.robot_id, "my-arm", JOINTS,
                                       control_hz=30)

    def read_joints(self):
        return dict(self.target)           # replace with your input device


leader = MyController(sync_buffer_ms=120.0)

@leader.on_ready
def _(descriptor):
    print("ready:", descriptor.robot_id, descriptor.flat_names())

@leader.on_control_granted
def _(lease):
    print("control granted")

leader.start()
if not leader.claim_control(timeout_s=10):
    raise SystemExit("no control: denied, or the follower never answered")

leader.hold_deadman(True)

for i in leader.ticks(hz=30):
    obs = leader.observation()             # joints and camera, one instant
    if i % 30 == 0:
        print(f"tick {i:4d}  joints={obs.joints}  "
              f"cameras={list(obs.images)}  sent={leader.commands_sent}")

    # your input device goes here. This sweeps one joint so there is
    # something to watch.
    leader.target["elbow"] = 20.0 * math.sin(i / 30.0)
```

`ticks()` paces the loop and sends a command on every pass, using whatever
`read_joints()` returns. `observation()` gives you the latest from the follower:
a camera frame, and the joint positions from the moment that frame was taken.

<Warning>
  The follower applies nothing until `claim_control()` succeeds and the deadman
  is held. On real hardware, set `max_misalignment` so the arm refuses to arm
  while the two poses disagree.
</Warning>

`hold_deadman(True)` arms immediately here because this example leaves
`max_misalignment` unset. Set it and the same call raises `NotAligned` until the
two poses match, so use
[`arm_when_aligned()`](/robotics/api-reference/python/leader#starting-to-drive) instead.

Control, arming and the fields on a synced observation are in the
[Leader reference](/robotics/api-reference/python/leader).

## Run it

Two terminals, both with the token and the room id exported.

```bash theme={null}
python follower.py     # on the robot machine
python leader.py       # on the controller machine
```

## Output

<CodeGroup>
  ```text follower theme={null}
  state: holding (starvation)
  follower ready: my-arm ['shoulder', 'elbow', 'wrist', 'gripper']
  control granted to 42mv3omt
  state: active
  applied 40 <- observation 302 {'shoulder': 0.0, 'elbow': 19.0822, 'wrist': 0.0, 'gripper': 0.0}
  applied 76 <- observation 309 {'shoulder': 0.0, 'elbow': 12.4968, 'wrist': 0.0, 'gripper': 0.0}
  applied 120 <- observation 317 {'shoulder': 0.0, 'elbow': -14.2315, 'wrist': 0.0, 'gripper': 0.0}
  applied 155 <- observation 324 {'shoulder': 0.0, 'elbow': -18.5163, 'wrist': 0.0, 'gripper': 0.0}
  applied 195 <- observation 332 {'shoulder': 0.0, 'elbow': 2.9917, 'wrist': 0.0, 'gripper': 0.0}
  ```

  ```text leader theme={null}
  ready: my-controller ['shoulder', 'elbow', 'wrist', 'gripper']
  control granted
  tick    0  joints={}  cameras=[]  sent=1
  tick   30  joints={'shoulder': 0.0, 'elbow': 14.8035, 'wrist': 0.0, 'gripper': 0.0}  cameras=['wrist']  sent=31
  tick   60  joints={'shoulder': 0.0, 'elbow': 19.3147, 'wrist': 0.0, 'gripper': 0.0}  cameras=['wrist']  sent=61
  tick   90  joints={'shoulder': 0.0, 'elbow': 6.068, 'wrist': 0.0, 'gripper': 0.0}  cameras=['wrist']  sent=91
  tick  120  joints={'shoulder': 0.0, 'elbow': -12.7576, 'wrist': 0.0, 'gripper': 0.0}  cameras=['wrist']  sent=121
  ```
</CodeGroup>

* **`holding (starvation)`** is the starting state. The follower applies nothing
  until the leader claims control.
* **`applied 40 <- observation 302`** pairs each command with the observation the
  operator was acting on. That pairing is what a recording keeps.
* **`joints={}`** and **`cameras=[]`** on the first tick mean nothing has
  arrived from the follower yet. Both fill in once observations start.
* **`cameras=['wrist']`** is the label from the follower's `CameraConfig`, and
  `obs.images` carries a decoded frame for each one.

<Note>
  Seeing `holding (starvation)` again mid-run is the watchdog. Commands stopped
  arriving for longer than `watchdog_timeout_s`, so the arm held position until
  they resumed. Stop the leader entirely and it stays there.
</Note>

New to the terms used here? [Concepts](/robotics/concepts/architecture) explains how a
session works, and the [Python SDK reference](/robotics/api-reference/python/overview)
lists every class and argument.

## Record a dataset

Add a path and the session is written to disk as you drive.

```python theme={null}
follower = MyArm(record="./sessions", ...)   # starts recording automatically

follower.start_episode("pick up the cube")
# ... drive ...
follower.end_episode(success=True)
```

`record=` attaches a recorder when the session starts, and `stop()` closes it.
To control that yourself, leave it out and call `start_recording()` and
`stop_recording()` instead.

See [Recording](/robotics/concepts/recording) for the format and the LeRobot export.

## Integration quickstarts

If your robot is already on one of these, start there instead.

<CardGroup cols={2}>
  <Card title="LeRobot" icon="box" href="/robotics/integrations/lerobot">
    Pass your existing LeRobot robot and teleoperator to the adapters. They
    work out the joints and units for you, so there is no class to write.
  </Card>

  <Card title="ROS 2" icon="diagram-project" href="/robotics/integrations/ros2">
    Run a bridge node on each host, beside the drivers you already have. Your
    drivers keep the hardware.
  </Card>
</CardGroup>

## Next steps

<CardGroup cols={2}>
  <Card title="Follower" icon="robot" href="/robotics/api-reference/python/follower">
    Every constructor argument, callback and method on the robot side.
  </Card>

  <Card title="Leader" icon="gamepad" href="/robotics/api-reference/python/leader">
    Control, arming, and the synced observation.
  </Card>

  <Card title="Safety" icon="shield-halved" href="/robotics/concepts/safety">
    The checks a command passes before it reaches the motors.
  </Card>

  <Card title="Custom robot" icon="puzzle-piece" href="/robotics/integrations/custom">
    The full version of what this page showed, including cameras and telemetry.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.