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.
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.
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.
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.
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>
Kibitz.mount(options)| Option | Type | Default | What it does |
|---|---|---|---|
room | string | — (required) | The call everyone joins. Same string → same call. |
name | string | '' | Pre-fills the visitor's display name (they can still edit it). |
startOpen | boolean | false | Open the panel immediately instead of the corner launcher. |
preview | boolean | false | Show 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.
MountedWidget| Member | What 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. |
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.
document.body, untouched by route changes. Ideal.
room on each page and people rejoin automatically.
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.
Kibitz.mount(options)| Option | Type | Default | What it does |
|---|---|---|---|
headless | boolean | false | Render no built-in panel; you draw the UI from the controller + events. |
identity | string | random | Stable per-user / per-seat id, so a reconnect is recognized as the same participant. |
meta | object | {} | Opaque per-participant metadata (seat, userId…) attached to you and carried in the roster. Update later with setMeta(). |
signalHost | string | auto | Point signaling at a specific worker (e.g. a self-hosted signal.example.com). Most embedders omit it. |
turnHost | string | same-origin | Route TURN — and its bill — through an independent provider's /api/turn on another origin (see §7). Omit for kibitz.chat's own relay. |
licenseKey | string | — | A premium bearer token sent to /api/turn so a gated endpoint grants TURN (see §7). The caller stays anonymous. Omit for the free tier. |
grant | string | — | A 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 · joinCredential | object · string | — | A 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. |
apiBase | string | same-origin | Base 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). |
MountedWidget| Member | What 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). |
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.
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.
GET /api/turnThe 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" }
{ iceServers: null, configured: false }, and the client falls back to public STUN — free, direct P2P.*, so a client on one origin may use a TURN provider on another.turnHostSet 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
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.
| Model | How | Who's billed |
|---|---|---|
| kibitz.chat default | nothing to do | us — free for you |
| Your own premium | a license key in your browser | you — works in any room you join |
| You / your embed | self-host, or set turnHost | you — one TURN account for all your calls |
| Opener pays | a signed room grant (?grant=) in the link | whoever opens the room — their license sponsors it |
| Bring-your-own-TURN | each user sets their own turnHost | each user, for their own usage |
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.
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).
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).
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."
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.