Castio — Platform Design Document
Date: 2026-03-06 Status: Reviewed — all 14 sections verified against primary sources (see Spec Review Tracker at end of document). Ready for build.
Architecture in one sentence: This is a self-hosted video/voice collaboration platform that runs entirely on the customer's own Cloudflare account. Each customer deployment is an independent, isolated instance with its own Workers, D1, R2, and KV — zero connection to the vendor. See Section 1.2 for the full deployment model.
Table of Contents
- Product Overview
- System Architecture
- Diagnostics & Observability
- User System & Authentication
- Role-to-Preset Mapping
- Meeting Lifecycle
- RTK Feature Mapping
- Post-Meeting Experience
- Webhook & Analytics Pipeline
- App Pages & Navigation
- Branding & Theming
- Organization Configuration
- Future Features
- Deployment App (Separate Product)
1. Product Overview
1.1 What This Is
A self-hosted, white-label video/voice collaboration platform deployed entirely within the customer's own Cloudflare account. The platform supports four meeting types — group video calls, audio-only rooms, webinars with stage management, and RTMP/HLS livestreams. All real-time media, participant state, recording, transcription, chat, and polls are handled by Cloudflare RealtimeKit. We build everything RealtimeKit does not provide: user management, scheduling, analytics, diagnostics, branding, and abuse protection.
1.2 Deployment Model
CRITICAL ARCHITECTURE CONCEPT — read this before working on ANY section.
This product has two completely separate systems that share NO infrastructure, NO database, and NO authentication:
Deployment App (vendor) Collaboration Platform (customer) **Runs on** Vendor's Cloudflare account Customer's OWN Cloudflare account **Domain** deploy.castio.app Customer's own domain (e.g., meet.acmecorp.com) **Purpose** Register, pay, deploy the platform Video meetings, chat, recording, etc. **Users** Customers buying the product Employees/guests using the product **Spec section** Section 14 ONLY Sections 1-13 **Auth system** Separate (deploy_users, deploy_sessions) Separate (users, refresh_tokens, guest_tokens) Sections 1-13 of this spec describe the collaboration platform — the product that runs on the customer's infrastructure. Section 14 is a separate product entirely.
Each deployment = ONE organization. There is no multi-org, no org picker, no org switching. When a user visits
meet.acmecorp.com, they are in Acme Corp — determined by which Worker is serving the request, not by user input. Login is email + password. Theorganizationstable has exactly one row per deployment.Do NOT:
- Add org selection UI, org switching, or org-aware routing to the platform (Sections 1-13)
- Confuse the two auth systems (deploy app auth ≠ platform auth)
- Route any platform traffic to the vendor's infrastructure (except optional diagnostics)
- Assume the platform knows about deploy.castio.app or vice versa
- Self-hosted: Runs entirely on the customer's Cloudflare account (Workers, D1, R2, KV)
- Zero vendor connection: No platform traffic routes to vendor infrastructure at runtime
- One org per deployment: The Worker deployment IS the organization — no org selection needed
- Optional diagnostics: Health telemetry can be sent to a vendor endpoint; enabled by default, toggled per-org
- Deployment: Handled by the Deployment App (Section 14) — a separate product on the vendor's account
1.3 What RealtimeKit Handles (We Do NOT Rebuild)
| Capability | RTK Component | Our Role |
|---|---|---|
| Media routing (video/audio/screen share) | SFU + Core SDK | Wire up SDK, expose in UI |
| Participant state & permissions | Presets + REST API | Create presets matching our roles, call REST API |
| Meeting lifecycle (join/leave/active/inactive) | REST API + SDK events | Proxy REST calls, listen to SDK events |
| Chat (public + private DMs + pin) | Core SDK + UI Kit | Render `rtk-chat` components |
| Polls | Core SDK + UI Kit | Render `rtk-polls` components |
| Stage management (webinars) | Presets + UI Kit | Render stage components, configure presets |
| Waiting rooms | Presets + UI Kit | Configure preset, render waiting screen |
| Breakout rooms | Connected Meetings API | Render `rtk-breakout-rooms-manager` (web only, beta) |
| Recording (composite) | REST API + Recording SDK | Start/stop via API, configure cloud storage |
| Transcription (Whisper Large v3 Turbo) | AI feature + webhooks | Enable via preset, display `rtk-ai-transcriptions` |
| AI meeting summaries | AI feature + webhooks | Consume `meeting.summary` webhook |
| Virtual backgrounds | `@cloudflare/realtimekit-virtual-background` | Include addon |
| Plugins (whiteboard, doc sharing) | Plugin system + UI Kit | Enable via preset |
| Simulcast | Core SDK config | Set simulcast options |
| TURN relay (ICE fallback) | Managed by RTK internally | Transparent — no developer action needed |
| Message broadcasting | Server-side API | Call from Worker when needed |
| Collaborative stores | Server-side API | Use for custom real-time state sync |
1.4 What We Build (Not in RTK)
| Feature | Why Custom | Storage |
|---|---|---|
| **User system** (accounts, auth, roles) | RTK has participants, not users | D1 |
| **Meeting scheduling** (instant, scheduled, recurring, permanent rooms) | RTK meetings have no time concept | D1 |
| **Post-meeting experience** (recording playback, transcript viewer, AI summary display) | RTK stores data 7 days, we persist references | D1 + R2 |
| **Analytics** (session counts, durations, participant stats, recording stats) | RTK provides basic daywise/livestream analytics via REST API; we supplement with granular per-meeting and custom-dimension analytics | D1 (webhook-fed + RTK analytics API) |
| **Diagnostics & observability** (error tracking, health checks, Sentry) | Enterprise requirement, not in RTK | Sentry + Workers Observability + D1 (2 tables) |
| **Org configuration** (settings, branding, policies) | Platform-level concern | D1 + R2 |
| **Abuse protection** (rate limiting, capacity caps, guest controls) | Platform-level concern | KV + D1 |
1.5 Meeting Types and Preset Mapping
Meeting type is determined by which preset is applied to participants at join time. There are 12 presets total (4 meeting types × 3 roles: host, participant, viewer). The Worker selects the appropriate preset based on the user's platform role + meeting type. See Section 5 for full preset JSON.
Summary of role → preset mapping:
| Platform Role | Video Call | Audio Room | Webinar | Livestream |
|---|---|---|---|---|
| **Owner/Admin** | video_host | audio_host | webinar_host | livestream_host |
| **Host** | video_host | audio_host | webinar_host | livestream_host |
| **Member** | video_participant | audio_participant | webinar_participant | livestream_participant |
| **Guest** | video_participant (restricted via meeting config) | audio_participant | webinar_viewer | livestream_viewer |
Note: Owner/Admin/Host all use the same
*_hostpreset (platform-level role distinction only — RTK treats them identically). Guest access restrictions (no screen share, waiting room required, public chat only) are enforced via meeting-level config and platform logic, not separate presets.
1.6 Scope Boundaries
In scope (v1, web-first):
- All Tier 1 features from gap analysis (meeting lifecycle, chat, polls, stage, recording, transcription, AI summaries, virtual backgrounds, plugins, simulcast, webhooks)
- RTMP/HLS livestreaming (sparse docs — discovery risk accepted)
- Breakout rooms (beta, web only)
- Full diagnostics/observability layer
Low priority (groundwork only):
- Realtime Agents (DO binding + route stubs only; SDK is experimental/transitional)
Needs research:
- Per-track recording — FUTURE (watch). API endpoint exists (
POST /recordings/track) but layer system only supportsdefaultkey (all participants), not per-participant isolation. Functionally equivalent to composite's audio/video split. Build when RTK ships multi-layer support. See Section 7.9.
Future / dropped:
- Noise cancellation (third-party client-side lib — future)
- SIP interconnect (does not exist in RTK — dropped)
2. System Architecture
2.1 High-Level Component Diagram
┌─────────────────────────────────────────────────────────────┐
│ Customer's Cloudflare Account │
│ │
│ ┌──────────────┐ ┌────────────────────────────────────┐ │
│ │ React SPA │◄──►│ Cloudflare Worker (API + Webhooks)│ │
│ │ (Worker) │ │ routes: /api/*, /webhooks/* │ │
│ │ RTK UI Kit │ └──────┬──────┬──────┬───────┬───────┘ │
│ │ RTK Core SDK│ │ │ │ │ │
│ └──────┬───────┘ │ │ │ │ │
│ │ ▼ ▼ ▼ ▼ │
│ │ ┌─────┐ ┌────┐ ┌────┐ ┌──────┐ │
│ │ │ D1 │ │ R2 │ │ KV │ │ DO │ │
│ │ │(DB) │ │(fs)│ │(ch)│ │(agt) │ │
│ │ └─────┘ └────┘ └────┘ └──────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────┐ │
│ │ RealtimeKit │ ◄── SDK connects directly from │
│ │ (SFU + services)│ browser to RTK infrastructure │
│ └──────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│ (optional)
▼
┌──────────────────┐
│ Sentry (SaaS) │ ◄── Error tracking from Worker + SPA (vendor DSN + optional customer DSN)
└──────────────────┘2.2 Cloudflare Worker — Route Architecture
Single Worker handling all server-side logic. Routes organized by concern:
Auth Routes
| Method | Route | Purpose |
|---|---|---|
| POST | `/api/auth/register` | Create user account |
| POST | `/api/auth/login` | Authenticate, return JWT |
| POST | `/api/auth/refresh` | Refresh JWT |
| POST | `/api/auth/logout` | Invalidate session |
| GET | `/api/auth/me` | Current user profile |
| POST | `/api/auth/forgot-password` | Send password reset email |
| POST | `/api/auth/reset-password` | Reset password with token |
| POST | `/api/auth/change-password` | Change password (authenticated) |
User & Org Routes
| Method | Route | Purpose |
|---|---|---|
| GET | `/api/users` | List org users (admin+) |
| PATCH | `/api/users/:userId` | Update user role/profile |
| DELETE | `/api/users/:userId` | Remove user from org |
| GET | `/api/org/settings` | Get org configuration |
| PATCH | `/api/org/settings` | Update org settings (owner/admin) |
| POST | `/api/org/branding` | Upload logo/assets to R2 |
Meeting Routes
| Method | Route | Purpose |
|---|---|---|
| POST | `/api/meetings` | Create meeting (calls RTK REST: `POST /meetings`) |
| GET | `/api/meetings` | List meetings with filters |
| GET | `/api/meetings/:meetingId` | Get meeting details |
| PATCH | `/api/meetings/:meetingId` | Update meeting (title, schedule, settings) |
| DELETE | `/api/meetings/:meetingId` | Deactivate meeting |
| POST | `/api/meetings/:meetingId/end` | End a meeting (Host/Admin/Owner) |
| POST | `/api/meetings/:meetingId/join` | Create RTK participant, return `authToken` + meeting config (handles both authenticated and guest joins — see Section 4) |
| GET | `/api/meetings/:meetingId/participants` | List participants |
| POST | `/api/meetings/:meetingId/recording/start` | Start composite recording via RTK API |
| POST | `/api/meetings/:meetingId/recording/stop` | Stop recording via RTK API |
| GET | `/api/meetings/:meetingId/recordings` | List recordings for meeting |
| GET | `/api/meetings/:meetingId/transcripts` | Get transcripts (from RTK or cached) |
| GET | `/api/meetings/:meetingId/sessions` | List sessions for a meeting (most recent first) |
| GET | `/api/meetings/:meetingId/summaries` | Get AI summaries |
| POST | `/api/meetings/:meetingId/summaries/generate` | Trigger on-demand summary generation via RTK |
| POST | `/api/meetings/:meetingId/lock` | Lock/unlock meeting (Host/Admin/Owner) |
| GET | `/api/meetings/:meetingId/chat-exports` | Get chat export for meeting |
| GET | `/api/meetings/resolve` | Resolve meeting slug to meetingId (`?slug=xyz` → `{ meetingId, title, status }`) |
Scheduling Routes
| Method | Route | Purpose |
|---|---|---|
| GET | `/api/meetings/:meetingId/occurrences` | List occurrences of a recurring meeting |
| PATCH | `/api/meetings/:meetingId/occurrences/:occurrenceId` | Override a specific occurrence (title, time, cancel) |
| POST | `/api/meetings/:meetingId/invites` | Invite users to a meeting |
| GET | `/api/meetings/:meetingId/invites` | List invites for a meeting |
| PATCH | `/api/meetings/:meetingId/invites/:inviteId` | Update RSVP status |
| DELETE | `/api/meetings/:meetingId/invites/:inviteId` | Remove an invite |
Note: Recurring patterns are created/managed as part of meeting creation/update (
POST /api/meetingswithschedulingType: "recurring"and RRULE in body). No separate CRUD needed for the pattern itself.
Analytics Routes
| Method | Route | Purpose |
|---|---|---|
| GET | `/api/analytics/overview` | Dashboard stats (sessions, participants, duration) |
| GET | `/api/analytics/meetings` | Per-meeting analytics |
| GET | `/api/analytics/usage` | Usage trends over time |
Diagnostics Routes
| Method | Route | Purpose |
|---|---|---|
| GET | `/api/diagnostics/health` | System health check (probe results) |
| GET | `/api/diagnostics/webhooks` | Webhook delivery status/failures (paginated) |
| GET | `/api/diagnostics/webhooks/:id` | Single webhook delivery detail |
| GET | `/api/diagnostics/active-meetings` | Currently active meetings |
| POST | `/api/diagnostics/phone-home` | Manual trigger for phone-home report |
| PATCH | `/api/diagnostics/settings` | Update diagnostics settings (toggle Sentry, phone-home, customer DSN) |
Webhook Receiver Route
| Method | Route | Purpose |
|---|---|---|
| POST | `/api/webhooks/rtk` | Receive all RTK webhook events |
Agent Routes (Groundwork Only)
| Method | Route | Purpose |
|---|---|---|
| POST | `/api/agents/:meetingId/init` | Initialize agent DO for meeting |
| POST | `/api/agents/:meetingId/deinit` | Tear down agent |
| ALL | `/agentsInternal/*` | DO internal pipeline (forwarded to DO fetch) |
SPA Catch-All
All routes that do NOT match /api/*, /webhooks/*, or /agentsInternal/* must return the SPA's index.html to enable client-side routing. The Worker's fetch handler should check the path prefix and either route to API handlers or serve the static SPA shell. See Section 10.6 for the route tree and Worker example.
Deactivated User Handling
When an admin deactivates a user (PATCH /api/users/:userId { is_active: false }):
- All refresh tokens for the user are deleted (CASCADE)
- JWT middleware rejects future API calls (
is_activecheck) - If the user is in an active meeting, the Worker calls
DELETE /accounts/{ACCOUNT_ID}/realtime/kit/{APP_ID}/meetings/{MEETING_ID}/participants/{PARTICIPANT_ID}to force-disconnect them - A
participant_eventsrow is logged:event_type: 'kicked', performed_by: <admin_user_id>
Finding active meetings for a user requires querying session_participants table for rows with matching user_id and no left_at timestamp.
2.3 D1 Database Schema
⚠️ SINGLE-ORG ARCHITECTURE —
org_idis NOT for multi-tenancyEvery table includes an
org_idforeign key. This exists solely for referential integrity and query consistency — NOT for multi-org support. Each deployment has exactly ONE organization. There is always exactly one row in theorganizationstable.During build, agents MUST NOT:
- Build org selection UI, org switching, or org picker components
- Add org-based routing, subdomains, or URL patterns
- Create tenant isolation middleware or org-scoping query wrappers
- Treat
org_idas a variable — it is always the same value across all rows- Add org_id to authentication tokens or session context (it's implicit)
The organization is determined by the Worker deployment itself, not by user input. See Section 1.2 (Deployment Model) for architecture details.
`users`
-- Users table — see Section 4.2 for canonical schema
-- Key columns: id (nanoid), org_id, email, name, password_hash (nullable), role (owner|admin|host|member), is_active, avatar_url`meetings`
-- Meetings table — see Section 6.2 for canonical schema
-- Key columns: id (nanoid), org_id, title, slug, meeting_type, status (draft|scheduled|active|ended|cancelled), created_by, scheduling_type, is_lockedScheduling Tables
-- Meeting scheduling: See Section 6.2 for canonical scheduling schema
-- (recurring_patterns, meeting_occurrences, meeting_invites)`sessions`
-- Sessions table — see Section 9.4 for canonical schema
-- Key columns: id, meeting_id, org_id, started_at, ended_at, duration_seconds, participant_count, peak_participant_count, status (active|ended), has_recording, has_transcript, has_summary, has_chat`session_participants`
-- Session participants table (canonical definition)
-- Tracks per-session participation (one row per user per session join)
CREATE TABLE session_participants (
id TEXT PRIMARY KEY, -- ulid
org_id TEXT NOT NULL,
session_id TEXT NOT NULL REFERENCES sessions(id),
meeting_id TEXT NOT NULL REFERENCES meetings(id),
user_id TEXT REFERENCES users(id), -- null for guests
rtk_participant_id TEXT, -- RTK participant ID
display_name TEXT NOT NULL,
role TEXT NOT NULL,
joined_at TEXT NOT NULL DEFAULT (datetime('now')),
left_at TEXT,
duration_seconds INTEGER,
is_guest INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_session_participants_session ON session_participants(session_id);
CREATE INDEX idx_session_participants_meeting ON session_participants(meeting_id);
CREATE INDEX idx_session_participants_user ON session_participants(user_id);`recordings`
-- Recordings table — see Section 9.4 for canonical schema
-- Key columns: id (RTK recording ID), org_id, meeting_id, session_id, status, download_url, duration_seconds`transcripts`
-- Transcripts table — see Section 9.4 for canonical schema
-- Key columns: id, org_id, meeting_id, session_id, download_url, transcript_json, language`summaries`
-- Summaries table — see Section 9.4 for canonical schema (table name: summaries)
-- Key columns: id, org_id, meeting_id, session_id, summary_markdown, summary_type`org_settings`
Note: This is a simplified overview. See Section 12.3 for the full canonical schema with all branding, policy, and watermark columns.
CREATE TABLE org_settings (
id TEXT PRIMARY KEY DEFAULT 'default', -- single-row table
org_name TEXT NOT NULL,
org_slug TEXT NOT NULL UNIQUE,
rtk_app_id TEXT, -- RealtimeKit App ID
logo_url TEXT, -- R2 key
brand_color TEXT DEFAULT '#2563EB',
default_meeting_type TEXT DEFAULT 'video',
max_meeting_duration_minutes INTEGER DEFAULT 480,
max_participants_per_meeting INTEGER DEFAULT 200,
guest_access_global INTEGER NOT NULL DEFAULT 1,
waiting_room_default INTEGER NOT NULL DEFAULT 1,
recording_storage_provider TEXT DEFAULT 'r2',
recording_storage_config TEXT, -- JSON: bucket, credentials (encrypted)
transcription_default INTEGER NOT NULL DEFAULT 0,
phone_home_enabled INTEGER NOT NULL DEFAULT 0,
phone_home_url TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);Diagnostics tables (2 tables) — see [Section 3](#3-diagnostics--observability) for full schema.
diagnostic_webhook_deliveries— webhook processing status trackingdiagnostic_health_snapshots— periodic health probe results
2.4 R2 Storage
| Bucket | Contents | Access Pattern |
|---|---|---|
| `rtk-branding` | Logos, custom backgrounds, favicon | Read on page load, write on settings update |
| `rtk-recordings` | Composite recordings (if using R2 over external) | Write from RTK, read on playback |
| `rtk-exports` | Analytics exports, transcript downloads | Write on demand, read once |
Note: Bucket names are configured at deployment time (see Section 14).
2.5 KV Namespaces
| Namespace | Key Pattern | Value | TTL |
|---|---|---|---|
| `sessions` | `session:{jwt_id}` | `{userId, orgId, role, exp}` | JWT expiry (e.g., 24h) |
| `rate-limits` | `ratelimit:{category}:{identifier}:{window}` | Request count (integer). Window = `Math.floor(Date.now() / (windowSeconds * 1000))` | Matches window duration |
| `meeting-state` | `active:{orgId}:{meetingId}` | `{sessionId, participantCount, startedAt}` | None (deleted on session end) |
| `cache` | `cache:{orgId}:{resource}:{id}` | Cached API responses | 30-300s depending on resource |
2.6 Durable Objects (Groundwork Only)
Reserved for Realtime Agents. Not built in v1 but wrangler config includes the binding:
// wrangler.jsonc (relevant section)
{
"durable_objects": {
"bindings": [
{ "class_name": "RealtimeAgent", "name": "REALTIME_AGENT" }
]
},
"migrations": [
{ "new_sqlite_classes": ["RealtimeAgent"], "tag": "v1" }
]
}2.7 Worker Bindings Summary
// wrangler.jsonc bindings
{
"d1_databases": [
{ "binding": "DB", "database_name": "rtk-platform", "database_id": "..." }
],
"r2_buckets": [
{ "binding": "R2_BRANDING", "bucket_name": "rtk-branding" },
{ "binding": "R2_RECORDINGS", "bucket_name": "rtk-recordings" },
{ "binding": "R2_EXPORTS", "bucket_name": "rtk-exports" }
],
"kv_namespaces": [
{ "binding": "KV_SESSIONS", "id": "..." },
{ "binding": "KV_RATE_LIMITS", "id": "..." },
{ "binding": "KV_MEETING_STATE", "id": "..." },
{ "binding": "KV_CACHE", "id": "..." }
],
"vars": {
"RTK_API_BASE": "https://api.cloudflare.com/client/v4/accounts",
"PHONE_HOME_URL": ""
},
"secrets": ["ACCOUNT_ID", "RTK_API_TOKEN", "JWT_SECRET", "RESEND_API_KEY", "SENTRY_DSN_VENDOR", "SENTRY_DSN_CUSTOMER", "SENTRY_RELEASE"],
"triggers": {
"crons": [
"* * * * *", // Every minute: activate scheduled meetings (Section 6.3)
"0 0 * * *", // Daily midnight UTC: materialize recurring occurrences (Section 6.4)
"0 3 * * *" // Daily 3 AM UTC: diagnostic cleanup (Section 3.11)
]
}
}2.8 Frontend Architecture
- React SPA served via Cloudflare Worker (static assets bundled or stored in R2)
- RTK UI Kit (
@cloudflare/realtimekit-react-ui) for all meeting UI — 136 pre-built components - RTK Core SDK (
@cloudflare/realtimekit-react) for programmatic control and custom UI where needed - Routing: Client-side (React Router). Meeting room is a single route (
/meetings/:meetingId/live) that loads the full RTK UI Kit experience. See Section 10.6 for full route tree. - State management: RTK SDK manages all meeting state; React context for app-level state (auth, settings)
- Addons loaded: Virtual background, recording, AI transcription
2.9 Data Flow: Meeting Join Sequence
User clicks "Join Meeting"
→ SPA calls POST /api/meetings/:meetingId/join (with JWT)
→ Worker validates JWT, checks user role + meeting permissions
→ Worker calls RTK REST API: POST /realtime/kit/{APP_ID}/meetings/{MEETING_ID}/participants
with preset matching user's role
→ RTK returns { id, token } (Worker maps token → authToken for client response)
→ Worker stores participant record in D1
→ Worker updates KV meeting-state (increment participant count)
→ Worker returns { authToken, meetingConfig, preset_name } to SPA
→ SPA initializes RTK SDK: RealtimeKitClient.init({ authToken })
→ SDK connects to RTK SFU (direct browser → Cloudflare edge)
→ Meeting UI renders via RTK UI Kit components2.10 Data Flow: Webhook Processing
RTK fires webhook event (e.g., recording.statusUpdate)
→ POST /api/webhooks/rtk
→ Worker validates webhook signature
→ Worker classifies event type
→ Worker routes to handler:
├── recording.statusUpdate → update recordings table, notify via WS if active
├── meeting.chatSynced → store chat export reference
├── meeting.summary → store summary in D1
├── meeting.started → create session record, update KV meeting-state
├── meeting.ended → close session record, compute duration, update analytics
├── meeting.participantJoined → update participant count
├── meeting.participantLeft → update participant record, compute duration
├── meeting.transcript → store transcript in D1
└── livestreaming.statusUpdate → update livestream state
→ Handler writes to D1 + KV as needed
→ Handler writes to diagnostic_webhook_deliveries (success or failure); errors captured by Sentry automatically
→ Worker returns 200 OK (or 500 + logs failure for retry)3. Diagnostics & Observability
3.1 Design Philosophy
Diagnostics is a core architectural layer, not an afterthought. The guiding principle: know about issues before customers call. The code is always present — orgs can disable reporting but the instrumentation is never stripped.
The ultimate test: when a customer says "my meeting was laggy" or "I couldn't join" — we must be able to look up that specific meeting, that specific user, and see exactly what happened without ever asking the customer to reproduce the problem on a screenshare.
We achieve this by layering four systems that each do what they're best at:
| Layer | Tool | What it handles | Why not something else |
|---|---|---|---|
| **Error tracking & investigation** | Sentry (`@sentry/cloudflare` + `@sentry/react`) | Error capture, deduplication, grouping, stack traces, release tracking, alerting | Purpose-built for this; replacing it with D1 tables would be rebuilding Sentry poorly |
| **Request tracing & I/O monitoring** | Cloudflare Workers Observability | Auto-traces every `fetch()`, KV, R2, D1, DO call. Zero code needed. OpenTelemetry-compliant. | Built into the platform — literally one config line. Traces exported to Sentry via OTLP. |
| **Post-meeting quality investigation** | RTK Peer Report API (`GET /sessions/peer-report/{peer_id}`) | Per-participant quality stats (MOS, jitter, packet loss, RTT), device info, ICE candidates, TURN usage, IP geolocation, timestamped events | RTK already captures this server-side for every participant — we just query it on demand |
| **Operational data we own** | D1 tables (2 tables) | Webhook delivery tracking, health check snapshots | Only we know about webhook processing flows and health probe results — no external tool captures this |
What we do NOT build:
D1 error tables— Sentry is the error store. Nodiagnostic_errorstable.D1 RTK API call logs— Workers Observability auto-traces everyfetch()to RTK API with latency, status, headers. Nodiagnostic_rtk_api_callstable.Custom RTK API call wrapper for diagnostics— Workers Observability instrumentsfetch()automatically. Our RTK API client is a thin wrapper focused on auth/retries, not diagnostic logging.
3.2 Sentry Integration
Sentry is the backbone of error tracking. Two SDKs, two contexts, one Sentry project.
Packages & Versions
- Worker:
@sentry/cloudflare(v10.x, production-ready, official Sentry package) - SPA:
@sentry/react(mature, full-featured including Session Replay) - Replaces: toucan-js (deprecated;
@hono/sentryalso deprecated in favor of@sentry/cloudflare) - Requirement:
compatibility_flags = ["nodejs_compat"]in wrangler.toml (for AsyncLocalStorage)
DSN Ownership Model
Each deployment has up to two Sentry DSNs:
| DSN | Secret Name | Purpose | Default |
|---|---|---|---|
| **Vendor DSN** | `SENTRY_DSN_VENDOR` | Our (platform vendor) Sentry project. We see errors across ALL customer deployments. | Pre-configured by deployment app. Enabled by default. |
| **Customer DSN** | `SENTRY_DSN_CUSTOMER` | Customer's own Sentry project. They see only their own errors. | Empty. Customer adds via org settings. |
Behavior:
- If both DSNs are set → errors go to both (dual-client pattern)
- If customer disables vendor DSN → errors go only to customer's Sentry (or nowhere if they have no DSN)
- If neither is set → errors log to
console.erroronly (captured by Workers Observability logs) - Customer can toggle vendor DSN on/off from org settings page at any time
- Customer can add/change their own DSN from org settings page at any time
- DSNs are write-only (can only submit events, cannot read/modify/delete) — safe to embed
Sentry project structure (vendor side):
- ONE Sentry project for all customer deployments (not per-customer)
- Events tagged with
deployment_idandorg_idfor filtering - Use Sentry's search/filter to isolate per-deployment views
- Sentry Team plan ($26/mo base, ~$0.00015/event overage) — handles scale well
Worker-Side Init
import * as Sentry from "@sentry/cloudflare";
// The entire Worker handler is wrapped with Sentry's withSentry()
export default Sentry.withSentry(
(env: Env) => ({
dsn: env.SENTRY_DSN_VENDOR,
environment: env.ENVIRONMENT || "production",
release: env.SENTRY_RELEASE, // git SHA, set at deploy time
tracesSampleRate: 0.1, // 10% of transactions
beforeSend(event) {
// Strip PII: redact email, IP, auth tokens
event = redactPII(event);
// Forward to customer DSN if configured
if (env.SENTRY_DSN_CUSTOMER && env.SENTRY_CUSTOMER_ENABLED !== "false") {
forwardToCustomerSentry(event, env.SENTRY_DSN_CUSTOMER);
}
// Check if vendor DSN is disabled by org
if (env.SENTRY_VENDOR_ENABLED === "false") return null; // drop event
return event;
},
}),
{
async fetch(request, env, ctx) {
// Sentry automatically captures unhandled exceptions,
// attaches request data, and traces fetch() sub-requests
return router.handle(request, env, ctx);
},
async scheduled(event, env, ctx) {
ctx.waitUntil(runHealthCheck(env));
ctx.waitUntil(cleanupOldDiagnostics(env));
},
}
);What withSentry() gives us automatically (zero additional code):
- Unhandled exception capture with full stack traces
- Request context (URL, method, headers) attached to every event
- Breadcrumbs for
console.log/warn/errorcalls - Distributed tracing across fetch sub-requests
SPA-Side Init
import * as Sentry from "@sentry/react";
Sentry.init({
dsn: config.sentryDsnVendor, // fetched from Worker config endpoint
environment: config.environment,
release: config.release,
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({ maskAllText: false, blockAllMedia: false }),
],
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0.1, // 10% of sessions
replaysOnErrorSampleRate: 1.0, // 100% of sessions with errors
beforeSend(event) {
event = redactPII(event);
if (config.sentryDsnCustomer) {
forwardToCustomerSentry(event, config.sentryDsnCustomer);
}
if (!config.sentryVendorEnabled) return null;
return event;
},
});Dual-DSN implementation (SPA): Use a custom transport wrapper that POSTs the event envelope to both DSN endpoints. This is simpler and more reliable than running two BrowserClient instances (which Sentry docs call "not recommended" due to integration conflicts).
function makeDualTransport(vendorDsn: string, customerDsn?: string) {
const vendorTransport = Sentry.makeFetchTransport({ dsn: vendorDsn });
const customerTransport = customerDsn
? Sentry.makeFetchTransport({ dsn: customerDsn })
: null;
return (options: Sentry.TransportOptions) => ({
send: async (envelope: Sentry.Envelope) => {
const results = await Promise.allSettled([
vendorTransport(options).send(envelope),
customerTransport ? customerTransport(options).send(envelope) : Promise.resolve(),
]);
return results[0].status === "fulfilled" ? results[0].value : results[1].value;
},
flush: (timeout?: number) => vendorTransport(options).flush(timeout),
});
}Sentry Tags (applied to every event)
| Tag | Value | Purpose |
|---|---|---|
| `deployment_id` | Unique per deployment | Filter vendor Sentry by deployment |
| `org_id` | Org identifier | Group errors by customer org |
| `user_id` | Platform user ID | "Show me all errors for user X" — set via `Sentry.setUser({ id })` |
| `participant_id` | RTK participant/peer ID (set on meeting join) | Cross-reference with peer-report API for quality investigation |
| `session_id` | RTK session ID (set on meeting join) | Correlate errors to specific meeting sessions |
| `component` | `worker`, `spa`, `rtk-sdk`, `webhook-handler` | Error source |
| `meeting_id` | RTK meeting ID (when in meeting context) | Correlate errors to specific meetings |
| `route` | e.g., `POST /api/meetings/:id/join` | Which API route errored |
Source Maps
# Deploy script uploads source maps to Sentry
SENTRY_RELEASE=$(sentry-cli releases propose-version)
wrangler deploy --var SENTRY_RELEASE:$SENTRY_RELEASE
sentry-cli sourcemaps upload --release=$SENTRY_RELEASE dist/- Requires
sentry-cli >= 2.17.0andwrangler >= 3.x - Worker source maps:
upload_source_maps = truein wrangler.toml - SPA source maps: Vite plugin or manual upload
What Gets Reported to Sentry
| Source | What | Automatic? |
|---|---|---|
| Worker unhandled exceptions | All crashes, binding errors, OOM | Yes (`withSentry` catches them) |
| Worker `fetch()` failures | RTK API 4xx/5xx, timeout, network errors | Yes (Workers Observability traces + Sentry captures thrown errors) |
| Webhook handler failures | Processing errors, invalid payloads | Yes (thrown errors within `withSentry`) |
| D1/KV/R2 failures | Query errors, constraint violations, permission errors | Yes (thrown errors) |
| SPA React error boundaries | Component crashes | Yes (`Sentry.ErrorBoundary` wraps RTK UI Kit components) |
| SPA RTK SDK errors | See section 3.5 below | Manual (event listeners → `Sentry.captureException`) |
| SPA network errors | Failed API calls to our Worker | Yes (`browserTracingIntegration` traces fetch) |
3.3 Cloudflare Workers Observability
Workers Observability provides auto-instrumentation of all Worker I/O with zero code changes. It captures what we would otherwise need custom wrapper code to log.
Configuration
# wrangler.toml
[observability]
enabled = true
[observability.logs]
head_sampling_rate = 1 # 1.0 = 100% of requests logged (adjust for cost)
[observability.traces]
enabled = true
head_sampling_rate = 0.01 # 1% of requests traced (adjust for cost vs visibility)That's it. No code changes. Note: logs and traces are separate opt-ins. This enables:
- Workers Logs — all
console.log/warn/errorcalls are collected, queryable in Cloudflare dashboard, searchable - Workers Traces — automatic OpenTelemetry-compliant traces for every
fetch(), KVget/put, R2get/put, D1prepare/run, and Durable Object calls. Shows latency, status, and call hierarchy per request. - Metrics Dashboard (Beta) — single view of all Worker metrics + logs
What this replaces
The old spec had a diagnostic_rtk_api_calls D1 table and a custom rtkApiCall() wrapper that manually logged every RTK API call's method, endpoint, duration, and status. Workers Observability does this automatically for every fetch() the Worker makes — including RTK API calls, webhook deliveries, and any external HTTP calls. With full OpenTelemetry trace context.
One exception: Workers Observability captures URL, method, status code, and latency — but NOT response bodies. Our RTK API client wrapper adds error-response logging so we know why a call failed, not just that it did:
// In the RTK API client wrapper (auth/retries layer)
if (!res.ok) {
const body = await res.text();
console.error(JSON.stringify({
rtk_api_error: { method, endpoint, status: res.status, body: body.slice(0, 500) }
}));
// Workers Observability captures this console.error, making it searchable
}OTel Export to Sentry
Workers Observability can export traces to Sentry's OTLP endpoint, connecting Cloudflare's auto-instrumented traces with Sentry's error tracking:
# wrangler.toml (if using direct OTel export — alternative to SDK-based tracing)
[observability]
enabled = true
[observability.logs]
enabled = true
invocation_logs = true # log start/end of each invocation
# Export config is set via Cloudflare dashboard or API:
# Destination: Sentry OTLP endpoint
# Protocol: OTLP/HTTPNote: When using @sentry/cloudflare with withSentry(), Sentry already captures traces via the SDK. OTel export provides a complementary view in Sentry (infrastructure-level traces vs application-level). Both can coexist.
Pricing (customer pays — runs on their Cloudflare account)
| Feature | Free | Workers Paid |
|---|---|---|
| Logs | 200K events/day, 3-day retention | 20M/month, 7-day retention, $0.60/M overage |
| Traces | Free during beta (no published pricing yet) | Free during beta |
For most deployments, the included quotas are more than sufficient. The head_sampling_rate can be lowered to reduce volume.
Logpush (optional, for long-term retention)
Customers who want logs retained beyond 7 days can enable Logpush to push Worker logs to external storage:
Supported destinations: R2, S3, Google Cloud Storage, Azure Blob, Datadog, Elastic, Splunk, Sumo Logic, New Relic, any HTTP endpoint.
Config: logpush = true in wrangler.toml. Workers Paid plan only.
3.4 D1 Diagnostics Tables (2 tables)
We only store data in D1 that no external tool captures: webhook processing status and health check results.
`diagnostic_webhook_deliveries`
Sentry captures errors, but it doesn't know about webhook processing flows (received → processing → processed/failed). Only we track this.
CREATE TABLE diagnostic_webhook_deliveries (
id TEXT PRIMARY KEY, -- ulid (time-sortable)
org_id TEXT NOT NULL,
webhook_event_type TEXT NOT NULL, -- e.g., recording.statusUpdate, meeting.summary
rtk_event_id TEXT, -- event ID from RTK payload
meeting_id TEXT,
session_id TEXT,
status TEXT NOT NULL CHECK(status IN ('received','processing','processed','failed','retry_exhausted')),
processing_duration_ms INTEGER,
error_message TEXT, -- null if success
payload_summary TEXT, -- truncated/redacted payload for debugging
received_at TEXT NOT NULL DEFAULT (datetime('now')),
processed_at TEXT
);
CREATE INDEX idx_webhook_org_time ON diagnostic_webhook_deliveries(org_id, received_at DESC);
CREATE INDEX idx_webhook_status ON diagnostic_webhook_deliveries(org_id, status);
CREATE INDEX idx_webhook_event ON diagnostic_webhook_deliveries(org_id, webhook_event_type, received_at DESC);
CREATE INDEX idx_webhook_meeting ON diagnostic_webhook_deliveries(meeting_id, received_at DESC);`diagnostic_health_snapshots`
Periodic health probe results. No external tool runs these probes — we do.
CREATE TABLE diagnostic_health_snapshots (
id TEXT PRIMARY KEY, -- ulid
org_id TEXT NOT NULL,
check_type TEXT NOT NULL CHECK(check_type IN ('scheduled','manual','phone_home')),
status TEXT NOT NULL CHECK(status IN ('healthy','degraded','unhealthy')),
d1_latency_ms INTEGER,
kv_latency_ms INTEGER,
r2_latency_ms INTEGER,
rtk_api_latency_ms INTEGER,
rtk_api_reachable INTEGER NOT NULL DEFAULT 1,
active_meetings_count INTEGER NOT NULL DEFAULT 0,
active_participants_count INTEGER NOT NULL DEFAULT 0,
details TEXT, -- JSON: per-check details
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_health_org_time ON diagnostic_health_snapshots(org_id, created_at DESC);
CREATE INDEX idx_health_status ON diagnostic_health_snapshots(org_id, status);3.5 Client-Side RTK SDK Error Capture
The RTK SDK emits events that @sentry/react doesn't automatically capture (they're not unhandled exceptions — they're SDK-specific events). We listen for them and forward to Sentry.
RTK SDK Events to Capture
| Event | Source | Callback shape | What it means |
|---|---|---|---|
| `mediaPermissionError` | `meeting.self` | `({ message, kind })` — `message`: `DENIED` / `SYSTEM_DENIED` / `COULD_NOT_START`; `kind`: `audio` / `video` / `screenshare` | Camera/mic permission denied or device unavailable. **Verified** against live RTK docs. |
| `mediaConnectionUpdate` | `meeting.meta` | `({ transport, state })` — `transport`: `consuming` / `producing`; `state`: `new` / `connecting` / `connected` / `disconnected` / `reconnecting` / `failed` | WebRTC media connection state changed. **Verified** (changelog + live docs). |
| `socketConnectionUpdate` | `meeting.meta` | `({ state, reconnectionAttempt, reconnected })` — `state`: `connected` / `disconnected` / `reconnecting` / `failed` | Signaling WebSocket state changed. **Verified** against live RTK docs. |
| `mediaScoreUpdate` | `meeting.self` + `meeting.participants.joined` | `({ kind, isScreenshare, score, scoreStats })` — `kind`: `audio` / `video`; `score`: 0-10; `scoreStats`: `{ bitrate, jitter, packetLoss, resolution }` | Per-participant media quality score. Also available for remote participants (adds `participantId`). **Verified** — note: often labeled "network quality score" in docs but event name is `mediaScoreUpdate`. |
| `roomJoined` | `meeting.self` | `()` (no args) | User successfully joined the meeting room. |
| `roomLeft` | `meeting.self` | `({ state })` — `state`: `left` / `kicked` / `ended` / `rejected` / `disconnected` / `failed` | User left the room — `state` tells you why. Critical for "unexpected disconnect" diagnostics. |
| `deviceUpdate` | `meeting.self` | Device object | Active audio/video device changed mid-meeting. Useful for debugging sudden media issues. |
| `deviceListUpdate` | `meeting.self` | Updated device list | Device plugged in or removed. Correlates with media failures. |
| Recording `ERRORED` state | `meeting.recording` | — | Recording failed (irrecoverable) |
| Screenshare errors | UI Kit language pack | — | Max screenshare limit reached, unknown error |
| Livestream errors | UI Kit language pack | — | Not supported, not found, sync error, start/stop failure |
Implementation Pattern
// In the React meeting component, after RTK meeting is initialized:
// Media permission errors
meeting.self.on("mediaPermissionError", ({ message, kind }) => {
// message: DENIED | SYSTEM_DENIED | COULD_NOT_START
// kind: audio | video | screenshare
Sentry.captureException(new Error(`Media permission: ${kind} - ${message}`), {
tags: { component: "rtk-sdk", error_type: "media_permission", meeting_id: meeting.meta.meetingId },
});
});
// WebRTC media connection state
meeting.meta.on("mediaConnectionUpdate", ({ transport, state }) => {
if (state === "failed") {
Sentry.captureException(new Error(`WebRTC ${transport} connection failed`), {
tags: { component: "rtk-sdk", error_type: "media_connection", meeting_id: meeting.meta.meetingId },
});
}
Sentry.addBreadcrumb({ category: "rtk", message: `Media ${transport}: ${state}`, level: "info" });
});
// Signaling WebSocket state
meeting.meta.on("socketConnectionUpdate", ({ state, reconnectionAttempt, reconnected }) => {
if (state === "failed") {
Sentry.captureException(new Error("Signaling WebSocket connection failed"), {
tags: { component: "rtk-sdk", error_type: "socket_connection", meeting_id: meeting.meta.meetingId },
});
}
Sentry.addBreadcrumb({ category: "rtk", message: `Socket: ${state}${reconnected ? " (reconnected)" : ""}`, level: "info" });
});
// Per-participant media quality (score 0-10)
meeting.self.on("mediaScoreUpdate", ({ kind, score, scoreStats }) => {
Sentry.addBreadcrumb({ category: "rtk", message: `Quality ${kind}: ${score}/10`, level: "info", data: scoreStats });
});Join Flow Tracing
"I couldn't join the meeting" is the #1 support complaint in any video product. The join flow crosses client→Worker→RTK→WebRTC, and failure at any step looks the same to the user: nothing happens. We trace every step with structured Sentry breadcrumbs so that if any step fails, the full trace is captured automatically.
// Join flow — each step adds a breadcrumb, failure at any step fires captureException with full trail
async function joinMeeting(meetingId: string) {
const addStep = (step: string) =>
Sentry.addBreadcrumb({ category: "join_flow", message: step, level: "info" });
try {
addStep("join_flow:requesting_token");
const { authToken, participantId } = await api.post(`/meetings/${meetingId}/join`);
Sentry.setTag("participant_id", participantId);
addStep("join_flow:token_received");
addStep("join_flow:sdk_init");
const meeting = await RTKClient.init({ authToken });
addStep("join_flow:connecting");
await meeting.join();
// Track room state transitions via roomJoined + roomLeft events
meeting.self.on("roomJoined", () => {
Sentry.addBreadcrumb({ category: "rtk", message: "Room joined", level: "info" });
});
meeting.self.on("roomLeft", ({ state }) => {
// state: left | kicked | ended | rejected | disconnected | failed
if (["disconnected", "kicked", "rejected", "failed"].includes(state)) {
Sentry.captureMessage(`Meeting ended unexpectedly: ${state}`, {
level: "warning",
tags: { component: "rtk-sdk", meeting_id: meetingId },
});
}
Sentry.addBreadcrumb({ category: "rtk", message: `Room left: ${state}`, level: "info" });
});
addStep("join_flow:joined");
} catch (err) {
// All breadcrumbs from prior steps are attached automatically
Sentry.captureException(err, {
tags: { component: "join_flow", meeting_id: meetingId },
});
throw err;
}
}Room lifecycle tracked via two events: roomJoined (successful entry) and roomLeft with state left | kicked | ended | rejected | disconnected | failed. The meeting.self.roomState property can also be polled: init → joined | waitlisted | rejected | kicked | left | ended. Abnormal exits (disconnected, kicked, rejected, failed) fire Sentry warning events.
React Error Boundaries
Wrap RTK UI Kit components in Sentry error boundaries to catch component crashes:
<Sentry.ErrorBoundary fallback={<MeetingErrorFallback />} showDialog>
<RtkMeeting meeting={meeting} />
</Sentry.ErrorBoundary>RTK Debugger Components (built-in quality UI)
RTK provides 6 debugger components that display real-time quality metrics. We include these in the meeting UI behind a toggle — no custom code needed:
| Component | Shows |
|---|---|
| `rtk-debugger` | Parent container for all debug panels |
| `rtk-debugger-audio` | Audio bitrate, packet loss, jitter, CPU limitations |
| `rtk-debugger-video` | Video bitrate, packet loss, jitter, bandwidth limitations |
| `rtk-debugger-screenshare` | Screenshare network & media stats |
| `rtk-debugger-system` | Battery level, battery charging status |
| `rtk-debugger-toggle` | Button to show/hide the debugger panel |
These components display quality ratings (Good / Average / Poor) with detailed stats. We render them as-is — RTK handles the data collection and display.
3.6 RTK Peer Report API (Post-Meeting Quality Investigation)
RTK captures detailed per-participant quality data server-side for every session. This is the primary tool for investigating "my meeting was laggy" complaints — no client-side instrumentation needed.
Endpoint: GET /accounts/{ACCOUNT_ID}/realtime/kit/{APP_ID}/sessions/peer-report/{PEER_ID}?filters=device_info,ip_information,precall_network_information,events,quality_stats
What it returns per participant:
| Category | Data | Investigation Use |
|---|---|---|
| `quality.audio_producer` | Timestamped: jitter, packet loss, RTT, MOS score, bytes sent | "Audio kept cutting out" → check packet loss spikes over time |
| `quality.video_producer` | Same metrics for video | "Video was frozen" → check if participant stopped producing or quality dropped |
| `quality.*_cumulative` | Aggregates: avg packet loss, p50/p75/p90 MOS, RTT distribution | Quick quality score for the session — was it good, mediocre, or bad? |
| `metadata.device_info` | OS, browser, browser version, CPU count, mobile flag, SDK version | "It only happens on Safari" → filter by browser across sessions |
| `metadata.ip_information` | City, country, region, ASN, timezone | "Our London office has problems" → check if issues correlate with location/ISP |
| `metadata.candidate_pairs` | ICE candidates (local/remote type, protocol, RTT), producing + consuming transports | "Behind corporate firewall" → see if TURN relay was used, check relay RTT |
| `metadata.pc_metadata` | `effective_network_type`, `reflexive_connectivity`, `relay_connectivity`, `turn_connectivity` | Pre-call network assessment — was the user's network even viable? |
| `metadata.events` | Timestamped peer events throughout session | Full timeline of what happened from RTK's perspective |
Usage model: On-demand, not automated. When a complaint arrives:
- Look up the meeting → get session ID from D1 or RTK sessions API
- Get participant list:
GET /sessions/{SESSION_ID}/participants - For the complaining user, call peer-report with all filters
- Compare their quality stats with other participants in the same session:
- If only they had bad MOS/high packet loss → their network
- If everyone had issues → RTK/regional problem
- If specific participants had issues → check their shared characteristics (location, ISP, browser)
"Always laggy" user: Pull peer reports across multiple sessions for the same custom_participant_id. If quality is consistently bad regardless of meeting → it's their network/device. The data answers the question definitively.
3.7 Diagnostic Data Flows
Flow 1: Worker Error (automatic)
Request arrives → Sentry withSentry() wrapper
→ Handler executes
→ On unhandled error:
├── Sentry captures automatically (stack trace, request context, breadcrumbs)
├── Workers Observability logs the error + full fetch trace
└── Worker returns error response
→ No custom error logging code neededFlow 2: Webhook Processing (custom — we track the flow)
POST /api/webhooks/rtk arrives
→ Validate webhook signature
├── Invalid: Sentry.captureMessage("Invalid webhook signature"), return 401
└── Valid: continue
→ Parse event type from payload
→ INSERT INTO diagnostic_webhook_deliveries (status: 'processing')
→ Route to event handler
→ On success:
├── UPDATE webhook delivery (status: 'processed', processing_duration_ms)
└── Return 200
→ On failure:
├── Sentry.captureException(err, { tags: { webhook_event_type, meeting_id } })
├── UPDATE webhook delivery (status: 'failed', error_message)
└── Return 500 (RTK will retry)Flow 3: Health Check (custom — periodic probes)
Scheduled CRON trigger (every 5 minutes) OR GET /api/diagnostics/health
→ Run parallel health probes:
├── D1: SELECT 1 → measure latency
├── KV: get test key → measure latency
├── R2: head test object → measure latency
└── RTK API: GET /meetings?limit=1 → measure latency + confirm auth
→ Compute status:
├── healthy: all probes pass
├── degraded: any probe > 2s
└── unhealthy: any probe fails
→ INSERT INTO diagnostic_health_snapshots
→ If phone_home_enabled AND (degraded OR unhealthy):
├── POST to vendor phone-home endpoint
└── Log delivery result
→ Return health JSONFlow 4: Client-Side Errors (Sentry SDK handles it)
RTK SDK error event fires in browser
→ Our event listener calls Sentry.captureException() with tags
→ Sentry SDK sends to vendor DSN (and customer DSN if configured)
→ No Worker round-trip needed — Sentry SDK sends directly to Sentry
React component crash in meeting UI
→ Sentry.ErrorBoundary catches it
→ Sentry SDK sends crash report with component tree
→ Fallback UI shown to user3.8 Phone-Home Diagnostics
Optional vendor telemetry. Enabled by default (pre-configured by deployment app). Org can disable from settings.
When enabled (org_settings.phone_home_enabled = 1):
→ On every health check (scheduled or manual):
POST {PHONE_HOME_URL}/api/telemetry/health
Body: {
orgId (SHA-256 hashed),
status, // healthy | degraded | unhealthy
activeMeetings,
activeParticipants,
d1Latency, kvLatency, r2Latency, rtkApiLatency,
workerVersion,
sdkVersion,
timestamp
}
→ On FATAL Sentry event (via beforeSend hook):
POST {PHONE_HOME_URL}/api/telemetry/alert
Body: {
orgId (SHA-256 hashed),
severity: 'fatal',
errorMessage (truncated, no PII),
component,
timestamp
}Privacy guarantees:
- Org ID is SHA-256 hashed before transmission
- No user data, meeting content, chat messages, participant names, emails, or IPs ever transmitted
- Only aggregate counts and system health metrics
- All phone-home calls logged in
diagnostic_health_snapshotsfor transparency - Can be disabled at any time; disabling immediately stops all outbound telemetry
3.9 Diagnostics Routes
| Method | Route | Purpose | Auth |
|---|---|---|---|
| GET | `/api/diagnostics/health` | System health check (probe results) | Owner/Admin |
| GET | `/api/diagnostics/webhooks` | Webhook delivery status/failures (paginated) | Owner/Admin |
| GET | `/api/diagnostics/webhooks/:id` | Single webhook delivery detail | Owner/Admin |
| GET | `/api/diagnostics/active-meetings` | Currently active meetings | Owner/Admin |
| GET | `/api/diagnostics/meetings/:meetingId/investigate` | Meeting investigation report (see below) | Owner/Admin |
| POST | `/api/diagnostics/phone-home` | Manual trigger for phone-home report | Owner only |
| PATCH | `/api/diagnostics/settings` | Update diagnostics settings (toggle vendor Sentry, phone-home, set customer DSN) | Owner only |
Removed routes (now handled by Sentry):
→ Use Sentry dashboardGET /api/diagnostics/errors→ Use Sentry dashboardGET /api/diagnostics/errors/:errorId→ Use Sentry dashboardGET /api/diagnostics/trends
Meeting Investigation Endpoint
GET /api/diagnostics/meetings/:meetingId/investigate — the "never need a screenshare" tool. Aggregates all diagnostic data for a specific meeting into one response.
What it calls (server-side, on demand):
GET /sessions?meeting_id={meetingId}→ session details (start/end time, status)GET /sessions/{sessionId}/participants→ participant list with join/leave timesGET /sessions/peer-report/{peerId}?filters=device_info,ip_information,quality_stats,events,precall_network_information→ per-participant quality (called for each participant)SELECT * FROM diagnostic_webhook_deliveries WHERE meeting_id = ?→ webhook processing history
Response structure:
{
"meeting_id": "...",
"session": { "id": "...", "started_at": "...", "ended_at": "...", "duration_seconds": 3600 },
"participants": [
{
"id": "...", "display_name": "...", "custom_participant_id": "...",
"joined_at": "...", "left_at": "...", "duration": 3540,
"device": { "os": "macOS", "browser": "Chrome 120", "is_mobile": false },
"network": { "city": "London", "country": "GB", "asn": "AS2856", "effective_type": "4g" },
"quality_summary": {
"audio_mos_avg": 4.2, "audio_packet_loss_avg": 0.3, "audio_rtt_avg": 45,
"video_mos_avg": 3.8, "video_packet_loss_avg": 1.2,
"ice_candidate_type": "relay", "turn_used": true
}
}
],
"webhook_deliveries": [ { "event_type": "recording.statusUpdate", "status": "processed", "...": "..." } ],
"sentry_link": "https://sentry.io/organizations/.../issues/?query=meeting_id:..."
}This single endpoint answers: who was there, when they joined/left, what device/browser/network they were on, what their quality was like, whether TURN was needed, and what webhooks fired. If quality was bad for one participant but fine for others — it's their network. If bad for everyone — it's RTK or regional.
3.10 Diagnostics Dashboard (Admin UI)
The SPA includes a /admin/diagnostics page (Owner/Admin role only):
| Panel | Data Source | Content |
|---|---|---|
| **System Health** | `diagnostic_health_snapshots` | Status badge (healthy/degraded/unhealthy), D1/KV/R2/RTK API latency gauges, last check time |
| **Webhook Deliveries** | `diagnostic_webhook_deliveries` | Recent deliveries with status badges, failure rate %, event types |
| **Active Meetings** | Worker API (KV + RTK) | Currently active meetings with participant count and duration |
| **Meeting Investigation** | `/api/diagnostics/meetings/:id/investigate` | Enter a meeting ID → see full investigation report: participants, quality per user, devices, network, webhook history, Sentry link |
| **Sentry Link** | External | "View Errors in Sentry" button linking to the Sentry project (filtered by deployment_id) |
| **Settings** | `org_settings` | Toggle vendor Sentry on/off, add/edit customer Sentry DSN, toggle phone-home |
3.11 Data Retention & Cleanup
Scheduled cleanup via Worker CRON:
| Table | Retention | Schedule |
|---|---|---|
| `diagnostic_webhook_deliveries` | 30 days | Daily at 03:00 UTC |
| `diagnostic_health_snapshots` | 90 days | Weekly |
Sentry retention is managed by the Sentry plan (90 days on Team plan). Workers Observability logs/traces retain for 7 days (Cloudflare managed). No cleanup code needed for either.
CRON trigger in wrangler config:
[triggers]
crons = ["0 3 * * *"] # Daily at 03:00 UTC3.12 Wrangler Configuration for Observability
# Observability — logs + traces, zero code
[observability]
enabled = true
[observability.logs]
head_sampling_rate = 1
invocation_logs = true
[observability.traces]
enabled = true
head_sampling_rate = 0.01Required secrets (set via wrangler secret put):
SENTRY_DSN_VENDOR # Vendor's Sentry DSN (set by deployment app)
SENTRY_DSN_CUSTOMER # Customer's Sentry DSN (optional, set via org settings)
SENTRY_RELEASE # Git SHA (set at deploy time)
PHONE_HOME_URL # Vendor telemetry endpoint (set by deployment app)3.13 Alerting (Launch Minimum)
"Know about issues before customers call" requires minimum alerting at launch. We use Sentry's built-in alert rules (zero code, configured in Sentry dashboard) plus phone-home:
Launch alerts (Sentry dashboard config, no custom code):
| Alert | Condition | Action |
|---|---|---|
| Error spike | >10 errors in 5 minutes | Sentry notification (email/Slack/PagerDuty via Sentry integrations) |
| Fatal error | Any `level: fatal` event | Immediate Sentry notification + phone-home alert |
| Recording failure | Events tagged `component: rtk-sdk` + `error_type: recording` | Sentry notification |
| Media connection failure | Events tagged `error_type: media_connection` | Sentry notification |
| New issue | First occurrence of a new error type | Sentry notification |
Launch alerts (phone-home, built into health check CRON):
- Health degraded/unhealthy → phone-home alert (already in Section 3.8)
- Webhook failure rate >20% in last 15 minutes → phone-home alert with severity
warning(querydiagnostic_webhook_deliveriesin health check CRON)
FUTURE: Org-facing alerting (email, webhook to Slack/Teams/PagerDuty) for health status changes, webhook failure spikes, and meeting quality degradation. Will be built as a separate notification system when the core platform is stable.
Sections 4+ (Frontend Architecture, Feature Implementation, Deployment, Testing) to be written by other team members.
Sections 4-6: User System, Role-to-Preset Mapping, Meeting Lifecycle
Section 4: User System & Authentication
4.1 User Model
The platform defines five roles in a strict hierarchy:
| Role | Level | Description |
|---|---|---|
| **Owner** | 5 | Organization creator. One per org. Cannot be demoted. Full control over billing, settings, and all users. |
| **Admin** | 4 | Organization-level administrator. Manages users, settings, presets. Cannot delete org or transfer ownership. |
| **Host** | 3 | Can create and manage meetings, admit waiting room participants, control stage, kick users. Default role for new members with meeting creation privileges. |
| **Member** | 2 | Standard participant. Can join meetings they're invited to or that are open. Cannot create meetings unless promoted. |
| **Guest** | 1 | Unauthenticated or externally-authenticated user. Joins via meeting link. Controlled by guest access policies. No org-level visibility. |
4.2 D1 Schema: Users & Organizations
-- Organizations table
CREATE TABLE organizations (
id TEXT PRIMARY KEY, -- nanoid
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE, -- URL-safe org identifier (e.g., "acme-corp")
rtk_app_id TEXT NOT NULL, -- RealtimeKit App ID for this org
owner_id TEXT NOT NULL, -- references users.id
settings_json TEXT DEFAULT '{}', -- org-wide settings (JSON blob)
guest_access_default TEXT NOT NULL DEFAULT 'link_only', -- 'disabled' | 'link_only' | 'open'
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Circular FK: organizations.owner_id → users.id, users.org_id → organizations.id
-- **Insert order**: Registration uses `PRAGMA defer_foreign_keys = ON` within a D1 `batch()` call.
-- Insert organization first (with placeholder owner_id), then user, then UPDATE organization.owner_id.
-- D1 batch() is transactional — if any statement fails, the entire batch rolls back.
-- Users table
CREATE TABLE users (
id TEXT PRIMARY KEY, -- nanoid
-- > **ID and token generation**:
-- > - Entity IDs: `nanoid(21)` with default alphabet (A-Za-z0-9_-). Package: `nanoid` (works in Workers).
-- > - Refresh tokens: 32 random bytes via `crypto.getRandomValues(new Uint8Array(32))`, encoded as base64url. Stored as SHA-256 hash in DB.
-- > - Guest tokens: Same generation as refresh tokens.
-- > - JWT secret: Set via `wrangler secret put JWT_SECRET`. Generate with `openssl rand -base64 32` (minimum 256 bits).
org_id TEXT NOT NULL REFERENCES organizations(id),
email TEXT NOT NULL,
name TEXT NOT NULL,
password_hash TEXT, -- null for SSO/guest users
role TEXT NOT NULL DEFAULT 'member', -- 'owner' | 'admin' | 'host' | 'member'
avatar_url TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
last_login_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(org_id, email)
);
-- Guest access tokens (for unauthenticated meeting access)
CREATE TABLE guest_tokens (
id TEXT PRIMARY KEY, -- nanoid
meeting_id TEXT NOT NULL REFERENCES meetings(id),
display_name TEXT NOT NULL,
email TEXT, -- optional, for follow-up
token_hash TEXT NOT NULL UNIQUE, -- hashed access token
rtk_participant_id TEXT, -- RTK participant ID once joined
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_users_org ON users(org_id);
-- Note: No separate index on (org_id, email) — the UNIQUE(org_id, email) constraint auto-creates one in SQLite/D1.
CREATE INDEX idx_guest_tokens_meeting ON guest_tokens(meeting_id);
CREATE INDEX idx_guest_tokens_hash ON guest_tokens(token_hash);4.3 Authentication Flow
This is a self-hosted Worker app — no third-party auth provider required. JWT-based auth with refresh tokens.
Registration / Org Creation Flow
1. POST /api/auth/register
Body: { email, password, name, orgName }
> The org is implicit per deployment — each deployed instance IS one organization.
> orgSlug is auto-generated from orgName (slugified, URL-safe). No user input needed.
Step 1: Create RTK app via REST API (POST /accounts/{ACCOUNT_ID}/realtime/kit/apps)
Step 2: D1 batch() with PRAGMA defer_foreign_keys = ON:
- INSERT organization (with rtk_app_id from step 1)
- INSERT user with role='owner'
- UPDATE organization SET owner_id = user.id
Step 3: Create default presets in RTK (12 presets via POST /presets — see Section 5)
On Step 2 failure: DELETE the RTK app (compensating action)
On Step 3 failure: Non-fatal — presets can be created lazily on first meeting creation
Return: { accessToken, refreshToken, user, org }Login Flow
1. POST /api/auth/login
Body: { email, password }
→ Lookup user by email (only one org per deployment — no org selection needed)
→ Verify password_hash (scrypt)
→ If password_hash is NULL (SSO-provisioned user), reject with 401: 'Password login not available for this account'
→ Return { accessToken, refreshToken, user }
2. Access token: JWT, 15-minute expiry
Payload: { sub: user.id, org: org.id, role: user.role, iat, exp }
Signed with: HS256 using Worker secret (JWT_SECRET env var)
JWT implementation uses the `jose` library (panva/jose) — zero dependencies, Web Crypto API native, works in Cloudflare Workers without polyfills.
3. Refresh token: opaque token, 30-day expiry, stored hashed in D1
POST /api/auth/refresh → new access token + rotated refresh tokenPassword hashing: Uses scrypt via Node.js
crypto.scryptSync()(requiresnodejs_compatflag in wrangler.toml). Parameters: N=16384, r=8, p=1, keyLen=32. Output stored as${salt}:${hash}where salt is 16 random bytes (hex). argon2id is not used because Workers have a 128MB memory limit and argon2id is memory-hard by design.
Guest Join Flow
Guests use the same endpoint as authenticated users: POST /api/meetings/{meetingId}/join.
The join handler detects guest vs authenticated by checking the Authorization header:
- Present + valid JWT → authenticated user flow (resolve role from DB, select preset by role)
- Absent → guest flow (check for meeting link token, require
displayNamein body)
1. User visits meeting link: /m/{meetingSlug}
2. If not authenticated → show guest join form (display name, optional email)
3. POST /api/meetings/{meetingId}/join
Headers: (no Authorization header)
Body: { displayName, email?, meetingToken }
→ Check meeting exists and is ACTIVE
→ Check org guest_access_global (master switch) and meeting-level guest_access override
→ If guest access disabled → 403
→ Validate meetingToken (from the meeting link)
→ Create guest_tokens row
→ Create RTK participant via REST API (preset = guest preset for this meeting type)
→ Return { guestToken, rtk_authToken, meetingConfig }
4. Client SDK initializes with rtk_authToken
5. If waiting room enabled → guest enters waiting room; host admits/rejects
Guest Auth Context:
The join endpoint skips JWT middleware when no Authorization header is present.
The Worker creates a GuestAuthContext: { guestTokenId, orgId, role: 'guest' }.
This is passed to resolvePreset('guest', meetingType, false) to select the
correct preset. Downstream code (e.g., participant_events) uses guest_token_id
instead of user_id.JWT Middleware (Worker)
// Every authenticated route runs through this middleware
async function authMiddleware(request: Request, env: Env): Promise<AuthContext> {
const token = request.headers.get('Authorization')?.replace('Bearer ', '');
if (!token) throw new HTTPError(401, 'Missing token');
// jose library — Web Crypto native, works in Workers
// import { jwtVerify, SignJWT } from 'jose';
// async function verifyJWT(token: string, secret: string): Promise<JWTPayload> {
// const key = new TextEncoder().encode(secret);
// const { payload } = await jwtVerify(token, key);
// return payload;
// }
const payload = await verifyJWT(token, env.JWT_SECRET);
const user = await env.DB.prepare('SELECT id, org_id, role, is_active FROM users WHERE id = ?')
.bind(payload.sub).first();
if (!user || !user.is_active) throw new HTTPError(401, 'Invalid user');
return { userId: user.id, orgId: user.org_id, role: user.role };
}4.4 Refresh Token Storage
CREATE TABLE refresh_tokens (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_refresh_tokens_user ON refresh_tokens(user_id);
CREATE INDEX idx_refresh_tokens_hash ON refresh_tokens(token_hash);Expired guest token cleanup: A Cron Trigger runs hourly:
DELETE FROM guest_tokens WHERE expires_at < datetime('now').
Token rotation: each refresh issues a new token and invalidates the old one. If a previously-rotated token is reused (replay detection), all tokens for that user are revoked.
4.5 Role-Based Access Control (RBAC)
Authorization checks happen at the Worker API layer, not in RTK:
| Action | Owner | Admin | Host | Member | Guest |
|---|---|---|---|---|---|
| Manage org settings | Yes | Yes | - | - | - |
| Manage users (invite, promote, deactivate) | Yes | Yes | - | - | - |
| Create meetings | Yes | Yes | Yes | - | - |
| Edit/delete any meeting | Yes | Yes | Own only | - | - |
| Start/stop recording | Yes | Yes | Yes (own) | - | - |
| Join meetings | Yes | Yes | Yes | Invited/Open | Via guest token if guest access enabled |
| Configure presets | Yes | Yes | - | - | - |
| View analytics | Yes | Yes | Yes (own) | - | - |
| Start/stop livestream | Yes | Yes | Yes (own) | - | - |
| Manage polls | Yes | Yes | Yes (in-meeting) | Via preset | Via preset |
| Transfer ownership | Yes | - | - | - | - |
| Promote/demote users | Yes | Yes (not to Owner) | - | - | - |
| Delete organization | Yes | - | - | - | - |
A meeting is "own" if
meetings.created_by = user.id. RBAC is enforced at the Worker API layer. RTK presets enforce in-meeting media/feature permissions. Both layers must agree — if a preset allows an action but RBAC denies it, the Worker rejects the request.
See also: Section 5 (Role-to-Preset Mapping) for how platform roles map to in-meeting RTK presets. Section 12.3 for
org_settingsschema. Logout, password reset, and forgot-password endpoints documented in Section 10 (App Pages & Navigation).
Section 5: Role-to-Preset Mapping
5.1 Core Principle
RTK presets are the enforcement layer for in-meeting permissions. Our platform roles (Owner, Admin, Host, Member, Guest) map to RTK presets at meeting join time. The Worker selects the correct preset when calling the RTK "Add Participant" API.
Platform Role + Meeting Type → RTK Preset Name → Participant Created with That Preset5.2 Preset Naming Convention
Format: {meeting_type}_{participant_role}
| Meeting Type | Host Preset | Participant Preset | Guest Preset | Viewer Preset |
|---|---|---|---|---|
| **Video** (group call) | `video_host` | `video_participant` | `video_guest` | — |
| **Audio-Only** | `audio_host` | `audio_participant` | `audio_guest` | — |
| **Webinar** | `webinar_host` | `webinar_participant` | — | `webinar_viewer` |
| **Livestream** | `livestream_host` | `livestream_participant` | — | `livestream_viewer` |
12 presets total: video (3), audio (3), webinar (3: host, participant, viewer), livestream (3: host, participant, viewer).
- Owner and Admin always get the
*_hostpreset (maximum in-meeting control) - Host gets the
*_hostpreset for meetings they created;*_participantfor others (configurable) - Member gets the
*_participantpreset - Guest gets the
*_guestpreset (most restrictive with media access) or*_viewer(view-only)
5.3 Preset Resolution Logic (Worker)
function resolvePreset(
userRole: 'owner' | 'admin' | 'host' | 'member' | 'guest',
meetingType: 'video' | 'audio' | 'webinar' | 'livestream',
isCreator: boolean
): string {
// Owner and Admin always get host preset
if (userRole === 'owner' || userRole === 'admin') {
return `${meetingType}_host`;
}
// Host gets host preset for own meetings, participant for others
if (userRole === 'host') {
return isCreator ? `${meetingType}_host` : `${meetingType}_participant`;
}
// Guest gets guest or viewer preset
if (userRole === 'guest') {
if (meetingType === 'webinar' || meetingType === 'livestream') {
return `${meetingType}_viewer`;
}
return `${meetingType}_guest`;
}
// Member gets participant preset
return `${meetingType}_participant`;
}5.4 Preset Configurations by Meeting Type
Each preset is created via the RTK REST API (POST /accounts/{ACCOUNT_ID}/realtime/kit/{APP_ID}/presets) when the org is provisioned. Below are the exact configurations using the verified RTK OpenAPI schema.
Schema note: The preset API uses snake_case throughout. Top-level required fields:
name,config,ui. Thepermissionsobject is optional but all its sub-fields are required when provided. Media permissions use"ALLOWED"/"NOT_ALLOWED"/"CAN_REQUEST"enums (not booleans). Theconfig.view_typehas three valid values:GROUP_CALL,WEBINAR,AUDIO_ROOM. Livestream presets useWEBINARview_type withcan_livestream: true.
waiting_room_typeenum:SKIP(bypass waiting room),ON_PRIVILEGED_USER_ENTRY(wait until a host-preset user admits),SKIP_ON_ACCEPT(wait first time, bypass after being admitted once). All host presets useON_PRIVILEGED_USER_ENTRY. Viewer presets useSKIP.
5.4.1 Video (Group Call) Presets
video_host — Full meeting control
{
"name": "video_host",
"config": {
"view_type": "GROUP_CALL",
"max_video_streams": { "desktop": 25, "mobile": 6 },
"max_screenshare_count": 1,
"media": {
"video": { "quality": "hd", "frame_rate": 30 },
"screenshare": { "quality": "hd", "frame_rate": 15 },
"audio": { "enable_high_bitrate": false, "enable_stereo": false }
}
},
"permissions": {
"media": {
"audio": { "can_produce": "ALLOWED" },
"video": { "can_produce": "ALLOWED" },
"screenshare": { "can_produce": "ALLOWED" }
},
"kick_participant": true,
"pin_participant": true,
"can_spotlight": true,
"disable_participant_audio": true,
"disable_participant_video": true,
"disable_participant_screensharing": true,
"accept_waiting_requests": true,
"can_accept_production_requests": true,
"can_change_participant_permissions": true,
"can_record": true,
"can_livestream": false,
"can_edit_display_name": true,
"show_participant_list": true,
"hidden_participant": false,
"waiting_room_type": "ON_PRIVILEGED_USER_ENTRY",
"recorder_type": "NONE",
"is_recorder": false,
"chat": {
"public": { "can_send": true, "text": true, "files": true },
"private": { "can_send": true, "can_receive": true, "text": true, "files": true }
},
"polls": { "can_create": true, "can_vote": true, "can_view": true },
"plugins": { "can_start": true, "can_close": true, "can_edit_config": true, "config": { "access_control": "FULL_ACCESS", "handles_view_only": false } },
"connected_meetings": { "can_alter_connected_meetings": true, "can_switch_connected_meetings": true, "can_switch_to_parent_meeting": true }
},
"ui": {
"design_tokens": {
"theme": "dark",
"border_radius": "rounded",
"border_width": "thin",
"spacing_base": 4,
"logo": "",
"colors": {
"brand": { "300": "#844d1c", "400": "#9d5b22", "500": "#b56927", "600": "#d37c30", "700": "#d9904f" },
"background": { "600": "#222222", "700": "#1f1f1f", "800": "#1b1b1b", "900": "#181818", "1000": "#141414" },
"text": "#EEEEEE", "text_on_brand": "#EEEEEE",
"danger": "#FF2D2D", "success": "#62A504", "warning": "#FFCD07", "video_bg": "#191919"
}
}
}
}video_participant — Standard participant (abbreviated: only differences from host shown)
{
"name": "video_participant",
"config": { "view_type": "GROUP_CALL", "...": "same as video_host" },
"permissions": {
"media": {
"audio": { "can_produce": "ALLOWED" },
"video": { "can_produce": "ALLOWED" },
"screenshare": { "can_produce": "ALLOWED" }
},
"kick_participant": false,
"pin_participant": true,
"can_spotlight": false,
"disable_participant_audio": false,
"disable_participant_video": false,
"disable_participant_screensharing": false,
"accept_waiting_requests": false,
"can_accept_production_requests": false,
"can_change_participant_permissions": false,
"can_record": false,
"can_livestream": false,
"can_edit_display_name": false,
"show_participant_list": true,
"hidden_participant": false,
"waiting_room_type": "ON_PRIVILEGED_USER_ENTRY",
"recorder_type": "NONE",
"is_recorder": false,
"chat": {
"public": { "can_send": true, "text": true, "files": true },
"private": { "can_send": true, "can_receive": true, "text": true, "files": true }
},
"polls": { "can_create": false, "can_vote": true, "can_view": true },
"plugins": { "can_start": false, "can_close": false, "can_edit_config": false, "config": { "access_control": "VIEW_ONLY", "handles_view_only": true } },
"connected_meetings": { "can_alter_connected_meetings": false, "can_switch_connected_meetings": true, "can_switch_to_parent_meeting": true }
},
"ui": { "design_tokens": "...same as video_host..." }
}video_guest — Restricted participant (no screenshare, no private chat)
{
"name": "video_guest",
"config": { "view_type": "GROUP_CALL", "...": "same as video_host" },
"permissions": {
"media": {
"audio": { "can_produce": "ALLOWED" },
"video": { "can_produce": "ALLOWED" },
"screenshare": { "can_produce": "NOT_ALLOWED" }
},
"kick_participant": false,
"pin_participant": false,
"can_spotlight": false,
"disable_participant_audio": false,
"disable_participant_video": false,
"disable_participant_screensharing": false,
"accept_waiting_requests": false,
"can_accept_production_requests": false,
"can_change_participant_permissions": false,
"can_record": false,
"can_livestream": false,
"can_edit_display_name": false,
"show_participant_list": true,
"hidden_participant": false,
"waiting_room_type": "ON_PRIVILEGED_USER_ENTRY",
"recorder_type": "NONE",
"is_recorder": false,
"chat": {
"public": { "can_send": true, "text": true, "files": false },
"private": { "can_send": false, "can_receive": true, "text": false, "files": false }
},
"polls": { "can_create": false, "can_vote": true, "can_view": true },
"plugins": { "can_start": false, "can_close": false, "can_edit_config": false, "config": { "access_control": "VIEW_ONLY", "handles_view_only": true } },
"connected_meetings": { "can_alter_connected_meetings": false, "can_switch_connected_meetings": false, "can_switch_to_parent_meeting": false }
},
"ui": { "design_tokens": "...same as video_host..." }
}5.4.2 Audio-Only Presets
Audio presets use view_type: "AUDIO_ROOM" with video production disabled. The config.media.audio block enables high-bitrate audio settings.
Screenshare is ALLOWED for audio_host intentionally — enables document/slide sharing in audio-only meetings without enabling camera video.
audio_host — Full meeting control (audio-only)
{
"name": "audio_host",
"config": {
"view_type": "AUDIO_ROOM",
"max_video_streams": { "desktop": 25, "mobile": 6 },
"max_screenshare_count": 1,
"media": {
"video": { "quality": "hd", "frame_rate": 30 },
"screenshare": { "quality": "hd", "frame_rate": 15 },
"audio": { "enable_high_bitrate": true, "enable_stereo": false }
}
},
"permissions": {
"media": {
"audio": { "can_produce": "ALLOWED" },
"video": { "can_produce": "NOT_ALLOWED" },
"screenshare": { "can_produce": "ALLOWED" }
},
"kick_participant": true,
"pin_participant": true,
"can_spotlight": true,
"disable_participant_audio": true,
"disable_participant_video": true,
"disable_participant_screensharing": true,
"accept_waiting_requests": true,
"can_accept_production_requests": true,
"can_change_participant_permissions": true,
"can_record": true,
"can_livestream": false,
"can_edit_display_name": true,
"show_participant_list": true,
"hidden_participant": false,
"waiting_room_type": "ON_PRIVILEGED_USER_ENTRY",
"recorder_type": "NONE",
"is_recorder": false,
"chat": {
"public": { "can_send": true, "text": true, "files": true },
"private": { "can_send": true, "can_receive": true, "text": true, "files": true }
},
"polls": { "can_create": true, "can_vote": true, "can_view": true },
"plugins": { "can_start": true, "can_close": true, "can_edit_config": true, "config": { "access_control": "FULL_ACCESS", "handles_view_only": false } },
"connected_meetings": { "can_alter_connected_meetings": true, "can_switch_connected_meetings": true, "can_switch_to_parent_meeting": true }
},
"ui": { "design_tokens": "...same as video_host..." }
}audio_participant — Standard participant (audio-only)
{
"name": "audio_participant",
"config": {
"view_type": "AUDIO_ROOM",
"max_video_streams": { "desktop": 25, "mobile": 6 },
"max_screenshare_count": 1,
"media": {
"video": { "quality": "hd", "frame_rate": 30 },
"screenshare": { "quality": "hd", "frame_rate": 15 },
"audio": { "enable_high_bitrate": true, "enable_stereo": false }
}
},
"permissions": {
"media": {
"audio": { "can_produce": "ALLOWED" },
"video": { "can_produce": "NOT_ALLOWED" },
"screenshare": { "can_produce": "NOT_ALLOWED" }
},
"kick_participant": false,
"pin_participant": true,
"can_spotlight": false,
"disable_participant_audio": false,
"disable_participant_video": false,
"disable_participant_screensharing": false,
"accept_waiting_requests": false,
"can_accept_production_requests": false,
"can_change_participant_permissions": false,
"can_record": false,
"can_livestream": false,
"can_edit_display_name": false,
"show_participant_list": true,
"hidden_participant": false,
"waiting_room_type": "ON_PRIVILEGED_USER_ENTRY",
"recorder_type": "NONE",
"is_recorder": false,
"chat": {
"public": { "can_send": true, "text": true, "files": true },
"private": { "can_send": true, "can_receive": true, "text": true, "files": true }
},
"polls": { "can_create": false, "can_vote": true, "can_view": true },
"plugins": { "can_start": false, "can_close": false, "can_edit_config": false, "config": { "access_control": "VIEW_ONLY", "handles_view_only": true } },
"connected_meetings": { "can_alter_connected_meetings": false, "can_switch_connected_meetings": true, "can_switch_to_parent_meeting": true }
},
"ui": { "design_tokens": "...same as video_host..." }
}audio_guest — Restricted participant (audio-only, no screenshare, no private chat)
{
"name": "audio_guest",
"config": {
"view_type": "AUDIO_ROOM",
"max_video_streams": { "desktop": 25, "mobile": 6 },
"max_screenshare_count": 1,
"media": {
"video": { "quality": "hd", "frame_rate": 30 },
"screenshare": { "quality": "hd", "frame_rate": 15 },
"audio": { "enable_high_bitrate": true, "enable_stereo": false }
}
},
"permissions": {
"media": {
"audio": { "can_produce": "ALLOWED" },
"video": { "can_produce": "NOT_ALLOWED" },
"screenshare": { "can_produce": "NOT_ALLOWED" }
},
"kick_participant": false,
"pin_participant": false,
"can_spotlight": false,
"disable_participant_audio": false,
"disable_participant_video": false,
"disable_participant_screensharing": false,
"accept_waiting_requests": false,
"can_accept_production_requests": false,
"can_change_participant_permissions": false,
"can_record": false,
"can_livestream": false,
"can_edit_display_name": false,
"show_participant_list": true,
"hidden_participant": false,
"waiting_room_type": "ON_PRIVILEGED_USER_ENTRY",
"recorder_type": "NONE",
"is_recorder": false,
"chat": {
"public": { "can_send": true, "text": true, "files": false },
"private": { "can_send": false, "can_receive": true, "text": false, "files": false }
},
"polls": { "can_create": false, "can_vote": true, "can_view": true },
"plugins": { "can_start": false, "can_close": false, "can_edit_config": false, "config": { "access_control": "VIEW_ONLY", "handles_view_only": true } },
"connected_meetings": { "can_alter_connected_meetings": false, "can_switch_connected_meetings": false, "can_switch_to_parent_meeting": false }
},
"ui": { "design_tokens": "...same as video_host..." }
}5.4.3 Webinar Presets
Webinars use view_type: "WEBINAR" with stage management. Only hosts and invited speakers are on stage; everyone else is a viewer. Stage management is handled by can_accept_production_requests (host accepts stage requests) and media CAN_REQUEST (participant requests to go on stage).
webinar_host — On stage, full control
{
"name": "webinar_host",
"config": {
"view_type": "WEBINAR",
"max_video_streams": { "desktop": 25, "mobile": 6 },
"max_screenshare_count": 1,
"media": {
"video": { "quality": "hd", "frame_rate": 30 },
"screenshare": { "quality": "hd", "frame_rate": 15 }
}
},
"permissions": {
"media": {
"audio": { "can_produce": "ALLOWED" },
"video": { "can_produce": "ALLOWED" },
"screenshare": { "can_produce": "ALLOWED" }
},
"kick_participant": true,
"pin_participant": true,
"can_spotlight": true,
"disable_participant_audio": true,
"disable_participant_video": true,
"disable_participant_screensharing": true,
"accept_waiting_requests": true,
"can_accept_production_requests": true,
"can_change_participant_permissions": true,
"can_record": true,
"can_livestream": true,
"can_edit_display_name": true,
"show_participant_list": true,
"hidden_participant": false,
"waiting_room_type": "ON_PRIVILEGED_USER_ENTRY",
"recorder_type": "NONE",
"is_recorder": false,
"chat": {
"public": { "can_send": true, "text": true, "files": true },
"private": { "can_send": true, "can_receive": true, "text": true, "files": true }
},
"polls": { "can_create": true, "can_vote": true, "can_view": true },
"plugins": { "can_start": true, "can_close": true, "can_edit_config": true, "config": { "access_control": "FULL_ACCESS", "handles_view_only": false } },
"connected_meetings": { "can_alter_connected_meetings": true, "can_switch_connected_meetings": true, "can_switch_to_parent_meeting": true }
},
"ui": { "design_tokens": "...same as video_host..." }
}webinar_participant — Can request stage access
{
"name": "webinar_participant",
"config": { "view_type": "WEBINAR", "...": "same as webinar_host" },
"permissions": {
"media": {
"audio": { "can_produce": "CAN_REQUEST" },
"video": { "can_produce": "CAN_REQUEST" },
"screenshare": { "can_produce": "NOT_ALLOWED" }
},
"kick_participant": false,
"accept_waiting_requests": false,
"can_accept_production_requests": false,
"can_record": false,
"can_livestream": false,
"show_participant_list": true,
"waiting_room_type": "ON_PRIVILEGED_USER_ENTRY",
"chat": {
"public": { "can_send": true, "text": true, "files": false },
"private": { "can_send": false, "can_receive": true, "text": false, "files": false }
},
"polls": { "can_create": false, "can_vote": true, "can_view": true },
"pin_participant": true,
"can_spotlight": false,
"disable_participant_audio": false,
"disable_participant_video": false,
"disable_participant_screensharing": false,
"can_change_participant_permissions": false,
"can_edit_display_name": false,
"show_participant_list": true,
"hidden_participant": false,
"recorder_type": "NONE",
"is_recorder": false,
"plugins": { "can_start": false, "can_close": false, "can_edit_config": false, "config": { "access_control": "VIEW_ONLY", "handles_view_only": true } },
"connected_meetings": { "can_alter_connected_meetings": false, "can_switch_connected_meetings": true, "can_switch_to_parent_meeting": true }
},
"ui": { "design_tokens": "...same as video_host..." }
}webinar_viewer — View-only (guests and livestream viewers)
{
"name": "webinar_viewer",
"config": { "view_type": "WEBINAR", "...": "same as webinar_host" },
"permissions": {
"media": {
"audio": { "can_produce": "NOT_ALLOWED" },
"video": { "can_produce": "NOT_ALLOWED" },
"screenshare": { "can_produce": "NOT_ALLOWED" }
},
"kick_participant": false,
"accept_waiting_requests": false,
"can_accept_production_requests": false,
"can_record": false,
"can_livestream": false,
"show_participant_list": false,
"hidden_participant": false,
"waiting_room_type": "SKIP",
"chat": {
"public": { "can_send": false, "text": false, "files": false },
"private": { "can_send": false, "can_receive": false, "text": false, "files": false }
},
"polls": { "can_create": false, "can_vote": true, "can_view": true },
"pin_participant": false,
"can_spotlight": false,
"disable_participant_audio": false,
"disable_participant_video": false,
"disable_participant_screensharing": false,
"can_change_participant_permissions": false,
"can_edit_display_name": false,
"hidden_participant": false,
"recorder_type": "NONE",
"is_recorder": false,
"plugins": { "can_start": false, "can_close": false, "can_edit_config": false, "config": { "access_control": "VIEW_ONLY", "handles_view_only": true } },
"connected_meetings": { "can_alter_connected_meetings": false, "can_switch_connected_meetings": false, "can_switch_to_parent_meeting": false }
},
"ui": { "design_tokens": "...same as video_host..." }
}5.4.4 Livestream Presets
Livestreaming uses view_type: "WEBINAR" (there is no LIVESTREAM view_type in the API) with can_livestream: true for hosts. RTMP/HLS export is controlled via the separate livestreaming API, not the preset itself.
livestream_host — On stage, full control, can start RTMP/HLS livestream
{
"name": "livestream_host",
"config": {
"view_type": "WEBINAR",
"max_video_streams": { "desktop": 25, "mobile": 6 },
"max_screenshare_count": 1,
"media": {
"video": { "quality": "hd", "frame_rate": 30 },
"screenshare": { "quality": "hd", "frame_rate": 15 }
}
},
"permissions": {
"media": {
"audio": { "can_produce": "ALLOWED" },
"video": { "can_produce": "ALLOWED" },
"screenshare": { "can_produce": "ALLOWED" }
},
"kick_participant": true,
"pin_participant": true,
"can_spotlight": true,
"disable_participant_audio": true,
"disable_participant_video": true,
"disable_participant_screensharing": true,
"accept_waiting_requests": true,
"can_accept_production_requests": true,
"can_change_participant_permissions": true,
"can_record": true,
"can_livestream": true,
"can_edit_display_name": true,
"show_participant_list": true,
"hidden_participant": false,
"waiting_room_type": "ON_PRIVILEGED_USER_ENTRY",
"recorder_type": "NONE",
"is_recorder": false,
"chat": {
"public": { "can_send": true, "text": true, "files": true },
"private": { "can_send": true, "can_receive": true, "text": true, "files": true }
},
"polls": { "can_create": true, "can_vote": true, "can_view": true },
"plugins": { "can_start": true, "can_close": true, "can_edit_config": true, "config": { "access_control": "FULL_ACCESS", "handles_view_only": false } },
"connected_meetings": { "can_alter_connected_meetings": true, "can_switch_connected_meetings": true, "can_switch_to_parent_meeting": true }
},
"ui": { "design_tokens": "...same as video_host..." }
}livestream_participant — Can request stage access, cannot livestream
{
"name": "livestream_participant",
"config": {
"view_type": "WEBINAR",
"max_video_streams": { "desktop": 25, "mobile": 6 },
"max_screenshare_count": 1,
"media": {
"video": { "quality": "hd", "frame_rate": 30 },
"screenshare": { "quality": "hd", "frame_rate": 15 }
}
},
"permissions": {
"media": {
"audio": { "can_produce": "CAN_REQUEST" },
"video": { "can_produce": "CAN_REQUEST" },
"screenshare": { "can_produce": "NOT_ALLOWED" }
},
"kick_participant": false,
"pin_participant": true,
"can_spotlight": false,
"disable_participant_audio": false,
"disable_participant_video": false,
"disable_participant_screensharing": false,
"accept_waiting_requests": false,
"can_accept_production_requests": false,
"can_change_participant_permissions": false,
"can_record": false,
"can_livestream": false,
"can_edit_display_name": false,
"show_participant_list": true,
"hidden_participant": false,
"waiting_room_type": "ON_PRIVILEGED_USER_ENTRY",
"recorder_type": "NONE",
"is_recorder": false,
"chat": {
"public": { "can_send": true, "text": true, "files": false },
"private": { "can_send": false, "can_receive": true, "text": false, "files": false }
},
"polls": { "can_create": false, "can_vote": true, "can_view": true },
"plugins": { "can_start": false, "can_close": false, "can_edit_config": false, "config": { "access_control": "VIEW_ONLY", "handles_view_only": true } },
"connected_meetings": { "can_alter_connected_meetings": false, "can_switch_connected_meetings": true, "can_switch_to_parent_meeting": true }
},
"ui": { "design_tokens": "...same as video_host..." }
}livestream_viewer — View-only, no media, no chat send
{
"name": "livestream_viewer",
"config": {
"view_type": "WEBINAR",
"max_video_streams": { "desktop": 25, "mobile": 6 },
"max_screenshare_count": 1,
"media": {
"video": { "quality": "hd", "frame_rate": 30 },
"screenshare": { "quality": "hd", "frame_rate": 15 }
}
},
"permissions": {
"media": {
"audio": { "can_produce": "NOT_ALLOWED" },
"video": { "can_produce": "NOT_ALLOWED" },
"screenshare": { "can_produce": "NOT_ALLOWED" }
},
"kick_participant": false,
"pin_participant": false,
"can_spotlight": false,
"disable_participant_audio": false,
"disable_participant_video": false,
"disable_participant_screensharing": false,
"accept_waiting_requests": false,
"can_accept_production_requests": false,
"can_change_participant_permissions": false,
"can_record": false,
"can_livestream": false,
"can_edit_display_name": false,
"show_participant_list": false,
"hidden_participant": false,
"waiting_room_type": "SKIP",
"recorder_type": "NONE",
"is_recorder": false,
"chat": {
"public": { "can_send": false, "text": false, "files": false },
"private": { "can_send": false, "can_receive": false, "text": false, "files": false }
},
"polls": { "can_create": false, "can_vote": true, "can_view": true },
"plugins": { "can_start": false, "can_close": false, "can_edit_config": false, "config": { "access_control": "VIEW_ONLY", "handles_view_only": true } },
"connected_meetings": { "can_alter_connected_meetings": false, "can_switch_connected_meetings": false, "can_switch_to_parent_meeting": false }
},
"ui": { "design_tokens": "...same as video_host..." }
}5.5 Dynamic Preset Override
Hosts can temporarily promote/demote participants during a meeting. This is handled via the RTK REST API:
PATCH /accounts/{ACCOUNT_ID}/realtime/kit/{APP_ID}/meetings/{MEETING_ID}/participants/{PARTICIPANT_ID}
Body: { "preset_name": "video_host" }Our Worker wraps this with RBAC checks — only participants currently assigned a *_host preset in this meeting (this includes Owner, Admin, and the meeting creator who received a host preset at join time). Promotion bounds: guests can be promoted up to *_participant; only Owner/Admin can promote to *_host. Demotion restores the participant's original preset as determined by resolvePreset() at join time. The promotion is tracked in an audit log:
CREATE TABLE participant_events (
id TEXT PRIMARY KEY,
org_id TEXT NOT NULL REFERENCES organizations(id),
meeting_id TEXT NOT NULL REFERENCES meetings(id),
user_id TEXT, -- null for guests
guest_token_id TEXT, -- null for authenticated users
event_type TEXT NOT NULL, -- 'joined' | 'left' | 'promoted' | 'demoted' | 'kicked' | 'admitted'
preset_from TEXT,
preset_to TEXT,
performed_by TEXT REFERENCES users(id), -- user_id of the person who did it
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_participant_events_meeting ON participant_events(meeting_id);
CREATE INDEX idx_participant_events_user ON participant_events(user_id);5.6 Preset Lifecycle
Presets are created during org provisioning (registration flow, Section 4.3) and are mutable via the Admin > Presets management UI (Section 12). Changes to a preset affect all future meetings using that preset — active meetings retain the preset configuration from join time.
If org_settings.guest_access_global is disabled, guest/viewer presets remain in RTK but the Worker refuses to issue guest authTokens (Section 4.3 guest-join flow checks access before reaching preset resolution). No RTK API call needed.
Section 6: Meeting Lifecycle
6.1 Meeting Types
Four meeting types, each mapping to an RTK meetingType in the preset:
| Type | RTK meetingType | Key Characteristics |
|---|---|---|
| **Video** | `video` | Full audio/video, all participants equal, screenshare allowed |
| **Audio-Only** | `voice` | Microphone only, no camera, lower bandwidth |
| **Webinar** | `webinar` | Stage-based, hosts present, audience watches, Q&A via chat |
| **Livestream** | `webinar` + RTMP | Stage-based + RTMP export to external platforms + HLS playback |
6.2 D1 Schema: Meetings & Scheduling
-- Meetings table
CREATE TABLE meetings (
id TEXT PRIMARY KEY, -- nanoid
org_id TEXT NOT NULL REFERENCES organizations(id),
rtk_meeting_id TEXT, -- RTK meeting ID (set after RTK API call)
title TEXT NOT NULL,
slug TEXT NOT NULL, -- URL-friendly meeting identifier
description TEXT,
meeting_type TEXT NOT NULL DEFAULT 'video', -- 'video' | 'audio' | 'webinar' | 'livestream'
scheduling_type TEXT NOT NULL DEFAULT 'instant', -- 'instant' | 'scheduled' | 'recurring' | 'permanent'
status TEXT NOT NULL DEFAULT 'draft', -- 'draft' | 'scheduled' | 'active' | 'ended' | 'cancelled'
created_by TEXT NOT NULL REFERENCES users(id),
-- Scheduling fields (null for instant meetings)
scheduled_start TEXT, -- ISO 8601 datetime
scheduled_end TEXT, -- ISO 8601 datetime
actual_start TEXT, -- set when first participant joins
actual_end TEXT, -- set when last participant leaves
timezone TEXT DEFAULT 'UTC',
-- Permanent room fields
is_permanent INTEGER NOT NULL DEFAULT 0,
vanity_slug TEXT UNIQUE, -- e.g., "standup" → /acme-corp/m/standup
-- Guest access (overrides org default)
guest_access TEXT, -- null = inherit org default | 'disabled' | 'link_only' | 'open'
-- Meeting configuration
waiting_room_enabled INTEGER NOT NULL DEFAULT 1,
is_locked INTEGER NOT NULL DEFAULT 0,
recording_auto_start INTEGER NOT NULL DEFAULT 0,
-- Recording policy interaction (see Section 12 org_settings):
-- org recording_policy='never' → recording_auto_start is ignored, Worker rejects all recording starts
-- org recording_policy='always' → RTK meeting created with record_on_start=true regardless of this field
-- org recording_policy='host_decides' → this field is honored (host's choice)
transcription_enabled INTEGER NOT NULL DEFAULT 1,
max_participants INTEGER DEFAULT 100,
-- Cloud storage override (null = use org default R2)
cloud_storage_config TEXT, -- JSON: { provider, bucket, region, credentials_ref }
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(org_id, slug)
);
-- Recurring meeting patterns
CREATE TABLE recurring_patterns (
id TEXT PRIMARY KEY,
meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
rrule TEXT NOT NULL, -- iCal RRULE string (e.g., "FREQ=WEEKLY;BYDAY=MO,WE,FR")
dtstart TEXT NOT NULL, -- pattern start date (ISO 8601)
until_date TEXT, -- pattern end date (null = no end)
occurrence_count INTEGER, -- alternative to until_date
exceptions TEXT DEFAULT '[]', -- JSON array of excluded dates
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Individual occurrences of recurring meetings (materialized)
CREATE TABLE meeting_occurrences (
id TEXT PRIMARY KEY,
meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
pattern_id TEXT NOT NULL REFERENCES recurring_patterns(id) ON DELETE CASCADE,
occurrence_date TEXT NOT NULL, -- the specific date/time of this occurrence
status TEXT NOT NULL DEFAULT 'scheduled', -- 'scheduled' | 'active' | 'ended' | 'cancelled'
rtk_meeting_id TEXT, -- each occurrence gets its own RTK meeting
actual_start TEXT,
actual_end TEXT,
override_json TEXT, -- per-occurrence overrides (title, description, etc.)
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(meeting_id, occurrence_date)
);
-- Meeting invites
CREATE TABLE meeting_invites (
id TEXT PRIMARY KEY,
meeting_id TEXT NOT NULL REFERENCES meetings(id) ON DELETE CASCADE,
user_id TEXT REFERENCES users(id),
email TEXT, -- for inviting non-members
role_override TEXT, -- override the user's default preset mapping
rsvp_status TEXT DEFAULT 'pending', -- 'pending' | 'accepted' | 'declined' | 'tentative'
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(meeting_id, user_id)
);
CREATE INDEX idx_meetings_org ON meetings(org_id);
CREATE INDEX idx_meetings_status ON meetings(org_id, status);
CREATE INDEX idx_meetings_vanity ON meetings(vanity_slug);
CREATE INDEX idx_meetings_scheduled ON meetings(org_id, scheduling_type, scheduled_start);
CREATE INDEX idx_occurrences_meeting ON meeting_occurrences(meeting_id, occurrence_date);
CREATE INDEX idx_occurrences_date ON meeting_occurrences(occurrence_date, status);
CREATE INDEX idx_invites_user ON meeting_invites(user_id);
CREATE INDEX idx_invites_meeting ON meeting_invites(meeting_id);
CREATE INDEX idx_meetings_creator ON meetings(created_by);
CREATE UNIQUE INDEX idx_invites_email ON meeting_invites(meeting_id, email) WHERE user_id IS NULL;6.3 Meeting Lifecycle State Machine
create
│
┌───────┴────────┐
│ │
(scheduled/ (instant)
recurring) │
│ │
▼ │
┌──────[DRAFT]─────┐ │
│ │ │ │
│ schedule│ │cancel│
│ │ │ │
▼ │ ▼ │
[SCHEDULED] │ [CANCELLED]◄┘
│ │ ▲
│ activate│ │ cancel
│ (cron │ │
│ or host)│ │
▼ ▼ │
[ACTIVE] ◄────────────┤
│ │
│ end (non-permanent)│
▼ │
[ENDED]────────────────┘
Permanent rooms (never reach ENDED):
[ACTIVE] ◄──► [INACTIVE/idle]
join → ← last leave
(cycle indefinitely, RTK meeting ID reused)
Owner/Admin can cancel a permanent room.
Cancelled permanent rooms can be reactivated:
cancelled → draft (re-schedule) or cancelled → active (reopen immediately)Note — cron failure recovery: The activation query
WHERE status='scheduled' AND scheduled_start <= nowinherently catches overdue meetings on the next successful cron run. No separate retry mechanism is needed.
State transitions and RTK API calls:
| Transition | Trigger | RTK API Call |
|---|---|---|
| `draft → scheduled` | Host sets date/time | None (RTK meeting not created yet) |
| `draft → active` | Host clicks "Start Now" | `POST /meetings` (create RTK meeting) then `PATCH /meetings/{id}` (set status=ACTIVE) |
| `scheduled → active` | Cron trigger at scheduled time OR host starts early | `POST /meetings` (create RTK meeting) then `PATCH /meetings/{id}` (set ACTIVE) |
| `active → ended` | Last participant leaves OR host clicks "End" | `PATCH /meetings/{id}` (set INACTIVE), kick all participants |
| `any → cancelled` | Host/Admin cancels | If RTK meeting exists: `PATCH /meetings/{id}` (set INACTIVE) |
6.4 Meeting Creation Flow
Instant Meeting
1. POST /api/meetings
Body: { title, meetingType: "video", schedulingType: "instant" }
2. Worker validates auth (must be Owner/Admin/Host)
3. Generate meeting slug (nanoid, 10 chars, URL-safe)
4. Create RTK meeting:
POST /realtime/kit/{APP_ID}/meetings
Body: { title }
→ Returns rtk_meeting_id
5. Activate RTK meeting:
PATCH /realtime/kit/{APP_ID}/meetings/{rtk_meeting_id}
Body: { status: "ACTIVE" }
6. Insert meeting row in D1 (status = 'active', rtk_meeting_id set)
-- timezone defaults to org_settings.timezone (resolved by Worker at creation time, not a DB default)
7. Return { meetingId, slug, joinUrl: "/m/{slug}" }Scheduled Meeting
1. POST /api/meetings
Body: { title, meetingType, schedulingType: "scheduled", scheduledStart, scheduledEnd, timezone }
2. Worker validates auth + time is in the future
3. Insert meeting row in D1 (status = 'scheduled', rtk_meeting_id = null)
4. RTK meeting is NOT created yet (saves resources, avoids stale meetings)
5. When scheduled time arrives:
- Workers Cron (`* * * * *` — every minute) queries meetings WHERE status='scheduled' AND scheduled_start <= now
- Creates RTK meeting via API
- Updates D1 row: status='active', rtk_meeting_id set
- Optionally sends notification (webhook/email) to inviteesRecurring Meeting
1. POST /api/meetings
Body: { title, meetingType, schedulingType: "recurring", rrule: "FREQ=WEEKLY;BYDAY=MO,WE,FR", dtstart, timezone }
2. Create meeting row (template) in D1
3. Create recurring_patterns row with RRULE
4. Materialize next 4 weeks of occurrences into meeting_occurrences table
5. Each occurrence is activated independently via the same cron mechanism (`* * * * *`)
6. Daily cron (`0 0 * * *` UTC) checks each recurring meeting:
- If fewer than 7 future occurrences remain, materialize the next 4-week batch
- This ensures there are always enough upcoming occurrences for calendar display
7. Each occurrence gets its own RTK meeting (created at activation time, NOT at materialization time)Permanent Room
1. POST /api/meetings
Body: { title, meetingType, schedulingType: "permanent", vanitySlug: "standup" }
2. Validate vanity slug uniqueness within org
3. Create meeting row with is_permanent=1, vanity_slug set, D1 status='active' (always joinable)
4. Create RTK meeting immediately (persistent room):
POST /realtime/kit/{APP_ID}/meetings
Body: { title, status: "INACTIVE" }
5. Room URL: /m/{vanitySlug} (e.g., /m/standup)
6. Room is always available but INACTIVE until someone joins:
- On join request → PATCH RTK meeting to ACTIVE
- On last leave → PATCH RTK meeting to INACTIVE
- RTK meeting ID is reused across sessions (never deleted)
7. Permanent rooms never expire. Owner/Admin can deactivate (soft delete) by setting meeting status to 'cancelled'.6.5 Joining a Meeting
Single endpoint handles both authenticated and guest joins:
1. Client navigates to /m/{meetingSlug}
2. POST /api/meetings/{meetingId}/join
- Authenticated: Headers: Authorization: Bearer <jwt>
- Guest: Body includes { guestToken, displayName } (no Authorization header)
Guest detection: if no Authorization header, check for guestToken in body or query param.
Guest must provide displayName (2-50 chars, HTML/script stripped).
3. Worker checks:
a. Meeting exists and is in joinable state (active, or permanent-room idle)
b. Meeting is not locked (check D1 is_locked — see Section 6.8)
c. Capacity not exceeded (participant count < max_participants)
4. Worker resolves identity and preset:
- Authenticated: user identity from JWT, platform role from D1
- Guest: generate guest ID (crypto.randomUUID()), role = 'guest'
- Resolve preset: resolvePreset(role, meetingType, isCreator)
5. Create RTK participant:
POST /realtime/kit/{APP_ID}/meetings/{RTK_MEETING_ID}/participants
Body: {
name: user.name || displayName,
preset_name: resolvedPreset,
custom_participant_id: user.id || guestUUID // REQUIRED — links RTK participant to our user
}
→ Returns { id: rtk_participant_id, token: rtk_auth_token }
Note: RTK API returns the field as `token`, not `authToken`.
6. Log participant_events row (event_type = 'joined')
7. Return to client:
{
authToken: data.token, // renamed from RTK's `token` for clarity in our API
meetingId: rtkMeetingId,
preset_name,
meetingConfig: {
title, type, waitingRoomEnabled, transcriptionEnabled, ...
}
}
8. Client initializes RTK SDK:
const meeting = await RealtimeKitClient.init({ authToken });
await meeting.join();6.6 Ending a Meeting
Two triggers:
Host ends meeting explicitly:
POST /api/meetings/{meetingId}/end
1. Validate caller is Owner/Admin/Host-of-meeting
2. RTK: kick all participants
POST /realtime/kit/{APP_ID}/meetings/{RTK_MEETING_ID}/active-session/kick-all
3. RTK: set meeting inactive
PATCH /realtime/kit/{APP_ID}/meetings/{RTK_MEETING_ID}
Body: { status: "INACTIVE" }
4. D1: update meeting status = 'ended', actual_end = now
5. Trigger post-meeting processing (recording finalization, transcript, summary)Last participant leaves (webhook-driven):
1. RTK fires `meeting.ended` webhook when last participant leaves (after `session_keep_alive_time_in_secs` elapses — default 60s, configurable 60-600s per meeting)
2. Worker webhook handler:
- Look up meeting by rtk_meeting_id
- If scheduling_type != 'permanent':
Set meeting status = 'ended', actual_end = now
- If scheduling_type == 'permanent':
Set RTK meeting to INACTIVE (keep D1 status as 'active' for rejoining)
- Trigger post-meeting processing6.7 Guest Access Configuration Hierarchy
Guest access is controlled at two levels, with meeting-level overriding org-level:
Effective guest access = meeting.guest_access ?? org.guest_access_default| Setting | Behavior |
|---|---|
| `disabled` | No guest access. Only authenticated org members can join. |
| `link_only` | Guests can join via meeting link. Must provide display name. Waiting room recommended. |
| `open` | Guests can join via link with minimal friction. Waiting room still applies if enabled. |
Per-meeting override: When creating/editing a meeting, the Host can set guest_access to override the org default for that specific meeting. Null means "inherit org default."
6.8 Abuse Protection Mechanisms
Rate Limiting (Cloudflare Workers)
// Rate limit configuration per endpoint category
const RATE_LIMITS = {
'auth.login': { requests: 5, window: 60 }, // 5 login attempts per minute
'auth.register': { requests: 3, window: 3600 }, // 3 registrations per hour (per IP)
'meeting.create': { requests: 10, window: 60 }, // 10 meetings per minute (per org)
'meeting.join': { requests: 30, window: 60 }, // 30 joins per minute (per meeting)
'guest.join': { requests: 20, window: 60 }, // 20 guest joins per minute (per meeting)
'chat.send': { requests: 30, window: 60 }, // 30 messages per minute (per user)
'api.general': { requests: 100, window: 60 }, // 100 requests per minute (per user)
};Rate limiting is enforced using a KV-based fixed-window counter (KV is eventually consistent, so sliding windows are impractical).
Key format: ratelimit:{category}:{identifier}:{window} where window = Math.floor(Date.now() / (windowSeconds * 1000)).
Example: ratelimit:meeting.join:mtg_abc123:28547281
Identifier is org_id for org-scoped limits, user_id for user-scoped limits, or IP for unauthenticated endpoints.
Capacity Caps
| Resource | Default Limit | Configurable |
|---|---|---|
| Max participants per meeting | 100 | Yes (per meeting) |
| Max concurrent meetings per org | 25 | Yes (org setting) |
| Max guests per meeting | 50% of max_participants | Yes (per meeting) |
| Max meeting duration | 24 hours | Yes (org setting) |
| Max recording storage (R2) | 50 GB per org | Yes (org setting) |
When a cap is reached, the Worker returns HTTP 429 with a clear error message. RTK's own limits (if any) are respected as the floor.
Guest Abuse Controls
- Waiting room enforcement: Guests always enter waiting room when
guest_access = 'link_only'(even if meeting-level waiting room is off for members) - Display name validation: Strip HTML/script tags, enforce 2-50 char length, block known abuse patterns
- Token expiry: Guest tokens expire after 4 hours (configurable)
- IP-based join throttle: Max 3 guest joins from the same IP within 5 minutes per meeting
- Host can lock meeting: Once started, host toggles meeting lock via
POST /api/meetings/{meetingId}/lock. Worker setsis_locked=truein D1. The join handler (Section 6.5 step 3b) checksis_lockedbefore creating an RTK participant. Current participants stay connected — only new joins are blocked. We do NOT use RTK's INACTIVE status for locking because it conflicts with the permanent room idle/live cycle (Worker can't distinguish "idle" from "locked") - Auto-kick idle guests: If a guest is in waiting room for >10 minutes without being admitted, their token is revoked
Webhook Verification
All incoming RTK webhooks are verified:
- Check signature header against shared secret — UNVERIFIED: exact header name (
X-RTK-Signature?) and verification mechanism not confirmed in RTK docs. Verify at build time; if no signature mechanism exists, validate by cross-referencing event data with REST API. - Check timestamp — reject if >5 minutes old (replay protection). UNVERIFIED: exact header name (
X-RTK-Timestamp?) not confirmed. - Idempotency: store webhook event IDs in KV with 24-hour TTL to deduplicate
6.9 API Response Conventions
All platform API endpoints follow a consistent response format:
Success: { success: true, data: { ... } }
Error: { success: false, error: { code: "MEETING_LOCKED", message: "This meeting has been locked by the host" } }| Endpoint | Success | Common Errors |
|---|---|---|
| POST /api/meetings | 201 | 400 (validation), 403 (role < Host) |
| POST /api/meetings/{id}/join | 200 | 404 (not found), 403 (not authorized), 409 (meeting ended/cancelled), 423 (meeting locked), 429 (rate limit) |
| POST /api/meetings/{id}/end | 200 | 404, 403 (not Host/Admin/Owner) |
| PATCH /api/meetings/{id} | 200 | 404, 403, 409 (invalid state transition) |
| POST /api/meetings/{id}/lock | 200 | 404, 403 (not Host/Admin/Owner) |
6.10 Post-Meeting Processing Pipeline
After a meeting ends (either explicitly or via last-leave webhook):
Meeting Ends
│
├─→ Recording: Poll RTK recording status via REST API until 'completed'
│ └─→ Store recording metadata in D1 (duration, size, R2 path)
│
├─→ Transcript: Wait for meeting.transcript webhook OR poll REST API
│ └─→ Store transcript in D1 (full text + timestamped segments)
│
├─→ AI Summary: Wait for meeting.summary webhook
│ └─→ Store summary in D1
│
├─→ Chat Export: Wait for meeting.chatSynced webhook
│ └─→ Store chat log in D1
│
└─→ Analytics: Aggregate session data
└─→ Update analytics tables (session duration, participant count, peak concurrent, etc.)All post-meeting data is linked to the meeting row and accessible via:
GET /api/meetings/{meetingId}/recordingsGET /api/meetings/{meetingId}/transcriptsGET /api/meetings/{meetingId}/summariesGET /api/meetings/{meetingId}/chat-exports
Design Document: Sections 7-9
Section 7: RTK Feature Mapping (Exhaustive)
Every RealtimeKit feature mapped to its app surface, API, and implementation status.
Legend:
- Config Level:
Preset= controlled via preset permissions,Meeting= meeting-level config,App= app-wide config,Client= SDK-side only,Server= REST API / Worker - Status:
Build= in scope now,Research= needs investigation,Future= planned later,Low Priority= groundwork only
7.1 Core Meeting Infrastructure
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Meeting creation | `POST /meetings` REST API | Create Meeting page, Schedule modal | Server | Build |
| Meeting ACTIVE/INACTIVE toggle | `PATCH /meetings/{id}` REST API | Meeting list admin actions | Server | Build |
| Meeting metadata | `meeting.meta` (client SDK) | Meeting header, lobby screen | Meeting | Build |
| Meeting title display | `rtk-meeting-title` component | In-meeting header bar | Meeting | Build |
| Meeting duration clock | `rtk-clock` component | In-meeting header bar | Client | Build |
| Session lifecycle (auto-create on join, end on last leave) | Managed by RTK platform | Backend webhook handler | Server | Build |
| Participant creation | `POST /meetings/{id}/participants` REST API | Join flow (Worker creates participant, returns authToken) | Server | Build |
| Participant token refresh | `POST /participants/{id}/token` REST API | Auto-refresh middleware in Worker | Server | Build |
| Preset creation & management | `POST /presets`, `PATCH /presets/{id}` REST API | Org Settings > Presets page | Server + App | Build |
| Meeting type: Video (Group Call) | Preset `view_type: "GROUP_CALL"` | Meeting creation form, preset config | Preset | Build |
| Meeting type: Voice (Audio Only) | Preset `view_type: "AUDIO_ROOM"` | Meeting creation form, preset config | Preset | Build |
| Meeting type: Webinar | Preset `view_type: "WEBINAR"` | Meeting creation form, preset config | Preset | Build |
| `record_on_start` config | Meeting creation parameter | Create Meeting form toggle | Meeting | Build |
| `persist_chat` config | Meeting creation parameter | Create Meeting form toggle | Meeting | Build |
| `ai_config` (transcription + summarization) | Meeting creation parameter | Create Meeting form — AI section | Meeting | Build |
| `summarize_on_end` config | Meeting creation parameter | Create Meeting form toggle | Meeting | Build |
7.2 Participant & Room Management
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Participant list | `rtk-participants` component | Sidebar > Participants tab | Preset | Build |
| Participants toggle | `rtk-participants-toggle` component | Control bar | Client | Build |
| Single participant entry | `rtk-participant` component | Inside `rtk-participants` (name, media status, actions) | Client | Build |
| Audio participant list | `rtk-participants-audio` component | Sidebar in audio-only meetings | Client | Build |
| Participant count badge | `rtk-participant-count` component | Control bar, header | Client | Build |
| Participant tile (video) | `rtk-participant-tile` component | Main grid area | Client | Build |
| Participant tile (audio-only) | `rtk-audio-tile` component | Audio grid layout | Client | Build |
| Participant name tag | `rtk-name-tag` component | Overlay on each tile | Client | Build |
| Participant avatar | `rtk-avatar` component | Audio tiles, participant list | Client | Build |
| Participant setup/preview | `rtk-participant-setup` component | Pre-join screen | Client | Build |
| Virtualized participant list | `rtk-virtualized-participant-list` | Large meeting sidebar | Client | Build |
| Viewer list (webinar) | `rtk-participants-viewer-list` component | Webinar sidebar tab | Client | Build |
| Viewer count (livestream) | `rtk-viewer-count` component | Livestream header | Client | Build |
| Kick participant | `participant.kick()` (RTKParticipant method) | Participant context menu | Preset | Build |
| Mute participant audio/video | `participant.disableAudio()`, `participant.disableVideo()` (RTKParticipant methods) | Participant context menu | Preset | Build |
| Mute all participants | `rtk-mute-all-button` + `rtk-mute-all-confirmation` | Control bar (host only) | Preset | Build |
| Edit participant name | `meeting.self.setName(name)` (RTKSelf method) | Participant settings | Preset | Build |
| User ID (persistent across sessions) | `meeting.self.userId` / `participant.userId` | Analytics, user tracking | Client | Build |
| Session ID (per-connection) | `meeting.self.id` / `participant.id` | Session-level tracking | Client | Build |
7.3 Waiting Room
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Waiting room enable/disable | Preset configuration | Preset editor > Waiting Room section | Preset | Build |
| Waiting room screen | `rtk-waiting-screen` component | Shown to waitlisted participants | Client | Build |
| Waiting room participant list | `rtk-participants-waiting-list` component | Host sidebar > Waiting tab | Preset | Build |
| Admit participant | `meeting.participants.acceptWaitingRoomRequest(id)` | Waiting list item action | Preset | Build |
| Reject participant | `meeting.participants.rejectWaitingRoomRequest(id)` | Waiting list item action | Preset | Build |
| Admit all | `meeting.participants.acceptAllWaitingRoomRequest(userIds)` | Waiting list bulk action | Preset | Build |
| Auto-admit when host joins | Preset `waiting_room` config | Preset editor toggle | Preset | Build |
| Bypass waiting room (by preset) | Preset configuration | Preset editor — bypass role list | Preset | Build |
7.4 Stage Management (Webinar)
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Stage view | `rtk-stage` component | Main content area (webinar mode) | Preset | Build |
| Join/leave stage toggle | `rtk-stage-toggle` component | Control bar (webinar) | Preset | Build |
| Join stage button | `rtk-join-stage` component | Viewer UI prompt | Preset | Build |
| On-stage participants list | `rtk-participants-stage-list` component | Host sidebar | Preset | Build |
| Stage request queue | `rtk-participants-stage-queue` component | Host sidebar > Requests tab | Preset | Build |
| Host: approve stage requests | `meeting.stage.grantAccess()` | Stage queue item actions | Preset | Build |
| Host: deny stage requests | `meeting.stage.denyAccess()` | Stage queue item actions | Preset | Build |
| Host: remove from stage | `meeting.stage.kick(userIds)` | Participant context menu | Preset | Build |
7.5 Chat System
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Complete chat panel | `rtk-chat` component | Sidebar > Chat tab | Preset | Build |
| Chat toggle | `rtk-chat-toggle` component | Control bar | Client | Build |
| Chat header | `rtk-chat-header` component | Chat panel header (pinned msgs, DM selector) | Client | Build |
| Message composer | `rtk-chat-composer-view` component | Chat panel bottom | Client | Build |
| Chat messages list (paginated) | `rtk-chat-messages-ui-paginated` component | Chat panel body (infinite scroll) | Client | Build |
| Single message view | `rtk-message-view` component | Within chat list | Client | Build |
| Markdown rendering | `rtk-markdown-view` component | Chat message content | Client | Build |
| Public chat | `chatPublic` preset permission | Default chat mode | Preset | Build |
| Private chat (1:1 DMs) | `chatPrivate` preset permission + `privateChatRecipient` prop | Chat selector > DM tab | Preset | Build |
| Chat selector (public/private) | `rtk-chat-selector` / `rtk-chat-selector-ui` | Chat panel header | Client | Build |
| Pinned messages | `rtk-pinned-message-selector` component | Chat header area (public chat only) | Client | Build |
| Chat search | `rtk-chat-messages-ui-paginated` component (search via props) | Chat panel search bar | Client | Build |
| File messages | `rtk-file-message-view` component | In chat message list | Client | Build |
| Image messages | `rtk-image-message-view` + `rtk-image-viewer` | In chat + full-screen viewer | Client | Build |
| File upload (drag & drop) | `rtk-file-dropzone` + `rtk-file-picker-button` | Chat composer area | Client | Build |
| Draft attachment preview | `rtk-draft-attachment-view` component | Chat composer area | Client | Build |
| Emoji picker | `rtk-emoji-picker` + `rtk-emoji-picker-button` | Chat composer | Client | Build |
| Chat export (post-meeting) | `meeting.chatSynced` webhook + Chat Replay REST API | Post-meeting page > Chat tab | Server | Build |
| Chat CSV dump | REST API download URL | Post-meeting download button | Server | Build |
| Fetch private messages | `meeting.chat.fetchPrivateMessages` SDK | DM chat history | Client | Build |
7.6 Polls
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Polls panel | `rtk-polls` component | Sidebar > Polls tab | Preset | Build |
| Polls toggle | `rtk-polls-toggle` component | Control bar | Client | Build |
| Single poll display | `rtk-poll` component | Within polls panel | Client | Build |
| Poll creation form | `rtk-poll-form` component | Polls panel > Create | Preset | Build |
| Poll permissions (create/view/interact) | Preset `polls` config | Preset editor > Polls section | Preset | Build |
7.7 Breakout Rooms (Connected Meetings)
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Breakout rooms manager | `rtk-breakout-rooms-manager` component | Sidebar > Breakout tab (host) | Preset | Build |
| Single room manager | `rtk-breakout-room-manager` component | Within breakout panel | Preset | Build |
| Room participant list | `rtk-breakout-room-participants` component | Within each room card | Client | Build |
| Breakout rooms toggle | `rtk-breakout-rooms-toggle` component | Control bar (host) | Client | Build |
| Broadcast message to rooms | `rtk-broadcast-message-modal` component | Breakout panel > Broadcast button | Preset | Build |
| Create/switch/close rooms | Connected Meetings SDK APIs | Breakout room management UI | Preset | Build |
| Cross-meeting broadcast | `meeting.participants.broadcastMessage` with `meetingIds` | Host broadcast action | Preset | Build |
| **Platform limitation** | Web only (beta) | Show "web only" badge; hide on mobile | — | Build |
7.8 Message Broadcasting & Collaborative Stores
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Broadcast message to meeting | `meeting.participants.broadcastMessage(type, payload)` | Custom event system (reactions, notifications) | Client | Build |
| Subscribe to broadcasts | `broadcastedMessage` event listener | Event handlers throughout app | Client | Build |
| Rate limiting config | `rateLimitConfig` / `updateRateLimits()` | Internal config (not user-facing) | Client | Build |
| Collaborative stores (KV) | `meeting.stores` API — create, subscribe, update | Shared state: hand raise, custom annotations, cursor position | Client | Build |
| Store subscription | `RTKStore` instance — `.subscribe()` on key changes | Real-time UI updates from store changes | Client | Build |
| Store session scoping | Data persists until session ends | Automatic cleanup — no action needed | — | Build |
7.9 Recording
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Start recording | Start Recording REST API + client SDK | Control bar > Record button (host) | Preset | Build |
| Stop recording | Stop Recording REST API + client SDK | Control bar > Stop Record button | Preset | Build |
| Recording toggle | `rtk-recording-toggle` component | Control bar | Preset | Build |
| Recording indicator | `rtk-recording-indicator` component | Meeting header (visible to all) | Client | Build |
| Fetch active recording | Fetch Active Recording REST API | Admin monitoring | Server | Build |
| Fetch recording details/download | Fetch Recording Details REST API | Post-meeting page > Recordings | Server | Build |
| `recording.statusUpdate` webhook | Webhook event (INVOKED/RECORDING/UPLOADING/UPLOADED/ERRORED) | Webhook handler > update D1 | Server | Build |
| Record on start | `record_on_start` meeting config | Meeting creation form | Meeting | Build |
| Composite recording | Default mode — multi-user single file (H.264/VP8) | Primary recording output | Meeting | Build |
| Custom cloud storage upload | AWS, Azure, DigitalOcean, GCS, SFTP config via API (R2 via aws-compatible config) | Org Settings > Storage config | App | Build |
| Watermarking | `video_config.watermark` in Start Recording API | Org Settings > Recording > Watermark | App | Build |
| Recording codec config | Video/audio codec parameters in Start Recording API | Org Settings > Recording > Quality | App | Build |
| Interactive recording (timed metadata) | Recording SDK + metadata API | Advanced: searchable playback markers | Meeting | Build |
| Custom recording app | `@cloudflare/realtimekit-recording-sdk` | Future: custom recording layouts | App | Future |
| Disable RTK bucket upload | Recording config option | Org Settings > Storage | App | Build |
| Recording config precedence | Config hierarchy management | Internal Worker logic | Server | Build |
| Per-track recording | `POST /recordings/track` — layers API ($0.0005/min). **API endpoint verified in OpenAPI** but functionally blocked: the `layers` system currently only supports a `default` key (all participants' audio) and `default-video` (all participants' video). This is NOT per-participant — it's just audio/video split, which the composite endpoint already does via `audio_config.export_file`. The referenced "Track Recordings Guide" page does not exist. **Per-participant isolation requires multi-layer support (not yet available).** Monitor RTK docs for when named participant layers ship. | Meeting settings toggle | Meeting | Future (watch) |
7.10 Livestreaming
Sparse docs warning: RTK SDK confirms livestreaming components exist (
rtk-livestream-*,RTKLivestreamAPI), but setup/configuration docs are minimal. RTMP destination config and HLS URL retrieval patterns will need discovery-by-experimentation at build time. The Start Recording API's RTMP export option is the likely config path.
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Livestream indicator | `rtk-livestream-indicator` component | Meeting header (during livestream) | Client | Build |
| Livestream toggle | `rtk-livestream-toggle` component | Control bar (host, webinar mode) | Preset | Build |
| Livestream player (HLS) | `rtk-livestream-player` component | Viewer page / external embed | Client | Build |
| RTMP export config | Part of recording/export system — likely via Start Recording API with RTMP destination | Meeting settings > Livestream section | Meeting | Build |
| HLS playback (hls.js) | Bundled hls.js dependency | Viewer page, post-meeting playback | Client | Build |
| Livestream as recording export mode | Pricing: same as "Export (recording, RTMP or HLS streaming)" | Worker config when starting stream | Server | Build |
| Stage management for livestream | Stage + livestream integration | Webinar host controls during stream | Preset | Build |
7.11 Transcription & AI
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Real-time transcription | Whisper Large v3 Turbo (Workers AI) | In-meeting captions overlay | Meeting + Preset | Build |
| Caption toggle | `rtk-caption-toggle` component | Control bar | Client | Build |
| AI panel | `rtk-ai` component | Sidebar > AI tab | Client | Build |
| AI toggle | `rtk-ai-toggle` component | Control bar | Client | Build |
| Live transcription display | `rtk-ai-transcriptions` component | AI panel / caption overlay | Client | Build |
| Single transcript entry | `rtk-transcript` component | Within transcription list | Client | Build |
| Transcript history | `rtk-transcripts` component | AI panel scrollback | Client | Build |
| Transcription language config | `ai_config.transcription.language` on meeting creation | Meeting creation form > Language | Meeting | Build |
| `transcription_enabled` preset flag | Preset parameter | Preset editor toggle | Preset | Build |
| Post-meeting transcript | `meeting.transcript` webhook + REST API fetch | Post-meeting page > Transcript tab | Server | Build |
| Transcript download URL | Presigned R2 URL (7-day retention) | Post-meeting download button | Server | Build |
| AI meeting summary | `meeting.summary` webhook + REST API fetch/trigger | Post-meeting page > Summary tab | Server | Build |
| Summary download URL | Presigned R2 URL | Post-meeting download button | Server | Build |
| Summary type config | `ai_config.summarization.summary_type` (e.g. `"team_meeting"`) | Meeting creation form > Summary type | Meeting | Build |
| Summary text format | `text_format: "markdown"` | Internal config (always markdown) | Meeting | Build |
| Manual summary trigger | `POST /sessions/{id}/summary` REST API | Post-meeting page > Generate Summary button | Server | Build |
| Summary output (Key Points, Action Items, Decisions) | Structured markdown output | Post-meeting summary viewer | Server | Build |
7.12 Virtual Backgrounds & Video Effects
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Background blur | `@cloudflare/realtimekit-virtual-background` package | Settings > Video > Background | Client | Build |
| Virtual background (custom image) | Same package + Video Background addon | Settings > Video > Background | Client | Build |
| Video Background addon | `realtimekit-ui-addons` Video Background addon | Pre-join screen + in-meeting settings | Client | Build |
7.13 Plugins
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Plugins panel | `rtk-plugins` component | Sidebar > Plugins tab | Preset | Build |
| Plugins toggle | `rtk-plugins-toggle` component | Control bar | Client | Build |
| Plugin main view | `rtk-plugin-main` component | Main content area (when plugin active) | Client | Build |
| Whiteboard (built-in) | Built-in plugin, enabled via preset | Plugins panel > Whiteboard | Preset | Build |
| Document Sharing (built-in) | Built-in plugin, enabled via preset | Plugins panel > Document Sharing | Preset | Build |
| Custom plugins | Plugin SDK (`meeting.plugins` API, iframe-based) | Org Settings > Custom Plugins (future) | Preset | Build |
| Plugin permissions (view/open/close) | Preset `plugins` config | Preset editor > Plugins section | Preset | Build |
| Plugin data exchange | `plugin.sendData()` / `plugin.handleIframeMessage()` | Plugin <-> app communication | Client | Build |
7.14 Media Controls & Device Selection
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Camera toggle | `rtk-camera-toggle` component | Control bar | Client | Build |
| Camera selector | `rtk-camera-selector` component | Settings > Video > Camera picker | Client | Build |
| Microphone toggle | `rtk-mic-toggle` component | Control bar | Client | Build |
| Microphone selector | `rtk-microphone-selector` component | Settings > Audio > Mic picker | Client | Build |
| Speaker selector | `rtk-speaker-selector` component | Settings > Audio > Speaker picker | Client | Build |
| Audio visualizer | `rtk-audio-visualizer` component | Name tag, settings preview, pre-join | Client | Build |
| Settings panel | `rtk-settings` component | Sidebar > Settings tab | Client | Build |
| Audio settings | `rtk-settings-audio` component | Settings > Audio sub-panel | Client | Build |
| Video settings | `rtk-settings-video` component | Settings > Video sub-panel | Client | Build |
| Settings toggle | `rtk-settings-toggle` component | Control bar | Client | Build |
7.15 Screen Sharing & PiP
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Screen share toggle | `rtk-screen-share-toggle` component | Control bar | Client | Build |
| Screen share view | `rtk-screenshare-view` component | Main content area (when sharing) | Client | Build |
| Screen share frame rate config | SDK media config `screenShareConfig.frameRate` | Internal config (default 5 FPS) | Client | Build |
| Picture-in-Picture toggle | `rtk-pip-toggle` component | Control bar | Client | Build |
| PiP with reactions | SDK PiP support | Browser PiP window | Client | Build |
| Fullscreen toggle | `rtk-fullscreen-toggle` component | Control bar | Client | Build |
7.16 Simulcast & Active Speakers
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Simulcast (multi-quality streams) | SDK config (added Core 1.2.0) | Internal — transparent to user | Client | Build |
| Per-track simulcast config | SDK `simulcastConfig` (`disable`, `encodings`) via `initMeeting` overrides | Bandwidth-adaptive quality (automatic) | Client | Build |
| Active speaker detection | `meeting.participants.lastActiveSpeaker` (single participantId) | Spotlight grid, speaker indicator | Client | Build |
| Active participants map | `meeting.participants.active` (map of active participants) | Active speaker grid layout | Client | Build |
| Spotlight grid | `rtk-spotlight-grid` component | Layout option: spotlight mode | Client | Build |
| Spotlight indicator | `rtk-spotlight-indicator` component | On active speaker tile | Client | Build |
| Network quality indicator | `rtk-network-indicator` component | On participant tiles | Client | Build |
7.17 Grid Layouts
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Default responsive grid | `rtk-grid` component | Main content area (default layout) | Client | Build |
| Simple grid | `rtk-simple-grid` component | Compact layout option | Client | Build |
| Mixed grid (video + screenshare) | `rtk-mixed-grid` component | When screen sharing active | Client | Build |
| Audio-only grid | `rtk-audio-grid` component | Voice meeting layout | Client | Build |
| Grid pagination | `rtk-grid-pagination` component | Below grid (large meetings) | Client | Build |
7.18 UI Chrome & Meeting Shell
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Complete meeting experience | `rtk-meeting` component | Drop-in full meeting page | Client | Build |
| Pre-join setup screen | `rtk-setup-screen` component | Meeting lobby / pre-join | Client | Build |
| Idle/disconnected screen | `rtk-idle-screen` component | Before joining / connection lost | Client | Build |
| Meeting ended screen | `rtk-ended-screen` component | After meeting ends | Client | Build |
| Leave meeting | `rtk-leave-meeting` / `rtk-leave-button` | Control bar > Leave | Client | Build |
| Control bar | `rtk-controlbar` component | Bottom of meeting view | Client | Build |
| Control bar buttons | `rtk-controlbar-button` component | Individual action buttons | Client | Build |
| Sidebar container | `rtk-sidebar` / `rtk-sidebar-ui` component | Right side panel (chat, participants, etc.) | Client | Build |
| Dialog/modal system | `rtk-dialog` / `rtk-dialog-manager` component | Confirmation dialogs, settings modals | Client | Build |
| Menu system | `rtk-menu` / `rtk-menu-item` / `rtk-menu-list` | Context menus, dropdowns | Client | Build |
| Notification system | `rtk-notification` / `rtk-notifications` | Toast notifications | Client | Build |
| Tab navigation | `rtk-tab-bar` component | Sidebar tab switching | Client | Build |
| Overlay modal | `rtk-overlay-modal` component | Full-screen overlays | Client | Build |
| Confirmation modal | `rtk-confirmation-modal` component | Destructive action confirmations | Client | Build |
| Permissions prompt | `rtk-permissions-message` component | Browser permission requests | Client | Build |
| More options menu | `rtk-more-toggle` component | Control bar overflow menu | Client | Build |
| Header bar | `rtk-header` component | Meeting top bar | Client | Build |
| Logo display | `rtk-logo` component | Header / idle screen | Client | Build |
| Loading spinner | `rtk-spinner` component | Loading states | Client | Build |
7.19 Addons
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Camera host control | `realtimekit-ui-addons` Camera Host Control | Participant tile menu (host) | Preset | Build |
| Mic host control | `realtimekit-ui-addons` Mic Host Control | Participant tile menu (host) | Preset | Build |
| Chat host control | `realtimekit-ui-addons` Chat Host Control | Participant tile menu (host) | Preset | Build |
| Hand raise | `realtimekit-ui-addons` Hand Raise addon | Control bar + participant list | Client | Build |
| Reactions | `realtimekit-ui-addons` Reactions Manager | Control bar + floating reactions overlay | Client | Build |
| Participant tile menu | `realtimekit-ui-addons` Participant Tile Menu | Right-click / long-press on tile | Client | Build |
| Custom control bar button | `realtimekit-ui-addons` Custom Control Bar Button | Extend control bar with app-specific actions | Client | Build |
| Participant menu item | `@cloudflare/realtimekit-ui-addons` Participant Menu Item | Custom menu items in participant context menu | Client | Build |
| Participant tab actions | `@cloudflare/realtimekit-ui-addons` Participants Tab Action/Toggle | Participant panel extensions | Client | Build |
7.20 Branding & Design System
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| UI Provider context | `rtk-ui-provider` component | App root wrapper | App | Build |
| Design system tokens | `provideRtkDesignSystem()` utility | App initialization | App | Build |
| Theme (light/dark/darkest) | Design token `theme` | Org Settings > Branding | App | Build |
| Brand color | Design token `brand.500` | Org Settings > Branding | App | Build |
| Background color | Design token `background.1000` | Org Settings > Branding | App | Build |
| Typography | Design token `fontFamily` / `googleFont` | Org Settings > Branding | App | Build |
| Spacing scale | Design token `spacingBase` | Internal (default 4px) | App | Build |
| Border width/radius | Design tokens | Org Settings > Branding | App | Build |
| Custom icon pack | JSON SVG icon pack (40+ icons) | Org Settings > Branding > Icons | App | Build |
| Custom logo | `rtk-logo` component or `rtk-meeting` prop | Org Settings > Branding > Logo | App | Build |
| Internationalization (i18n) | `RtkI18n` type + `t` prop / `useLanguage()` hook | Org Settings > Language | App | Build |
| CSS variable prefix (`--rtk-*`) | All design system outputs | Automatic from token config | App | Build |
7.21 Debug & Diagnostics
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Debug panel | `rtk-debugger` component | Settings > Debug (admin/dev only) | Client | Build |
| Debug toggle | `rtk-debugger-toggle` component | Control bar (admin only) | Client | Build |
| Audio debug info | `rtk-debugger-audio` component | Debug panel > Audio tab | Client | Build |
| Video debug info | `rtk-debugger-video` component | Debug panel > Video tab | Client | Build |
| Screenshare debug info | `rtk-debugger-screenshare` component | Debug panel > Screen tab | Client | Build |
| System debug info | `rtk-debugger-system` component | Debug panel > System tab | Client | Build |
| Error codes | RTK error code reference | Error display UI + Sentry reporting | Client | Build |
7.22 Realtime Agents (Low Priority — Groundwork Only)
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| Agent as meeting participant | `@cloudflare/realtime-agents` SDK | Agent joins meeting with authToken | Server (DO) | Low Priority |
| STT pipeline (Deepgram) | `DeepgramSTT` component | Automatic transcription of meeting audio | Server (DO) | Low Priority |
| LLM processing | `TextComponent` + Workers AI binding | Agent text generation | Server (DO) | Low Priority |
| TTS pipeline (ElevenLabs) | `ElevenLabsTTS` component | Agent spoken responses | Server (DO) | Low Priority |
| RTK Transport | `RealtimeKitTransport` component | Agent media I/O to meeting | Server (DO) | Low Priority |
| DO binding in wrangler | Durable Object config | `wrangler.jsonc` setup | Server | Low Priority |
| Agent route stubs | `/agent/*` and `/agentsInternal` routes | Worker routing | Server | Low Priority |
7.23 TURN & NAT Traversal
What TURN does
TURN (Traversal Using Relays around NAT) is a relay service that ensures video/voice works for users behind corporate firewalls, strict NATs, or restrictive networks. Without it, ~10-15% of users (typically corporate/enterprise) cannot establish direct connections and would be unable to join meetings. TURN relays their media traffic through a public server as a fallback when direct or STUN-assisted connections fail.
How RTK handles TURN — zero developer action required
RealtimeKit manages TURN/ICE/NAT traversal entirely within its platform. When a participant joins a meeting using their authToken, the RTK SDK automatically:
- Performs ICE candidate gathering (local, STUN reflexive, TURN relay)
- Negotiates the best connection path with the RTK SFU
- Falls back to TURN relay if direct connectivity fails
- Handles credential rotation for long-running sessions
There is no TURN configuration in the RTK SDK. The RealtimeKitClient.init() method accepts only authToken, baseURI, and defaults — no ICE server URLs, no TURN keys, no credentials. The RTK quickstart flow is: create meeting → add participant → get authToken → pass to SDK → done.
We write zero TURN code. No key provisioning, no credential generation, no ICE server distribution.
⚠️ AGENT BUILD WARNING
Do NOT implement any TURN key provisioning, credential generation, or ICE server configuration code. If you encounter Cloudflare documentation describing these steps, it is for a different product (the standalone TURN Service for raw WebRTC apps) — not RealtimeKit. RTK handles all TURN/ICE internally. We write zero TURN code.
TURN diagnostics (post-meeting investigation)
While we don't manage TURN, we can observe it via the Peer Report API. Each participant's report includes:
| Field | Location in peer report | What it tells us |
|---|---|---|
| `turn_connectivity` | `metadata.pc_metadata[]` | Whether the participant's network could reach TURN servers |
| `relay_connectivity` | `metadata.pc_metadata[]` | Whether relay (TURN) connections were available |
| `reflexive_connectivity` | `metadata.pc_metadata[]` | Whether STUN reflexive connections were available |
| `effective_network_type` | `metadata.pc_metadata[]` | Network type (4g, wifi, etc.) at connection time |
| `local_candidate_type` | `metadata.candidate_pairs.producing_transport[]` | `host` (direct), `srflx` (STUN), or `relay` (TURN) — which path was actually used |
| `remote_candidate_type` | `metadata.candidate_pairs.producing_transport[]` | The SFU's candidate type |
This data is available via GET /sessions/peer-report/{peer_id}?filters=precall_network_information and is surfaced in the Meeting Investigation page (Section 3.6).
| Feature | RTK API/Component | App Location | Config Level | Status |
|---|---|---|---|---|
| TURN relay | Managed by RTK internally via `authToken` — zero developer code | Transparent — no user-facing config | Platform | Build |
| NAT traversal (ICE) | RTK SDK handles candidate gathering, STUN/TURN fallback automatically | Transparent — no developer action | Platform | Build |
| Protocol support | STUN/UDP, TURN/UDP, TURN/TCP, TURN/TLS — all handled by RTK | Automatic fallback based on network conditions | Platform | Build |
| TURN diagnostics | Peer Report API: `turn_connectivity`, `candidate_pairs`, `effective_network_type` | Admin > Meeting Investigation page | Server | Build |
Section 8: Post-Meeting Experience
The post-meeting page is a first-class product surface — not an afterthought. Users land here after a meeting ends, from email links (triggered by meeting.ended webhook), and from the meeting history list.
8.1 Page Structure
Route: /meetings/{meetingId}/sessions/{sessionId}
Layout: Full-width page with tabbed content area and session metadata header.
+------------------------------------------------------------------+
| Session Header |
| Meeting Title | Date & Time | Duration | Participant Count |
| Host: Name | Type: Video/Voice/Webinar | Status: Ended |
+------------------------------------------------------------------+
| [Summary] [Transcript] [Recording] [Chat] [Analytics] |
+------------------------------------------------------------------+
| |
| (Active tab content) |
| |
+------------------------------------------------------------------+8.2 Session Metadata Header
Sourced from D1 (populated by meeting.ended webhook + REST API enrichment):
| Field | Source |
|---|---|
| Meeting title | REST API `meeting_display_name` field (stored in D1 sessions table on session start) |
| Date & time | Webhook `meeting.ended` timestamp + session start time |
| Duration | Calculated: end - start |
| Participant count | From webhook payload or REST API session detail |
| Host name | From our user system (mapped via participant userId) |
| Meeting type | From preset config (Video/Voice/Webinar) |
| Recording available | Boolean — from `recording.statusUpdate` `uploaded` event |
| Transcript available | Boolean — from `meeting.transcript` webhook received |
| Summary available | Boolean — from `meeting.summary` webhook received |
8.3 Summary Tab
Data source: meeting.summary webhook summaryDownloadUrl or GET /api/meetings/{meetingId}/summaries
Our API returns D1-cached data (populated by webhooks). If data is not yet cached, the Worker fetches from RTK REST API on demand.
Display:
- Rendered markdown (RTK returns structured markdown with sections: Key Discussion Points, Action Items, Decisions)
- Copy-to-clipboard button for full summary
- Download as
.mdbutton - If summary not yet generated and meeting has transcription data, show "Generate Summary" button (calls
POST /api/meetings/{meetingId}/summaries/generate) - If no transcription was enabled, show disabled state with explanation
Summary customization: RTK supports 9 summary types via ai_config.summarization.summary_type: general (default), team_meeting, sales_call, client_check_in, interview, daily_standup, one_on_one_meeting, lecture, code_review. This is set per-meeting at creation time. Word limit: 150-1000 (default 500). Format: markdown.
Retention notice: "Available for 7 days from meeting start" with countdown badge. Recording retention may differ — download URLs have separate expiry. To preserve recordings beyond RTK retention, configure custom cloud storage (Section 12).
8.4 Transcript Tab
Data source: meeting.transcript webhook transcriptDownloadUrl or GET /api/meetings/{meetingId}/transcripts
Our API returns D1-cached data (populated by webhooks). If data is not yet cached, the Worker fetches from RTK REST API on demand.
Display:
- Timeline-based transcript viewer with speaker labels and timestamps
- Each entry:
[HH:MM:SS] Speaker Name: "Transcript text" - Search within transcript (client-side filter)
- Jump to timestamp (if recording is available, clicking a timestamp seeks the recording player)
- Download as
.txtor.json - Partial transcript indicator: entries with
isPartialTranscript: trueare excluded from post-meeting view (only final transcripts shown)
Transcript JSON structure (stored in transcripts.transcript_json):
[
{
"start_ms": 12340,
"end_ms": 15670,
"participant_id": "rtk_part_abc123",
"speaker_name": "Jane Doe",
"text": "Hello everyone, let's get started."
}
]speaker_name is resolved during webhook processing by joining session_participants on rtk_participant_id. The raw RTK transcript contains participant IDs only.
Retention notice: Same 7-day-from-meeting-start window.
8.5 Recording Tab
Data source: recording.statusUpdate webhook (uploaded state) provides download URL via GET /api/meetings/{meetingId}/recordings.
Our API returns D1-cached data (populated by webhooks). If data is not yet cached, the Worker fetches from RTK REST API on demand.
Display:
- Video player (HTML5
<video>) for composite recording playback - Player controls: play/pause, seek, volume, playback speed (0.5x-2x), fullscreen
- If transcript is available: synchronized caption overlay (map transcript timestamps to video timeline)
- Download original recording button
- Recording metadata: codec (H.264/VP8), duration, file size, storage location
- If watermark was applied: watermark indicator
- If recording is still processing (
uploadingstate): show progress indicator - If recording errored (
erroredstate): show error state with explanation
Playback URL: Our API returns the RTK download_url — a direct MP4 URL playable by the HTML5 <video> tag. The URL has a time-limited expiry (download_url_expiry). If expired, the Worker re-fetches a fresh URL from the RTK REST API. For orgs with custom cloud storage, the URL points to their configured bucket.
Custom cloud storage: If org has configured external storage (AWS/Azure/DigitalOcean), recording URL points to their bucket. Display storage location label.
8.6 Chat Tab
Data source: meeting.chatSynced webhook triggers chat replay fetch via GET /api/meetings/{meetingId}/chat-exports.
Our API returns D1-cached data (populated by webhooks). If data is not yet cached, the Worker fetches from RTK REST API on demand.
Prerequisite: Meeting must have persist_chat: true set on the RTK meeting (configured at meeting creation). If not enabled, chat data is not retained and this tab shows "Chat was not enabled for this meeting."
Display:
- Full chat replay in chronological order
- Message rendering: text, markdown, files, images (reusing chat components where possible)
- Speaker attribution with participant names
- File/image attachments with download links
- Search within chat
- Download as CSV button (using RTK's CSV dump format: id, participant info, message content, metadata)
- Pinned messages highlighted at top
GET /api/meetings/{id}/chat-exports returns JSON (array of chat_messages rows) by default. Add ?format=csv for CSV download (RTK's CSV dump format).
8.7 Analytics Tab (Session-Level)
Data source: D1 database (custom-built, populated by webhooks — see Section 9).
Display:
- Participation timeline: Visual bar showing when each participant was in the session (join/leave events)
- Duration breakdown: Total session time, per-participant time, average engagement
- Feature usage: Recording duration, chat message count
- Quality indicators: Connection quality data available via Peer Report API — see Section 3.6 (Meeting Investigation) for diagnostics details
This tab is custom-built (RTK has no analytics API). Data is approximate, based on webhook events and periodic REST API polling during active sessions.
8.8 Access Control
- Org members: Can access any session from their org's meetings
- Guests: Cannot access post-meeting pages (redirected to sign-in)
- Hosts/Admins: See full analytics tab; members see summary/transcript/recording/chat only
- Expiry: After 7-day retention window, transcript/summary/recording links expire. D1 metadata and chat data persist indefinitely.
8.9 Notifications
Notification Channel Abstraction
Notifications use a channel-agnostic interface. New channels (push, SMS) and new providers (SendGrid, Twilio) are added by implementing the interface — no changes to calling code or notification triggers.
// Notification channel interface (Worker implementation)
interface NotificationChannel {
readonly type: 'email' | 'push' | 'sms' | 'webhook';
send(params: {
to: string; // email address, device token, phone number, or URL
subject?: string; // used by email
body: string; // plain text body
html?: string; // rich body (email, push)
metadata?: Record<string, string>; // channel-specific data
}): Promise<{ id: string; success: boolean }>;
}Resolution flow: enabled channels (org_settings.notification_channels) → for each channel, resolve provider → send via provider.
Channel & provider registry:
| Channel | Provider | Status | Secret Required |
|---|---|---|---|
| `email` | Resend | Built (default) | `RESEND_API_KEY` |
| `email` | SendGrid | Future | `SENDGRID_API_KEY` |
| `email` | SES | Future | AWS SES credentials |
| `push` | Web Push | Future | VAPID keys |
| `sms` | Twilio | Future | `TWILIO_*` credentials |
If a channel has no configured provider or its API key is missing, that channel is silently skipped. Post-meeting data remains accessible via the meeting history page regardless.
Post-Meeting Notification Flow
meeting.endedwebhook fires- Worker stores session metadata in D1
- Worker iterates enabled notification channels, sends to each participant with a user account (guests excluded — no contact info on file)
- Email content: meeting title, duration, summary snippet (first 200 chars if available), link to post-meeting page (
/meetings/{meetingId}/sessions/{sessionId}) - If transcript/summary/recording not yet ready (async processing), message says "Processing — check back shortly" with link
- Send failures are logged to
diagnostic_webhook_deliveriesand captured by Sentry. No retry — the post-meeting page is always accessible as fallback.
8.10 Tab States
Each tab handles five states based on data availability:
| State | Summary | Transcript | Recording | Chat |
|---|---|---|---|---|
| **Processing** | "Summary being generated..." | "Transcript processing..." | Upload progress indicator | "Chat data syncing..." |
| **Ready** | Rendered markdown | Timeline viewer | Video player | Message list |
| **Error** | "Summary generation failed" | "Transcript processing failed" | Error message with detail | "Chat export failed" |
| **Expired** | "Content expired (7-day retention)" | Same | Same | Chat persists in D1 (no expiry) |
| **Not enabled** | "Transcription was not enabled for this meeting" | Same | "Recording was not started" | "Chat persistence was not enabled" |
Loading state is shown until the first webhook for that data type is received. The post-meeting page polls our API every 30 seconds while any tab is in "Processing" state.
Section 9: Webhook & Analytics Pipeline
9.1 Webhook Architecture
Endpoint: POST /api/webhooks/rtk — a route on our Cloudflare Worker
Registration: On app setup, Worker calls POST /webhooks REST API with name (required), url, and events array to register for all supported events.
Security: Webhook payloads should be validated (signature verification if RTK provides it; if not, validate by cross-referencing event data with REST API).
9.2 Webhook Event Catalog
Every webhook event RTK sends, what we do with each:
| Webhook Event | Payload (key fields) | Our Action | D1 Table |
|---|---|---|---|
| `meeting.started` | `meetingId`, `sessionId`, timestamp | Create session record in D1 with exact start time. Update KV meeting-state. | `sessions` |
| `meeting.ended` | `meetingId`, `sessionId`, timestamp | 1. Store session end record in D1. 2. Trigger post-meeting data aggregation (fetch participant list, duration from REST API). 3. Queue email notifications to participants. 4. Mark session as ENDED in D1. | `sessions` |
| `meeting.participantJoined` | `meetingId`, `sessionId`, `participantId` | Update real-time participant count, update `peak_participant_count` if new high. | `sessions`, `session_participants` |
| `meeting.participantLeft` | `meetingId`, `sessionId`, `participantId` | Update participant record with leave time, compute individual duration. Decrement count. | `sessions`, `session_participants` |
| `recording.statusUpdate` | `meetingId`, `sessionId`, `recordingId`, `status` (INVOKED/RECORDING/UPLOADING/UPLOADED/ERRORED) | 1. Upsert recording status in D1. 2. On UPLOADED: fetch recording details (download URL, duration, size) from REST API, store in D1. 3. On ERRORED: log to Sentry, mark recording as failed. | `recordings` |
| `meeting.transcript` | `meetingId`, `sessionId`, `transcriptDownloadUrl`, `transcriptDownloadUrlExpiry` | 1. Store transcript URL and expiry in D1. 2. Download transcript JSON, store parsed version in D1 for search/display. 3. Update session record: `has_transcript = true`. | `transcripts`, `sessions` |
| `meeting.summary` | `meetingId`, `sessionId`, `summaryDownloadUrl`, `summaryDownloadUrlExpiry` | 1. Store summary URL and expiry in D1. 2. Download summary markdown, store in D1 for display. 3. Update session record: `has_summary = true`. | `summaries`, `sessions` |
| `meeting.chatSynced` | `meetingId`, `sessionId` | 1. Fetch chat replay via REST API. 2. Store chat messages in D1 (persists beyond 7-day window). 3. Update session record: `has_chat = true`. | `chat_messages`, `sessions` |
| `livestreaming.statusUpdate` | `meetingId`, `livestreamId`, `status` | Update livestream state in D1. On completion, store playback URL. | `livestreams` |
Field casing: RTK webhooks deliver camelCase field names (e.g.,
meetingId,sessionId) and UPPERCASE status enums (e.g.,UPLOADED). The webhook handler transforms these before D1 insert: camelCase → snake_case column names, UPPERCASE → lowercase status values.
9.3 Inferred Events (REST API Polling)
With meeting.participantJoined/Left webhooks, real-time participant tracking is handled. Polling supplements for reliability and catches edge cases:
| Data Point | Method | Frequency | D1 Table |
|---|---|---|---|
| Active sessions list | `GET /sessions` (filter status=LIVE) | Every 60s via Cron Trigger | `sessions` |
| Participant count per active session | REST API session detail | Every 60s during active sessions | `session_snapshots` |
| Recording status (backup) | `GET /recordings/active-recording/{meeting_id}` | Every 30s during active recording (per-meeting) | `recordings` |
| Meeting metadata changes | `GET /meetings/{id}` | On-demand when needed | `meetings` |
Cron Trigger: A scheduled Worker runs every 60 seconds to poll active sessions and update D1. This supplements webhook data for analytics accuracy.
9.4 D1 Analytics Schema
-- Core entities — see canonical schemas in their respective sections
-- Organizations table — see Section 4.2 for canonical schema
-- Key columns: id (nanoid), name, slug, rtk_app_id, owner_id, settings_json, guest_access_default
-- Users table — see Section 4.2 for canonical schema
-- Key columns: id (nanoid), org_id, email, name, password_hash (nullable), role (owner|admin|host|member), is_active, avatar_url
-- RTK App ID is stored in org_settings.rtk_app_id (single app per deployment)
-- Meetings table — see Section 6.2 for canonical schema
-- Key columns: id (nanoid), org_id, title, slug, meeting_type, status (draft|scheduled|active|ended|cancelled), created_by, scheduling_type
-- Session tracking (populated by webhooks + REST API)
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
meeting_id TEXT NOT NULL REFERENCES meetings(id),
org_id TEXT NOT NULL REFERENCES organizations(id),
started_at TEXT,
ended_at TEXT,
duration_seconds INTEGER, -- calculated on end
participant_count INTEGER DEFAULT 0,
peak_participant_count INTEGER DEFAULT 0,
has_recording INTEGER NOT NULL DEFAULT 0,
has_transcript INTEGER NOT NULL DEFAULT 0,
has_summary INTEGER NOT NULL DEFAULT 0,
has_chat INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL CHECK (status IN ('active', 'ended')) DEFAULT 'active',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_sessions_meeting ON sessions(meeting_id);
CREATE INDEX idx_sessions_org ON sessions(org_id);
CREATE INDEX idx_sessions_status ON sessions(status);
CREATE INDEX idx_sessions_started ON sessions(started_at);
-- Session participant snapshots (from polling during active sessions)
CREATE TABLE session_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
participant_count INTEGER NOT NULL,
recorded_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_snapshots_session ON session_snapshots(session_id);
-- Recording metadata (from recording.statusUpdate webhook)
CREATE TABLE recordings (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL REFERENCES sessions(id),
meeting_id TEXT NOT NULL REFERENCES meetings(id),
org_id TEXT NOT NULL REFERENCES organizations(id),
status TEXT NOT NULL CHECK (status IN ('invoked', 'recording', 'paused', 'uploading', 'uploaded', 'errored')),
download_url TEXT,
download_url_expiry TEXT,
duration_seconds INTEGER,
file_size_bytes INTEGER,
codec TEXT, -- 'h264' or 'vp8'
storage_location TEXT, -- 'default' (RTK/R2) | 'aws' | 'azure' | 'digitalocean' | 'gcs' | 'sftp'
has_watermark INTEGER NOT NULL DEFAULT 0,
started_at TEXT,
ended_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_recordings_session ON recordings(session_id);
CREATE INDEX idx_recordings_org ON recordings(org_id);
CREATE INDEX idx_recordings_status ON recordings(status);
-- Transcript metadata (from meeting.transcript webhook)
CREATE TABLE transcripts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
meeting_id TEXT NOT NULL REFERENCES meetings(id),
org_id TEXT NOT NULL REFERENCES organizations(id),
download_url TEXT NOT NULL,
download_url_expiry TEXT NOT NULL,
transcript_json TEXT, -- full parsed transcript for in-app display
language TEXT DEFAULT 'en-US',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_transcripts_session ON transcripts(session_id);
-- Summary metadata (from meeting.summary webhook)
CREATE TABLE summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL REFERENCES sessions(id),
meeting_id TEXT NOT NULL REFERENCES meetings(id),
org_id TEXT NOT NULL REFERENCES organizations(id),
download_url TEXT NOT NULL,
download_url_expiry TEXT NOT NULL,
summary_markdown TEXT, -- full summary content for in-app display
summary_type TEXT, -- e.g. 'team_meeting'
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_summaries_session ON summaries(session_id);
-- Chat messages (from meeting.chatSynced webhook + CSV download)
CREATE TABLE chat_messages (
id TEXT PRIMARY KEY, -- RTK chat message ID
session_id TEXT NOT NULL REFERENCES sessions(id),
meeting_id TEXT NOT NULL REFERENCES meetings(id),
org_id TEXT NOT NULL REFERENCES organizations(id),
participant_id TEXT, -- RTK participantId from CSV
sender_name TEXT, -- displayName from CSV
message_type TEXT NOT NULL CHECK (message_type IN ('text', 'image', 'file')),
-- CSV payloadType mapping: TEXT_MESSAGE→'text', IMAGE_MESSAGE→'image', FILE_MESSAGE→'file'
content TEXT, -- payload from CSV
is_pinned INTEGER NOT NULL DEFAULT 0, -- pinned from CSV
is_edited INTEGER NOT NULL DEFAULT 0, -- isEdited from CSV
is_private INTEGER NOT NULL DEFAULT 0, -- private messages (not in CSV, set by our logic if needed)
recipient_user_id TEXT, -- for private messages
sent_at TEXT NOT NULL, -- createdAt from CSV
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_chat_session ON chat_messages(session_id);
CREATE INDEX idx_chat_pinned ON chat_messages(session_id, is_pinned) WHERE is_pinned = 1;
-- Webhook event log (all raw webhook payloads for debugging/replay)
CREATE TABLE webhook_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
meeting_id TEXT,
session_id TEXT,
payload_json TEXT NOT NULL,
processed INTEGER NOT NULL DEFAULT 0,
error_message TEXT,
retry_count INTEGER NOT NULL DEFAULT 0,
received_at TEXT NOT NULL DEFAULT (datetime('now')),
processed_at TEXT
);
CREATE INDEX idx_webhook_type ON webhook_events(event_type);
CREATE INDEX idx_webhook_unprocessed ON webhook_events(processed) WHERE processed = 0;
-- Aggregate analytics (computed daily by Cron Trigger)
CREATE TABLE daily_analytics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
org_id TEXT NOT NULL REFERENCES organizations(id),
date TEXT NOT NULL, -- YYYY-MM-DD
total_sessions INTEGER NOT NULL DEFAULT 0,
total_participants INTEGER NOT NULL DEFAULT 0,
total_duration_minutes INTEGER NOT NULL DEFAULT 0,
total_recordings INTEGER NOT NULL DEFAULT 0,
total_recording_minutes INTEGER NOT NULL DEFAULT 0,
total_chat_messages INTEGER NOT NULL DEFAULT 0,
total_transcripts INTEGER NOT NULL DEFAULT 0,
total_summaries INTEGER NOT NULL DEFAULT 0,
meeting_type_video INTEGER NOT NULL DEFAULT 0,
meeting_type_audio INTEGER NOT NULL DEFAULT 0,
meeting_type_webinar INTEGER NOT NULL DEFAULT 0,
meeting_type_livestream INTEGER NOT NULL DEFAULT 0,
computed_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE(org_id, date)
);
CREATE INDEX idx_daily_org_date ON daily_analytics(org_id, date);
-- Livestream tracking (from livestreaming.statusUpdate webhook)
CREATE TABLE livestreams (
id TEXT PRIMARY KEY, -- RTK livestream ID
session_id TEXT NOT NULL REFERENCES sessions(id),
meeting_id TEXT NOT NULL REFERENCES meetings(id),
org_id TEXT NOT NULL REFERENCES organizations(id),
status TEXT NOT NULL CHECK (status IN ('started', 'stopped', 'errored')),
rtmp_url TEXT,
playback_url TEXT,
started_at TEXT,
ended_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_livestreams_session ON livestreams(session_id);
CREATE INDEX idx_livestreams_meeting ON livestreams(meeting_id);9.5 Webhook Processing Pipeline
Incoming POST /api/webhooks/rtk
|
v
1. Validate payload (signature check or cross-reference)
2. Store raw event in webhook_events table (payload_json)
3. Route by event_type:
|
+-- meeting.started ----------> createSession()
+-- meeting.ended ------------> processSessionEnd()
+-- meeting.participantJoined -> updateParticipantJoined()
+-- meeting.participantLeft --> updateParticipantLeft()
+-- recording.statusUpdate ---> processRecordingUpdate()
+-- meeting.transcript -------> processTranscript()
+-- meeting.summary ----------> processSummary()
+-- meeting.chatSynced -------> processChatSync()
+-- livestreaming.statusUpdate > updateLivestreamState()
|
4. Mark webhook_events.processed = 1
5. On error: set error_message, keep processed = 0 for retry9.6 Retry & Reliability
- Deduplication: Two deduplication layers protect against duplicate processing: (1) KV-based event ID dedup with 24h TTL rejects duplicate webhook deliveries at ingestion (see Section 6.8); (2) D1 upsert patterns keyed by RTK IDs ensure writes are idempotent even if a duplicate slips through.
- Dead letter: The every-minute Cron Trigger (
* * * * *) checks for unprocessed events (processed = 0 AND retry_count < 3). Failed events are retried up to 3 times before being marked as dead letter (retry_count >= 3). - Monitoring: Sentry alert on webhook processing failures. Dashboard shows unprocessed event count.
- Raw event storage: All webhook payloads stored permanently in
webhook_eventsfor debugging and replay.
9.7 Analytics Computation
Real-time (per-event):
- Session count, participant count, recording status — updated immediately on webhook receipt
Periodic (Cron Trigger, daily at midnight UTC via 0 0 * * *):
- Aggregate
daily_analyticsrows from sessions, recordings, chat_messages, transcripts, summaries - Roll up per-org daily totals for dashboard display
Dashboard queries (examples):
- Sessions this week:
SELECT COUNT(*) FROM sessions WHERE org_id = ? AND started_at >= date('now', '-7 days') - Average session duration:
SELECT AVG(duration_seconds) FROM sessions WHERE org_id = ? AND ended_at IS NOT NULL - Recording usage:
SELECT SUM(duration_seconds) FROM recordings WHERE org_id = ? AND status = 'uploaded' - Feature adoption:
SELECT has_transcript, has_summary, has_recording, COUNT(*) FROM sessions WHERE org_id = ? GROUP BY 1, 2, 3
9.8 Webhook Field Mapping
RTK webhooks use camelCase. Our D1 uses snake_case. The webhook handler applies these transformations:
| Webhook Field | D1 Column | Transform |
|---|---|---|
| `meetingId` | `meeting_id` | camelCase → snake_case |
| `sessionId` | `session_id` | camelCase → snake_case |
| `participantId` | `participant_id` | camelCase → snake_case |
| `recordingId` | `id` (recordings table PK) | Direct use as ID |
| `status` (UPPERCASE) | `status` (lowercase) | `.toLowerCase()` |
| `transcriptDownloadUrl` | `download_url` | Rename |
| `summaryDownloadUrl` | `download_url` | Rename |
| `transcriptDownloadUrlExpiry` | `download_url_expiry` | Rename |
| `summaryDownloadUrlExpiry` | `download_url_expiry` | Rename |
Chat CSV mapping (from meeting.chatSynced → CSV download → D1 insert):
| CSV Column | D1 Column | Transform |
|---|---|---|
| `participantId` | `participant_id` | Direct |
| `displayName` | `sender_name` | Rename |
| `payloadType` | `message_type` | TEXT_MESSAGE→'text', IMAGE_MESSAGE→'image', FILE_MESSAGE→'file' |
| `payload` | `content` | Rename |
| `pinned` | `is_pinned` | Boolean → INTEGER |
| `isEdited` | `is_edited` | Boolean → INTEGER |
| `createdAt` | `sent_at` | Rename |
Design Document: Sections 10-13
Section 10: App Pages & Navigation
10.1 Page Inventory
The app has a flat navigation structure with role-gated access. Every page listed below is a discrete route.
Route naming conventions:
- Page routes use plural
/meetings/consistently (e.g.,/meetings/:meetingId/setup) /m/:slugis the short vanity URL for meeting links (resolves slug → meetingId, redirects to pre-join)- API routes use
/api/meetings/(see Section 2.2) - All non-API routes are served by the Worker returning the SPA's
index.htmlfor client-side routing (see Section 10.6)
Public Pages (no auth required)
| Page | Route | Purpose | Key Elements |
|---|---|---|---|
| **Login** | `/login` | Email/password login | Login form, "forgot password" link, org logo. OAuth is a **FUTURE FEATURE** (will use org's own OAuth credentials, not vendor's — see Section 13) |
| **Register** | `/register` | Account creation (invite-only or open, per org setting) | Registration form, invite code field (if required via `invite_only_registration` org setting — see Section 12.3), terms checkbox |
| **Forgot Password** | `/forgot-password` | Password reset request | Email input, submit, confirmation message |
| **Reset Password** | `/reset-password/:token` | Set new password | New password + confirm fields |
| **Meeting Link** | `/m/:slug` | Vanity/short URL for meeting access | Resolves slug to meetingId via `GET /api/meetings/resolve?slug=:slug`. Authenticated users → redirect to `/meetings/:meetingId/setup`. Unauthenticated users → if guest access enabled, show guest join form; else redirect to `/login?redirect=/m/:slug`. See Section 6.5 for full join flow. |
| **Guest Join** | `/meetings/:meetingId/guest` | Guest entry form (reached via `/m/:slug` redirect) | Display name input (2-50 chars), optional email, "Join" button. On submit: `POST /api/meetings/:meetingId/join` with `{ displayName, email }` → receive `authToken` → redirect to `/meetings/:meetingId/setup` for device preview. See Section 4.3 for guest auth flow. |
| **Invite Landing** | `/register?code=:inviteCode` | Pre-filled registration for invited users | Same as Register page but invite code field pre-filled from URL query param. Email may also be pre-filled if the invite was sent to a specific address. |
| **Livestream Viewer** | `/live/:meetingId` | HLS livestream viewer (no RTK SDK needed) | Lightweight page with HLS.js video player. No auth required. Only active when meeting has livestream enabled and is currently streaming. Shows "Stream not available" otherwise. |
| **Not Found** | `*` (catch-all) | 404 page | "Page not found" message, link to Dashboard (or Login if unauthenticated) |
Authenticated Pages (any role)
| Page | Route | Purpose | Key Elements |
|---|---|---|---|
| **Dashboard** | `/` | Home page — upcoming meetings, quick actions | Meeting list (upcoming/recent), pending invitations with RSVP buttons, "New Meeting" button (Host+ only), join-by-code input (see Section 10.7) |
| **Pre-Join** | `/meetings/:meetingId/setup` | Device check before joining | `rtk-setup-screen` — camera preview, mic selector, speaker test, display name. Always shown before entering a meeting (even from direct links). |
| **Meeting Room** | `/meetings/:meetingId/live` | Live meeting experience (RTK UI Kit) | Full `rtk-meeting` component or custom layout with RTK components. Only accessible after passing through pre-join. |
| **Post-Meeting** | `/meetings/:meetingId/sessions/:sessionId` | After-meeting experience (session-specific) | Recording player, transcript viewer, AI summary, chat export, attendee list. Route matches Section 8.1. Client obtains `sessionId` from RTK SDK `meeting.ended` event or from the sessions list API. Guests cannot access this page (redirect to `/login`). |
| **Recordings** | `/recordings` | Browse all recordings user has access to | Recording list with search/filter, thumbnail, duration, date, download link |
| **Recording Player** | `/recordings/:recordingId` | Playback a specific recording | Video player, transcript sidebar (synced), AI summary tab, download button |
| **Profile** | `/profile` | User's own profile settings | Display name, avatar upload, email, password change, notification prefs |
Host/Admin Pages (Host, Admin, Owner)
| Page | Route | Purpose | Key Elements |
|---|---|---|---|
| **Schedule Meeting** | `/meetings/new` | Create instant or scheduled meeting | Meeting type selector (instant/scheduled/recurring/permanent), title, date/time picker, preset selector, recording toggle, waiting room toggle, invite list |
| **Meeting Settings** | `/meetings/:meetingId/settings` | Edit meeting config before/between sessions | Same fields as create, plus: delete meeting, regenerate link, view past sessions |
| **Meeting History** | `/meetings/:meetingId/history` | Past sessions for a specific meeting room | Session list with start/end times, participant count, recordings, transcripts. Clicking a session → `/meetings/:meetingId/sessions/:sessionId` |
Admin Pages (Admin/Owner only)
| Page | Route | Purpose | Key Elements |
|---|---|---|---|
| **Org Settings** | `/admin/settings` | Organization-wide configuration | Tabbed interface (see Section 12 for full schema) |
| **Members** | `/admin/members` | User management | Member list, role assignment (Owner/Admin/Host/Member), invite button, remove/deactivate |
| **Invite** | `/admin/invite` | Send invitations | Email input (single or bulk CSV), role selector, custom message, pending invites list |
| **Analytics** | `/admin/analytics` | Org-wide usage dashboard (Admin/Owner) | Session count, participant count, total duration, recording stats, charts (daily/weekly/monthly). Note: Hosts can view analytics for their own meetings via a "My Stats" section on the Dashboard (see Section 4.5 RBAC: "View analytics: Host (own)"). |
| **Presets** | `/admin/presets` | Manage RTK presets | List of presets with preview of permissions, create/edit/clone/delete, JSON editor for advanced users |
| **Branding** | `/admin/branding` | Visual customization | Theme picker, color inputs, logo upload, font selector, live preview panel |
| **Recording Storage** | `/admin/storage` | Configure recording destination | Storage provider selector (RTK default, AWS S3, Azure Blob, DigitalOcean Spaces), credentials form, test connection button |
| **Diagnostics** | `/admin/diagnostics` | System health & debugging (see Section 3.10) | 6 panels: System Health, Webhook Deliveries, Active Meetings, Meeting Investigation, Sentry Link, Settings |
10.2 Navigation Structure
Top Nav Bar (persistent, all authenticated pages):
+------------------------------------------------------------------+
| [Org Logo] Dashboard Recordings | [Profile Avatar] [Logout] |
+------------------------------------------------------------------+
| |
| (Admin/Owner only) |
+-- Admin dropdown: +-- Profile
Settings | Members | Analytics | Change password
Presets | Branding | Storage | Notification prefs
DiagnosticsRules:
- Top nav is hidden during active meeting (meeting room is full-screen)
- Admin dropdown only visible to Admin/Owner roles
- "Schedule Meeting" is a button on Dashboard, not a nav item (visible to Host+ only)
- Mobile: top nav collapses to hamburger menu (breakpoints: Desktop ≥1024px, Tablet 768-1023px, Mobile <768px)
- Post-meeting page shows a "Back to Dashboard" link prominently
- Meeting room uses RTK UI Kit's built-in responsive layout on mobile — no custom mobile layout needed
10.3 Meeting Room Layout
The meeting room (/meetings/:meetingId/live) is the core experience. It uses RTK UI Kit components composed into a standard layout:
+------------------------------------------------------------------+
| rtk-header: [Logo] [Meeting Title] [Clock] [Recording Indicator] |
+------------------------------------------------------------------+
| | |
| | rtk-sidebar: |
| rtk-grid (or rtk-spotlight-grid | - rtk-chat |
| or rtk-mixed-grid) | - rtk-participants |
| | - rtk-polls |
| Contains: rtk-participant-tile | - rtk-ai |
| rtk-screenshare-view | - rtk-plugins |
| rtk-name-tag | - rtk-breakout- |
| rtk-audio-visualizer | rooms-manager |
| | |
+------------------------------------------------------------------+
| rtk-controlbar: |
| [Mic] [Camera] [Screenshare] [Recording] [More...] [Leave] |
| Sidebar toggles: [Chat] [Participants] [Polls] [AI] [Plugins] |
+------------------------------------------------------------------+Component verification: All 20 RTK components above verified against
docs/llms-full.txt. Additional companion components available:rtk-polls-toggle,rtk-plugins-toggle,rtk-controlbar-button. Note:rtk-chatprivate chat control is via preset config (not the removeddisablePrivateChatprop — see SDK 1.2.4 changelog).
Meeting lifecycle states mapped to RTK components:
- Not connected →
rtk-idle-screen(redirects to pre-join) - Pre-join →
rtk-setup-screen(device selection + preview) - Waiting room →
rtk-waiting-screen(if waiting room enabled) - In meeting → Full layout above
- Meeting ended →
rtk-ended-screen→ auto-redirect to post-meeting page
10.4 Page Transitions
| From | To | Trigger | Notes |
|---|---|---|---|
| Any public page | Dashboard | Successful login | Redirects to `?redirect` param if present, else `/` |
| Dashboard | Pre-Join | Click meeting link or "Join" button | Always goes to pre-join first, never directly to meeting room |
| `/m/:slug` | Pre-Join | Authenticated user clicks meeting link | Slug resolved → redirect to `/meetings/:meetingId/setup` |
| `/m/:slug` | Guest Join | Unauthenticated user + guest access enabled | Shows guest form at `/meetings/:meetingId/guest` |
| `/m/:slug` | Login | Unauthenticated user + guest access disabled | Redirect to `/login?redirect=/m/:slug` |
| Guest Join | Pre-Join | Guest submits name → gets authToken | Redirect to `/meetings/:meetingId/setup` |
| Pre-Join | Meeting Room | Click "Join Meeting" after device setup | Navigates to `/meetings/:meetingId/live` |
| Pre-Join | Waiting Room | Auto, if meeting has waiting room enabled | Stays on same route, RTK shows `rtk-waiting-screen` |
| Waiting Room | Meeting Room | Host admits participant | RTK handles transition automatically |
| Meeting Room | Post-Meeting | Meeting ends or user leaves | Redirect to `/meetings/:meetingId/sessions/:sessionId`. `sessionId` obtained from RTK SDK `meeting.ended` event. |
| Meeting Room | Dashboard | Guest leaves meeting | Guests cannot access post-meeting page |
| Post-Meeting | Dashboard | Click "Back to Dashboard" | |
| Post-Meeting | Recording Player | Click "View Recording" (when available) | |
| Dashboard | Schedule Meeting | Click "New Meeting" | Host+ only |
10.5 Route Guards & Redirect Rules
// Route guard logic (pseudocode for React Router loaders/wrappers)
// 1. Auth guard — protects all authenticated routes
if (!isAuthenticated && route.requiresAuth) {
redirect(`/login?redirect=${encodeURIComponent(currentPath)}`);
}
// 2. Already authenticated — redirect away from public auth pages
if (isAuthenticated && route.isAuthPage) { // /login, /register, /forgot-password
redirect('/');
}
// 3. Role guard — protects admin and host routes
if (route.requiredRole === 'admin' && user.role !== 'owner' && user.role !== 'admin') {
redirect('/'); // silent redirect to dashboard (no 403 page needed for v1)
}
if (route.requiredRole === 'host' && user.role === 'member') {
redirect('/');
}
// 4. Meeting room guard — must go through pre-join
if (route === '/meetings/:meetingId/live' && !hasCompletedPreJoin) {
redirect(`/meetings/${meetingId}/setup`);
}
// 5. Post-login redirect
onLoginSuccess(() => {
const redirectTo = searchParams.get('redirect') || '/';
navigate(redirectTo);
});10.6 React Router Configuration
// Route tree — layout routes group pages that share UI chrome
const router = createBrowserRouter([
// Public routes (no auth required)
{
element: <PublicLayout />, // minimal: org logo, centered content
children: [
{ path: '/login', element: <LoginPage /> },
{ path: '/register', element: <RegisterPage /> },
{ path: '/forgot-password', element: <ForgotPasswordPage /> },
{ path: '/reset-password/:token', element: <ResetPasswordPage /> },
],
},
// Meeting access routes (auth-optional, depend on guest settings)
{ path: '/m/:slug', element: <MeetingLinkResolver /> },
{ path: '/meetings/:meetingId/guest', element: <GuestJoinPage /> },
{ path: '/live/:meetingId', element: <LivestreamViewerPage /> },
// Authenticated routes (any role)
{
element: <AuthLayout />, // top nav bar, auth guard
errorElement: <ErrorBoundary />, // Sentry ErrorBoundary (Section 3)
children: [
{ path: '/', element: <DashboardPage /> },
{ path: '/profile', element: <ProfilePage /> },
{ path: '/recordings', element: <RecordingsPage /> },
{ path: '/recordings/:recordingId', element: <RecordingPlayerPage /> },
{ path: '/meetings/new', element: <ScheduleMeetingPage /> }, // Host+ guard inside
{ path: '/meetings/:meetingId/setup', element: <PreJoinPage /> },
{ path: '/meetings/:meetingId/settings', element: <MeetingSettingsPage /> }, // Host+ guard
{ path: '/meetings/:meetingId/history', element: <MeetingHistoryPage /> }, // Host+ guard
{ path: '/meetings/:meetingId/sessions/:sessionId', element: <PostMeetingPage /> },
],
},
// Meeting room (full-screen, no nav bar)
{
element: <MeetingLayout />, // auth guard, no top nav
children: [
{ path: '/meetings/:meetingId/live', element: <MeetingRoomPage /> },
],
},
// Admin routes (Admin/Owner guard)
{
element: <AdminLayout />, // auth guard + admin role guard, admin nav
children: [
{ path: '/admin/settings', element: <OrgSettingsPage /> },
{ path: '/admin/members', element: <MembersPage /> },
{ path: '/admin/invite', element: <InvitePage /> },
{ path: '/admin/analytics', element: <AnalyticsPage /> },
{ path: '/admin/presets', element: <PresetsPage /> },
{ path: '/admin/branding', element: <BrandingPage /> },
{ path: '/admin/storage', element: <RecordingStoragePage /> },
{ path: '/admin/diagnostics', element: <DiagnosticsPage /> },
],
},
// Catch-all 404
{ path: '*', element: <NotFoundPage /> },
]);Worker SPA catch-all: The Cloudflare Worker MUST return index.html for all routes that do not match /api/* or /webhooks/*. This enables client-side routing:
// In Worker fetch handler (simplified)
if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/webhooks/')) {
return handleApiRoute(request);
}
// All other paths → serve SPA
return serveStaticAsset(request, 'index.html');10.7 Join-by-Code Flow
The Dashboard includes a "join-by-code" input field. Meeting codes work as follows:
- What is a meeting code? The meeting
slugfield (see Section 6.2). Generated at meeting creation: 10-char alphanumeric for instant/scheduled, or a custom vanity slug for permanent rooms. - Resolution: User enters code → client calls
GET /api/meetings/resolve?slug={code}→ returns{ meetingId, title, status }or 404. - Navigation: On success → redirect to
/meetings/:meetingId/setup(pre-join). On 404 → show inline error "Meeting not found". - Sharing format: Meeting links are
https://{org-domain}/m/{slug}. The code displayed to users is just the slug portion.
10.8 Deep Link Behavior
| Route | Direct Access Behavior |
|---|---|
| `/meetings/:meetingId/setup` | Works if meeting exists and is accessible to user. If meeting ended/not found → redirect to Dashboard with toast message. |
| `/meetings/:meetingId/live` | Redirects to `/meetings/:meetingId/setup` (must go through pre-join). |
| `/meetings/:meetingId/sessions/:sessionId` | Works if user attended that session. If expired (7-day retention) → show "Session data expired" message. |
| `/recordings/:recordingId` | Works if user has access. If deleted → 404. |
| `/m/:slug` | Always works — resolves slug and follows normal join flow. |
| `/admin/*` | Redirects to `/` if user lacks Admin/Owner role. |
10.9 Error & Loading States
Error boundary: Wrap the entire app in Sentry ErrorBoundary (Section 3.1). Show a generic "Something went wrong" page with "Reload" button and Sentry event ID for support.
Loading states:
- Initial app load: Full-page spinner with org logo (if cached) while auth check completes
- Page navigation: Skeleton loaders matching page layout (dashboard cards, table rows, etc.)
- Actions (join, create, etc.): Inline spinner on the button, button disabled during request
- Meeting room: RTK UI Kit handles its own loading states internally
Section 11: Branding & Theming
11.1 RTK Design Token System
All visual customization flows through provideRtkDesignSystem(), which generates CSS custom properties (prefix: --rtk-*). The org admin configures these values; they are passed to rtk-ui-provider or rtk-meeting at runtime.
Available Tokens
| Category | Token | Type | Default | Notes |
|---|---|---|---|---|
| **Theme** | `theme` | `'light' | 'dark' | 'darkest'` | `'dark'` | Sets background shade palette globally |
| **Brand Color** | `colors.brand.500` | hex color | RTK default blue | Primary accent. RTK auto-generates 300-700 shades |
| **Background** | `colors.background.1000` | hex color | Theme-dependent | Base background. RTK auto-generates 600-900 shades |
| **Text** | `colors.text` | hex color | `#FFFFFF` (dark) | Primary text. Lighter shades derived automatically |
| **Text on Primary** | `colors["text-on-primary"]` | hex color | `#FFFFFF` | Text on brand-colored surfaces. Note: RTK docs prose uses `text-on-brand` but code examples use `text-on-primary` — we follow the code example. Verify at runtime. |
| **Video BG** | `colors["video-bg"]` | hex color | `#1A1A1A` | Background of video tiles when camera off |
| **Font** | `fontFamily` | CSS font-family string | System default | Or use `googleFont` for Google Fonts auto-loading |
| **Spacing** | `spacingBase` | number (px) | `4` | Base unit; scale 0-96 auto-generated |
| **Borders** | `borderWidth` | `'none' | 'thin' | 'fat'` | `'thin'` | Component border thickness |
| **Corners** | `borderRadius` | `'sharp' | 'rounded' | 'extra-rounded' | 'circular'` | `'rounded'` | Corner rounding style |
Application Code
// At app initialization, before rendering any RTK components
import { provideRtkDesignSystem } from '@cloudflare/realtimekit-react-ui';
provideRtkDesignSystem({
theme: orgSettings.theme, // from org_settings table
colors: {
brand: { 500: orgSettings.brandColor },
background: { 1000: orgSettings.backgroundColor },
text: orgSettings.textColor,
"text-on-primary": orgSettings.textOnPrimaryColor,
"video-bg": orgSettings.videoBgColor,
},
fontFamily: orgSettings.fontFamily,
googleFont: orgSettings.googleFont, // mutually exclusive with fontFamily
borderWidth: orgSettings.borderWidth,
borderRadius: orgSettings.borderRadius,
});11.2 Logo Configuration
| Setting | Storage | Usage |
|---|---|---|
| `logoUrl` | R2 bucket (uploaded via admin UI) | Passed to `rtk-logo` component; shown in meeting header and idle/ended screens |
| `faviconUrl` | R2 bucket | Set as page favicon via `` |
| `logoHeight` | org_settings (number, px) | CSS constraint on logo display height |
Upload flow: Admin uploads image → Worker stores in R2 → returns public URL → saved to org_settings → served via R2 public access or signed URL.
11.3 Icon Pack Customization
RTK supports full SVG icon replacement via a JSON icon pack object (40+ icons). The icon editor at icons.realtime.cloudflare.com generates the JSON.
| Setting | Type | Storage |
|---|---|---|
| `customIconPack` | JSON blob | org_settings (TEXT column, JSON-serialized) |
Passed to rtk-meeting or rtk-ui-provider via the iconPack prop.
11.4 Recording Watermark
RTK supports watermarking on composite recordings natively. Configuration is per-preset (passed at recording start time).
| Setting | Type | Scope | Notes |
|---|---|---|---|
| `watermark.enabled` | boolean | org-wide | Whether to apply watermark |
| `watermark.url` | string (URL) | org-wide | Watermark image (typically org logo), stored in R2 |
| `watermark.position` | enum: `left top`, `right top`, `left bottom`, `right bottom` | org-wide | Position on the recording frame |
| `watermark.size` | object: `{ height: number, width: number }` (px) | org-wide | Size of the watermark overlay |
Applied when starting a recording via the RTK Recording SDK / REST API parameters.
11.5 Internationalization
| Setting | Type | Scope |
|---|---|---|
| `defaultLocale` | string (BCP 47) | org-wide |
| `customStrings` | JSON blob | org-wide |
RTK components accept an RtkI18n object via the t prop. The app merges org custom strings over RTK defaults and passes them through rtk-ui-provider.
Section 12: Organization Configuration
12.1 Configuration Hierarchy
Settings apply at three levels with clear override rules:
Org-Wide Defaults (org_settings table)
└── Per-Meeting Overrides (meetings table columns)
└── Runtime Overrides (RTK preset / SDK calls during session)Rules:
- Org-wide defaults apply to all new meetings unless explicitly overridden
- Per-meeting overrides are set at meeting creation/edit time by the host
- Runtime overrides happen during a live session (e.g., host mutes all, starts recording)
- Lower levels can only override settings that the org allows overriding (some are locked at org level)
12.2 What Lives Where
| Setting | Org-Wide | Per-Meeting | Runtime | Notes |
|---|---|---|---|---|
| Branding (theme, colors, logo, fonts) | Yes (primary) | No | No | Org-wide only |
| Icon pack | Yes (primary) | No | No | Org-wide only |
| Default meeting type | Yes | Yes (override) | No | video, audio, webinar, livestream |
| Waiting room enabled | Yes (default) | Yes (override) | No | Host can toggle per meeting |
| Recording auto-start | Yes (default) | Yes (override) | Yes (start/stop) | Subject to `recording_policy`: ignored when policy=`never`, forced when policy=`always` |
| Recording storage provider | Yes (primary) | No | No | Org-wide only |
| Watermark config | Yes (primary) | No | No | Org-wide only |
| Max participants | Yes (default) | Yes (override) | No | Per-meeting cap |
| Guest access enabled | Yes (default) | Yes (override) | No | |
| Chat enabled | Yes (default) | Via preset | Yes (mute) | Controlled by preset `permissions.chat` — no meetings table column. Per-meeting override = select preset with chat disabled. |
| Polls enabled | Yes (default) | Via preset | No | Controlled by preset `permissions.polls` — no meetings table column. |
| Breakout rooms enabled | Yes (default) | Via preset | Yes (create/close) | Controlled by preset config — no meetings table column. |
| Transcription enabled | Yes (default) | Yes (override) | Yes (start/stop) | meetings.transcription_enabled column exists |
| AI summaries enabled | Yes (default) | Via preset | No | Depends on transcription — no separate meetings table column. |
| Virtual backgrounds allowed | Yes (default) | No | No | Org-wide policy |
| Plugins (whiteboard, doc sharing) | Yes (default) | Via preset | No | Controlled by preset `permissions.plugins` — no meetings table column. |
| RTMP livestream enabled | Yes (default) | Via preset | Yes (start/stop) | Controlled by preset `permissions.livestreaming` — no meetings table column. |
| Default locale | Yes (primary) | No | No | Org-wide only |
| Simulcast settings | Yes (default) | No | No | Org-wide, technical setting |
| Rate limits (joins/min) | Yes (primary) | No | No | Abuse protection, org-wide |
| Invite-only registration | Yes (primary) | No | No | Org-wide policy |
12.3 D1 Schema: `org_settings`
CREATE TABLE org_settings (
id TEXT PRIMARY KEY DEFAULT 'default', -- single-row table, always 'default'
org_name TEXT NOT NULL,
org_slug TEXT NOT NULL UNIQUE, -- URL-safe org identifier
-- Branding: Theme
theme TEXT NOT NULL DEFAULT 'dark', -- 'light' | 'dark' | 'darkest'
brand_color TEXT NOT NULL DEFAULT '#2563EB', -- hex, maps to brand.500
background_color TEXT, -- hex, maps to background.1000 (null = theme default)
text_color TEXT, -- hex (null = theme default)
text_on_primary_color TEXT, -- hex (null = auto)
video_bg_color TEXT, -- hex (null = theme default)
font_family TEXT, -- CSS font-family (null = system default)
google_font TEXT, -- Google Font name (null = use font_family)
border_width TEXT NOT NULL DEFAULT 'thin', -- 'none' | 'thin' | 'fat'
border_radius TEXT NOT NULL DEFAULT 'rounded', -- 'sharp' | 'rounded' | 'extra-rounded' | 'circular'
-- Branding: Assets
logo_url TEXT, -- R2 URL for org logo
logo_height INTEGER DEFAULT 32, -- px
favicon_url TEXT, -- R2 URL for favicon
custom_icon_pack TEXT, -- JSON blob for RTK icon pack (nullable)
-- Watermark
watermark_enabled INTEGER NOT NULL DEFAULT 0, -- boolean
watermark_image_url TEXT, -- R2 URL
watermark_position TEXT DEFAULT 'right bottom', -- 'left top' | 'right top' | 'left bottom' | 'right bottom'
watermark_width INTEGER DEFAULT 120, -- px
watermark_height INTEGER DEFAULT 40, -- px
-- Meeting Defaults
default_meeting_type TEXT NOT NULL DEFAULT 'video', -- 'video' | 'audio' | 'webinar' | 'livestream' (maps to RTK view_type: GROUP_CALL, AUDIO_ROOM, WEBINAR, WEBINAR+can_livestream)
default_waiting_room INTEGER NOT NULL DEFAULT 0, -- boolean
default_recording_autostart INTEGER NOT NULL DEFAULT 0, -- boolean
default_max_participants INTEGER NOT NULL DEFAULT 100,
-- Guest access resolution:
-- 1. If guest_access_global = 0 → all guest access disabled (overrides everything)
-- 2. Else: effective = meeting.guest_access ?? organizations.guest_access_default
-- See Section 6.7 for full guest access hierarchy
default_chat_enabled INTEGER NOT NULL DEFAULT 1, -- boolean
default_polls_enabled INTEGER NOT NULL DEFAULT 1, -- boolean
default_breakout_rooms_enabled INTEGER NOT NULL DEFAULT 0, -- boolean (beta)
default_transcription_enabled INTEGER NOT NULL DEFAULT 0, -- boolean
default_ai_summaries_enabled INTEGER NOT NULL DEFAULT 0, -- boolean
default_plugins_enabled INTEGER NOT NULL DEFAULT 1, -- boolean
default_livestream_enabled INTEGER NOT NULL DEFAULT 0, -- boolean
-- Org Policies (not overridable per-meeting)
virtual_backgrounds_allowed INTEGER NOT NULL DEFAULT 1, -- boolean
guest_access_global INTEGER NOT NULL DEFAULT 1, -- boolean, master switch
invite_only_registration INTEGER NOT NULL DEFAULT 0, -- boolean
simulcast_enabled INTEGER NOT NULL DEFAULT 1, -- boolean
-- Recording Policy
recording_policy TEXT NOT NULL DEFAULT 'host_decides' CHECK(recording_policy IN ('always', 'host_decides', 'never')),
-- When `recording_policy = 'never'`, the Worker rejects all recording start requests regardless of preset `can_record` value.
-- The frontend checks this setting via `GET /api/org/settings` and hides the record button when policy is `never`.
-- When `recording_policy = 'always'`, meetings auto-start recording via `record_on_start: true` at meeting creation time — the Worker enforces this.
-- Recording Storage
recording_storage_provider TEXT NOT NULL DEFAULT 'r2', -- 'r2' | 'aws' | 'azure' | 'digitalocean' | 'gcs' | 'sftp'
recording_storage_config TEXT, -- JSON: bucket, region, credentials ref
-- i18n
timezone TEXT NOT NULL DEFAULT 'UTC', -- Org timezone (IANA format, e.g. 'America/New_York'). Used as default for meeting scheduling, analytics display, and cron context.
default_locale TEXT NOT NULL DEFAULT 'en-US',
custom_strings TEXT, -- JSON blob for i18n overrides
-- Notifications (channel abstraction — see Section 8.9)
notification_channels TEXT DEFAULT '["email"]', -- JSON array of enabled channels: 'email', 'push', 'sms', 'webhook'
email_provider TEXT DEFAULT 'resend', -- which email provider: 'resend' | 'sendgrid' | 'ses' | 'smtp' (extensible)
email_from_address TEXT, -- sender address (e.g. 'noreply@meetings.example.com'), null = provider default
-- Abuse Protection & Capacity Caps (see Section 6.8)
rate_limit_joins_per_minute INTEGER NOT NULL DEFAULT 60,
max_concurrent_meetings INTEGER NOT NULL DEFAULT 25,
max_meeting_duration_minutes INTEGER NOT NULL DEFAULT 1440, -- 24 hours
max_recording_storage_gb INTEGER NOT NULL DEFAULT 50, -- R2 storage cap per org
-- Meeting Defaults (continued)
default_persist_chat INTEGER NOT NULL DEFAULT 1, -- boolean: save chat messages to D1 after meeting
-- RTK App Config
rtk_app_id TEXT NOT NULL, -- RealtimeKit App ID
rtk_api_key_encrypted TEXT NOT NULL, -- Encrypted RTK API key
-- Metadata
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- Trigger to auto-update updated_at
CREATE TRIGGER org_settings_updated_at
AFTER UPDATE ON org_settings
BEGIN
UPDATE org_settings SET updated_at = datetime('now') WHERE id = NEW.id;
END;12.4 Admin Settings UI Tabs
The /admin/settings page uses a tabbed layout:
| Tab | Settings Exposed | Maps to org_settings columns |
|---|---|---|
| **General** | Org name, slug, timezone | `org_name`, `org_slug`, `timezone` |
| **Branding** | Theme, colors, logo, favicon, fonts, borders, icon pack | `theme` through `custom_icon_pack` |
| **Meetings** | Default meeting type, waiting room, max participants, persist chat, guest access | `default_meeting_type` through `default_livestream_enabled`, `default_persist_chat` |
| **Recording** | Recording policy, auto-start default, storage provider, storage config, watermark | `recording_policy`, `default_recording_autostart`, `recording_storage_*`, `watermark_*` |
| **Features** | Chat, polls, breakout rooms, transcription, AI summaries, plugins, livestream, virtual backgrounds, simulcast | Various `default_*` and policy columns, `simulcast_enabled` |
| **Notifications** | Enabled channels, email provider, sender address | `notification_channels`, `email_provider`, `email_from_address` |
| **Security** | Guest access master switch, invite-only registration, rate limits, capacity caps | `guest_access_global`, `invite_only_registration`, `rate_limit_*`, `max_concurrent_meetings`, `max_meeting_duration_minutes`, `max_recording_storage_gb` |
| **Localization** | Default locale, custom strings | `default_locale`, `custom_strings` |
Each tab has a "Save" button that PATCHes only the changed fields. Changes to branding are previewed live in a side panel before saving.
12.5 Preset Management
RTK presets are managed via the RTK REST API, not stored in D1. The admin UI at /admin/presets is a CRUD interface over the RTK preset API:
- List presets:
GET /accounts/{ACCOUNT_ID}/realtime/kit/{APP_ID}/presets - Create preset:
POST /accounts/{ACCOUNT_ID}/realtime/kit/{APP_ID}/presets - Update preset:
PATCH /accounts/{ACCOUNT_ID}/realtime/kit/{APP_ID}/presets/{preset_id} - Delete preset:
DELETE /accounts/{ACCOUNT_ID}/realtime/kit/{APP_ID}/presets/{preset_id}
Presets control per-participant permissions: media (camera, mic, screenshare), chat (public, private, file sharing), stage access, recording, plugins, host controls, and waiting room bypass.
The admin UI presents presets as role templates:
- Host: Full permissions (default for meeting creators)
- Participant: Standard permissions (camera, mic, chat, no host controls)
- Viewer: View-only (webinar audience, no media publishing)
- Guest: Restricted (no file sharing, no private chat, waiting room required)
Admins can clone and customize these or create new presets from scratch via a permission checklist UI or a raw JSON editor for advanced users.
Section 13: Future Features
Features listed here are NOT built in the initial release. Each has a clear trigger condition for when to build it.
13.1 Future Feature List
| Feature | Description | Build When |
|---|---|---|
| **Noise Cancellation** | Client-side audio processing (Krisp SDK or RNNoise WASM) | When user feedback shows audio quality is a top complaint, AND a viable client-side library is identified that works across web + mobile |
| **SIP/PSTN Interconnect** | Dial-in/dial-out via phone numbers | When RTK adds SIP support OR when a third-party SIP gateway (e.g., Twilio SIP trunking) can bridge to RTK meetings via a participant bot |
| **Per-Track Recording** | Per-participant audio/video track isolation. API endpoint exists (`POST /recordings/track`) but `layers` system only supports `default` key (all participants combined) — NOT per-participant. Current capability is just audio/video split, which composite already does via `audio_config.export_file`. See Section 7.9. | When RTK ships multi-layer support allowing named per-participant layers. Monitor the "Track Recordings Guide" page (referenced in API docs but doesn't exist yet). |
| **Mobile Apps (iOS/Android)** | Native mobile clients using RTK mobile UI Kits | After web app is feature-complete and stable. Start with iOS (Swift, UI Kit v0.5.7) as it's most mature mobile SDK |
| **Flutter / React Native Apps** | Cross-platform mobile clients | After native iOS/Android apps ship, IF the Flutter/RN SDKs reach v1.0 GA |
| **Realtime AI Agents** | Voice AI in meetings (STT -> LLM -> TTS via Durable Objects) | When Cloudflare consolidates `@cloudflare/realtime-agents` into the stable Agents SDK. Current SDK is experimental and will be replaced |
| **Advanced Analytics Dashboard** | Trends, cohort analysis, export, custom date ranges | After basic analytics (session count, participant count, duration) proves useful and users request deeper insights |
| **Multi-Timezone Display** | Show meeting times in multiple timezones for distributed teams. Participants see scheduled times converted to their local timezone. | When user feedback indicates timezone confusion in scheduling |
| **Calendar Integration** | Google Calendar / Outlook sync for scheduled meetings | When meeting scheduling is stable and users report friction from manual scheduling |
| **OAuth Login Providers** | Social/enterprise login (Google, Microsoft, GitHub, etc.) using the **org's own** OAuth credentials — never vendor credentials. Each org configures their own OAuth app in the provider's console and enters client ID/secret in org settings. | When email/password login proves insufficient for user adoption. Groundwork: login page layout already reserves space; auth system uses a clean session model that OAuth can plug into. No code changes needed to "prepare" — just add OAuth flow when ready. |
| **SSO / SAML** | Enterprise single sign-on | When enterprise customers require it. Implement via Cloudflare Access or direct SAML/OIDC integration |
| **Custom Recording Layouts** | Branded recording with custom grid layout, overlays | When users need recordings that look different from the default composite layout. Uses RTK Recording SDK (custom recording app) |
| **E2E Encryption** | End-to-end encrypted meetings | When RTK adds E2EE support (not currently available) |
| **Meeting Templates** | Pre-configured meeting setups (standup, all-hands, interview) | When hosts report repetitive meeting configuration as a pain point |
| **Org-Facing Alerting** | Email/webhook alerts to org admins for health status changes, webhook failure spikes, meeting quality degradation (see Section 3.10 note) | After diagnostics dashboard is stable and admins request proactive alerts instead of manual checking |
| **Push Notifications (Web Push)** | Browser push notifications for meeting reminders, invitations, recording ready | After email notifications are stable and users request real-time browser alerts |
| **SMS Notifications (Twilio)** | SMS channel for meeting reminders and critical alerts | When orgs request SMS as a notification channel. Requires Twilio account (org's own credentials). |
| **Additional Email Providers** | SendGrid, Amazon SES, SMTP relay as alternatives to Resend | When orgs have existing email provider relationships they want to reuse |
| **Webhook Integrations** | Slack/Teams/email notifications on meeting events | After webhook processing is stable and users request specific integrations |
| **API for Customers** | Public REST API for programmatic meeting management | When the product is used by teams that want to integrate meetings into their own apps |
| **White-Label Deployment App** | Self-service deployment tool for non-technical users | SEPARATE product. Full design in Section 14. Build after the main app is production-ready |
13.2 Prioritization Criteria
For any future feature, evaluate against these criteria before starting:
- Platform support: Does RTK provide the underlying capability? (If not, is a viable third-party integration available?)
- User demand: Have multiple users/orgs requested this?
- Effort vs. impact: Is the implementation effort proportional to the number of users it helps?
- SDK stability: For features depending on pre-GA SDKs, has the SDK reached v1.0?
- Dependencies: Are prerequisite features (e.g., basic analytics before advanced analytics) already built and stable?
13.3 Research Items (to resolve before deciding build/no-build)
| Item | Question to Answer | How to Research |
|---|---|---|
| Per-track recording | **RESOLVED (Future).** API endpoint verified via OpenAPI: `POST /recordings/track` with `layers` system. However, only `default` layer key is supported — gives audio/video split (same as composite's `export_file`), NOT per-participant isolation. The referenced "Track Recordings Guide" page doesn't exist. Status moved to Future (watch) in Section 7.9. | Periodically check RTK docs for Track Recordings Guide page and multi-layer support. When per-participant layers ship, move to BUILD. |
| RTMP/HLS setup | What configuration is needed to start an RTMP livestream? | Experiment with RTK SDK `RTKLivestream` API; check for RTMP URL/key configuration in REST API |
| Mobile UI Kit parity | Do mobile UI Kits have equivalent components to the web 136-component set? | Install iOS/Android UI Kit packages and enumerate available views/components |
| Recording SDK | How does the custom recording app work? Can we customize layout/watermark position? | Review RTK Recording SDK docs when available; test with sample implementation |
Section 14: Deployment App (Separate Product)
The Deployment App is a separate product from the collaboration platform. It runs on the vendor's Cloudflare account (Workers + D1 + R2) and enables non-technical customers to deploy the full RTK collaboration platform into their own Cloudflare account — with zero CLI usage, zero code, and zero Cloudflare expertise required.
14.1 Product Overview
| Aspect | Detail |
|---|---|
| **Target user** | Non-technical business user ("I want video meetings for my company") |
| **What customer provides** | A Cloudflare account + a scoped API token (we tell them exactly how to create it) |
| **What the app does** | 100% of deployment: creates Workers, D1, R2, KV, presets, webhooks, DNS — everything |
| **Post-deploy relationship** | Customer manages their platform directly. Comes back for updates, health checks, token rotation |
| **Tech stack** | CF Workers + D1 + R2 (same as the platform itself) |
| **Billing** | Deferred — not included in this design phase |
| **Deployments per account** | One (multi-deployment support designed for later) |
| **Update mechanism** | Two-Worker architecture: main platform Worker + updater Worker. Vendor-hosted R2 version manifest checked hourly. Owner triggers update manually. See Section 14.11 for full design. |
14.2 Authentication System
D1-backed sessions (not JWT — need server-side revocation for security).
Database schema (vendor D1):
CREATE TABLE deploy_users (
id TEXT PRIMARY KEY, -- ulid
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL, -- argon2id via @noble/hashes
email_verified INTEGER NOT NULL DEFAULT 0,
verification_token TEXT,
verification_expires_at TEXT,
reset_token TEXT,
reset_expires_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE deploy_sessions (
id TEXT PRIMARY KEY, -- random 32-byte hex
user_id TEXT NOT NULL REFERENCES deploy_users(id),
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_sessions_user ON deploy_sessions(user_id);
CREATE INDEX idx_sessions_expires ON deploy_sessions(expires_at);
CREATE TABLE deployments (
id TEXT PRIMARY KEY, -- ulid
user_id TEXT NOT NULL REFERENCES deploy_users(id),
cf_account_id TEXT NOT NULL,
org_name TEXT NOT NULL,
org_slug TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending'
CHECK(status IN ('pending','deploying','active','failed','suspended','deleted')),
worker_name TEXT NOT NULL,
custom_domain TEXT,
d1_database_id TEXT,
r2_branding_bucket TEXT,
r2_recordings_bucket TEXT,
r2_exports_bucket TEXT,
kv_sessions_id TEXT,
kv_rate_limits_id TEXT,
kv_meeting_state_id TEXT,
kv_cache_id TEXT,
rtk_app_id TEXT,
last_health_check TEXT,
health_status TEXT DEFAULT 'unknown',
deployed_version TEXT,
-- Pre-deploy configuration (applied to platform at deploy time)
config_brand_color TEXT DEFAULT '#0055FF',
config_guest_access INTEGER DEFAULT 1,
config_recording_policy TEXT DEFAULT 'host_decides',
config_default_meeting_type TEXT DEFAULT 'video',
config_waiting_room INTEGER DEFAULT 1,
config_transcription INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_deployments_user ON deployments(user_id);
CREATE TABLE deploy_activity (
id TEXT PRIMARY KEY, -- ulid
deployment_id TEXT NOT NULL REFERENCES deployments(id),
user_id TEXT NOT NULL REFERENCES deploy_users(id),
action TEXT NOT NULL, -- 'deployed' | 'updated' | 'rolled_back' | 'health_check' | 'config_changed' | 'domain_added' | 'domain_removed' | 'suspended' | 'deleted'
details TEXT, -- JSON: version numbers, error messages, etc.
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_activity_deployment ON deploy_activity(deployment_id);
CREATE INDEX idx_activity_user ON deploy_activity(user_id);
CREATE TABLE deploy_notification_prefs (
user_id TEXT PRIMARY KEY REFERENCES deploy_users(id),
email_health_alerts INTEGER NOT NULL DEFAULT 1, -- boolean
email_update_available INTEGER NOT NULL DEFAULT 1, -- boolean
email_deploy_status INTEGER NOT NULL DEFAULT 1, -- boolean
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);Auth flows:
- Registration: email + password + display name → argon2id hash → email verification via Resend (same provider as the platform — see Section 8.9)
- Login: verify password → create D1 session → set
HttpOnly; Secure; SameSite=Strictcookie (7-day expiry) - Password reset: token-based, 1-hour expiry, invalidates all sessions on reset
- OAuth: Deferred. UI has reserved slot ("or" divider) for Google OAuth later
- Rate limiting: login attempts via KV (
rl:login:{email}:{minute})
14.3 Token Validation Pipeline
The most critical onboarding step. Customer pastes their CF API token; we validate it has exact required permissions.
Required token permissions:
| Permission Group | Permission | Level | Why |
|---|---|---|---|
| Account — Workers Scripts | Workers Scripts | Edit | Upload Worker code |
| Account — Workers KV Storage | Workers KV Storage | Edit | Create KV namespaces |
| Account — Workers R2 Storage | Workers R2 Storage | Edit | Create R2 buckets |
| Account — D1 | D1 | Edit | Create database, run migrations |
| Account — Account Settings | Account Settings | Read | Verify account access |
| Account — Cloudflare RealtimeKit | RealtimeKit | Edit | Create apps, presets, webhooks |
| Zone — Zone *(optional)* | Zone | Read | List zones for custom domain setup (Workers custom domains handle DNS automatically — no DNS Edit needed) |
Validation sequence:
- Verify token:
GET /user/tokens/verify→ confirms token is active - Get account:
GET /accounts→ extract account_id - Permission probes (7 parallel GET requests):
GET /accounts/{id}/workers/scripts→ WorkersGET /accounts/{id}/d1/database→ D1GET /accounts/{id}/r2/buckets→ R2GET /accounts/{id}/storage/kv/namespaces→ KVGET /accounts/{id}→ Account SettingsGET /accounts/{id}/realtime/kit/apps→ RealtimeKitGET /zones?account.id={id}→ Zone (optional, for custom domain)
- Store token: AES-256-GCM encrypted in vendor D1. Never stored in plaintext. Never sent to frontend after validation.
14.4 Deployment Pipeline
Creates all infrastructure in the customer's CF account. ~15-30 seconds total.
Resource inventory per deployment:
| Resource | Count | Names |
|---|---|---|
| D1 Database | 1 | `rtk-platform` |
| R2 Buckets | 3 | `{slug}-branding`, `{slug}-recordings`, `{slug}-exports` |
| KV Namespaces | 4 | `rtk-sessions`, `rtk-rate-limits`, `rtk-meeting-state`, `rtk-cache` |
| Workers | 2 | `rtk-platform-{slug}` (main) + `rtk-updater-{slug}` (update/rollback, never self-updates) |
| Worker Secrets | 7 | `ACCOUNT_ID`, `RTK_API_TOKEN`, `JWT_SECRET`, `RESEND_API_KEY`, `SENTRY_DSN_VENDOR`, `SENTRY_DSN_CUSTOMER`, `SENTRY_RELEASE` |
| RTK App | 1 | Created via RTK REST API |
| RTK Presets | 12 | Per Section 5.2 (4 meeting types × 3 roles each) |
| RTK Webhook | 1 | Points to Worker's `/api/webhooks/rtk` |
| Custom Domain | 0-1 | Optional |
Pipeline phases:
Phase 1: Create Storage (parallel, ~2-4s)
8 independent API calls run simultaneously:
| Call | Endpoint | Body |
|---|---|---|
| D1 database | `POST /accounts/{id}/d1/database` | `{ "name": "rtk-platform" }` |
| R2 branding | `POST /accounts/{id}/r2/buckets` | `{ "name": "{slug}-branding" }` |
| R2 recordings | `POST /accounts/{id}/r2/buckets` | `{ "name": "{slug}-recordings" }` |
| R2 exports | `POST /accounts/{id}/r2/buckets` | `{ "name": "{slug}-exports" }` |
| KV sessions | `POST /accounts/{id}/storage/kv/namespaces` | `{ "title": "rtk-sessions" }` |
| KV rate limits | `POST /accounts/{id}/storage/kv/namespaces` | `{ "title": "rtk-rate-limits" }` |
| KV meeting state | `POST /accounts/{id}/storage/kv/namespaces` | `{ "title": "rtk-meeting-state" }` |
| KV cache | `POST /accounts/{id}/storage/kv/namespaces` | `{ "title": "rtk-cache" }` |
Phase 2: Deploy Worker (sequential, ~5-10s, needs Phase 1 IDs)
Upload Worker with bindings:
PUT /accounts/{id}/workers/scripts/{worker_name} Content-Type: multipart/form-dataWorker code is pre-built and stored in vendor R2. Bindings injected dynamically from Phase 1 resource IDs.
Set secrets (6 sequential PUT calls):
PUT /accounts/{id}/workers/scripts/{worker_name}/secretsSets:
ACCOUNT_ID,RTK_API_TOKEN,JWT_SECRET,SENTRY_DSN_VENDOR,SENTRY_DSN_CUSTOMER,SENTRY_RELEASEEnable workers.dev subdomain:
POST /accounts/{id}/workers/scripts/{worker_name}/subdomainRun D1 schema migration:
POST /accounts/{id}/d1/database/{db_id}/queryExecutes complete schema from Section 2.3.
Phase 3: Configure RTK (mostly parallel, ~4-8s)
- Create RTK App:
POST /accounts/{id}/realtime/kit/apps - Create 12 presets (parallel):
POST /accounts/{id}/realtime/kit/{app_id}/presets— per Section 5.2 - Register webhook:
POST /accounts/{id}/realtime/kit/{app_id}/webhooks - Seed org_settings + Create Owner user: via D1 query, using customer's pre-deploy config choices
Phase 4: Custom Domain (optional)
If customer has a zone on Cloudflare:
PUT /accounts/{id}/workers/domains
Body: { "hostname": "meet.example.com", "service": "{worker_name}", "zone_id": "{zone_id}" }Progress reporting: Server-Sent Events (SSE) stream real-time step completion to the frontend. Falls back to polling.
Rollback on failure: Every created resource is tracked. On failure, delete in reverse order. Deployments are idempotent — retry creates only missing resources.
Estimated timing:
| Phase | Description | Time |
|---|---|---|
| Phase 1 | Create storage (8 parallel) | 2-4s |
| Phase 2 | Deploy Worker + secrets + schema | 5-10s |
| Phase 3 | RTK app + 12 presets + webhook + seed | 4-8s |
| Phase 4 | Custom domain (optional) | 1-5s |
| **Total** | **12-27s** |
14.5 Health Check System
On-demand: Vendor Worker calls deployed Worker's /api/diagnostics/health:
| Check | Method | Pass Criteria |
|---|---|---|
| D1 connectivity | `SELECT 1` | No error |
| R2 connectivity | `HEAD` on branding bucket | 200 or 404 |
| KV connectivity | `GET` test key | No error |
| RTK API | `GET /realtime/kit/{app_id}/meetings?limit=1` | 200 |
Periodic: Deployed Worker runs health checks every 30 minutes via cron trigger. Vendor Worker iterates all active deployments every 30 minutes as well. Alert email after 3 consecutive failures.
14.6 User Flows
Flow 1: Registration
Landing page → Sign up (email + password) → Email verification → First login → Redirect to onboarding wizard.
Flow 2: Onboarding Wizard (5 steps)
Step 1 — Welcome: Explains what the app does, what they need (CF account + 5 minutes), what gets created.
Step 2 — Connect Cloudflare: Step-by-step visual guide to create a CF API token with exact permissions. Token input field (masked). Real-time validation with per-permission checkmarks. This is the hardest step — needs clear, non-intimidating instructions with expandable "Learn more" sections.
Step 3 — Configure Platform: Pre-deployment configuration (all changeable later on their platform):
| Setting | Input Type | Default | Description |
|---|---|---|---|
| Organization name | Text | Required | Shown in platform UI and meeting links |
| Domain | Domain selector (see below) | Required | Default: subdomain on user's zone. Fallback: `{slug}.workers.dev` |
| Primary brand color | Color picker | `#0055FF` | Applied to platform UI |
| Logo | File upload | Skip | Optional, changeable later |
| Default meeting type | Dropdown | Video | Pre-selected option when host creates a new meeting. Host can always change per-meeting. |
| Guest access | Toggle | On | Allow link-based access without accounts |
| Waiting room | Toggle | On | Require host approval for guests |
| Recording policy | Dropdown | Host Decides | Always / Host Decides / Never |
| Transcription | Toggle | Off | Auto-transcribe recordings |
Domain selector UX (Step 3):
- If user's token has Zone Read permission, fetch their zones via
GET /zones. Show a dropdown of available domains. - Default to subdomain mode: user picks a zone, enters a subdomain (e.g.,
meet.example.com). This is the recommended and most common option. - Option to use root domain: if selected, show a warning — "Using your root domain (example.com) will route all traffic to Castio. If you have an existing website or application at this domain, it will stop working. Only use a root domain if it's dedicated to Castio."
- If no zones available (token lacks Zone Read): fall back to
{slug}.workers.devwith a note that custom domains can be configured later. - Workers custom domains handle DNS automatically — no additional DNS configuration needed from the user.
Step 4 — Review & Deploy: Summary of all config. Edit links back to each step. "Deploy Platform" button.
Step 5 — Deploying: Real-time progress tracker (see Section 14.4). Continues server-side if tab is closed. On success: celebration animation, platform URL, "Go to Dashboard" button.
Flow 3: Post-Deploy Dashboard
Status card (healthy/degraded/unhealthy), quick stats (meetings, users, recordings), quick actions (open platform, view deployment), activity feed.
Flow 4: Error Recovery
- Deploy failed → retry from failed stage (idempotent)
- Token invalid/revoked → banner warning, platform keeps running, management blocked
- Token expiring → 30/7/1-day advance warnings
- Platform unhealthy → per-service diagnostics with suggested actions
14.7 Page & Route Map
Public Pages (no auth)
| Route | Page | Purpose |
|---|---|---|
| `/` | Landing | Product explanation, CTA to register |
| `/login` | Login | Email + password (OAuth slot reserved) |
| `/register` | Register | Email + password + display name |
| `/verify-email` | Email Verification | Pending verification + token confirmation |
| `/forgot-password` | Forgot Password | Email input to request reset |
| `/reset-password/:token` | Reset Password | New password form |
Onboarding Wizard (auth required, full-screen)
| Route | Step | Title |
|---|---|---|
| `/onboarding/welcome` | 1 | Welcome — what you need |
| `/onboarding/connect` | 2 | Connect Cloudflare — token setup |
| `/onboarding/configure` | 3 | Configure Platform — pre-deploy settings |
| `/onboarding/review` | 4 | Review & Deploy — summary |
| `/onboarding/deploying` | 5 | Deploying — live progress |
Authenticated Pages
| Route | Page | Purpose |
|---|---|---|
| `/dashboard` | Dashboard | Post-deploy home: health, stats, quick actions |
| `/deployment` | Deployment Detail | Health checks per service, infrastructure inventory, deploy history, danger zone |
| `/help` | Help & Docs | FAQ, troubleshooting, support contact |
Settings
| Route | Page | Purpose |
|---|---|---|
| `/settings/account` | Account | Profile, password, sessions, delete account |
| `/settings/cloudflare` | CF Connection | Token status, re-validation, token update |
| `/settings/platform` | Platform Config | Org name, branding, domain, meeting defaults, diagnostics |
| `/settings/notifications` | Notifications | Email alerts for health changes, token expiry, updates |
| `/settings/updates` | Updates | Current version, changelog, update button (mechanism TBD) |
Utility
| Route | Page |
|---|---|
| `*` | 404 Not Found |
| `/error` | Generic Error |
Total: 22 routes
14.8 Component Architecture
Layout Components
| Component | Description |
|---|---|
| `AppShell` | Authenticated layout: sidebar + topbar + content slot |
| `PublicLayout` | Unauthenticated layout: topbar + centered content |
| `Sidebar` | Nav links with icons, collapsible on mobile, health status dot |
| `Topbar` | Logo, breadcrumbs, notification bell, avatar dropdown |
| `WizardShell` | Full-screen onboarding layout with step indicator |
Feedback & Status
| Component | Description |
|---|---|
| `Toast` | Non-blocking notification (success/error/info/warning), auto-dismiss |
| `AlertBanner` | Page-level persistent alert, dismissible or sticky |
| `StatusBadge` | Colored pill: healthy/degraded/down/pending |
| `EmptyState` | Icon + message + CTA for empty data |
| `LoadingSkeleton` | Animated placeholder for cards/tables |
| `DeploymentProgress` | Vertical step tracker with live status per step |
Data Display
| Component | Description |
|---|---|
| `StatCard` | Metric: label + value + optional trend |
| `DataTable` | Sortable, paginated table with loading/empty/error states |
| `ActivityItem` | Icon + text + relative timestamp |
| `HealthCheckRow` | Service name + latency + status dot |
| `KeyValueList` | Label-value pairs for deployment info |
Forms & Inputs
| Component | Description |
|---|---|
| `FormField` | Label + input + help text + error wrapper |
| `TextInput` | Standard input with validation states |
| `PasswordInput` | Input with show/hide toggle + strength indicator |
| `TokenInputForm` | Masked input + validate button + permission checklist |
| `SelectDropdown` | Styled select |
| `Toggle` | On/off switch |
| `ColorPicker` | Color swatch for branding |
| `FileUpload` | Drag-and-drop with preview (logo) |
| `CopyableField` | Read-only with copy-to-clipboard |
Actions & Containers
| Component | Description |
|---|---|
| `PrimaryButton` | Main action with loading state |
| `DangerButton` | Red-styled for destructive actions |
| `Card` | Bordered container with optional header/body/footer |
| `CollapsibleCard` | Expandable card (progressive disclosure) |
| `DangerZone` | Red-bordered section for destructive actions |
Modals
| Modal | Trigger | Purpose |
|---|---|---|
| `ConfirmDeployModal` | Deploy/Redeploy button | Confirm before creating infrastructure |
| `ConfirmDestroyModal` | "Destroy Deployment" | Typed confirmation to delete all resources |
| `ConfirmDeleteAccountModal` | "Delete Account" | Typed confirmation |
| `TokenUpdateModal` | "Update Token" | Token input with permission diff |
| `RedeployRequiredModal` | Config save needing redeploy | Choose "Save & Redeploy" or "Save Only" |
| `SessionExpiredModal` | 401 response | "Sign In Again" prompt |
14.9 Responsive Design
| Breakpoint | Behavior |
|---|---|
| Desktop (>1024px) | Full sidebar + content area |
| Tablet (768-1024px) | Collapsible sidebar, full functionality |
| Mobile (<768px) | Hamburger menu, read-only dashboard + status check. No deployment or settings changes (too complex for small screens) |
14.10 Cross-Cutting Concerns
Loading states: Skeleton screens for dashboard cards. Inline spinners for async ops (token validation, health checks). Full-page loader only on initial app load.
Empty states: No deployment → "Complete setup to deploy" CTA. No activity → "No recent activity." No stats → "Hold your first meeting to see data."
Error handling pattern: Plain-language error messages for non-technical users. Technical details hidden behind expandable "Show details" sections. Every error has a clear action: retry, update token, contact support.
Security: Tokens encrypted at rest (AES-256-GCM). Session cookies are HttpOnly + Secure + SameSite=Strict. Customer's CF API token used server-side only, never sent to browser after validation. Customer can revoke their token from CF dashboard at any time — running platform is unaffected (Workers run independently of the API token).
14.11 Update & Rollback Mechanism
Updates are manual, initiated by the Owner role from their deployed platform (not from the vendor's deployment app). The deployed platform self-updates using the customer's own CF API token.
Architecture: Two Workers
| Worker | Purpose | Self-updates? |
|---|---|---|
| `rtk-platform-{slug}` | Main platform Worker | Yes (code is replaced during updates) |
| `rtk-updater-{slug}` | Tiny updater Worker | **Never** — always stable, can recover a bricked main Worker |
The updater Worker is deployed alongside the main Worker during initial deployment (Phase 2). Its only job: update or rollback the main Worker.
Updater Worker bindings:
- KV:
rtk-meeting-state(readsplatform:current_version,platform:available_update, writesrollback:*) - D1:
rtk-platform(runs migrations, reads_migrations) - Secret:
ACCOUNT_ID,RTK_API_TOKEN(same as main Worker — needed for CF API calls)
Updater Worker routes:
| Method | Route | Purpose |
|---|---|---|
| POST | `/update` | Trigger update flow (Owner-initiated, auth required) |
| POST | `/rollback` | Trigger rollback flow (Owner-initiated or auto) |
| GET | `/health` | Updater health check (always returns 200 if running) |
| GET | `/status` | Current version info + available update from KV |
Communication: The main platform Worker calls the updater via its subdomain (rtk-updater-{slug}.{account-subdomain}.workers.dev). The main Worker's "Update Now" button sends POST /update to the updater's URL. Auth: the request includes the Owner's JWT, which the updater verifies against the shared D1 users table.
Version Manifest (Vendor R2, public read)
The vendor publishes version manifests to a public R2 bucket:
{
"latest": "1.2.3",
"minimum_supported": "1.0.0",
"released_at": "2026-03-06T00:00:00Z",
"changelog_url": "https://updates.vendor.com/changelog/1.2.3.md",
"bundle_url": "https://updates.vendor.com/bundles/1.2.3.bundle.js",
"migrations": ["0005_add_column.sql", "0006_add_index.sql"],
"sha256": "abc123...",
"breaking": false,
"notes": "Bug fixes and performance improvements"
}Update Check (Automatic)
Main Worker cron trigger checks for updates hourly:
[triggers]
crons = ["0 * * * *"] # every hourFetches manifest, compares latest against platform:current_version in KV. If newer version exists, stores manifest in platform:available_update KV key. Owner sees "Update Available" badge in their platform dashboard.
Update Flow (Manual, Owner-initiated)
Owner clicks "Update Now" in their platform settings. Request goes to the updater Worker:
- Download new bundle from vendor R2. Verify SHA-256 checksum.
- Save rollback info to KV:
rollback:{version}:previous_version= current Worker version ID (fromGET /deployments)rollback:{version}:bookmark= current D1 bookmark (fromGET /d1/{db_id}/time_travel/bookmark)
- Upload new Worker version (does NOT deploy yet):
POST /accounts/{id}/workers/scripts/{worker_name}/versions - Run D1 migrations (additive-only — see migration strategy below):
POST /accounts/{id}/d1/database/{db_id}/query - Deploy new version:
POST /accounts/{id}/workers/scripts/{worker_name}/deployments Body: { "strategy": "percentage", "versions": [{ "version_id": "{new}", "percentage": 100 }] } - Update KV:
platform:current_version= new version - Health check: Hit main Worker's
/api/diagnostics/health. If unhealthy, auto-rollback.
Rollback Flow (Owner-initiated or automatic)
Owner clicks "Roll Back" in settings, or auto-triggered if post-update health check fails. Request goes to the updater Worker:
- Read rollback info from KV: previous version ID + pre-migration D1 bookmark
- Redeploy previous Worker version:
POST /accounts/{id}/workers/scripts/{worker_name}/deployments Body: { "strategy": "percentage", "versions": [{ "version_id": "{previous}", "percentage": 100 }] } - Schema: No D1 rollback needed (additive-only migrations — old code ignores new columns)
- Emergency D1 restore (manual, only if data is corrupted):Warning: This loses all data written after the bookmark. Owner must confirm.
POST /accounts/{id}/d1/database/{db_id}/time_travel/restore?bookmark={saved_bookmark}
D1 Migration Strategy: Additive-Only
| Allowed | Not Allowed |
|---|---|
| ADD COLUMN (nullable or with default) | DROP COLUMN |
| ADD TABLE | DROP TABLE |
| ADD INDEX | RENAME COLUMN |
| INSERT seed data | ALTER COLUMN type destructively |
Why: Old Worker versions must work against newer schemas. When rolling back code, the database keeps its current schema. Old code simply ignores columns/tables it doesn't know about.
Migration tracking: _migrations table in each deployed D1:
CREATE TABLE _migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL DEFAULT (datetime('now')),
platform_version TEXT NOT NULL
);Before running migrations, check SELECT MAX(version) FROM _migrations. Run only unapplied migrations in order.
D1 bookmark: Saved before every migration batch as insurance. 30-day retention. Used only for emergency recovery, not routine rollback.
Cloudflare Rollback Capabilities (Reference)
| Feature | Detail |
|---|---|
| Worker version retention | **100 versions** available for rollback |
| Rollback speed | Instant (seconds for global propagation) |
| Rollback scope | Code + config only. **Secrets, bindings, KV, D1, R2 are NOT rolled back.** |
| Gradual rollouts | Can split traffic between two versions (canary deployment) |
| D1 Time Travel | **30-day retention**, always on, no extra cost |
| D1 restore | Atomic. Returns `previous_bookmark` for undo. All post-restore writes are lost. |
| R2 versioning | None. Use versioned key prefixes (`assets/v1.2.3/`) |
| KV versioning | None. Use versioned keys |
| Secrets | Script-level, not version-level. Persist across rollbacks. |
API Endpoints for Update System
| Purpose | Method | Path |
|---|---|---|
| List Worker versions | GET | `/accounts/{id}/workers/scripts/{name}/versions` |
| Upload new version | POST | `/accounts/{id}/workers/scripts/{name}/versions` |
| List deployments | GET | `/accounts/{id}/workers/scripts/{name}/deployments` |
| Create deployment (rollback) | POST | `/accounts/{id}/workers/scripts/{name}/deployments` |
| Get D1 bookmark | GET | `/accounts/{id}/d1/database/{db_id}/time_travel/bookmark` |
| Restore D1 | POST | `/accounts/{id}/d1/database/{db_id}/time_travel/restore` |
| Run D1 query (migrations) | POST | `/accounts/{id}/d1/database/{db_id}/query` |
UI Surface (Owner's Platform Settings)
- Current version:
v1.2.3with release date - Update available banner: version number + changelog summary + "Update Now" button
- Changelog viewer: Rendered markdown from vendor manifest
- Update progress: Step tracker (download → save rollback point → upload → migrate → deploy → health check)
- Rollback button: "Roll Back to v1.2.2" with confirmation dialog
- Emergency D1 restore: Hidden under "Advanced" — requires typed confirmation, warns about data loss
- Update history: Table of past updates (version, date, status: success/failed/rolled-back)
14.12 Deployment App API Routes
| Method | Route | Auth | Purpose |
|---|---|---|---|
| POST | `/api/auth/register` | No | Create account |
| POST | `/api/auth/login` | No | Login |
| POST | `/api/auth/logout` | Yes | Logout |
| GET | `/api/auth/verify` | No | Verify email token |
| POST | `/api/auth/forgot-password` | No | Request password reset |
| POST | `/api/auth/reset-password` | No | Complete password reset |
| GET | `/api/auth/me` | Yes | Get profile |
| PATCH | `/api/auth/profile` | Yes | Update profile |
| POST | `/api/auth/change-password` | Yes | Change password |
| GET | `/api/auth/sessions` | Yes | List active sessions |
| DELETE | `/api/auth/sessions` | Yes | Sign out all other sessions |
| POST | `/api/deploy/validate-token` | Yes | Validate CF API token + permissions |
| GET | `/api/deploy/check-subdomain/:name` | Yes | Check subdomain availability |
| POST | `/api/deploy/start` | Yes | Trigger deployment pipeline |
| GET | `/api/deploy/status` | Yes | Get deployment health + status |
| GET | `/api/deploy/status/stream` | Yes | SSE stream for deployment progress |
| GET | `/api/deploy/infrastructure` | Yes | List deployed CF resources |
| GET | `/api/deploy/history` | Yes | Deployment history |
| GET | `/api/deploy/stats` | Yes | Quick stats (proxied from platform) |
| GET | `/api/deploy/token-status` | Yes | Current token validity |
| PUT | `/api/deploy/token` | Yes | Update CF API token |
| GET | `/api/deploy/config` | Yes | Get platform configuration |
| PATCH | `/api/deploy/config` | Yes | Update platform configuration |
| POST | `/api/deploy/redeploy` | Yes | Trigger redeployment |
| POST | `/api/deploy/destroy` | Yes | Destroy all deployed resources |
| POST | `/api/deploy/health-check` | Yes | Trigger manual health check |
| GET | `/api/deploy/version` | Yes | Current deployed version |
| GET | `/api/deploy/updates` | Yes | Available updates |
| GET | `/api/activity` | Yes | Recent activity log |
| GET | `/api/settings/notifications` | Yes | Notification preferences |
| PATCH | `/api/settings/notifications` | Yes | Update notification preferences |
14.13 Cloudflare API Endpoints Used
Core deployment (16 endpoints):
| Purpose | Method | Path |
|---|---|---|
| Verify token | GET | `/user/tokens/verify` |
| List accounts | GET | `/accounts` |
| Create D1 database | POST | `/accounts/{id}/d1/database` |
| Query D1 | POST | `/accounts/{id}/d1/database/{db_id}/query` |
| Delete D1 | DELETE | `/accounts/{id}/d1/database/{db_id}` |
| Create R2 bucket | POST | `/accounts/{id}/r2/buckets` |
| Delete R2 bucket | DELETE | `/accounts/{id}/r2/buckets/{name}` |
| Create KV namespace | POST | `/accounts/{id}/storage/kv/namespaces` |
| Delete KV namespace | DELETE | `/accounts/{id}/storage/kv/namespaces/{ns_id}` |
| Upload Worker | PUT | `/accounts/{id}/workers/scripts/{name}` |
| Delete Worker | DELETE | `/accounts/{id}/workers/scripts/{name}` |
| Set Worker secret | PUT | `/accounts/{id}/workers/scripts/{name}/secrets` |
| Enable subdomain | POST | `/accounts/{id}/workers/scripts/{name}/subdomain` |
| Attach custom domain | PUT | `/accounts/{id}/workers/domains` |
| Detach custom domain | DELETE | `/accounts/{id}/workers/domains/{domain_id}` |
| Patch Worker settings | PATCH | `/accounts/{id}/workers/scripts/{name}/settings` |
RealtimeKit (8 endpoints):
| Purpose | Method | Path |
|---|---|---|
| Create app | POST | `/accounts/{id}/realtime/kit/apps` |
| Create preset | POST | `/accounts/{id}/realtime/kit/{app_id}/presets` |
| Update preset | PATCH | `/accounts/{id}/realtime/kit/{app_id}/presets/{preset_id}` |
| Delete preset | DELETE | `/accounts/{id}/realtime/kit/{app_id}/presets/{preset_id}` |
| Add webhook | POST | `/accounts/{id}/realtime/kit/{app_id}/webhooks` |
| Update webhook | PATCH | `/accounts/{id}/realtime/kit/{app_id}/webhooks/{wh_id}` |
| Delete webhook | DELETE | `/accounts/{id}/realtime/kit/{app_id}/webhooks/{wh_id}` |
| List meetings | GET | `/accounts/{id}/realtime/kit/{app_id}/meetings` |
Zone (optional, 1 endpoint — Workers custom domains handle DNS automatically):
| Purpose | Method | Path |
|---|---|---|
| List zones | GET | `/zones?account.id={id}` |
Spec Review Tracker
This section is not part of the product specification. It tracks the review and verification status of each spec section before build begins.
Every section must be reviewed and marked DONE before build begins. Reviews follow dependency order — a section should not be reviewed until its dependencies are complete.
Review Status
| Order | Section | Status | Depends On | Key Review Focus | Risk |
|---|---|---|---|---|---|
| 1 | 4 — User System & Auth | DONE | None | Role definitions (Owner/Admin/Host/Member/Guest), auth flow, token lifecycle, permission matrix | — |
| 2 | 5 — Role-to-Preset Mapping | DONE | Section 4 | Preset schema verified against OpenAPI, `view_type` enum, permissions structure, 12 presets | — |
| 3 | 6 — Meeting Lifecycle | DONE | Sections 4, 5 | Meeting CRUD, session lifecycle, participant creation, join flow, scheduling (instant/scheduled/recurring/permanent) | — |
| 4 | 2 — System Architecture | DONE | Sections 4, 5, 6 | D1 schemas, KV key patterns, R2 bucket structure, route completeness, wrangler bindings, data flows | — |
| 5 | 3 — Diagnostics & Observability | DONE | Section 2 | SDK events verified against live docs, code samples corrected, Peer Report API, join-flow tracing, alerting rules | — |
| 6 | 7 — RTK Feature Mapping | DONE | Sections 5, 6 | SDK methods verified, deprecated components replaced, per-track→Research, TURN rewritten, simulcast automatic | — |
| 7 | 8 — Post-Meeting Experience | DONE | Section 7 | Recording playback, transcript viewer, AI summary display, download URLs, session analytics display | — |
| 8 | 9 — Webhook & Analytics Pipeline | DONE | Sections 6, 7, 8 | Event catalog, payload shapes, D1 schemas, field mapping, polling endpoints, analytics queries | — |
| 9 | 10 — App Pages & Navigation | DONE | Sections 4, 6, 7, 8 | Page inventory matches features, route structure, component-to-page mapping, all user flows covered | — |
| 10 | 11 — Branding & Theming | DONE | Section 10 | Theme token names, CSS custom properties, design system consistency, RTK theming API | — |
| 11 | 12 — Organization Configuration | DONE | All feature sections | Settings aggregation matches actual features, no orphaned settings, canonical schema cross-ref | — |
| 12 | 13 — Future Features | DONE | All feature sections | Nothing marked "future" is actually buildable now; nothing marked "build" belongs here | — |
| 13 | 14 — Deployment App | DONE | Independent | Update mechanism, Worker deploy, migration strategy, rollback, wrangler config, licensing | — |
| 14 | 1 — Product Overview | DONE | Everything | Summary accuracy vs all detailed sections, no stale claims, scope table matches reality | — |
Completed Reviews
| Section | Date | Key Changes Made |
|---|---|---|
| 3 — Diagnostics | 2026-03-06 | SDK events verified (mediaPermissionError, mediaConnectionUpdate, socketConnectionUpdate, mediaScoreUpdate, roomJoined/roomLeft). Added deviceUpdate, deviceListUpdate events. Code samples corrected. Peer Report API section added. Join-flow tracing added. Alerting upgraded to launch minimum with Sentry alert rules. |
| 5 — Role-to-Preset Mapping | 2026-03-06 | Section 5.4 rewritten with verified OpenAPI preset schema. `view_type` enum corrected to GROUP_CALL/WEBINAR/AUDIO_ROOM. config/permissions/ui structure documented with full example. |
| 7 — RTK Feature Mapping | 2026-03-06 | SDK methods added (participant.kick(), disableAudio/Video, waiting room accept/reject, stage grant/deny/kick). Deprecated components replaced. Per-track recording → Research. Livestream sparse docs noted. TURN section rewritten (RTK manages internally, not standalone TURN Service). Simulcast kept automatic. |
| 4 — User System & Auth | 2026-03-06 | argon2id→scrypt (Workers 128MB limit), circular FK documented, JWT library named (jose), nanoid/token specs added, RBAC table expanded, auth middleware hardened, guest auth context documented, registration failure handling added. |
| 5 — Role-to-Preset Mapping (re-review) | 2026-03-06 | All 12 presets expanded to full JSON (no abbreviations). webinar_guest removed (dead preset). Preset count corrected to 12. PATCH endpoint path fixed. waiting_room_type enum documented. config.media.audio added. participant_events table improved. Dynamic override scope and preset lifecycle documented. |
| 2 — System Architecture (partial) | 2026-03-06 | Duplicate table definitions replaced with cross-references to canonical schemas (Sec 4.2, 6.2). Deactivated user handling added. Recording policy added to org_settings (Sec 12). |
| 6 — Meeting Lifecycle | 2026-03-06 | API corrections: create meeting two-step POST+PATCH, participant token field name, RealtimeKitClient class name. Meeting locking via is_locked D1 column (not RTK INACTIVE — conflicts with permanent room cycle). State machine updated for permanent rooms and instant meetings. HTTP status codes + error format added. Workers Cron intervals specified. Fixed-window rate limiting with KV key format. Recording policy interaction documented. Guest join consolidated to single /join endpoint. |
| 2 — System Architecture (cross-section) | 2026-03-06 | Stale schedules table removed (Section 6 canonical). sessions/session_participants dedup. Guest access field consolidation. Webhook route standardized. Cloudflare Pages→Workers. org_id single-org warning with 5 MUST NOT rules. Missing routes added. |
| 2 — System Architecture (full review) | 2026-03-06 | recordings/transcripts/summaries tables → cross-refs to Section 9.4. KV rate limit key pattern fixed. R2 bucket naming standardized (rtk-*). Cron triggers added to wrangler config. JWT_SECRET naming standardized across Sections 2, 4. Scheduling CRUD routes added. Endpoint naming pluralized. Data flow diagram corrected (token not authToken). Missing auth/lock/chat-export routes added. |
| 8 — Post-Meeting Experience | 2026-03-06 | All RTK API paths → our Worker API paths. Retention fixed (7 days from meeting start). Meeting title source clarified (REST API meeting_display_name). Recording status lowercase. Transcript JSON schema defined. persist_chat dependency noted. Recording playback URL flow documented. Chat response format clarified (JSON + ?format=csv). Tab states table added (processing/ready/error/expired/not-enabled). Analytics: removed poll/screenshare counts. Notification channel abstraction added (NotificationChannel interface, email/push/sms/webhook channels, Resend default). Sessions list endpoint added. Summary types documented (9 types). |
| 9 — Webhook & Analytics Pipeline | 2026-03-06 | Removed rtk_apps table (single app). Added livestreams table. Added PAUSED to recording status. Added retry_count to webhook_events. Fixed chat_messages to match RTK CSV format. Fixed storage_location (removed r2). Added webhook registration name field. Pipeline diagram completed (9 handlers). Polling fixed: GET /sessions status=LIVE (not GET /meetings). Two-layer dedup documented (KV + upsert). Retry via every-minute cron. Analytics cron aligned to midnight. Added Section 9.8 Webhook Field Mapping (camelCase→snake_case + chat CSV mapping). Timezone added to org_settings. Multi-timezone display added to future features. |
| 10 — App Pages & Navigation | 2026-03-07 | All 20 RTK component names verified (zero issues). Route naming standardized: /meetings/ (plural) for page routes, /m/:slug for vanity URLs. Post-meeting route fixed to session-specific /meetings/:meetingId/sessions/:sessionId. Added 8 missing pages. Added 5 new subsections (Route Guards, React Router Config, Join-by-Code, Deep Links, Error/Loading States). OAuth → Future Feature. SPA catch-all documented. |
| 11 — Branding & Theming | 2026-03-07 | All design tokens verified against llms-full.txt: provideRtkDesignSystem() params, theme/borderWidth/borderRadius enums all correct. rtk-logo, rtk-ui-provider, rtk-meeting props confirmed. Icon editor URL (icons.realtime.cloudflare.com) confirmed. Recording watermark config shape verified. RtkI18n type and t prop confirmed. text-on-primary ambiguity noted (RTK prose says text-on-brand, code example says text-on-primary — we follow code example, flagged for runtime verification). All Section 12.3 org_settings columns cross-checked — zero mismatches. Watermark default position is deliberate override (spec: right bottom, RTK default: left top). |
| 12 — Organization Configuration | 2026-03-07 | Per-meeting override column clarified: 6 features (chat, polls, breakout, AI summaries, plugins, livestream) controlled via preset, not meetings table columns — documented in 12.2 Notes. Added 3 missing org_settings columns: max_meeting_duration_minutes, max_recording_storage_gb, default_persist_chat. Fixed max_concurrent_meetings default (50→25, matching Section 6.8). Added Notifications tab to 12.4. Added recording_policy to Recording tab. Added timezone to General tab. Added simulcast to Features tab. Preset API paths standardized to /accounts/{ACCOUNT_ID}/realtime/kit/{APP_ID}/presets format. Watermark 11.4 contradiction fixed (was "overridable per meeting", now "org-wide"). Recording auto-start row annotated with policy interaction. |
| 13 — Future Features | 2026-03-07 | Per-track recording status reverted to Research (was incorrectly marked "MOVED TO BUILD" — contradicted Section 7.9 which has zero API docs). Section 1 cross-ref fixed to match. Research item updated. Added 4 missing future items: Org-Facing Alerting, Push Notifications, SMS Notifications, Additional Email Providers. OAuth Login Providers already added in Section 10 review. |
| 14 — Deployment App | 2026-03-07 | Preset count fixed (14→12). RESEND_API_KEY added to secrets list (6→7). Update mechanism TBD removed (14.11 is fully designed). Updater Worker spec added: bindings (KV, D1, secrets), routes (/update, /rollback, /health, /status), communication method (subdomain fetch + JWT auth). Missing deploy_activity and deploy_notification_prefs tables added to schema. Mailchannels warning resolved → Resend. All CF API endpoints verified against OpenAPI. Two-system separation confirmed clean. |
| 1 — Product Overview | 2026-03-07 | Meeting types: "three" → "four" (livestream was listed separately, now counted as 4th meeting type). Section 1.5 preset table rewritten: was 8-row simplified role list, now 4×4 matrix matching actual 12-preset structure from Section 5. Guest preset mapping clarified (restrictions via meeting config, not separate presets). Per-track recording already fixed (Research status matches Section 7.9). Scope boundaries verified against all sections. |
Review Rules
- Review Protocol: Before reviewing any section, establish section-specific criteria first (see CLAUDE.md > Spec Review Protocol).
- No self-validation: Never validate the spec against itself — always cross-reference MCP OpenAPI,
docs/llms-full.txt, or live Cloudflare docs. - Fix-forward: If a review reveals the spec is wrong, fix the spec immediately, then log the change in the Completed Reviews table.
- Build gate: BUILD CANNOT START until every section in the Review Status table shows DONE.