| Protocol | Wire id | What it exposes | Spun up with |
|---|---|---|---|
ssh | ssh/2 | Shell + files (bash, SFTP) in a sandboxed workspace | Workspace (built in) |
mcp | mcp/2025-11-25 | Your own tools over the Model Context Protocol | fastmcp |
cdp | cdp/1.3 | Browser control over the Chrome DevTools Protocol | Chromium (playwright) |
rfb | rfb/3.8 | Full computer-use over VNC: screen + keyboard/mouse | Xvfb + x11vnc |
robot | openpi/0 | Schema-driven robot observation/action loop over WebSocket (beta) | robot bridge |
The Capability dataclass
A capability is (name, protocol, url, params) — concrete wire data carrying the real address of something serving the protocol.
| Field | Type | Description |
|---|---|---|
name | str | Capability name (e.g. "shell", "browser"). |
protocol | str | Wire protocol id (e.g. "ssh/2"). |
url | str | Connection URL. |
params | dict | Protocol-specific connection params. |
Capability.ssh, .mcp, .cdp, .rfb, .robot) that normalizes the URL and fills defaults; cap.to_manifest() / Capability.from_manifest(data) round-trip it.
Spinning up a capability
Every capability points at a daemon. For one that already exists, pass the factory to the constructor. For a daemon the environment runs itself, the pattern is always the same: start it in@env.initialize, block until it’s listening, publish its address with env.add_capability(...), and tear it down in @env.shutdown. The env doesn’t accept a client connection until every initialize hook returns, so waiting for the port closes the startup race.
A small readiness helper the snippets below reuse:
127.0.0.1: a loopback capability is forwarded through the env’s one control port (see Bindings are always reachable), so nothing else needs publishing.
ssh — a sandboxed shell
The shell case is built in. A Workspace is a sandboxed directory the agent gets over ssh; env.workspace(root) starts it, publishes its ssh capability, and stops it with the env — one line, no hook:
env.py
Use a relative path (
"workspace", created next to env.py). Sandbox isolation (bwrap) is Linux-only — unisolated elsewhere, isolated in a built image.ws.capability() by hand:
env.py
mcp — your own tools
Serve bespoke tools on a FastMCP server. The streamable-HTTP transport serves under /mcp, so that path is part of the published URL:
env.py
Capability.mcp accepts ws/wss/http/https URLs (no stdio) and an optional auth_token=.
cdp — a browser
Launch Chromium with a DevTools port. Playwright ships the binary (playwright install chromium); run it as a subprocess so the CDP endpoint is reachable at http://127.0.0.1:9222:
env.py
Capability.cdp defaults to port 9222 and takes an optional target_id=. (Add --no-sandbox only when running as root in a container.)
rfb — a virtual screen
Full computer-use is a VNC server over a virtual display. On Linux, Xvfb paints the framebuffer and x11vnc serves it (apt install xvfb x11vnc):
env.py
Capability.rfb listens on 5900 + display and takes an optional password=. Host multiple screens by publishing one rfb capability per display.
Capability.robot
openpi/0 control loop (beta). This is an openpi-like protocol: it reuses openpi’s wire format (msgpack with transparent, recursive numpy serialization) and its flat observation/action naming schema (observation/... keys, actions), so an openpi policy server and a HUD env speak the same bytes. It differs fundamentally in role assignment — in openpi a policy server answers inference requests; here the environment is the server (it owns the world and pushes observations) and the agent is the client (it acts in the world, replying with actions). contract is the environment’s full self-describing schema — robot_type, control_rate, and every observation/action feature — carried in the manifest params so the agent wires itself with no shared config. The serving bridge binds an ephemeral loopback port, so publish this from an @env.initialize hook after await bridge.start():
Workspace
Workspace is the standard shell daemon: a directory plus a bwrap-isolated SSH server (bash + chroot’d SFTP). Attach one with env.workspace(root, ...) and the environment brings it up (keys, socket, accept loop) when it serves, tearing it down on env.stop(). Extra kwargs configure the workspace — mounts, network, env vars, guest path, fixed ports, your own keys:
ws.capability() as a concrete ssh capability:
| Member | Description |
|---|---|
Workspace(root, *, host="127.0.0.1", port=0, mounts=(), network=False, env=None, user="agent", ...) | Construct. port=0 binds an ephemeral port. |
await ws.start() | Start the SSH accept loop (idempotent). |
ws.capability(name="shell") | The resolved ssh Capability (materializes keys, binds the socket). |
await ws.stop() | Stop accepting sessions and release the socket. |
ws.ssh_url / ws.ssh_host_pubkey | Connection address and host key. |
ws.bwrap_available | Whether bwrap isolation is active. |
mounts=[Mount("ro", src=..., dst=...)] and network=True (both from hud.environment) to configure the sandbox.
Bindings are always reachable
Every address in the manifest is dialable from where the client runs. A loopback daemon (a workspace, a browser in the same container) is transparently forwarded through the env’s control port, so a container only ever publishes one port — bind your daemons to127.0.0.1 and don’t worry about the rest.
Harness clients
A harness opens a capability to get a live client. The capability clients live inhud.capabilities:
| Client | Protocol |
|---|---|
SSHClient | ssh/2 (raw asyncssh connection via .conn) |
MCPClient | mcp/2025-11-25 |
CDPClient | cdp/1.3 |
RFBClient | rfb/3.8 |
RobotClient | openpi/0 — joins the registry on first open (the robot extra: numpy/openpi-client) |