Skip to main content
The robot capability is in beta. The wire protocol is versioned openpi/0; the contract schema is v0. Expect additive changes while the design settles.
HUD runs robot environments the same way it runs everything else - an environment declares tasks and capabilities, an agent drives a live Run, but a 50 Hz policy can’t stream actions over tool calls. So the robot capability is instead a continuous observation/action loop over WebSocket: the environment streams observations (camera frames, robot state) and the agent streams back actions, as fast as the policy can run. The wire format is openpi-inspired (msgpack with numpy serialization), so existing openpi policy servers only need a thin adapter. Everything below ships behind the robot extra (pulls in numpy + openpi-client):

Overview

Like with other HUD workflows there’s the environment side and the agent side. For robotics the environment side translates incoming actions into changes in the digital or physical environment and serves observations. The agent side owns the policy: it reads those observations, runs inference, and sends actions back. Both sides need building, and this is where robotics differs from the rest of HUD. For LLM agents you can lean on a standard inference provider and a stock harness, so often the environment is the only thing you write. For robot policies there is no equivalent - no hosted inference provider, no standard harness. HUD ships tooling for both sides: a handful of small, named abstractions you implement, with the framework owning everything in between (the serve loop, the wire protocol, telemetry to platform). Environment side - owns the simulator and serves frames:
  • RobotBridge - the one class you implement around your sim: reset / step / get_observation. The framework owns the WebSocket serve loop and the single-agent connection.
  • RobotEndpoint - wraps the bridge - the environment server’s handle for the sim (even if the sim is running in another process)
Agent side - runs the policy and streams actions:
  • RobotAgent - the harness: connects to the env and bridge, owns adapter and model, drives model until env terminates.
  • Model - the actual stateless checkpoint of the model (includes pre-/post-processing)
  • Adapter - translates the env’s observation space to the model’s, and the model’s action space to the env’s
The contract (of the environment) - the one artifact both sides share: a self-describing JSON schema of the environment including the embodiment’s control rate, observation and action spaces, carried in the capability’s manifest params. The agent wires observations to policy inputs purely from the manifest; there is no shared config. In addition, the contract serves to define useful metadata that you can easily toggle to improve trace visualization on the platform.

Environment side

HUD has two ways to build the environment side: env.gym for anything gym-shaped, which derives the contract, capability, and serving for you, or a custom RobotBridge for a sim that isn’t.

Gym-style envs

Calling env.gym(make_env) derives the contract from a sample observation, serves the env over the robot WebSocket, and returns a RobotEndpoint - the handle templates drive episodes through:
env.py
env.gym takes one argument, in any of three forms:
  • A factory callable, like make_env above - a module-level function that returns a gym-style env. Its signature splits task args into build args (a change rebuilds the env) and episodic args, which flow to env.reset(seed=..., options=...).
  • A gymnasium registry id, a string such as "CartPole-v1" - built with gym.make (or gym.make_vec when num_envs is passed).
  • An already-constructed env made through the gymnasium registry - env.gym reduces it to its spec and rebuilds it in the sim process.
env.gym serves either a standard, single-instance env or a vectorized one - one gym env that internally holds num_envs copies of the environment, stepped together in the same process for GPU efficiency. Each copy is a slot, and a vectorized sim hands back a slot token on reset so the rollout that claimed it can step and grade that copy alone, leaving the rest untouched. A single-env sim has only one slot, so the template may omit the token, as above. A vectorized sim (num_envs > 1) must thread it through so each session grades its own slot - yield {"prompt": ep["prompt"], "robot": {"token": ep["token"]}} and call sim.result(token=ep["token"]); see vectorized envs and evals for the full pattern. You can find gym-shaped environments in various places - LeRobot EnvHub, including Isaac Lab Arena environments, or environments built into LeRobot directly.
Vectorized (num_envs=4), with cameras enabled for RTX frames. make_env downloads the EnvHub repo’s module and calls its make_env the same way LeRobot’s loader does.
env.py
Meta-World ships inside LeRobot itself rather than as an EnvHub repo, so make_env calls LeRobot’s own factory directly. GymBridge keeps the env’s pixels and agent_pos keys as-is.
metaworld_sim.py
env.py
Needs metaworld / lerobot[metaworld] installe.

