Live 1:1 video calls with your website visitors, right in the browser. A human alternative to AI support bots. Runs on your own Cloudflare account.
TypeScript
2
36 commits
updated Sep 22, 2026
A "talk to a real person right now" button for your websites.
One script tag on any number of sites. One dashboard for your team. One shared queue. A visitor sees that someone is live, clicks, waits in line, and lands in a private 1:1 video call — without installing anything, creating an account, opening Zoom, or leaving the page they were on.
Runs entirely on your own Cloudflare account: Workers, Durable Objects, D1, KV, R2 and RealtimeKit for the WebRTC media. No servers, no third-party SaaS, no media passes through your code.
widget on any site ──ws──┐
agent dashboards ───ws───┼──▶ LiveHostRoom (one Durable Object)
waiting visitors ───ws───┘ │
├─▶ D1 history, agents, sites, inbox
├─▶ KV rate-limit windows
├─▶ R2 the intro clip
└─▶ RealtimeKit only when a call is accepted
/call (same-origin iframe) ──────▶ WebRTC, peer to Cloudflare's edge
The dashboard while two people wait — one from each site — and two agents are live:

What a visitor sees on your site, from the first click to their place in line:
![]() | ![]() | ![]() |
| Someone is live | Asking to talk | Waiting, with a place in line |
![]() Nobody live: a message form instead (or nothing at all — per site) | ![]() On a phone: a bottom sheet with a chevron to dismiss |
Everything except the media is well inside Cloudflare's free or lowest paid tier for a small team: a Durable Object that mostly hibernates, a few D1 rows per visitor, one KV write per rate-limited request, one R2 object. RealtimeKit bills per participant-minute of actual calls — check its current pricing — and only calls that were accepted create a meeting; people waiting in line cost nothing.
Requirements: Node 20+, a free Cloudflare account, npx wrangler login.
git clone https://github.com/yoavshalev/live-video-chat
cd live-video-chat
npm install
cp .dev.vars.example .dev.vars # local secrets; the defaults work as-is
npm run db:migrate && npm run db:seed # local D1: schema + two example sites + a dev admin
Three terminals:
npm run dev # the Worker on http://localhost:8787
npm run demo # a pretend customer site on http://localhost:8788
npm run smoke # 45 end-to-end checks against both (optional)
Open http://localhost:8787/host and sign in as dev@example.com / local-dev-password, go live, then open http://localhost:8788 in another browser (or a private window) and click the bubble.
Calls need RealtimeKit even locally: put CLOUDFLARE_ACCOUNT_ID,
REALTIMEKIT_APP_ID and REALTIMEKIT_API_TOKEN in .dev.vars (see
RealtimeKit below). Everything up to the call — presence,
queue, invitations, inbox — works without them.
The button copies this repository into your GitHub account, creates the D1
database, KV namespace, R2 bucket and Durable Object on your Cloudflare account,
and deploys the Worker to your *.workers.dev subdomain. Pushes to your copy
redeploy it.
It asks for three secrets. Two come from RealtimeKit, Cloudflare's hosted WebRTC. You can add them afterwards: everything except starting a call works without them, and the dashboard says so until they are set.
REALTIMEKIT_APP_ID — https://dash.cloudflare.com/?to=/:account/realtime/kit
→ Create App → copy the App ID. Create it in the dashboard, not through
the API, so it comes with the default presets this code expects.REALTIMEKIT_API_TOKEN — https://dash.cloudflare.com/profile/api-tokens
→ Create Token with the Realtime → RealtimeKit Admin permission.CLOUDFLARE_ACCOUNT_ID — on your dashboard's overview page. The deploy
fills this in by itself when it can.The session-signing secret is generated for you, and the database schema is applied as part of every deploy.
When it finishes, open https://founderlive.<your-subdomain>.workers.dev/setup
and create the first admin — you. Do it right away: until someone has, that page
is open to whoever finds it first, as with any fresh install. Then, on the
dashboard: Embed tab → create a site, add its domain, copy the snippet into
your website. Clip tab → record an intro. Agents tab → add your
colleagues. Go live.
To put it on your own domain later, add a route in your copy's wrangler.jsonc
(the comments there show how) and push. Nothing else changes: the Worker reads
its own URL from each request.
The same steps from a terminal. You need Node 20+ and npx wrangler login.
1. Configuration. wrangler.jsonc is a template with placeholder ids. Copy
it and keep your real values out of git:
cp wrangler.jsonc wrangler.prod.local.jsonc # any wrangler.*.local.jsonc is gitignored
Every remote command below takes --config wrangler.prod.local.jsonc. (Editing
wrangler.jsonc in place also works if you are not planning to contribute back.)
2. Resources.
npx wrangler d1 create founderlive # → database_id
npx wrangler kv namespace create RATE # → id
npx wrangler r2 bucket create founderlive-media
Paste the D1 database_id and the KV id into your config. Optionally set
ORG_ID — any short slug naming your organization; keep it stable forever, it
names the Durable Object and the row that holds your intro clip — and a custom
domain (routes; the comments in the file show how). The Worker, D1 database
and R2 bucket are named founderlive, the project's internal name, which also
appears in the widget's JavaScript API (FounderLive.init(...)) and its DOM
events. Renaming them is not necessary.
3. RealtimeKit. The two secrets described under "One click", plus your account id:
C=wrangler.prod.local.jsonc
npx wrangler secret put REALTIMEKIT_APP_ID --config $C
npx wrangler secret put REALTIMEKIT_API_TOKEN --config $C
npx wrangler secret put CLOUDFLARE_ACCOUNT_ID --config $C # or let the deploy detect it
Secrets, never vars: a plaintext var of the same name silently overrides a
secret on deploy.
Preset names use underscores. Cloudflare's docs show group-call-host, but
an app is actually created with group_call_host and group_call_participant,
which is what wrangler.jsonc ships with. A mismatch fails only at the moment
a real call is accepted, so after deploying check
GET https://<your host>/api/host/realtimekit (signed in): it lists the
presets your app really has and whether the configured names match.
4. Deploy.
npm run deploy -- --config $C
This refuses template placeholders, builds, typechecks and tests, applies the
D1 migrations, deploys, and then sets SESSION_SECRET (generated) and
CLOUDFLARE_ACCOUNT_ID (detected) if they are missing. An existing value is
never touched: rotating the session secret signs everyone out.
Do not run npm run db:seed:remote unless you want the two example sites;
you will create your real sites on the dashboard. The local-only seed file with
the dev@example.com admin is never applied remotely.
5. Your first admin. Open https://<your host>/setup right after deploying
and create yourself, as under "One click". Or from the terminal, where the
password is printed once:
node scripts/agent.mjs add --name "Ada" --email ada@example.com --role admin --remote --config $C
git pull
npm run deploy -- --config $C # migrations are part of the deploy
With the button: pull this repository into your copy and push; the build does the rest.
Deploy while nobody is live if you can: a deploy that changes the Durable Object's stored state shape resets it (queue, presence), which is harmless between conversations and abrupt during one.
One deployment is one organization with any number of agents. Each agent has their own sign-in and their own live/paused/offline switch; the widgets show the organization as live while any agent is.
| Assignment mode | What happens when someone joins the line |
|---|---|
| Automatic (default) | They are invited by the available agent who has gone longest since their last assignment. Ties break alphabetically by agent id. When that call ends, the next waiting person goes to the next agent in the rotation. |
| Manual | Nobody is invited until an agent presses Accept or Accept next. |
An agent is available when they are live, connected, not paused, and not in a call or holding an invitation. Pausing takes you out of the rotation without taking the team offline. If the agent who invited someone goes offline before the call connects, the visitor goes back to the head of the line for the next agent; if a call is already running it ends.
Every dashboard shows the whole queue plus each colleague's status and who they are talking to; each agent's own call fills their main area. Wait estimates shown to visitors divide by the number of live agents.
| agent | admin | |
|---|---|---|
| Go live, take calls, see the queue, inbox and team | ✓ | ✓ |
| Record the intro clip | ✓ | ✓ |
| Change their own password | ✓ | ✓ |
| Sites, domains, per-site settings | ✓ | |
| Add agents, change roles, disable, reset passwords | ✓ |
The last enabled admin cannot be demoted or disabled.
Everything the Agents tab does, from a terminal — including the first admin on
a fresh deployment. Without --remote it acts on the local dev database.
node scripts/agent.mjs add --name "Ada" --email ada@example.com [--password ...] [--role admin] [--remote] [--config file]
node scripts/agent.mjs password --email ada@example.com [--password ...] [--remote]
node scripts/agent.mjs list [--remote]
node scripts/agent.mjs disable --email ada@example.com [--remote]
node scripts/agent.mjs enable --email ada@example.com [--remote]
Passwords are stored as PBKDF2-SHA256 (100 000 iterations — the most the
Workers runtime allows — with a per-user salt); the
CLI and the Worker produce and verify the same format, which
test/password.test.ts proves.
HOST_AUTH_MODE in wrangler.jsonc picks one of two modes.
password (default). Each agent signs in with email and password; a
success is a signed HttpOnly cookie valid for 12 hours, rate-limited to 8
attempts per IP per 15 minutes. Disabling an agent takes effect on their next
request. Simple, and fine for a small team.
access — preferred if you already use Cloudflare Zero Trust. Put a
Cloudflare Access application in front of the hostname, scoped to /host*
and /ws/host only — a policy covering the whole hostname would challenge
/widget.js, /embed/*, /ws/widget, /media/*, /call and /api/*, which
visitors and embedding sites call unauthenticated, and the widget would stop
working everywhere. The Worker verifies the JWT Access injects (RS256 against
your team's published keys, aud, issuer, expiry) rather than trusting the
header, so a misrouted request cannot forge a session. Anyone the policy admits
becomes an agent on their first visit; the very first one becomes admin.
npx wrangler secret put CF_ACCESS_TEAM_DOMAIN --config $C # yourteam.cloudflareaccess.com
npx wrangler secret put CF_ACCESS_AUD --config $C # the application's AUD tag
npx wrangler secret put ALLOWED_HOST_EMAIL --config $C # optional: comma-separated allow-list on top of the policy
With Access, set workers_dev to false and use a custom domain: a
*.workers.dev hostname is an unauthenticated route that bypasses any Access
policy bound to the real domain.
Dashboard writes are refused when the browser reports them as cross-site
(Sec-Fetch-Site / Origin), on top of the SameSite=Lax cookie; dashboard
pages send frame-ancestors 'none'; and the two credentials published for
local development — the example SESSION_SECRET and the dev@example.com
admin — are refused outright anywhere but localhost.
Sites are managed on the dashboard's Embed tab (admins): create one, add its domains, copy the snippet.
<script
src="https://live.example.com/widget.js"
data-site="my-site"
data-position="bottom-right"
defer
></script>
A site lists root domains, not exact origins. Each root covers itself and
every subdomain over https: example.com admits example.com,
www.example.com and app.example.com. Entering app.example.com scopes a
site to that subtree. www. is stripped on entry; nothing else is.
Refused on purpose, pinned by test/domains.test.ts: lookalikes
(evil-example.com), the domain as a prefix of another (example.com.attacker.net),
plain http:// on a real domain, bare registry suffixes (com, co.uk), and
multi-tenant hosting suffixes as roots (github.io, pages.dev, vercel.app,
myshopify.com… — a site under one, like mysite.github.io, is fine).
localhost is the one exception to https, on any port, for local development —
remove it from production sites.
A page on a domain that is not listed sees nothing: the widget refuses to load rather than showing an error. Changes reach every edge within a minute.
| Setting | What it does |
|---|---|
| Name in the widget | The name in every line of copy — "… is live", "Talk to …". Empty means Agent. Agents' real names are never shown to visitors unless you put them here. |
| When nobody is live | Show renders the offline state with a leave-a-message form. Hide renders nothing at all — but stays connected, so the bubble appears the instant someone goes live. |
| Enabled | Off refuses every request for the site, including the widget itself. |
FounderLive.init({ siteId: 'my-site', position: 'bottom-right' })
FounderLive.open(); FounderLive.close(); FounderLive.destroy()
FounderLive.getStatus() // { presence, self, view }
Events fire on window, so a host page can feed them to its own analytics:
founderlive:ready founderlive:queuejoined founderlive:callstarted
founderlive:opened founderlive:queueleft founderlive:callended
founderlive:closed founderlive:invited founderlive:inviteexpired
founderlive:talkclicked founderlive:offlinemessagesent
widget.js is completely static — no presence, no site config, not even the
base URL, which it derives from its own script.src — so it caches for an hour
while presence still changes in seconds.
If an embedding site sets a CSP, it needs one origin:
script-src https://live.example.com
connect-src https://live.example.com wss://live.example.com
frame-src https://live.example.com
img-src https://live.example.com
media-src https://live.example.com
The RealtimeKit SDK is re-served from /sdk/ and the intro clip from /media/
so the widget never talks to anything else.
| Tab | |
|---|---|
| Live | Your status and call, the queue with each person's site, question and wait, every colleague's status, today's numbers, and Earlier today — everyone who joined and what happened, including joins that expired while nobody was watching. |
| Embed | Sites, domains, per-site settings, snippets. Admins. |
| Clip | Record or upload the intro loop; device check. |
| Inbox | Every join request and offline message ever submitted, with who took the call. |
| Agents | The team, roles, add / disable / reset password; your own password. |
![]() | ![]() |
| Embed: sites, domains, per-site settings, the snippet | Agents: the team, roles, passwords |
Alerts. While somebody is waiting and you are available, the dashboard rings a chime every 20 seconds, flashes the tab title, and — if you allowed notifications — posts one per arrival while the tab is hidden. It is quiet during a call, while paused, and while offline; the Sound button mutes it per browser. Browsers refuse to play sound until you have clicked something on the page, so after a reload a banner says so; any click fixes it.
Runs in an iframe from your own origin inside the widget, with
allow="camera; microphone", so the WebRTC SDK, the permissions and the call's
CSS stay on your origin rather than injected into a customer's page. The same
URL works opened top-level, which is the fallback offered when a browser
refuses camera access inside a cross-origin frame.
Camera and microphone are requested only when someone clicks Join, on a screen whose only content is that request — a denial in Chrome is remembered per origin, so one badly-timed prompt costs every future call.
Screen sharing from either side on any desktop browser; the share fills the stage and the camera becomes a tile. Remote audio is played by this code, not the SDK — the core RealtimeKit SDK hands over a raw track and plays nothing — with a "Tap to hear" fallback where autoplay is blocked.
"I can't hear you" is fixed on whichever side has the wrong device, so both sides get the same tools: a microphone meter on the preview screen before joining, and in the call a settings button with microphone, camera and (in Chrome, Edge and Firefox) speaker pickers, a meter of your own microphone, a meter of what is arriving from the other person, a "mic off" label when their microphone is not in the call, and a status line that says which it is ("Your mic: on — Shure MVX2U. Them: arriving and playing"). The mute button reflects what the SDK actually has, not what was asked for: a microphone the browser refused, or that iOS handed over silent, shows as off on that person's own screen with a one-tap "Turn it on", instead of only as "mic off" on the other side. A phone that mutes the capture underneath the SDK (iOS does, whenever anything captures again, or when Safari goes to the background) is caught by watching the track itself. The only cure is a fresh capture — the SDK's enable/disable merely flip a flag on the track it already has — so the microphone is captured afresh: on a tap of the full-width "Fix microphone" / "Turn it on" banner as often as you like, and on desktop browsers also on its own once a mute has lasted a moment (at most a few times a minute). On iOS a capture only ever happens inside a tap, because one started on its own can put a permission prompt in front of somebody who did not ask for it. A refused capture is explained in the banner (which Safari setting to change), and if a fresh capture comes back muted the banner says what to check on the phone. A diagnostics snapshot goes to the Worker's logs either way. The other side is told what to ask for. The controls carry labels — Mute / Unmute, Camera, Share, Settings — so the button is never a guess on a phone.
Device defaults. The camera, microphone and speaker you pick — under the dashboard's Audio & video settings, which also rings the chosen speaker so you can tell it is the right one, on the pre-call check, or inside a call — are remembered per browser and used for every call after that; the chosen speaker is re-applied every time audio is attached, and a browser that refuses the switch says so in the status line. Agents can also tick skip this check next time to join the moment their devices are up. Nothing is stored on the server: a device id only means something to the browser that issued it.
A meeting is created only when a call is accepted, never for people in the
queue, and exactly two participant tokens are minted, by the Durable Object.
No recording, no transcription, no third party in the room. calls records
who, which site, which agent, when, how long and why it ended; that list is
deliberately complete.
npm test # 85 unit tests: state machine (round-robin, races, timers), domains, alerts, passwords
npm run smoke # 45 end-to-end checks against a running Worker: origins, sockets, queue, auto-assignment, D1
npm run smoke:sites # 26 checks on site management, domain enforcement, per-site settings
npm run smoke:call # 16 checks that create and tear down a REAL RealtimeKit meeting
npm run check # build + typecheck (worker and browser) + unit tests; runs before every deploy
The smoke suites take [baseUrl] [password] [email] and refuse a non-localhost
target unless you add --disruptive: they take agents offline, drive the queue
and create a throwaway site, which disconnects anyone using the deployment.
src/shared/machine/ is a pure reducer — (state, command) → {state, effects}
with no I/O, no clock and no randomness; now and every generated id arrive on
the command — so the whole product lifecycle, including three agents and two
concurrent calls, runs in milliseconds under test. The Durable Object is a shell
that persists, broadcasts and sets alarms.
LiveHostRoom, addressed by idFromName(ORG_ID). One organization means one
instance, which is what makes a single global queue across unrelated websites
possible: everyone lands in the same line because there is only one room.
Sockets are accepted with ctx.acceptWebSocket, so hundreds of idle widgets
cost nothing while the object hibernates. Consequences: no timers (every
deadline collapses into the single alarm slot, and TICK applies all of them at
once); no in-memory truth (state is read from storage and written back each
event); socket identity in serializeAttachment, not a Map. Keepalives are
answered by setWebSocketAutoResponse without waking the object.
Agent OFFLINE → AVAILABLE ⇄ PAUSED (per agent; org presence = best agent)
↕
BUSY
Visitor BROWSING → WAITING → INVITED → CONNECTING → IN_CALL → COMPLETED
↓ ↓ ↓
LEFT EXPIRED VISITOR_LEFT
DECLINED
Each agent has an intent (what they asked for); their status is derived from it plus reality — connected? in a call? holding an invitation? Storing the derived value would let the two drift, which is how a widget ends up showing LIVE for a dashboard that closed an hour ago.
The Durable Object handles one event at a time, so these are plain checks:
| Race | Resolution |
|---|---|
| Two agents accept the same visitor | The second sees the invitation and is told so |
| Visitor leaves as an agent accepts | Invitation and provisional call go with them; the agent is freed |
| Invitation expires as the visitor accepts | Deadline re-checked in the reducer, not left to the timer |
| Agent goes offline mid-invitation | Visitor returns to the head of the line for the next agent |
| A command is retried after a dropped socket | commandId ring buffer makes it a no-op |
| RealtimeKit fails to create the meeting | Visitor keeps their place; automatic assignment pauses for 30s rather than hammering a broken dependency, and an agent can still accept by hand |
| What happens | What we do |
|---|---|
| An agent closes the dashboard | 45s grace, then offline. The queue is released only when the last live agent goes |
| A visitor's tab is suspended | 90s grace; they keep their exact position if they return |
| Either side drops mid-call | 30s to reconnect before the call ends |
| Visitor accepts but never connects | 120s, then the call is abandoned and does not count |
Rolling average of the last 10 connected calls, divided by the number of live agents, plus the remainder of the soonest-ending call in progress. Below 3 completed calls there is no estimate, just "2 people ahead of you". Estimates are bucketed ("~5 min", "~10 min", "20+ min") because "~13 minutes" reads as a promise.
siteId arrives from a script tag on a page you do not control, so it is a
claim. Every entry point that spends resources — opening a socket, joining the
queue, minting call credentials — validates it against the browser-supplied
Origin. Rate limits (KV, fixed window, fail open): sockets 60/min, joins
10/5 min, offline messages 5/hour, call credentials 20/5 min, sign-in 8/15 min.
The strongly-consistent limits — one queue entry per visitor, one call per
agent — live in the state machine.
src/
index.ts Worker entry; route registration
config.ts every tunable number and every visitor-facing string
types.ts bindings and D1 row shapes
durable/
LiveHostRoom.ts the one coordinator: sockets, alarms, broadcasts
commands.ts socket message → command, where identity is enforced
tokens.ts, signatures.ts call tokens; "did anything change?" digests
shared/
protocol.ts the wire format, shared with client/
machine/ the pure reducer — read this first
types.ts state, commands and effects
transitions.ts one function per state change
reduce.ts the command switch
views.ts read-only projections for the wire
domains.ts the domain allow-list rules
validation.ts inbound parsing and sanitisation
routes/ api, ws, call, media, and host/ (dashboard: one file per tab)
lib/ auth, access, csrf, password, sites, db/, realtimekit, ratelimit, analytics
ui/styles/ dashboard and call page CSS
client/
widget/ the embeddable widget (shadow DOM, no framework); views/ = one file per screen
host/ the dashboard: one file per tab, plus socket, alerts, recorder
call/ the call page: devices, meters, microphone, peer, lifecycle
shared/ reconnecting WebSocket, device prefs, test tone
migrations/ D1 schema, applied in order
seed/ example sites (any environment) and the dev admin (local only)
scripts/ agent CLI, client build, deploy preflight, demo server, smoke suites
test/ vitest
demo/ a pretend customer page for local development
AGENTS.md the rules of the road for AI coding agents (Codex, Claude Code, Cursor…)
client/ imports types straight out of src/shared/: the protocol is defined
once and both ends of the wire are checked against it.
Public broadcasting, group calls, visitor-to-visitor chat, scheduling, visitor
accounts, recording, transcripts, SMS, CRM integrations, departments or skills
routing, payments. The reducer is one file where a routing rule would go, and
queue_sessions and calls already carry site_id, visitor_id and
agent_id.
MIT — see LICENSE.
36 commits
TypeScript
90.4%
JavaScript
9.6%
Live 1:1 video calls with your website visitors, right in the browser. A human alternative to AI support bots. Runs on your own Cloudflare account.
TypeScript
2
36 commits
updated Sep 22, 2026
A "talk to a real person right now" button for your websites.
One script tag on any number of sites. One dashboard for your team. One shared queue. A visitor sees that someone is live, clicks, waits in line, and lands in a private 1:1 video call — without installing anything, creating an account, opening Zoom, or leaving the page they were on.
Runs entirely on your own Cloudflare account: Workers, Durable Objects, D1, KV, R2 and RealtimeKit for the WebRTC media. No servers, no third-party SaaS, no media passes through your code.
widget on any site ──ws──┐
agent dashboards ───ws───┼──▶ LiveHostRoom (one Durable Object)
waiting visitors ───ws───┘ │
├─▶ D1 history, agents, sites, inbox
├─▶ KV rate-limit windows
├─▶ R2 the intro clip
└─▶ RealtimeKit only when a call is accepted
/call (same-origin iframe) ──────▶ WebRTC, peer to Cloudflare's edge
The dashboard while two people wait — one from each site — and two agents are live:

What a visitor sees on your site, from the first click to their place in line:
![]() | ![]() | ![]() |
| Someone is live | Asking to talk | Waiting, with a place in line |
![]() Nobody live: a message form instead (or nothing at all — per site) | ![]() On a phone: a bottom sheet with a chevron to dismiss |
Everything except the media is well inside Cloudflare's free or lowest paid tier for a small team: a Durable Object that mostly hibernates, a few D1 rows per visitor, one KV write per rate-limited request, one R2 object. RealtimeKit bills per participant-minute of actual calls — check its current pricing — and only calls that were accepted create a meeting; people waiting in line cost nothing.
Requirements: Node 20+, a free Cloudflare account, npx wrangler login.
git clone https://github.com/yoavshalev/live-video-chat
cd live-video-chat
npm install
cp .dev.vars.example .dev.vars # local secrets; the defaults work as-is
npm run db:migrate && npm run db:seed # local D1: schema + two example sites + a dev admin
Three terminals:
npm run dev # the Worker on http://localhost:8787
npm run demo # a pretend customer site on http://localhost:8788
npm run smoke # 45 end-to-end checks against both (optional)
Open http://localhost:8787/host and sign in as dev@example.com / local-dev-password, go live, then open http://localhost:8788 in another browser (or a private window) and click the bubble.
Calls need RealtimeKit even locally: put CLOUDFLARE_ACCOUNT_ID,
REALTIMEKIT_APP_ID and REALTIMEKIT_API_TOKEN in .dev.vars (see
RealtimeKit below). Everything up to the call — presence,
queue, invitations, inbox — works without them.
The button copies this repository into your GitHub account, creates the D1
database, KV namespace, R2 bucket and Durable Object on your Cloudflare account,
and deploys the Worker to your *.workers.dev subdomain. Pushes to your copy
redeploy it.
It asks for three secrets. Two come from RealtimeKit, Cloudflare's hosted WebRTC. You can add them afterwards: everything except starting a call works without them, and the dashboard says so until they are set.
REALTIMEKIT_APP_ID — https://dash.cloudflare.com/?to=/:account/realtime/kit
→ Create App → copy the App ID. Create it in the dashboard, not through
the API, so it comes with the default presets this code expects.REALTIMEKIT_API_TOKEN — https://dash.cloudflare.com/profile/api-tokens
→ Create Token with the Realtime → RealtimeKit Admin permission.CLOUDFLARE_ACCOUNT_ID — on your dashboard's overview page. The deploy
fills this in by itself when it can.The session-signing secret is generated for you, and the database schema is applied as part of every deploy.
When it finishes, open https://founderlive.<your-subdomain>.workers.dev/setup
and create the first admin — you. Do it right away: until someone has, that page
is open to whoever finds it first, as with any fresh install. Then, on the
dashboard: Embed tab → create a site, add its domain, copy the snippet into
your website. Clip tab → record an intro. Agents tab → add your
colleagues. Go live.
To put it on your own domain later, add a route in your copy's wrangler.jsonc
(the comments there show how) and push. Nothing else changes: the Worker reads
its own URL from each request.
The same steps from a terminal. You need Node 20+ and npx wrangler login.
1. Configuration. wrangler.jsonc is a template with placeholder ids. Copy
it and keep your real values out of git:
cp wrangler.jsonc wrangler.prod.local.jsonc # any wrangler.*.local.jsonc is gitignored
Every remote command below takes --config wrangler.prod.local.jsonc. (Editing
wrangler.jsonc in place also works if you are not planning to contribute back.)
2. Resources.
npx wrangler d1 create founderlive # → database_id
npx wrangler kv namespace create RATE # → id
npx wrangler r2 bucket create founderlive-media
Paste the D1 database_id and the KV id into your config. Optionally set
ORG_ID — any short slug naming your organization; keep it stable forever, it
names the Durable Object and the row that holds your intro clip — and a custom
domain (routes; the comments in the file show how). The Worker, D1 database
and R2 bucket are named founderlive, the project's internal name, which also
appears in the widget's JavaScript API (FounderLive.init(...)) and its DOM
events. Renaming them is not necessary.
3. RealtimeKit. The two secrets described under "One click", plus your account id:
C=wrangler.prod.local.jsonc
npx wrangler secret put REALTIMEKIT_APP_ID --config $C
npx wrangler secret put REALTIMEKIT_API_TOKEN --config $C
npx wrangler secret put CLOUDFLARE_ACCOUNT_ID --config $C # or let the deploy detect it
Secrets, never vars: a plaintext var of the same name silently overrides a
secret on deploy.
Preset names use underscores. Cloudflare's docs show group-call-host, but
an app is actually created with group_call_host and group_call_participant,
which is what wrangler.jsonc ships with. A mismatch fails only at the moment
a real call is accepted, so after deploying check
GET https://<your host>/api/host/realtimekit (signed in): it lists the
presets your app really has and whether the configured names match.
4. Deploy.
npm run deploy -- --config $C
This refuses template placeholders, builds, typechecks and tests, applies the
D1 migrations, deploys, and then sets SESSION_SECRET (generated) and
CLOUDFLARE_ACCOUNT_ID (detected) if they are missing. An existing value is
never touched: rotating the session secret signs everyone out.
Do not run npm run db:seed:remote unless you want the two example sites;
you will create your real sites on the dashboard. The local-only seed file with
the dev@example.com admin is never applied remotely.
5. Your first admin. Open https://<your host>/setup right after deploying
and create yourself, as under "One click". Or from the terminal, where the
password is printed once:
node scripts/agent.mjs add --name "Ada" --email ada@example.com --role admin --remote --config $C
git pull
npm run deploy -- --config $C # migrations are part of the deploy
With the button: pull this repository into your copy and push; the build does the rest.
Deploy while nobody is live if you can: a deploy that changes the Durable Object's stored state shape resets it (queue, presence), which is harmless between conversations and abrupt during one.
One deployment is one organization with any number of agents. Each agent has their own sign-in and their own live/paused/offline switch; the widgets show the organization as live while any agent is.
| Assignment mode | What happens when someone joins the line |
|---|---|
| Automatic (default) | They are invited by the available agent who has gone longest since their last assignment. Ties break alphabetically by agent id. When that call ends, the next waiting person goes to the next agent in the rotation. |
| Manual | Nobody is invited until an agent presses Accept or Accept next. |
An agent is available when they are live, connected, not paused, and not in a call or holding an invitation. Pausing takes you out of the rotation without taking the team offline. If the agent who invited someone goes offline before the call connects, the visitor goes back to the head of the line for the next agent; if a call is already running it ends.
Every dashboard shows the whole queue plus each colleague's status and who they are talking to; each agent's own call fills their main area. Wait estimates shown to visitors divide by the number of live agents.
| agent | admin | |
|---|---|---|
| Go live, take calls, see the queue, inbox and team | ✓ | ✓ |
| Record the intro clip | ✓ | ✓ |
| Change their own password | ✓ | ✓ |
| Sites, domains, per-site settings | ✓ | |
| Add agents, change roles, disable, reset passwords | ✓ |
The last enabled admin cannot be demoted or disabled.
Everything the Agents tab does, from a terminal — including the first admin on
a fresh deployment. Without --remote it acts on the local dev database.
node scripts/agent.mjs add --name "Ada" --email ada@example.com [--password ...] [--role admin] [--remote] [--config file]
node scripts/agent.mjs password --email ada@example.com [--password ...] [--remote]
node scripts/agent.mjs list [--remote]
node scripts/agent.mjs disable --email ada@example.com [--remote]
node scripts/agent.mjs enable --email ada@example.com [--remote]
Passwords are stored as PBKDF2-SHA256 (100 000 iterations — the most the
Workers runtime allows — with a per-user salt); the
CLI and the Worker produce and verify the same format, which
test/password.test.ts proves.
HOST_AUTH_MODE in wrangler.jsonc picks one of two modes.
password (default). Each agent signs in with email and password; a
success is a signed HttpOnly cookie valid for 12 hours, rate-limited to 8
attempts per IP per 15 minutes. Disabling an agent takes effect on their next
request. Simple, and fine for a small team.
access — preferred if you already use Cloudflare Zero Trust. Put a
Cloudflare Access application in front of the hostname, scoped to /host*
and /ws/host only — a policy covering the whole hostname would challenge
/widget.js, /embed/*, /ws/widget, /media/*, /call and /api/*, which
visitors and embedding sites call unauthenticated, and the widget would stop
working everywhere. The Worker verifies the JWT Access injects (RS256 against
your team's published keys, aud, issuer, expiry) rather than trusting the
header, so a misrouted request cannot forge a session. Anyone the policy admits
becomes an agent on their first visit; the very first one becomes admin.
npx wrangler secret put CF_ACCESS_TEAM_DOMAIN --config $C # yourteam.cloudflareaccess.com
npx wrangler secret put CF_ACCESS_AUD --config $C # the application's AUD tag
npx wrangler secret put ALLOWED_HOST_EMAIL --config $C # optional: comma-separated allow-list on top of the policy
With Access, set workers_dev to false and use a custom domain: a
*.workers.dev hostname is an unauthenticated route that bypasses any Access
policy bound to the real domain.
Dashboard writes are refused when the browser reports them as cross-site
(Sec-Fetch-Site / Origin), on top of the SameSite=Lax cookie; dashboard
pages send frame-ancestors 'none'; and the two credentials published for
local development — the example SESSION_SECRET and the dev@example.com
admin — are refused outright anywhere but localhost.
Sites are managed on the dashboard's Embed tab (admins): create one, add its domains, copy the snippet.
<script
src="https://live.example.com/widget.js"
data-site="my-site"
data-position="bottom-right"
defer
></script>
A site lists root domains, not exact origins. Each root covers itself and
every subdomain over https: example.com admits example.com,
www.example.com and app.example.com. Entering app.example.com scopes a
site to that subtree. www. is stripped on entry; nothing else is.
Refused on purpose, pinned by test/domains.test.ts: lookalikes
(evil-example.com), the domain as a prefix of another (example.com.attacker.net),
plain http:// on a real domain, bare registry suffixes (com, co.uk), and
multi-tenant hosting suffixes as roots (github.io, pages.dev, vercel.app,
myshopify.com… — a site under one, like mysite.github.io, is fine).
localhost is the one exception to https, on any port, for local development —
remove it from production sites.
A page on a domain that is not listed sees nothing: the widget refuses to load rather than showing an error. Changes reach every edge within a minute.
| Setting | What it does |
|---|---|
| Name in the widget | The name in every line of copy — "… is live", "Talk to …". Empty means Agent. Agents' real names are never shown to visitors unless you put them here. |
| When nobody is live | Show renders the offline state with a leave-a-message form. Hide renders nothing at all — but stays connected, so the bubble appears the instant someone goes live. |
| Enabled | Off refuses every request for the site, including the widget itself. |
FounderLive.init({ siteId: 'my-site', position: 'bottom-right' })
FounderLive.open(); FounderLive.close(); FounderLive.destroy()
FounderLive.getStatus() // { presence, self, view }
Events fire on window, so a host page can feed them to its own analytics:
founderlive:ready founderlive:queuejoined founderlive:callstarted
founderlive:opened founderlive:queueleft founderlive:callended
founderlive:closed founderlive:invited founderlive:inviteexpired
founderlive:talkclicked founderlive:offlinemessagesent
widget.js is completely static — no presence, no site config, not even the
base URL, which it derives from its own script.src — so it caches for an hour
while presence still changes in seconds.
If an embedding site sets a CSP, it needs one origin:
script-src https://live.example.com
connect-src https://live.example.com wss://live.example.com
frame-src https://live.example.com
img-src https://live.example.com
media-src https://live.example.com
The RealtimeKit SDK is re-served from /sdk/ and the intro clip from /media/
so the widget never talks to anything else.
| Tab | |
|---|---|
| Live | Your status and call, the queue with each person's site, question and wait, every colleague's status, today's numbers, and Earlier today — everyone who joined and what happened, including joins that expired while nobody was watching. |
| Embed | Sites, domains, per-site settings, snippets. Admins. |
| Clip | Record or upload the intro loop; device check. |
| Inbox | Every join request and offline message ever submitted, with who took the call. |
| Agents | The team, roles, add / disable / reset password; your own password. |
![]() | ![]() |
| Embed: sites, domains, per-site settings, the snippet | Agents: the team, roles, passwords |
Alerts. While somebody is waiting and you are available, the dashboard rings a chime every 20 seconds, flashes the tab title, and — if you allowed notifications — posts one per arrival while the tab is hidden. It is quiet during a call, while paused, and while offline; the Sound button mutes it per browser. Browsers refuse to play sound until you have clicked something on the page, so after a reload a banner says so; any click fixes it.
Runs in an iframe from your own origin inside the widget, with
allow="camera; microphone", so the WebRTC SDK, the permissions and the call's
CSS stay on your origin rather than injected into a customer's page. The same
URL works opened top-level, which is the fallback offered when a browser
refuses camera access inside a cross-origin frame.
Camera and microphone are requested only when someone clicks Join, on a screen whose only content is that request — a denial in Chrome is remembered per origin, so one badly-timed prompt costs every future call.
Screen sharing from either side on any desktop browser; the share fills the stage and the camera becomes a tile. Remote audio is played by this code, not the SDK — the core RealtimeKit SDK hands over a raw track and plays nothing — with a "Tap to hear" fallback where autoplay is blocked.
"I can't hear you" is fixed on whichever side has the wrong device, so both sides get the same tools: a microphone meter on the preview screen before joining, and in the call a settings button with microphone, camera and (in Chrome, Edge and Firefox) speaker pickers, a meter of your own microphone, a meter of what is arriving from the other person, a "mic off" label when their microphone is not in the call, and a status line that says which it is ("Your mic: on — Shure MVX2U. Them: arriving and playing"). The mute button reflects what the SDK actually has, not what was asked for: a microphone the browser refused, or that iOS handed over silent, shows as off on that person's own screen with a one-tap "Turn it on", instead of only as "mic off" on the other side. A phone that mutes the capture underneath the SDK (iOS does, whenever anything captures again, or when Safari goes to the background) is caught by watching the track itself. The only cure is a fresh capture — the SDK's enable/disable merely flip a flag on the track it already has — so the microphone is captured afresh: on a tap of the full-width "Fix microphone" / "Turn it on" banner as often as you like, and on desktop browsers also on its own once a mute has lasted a moment (at most a few times a minute). On iOS a capture only ever happens inside a tap, because one started on its own can put a permission prompt in front of somebody who did not ask for it. A refused capture is explained in the banner (which Safari setting to change), and if a fresh capture comes back muted the banner says what to check on the phone. A diagnostics snapshot goes to the Worker's logs either way. The other side is told what to ask for. The controls carry labels — Mute / Unmute, Camera, Share, Settings — so the button is never a guess on a phone.
Device defaults. The camera, microphone and speaker you pick — under the dashboard's Audio & video settings, which also rings the chosen speaker so you can tell it is the right one, on the pre-call check, or inside a call — are remembered per browser and used for every call after that; the chosen speaker is re-applied every time audio is attached, and a browser that refuses the switch says so in the status line. Agents can also tick skip this check next time to join the moment their devices are up. Nothing is stored on the server: a device id only means something to the browser that issued it.
A meeting is created only when a call is accepted, never for people in the
queue, and exactly two participant tokens are minted, by the Durable Object.
No recording, no transcription, no third party in the room. calls records
who, which site, which agent, when, how long and why it ended; that list is
deliberately complete.
npm test # 85 unit tests: state machine (round-robin, races, timers), domains, alerts, passwords
npm run smoke # 45 end-to-end checks against a running Worker: origins, sockets, queue, auto-assignment, D1
npm run smoke:sites # 26 checks on site management, domain enforcement, per-site settings
npm run smoke:call # 16 checks that create and tear down a REAL RealtimeKit meeting
npm run check # build + typecheck (worker and browser) + unit tests; runs before every deploy
The smoke suites take [baseUrl] [password] [email] and refuse a non-localhost
target unless you add --disruptive: they take agents offline, drive the queue
and create a throwaway site, which disconnects anyone using the deployment.
src/shared/machine/ is a pure reducer — (state, command) → {state, effects}
with no I/O, no clock and no randomness; now and every generated id arrive on
the command — so the whole product lifecycle, including three agents and two
concurrent calls, runs in milliseconds under test. The Durable Object is a shell
that persists, broadcasts and sets alarms.
LiveHostRoom, addressed by idFromName(ORG_ID). One organization means one
instance, which is what makes a single global queue across unrelated websites
possible: everyone lands in the same line because there is only one room.
Sockets are accepted with ctx.acceptWebSocket, so hundreds of idle widgets
cost nothing while the object hibernates. Consequences: no timers (every
deadline collapses into the single alarm slot, and TICK applies all of them at
once); no in-memory truth (state is read from storage and written back each
event); socket identity in serializeAttachment, not a Map. Keepalives are
answered by setWebSocketAutoResponse without waking the object.
Agent OFFLINE → AVAILABLE ⇄ PAUSED (per agent; org presence = best agent)
↕
BUSY
Visitor BROWSING → WAITING → INVITED → CONNECTING → IN_CALL → COMPLETED
↓ ↓ ↓
LEFT EXPIRED VISITOR_LEFT
DECLINED
Each agent has an intent (what they asked for); their status is derived from it plus reality — connected? in a call? holding an invitation? Storing the derived value would let the two drift, which is how a widget ends up showing LIVE for a dashboard that closed an hour ago.
The Durable Object handles one event at a time, so these are plain checks:
| Race | Resolution |
|---|---|
| Two agents accept the same visitor | The second sees the invitation and is told so |
| Visitor leaves as an agent accepts | Invitation and provisional call go with them; the agent is freed |
| Invitation expires as the visitor accepts | Deadline re-checked in the reducer, not left to the timer |
| Agent goes offline mid-invitation | Visitor returns to the head of the line for the next agent |
| A command is retried after a dropped socket | commandId ring buffer makes it a no-op |
| RealtimeKit fails to create the meeting | Visitor keeps their place; automatic assignment pauses for 30s rather than hammering a broken dependency, and an agent can still accept by hand |
| What happens | What we do |
|---|---|
| An agent closes the dashboard | 45s grace, then offline. The queue is released only when the last live agent goes |
| A visitor's tab is suspended | 90s grace; they keep their exact position if they return |
| Either side drops mid-call | 30s to reconnect before the call ends |
| Visitor accepts but never connects | 120s, then the call is abandoned and does not count |
Rolling average of the last 10 connected calls, divided by the number of live agents, plus the remainder of the soonest-ending call in progress. Below 3 completed calls there is no estimate, just "2 people ahead of you". Estimates are bucketed ("~5 min", "~10 min", "20+ min") because "~13 minutes" reads as a promise.
siteId arrives from a script tag on a page you do not control, so it is a
claim. Every entry point that spends resources — opening a socket, joining the
queue, minting call credentials — validates it against the browser-supplied
Origin. Rate limits (KV, fixed window, fail open): sockets 60/min, joins
10/5 min, offline messages 5/hour, call credentials 20/5 min, sign-in 8/15 min.
The strongly-consistent limits — one queue entry per visitor, one call per
agent — live in the state machine.
src/
index.ts Worker entry; route registration
config.ts every tunable number and every visitor-facing string
types.ts bindings and D1 row shapes
durable/
LiveHostRoom.ts the one coordinator: sockets, alarms, broadcasts
commands.ts socket message → command, where identity is enforced
tokens.ts, signatures.ts call tokens; "did anything change?" digests
shared/
protocol.ts the wire format, shared with client/
machine/ the pure reducer — read this first
types.ts state, commands and effects
transitions.ts one function per state change
reduce.ts the command switch
views.ts read-only projections for the wire
domains.ts the domain allow-list rules
validation.ts inbound parsing and sanitisation
routes/ api, ws, call, media, and host/ (dashboard: one file per tab)
lib/ auth, access, csrf, password, sites, db/, realtimekit, ratelimit, analytics
ui/styles/ dashboard and call page CSS
client/
widget/ the embeddable widget (shadow DOM, no framework); views/ = one file per screen
host/ the dashboard: one file per tab, plus socket, alerts, recorder
call/ the call page: devices, meters, microphone, peer, lifecycle
shared/ reconnecting WebSocket, device prefs, test tone
migrations/ D1 schema, applied in order
seed/ example sites (any environment) and the dev admin (local only)
scripts/ agent CLI, client build, deploy preflight, demo server, smoke suites
test/ vitest
demo/ a pretend customer page for local development
AGENTS.md the rules of the road for AI coding agents (Codex, Claude Code, Cursor…)
client/ imports types straight out of src/shared/: the protocol is defined
once and both ends of the wire are checked against it.
Public broadcasting, group calls, visitor-to-visitor chat, scheduling, visitor
accounts, recording, transcripts, SMS, CRM integrations, departments or skills
routing, payments. The reducer is one file where a routing rule would go, and
queue_sessions and calls already carry site_id, visitor_id and
agent_id.
MIT — see LICENSE.
36 commits
TypeScript
90.4%
JavaScript
9.6%