Module: network
Distributed multi-user collaboration: connect this node to other users' nodes, share panes live, and let your agent talk to theirs. The frontend is a Peers widget plus settings; the heavy lifting is the backend peer fabric documented in Distributed peer fabric.
Status: implemented (groundwork). Frontend in
packages/core/src/modules/network/, backend in backend/modules/network/.
Contributions to the layout shell
All five network widgets form the Peer Fabric panel group (network.fabric).
Opening any one of them shows a companion strip on the right edge of the pane with
toggle buttons for the other members — click to expand a companion into the
workspace, click again to close ("rail") it. See
Panel Groups for the architecture.
- Widget
network.peers(Peers) —PeersWidget.tsx. The group primary (hub). Shows this node's identity and connected peers with status dots; dial a peer byws://…/peer-wsaddress, generate an invite link, or redeem one to pair. Exposes presence to the local agent viauseAgentContext(so the model can fillagent.ask_peer(peerId)). - Widget
network.lobby(Lobby) —LobbyPanel.tsx. Connect to a lobby server, see hosted rooms, host one, or join one — joining hands off to a direct P2P link (relay fallback). See the lobby system. - Widget
network.monitor(Peer Monitor) —PeerMonitor.tsx. Live link health per peer: transport, round-trip time, and throughput (bytes/messages in/out), streamed from the backend Peer Monitor heartbeat. - Widget
network.chat(Peer Chat) —PeerChatPanel.tsx. Direct 1:1 messaging with a connected peer over thepeerchatchannel. - Widget
network.relay(Agent Relay) —AgentRelayPanel.tsx. Ask a peer's agent a question and read its (gated, read-only) answer — the UI overagent.ask_peer. - Commands
network.open(Open peers),network.openLobby(Open lobby),network.openMonitor(Open peer monitor),network.openChat(Open peer chat),network.openRelay(Ask a peer agent). - Settings (see below).
Network-aware panes (the collab contract)
A pane declares it participates in the peer fabric through the collab field
on its PanelDecl/WidgetDecl (the CollabDecl shape in
@horribledashboard/sdk):
collab?: {
room: 'shared' | 'instance'; // one well-known room, or one room per pane instance
key?: string; // key suffix; defaults to the view id
autoShare?: boolean; // start shared on open (default false — opt-in)
}
This makes networking a declared, first-class property of a pane rather than
something each pane hand-wires. Panes consume it through the useCollab(paneKey)
host hook (exported from the network module), which owns joining/leaving the room,
tracking the authoritative revision, rebasing on a rejected (stale) op, and
surfacing a live presence count — returning { text, setText, shared, setShared, members }. The scratch panel is the reference consumer.
The agent-to-agent tools list_peers and agent.ask_peer are backend-static
(resolved in the orchestrator against the PeerHub), so unlike other modules they
need no frontend agentTools handler — there's nothing for a browser to execute.
They're documented in Agent chat.
Backend surface
REST — /api/network
| Method & path | Purpose |
|---|---|
GET /identity | This node's NodeIdentity (node_id, public_key, node_name, capabilities). |
GET /peers | { self, peers[] } snapshot. |
POST /invite | Mint a single-use invite { invite, token, expires }. |
POST /pair | Redeem an invite (decode → dial → handshake with the token). |
POST /connect | Dial an address directly { address, transport }. |
DELETE /peers/{node_id} | Disconnect a peer. |
POST /ask-peer | Ask a peer's agent { peer_id, prompt } → { ok, answer?, error? } (REST entry to agent.ask_peer; the remote turn is gated read-only). |
WebSocket endpoints
-
/peer-ws— inbound peer connections (the signedPeerEnvelopeprotocol between nodes; not the browser channel protocol). Distinct from/ws. -
/wsnetworkchannel — live presence + control for the browser:Direction event data client→server list_peers{}client→server list_metrics{}(request an immediate Peer Monitor snapshot)client→server connect{ address?, nodeId?, transport }client →server disconnect{ nodeId }client→server pair_redeem{ invite }server→client peers{ self, peers[] }server→client peer_update{ peer }server→client peer_metrics{ metrics[] }(periodic + on request — see Peer Monitor)server→client pair_result{ ok, peer?, error? }server→client error{ message } -
/wscollabchannel — shared-pane sync (see below). -
/wspeerchatchannel — direct 1:1 peer messaging (see Peer Chat). -
/wslobbychannel — discovery + rooms for the browser:Direction event data client→server state{}(request a snapshot)client→server connect{ url? }(connect to a lobby server)client→server list_rooms/create_room/join_room/leave_room{ … }server→client state{ connected, url, rooms[], directory[], self }server→client rooms{ rooms[] }server→client directory{ directory[] }server→client room_created/joined/peer_joining/error{ … }
The lobby server itself is a standalone app (lobby_server.py) exposing
/lobby-ws (directory + rooms + signaling) and the bundled /relay-ws for the
fallback path; run it separately:
uv run uvicorn backend.modules.network.lobby_server:app --port 9000. A node opts in
via network.lobbyUrl. See
the lobby system.
Collaborative shared panes
A CollabRoom (collab.py), keyed by an opaque paneKey, holds authoritative
text + a monotonic rev. Members sync with a rev check — last-writer-wins,
but no lost updates: an op with a stale baseRev is rejected and the writer
rebases onto the authoritative state. Accepted ops are forwarded to connected peers
(collab_op on the peer wire); inbound peer ops are adopted as authoritative by
rev and rebroadcast to local members (not re-forwarded, so no loops). This is
groundwork, not a CRDT — a clean seam for one later.
| Direction | event | data |
|---|---|---|
| client→server | join | { paneKey } |
| client→server | leave | { paneKey } |
| client→server | op | { paneKey, baseRev, text } |
| server→client | state | { paneKey, rev, text, members } |
| server→client | op | { paneKey, rev, text, from } |
| server→client | presence | { paneKey, members } (occupancy changed) |
| server→client | rejected | { paneKey, rev, text } |
The reference consumer is the scratch panel's "Share" toggle, which declares a
collab room and drives the useCollab
hook (modules/scratch/index.tsx + modules/network/useCollab.ts), joining a
well-known room so notes sync across users and showing a live editor count. See
Scratch.
Peer Monitor
A backend PeerMonitor (monitor.py, started by the app lifespan) heartbeats
every connected peer on an interval: it pings each (the hub's ping/pong
round-trip) to measure RTT and samples the per-session byte/message counters the
hub maintains. Each tick is broadcast as a peer_metrics event on the network
channel; a freshly opened monitor requests an immediate snapshot with
list_metrics. The network.monitor widget renders the table. The monitor is
read-only — it never mutates peer state beyond stamping each session's rtt_ms.
Each PeerMetrics row: { node_id, node_name, transport, status, rtt_ms, bytes_in, bytes_out, msgs_in, msgs_out, last_seen }.
Peer Chat
Direct 1:1 messaging with a connected peer. A browser opens a conversation over the
/ws peerchat channel; the backend ChatManager (chat.py) relays the
message to that peer over the signed peer wire (a peer_chat envelope) and mirrors
it to this node's own tabs. Inbound peer messages are fanned out to every local
browser. History is held per-peer in memory, so a reopened panel gets the recent
backlog. Where collab syncs an editable document, peerchat is an
append-only message log.
| Direction | event | data |
|---|---|---|
| client→server | open | { nodeId } (subscribe + request backlog) |
| client→server | send | { nodeId, text } |
| client→server | close | { nodeId } |
| server→client | history | { nodeId, messages[] } |
| server→client | message | { id, nodeId, from, text, ts, direction } |
| server→client | error | { nodeId, message } |
Agent-to-agent relay
The network.relay widget (Agent Relay) is the UI over agent-to-agent: pick an
agent-capable peer, ask a question, and read the answer. It calls POST /api/network/ask-peer, a thin wrapper over the same agent_bridge.ask_peer the
agent.ask_peer tool uses, so the remote turn runs gated and read-only-by-default
on the peer's node (which must have network.allowRemoteAgent enabled). See
Agent chat.
Settings
| Key | Type | Default | Meaning |
|---|---|---|---|
network.nodeName | string | hostname | Display name advertised to peers. |
network.advertisedAddress | string | ws://localhost:8000/peer-ws | URL peers dial to reach this node (encoded into invites). |
network.enableDirect | boolean | true | Accept/dial direct WebSocket peers. |
network.enableLanDiscovery | boolean | false | Advertise/discover via mDNS. |
network.relayUrl | string | "" | Rendezvous broker URL (blank = off). |
network.trustMode | enum | manual | manual / directory / open-lan. |
network.directoryUrl | string | "" | Optional directory service. |
network.lobbyUrl | string | "" | Lobby server ws://…/lobby-ws for discovery + rooms (blank = off). |
network.iceEnabled | boolean | false | Gather a STUN server-reflexive candidate (public IP) alongside host/LAN candidates. |
network.stunServer | string | stun.l.google.com:19302 | host:port for STUN public-IP discovery (when ICE is on); also the STUN ICE server for the WebRTC transport. |
network.enableWebRtc | boolean | false | Connect over a WebRTC data channel (ICE/STUN NAT traversal), SDP signaled via the lobby. Needs the backend webrtc extra (uv sync --extra webrtc); the relay stays the fallback. |
network.turnUrl | string | "" | Optional TURN relay for WebRTC (turn:host:3478) for NATs STUN can't punch (blank = STUN only). |
network.turnUsername | string | "" | TURN username credential (if required). |
network.turnCredential | string | "" | TURN password/credential (if required). |
network.allowRemoteAgent | boolean | false | Let a trusted peer's agent ask yours. |
network.remoteAgentMode | enum | plan | Permission mode for a remote turn (plan = read-only). |
Server-side state under $HORRIBLE_DATA_DIR: network-identity.key,
network-peers.json, network-invites.json.
Browser vs desktop
| Browser | Desktop (Tauri) | |
|---|---|---|
Peers widget, REST, /ws channels | ✅ | ✅ |
| Direct / relay transports | ✅ (backend) | ✅ (backend) |
| LAN discovery (mDNS) | depends on the host network allowing multicast | same |
Everything runs in the backend, so both layouts behave identically; LAN discovery is the only capability that depends on the host network (and degrades gracefully when multicast is unavailable).