Overview
LiveKit Portal provides a small API surface that works with any robotics stack. It includes optional LeRobot integrations, but doesn't depend on a specific perception, control, or learning framework.
To learn more about Portal and this API, see Concepts.
The typical workflow is:
- Create a configuration.
- Construct a
RobotorOperator. - Register callbacks.
- Connect to a LiveKit room.
- Send and receive observations and actions.
pip install livekit-portal # or: uv add livekit-portal
Portal also provides a native Rust crate:
# Cargo.toml[dependencies]livekit-portal = { path = "path/to/portal/livekit-portal" }
The Python bindings ship as the livekit-portal-ffi crate plus a pure-Python package. For platform coverage and source builds, see the LiveKit Portal quickstart.
Declare the schema
The robot and operator must declare the same observation and action schema before connecting. The schema includes each field's name, order, and data type.
The following example declares two cameras and a four-field state and action schema, then sets the capture rate:
from livekit.portal import DType, RobotConfigcfg = RobotConfig("session-1")cfg.add_video("camera1")cfg.add_video("wrist")cfg.add_state_typed([("joint1", DType.F32),("joint2", DType.F32),("gripper", DType.BOOL),("mode", DType.I8),])cfg.add_action_typed([("joint1", DType.F32),("joint2", DType.F32),("gripper", DType.BOOL),("mode", DType.I8),])cfg.set_fps(30)
The string passed to RobotConfig is the session name. It is used only as a local label in logs and is independent of the LiveKit room name specified by the access token.
Choosing dtypes
Each field declares a data type that determines its representation on the wire.
| dtype | Bytes | Typical use |
|---|---|---|
F64 | 8 | Default choice when maximum precision is required. |
F32 | 4 | Joint angles and other floating-point values where single precision is sufficient. |
I32, I16, I8 | 4, 2, 1 | Signed indices, modes, and counters. |
U32, U16, U8 | 4, 2, 1 | Unsigned indices and counters. |
BOOL | 1 | Boolean values such as gripper state or emergency stop. |
Values passed to send_state() and send_action() use normal Python types. LiveKit Portal performs the required conversion when serializing data for transport.
Field order is part of the schema. Reordering fields on one side changes the schema fingerprint and prevents communication. A shared YAML file is the recommended way to keep robot and operator configurations synchronized.
Full config surface
Both RobotConfig and OperatorConfig expose the same configuration options. Options that apply only to one role are ignored by the other.
| Method | Default | Description |
|---|---|---|
add_video(name, codec=..., quality=..., max_bitrate_kbps=...) | H264 | Declare a camera track. |
add_state_typed([(name, dtype), ...]) | none | Declare the state schema. |
add_action_typed([(name, dtype), ...]) | none | Declare the action schema. |
add_action_chunk(name, horizon, fields) | none | Declare a fixed-horizon action batch. |
set_fps(int) | 30 | Capture rate. Drives the match window. |
set_slack(int) | 5 | Ticks of buffer headroom. |
set_tolerance(float) | 1.5 | Match window, in ticks. |
set_state_reliable(bool) | True | Reliable delivery for state. |
set_action_reliable(bool) | True | Reliable delivery for actions. |
set_ping_ms(int) | 1000 | RTT probe cadence; 0 disables probing on this side. |
set_reuse_stale_frames(bool) | False | Freeze video on loss instead of dropping state. |
set_action_subscription(bool) | False | Operator only. Receive executed actions. |
set_e2ee_key(bytes) | none | Shared-key encryption. |
You can also build the whole config from a file with RobotConfig.from_yaml_file("portal.yaml", "session-1"), which keeps one shareable wire contract for both sides.
Robot
The robot declares its schema, applies incoming actions to the hardware, and publishes video and state on each tick:
import asyncioimport timefrom livekit.portal import DType, Robot, RobotConfigSCHEMA = [("joint1", DType.F32), ("joint2", DType.F32), ("gripper", DType.BOOL)]async def main(url: str, token: str, hardware) -> None:cfg = RobotConfig("session-1")cfg.add_video("camera1")cfg.add_state_typed(SCHEMA)cfg.add_action_typed(SCHEMA)cfg.set_fps(30)robot = Robot(cfg)def on_action(action) -> None:# action.values is the typed dict.# action.timestamp_us is the operator's clock.# action.sender is the operator identity, stamped at the gate.# Only the active operator's actions reach this callback.hardware.apply(action.values)robot.on_action(on_action)await robot.connect(url, token)try:while hardware.running:reading = hardware.read()ts = int(time.time() * 1_000_000)robot.send_video_frame("camera1", reading.rgb, timestamp_us=ts)robot.send_state(reading.joints, timestamp_us=ts)await asyncio.sleep(1 / 30)finally:await robot.disconnect()robot.close()
Stamp the frame and the state with the same ts. The shared timestamp is what allows the operator to pair them.
If you omit timestamp_us, LiveKit Portal applies the current time at the moment of the call. This is correct when the two calls are adjacent, but incorrect if you capture, perform processing, and then send.
The send_video_frame method infers the width and height from the NumPy array. Specify them explicitly only when passing raw bytes.
Operator
The operator declares the same schema as the robot, runs its policy on each observation, and publishes the resulting actions:
import asynciofrom livekit.portal import DType, Operator, OperatorConfig, frame_bytes_to_numpy_rgbSCHEMA = [("joint1", DType.F32), ("joint2", DType.F32), ("gripper", DType.BOOL)]async def main(url: str, token: str, policy) -> None:cfg = OperatorConfig("session-1")cfg.add_video("camera1")cfg.add_state_typed(SCHEMA)cfg.add_action_typed(SCHEMA)cfg.set_fps(30)op = Operator(cfg)def on_observation(obs) -> None:frame = obs.frames["camera1"]rgb = frame_bytes_to_numpy_rgb(bytes(frame.data), frame.width, frame.height)action = policy(rgb, obs.state)op.send_action(action, in_reply_to_ts_us=obs.timestamp_us)op.on_observation(on_observation)await op.connect(url, token)# Without this the robot drops every action you send.await op.set_active_operator(op.local_identity())try:await asyncio.Event().wait()finally:await op.disconnect()op.close()
Passing in_reply_to_ts_us is optional. It allows metrics.policy.e2e_us_p50 to report true observation-to-action latency rather than a network round trip.
Lifecycle
A portal joins a room, leaves it, and releases its native handle:
await portal.connect(url, token) # join the roomawait portal.disconnect() # leave itportal.close() # drop the native handle eagerly
The close() method drops the reference to the native handle. It is optional: the handle is also released when the object is garbage-collected. Call it explicitly in a long-running service that creates portals repeatedly, so that a handle is not held until the next collection.
The connect method raises PortalError.AlreadyConnected if it is called twice.
Send data
Robot only:
robot.send_video_frame(track, frame, width=None, height=None, timestamp_us=None)robot.send_state(values, timestamp_us=None)
Operator only:
op.send_action(values, timestamp_us=None, in_reply_to_ts_us=None)op.send_action_chunk(name, data, timestamp_us=None, in_reply_to_ts_us=None)
The send_action_chunk method accepts either a dictionary of per-field columns of length horizon, or a single NumPy array of shape (horizon, n_fields) in declared field order. The array form matches what most VLA policies already emit. Columns of the wrong length are zero-padded.
Chunks travel as byte streams rather than data packets, so a full horizon is not bounded by the 15 KB packet limit.
Receive data
Every callback has a matching pull accessor. Use callbacks for push-driven loops, and use the accessors when your own loop controls the timing. Accessors return the latest value, so a slow reader sees the most recent value rather than a backlog.
Operator:
| Callback | Pull | Value |
|---|---|---|
op.on_observation(cb) | op.get_observation() | Observation |
op.on_state(cb) | op.get_state() | State, every packet, unmatched |
op.on_video_frame(track, cb) | op.get_video_frame(track) | VideoFrameData, unmatched |
op.on_drop(cb) | none | List[Dict[str, ...]] |
Robot:
| Callback | Pull | Value |
|---|---|---|
robot.on_action(cb) | robot.get_action() | Action |
robot.on_action_chunk(name, cb) | robot.get_action_chunk(name) | ActionChunk |
The on_state and on_video_frame callbacks are raw, unmatched streams. They fire on arrival with no matching, which suits a preview pane or a debug log. Use on_observation when frames and state must correspond.
Typed values on receive
The Action, State, Observation, and ActionChunk objects carry typed values by default. The values attribute holds Python-native types according to your declared schema, and the raw_values attribute holds the lossless all-f64 representation. On an Observation, these attributes are named state and raw_state.
def on_action(action):action.values["gripper"] # True, a boolaction.values["mode"] # 3, an intaction.values["joint1"] # 0.5, a floataction.raw_values # Dict[str, float], every field as f64
The Rust core mirrors this: Action, State, and Observation carry values: HashMap<String, TypedValue> alongside raw_values: HashMap<String, f64>.
The active operator
The robot accepts actions from one operator at a time, named by its active_operator pointer. For how the control gate behaves, see Set the active operator.
# Either roleportal.active_operator() # Optional[str]await portal.set_active_operator("policy-v1") # None clears itportal.operators() # connected operator identitiesportal.local_identity() # own identity, after connect# Operator onlyop.robot_identity() # the robot, once discovered
The set_active_operator method is symmetric. The robot writes its own attribute directly. An operator sends a portal.set_active_operator RPC, and the robot's handler performs the write. In both cases, the change propagates to all participants.
React to changes with three callbacks:
portal.on_operator_joined(lambda identity: ...)portal.on_operator_left(lambda identity: ...)portal.on_active_operator_changed(lambda identity: ...) # identity can be None
Both roles need can_update_own_metadata=True in their token, because Robot and Operator self-set an lk.portal.role attribute on connect. Set the active operator when you generate the token so the robot accepts that operator's actions from the moment it connects:
api.AccessToken(key, secret).with_attributes({"lk.portal.active_operator": "policy-v1"})
Video codecs
The add_video(name) method defaults to H.264 on the WebRTC media path. The VP8, VP9, AV1, and H265 codecs are also available; AV1 and H265 require both peers to negotiate them. The max_bitrate_kbps value is a ceiling, not a target.
from livekit.portal import VideoCodeccfg.add_video("front", max_bitrate_kbps=8000) # H264, 8 Mbps capcfg.add_video("wide", codec=VideoCodec.VP9, max_bitrate_kbps=4000)
For lossless frames, pass a byte-stream codec. The user-facing API doesn't change: send_video_frame, on_video_frame, get_video_frame, and observations behave identically, and frames arrive as RGB regardless of the codec:
cfg.add_video("front", codec=VideoCodec.MJPEG, quality=90)cfg.add_video("wrist", codec=VideoCodec.PNG)cfg.add_video("debug", codec=VideoCodec.RAW)
For guidance on choosing a codec, see Video frame format. Codec details, latency, and per-track fps ceilings are in Frame video .
RPC
Either side can register methods, and either side can invoke them. Use RPC for one-off requests that do not belong in the control loop.
robot.register_rpc_method("home", lambda data: "ok")robot.unregister_rpc_method("home")reply = await op.perform_rpc("home", payload="{}")
Handlers must return a string. Full surface, error codes, and payload limits are in RPC .
Metrics
A connected portal exposes runtime metrics for synchronization, round-trip time, and policy latency:
m = portal.metrics()m.sync.observations_emittedm.sync.states_droppedm.rtt.rtt_us_p95m.policy.e2e_us_p95portal.reset_metrics()
Every field is documented in Metrics .
Errors
You can catch these PortalError variants:
| Variant | Cause |
|---|---|
AlreadyConnected | connect called on a connected portal. |
NotConnected | A perform_rpc or send_action_chunk call before connect, or after disconnect. |
NoPeer | perform_rpc with no peer discovered and no destination. |
AmbiguousPeer | Several remote participants and no peer identified. Pass destination. |
UnknownVideoTrack | Track name was never declared with add_video. |
UnknownChunk | Chunk name was never declared with add_action_chunk. |
WrongFrameSize | Buffer length does not equal width * height * 3. |
InvalidFrameDimensions | Width or height is odd. |
WrongRole | send_action on a robot, or send_state on an operator. |
DtypeMismatch | A sent value's Python type disagrees with the declared dtype. |
Deserialization | A received payload could not be parsed. |
Codec | Frame encode or decode failed. |
Rpc | The remote handler raised. Carries the RpcError. |
The ConfigFileError type is separate and originates only from the YAML loader.
Surface summary
A condensed list of every method available to each role:
# datarobot.send_video_frame(track, frame, width=None, height=None, timestamp_us=None)robot.send_state(values, timestamp_us=None)robot.on_action(cb) # active operator onlyrobot.on_action_chunk(name, cb)robot.get_action() / robot.get_action_chunk(name)# control planerobot.active_operator() / await robot.set_active_operator(identity)robot.operators() / robot.local_identity()robot.on_operator_joined(cb) / robot.on_operator_left(cb)robot.on_active_operator_changed(cb)# rpc, metrics, lifecyclerobot.register_rpc_method(name, handler) / robot.unregister_rpc_method(name)await robot.perform_rpc(method, payload, destination=None, response_timeout_ms=None)robot.metrics() / robot.reset_metrics()await robot.connect(url, token) / await robot.disconnect() / robot.close()
# dataop.send_action(values, timestamp_us=None, in_reply_to_ts_us=None)op.send_action_chunk(name, data, timestamp_us=None, in_reply_to_ts_us=None)op.on_observation(cb) / op.on_state(cb) / op.on_drop(cb)op.on_video_frame(track, cb)op.get_observation() / op.get_state() / op.get_video_frame(track)op.on_action(cb) / op.get_action() # requires action subscription# control planeop.active_operator() / await op.set_active_operator(identity)op.operators() / op.robot_identity() / op.local_identity()op.on_operator_joined(cb) / op.on_operator_left(cb)op.on_active_operator_changed(cb)# rpc, metrics, lifecycleop.register_rpc_method(name, handler) / op.unregister_rpc_method(name)await op.perform_rpc(method, payload, destination=None, response_timeout_ms=None)op.metrics() / op.reset_metrics()await op.connect(url, token) / await op.disconnect() / op.close()
Using LiveKit Portal directly
The Robot and Operator classes are role-specific wrappers over a unified Portal class, which is also exported:
from livekit.portal import Portal, PortalConfig, Rolecfg = PortalConfig("session-1", Role.ROBOT)portal = Portal(cfg)
The Portal class provides the same behavior as the role-specific wrappers. The gate, the role attribute, and the built-in RPC handler are all present, with no opt-in flag. The only difference is that the type system exposes every method regardless of role, so a method that raises WrongRole at runtime can still be called.
LiveKit recommends using Robot or Operator in new code. Use Portal when the role is genuinely dynamic.
Next steps
Continue with runnable examples and the full Portal documentation: