Kibitz

The Kibitz Engine

One small, open, peer-to-peer engine — three ways to use it. Drop it on any page as a floating, end-to-end-encrypted call with one script tag; drive it headless to build your own UI on top of its rooms, media and data; or let an AI agent join a room as a participant, over the same encrypted channel people use. No accounts, no SDK, no build step.

Open source under the AGPL-3.0. No black box — read, audit, build, fork, and self-host every line of the engine: github.com/kibitz-chat/kibitz.

It's the same widget.js that powers kibitz.chat and whist.kibitz.chat — a full multiplayer card game built entirely on the headless engine, open source as a reference design. The widget renders inside a shadow root, so it can't clash with your styles — and your styles can't break it.

How AI fits in

A room is just a link, and an agent joins it the same way a person does — over the same peer-to-peer, end-to-end-encrypted channel, seeing what the room sees and acting right beside everyone. Because the engine is headless-first, an agent is simply a participant your code drives: it reads the room's shared state and browser view and acts back through the same data channel. It's an open platform — open protocol, open source — so any agent can be invited into any room. The live proof is our kibitzer: it pulls up a chair at a game of Whist, reads a hand, and calls the plays as they happen — over the very channel the humans use. The controller an agent drives is headless mode, below.

1 · The one-liner

Add this anywhere in your page. Everyone who opens it with the same data-room lands in the same call:

<script src="https://kibitz.chat/widget.js" data-room="room-3f9k2mq7p1"></script>

A small mic launcher appears in the corner; tapping it opens the call panel. That's the whole integration.

The room string is the invite — make it unguessable. Anyone who knows the room can join, so use a random id like room-3f9k2mq7p1 above — not a guessable word. Generate one however you like, e.g. 'room-' + crypto.randomUUID().slice(0, 8), then share the page URL only with the people you want in. A readable name like my-room is fine only when you actually want anyone who can guess it to be able to join.

2 · Mount it yourself

Leave off data-room and call Kibitz.mount() when you're ready — handy for SPAs, or to set the room dynamically:

<script src="https://kibitz.chat/widget.js"></script>
<script>
  const call = Kibitz.mount({ room: 'room-3f9k2mq7p1', name: 'Dana' })
  // later, e.g. when leaving the page / view:
  // call.unmount()
</script>

Options — Kibitz.mount(options)

OptionTypeDefaultWhat it does
roomstring— (required)The call everyone joins. Same string → same call.
namestring''Pre-fills the visitor's display name (they can still edit it).
startOpenbooleanfalseOpen the panel immediately instead of the corner launcher.
previewbooleanfalseShow the real panel as a local self-view only — never dials a room. For demos.

The auto-mount script tag accepts data-room and data-name, matching the first two options.

Returned object — MountedWidget

