@vdoninja/ninja-p2p 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,67 @@
1
+ /**
2
+ * P2P Message Protocol
3
+ *
4
+ * Defines the wire format for all messages exchanged over VDO.Ninja
5
+ * WebRTC data channels. This module has zero dependencies on stevesbot
6
+ * internals — it can be extracted and reused by any bot.
7
+ */
8
+ import { randomBytes, randomUUID } from "node:crypto";
9
+ // ── Helpers ──────────────────────────────────────────────────────────────────
10
+ /** Generate a short unique message ID. */
11
+ export function createMessageId() {
12
+ return randomBytes(12).toString("base64url");
13
+ }
14
+ /** Generate a unique instance ID (per-process, distinguishes restarts). */
15
+ export function createInstanceId() {
16
+ return randomUUID().slice(0, 8);
17
+ }
18
+ /** Generate a cryptographically random room name. */
19
+ export function generateRoomName() {
20
+ return "clawd_" + randomBytes(16).toString("hex");
21
+ }
22
+ /** Create a full message envelope. */
23
+ export function createEnvelope(from, type, payload, options) {
24
+ return {
25
+ v: 1,
26
+ id: createMessageId(),
27
+ type,
28
+ from,
29
+ to: options?.to ?? null,
30
+ topic: options?.topic ?? null,
31
+ ts: Date.now(),
32
+ payload,
33
+ };
34
+ }
35
+ /** Validate that a received object looks like a MessageEnvelope. */
36
+ export function isValidEnvelope(data) {
37
+ if (typeof data !== "object" || data === null)
38
+ return false;
39
+ const d = data;
40
+ return (d.v === 1 &&
41
+ typeof d.id === "string" &&
42
+ typeof d.type === "string" &&
43
+ typeof d.from === "object" &&
44
+ d.from !== null &&
45
+ typeof d.from.streamId === "string" &&
46
+ typeof d.ts === "number");
47
+ }
48
+ /** Parse raw data received from a data channel into an envelope. Returns null on failure. */
49
+ export function parseEnvelope(raw) {
50
+ try {
51
+ const data = typeof raw === "string" ? JSON.parse(raw) : raw;
52
+ if (isValidEnvelope(data))
53
+ return data;
54
+ return null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ /**
61
+ * Wrap a payload into a sendable JSON object.
62
+ * The VDO.Ninja SDK sends objects via sendData — this ensures our envelope
63
+ * is the top-level object sent over the wire.
64
+ */
65
+ export function envelopeToWire(envelope) {
66
+ return { ...envelope };
67
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * VDO Bridge
3
+ *
4
+ * Wraps the @vdoninja/sdk to provide a high-level P2P communication layer.
5
+ * Manages the SDK lifecycle, peers, message routing, auto-announce,
6
+ * heartbeat pings, offline queue flushing, and graceful shutdown.
7
+ *
8
+ * Zero dependencies on stevesbot internals.
9
+ */
10
+ import { EventEmitter } from "node:events";
11
+ import { MessageBus, type MessageBusOptions } from "./message-bus.js";
12
+ import { PeerRegistry } from "./peer-registry.js";
13
+ import { type AnnouncePayload, type MessageEnvelope, type PeerIdentity } from "./protocol.js";
14
+ export type VDOBridgeOptions = {
15
+ room: string;
16
+ streamId: string;
17
+ identity: Omit<PeerIdentity, "instanceId">;
18
+ password?: string | false;
19
+ host?: string;
20
+ forceTurn?: boolean;
21
+ debug?: boolean;
22
+ /** Skills to announce. */
23
+ skills?: string[];
24
+ /** Topics to subscribe to. */
25
+ topics?: string[];
26
+ /** Heartbeat interval in ms. Default: 30000. */
27
+ heartbeatMs?: number;
28
+ /** MessageBus options. */
29
+ busOptions?: MessageBusOptions;
30
+ };
31
+ export declare class VDOBridge extends EventEmitter {
32
+ readonly peers: PeerRegistry;
33
+ readonly bus: MessageBus;
34
+ readonly identity: PeerIdentity;
35
+ private sdk;
36
+ private readonly options;
37
+ private heartbeatTimer;
38
+ private connected;
39
+ private skills;
40
+ private status;
41
+ private statusDetail;
42
+ private version;
43
+ constructor(options: VDOBridgeOptions);
44
+ connect(): Promise<void>;
45
+ disconnect(): Promise<void>;
46
+ isConnected(): boolean;
47
+ /** Update skills and broadcast the change. */
48
+ updateSkills(skills: string[]): void;
49
+ /** Update status and broadcast the change. */
50
+ updateStatus(status: string, detail?: string): void;
51
+ /** Get current announce payload. */
52
+ getAnnouncePayload(): AnnouncePayload;
53
+ /** Send a chat message to everyone or a specific peer. */
54
+ chat(text: string, to?: string): MessageEnvelope;
55
+ /** Send a chat message to a topic. */
56
+ chatTopic(topic: string, text: string): MessageEnvelope;
57
+ /** Send a command to a specific peer. */
58
+ command(targetStreamId: string, command: string, args?: unknown): MessageEnvelope;
59
+ /** Publish an event to a topic. */
60
+ publishEvent(topic: string, kind: string, data?: unknown): MessageEnvelope;
61
+ private wireSDKEvents;
62
+ private startHeartbeat;
63
+ private stopHeartbeat;
64
+ private respondPong;
65
+ private broadcastSkillUpdate;
66
+ }
@@ -0,0 +1,318 @@
1
+ /**
2
+ * VDO Bridge
3
+ *
4
+ * Wraps the @vdoninja/sdk to provide a high-level P2P communication layer.
5
+ * Manages the SDK lifecycle, peers, message routing, auto-announce,
6
+ * heartbeat pings, offline queue flushing, and graceful shutdown.
7
+ *
8
+ * Zero dependencies on stevesbot internals.
9
+ */
10
+ import { EventEmitter } from "node:events";
11
+ import { MessageBus } from "./message-bus.js";
12
+ import { PeerRegistry } from "./peer-registry.js";
13
+ import { createEnvelope, createInstanceId, envelopeToWire, parseEnvelope, } from "./protocol.js";
14
+ // ── Class ────────────────────────────────────────────────────────────────────
15
+ export class VDOBridge extends EventEmitter {
16
+ peers;
17
+ bus;
18
+ identity;
19
+ sdk = null;
20
+ options;
21
+ heartbeatTimer = null;
22
+ connected = false;
23
+ skills;
24
+ status = "idle";
25
+ statusDetail = "";
26
+ version = "0.1.0";
27
+ constructor(options) {
28
+ super();
29
+ this.options = options;
30
+ this.identity = {
31
+ streamId: options.streamId,
32
+ role: options.identity.role,
33
+ name: options.identity.name,
34
+ instanceId: createInstanceId(),
35
+ };
36
+ this.skills = options.skills ?? [];
37
+ this.peers = new PeerRegistry();
38
+ this.bus = new MessageBus(this.identity, this.peers, options.busOptions);
39
+ // Subscribe to requested topics
40
+ for (const topic of options.topics ?? []) {
41
+ this.bus.subscribe(topic);
42
+ }
43
+ }
44
+ // ── Lifecycle ────────────────────────────────────────────────────────────
45
+ async connect() {
46
+ if (this.connected)
47
+ return;
48
+ // Dynamic import for CJS SDK in ESM context
49
+ const { createRequire } = await import("node:module");
50
+ const require = createRequire(import.meta.url);
51
+ const VDONinjaSDK = require("@vdoninja/sdk");
52
+ this.sdk = new VDONinjaSDK({
53
+ host: this.options.host ?? "wss://wss.vdo.ninja",
54
+ debug: this.options.debug ?? false,
55
+ forceTURN: this.options.forceTurn ?? false,
56
+ });
57
+ this.wireSDKEvents();
58
+ // Set the send function on the bus
59
+ this.bus.setSendDataFn((data, target) => {
60
+ if (!this.sdk)
61
+ return;
62
+ try {
63
+ this.sdk.sendData(data, target ?? undefined);
64
+ }
65
+ catch (err) {
66
+ this.emit("error", err);
67
+ }
68
+ });
69
+ await this.sdk.connect();
70
+ // Build joinRoom options
71
+ const joinOpts = { room: this.options.room };
72
+ if (this.options.password !== undefined) {
73
+ joinOpts.password = this.options.password;
74
+ }
75
+ await this.sdk.joinRoom(joinOpts);
76
+ // Announce ourselves as a data-only publisher
77
+ await this.sdk.announce({ streamID: this.options.streamId });
78
+ this.connected = true;
79
+ this.startHeartbeat();
80
+ console.log(`[P2P] Connected to room "${this.options.room}" as "${this.options.streamId}"`);
81
+ this.emit("connected");
82
+ }
83
+ async disconnect() {
84
+ if (!this.connected || !this.sdk)
85
+ return;
86
+ this.stopHeartbeat();
87
+ // Send a leaving event to all peers
88
+ try {
89
+ const envelope = createEnvelope(this.identity, "event", { kind: "leaving" });
90
+ this.sdk.sendData(envelopeToWire(envelope));
91
+ }
92
+ catch { /* best effort */ }
93
+ try {
94
+ this.sdk.leaveRoom();
95
+ }
96
+ catch { /* best effort */ }
97
+ try {
98
+ this.sdk.disconnect();
99
+ }
100
+ catch { /* best effort */ }
101
+ this.connected = false;
102
+ this.sdk = null;
103
+ this.peers.clear();
104
+ console.log("[P2P] Disconnected.");
105
+ this.emit("disconnected");
106
+ }
107
+ isConnected() {
108
+ return this.connected;
109
+ }
110
+ // ── Identity Management ──────────────────────────────────────────────────
111
+ /** Update skills and broadcast the change. */
112
+ updateSkills(skills) {
113
+ this.skills = skills;
114
+ this.broadcastSkillUpdate();
115
+ }
116
+ /** Update status and broadcast the change. */
117
+ updateStatus(status, detail) {
118
+ this.status = status;
119
+ this.statusDetail = detail ?? "";
120
+ this.broadcastSkillUpdate();
121
+ }
122
+ /** Get current announce payload. */
123
+ getAnnouncePayload() {
124
+ return {
125
+ skills: this.skills,
126
+ status: this.status,
127
+ statusDetail: this.statusDetail,
128
+ version: this.version,
129
+ topics: this.bus.getSubscriptions(),
130
+ };
131
+ }
132
+ // ── Convenience Methods ──────────────────────────────────────────────────
133
+ /** Send a chat message to everyone or a specific peer. */
134
+ chat(text, to) {
135
+ if (to) {
136
+ return this.bus.send(to, "chat", { text });
137
+ }
138
+ return this.bus.broadcast("chat", { text });
139
+ }
140
+ /** Send a chat message to a topic. */
141
+ chatTopic(topic, text) {
142
+ return this.bus.publish(topic, "chat", { text });
143
+ }
144
+ /** Send a command to a specific peer. */
145
+ command(targetStreamId, command, args) {
146
+ return this.bus.send(targetStreamId, "command", { command, args });
147
+ }
148
+ /** Publish an event to a topic. */
149
+ publishEvent(topic, kind, data) {
150
+ return this.bus.publish(topic, "event", { kind, ...((data && typeof data === "object") ? data : { data }) });
151
+ }
152
+ // ── SDK Event Wiring ─────────────────────────────────────────────────────
153
+ wireSDKEvents() {
154
+ if (!this.sdk)
155
+ return;
156
+ // Peer connected (WebRTC connection established)
157
+ this.sdk.addEventListener("peerConnected", (event) => {
158
+ const uuid = event.detail?.uuid ?? "unknown";
159
+ const streamId = event.detail?.streamID ?? uuid;
160
+ this.peers.addPeer(streamId, uuid);
161
+ this.emit("peer:connected", { streamId, uuid });
162
+ });
163
+ // Data channel opened — send our announce
164
+ this.sdk.addEventListener("dataChannelOpen", (event) => {
165
+ const uuid = event.detail?.uuid ?? "unknown";
166
+ const mappedStreamId = this.peers.streamIdForUuid(uuid);
167
+ const eventStreamId = event.detail?.streamID;
168
+ const streamId = mappedStreamId ?? (eventStreamId && eventStreamId !== this.options.streamId ? eventStreamId : uuid);
169
+ // Send announce to this specific peer
170
+ const announce = createEnvelope(this.identity, "announce", this.getAnnouncePayload(), { to: streamId });
171
+ try {
172
+ this.sdk.sendData(envelopeToWire(announce), { UUID: uuid });
173
+ }
174
+ catch { /* peer may have disconnected */ }
175
+ // Flush any queued offline messages for this peer
176
+ const flushed = this.bus.flushOfflineQueue(streamId);
177
+ if (flushed.length > 0) {
178
+ console.log(`[P2P] Flushed ${flushed.length} queued messages to ${streamId}`);
179
+ }
180
+ this.emit("datachannel:open", { streamId, uuid });
181
+ });
182
+ // Data received — parse and route through MessageBus
183
+ this.sdk.addEventListener("dataReceived", (event) => {
184
+ const raw = event.detail?.data;
185
+ const uuid = event.detail?.uuid ?? "unknown";
186
+ const envelope = parseEnvelope(raw);
187
+ if (!envelope) {
188
+ // Not our protocol — emit as raw data for consumers who want it
189
+ this.emit("rawData", { data: raw, uuid });
190
+ return;
191
+ }
192
+ // If we don't have a streamId mapping for this uuid, use the envelope's from
193
+ const senderStreamId = envelope.from.streamId;
194
+ if (!this.peers.getPeer(senderStreamId)) {
195
+ const orphanPeer = this.peers.getPeer(uuid);
196
+ if (orphanPeer && orphanPeer.streamId === uuid) {
197
+ this.peers.rekeyPeer(uuid, senderStreamId);
198
+ }
199
+ else {
200
+ this.peers.addPeer(senderStreamId, uuid);
201
+ }
202
+ }
203
+ // Handle protocol-level messages
204
+ switch (envelope.type) {
205
+ case "announce":
206
+ this.peers.updateFromAnnounce(senderStreamId, envelope.from, envelope.payload);
207
+ this.emit("peer:announce", { streamId: senderStreamId, identity: envelope.from, announce: envelope.payload });
208
+ break;
209
+ case "skill_update":
210
+ this.peers.updateFromSkillUpdate(senderStreamId, envelope.payload);
211
+ break;
212
+ case "ping":
213
+ // Respond with pong
214
+ this.respondPong(senderStreamId, envelope);
215
+ break;
216
+ case "pong":
217
+ // Just update last-seen (already done in bus.handleIncoming)
218
+ break;
219
+ case "history_request": {
220
+ // Send recent history to the requesting peer
221
+ const count = typeof envelope.payload === "object" && envelope.payload !== null
222
+ ? envelope.payload.count ?? 50
223
+ : 50;
224
+ const history = this.bus.getHistory(count);
225
+ for (const msg of history) {
226
+ const replay = createEnvelope(this.identity, "history_replay", msg, { to: senderStreamId });
227
+ try {
228
+ this.sdk.sendData(envelopeToWire(replay), { UUID: uuid });
229
+ }
230
+ catch { /* best effort */ }
231
+ }
232
+ break;
233
+ }
234
+ }
235
+ // Route through MessageBus for application-level handling
236
+ this.bus.handleIncoming(envelope);
237
+ });
238
+ // Peer disconnected
239
+ this.sdk.addEventListener("peerDisconnected", (event) => {
240
+ const uuid = event.detail?.uuid ?? "unknown";
241
+ const streamId = event.detail?.streamID ?? this.peers.streamIdForUuid(uuid) ?? uuid;
242
+ this.peers.markDisconnected(streamId);
243
+ console.log(`[P2P] Peer disconnected: ${streamId}`);
244
+ this.emit("peer:disconnected", { streamId, uuid });
245
+ });
246
+ // SDK-level connection events
247
+ this.sdk.addEventListener("disconnected", () => {
248
+ console.log("[P2P] WebSocket disconnected, SDK will attempt reconnect...");
249
+ this.emit("ws:disconnected");
250
+ });
251
+ this.sdk.addEventListener("reconnected", () => {
252
+ console.log("[P2P] WebSocket reconnected.");
253
+ this.emit("ws:reconnected");
254
+ });
255
+ // Room listing (existing peers when we join)
256
+ this.sdk.addEventListener("listing", (event) => {
257
+ const list = event.detail?.list ?? [];
258
+ for (const entry of list) {
259
+ if (entry.streamID && entry.streamID !== this.options.streamId) {
260
+ // View each existing peer to establish data channels
261
+ this.sdk.view(entry.streamID, { audio: false, video: false });
262
+ }
263
+ }
264
+ });
265
+ // Error handling
266
+ this.sdk.addEventListener("error", (event) => {
267
+ console.error("[P2P] SDK error:", event.detail?.error);
268
+ this.emit("error", event.detail?.error);
269
+ });
270
+ }
271
+ // ── Heartbeat ────────────────────────────────────────────────────────────
272
+ startHeartbeat() {
273
+ const interval = this.options.heartbeatMs ?? 30_000;
274
+ this.heartbeatTimer = setInterval(() => {
275
+ if (!this.connected || !this.sdk)
276
+ return;
277
+ const ping = createEnvelope(this.identity, "ping", { ts: Date.now() });
278
+ try {
279
+ this.sdk.sendData(envelopeToWire(ping));
280
+ }
281
+ catch { /* connection may be lost */ }
282
+ }, interval);
283
+ // Don't block process exit
284
+ if (this.heartbeatTimer.unref)
285
+ this.heartbeatTimer.unref();
286
+ }
287
+ stopHeartbeat() {
288
+ if (this.heartbeatTimer) {
289
+ clearInterval(this.heartbeatTimer);
290
+ this.heartbeatTimer = null;
291
+ }
292
+ }
293
+ respondPong(targetStreamId, pingEnvelope) {
294
+ if (!this.sdk)
295
+ return;
296
+ const pong = createEnvelope(this.identity, "pong", {
297
+ pingId: pingEnvelope.id,
298
+ pingTs: pingEnvelope.payload?.ts,
299
+ pongTs: Date.now(),
300
+ }, { to: targetStreamId });
301
+ const peer = this.peers.getPeer(targetStreamId);
302
+ if (peer) {
303
+ try {
304
+ this.sdk.sendData(envelopeToWire(pong), { UUID: peer.uuid });
305
+ }
306
+ catch { /* best effort */ }
307
+ }
308
+ }
309
+ broadcastSkillUpdate() {
310
+ if (!this.connected)
311
+ return;
312
+ this.bus.broadcast("skill_update", {
313
+ skills: this.skills,
314
+ status: this.status,
315
+ statusDetail: this.statusDetail,
316
+ });
317
+ }
318
+ }
package/package.json ADDED
@@ -0,0 +1,78 @@
1
+ {
2
+ "name": "@vdoninja/ninja-p2p",
3
+ "version": "0.1.0",
4
+ "description": "Reusable P2P agent communication module using VDO.Ninja WebRTC data channels",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./dist/index.d.ts",
11
+ "import": "./dist/index.js"
12
+ },
13
+ "./vdo-bridge": {
14
+ "types": "./dist/vdo-bridge.d.ts",
15
+ "import": "./dist/vdo-bridge.js"
16
+ },
17
+ "./message-bus": {
18
+ "types": "./dist/message-bus.d.ts",
19
+ "import": "./dist/message-bus.js"
20
+ },
21
+ "./peer-registry": {
22
+ "types": "./dist/peer-registry.d.ts",
23
+ "import": "./dist/peer-registry.js"
24
+ },
25
+ "./protocol": {
26
+ "types": "./dist/protocol.d.ts",
27
+ "import": "./dist/protocol.js"
28
+ },
29
+ "./dashboard.html": "./dashboard.html",
30
+ "./package.json": "./package.json"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "dashboard.html",
35
+ "README.md",
36
+ "skills/ninja-p2p"
37
+ ],
38
+ "sideEffects": false,
39
+ "scripts": {
40
+ "test": "tsx --test tests/**/*.test.ts",
41
+ "prebuild": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"",
42
+ "build": "tsc -p tsconfig.json",
43
+ "prepare": "npm run build"
44
+ },
45
+ "keywords": ["p2p", "webrtc", "vdo.ninja", "agent", "pub-sub", "data-channel", "bot"],
46
+ "author": "Steve Seguin",
47
+ "license": "MIT",
48
+ "publishConfig": {
49
+ "access": "public"
50
+ },
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "repository": {
55
+ "type": "git",
56
+ "url": "git+https://github.com/steveseguin/ninja-p2p.git"
57
+ },
58
+ "bugs": {
59
+ "url": "https://github.com/steveseguin/ninja-p2p/issues"
60
+ },
61
+ "homepage": "https://github.com/steveseguin/ninja-p2p#readme",
62
+ "dependencies": {
63
+ "@vdoninja/sdk": "^1.3.18"
64
+ },
65
+ "peerDependencies": {
66
+ "@roamhq/wrtc": "^0.8.0"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "@roamhq/wrtc": {
70
+ "optional": true
71
+ }
72
+ },
73
+ "devDependencies": {
74
+ "@types/node": "^22.0.0",
75
+ "tsx": "^4.19.0",
76
+ "typescript": "^5.8.0"
77
+ }
78
+ }
@@ -0,0 +1,74 @@
1
+ ---
2
+ name: ninja-p2p
3
+ description: Build bot-to-bot communication over VDO.Ninja WebRTC data channels. Use this skill when the user wants agents or services to meet in a room, discover peers, send private messages, broadcast events, or keep lightweight P2P presence without a central relay.
4
+ ---
5
+
6
+ # ninja-p2p
7
+
8
+ Use this skill when a project needs room-based peer discovery and direct messaging between bots over WebRTC.
9
+
10
+ ## What This Repo Provides
11
+
12
+ - `VDOBridge` for connection lifecycle, room join, announce, heartbeat, and SDK wiring
13
+ - `PeerRegistry` for presence, skills, topics, and status tracking
14
+ - `MessageBus` for broadcast, direct messages, topic-based fanout, history replay, and offline queueing
15
+ - `dashboard.html` for a browser-side room monitor and chat UI
16
+
17
+ ## Install The Library In A Project
18
+
19
+ Install it from npm:
20
+
21
+ ```bash
22
+ npm install @vdoninja/ninja-p2p @roamhq/wrtc
23
+ ```
24
+
25
+ `@vdoninja/sdk` is installed transitively. `ws` comes from the SDK in Node environments.
26
+
27
+ ## Add This Skill To Codex
28
+
29
+ Use Codex's built-in `skill-installer` helper and install the `skills/ninja-p2p` path from this repo, then restart Codex.
30
+
31
+ ## Default Integration Pattern
32
+
33
+ 1. Pick a shared `room` for the bots that should discover each other.
34
+ 2. Give each bot a unique `streamId`.
35
+ 3. Connect with `VDOBridge`.
36
+ 4. Use `bridge.chat(text)` for room-wide messages.
37
+ 5. Use `bridge.chat(text, targetStreamId)` or `bridge.command(targetStreamId, command, args)` for private messages.
38
+ 6. Use `bridge.publishEvent(topic, kind, data)` for topic fanout.
39
+
40
+ ## Example
41
+
42
+ ```ts
43
+ import { VDOBridge } from "@vdoninja/ninja-p2p";
44
+
45
+ const bridge = new VDOBridge({
46
+ room: "agents_room",
47
+ streamId: "planner_bot",
48
+ identity: {
49
+ streamId: "planner_bot",
50
+ role: "agent",
51
+ name: "Planner",
52
+ },
53
+ password: false,
54
+ skills: ["plan", "chat"],
55
+ topics: ["events"],
56
+ });
57
+
58
+ await bridge.connect();
59
+
60
+ bridge.chat("Planner online");
61
+ bridge.chat("sync now", "worker_bot");
62
+ bridge.publishEvent("events", "status_change", { status: "busy" });
63
+
64
+ bridge.bus.on("message:chat", (envelope) => {
65
+ console.log(`${envelope.from.name}: ${envelope.payload.text}`);
66
+ });
67
+ ```
68
+
69
+ ## Notes
70
+
71
+ - `room` and `streamId` should be stable and human-readable, but unique enough to avoid collisions.
72
+ - Private messages target peer `streamId`s.
73
+ - Topic messages are broadcast and filtered on the receiver side.
74
+ - The browser dashboard can sit in the same room as the bots for live inspection.