Custom bridges

For a sim that isn’t gym-shaped, subclass RobotBridge instead:
Those three methods are all you write. Under the hood the framework takes care of communication with the agent and starting/stopping as well as stepping of the simulator at the control rate. All three hooks run on the sim thread (main under serve_bridge), so thread-affine sims such as Isaac are safe without extra hopping in your subclass.
  • reset starts a fresh episode for a task and returns its prompt (the text the agent is given).
  • step applies one action and advances the sim a tick, setting success / terminated as the episode plays out.
  • get_observation returns a structured dict of the current observation plus whether the episode is done.
The get_observation function has a strict output convention, see below to follow it.
The data dict is the strict part. It is what the agent indexes by name and feeds straight to the policy, so a few things have to be exactly right:
  • Values are numpy arrays - nothing else survives the trip into the adapter and the trace viewer.
  • Each key is an observation feature’s name, verbatim from the contract. The agent does data[name] directly off the contract
  • Images are HWC arrays ([H, W, 3], uint8 RGB).
  • State is a single 1-D array, passed to the policy as float32; everything rank-1 is treated as state.
  • terminated is a sibling, not part of data - return it as the second item of your (data, terminated) tuple and the framework attaches it to the frame.
Actions come back the same way: the agent sends them under openpi’s actions key, and your step(action) receives an already-decoded numpy array - you never touch the codec.
RobotEndpoint is the env’s control handle on the bridge - the one surface it drives an episode through. Pass your bridge class (or instance); start spawns the sim process and serves it, capability publishes the robot binding (contract comes from the bridge), reset / result run the episode. Control-plane only - the agent’s observe/act loop tunnels straight to the bridge’s WebSocket.

Agent side

The harness lives in hud.agents.robot. We provide a base class called RobotAgent. It connects to the robot binding, reads the contract, then runs the rollout loop including model inference until the environment terminates. You supply two objects.
  • Model - something with an infer() function that returns action chunks (pre-/post-processing included)
  • Adapter - translates env ↔ model spaces.
Run it with the normal engine - Taskset(...).run(agent, runtime=...) - against any substrate serving an env with the robot capability and an adaptable embodiment.

LeRobot integration

HUD integrates with LeRobot natively, so a stock checkpoint is a complete agent in a few lines. The two bundled seams are the LeRobot convention:
  • LeRobotModel(policy, preprocess, postprocess) runs the policy through its own LeRobot pre/post-processors, so the checkpoint behaves exactly as it does upstream. Pass an Ensembler to reduce overlapping action chunks to one action per step.
  • LeRobotAdapter(model_image_keys=...) maps the env’s cameras and state onto the policy’s inputs from the contract - HWC uint8 → CHW float, state and prompt passed through.
Anything past the stock image/state convention is just a subclass of Model or Adapter; the LeRobot classes are the batteries-included default. See the robot benchmark cookbook for a full LIBERO + pi0.5 run.

The Model

Model owns how to run a policy. To wrap a non-LeRobot checkpoint, subclass it and implement one method - infer; the episode loop, threading, and the wire are handled for you.
  • Input (batch) - the policy-ready inputs your Adapter produced for this step (images, a state vector, the task prompt - whatever your policy consumes). Model and Adapter are a matched pair, so the batch is exactly what your adapter emits.
  • Output - a [T, A] float32 numpy array: an action chunk of T timesteps × A action dims, already in the env’s action space. Single-action policies return T = 1.
  • reset() - optional; clear per-episode state (an action queue, a chunk buffer) at the start of each episode.
The harness awaits ainfer, which runs your (blocking) infer in a worker thread by default - override ainfer only if your policy is natively async. For chunked policies, reduce each [T, A] chunk to one action per step with an Ensembler.

Batching