MemberWhat it does
unmount()Remove the widget and tear down the call.
broadcast(data)Send any structured-clone-able value to everyone else on the call (see co-browse below). No-op until the call's data link is up; you never receive your own message back.
sendTo(id, data)Send a value to one participant by id (e.g. a game's per-player hidden state). Delivered directly peer-to-peer over a DTLS-encrypted data connection — no other participant (not even the room host) relays or sees it.
onMessage(cb)Receive values from other people on the call: onMessage((data, from) => …) also gives the sender's id. Additive — each call adds a listener and returns an unsubscribe function.

3 · Shared state (co-browse / follow-me)

broadcast / onMessage ride the call's existing data channel, so you can sync a little app state across everyone on the call with no server. The classic use is follow-me: when one person changes page/tab, everyone follows.

const call = Kibitz.mount({ room })
let applyingRemote = false // guard so a move we're applying doesn't echo back

// tell everyone where I just went
addEventListener('hashchange', () => {
  if (!applyingRemote) call.broadcast({ kind: 'route', route: location.hash })
})

// follow someone else's move
call.onMessage((data) => {
  if (data?.kind !== 'route' || location.hash === data.route) return
  applyingRemote = true
  location.hash = data.route // fires hashchange → your router updates the page
  setTimeout(() => (applyingRemote = false), 0)
})

The applyingRemote guard (plus the "already there" check) stops two tabs from ping-ponging. Kibitz never inspects data — it's your channel.

4 · Single-page vs multi-page sites

5 · Good to know

6 · Headless / composable mode (advanced)

Pass headless: true and Kibitz mounts no UI of its own — it just runs the call (media, presence, data) and hands you a controller. You render your own tiles, controls and chat from the members and events below. This is how whist.kibitz.chat puts the table video, presence and shared game state onto its own board — and it's exactly how an AI agent joins a room as a participant: same controller, perceiving the roster + shared state and acting through broadcast/sendTo. It's the same widget.js — just driven instead of displayed.

Extra options — Kibitz.mount(options)

OptionTypeDefaultWhat it does
headlessbooleanfalseRender no built-in panel; you draw the UI from the controller + events.
identitystringrandomStable per-user / per-seat id, so a reconnect is recognized as the same participant.
metaobject{}Opaque per-participant metadata (seat, userId…) attached to you and carried in the roster. Update later with setMeta().
signalHoststringautoPoint signaling at a specific worker (e.g. a self-hosted signal.example.com). Most embedders omit it.
turnHoststringsame-originRoute TURN — and its bill — through an independent provider's /api/turn on another origin (see §7). Omit for kibitz.chat's own relay.
licenseKeystringA premium bearer token sent to /api/turn so a gated endpoint grants TURN (see §7). The caller stays anonymous. Omit for the free tier.
grantstringA signed room-grant token (the "opener pays" capability, see §7) sent to /api/turn as X-Kibitz-Grant, so the room opener's license sponsors this peer's TURN. Usually carried in the invite link as ?grant=, but can be set in code.
joinGate · joinCredentialobject · stringA who-can-join gate decoded from the room link (set at room creation): { mode: 'open'|'names'|'code'|'email'|'google'|'invite', … } plus this peer's own signed invite token for invite mode. See verification.
apiBasestringsame-originBase URL of the email-code backend (issuer + /api/email/jwks) for the email verify method — an embedder on a different origin points this at https://kibitz.chat (where the backend lives).

Controller — the rest of MountedWidget

MemberWhat it does
getState()Snapshot — { inCall, micOn, camOn, sharing, self, isHost, lobbyOn, locked, lobbyStatus }, plus the verified-identity/roster fields when verifyIdentity is set: identityEnabled, selfEmail, rosterActive, rosterCanShare, rosterCompromised.
getParticipants()Everyone on the call, each with id, name, isSelf, camOn, speaking, stream, meta, role (role is 'host' or 'guest'). Attach stream to a muted <video> to draw a tile.
join({ mic?, cam? })Join the call (promise). Mount with startOpen: true when driving headlessly so the data link comes up.
leave()Leave the call but stay mounted.
toggleMic() · toggleCam()Turn your mic / camera on or off (camera returns a promise).
shareScreen() · shareTrack(track) · stopShare()Share via the browser picker, or publish an arbitrary MediaStreamTrack (e.g. a tab capture), then stop.
setName(n) · setAvatar(a) · setMeta(m)Update your roster fields; changes propagate to everyone.
getKnocks() · setLobby(on) · admit(id) · deny(id) · remove(id) · setLocked(on) · resetRoom()Lobby + moderation, host side: gate the room (setLobby), approve/refuse each person waiting (getKnocks(){ id, name, avatar }[]), remove(id) a member (told to leave + blocked from rejoining), setLocked(on) to seal the room to new members, and resetRoom() to clear everyone's chat. No-op unless you're the room authority.
knock(name, avatar)Knock-to-admit, joiner side: introduce yourself to a gated room before joining — what the host sees in the queue. Re-callable to rename while you wait.
signInIdentity(el, 'google'|'email') · identityNonce() · provideIdentityToken(jwt)Verified identity (inert unless verifyIdentity is set): render the provider's sign-in into your element, or adopt a cert-bound token minted out-of-page (e.g. a sign-in popup on another origin). See §2a.
getCapabilityGrant(id) · setCapabilityGrant(id, grant) · getAgentAudit(id)Participant capabilities, host side: read a peer's effective Grant (what it may perceive/act), widen or revoke it (null clears), and read the local audit feed (blocked acts + changes). Humans default full; agents default read-only. See §6b.
on(event, cb)Subscribe; returns an unsubscribe fn. Events: participants, join, leave, speaking, state (the getState() fields above), knocks (host queue changed), lobby (your own waiting/denied/locked/admitted status).

6b · Participant capabilities (humans & agents)

Every participant carries a Grant of what it may perceive (see-screen, hear-audio, read-chat, read-roster, receive-directed) and act (send-chat, speak, act). Humans default to full; agents (meta.role==='agent') default to read-only — they perceive chat/roster/directed data, but receive no media and can act nothing. The engine enforces it per-peer: perceive = the sender withholds (data is never delivered to a peer that can't see it, and a withheld screen-share/audio lane is swapped for a placeholder on that peer's connection); act = every honest peer drops content from a peer lacking send-chat. The host widens/revokes any grant live (setCapabilityGrant) via a per-agent consent panel + a local audit feed, and the room authority distributes the grant map so it holds uniformly. An agent can disclose its model backend/egress ("what it sees leaves the room"). Full model: the agent platform deep-dive.

const call = Kibitz.mount({ room, headless: true, identity: 'seat-3', name: 'Dana' })
await call.join({ mic: false, cam: false })

// draw your own tiles whenever the roster changes
call.on('participants', (people) => {
  for (const p of people) {
    // p.stream → a <video>; p.name / p.speaking / p.meta → your UI
  }
})

call.toggleMic() // wire to your own button

Headless mode ships in the live widget.js today (it's what runs whist.kibitz.chat). The TypeScript types for MountOptions / MountedWidget live in the open-source repo.

7 · TURN, relays & who pays (advanced / self-host)

Calls are peer-to-peer, so most cost nothing. A TURN relay kicks in only when two networks can't connect directly — roughly 1 call in 6 (strict/symmetric NAT, locked-down corporate Wi-Fi). On kibitz.chat that relay is on us, so embedders pay nothing. But the relay endpoint is a plain, open contract, so who provides and pays for TURN is fully swappable.

The endpoint — GET /api/turn

The client fetches short-lived ICE/TURN servers from this endpoint; the long-term TURN key never reaches the browser. It's a standard HTTP contract anyone can implement:

GET /api/turn
Authorization: Bearer <licenseKey>   // optional — only for gated/premium TURN

 { "iceServers": [ … ] | null, "configured": true, "tier": "open" }

Point TURN somewhere else — turnHost

Set turnHost to route TURN, and its bill, through an independent provider — the twin of signalHost. Omit it for kibitz.chat's relay.

Kibitz.mount({ room, turnHost: 'turn.example.com' }) // your /api/turn, your bill

"Opener pays" — a signed room grant

A licensed opener can sponsor everyone in their room. Their browser mints a short-lived, room-scoped, signed grant (POST /api/room-grant, authed by their license) and bakes it into the invite link as ?grant=…. Joiners adopt it before connecting and present it to /api/turn as X-Kibitz-Grant; it's verified and metered to the sponsor — over kibitz.chat's own relay, so there's no open relay and the opener's key never leaves their browser. It rides the link (not the in-call roster) because a peer that needs a relay to connect at all must have it before connecting — the same idea as the offline ?galaxy= link.

https://kibitz.chat/?grant=<signed-grant>#ember-a3f9k2mq7p

There is deliberately no ?turn=host link: letting a link silently point a joiner's relay at an arbitrary host would let a third party harvest their IP + connection metadata without consent. Pointing TURN at an independent provider is a code-set turnHost mount option only (an embedder who owns both sides) — never something a shared link can do.

Who pays — pick a model

ModelHowWho's billed
kibitz.chat defaultnothing to dous — free for you
Your own premiuma license key in your browseryou — works in any room you join
You / your embedself-host, or set turnHostyou — one TURN account for all your calls
Opener paysa signed room grant (?grant=) in the linkwhoever opens the room — their license sponsors it
Bring-your-own-TURNeach user sets their own turnHosteach user, for their own usage

Premium travels with you

A license key is per-person, held in your own browser — so premium relay follows you into every call you join, billed to your own subscription, no matter who hosts. The classic case: a guest on a locked-down corporate network (the person who actually needs TURN) joins a free host's room and still connects — Cloudflare TURN's turns:…:443 punches through the firewall, and in a 1:1 only one side needs a relay candidate to carry the call. This is the inverse of "opener pays": there the host sponsors guests; here you bring your own, and it just works wherever you go.

Group calls (3+): your relay rescues connections involving you; any other pair of strict-NAT peers still needs one side with TURN or a direct path. Premium coverage is per-participant-pair, not whole-room.

Charging for TURN, without accounts

The optional Authorization: Bearer is how a provider can charge for relay while keeping calls account-free. You sell an opaque license key — a bearer token, not an identity; the client sends it, and your /api/turn mints TURN only for an active key (everyone else gets free STUN). The caller stays anonymous; only the buyer is known to the payment processor. The contract is open, so the gate, key store and checkout are yours to run (the typical shape: checkout → webhook → entitlement store → token check).

This is plumbing, not a price list. kibitz.chat itself doesn't sell a tier today — these knobs exist so you (or an independent provider) can supply and bill TURN for your own deployments. Free, direct P2P is always the default.

8 · Offline / same-Wi-Fi rooms (no internet)

The same embedded engine also runs a room over a local network with no internet at all — a plane, a cabin, an event with dead cell, an air-gapped office. One device on the Wi-Fi runs a tiny open-source LAN hub; everyone opens the link once and the call rides that LAN instead of the internet broker. So a site that embeds Kibitz keeps working offline — same widget.js, same rooms, no accounts and no cloud. (This is unrelated to the TURN relay: TURN forwards an internet call's media when a direct path is blocked; the LAN hub replaces the internet entirely.)

The LAN hub is selected by a ?galaxy=… link it hands out; the widget adopts and persists it (open it once on the hub's Wi-Fi and that LAN works forever after — ?galaxy=off clears it). Two things must be reachable on the LAN for an embedded site: your page and widget.js — serve them from the LAN-hub device, or rely on PWA caching (open the site once on real internet first, so it's cached).

This is orthogonal to TURN above: TURN relays an internet call when NAT blocks a direct path; the LAN relay replaces the internet entirely with a same-Wi-Fi rendezvous. Setup + downloads are on the Offline mode page (beta).

Specifications & deep dives

The engineering docs — how the room, the encryption, the admission gate and the agent layer actually work. Honest by design; this is the "show your work."

See it live

Two working examples, same widget: embedded on a page and in a single-page app (where the call follows you through every tab — the follow-me snippet above, in action). And the full headless build powers whist.kibitz.chat.

Want a full app built on this engine? whist.kibitz.chat is a multiplayer card game whose table video, presence and shared game state all ride this widget's headless controller — it's open source as a reference design: github.com/kibitz-chat/whist.