← The Kibitz Engine · deep dive
Video rooms on a LAN with no internet, no accounts, and nothing to install on the guests' side. Run one tiny program on one device on the local network — the LAN hub — and everyone else just opens kibitz.chat in a browser on that same Wi-Fi, and they're in a call. The hub is the rendezvous; on a phone Wi-Fi it also relays the (encrypted) media so the call works where browser-to-browser fails (see Media).
Not the internet TURN relay. Kibitz has two unrelated things. The internet TURN relay forwards an online call's encrypted media when a direct connection is blocked (see architecture.md §3). The LAN hub described here (Offline mode) replaces the internet entirely for a same-Wi-Fi call — and it runs its own small LAN TURN. The hub ships as the kibitz-offline project (a static Go binary, MIT) — see its ARCHITECTURE.md.
An offline call is a normal room with an un-guessable id, hosted on a hub, and joined by a link — exactly like an online call, except the link also carries the hub itself.
…/?galaxy=<blob>#<roomid> — <blob> is the hub (its fixed identity + LAN
address + TURN), <roomid> is the room.?galaxy= blob and
connects straight to it — no discovery, no scan — then joins the room id. Same
one tap as any online call.Where discovery fits — and where it doesn't. Discovery exists for exactly one moment: a creator reaching a hub that isn't their own device (a dedicated or headless relay — no screen to scan a QR off). Everyone you invite uses the link, which already names the hub, so they never discover. And there is deliberately no "browse and join a nearby call" — a room is reached only by its link, so a random person on the Wi-Fi can't wander into your call. (With the fixed identity, a link is really just discovery plus an IP hint; discovery is the link minus the IP.)
The relay is deliberately dumb — it assigns each browser an id and routes
{to, payload} frames between them, and nothing else (the contract the web client
talks to in galaxyHub.ts, send/broadcast). All call semantics (presence,
roster, media signaling) live in the web layer as frame payloads, so the box itself
almost never needs updating. It ships as a single static Go binary (a Pi appliance,
an Android app, or any laptop).
The relay scopes routing by room, so a single hub carries several independent
calls at once. A client declares its room with a join frame; the hub then returns
only same-room peers from peers, and a to only reaches a same-room peer
(relaycore idsInRoom / dstInRoom). The room is the join link's URL #hash —
…/?galaxy=…#ember-a3f9k2mq7p and …#opal-77x… are isolated calls on the same box.
This is exactly how the start-a-call model above stays private: every call gets a
fresh un-guessable room id (freshRoom), and the id is the capability — reaching the
(open) hub is not the same as being in a room. (A client that never sends join — an
older build, or iwhist — stays in the default shared room "", the back-compat path.)
A browser can dial WebRTC on its own but needs to exchange setup info ("signaling")
first — normally a server's job. The hub sidesteps that with a permanent identity:
a fixed UDP port, fixed ICE credentials, and a fixed DTLS certificate. That whole half
of the handshake packs into one short blob, which becomes the ?galaxy=… in a
link / QR. The web app reconstructs the hub's side of the handshake locally from the
blob — so there is zero per-session signaling (galaxySignal.ts).
The identity comes in two flavors:
--fixed-id=false): a unique
identity saved to disk, so a printed QR keeps working across restarts. Reachable only
via its own link/QR.relaycore/fixedid.go; default on for the desktop/Pi
binaries, always on in the Android app): every hub shares the same identity, and the
web bakes in the same constants (hubDiscover.ts). That's what makes discovery
possible — the app can probe the LAN for the well-known identity with no prior info
(used only at create-time; see above). WebRTC isn't subject to mixed-content rules, so
this works from the HTTPS PWA where an HTTP probe couldn't.The blob also persists in localStorage for the connection (galaxyHub.ts), but
routing keys on the blob in this URL's ?galaxy= (urlGalaxyBlob), never the
cache — so a blob cached from an earlier offline call can't hijack a normal online room.
The hub runs a small LAN TURN server. On a phone Wi-Fi, browser-to-browser media
often can't form a direct link — iOS (and others) hide each device's host candidate
behind an mDNS .local name the other phone can't resolve — so the media mesh dials
relay-only (iceTransportPolicy: 'relay') and audio, video, and the data
channel (chat, co-browse, ink) all route through the hub's TURN
(lanMesh.ts:88-94). That single change is what makes offline media actually work on
iPhone↔Android.
But the hub only ever sees ciphertext. Every link is DTLS-encrypted end-to-end
between the two browsers; the TURN relays the encrypted packets without the keys to
read them. So the hub carries your traffic but can never decrypt, record, or join it.
The mesh is otherwise the same battle-tested code and no-churn rules as the online mesh
(smaller id initiates each pair; audio + a placeholder video lane from the start; camera
toggles are replaceVideoTrack swaps, never a re-dial). LAN is a best-case environment —
gigabit, ~0 ms RTT — so a handful of tiles run great.
A
g1relay with no TURN falls back to direct browser-to-browser media (iceServers: []) — fine on a forgiving LAN, but it's why phone LANs needed the TURN.
Where an online room elects a migratory coordinator over the broker, the LAN room needs
none — the relay is an always-on box, so peers just self-assemble (lanRoom.ts). Each
browser periodically broadcasts a presence beacon (present: name / cam / avatar);
every peer builds the roster locally and reaps the silent ones (BEACON_MS = 2500,
REAP_MS = 8000). A newcomer's hi prompts everyone to re-announce; bye is a clean
leave. Identity = the hub id, stable for the session — all a LAN call needs.
RoomLink interface
(lanRoom.ts is the LAN twin of room.ts); the entire call UI (Tile / CallSurface /
widget panel, chat); the room's invite panel (QR + copy) — for an offline room
buildInvite just returns the current ?galaxy=<blob>#<roomid> URL, so Share works
unchanged.?galaxy=… blob codec (buildGalaxyBlob /
parseGalaxyBlob).kibitz-offline binary. It binds one UDP port, answers WebRTC
handshakes, and runs a LAN TURN; that's the whole job.clients.claim()).
The Android app bundles the web client, so the host cold-starts with no internet at
all. (The link carries the connection, not the app — a guest still needs the app
loaded once.)--fixed-id, default on): a downloaded/double-clicked hub
is found by the app with no QR. --fixed-id=false → a unique per-relay identity,
reachable only via its own link/QR..local hostname so a Pi without a static IP is reachable by name.--state) so the same link
works across restarts.kibitz-offline repo; the
binary makes no network calls home and carries no telemetry.disconnected states in seconds with no signaling.
Because the hub is always on, a peer can also just re-handshake through it.An earlier design (2026-06-07, not built) replaced the hub with QR codes exchanged
directly between phones — no relay box at all. Each pair did a mutual QR "kiss" (two QRs
carrying the ~100-byte variable part of each side's SDP), the first device acted as a
founder that relayed joiner↔joiner signaling over data channels, and the mesh assembled
from N−1 kisses. It was dropped in favor of the LAN hub: the hub needs no per-pair
ceremony, no minimal-SDP template codec (which drifts across browser versions), and
supports auto-reconnect — at the cost of one small always-on box. The privacy properties
are the same (a content-blind coordination point caught by the safety code). Kept here
only as design history.