Running many rollouts at once, each calling infer once per step, wastes the GPU on single-observation forwards. BatchedAgent fixes that: it wraps a RobotAgent, gives each concurrent rollout its own episode state, and shares one batched model that coalesces their per-step infer calls into a single stacked forward.
Each tick, the concurrent observations queued within a short window (max_wait_s, default 50 ms) are stacked into one [N, ...] batch, run through a single forward, and scattered back to each rollout - so the batch is as wide as whatever is in flight, and the tail of a suite never stalls. max_concurrent already bounds the width; pass batch_size= only to cap the forward below it (e.g. VRAM headroom). BatchedAgent needs an in-process, stateless model whose infer runs the whole [N, ...] batch in one forward - LeRobotModel qualifies. A remote OpenPI policy server is not supported (its protocol has no batched-request shape); run one agent per rollout against it instead. BatchedAgent also takes ownership of the agent you pass (it swaps in the batched model in place), so give it a dedicated instance rather than one you also use for unbatched runs.

Vectorized envs and evals

Grading N rollouts by booting N separate containers wastes a GPU sim’s whole advantage - each container pays for its own physics from scratch, when a sim like Isaac Sim could instead step all N copies together in one vectorized instance. Running an eval against a vectorized sim needs no changes to the agent or the env’s contract, only to how the eval provisions rollouts: wrap the runtime in Shared(provider, width=N) so group rollouts share that one substrate instead of provisioning N containers.
group and max_concurrent are the ordinary Taskset.run parameters. A few things to note:
  • The agent doesn’t change. Each rollout still drives one ordinary WebSocket connection and calls reset / result as usual - the environment resolves each call to that rollout’s own slot.
  • Slots fill in order, then stop. The first reset claims slot 0; once every slot is taken, a further reset errors instead of queuing, so set max_concurrent to width so every slot fills.
  • EnvHub sims work unchanged. A LeRobot EnvHub package’s make_env often already returns a vectorized sim, so env.gym(make_env, num_envs=N) serves it directly.

Contract

Embodiments and policies disagree on cameras, state layout, action semantics, and control rate, so pairing a model with an env always needs a wiring step. The contract makes it explicit: a JSON document in the capability manifest that the agent reads back with RobotClient.spaces(), which splits features into an observation and an action space by each feature’s role - so a policy wires itself with no shared config. Here’s a proper minimal contract - one camera, a state vector, and an action:
role and type wire the spaces; control_rate and names are what make the run legible on the platform, so a real contract should always set them:
  • role (observation / action) - spaces() splits the contract by it and the Adapter wires against that split. Required on every feature.
  • type on image observations - rgb/bgr/gray/depth is how the bundled adapter spots a camera; the first observation without an image type becomes the state. Omit it and your image is mistaken for the state. (On the state and action, type is descriptive.)
  • control_rate (top level, Hz) - the agent reads it as the playback fps for the recorded episode, so the trace viewer plays back at real speed and any saved dataset has the right frame rate. Set it to your real control rate.
  • names (per feature) - per-element labels the trace viewer shows on each state/action slice; without them the dimensions display unlabeled.
Feature keys are openpi flat slash-paths and must match verbatim the keys your bridge returns from get_observation (action is the single action feature). Everything else - robot_type, dtype, shape, stats - is descriptive and never enforced. Full list in the reference below.
The v0 schema is deliberately narrow: one embodiment, one observation space, one action space per contract. The framework never validates your arrays against shape / dtype; the full authoring spec - the closed symbol sets and known traps - lives outside the SDK alongside the contract corpus.

Recording datasets

Set agent.save = True (wire it to a --save flag on your runner) to also record every (observation, executed action) tick into a LeRobot v3 dataset - the rollouts you just ran, ready to finetune a policy on. Telemetry streams either way; saving is the opt-in extra. Recording is agent-side: it consumes the observations and actions the agent already sees, so it runs in your process, never the environment container. Only your machine needs pip install 'lerobot[dataset]' - the sim’s own dependency stack (e.g. Isaac/RoboLab) never enters the picture. All rollouts in a process record into one shared dataset: each buffers its episode and commits it whole, so concurrent runs (e.g. BatchedAgent) interleave safely, and the dataset finalizes at process exit. Destination and Hub push come from the environment: The contract drives the schema with no extra wiring: image features become observation.images.<camera> (encoded to per-episode video), the lone state vector becomes observation.state, the action becomes action, and the task prompt rides along as each frame’s task.

API summary

See also

Robot benchmark cookbook

LIBERO in Docker, driven by pi0.5, end to end.

Capabilities