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.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)
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
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
Callingenv.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_envabove - 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 toenv.reset(seed=..., options=...). - A gymnasium registry id, a string such as
"CartPole-v1"- built withgym.make(orgym.make_vecwhennum_envsis passed). - An already-constructed env made through the gymnasium registry -
env.gymreduces 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.
Example: Isaac Lab Arena (EnvHub)
Example: Isaac Lab Arena (EnvHub)
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
Example: Meta-World (built into LeRobot)
Example: Meta-World (built into LeRobot)
Meta-World ships inside LeRobot itself rather than as an EnvHub repo, so Needs
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
metaworld / lerobot[metaworld] installe.Custom bridges
For a sim that isn’t gym-shaped, subclassRobotBridge instead:
serve_bridge), so thread-affine sims such as
Isaac are safe without extra hopping in your subclass.
resetstarts a fresh episode for a task and returns its prompt (the text the agent is given).stepapplies one action and advances the sim a tick, settingsuccess/terminatedas the episode plays out.get_observationreturns 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 openpi observation convention
The openpi observation convention
The Actions come back the same way: the agent sends them under openpi’s
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
HWCarrays ([H, W, 3],uint8RGB). - State is a single 1-D array, passed to the policy as
float32; everything rank-1 is treated as state. terminatedis a sibling, not part ofdata- return it as the second item of your(data, terminated)tuple and the framework attaches it to the frame.
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 inhud.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 aninfer()function that returns action chunks (pre-/post-processing included)Adapter- translates env ↔ model spaces.
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 anEnsemblerto 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 - HWCuint8→ CHW float, state and prompt passed through.
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 yourAdapterproduced for this step (images, a state vector, the task prompt - whatever your policy consumes).ModelandAdapterare a matched pair, so the batch is exactly what your adapter emits. - Output - a
[T, A]float32numpy array: an action chunk ofTtimesteps ×Aaction dims, already in the env’s action space. Single-action policies returnT = 1. reset()- optional; clear per-episode state (an action queue, a chunk buffer) at the start of each episode.
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 callinginfer 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.
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 inShared(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/resultas usual - the environment resolves each call to that rollout’s own slot. - Slots fill in order, then stop. The first
resetclaims slot 0; once every slot is taken, a furtherreseterrors instead of queuing, so setmax_concurrenttowidthso every slot fills. - EnvHub sims work unchanged. A LeRobot EnvHub
package’s
make_envoften already returns a vectorized sim, soenv.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 withRobotClient.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 theAdapterwires against that split. Required on every feature.typeon image observations -rgb/bgr/gray/depthis 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,typeis 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.
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.
Full field reference
Full field reference
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
Setagent.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.