Skip to main content

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 by ws://…/peer-ws address, generate an invite link, or redeem one to pair. Exposes presence to the local agent via useAgentContext (so the model can fill agent.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 the peerchat channel.
  • Widget network.relay (Agent Relay) — AgentRelayPanel.tsx. Ask a peer's agent a question and read its (gated, read-only) answer — the UI over agent.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 & pathPurpose
GET /identityThis node's NodeIdentity (node_id, public_key, node_name, capabilities).
GET /peers{ self, peers[] } snapshot.
POST /inviteMint a single-use invite { invite, token, expires }.
POST /pairRedeem an invite (decode → dial → handshake with the token).
POST /connectDial an address directly { address, transport }.
DELETE /peers/{node_id}Disconnect a peer.
POST /ask-peerAsk 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 signed PeerEnvelope protocol between nodes; not the browser channel protocol). Distinct from /ws.

  • /ws network channel — live presence + control for the browser:

    Directioneventdata
    client→serverlist_peers{}
    client→serverlist_metrics{} (request an immediate Peer Monitor snapshot)
    client→serverconnect{ address?, nodeId?, transport }
    client→serverdisconnect{ nodeId }
    client→serverpair_redeem{ invite }
    server→clientpeers{ self, peers[] }
    server→clientpeer_update{ peer }
    server→clientpeer_metrics{ metrics[] } (periodic + on request — see Peer Monitor)
    server→clientpair_result{ ok, peer?, error? }
    server→clienterror{ message }
  • /ws collab channel — shared-pane sync (see below).

  • /ws peerchat channel — direct 1:1 peer messaging (see Peer Chat).

  • /ws lobby channel — discovery + rooms for the browser:

    Directioneventdata
    client→serverstate{} (request a snapshot)
    client→serverconnect{ url? } (connect to a lobby server)
    client→serverlist_rooms / create_room / join_room / leave_room{ … }
    server→clientstate{ connected, url, rooms[], directory[], self }
    server→clientrooms{ rooms[] }
    server→clientdirectory{ directory[] }
    server→clientroom_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.

Directioneventdata
client→serverjoin{ paneKey }
client→serverleave{ paneKey }
client→serverop{ paneKey, baseRev, text }
server→clientstate{ paneKey, rev, text, members }
server→clientop{ paneKey, rev, text, from }
server→clientpresence{ paneKey, members } (occupancy changed)
server→clientrejected{ 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.

Directioneventdata
client→serveropen{ nodeId } (subscribe + request backlog)
client→serversend{ nodeId, text }
client→serverclose{ nodeId }
server→clienthistory{ nodeId, messages[] }
server→clientmessage{ id, nodeId, from, text, ts, direction }
server→clienterror{ 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

KeyTypeDefaultMeaning
network.nodeNamestringhostnameDisplay name advertised to peers.
network.advertisedAddressstringws://localhost:8000/peer-wsURL peers dial to reach this node (encoded into invites).
network.enableDirectbooleantrueAccept/dial direct WebSocket peers.
network.enableLanDiscoverybooleanfalseAdvertise/discover via mDNS.
network.relayUrlstring""Rendezvous broker URL (blank = off).
network.trustModeenummanualmanual / directory / open-lan.
network.directoryUrlstring""Optional directory service.
network.lobbyUrlstring""Lobby server ws://…/lobby-ws for discovery + rooms (blank = off).
network.iceEnabledbooleanfalseGather a STUN server-reflexive candidate (public IP) alongside host/LAN candidates.
network.stunServerstringstun.l.google.com:19302host:port for STUN public-IP discovery (when ICE is on); also the STUN ICE server for the WebRTC transport.
network.enableWebRtcbooleanfalseConnect 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.turnUrlstring""Optional TURN relay for WebRTC (turn:host:3478) for NATs STUN can't punch (blank = STUN only).
network.turnUsernamestring""TURN username credential (if required).
network.turnCredentialstring""TURN password/credential (if required).
network.allowRemoteAgentbooleanfalseLet a trusted peer's agent ask yours.
network.remoteAgentModeenumplanPermission 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

BrowserDesktop (Tauri)
Peers widget, REST, /ws channels
Direct / relay transports✅ (backend)✅ (backend)
LAN discovery (mDNS)depends on the host network allowing multicastsame

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).