# RGB.live — real-time video sessions for robots, cameras and models > Written for coding agents and developers. Read it top to bottom once; everything needed to build > against RGB is here. Spec status: DRAFT 0.2 (2026-10-30). "Status" at the end separates what is > implemented today from what is proposed. A **session is a graph**: participants (cameras, robots, viewers, programs) and models, connected by tracks. Creating a session admits the first version of the graph; `attach` admits a change; `detach` removes one. There are no fixed pipelines: a model can be attached, re-pointed or removed while the session runs, and the same camera can feed several models. Every attach is an admission decision made by the platform at that moment. When there is no room, the attach fails and the session keeps running with what it had. One model failing fails that attachment, not the session. Everything travels as typed **tracks** over one transport per session, through a relay. Nothing needs an inbound port. The relay never looks inside a track. Install (packages are not yet published; see Status): pip install rgb-live # Python: import rgb npm i @rgb-live/sdk # JavaScript/TypeScript: import * as rgb from '@rgb-live/sdk' ## 1. Concepts (six words) - **Session**: a graph plus a clock, held as state by the control plane. Created by your backend with a duration; everything in it is addressed under its id. - **Participant**: anything that joins. A camera, a robot, a browser, a Python script. A participant joins with a role (`camera`, `viewer`, `robot`, `controller`), what it publishes and what it subscribes to, and receives an **access** credential it uses to connect. - **Model**: a catalog entry (`sam3-detector-v1`, `qwen-monitor-v1`) attached to a participant's video at runtime, under a name you choose (`as: 'objects'`). The platform admits it onto a node with capacity, or refuses. Models publish results as tracks. - **Track**: a named stream inside a node's broadcast. Three kinds: **video** (encoded access units plus a *track description*; Appendix A), **JSON stream** (one record per message, lossless, ordered), **JSON snapshot** (latest value). Every frame and every record carries a **timestamp on the session clock**, so a result always names the exact source frame it refers to. - **Watch**: a subscription to any track from any participant in any language. - **Transport**: how a session's bytes move. One transport per session (section 8). The API above the transport is the same. The rule that makes this an API rather than a demo: a new kind of participant, model or watcher needs no new infrastructure and no deployment. It needs a name, the tracks it reads, and the tracks it writes. ## 2. Addressing Every node is a path under the session. Clients never see the relay's internals; the SDK maps these. {session}/{participant} video track + its description, published by the participant {session}/{participant}/{as} results of the model attached under that name {session}/link link state (demo 4), published by the publisher that owns the link Names: `[a-z0-9][a-z0-9-]{0,31}`. A participant and an attachment name together identify an attachment: `cam0/objects`. In `watch('cam0/objects')` that path means the attachment's primary output; `watch('cam0/objects:checks')` names another output of the same attachment. The session graph itself (participants, attachments, their admission state, revision) lives in the control plane and is read through the API (section 7); it is not a relay track. ## 3. Python SDK ```python import rgb # RGB_API_KEY and RGB_ORIGIN from the environment session = rgb.sessions.create(name="warehouse-demo", duration_seconds=3600) # backend: idempotent by name # participants cam = session.add_source(rgb.FileSource("clip.mp4", id="cam0", loop=True)) # join as a camera and publish from this process cam = session.source("web0") # refer to a camera that joined elsewhere cam = session.add_source(rgb.RtspSource("rtsp://10.0.0.5/stream1", id="dock")) # proposed; IP camera, no re-encode # models: attach a catalog model to a camera, under a name. Admitted now, or refused now. objects = session.attach("sam3-detector-v1", to=cam, as_="objects", params={"prompts": ["person", "pallet"]}) alerts = session.attach(rgb.Monitor(rules=["a person entered the robot work zone"]), to=cam, as_="alerts") # typed sugar objects.configure(prompts=["person", "forklift"]) # re-admits the attachment with new params; tracking restarts try: depth = session.attach("depth-anything-v2", to=cam, as_="depth") except rgb.AttachRefused as e: # no node has room: the session keeps running print(e.reason) # "no_capacity: 0 of 4 detector slots free on pod-1" # watching @objects.watch def on_detections(rec): ... # one record per processed frame @alerts.watch def on_event(e): ... # sparse, confirmed events only alerts.outputs.checks.watch(print) # any named output, typed session.watch("cam0/alerts:checks", print) # any output by path, untyped session.run() # blocking; or `async with session:` ``` `rgb.sessions.create(name=None, duration_seconds=86400, transport="moq")` creates the session in the control plane and returns a handle. With `name`, create is idempotent: a live session of that name is returned instead of a new one (the same rule Gridcaster applies to creation keys). `rgb.sessions.get(id)`, `.list()`, `.close(id)`. `rgb.Client(api_key=..., origin=...)` builds a client explicitly; `rgb.sessions` is the default client built from `RGB_API_KEY` and `RGB_ORIGIN`. `session.join(as_="camera", id="cam0", publish=["video"], subscribe=[])` registers a participant and returns its **access**: `{session_id, participant_id, token, relay, transport, expires_at}`. A device process never holds the API key; it gets an access from the backend and calls `rgb.connect(access)`, which returns the same `Session` handle limited to publishing and watching. `add_source` is sugar for join-as-camera plus publish-from-here; `source(id)` only refers. `attach(model, to=, as_=, params=)` POSTs the attachment to the control plane and returns a `Model` handle whose `.attachment` carries the admission result (`state`, `node`, `rev`). It raises `AttachRefused` with a reason when no node can serve it. `detach(model)` removes it. The model's `params` are the catalog model's config (section 6); the typed classes `Detector`, `Monitor`, `Agent` fill in the catalog id and validate params. Sources you publish from Python expose runtime controls: `cam.set_bitrate(bps)`, `cam.keyframe()`, and with `hd_ring_s` set, `await cam.backfill(seconds=6, to_ts_us=..., link=link)`. `session.link(sources, budget_bps=...)` shares one budget between sources (demo 4). ## 4. JavaScript SDK Same verbs. In a backend, the SDK creates sessions and attaches models with the API key. In a browser, it connects with an access and is a viewer, a controller and a camera at once. The demo pages do both from the browser because they are demos. ```js import * as rgb from '@rgb-live/sdk'; const client = rgb.client({ apiKey: KEY, origin: 'https://rgb.example' }); const session = await client.sessions.create({ name: 'warehouse-demo', durationSeconds: 3600 }); const cam = await session.join({ as: 'camera', id: 'web0', publish: ['video'] }); const objects = await session.attach('sam3-detector-v1', { to: 'web0', as: 'objects', params: { prompts: ['person'] } }); // browser (or the same page) await session.connect(cam.access, { publish: { camera: await navigator.mediaDevices.getUserMedia({ video: true }) } }); objects.watch((rec) => drawBoxes(rec)); // data, not burned-in pixels session.watch('web0/objects', drawBoxes); // the same, by path await session.watchVideo('web0', (frame) => ctx.drawImage(frame, 0, 0)); // decoded VideoFrame ``` `session.attach(new rgb.Detector({ prompts }), { to, as })` is the typed form. `attach` rejects with `rgb.AttachRefused` when there is no capacity. `model.outputs..watch(cb)` subscribes to any named output; `session.watch(path, cb)` to any by path. `session.graph()` returns the current graph (section 7) and `session.watchGraph(cb)` follows it. ## 5. The four demos, as code Each demo is the same session model with different participants and attachments. Read sections 3 and 4 first for the verbs. Lines marked `# proposed` use API that is designed but not yet implemented. **Demo 1: open-vocabulary detection.** Type what to look for; boxes and track IDs come back as data. ```python session = rgb.sessions.create(name="warehouse-demo") cam = session.add_source(rgb.FileSource("clips/unitree_warehouse_60s.mp4", id="cam0", loop=True)) objects = session.attach("sam3-detector-v1", to=cam, as_="objects", params={"prompts": ["spray can", "robot dog", "hand"]}) @objects.watch # one record per processed frame def on_detections(rec): print(rec["frame_ts"], [(t["label"], t["track_id"]) for t in rec["tracks"]]) session.run() ``` ```js const session = await client.sessions.create({ name: 'warehouse-demo' }); const objects = await session.attach('sam3-detector-v1', { to: 'cam0', as: 'objects', params: { prompts: ['person'] } }); await session.connect(); // viewer objects.watch(drawBoxes); // data on a canvas, not pixels in the video await session.watchVideo('cam0', (frame) => ctx.drawImage(frame, 0, 0)); ``` **Demo 2: natural-language alerts.** Watch for an incident described in plain language. A cheap detector gates every frame; VLMs confirm and explain before anything is raised. ```python cam = session.source("cam0") # published by someone else alerts = session.attach("qwen-monitor-v1", to=cam, as_="alerts", params={"rules": ["a fire broke out", "a person is in the robot's path"]}) @alerts.watch # confirmed events only def on_event(e): page_operator(f"{e['camera_id']}: {e['rule']} — {e['reason']}") alerts.outputs.checks.watch(log) # every VLM call, for the timeline session.run() ``` ```js const alerts = await session.attach('qwen-monitor-v1', { to: 'cam0', as: 'alerts', params: { rules: ['a fire broke out', "a person is in the robot's path"] } }); alerts.watch((e) => showAlert(`${e.rule} — ${e.reason}`)); // confirmed events only alerts.outputs.checks.watch((c) => timeline.add(c)); // every VLM call, for the timeline ``` When a fire breaks out, the detector (watching for "fire", "smoke", derived from the rule) wakes the monitor within a second, the screen and verify steps take about 2 s, and one event per cooldown is emitted with the frames it was judged on in `clip_ref`. The dynamic graph in one example: attach a second model only when the first one sees something. ```python @objects.watch def on_detections(rec): if any(t["label"] == "person" for t in rec["tracks"]) and "cam0/depth" not in session.graph()["attachments"]: session.attach("depth-anything-v2", to=cam, as_="depth") # admitted now, on a node with room session.record(pre_roll_seconds=10, seconds=30) # proposed ``` **Demo 3: an agent drives a world, a person can take over.** A simulator (MuJoCo today; Cosmos or any world model later) joins as a participant that publishes video and reads a `commands` input. An Agent attached to it looks at the frame and writes commands from a plain-language instruction; a viewer watches the same video and publishes the same input to take the wheel. The world decides who wins (the operator while their commands are fresh). ```python world = session.add_source(rgb.Node("world", publishes=["video"], subscribes=["commands"])) pilot = session.attach("qwen-agent-v1", to=world, as_="pilot", params={"prompt": "drive to the red crate and push it", "rate_hz": 2}) @world.inputs.commands.watch # (publisher, record): the agent's or a person's def on_command(who, cmd): ... state = await world.output("state", snapshot=True) # anything the world wants to say, as a JSON snapshot world.write(frame_rgb, timestamp_us) # one frame in, H.264 out ``` ```js await session.watchVideo('world', draw); // watch what the pilot sees session.publish('world', 'commands', { throttle: 1, steer: 0, role: 'operator' }); // a person takes the wheel session.watchOutput('world', 'state', showWhoIsDriving, { snapshot: true }); pilot.watch((rec) => timeline.add(rec.command.reason)); // the agent explains each decision ``` Inputs live at `{session}/{node}/{input}/{publisher}` (one broadcast per publisher, so an agent and a person never collide); the node sees every publisher and applies its own rule. The agent publishes to the world's input and to its own output track `{session}/world/pilot` (`commands`, one record per decision with the frame it looked at, the command and the reason). **Recording.** `session.record(to, as_="rec", pre_roll_s=10, seconds=30, tracks=["objects:detections"])` is an attachment of `record-v1`, admitted onto the recorder node like any model. `to` is a participant (`"cam0"`) or a model's video output (`"cam0/depth"`). The recorder subscribes the Hang track with the relay's retention window (that is the pre-roll), writes the encoded frames into an MP4 as they arrive (Mediabunny; no decode, no re-encode; timestamps kept on the session clock), writes each listed JSON track as JSONL, uploads, and reports to the registry. Detach to stop early; with `seconds` it detaches itself. Playback is a plain `