LiveKit docs › Teleoperation › Robot

---

# Build the robot app for teleoperation

> Build the robot app for teleoperation.

## Overview

Teleoperation is not limited to any particular language, but this guide focuses on native and embedded robot apps built with [C++](https://github.com/livekit/client-sdk-cpp), [Rust](https://github.com/livekit/rust-sdks), or [Python](https://github.com/livekit/python-sdks).

Building a robot teleoperation app typically involves the following steps:

1. Publish camera views.
2. Receive control commands.
3. Apply commands safely.

## Publish camera views

Publish each camera view from the robot as a named [video track](https://docs.livekit.io/robotics/media/video.md). For example, a mobile robot might publish `front_camera` and `rear_camera` tracks. The operator subscribes to the views it needs and renders the frames in the control interface.

The client SDK accepts frames from your app, encodes them, and publishes them to the room. LiveKit then forwards each track to subscribed operators. Keeping camera views in separate tracks lets the operator select views independently and lets LiveKit manage each stream according to the subscriber's needs.

Video latency affects how quickly an operator can react. Use [hardware-accelerated encoding](https://docs.livekit.io/robotics/media/video/encoders.md) where available, and test your app with the [low-latency settings](https://docs.livekit.io/robotics/media/performance/low-latency.md) that match its network conditions and tolerance for jitter.

The following examples publish a `front_camera` video track and push RGBA frames captured by app code. Replace the `camera` calls with your camera driver, GStreamer pipeline, or another local capture source:

**C++**:

```cpp
#include "livekit/local_video_track.h"
#include "livekit/video_source.h"

constexpr int kWidth = 1280;
constexpr int kHeight = 720;

auto source = std::make_shared<livekit::VideoSource>(kWidth, kHeight);
auto track = livekit::LocalVideoTrack::createLocalVideoTrack("front_camera", source);

livekit::TrackPublishOptions options;
options.source = livekit::TrackSource::SOURCE_CAMERA;

if (auto local_participant = room->localParticipant().lock()) {
  local_participant->publishTrack(track, options);
}

while (camera.isRunning()) {
  auto frame = livekit::VideoFrame::create(kWidth, kHeight, livekit::VideoBufferType::RGBA);
  camera.readRgba(frame.data(), kWidth, kHeight); // Application-specific camera frame capture call below
  source->captureFrame(frame);
}

```

---

**Rust**:

```rust
use livekit::prelude::*;

let source = NativeVideoSource::new(
    VideoResolution {
        width: 1280,
        height: 720,
    },
    false,
);
let track =
    LocalVideoTrack::create_video_track("front_camera", RtcVideoSource::Native(source.clone()));

let options = TrackPublishOptions {
    source: TrackSource::Camera,
    video_encoding: VideoEncoding {
        max_bitrate: 3_000_000,
        max_framerate: 30.0,
    }
    .into(),
    ..Default::default()
};

room.local_participant()
    .publish_track(LocalTrack::Video(track), options)
    .await?;

while let Some(frame) = camera.next_rgba_frame().await { // Application-specific camera frame capture
    source.capture_frame(&frame);
}

```

---

**Python**:

```python
from livekit import rtc

WIDTH = 1280
HEIGHT = 720

source = rtc.VideoSource(WIDTH, HEIGHT)
track = rtc.LocalVideoTrack.create_video_track("front_camera", source)
options = rtc.TrackPublishOptions(
    source=rtc.TrackSource.SOURCE_CAMERA,
    simulcast=True,
    video_encoding=rtc.VideoEncoding(
        max_framerate=30,
        max_bitrate=3_000_000,
    ),
)

publication = await room.local_participant.publish_track(track, options)

async for frame_bytes, capture_time_us in camera.rgba_frames():
    frame = rtc.VideoFrame(WIDTH, HEIGHT, rtc.VideoBufferType.RGBA, frame_bytes)
    source.capture_frame(frame, timestamp_us=capture_time_us)

```

Publish another named track for each additional camera view. If the robot also streams telemetry, lidar, or diagnostics, use [data tracks](https://docs.livekit.io/robotics/media/data/data-tracks.md) for high-frequency structured data and [RPC](https://docs.livekit.io/transport/data/rpc.md) for discrete operations that need a response, such as operator control leasing or authentication.

## Receive control commands

Subscribe to the operator's [control data track](https://docs.livekit.io/robotics/media/data/data-tracks.md), deserialize each frame against the [control schema](https://docs.livekit.io/robotics/teleop/operator.md#control-schema), and translate the resulting command into the local control system. Because data tracks use lossy delivery, the robot should apply the latest valid command and tolerate missing frames.

Register data track handlers before connecting to the room. Otherwise, the client can miss already-published control tracks if events such as `DataTrackPublished` fire during the connection handshake.

The following examples subscribe to a `robot.control` data track from the expected operator identity. They decode each frame as UTF-8 JSON and pass decoded commands to your app code:

**C++**:

```cpp
const std::string kOperatorIdentity = "operator";
const std::string kControlTrackName = "robot.control";

const auto callback_id = room->addOnDataFrameCallback(
    kOperatorIdentity,
    kControlTrackName,
    [&](const std::vector<std::uint8_t>& payload,
        std::optional<std::uint64_t> /*user_timestamp*/) {
      auto command = decodeControlFrame(payload);
      if (!command) {
        return;
      }

      // Application-specific command handling below
      controller.applyLatest(*command);
    });

```

---

**Rust**:

```rust
use futures_util::StreamExt;
use livekit::prelude::*;

const OPERATOR_IDENTITY: &str = "operator";
const CONTROL_TRACK_NAME: &str = "robot.control";

while let Some(event) = room_events.recv().await {
    if let RoomEvent::DataTrackPublished(track) = event {
        if track.publisher_identity() != OPERATOR_IDENTITY
            || track.info().name() != CONTROL_TRACK_NAME
        {
            continue;
        }

        let controller = controller.clone();
        tokio::spawn(async move {
            let Ok(mut stream) = track
                .subscribe_with_options(
                    DataTrackSubscribeOptions::new().with_buffer_size(1),
                )
                .await
            else {
                return;
            };

            while let Some(frame) = stream.next().await {
                if let Ok(command) = decode_control_frame(frame.payload()) {
                    // Application-specific command handling below
                    controller.apply_latest(command).await;
                }
            }
        });
    }
}

```

---

**Python**:

```python
import asyncio
import json

from livekit import rtc

OPERATOR_IDENTITY = "operator"
CONTROL_TRACK_NAME = "robot.control"


@room.on("data_track_published")
def on_data_track_published(track: rtc.RemoteDataTrack):
    if (
        track.publisher_identity != OPERATOR_IDENTITY
        or track.info.name != CONTROL_TRACK_NAME
    ):
        return

    asyncio.create_task(read_control_track(track))


async def read_control_track(track: rtc.RemoteDataTrack):
    stream = track.subscribe(buffer_size=1)

    async for frame in stream:
        try:
            payload = json.loads(frame.payload.decode("utf-8"))
            command = validate_control_frame(payload)
        except (json.JSONDecodeError, UnicodeDecodeError, ValueError):
            continue

        # Application-specific command handling below
        await controller.apply_latest(command)

```

Keep the data track buffer small for control input. A larger buffer can preserve more frames, but stale commands are usually worse than dropped commands for continuous teleoperation input.

## Apply commands safely

Treat every command received from LiveKit as untrusted input before applying it to an actuator. These are general guidelines that don't all apply to every robot or control system. At a minimum, the robot app should do the following:

- **Authorize the operator**: Accept commands only from an expected participant identity and issue tokens with the minimum required [participant permissions](https://docs.livekit.io/frontends/reference/tokens-grants.md).
- **Enforce one controller**: Use an explicit control lease when more than one operator can join the room.
- **Validate every frame**: Reject malformed, out-of-range, expired, or out-of-order commands.
- **Stop on lost input**: Move to a safe state when the command deadline passes, the data track closes, or the controlling participant disconnects.
- **Clamp commands locally**: Apply the robot's position, velocity, and acceleration limits on the robot, not only in the operator interface.

These app-level safeguards complement the authentication, encryption, and transport provided by LiveKit. They don't replace the hardware interlocks and safety systems required for your robot.

After validation, translate normalized control values into the local control system. Keep this translation deterministic and local to the robot:

```python
def apply_drive_command(command: ControlCommand):
    steering = clamp(command.control_values["steering"], -1.0, 1.0)
    throttle = clamp(command.control_values["throttle"], -1.0, 1.0)

    left_velocity = clamp(throttle - steering, -1.0, 1.0) * MAX_WHEEL_VELOCITY
    right_velocity = clamp(throttle + steering, -1.0, 1.0) * MAX_WHEEL_VELOCITY

    motor_controller.set_velocity(left_velocity, right_velocity)

```

Drive the actuator only while valid commands are being received from the command app. Run it behind a watchdog so that if the latest valid command expires, the control track closes, or the controlling participant disconnects, the robot stops or moves to another safe state.

## Additional resources

Use these implementations as references for robot teleoperation apps.

- **[Rover teleop robot](https://github.com/livekit-examples/rover-teleop/tree/main/rover)**: Python robot participant for a Raspberry Pi rover that publishes camera video and applies remote drive commands.

- **[Pan-tilt teleop robot](https://github.com/livekit-examples/teleop/tree/main/pan_tilt_demo)**: C++ robot participant that publishes RealSense video, receives velocity commands, and gates control with RPC.

- **[C++ video example](https://github.com/livekit-examples/cpp-example-collection/tree/main/simple_room)**: Native example that creates local audio and video sources, publishes tracks, and captures frames.

- **[C++ data track example](https://github.com/livekit-examples/cpp-example-collection/tree/main/ping_pong)**: Native example that publishes and subscribes to named data tracks.

---

This document was rendered at 2026-10-07T16:01:38.218Z.
For the latest version of this document, see [https://docs.livekit.io/robotics/teleop/robot.md](https://docs.livekit.io/robotics/teleop/robot.md).

To explore all LiveKit documentation, see [llms.txt](https://docs.livekit.io/llms.txt).