Web SDK¶
We have two ways to construct web interfaces: using the cockpit or using the web SDK. The cockpit is the batteries-included method, where you write a blueprint which describes how pre-built panels are put together to form an interface to control and visualize a particular robot.
This is the second way, the web SDK. You get the raw internals and you build your web app your way.
This tutorial will start with simple stuff like "show odom on my own page" and finish with "stream my own message type with a custom binary encoding".
Start a robot with a local relay¶
Any blueprint works. Simulation needs no hardware:
Using --local-relay spawns a relay on http://127.0.0.1:7780 and bridges the robot to it. The relay serves:
/the cockpit (or your own files, with--serve-dir)/sdk.jsthe SDK as a zero-build ES module/api/inforelay bootstrap info
The local relay binds loopback and deliberately trusts local browser clients (wildcard CORS on the routes above), so a page from any local origin can connect without configuration. This is a local development mode, not the remote deployment story.
Your first page¶
Create ui/index.html:
<!DOCTYPE html>
<html>
<body>
<div id="status">connecting</div>
<pre id="odom">(no data)</pre>
<script type="module">
import { connect } from "/sdk.js";
const session = connect();
session.status.subscribe(() => {
const s = session.status.get();
document.querySelector("#status").textContent =
`${s.transport.phase}, robots: ${s.robots.map((r) => r.id).join(", ") || "none"}`;
});
session.subscribe("odom", (snapshot) => {
document.querySelector("#odom").textContent =
JSON.stringify(snapshot.slot?.value ?? null, null, 2);
});
</script>
</body>
</html>
Serve it from the relay instead of the cockpit:
You will see http://127.0.0.1:7780 open automatically and you get live odometry on your own page.
Intro to what some of the parts do:
connect()returns aSessionimmediately and connects in the background.- Normally, you only have one robot connected to the relay when running locally, so the session watches it automatically.
session.watch(robotId)pins a specific robot when there are several. session.subscribe(ch, cb)is one operation for both halves: it registers your callback and creates wire interest. The first subscriber for a channel sendssubto the relay, the last unsubscribe sendsunsub. It returns an unsubscribe function, but ignored here.- Subscribing before the robot or its manifest exists is fine. The subscription stays desired and activates when a manifest with that channel arrives.
session.close()tears everything down.
Snapshots, and the two read paths¶
Callbacks receive a ChannelSnapshot on the session's UI tick. The reason for this is so you can update multiple UI elements without forcing an unreasonable number of UI re-renders.
For canvas or video rendering you want every frame, so read the store directly. session.store.subscribe(ch, cb) fires per accepted frame and session.store.get(ch) returns the latest slot:
session.subscribe("color_image", () => {}); // holds the wire subscription
session.store.subscribe("color_image", () => {
const slot = session.store.get("color_image");
if (slot) draw(slot.value);
});
The store path never creates wire interest by itself, so keep one session.subscribe handle alive for the channel.
Delivery mode is per channel and declared robot-side: reliable channels arrive ordered and complete, latest channels (video, costmap) drop stale frames and keep only the newest.
Working from another origin¶
--serve-dir is optional. A Vite dev server, or any local page, can point at the relay explicitly:
import { connect } from "http://127.0.0.1:7780/sdk.js";
const session = connect({ url: "http://127.0.0.1:7780" });
Inside the dimos repository you can also import the SDK source directly: web/sdk is the @dimos/sdk Deno workspace package, and web/sdk/fixture/ is a small Vite consumer you can copy (deno task fixture). React bindings live on the @dimos/sdk/react subpath and read UI-tick snapshots through useSyncExternalStore:
import { useChannel, useStatus } from "@dimos/sdk/react";
function Pose({ session }) {
const status = useStatus(session);
const odom = useChannel(session, "odom"); // subscribes while mounted
return <pre>{status.transport.phase}: {JSON.stringify(odom.slot?.value)}</pre>;
}
@dimos/sdk is not published to npm yet. Outside the repository, use /sdk.js.
Exposing your own channels¶
So far the page could only subscribe to the bridge's built-in channels (odom, color_image, global_costmap). cockpit(channels=[...]) exposes any typed stream without a panel:
Channel(
stream, # stream name, matched by autoconnect
message_type, # the stream's Python type
encoding="json.v1", # wire encoding (see codecs below)
delivery="reliable", # or "latest"
max_hz=10.0, # encode-rate cap
)
The example package below adds two channels to the go2: a health stream from a module of our own, and the go2's lidar stream with a custom binary encoding, drawn as a live 2D point scatter.
(page/index.html is written in the next section.)
pyproject.toml declares the blueprint entry point so dimos run can find it:
[project]
name = "my-ui"
version = "0.1.0"
[project.entry-points."dimos.blueprints"]
go2-web = "my_ui.web:go2_web"
[tool.setuptools]
packages = ["my_ui"]
[build-system]
requires = ["setuptools"]
build-backend = "setuptools.build_meta"
my_ui/web.py:
from dataclasses import dataclass
import time
import reactivex as rx
from dimos.core.coordination.blueprints import autoconnect
from dimos.core.core import rpc
from dimos.core.module import Module
from dimos.core.stream import Out
from dimos.msgs.sensor_msgs.PointCloud2 import PointCloud2
from dimos.robot.unitree.go2.blueprints.smart.unitree_go2 import unitree_go2
from dimos.web.cockpit import Channel, cockpit
from dimos.web.codecs import EncodedPayload, web_encoder
@dataclass
class Health:
status: str
uptime_s: float
class HealthMonitor(Module):
health: Out[Health]
_started: float = 0.0
@rpc
def start(self) -> None:
super().start()
self._started = time.time()
self.register_disposable(
rx.interval(1.0).subscribe(
lambda _: self.health.publish(Health("ok", time.time() - self._started))
)
)
# lidar.xy.v1: little-endian float32 (x, y) pairs, point count in meta.
@web_encoder("lidar.xy.v1")
def encode_lidar_xy(msg: PointCloud2) -> EncodedPayload:
points = msg.points_f32()[::4, :2] # every 4th point, drop z
return EncodedPayload(points.tobytes(), {"n": len(points)})
go2_web = autoconnect(
unitree_go2,
HealthMonitor.blueprint(),
cockpit(
channels=[
Channel("health", Health, max_hz=1.0),
Channel("lidar", PointCloud2, encoding="lidar.xy.v1", delivery="latest", max_hz=5.0),
]
),
)
How the pieces connect:
- A
Channelwhose stream is not a built-in bridge port generates a bridge input port of that name and type.autoconnectthen wires it like any other stream:healthtoHealthMonitor.health,lidarto the driver'slidar: Out[PointCloud2]already insideunitree_go2. Channel("health", Health)uses the defaultjson.v1encoding. It works for JSON scalars, lists, dicts, and plain dataclasses, and the browser decodes it without registration. Anything potentially large (dimOS/LCM messages, images, arrays, bytes) is rejected at authoring time and needs an explicit@web_encoder.- An encoder returns
bytes, anEncodedPayload(payload plus a small JSON meta mapping that rides the frame header), orNoneto skip a sample. The first parameter annotation declares the message type it supports, and the channel's type must match. - You can normally use
cockpit(layout=..., channels=[...])to create a cockpit layout UI. But for a pure SDK access, leave "layout" out and just specify which channels you want to serve. - Encoding is lazy. A channel costs nothing until some viewer subscribes, and stops encoding when the last viewer leaves.
Caveat: because dimOS modules live on different processes, registered functions must be publicly adressable. That is, you can't use @web_encoder on an inline function, because such a function cannot be pickled in order to be sent to other processes.
Decoding custom encodings in the browser¶
json.v1 and any *.json.vN encoding decode automatically. lidar.xy.v1 is opaque bytes to the SDK, so the page registers the matching decoder. page/index.html:
<!DOCTYPE html>
<html>
<body>
<div id="health">health: (no data)</div>
<canvas id="lidar" width="400" height="400" style="border: 1px solid #888"></canvas>
<script type="module">
import { connect, createDecoderRegistry } from "/sdk.js";
const decoders = createDecoderRegistry();
decoders.register("lidar.xy.v1", (payload, header) => {
const view = new DataView(payload.buffer, payload.byteOffset, payload.byteLength);
const points = [];
for (let i = 0; i + 8 <= payload.byteLength; i += 8) {
points.push({ x: view.getFloat32(i, true), y: view.getFloat32(i + 4, true) });
}
return { value: points };
});
const session = connect({ decoders });
session.subscribe("health", (snapshot) => {
document.querySelector("#health").textContent =
`health: ${JSON.stringify(snapshot.slot?.value ?? null)}`;
});
const ctx = document.querySelector("#lidar").getContext("2d");
session.subscribe("lidar", (snapshot) => {
const points = snapshot.slot?.value;
if (!points?.length) return;
ctx.clearRect(0, 0, 400, 400);
for (const p of points) {
ctx.fillRect(200 + p.x * 20, 200 - p.y * 20, 2, 2);
}
});
</script>
</body>
</html>
Install the package into the same environment as dimos and run it:
uv pip install -e ./my-ui
uv run dimos --replay run my-ui.go2-web --local-relay --serve-dir my-ui/page
The page opens automatically: health updates once a second and the lidar scatter draws live. The replay recording is a couple of minutes long. When it ends the picture freezes, so restart the run to see it again. web/examples/custom-path/ in the repository is a minimal codec pair of the same shape.
Decoder notes:
- A decoder is
(payload: Uint8Array, header) => { value }, looked up by the channel's manifest encoding.header.metacarries the encoder'sEncodedPayloadmeta. - Each session owns its registry (
connect({decoders})), so two apps on one page cannot clobber each other. Registering a taken encoding throws unless you pass{replace: true}. - An encoding with no decoder is not an error. The channel still counts frames and renders as unsupported. A throwing decoder bumps
decodeErrorsand keeps the last good value. - Decoders run on the ingest path, so keep them synchronous and cheap. Heavy work (inflate, draw) belongs in the consumer.
Publishing to the robot¶
A dir="tx" channel with publish="shared" is a browser input: any viewer may publish, the bridge decodes the JSON value with the matching @web_decoder and publishes it on a typed Out port, and your modules consume it like any other stream.
cockpit(channels=[
Channel("human_input", str, dir="tx", encoding="text.json.v1", publish="shared"),
])
try {
const receipt = await session.publish("human_input", "hello robot");
// The bridge decoded the value and published it on the dimOS stream.
} catch (e) {
// e.outcome === "rejected": it definitively did not happen (e.code says why).
// e.outcome === "unknown": the connection died before the ack - it MAY have
// been published. Never auto-resend an "unknown" command.
}
Publish notes:
- Only reliable JSON-encoded tx channels declared
publish="shared"acceptpublish(); everything else (rx channels, teleop) rejects locally with a stable code. Values are any JSON value (nullincluded), at most 32 KiB serialized and at most 100 nesting levels deep. - The relay rate-limits per viewer and per robot at the channel's
max_hz, so extra open tabs never multiply the accepted rate. text.json.v1(strings) is built in; other encodings need an@web_decoderwhose return annotation matches the channel'smessage_type. An optional secondPublishContextparameter carries provenance (request id, principal, relay/client timestamps).web/examples/chat-input/in the repository is a minimal publish page.