RTCstack
A production-ready, self-hosted WebRTC communication platform. Drop-in video conferencing, screen sharing, recording, RTMP/WHIP ingress, and live/post-call transcription — packaged as a single Docker Compose stack with a clean TypeScript SDK and three UI kits.
Built on LiveKit, Caddy, and OpenAI Whisper.
Contents
Architecture
RTCstack separates media from signalling. There are two independent data paths:
┌─────────────────────────────────────────────────────────┐
│ Docker Stack │
│ │
Browser ──── HTTPS ──▶ │ Caddy ──▶ /v1/* ──▶ RTCstack API ──▶ LiveKit SDK │
│ │
Browser ──── WSS ────▶ │ Caddy ──▶ /livekit ──▶ LiveKit SFU │
Browser ──── UDP ────▶ │ (direct, no proxy) │
└─────────────────────────────────────────────────────────┘
Path 1 — Token handshake (HTTPS): Your app calls the RTCstack API to get a signed LiveKit JWT. The API is a thin Fastify server; no media passes through it.
Path 2 — Call (WebRTC): The browser connects directly to LiveKit via WebSocket + UDP. The API is not in the media path; all audio/video is end-to-end encrypted at the SFU layer.
Features
| Feature | Status |
|---|
| Video & audio conferencing | ✅ |
| Screen sharing | ✅ |
| Room recording (composite + per-track) | ✅ |
| RTMP / WHIP ingress (stream from OBS, etc.) | ✅ |
| Text chat over data channel | ✅ |
| Reactions | ✅ |
| Live transcription (Whisper, per-room) | ✅ optional |
| Post-call transcription (async, from recording) | ✅ optional |
| Role-based access (host, moderator, participant, viewer) | ✅ |
| Device selection (mic, camera, speaker) | ✅ |
| Connection quality badges | ✅ |
| HMAC-signed API requests + replay protection | ✅ |
| Per-key rate limiting | ✅ |
| Webhook delivery with retries | ✅ |
| TLS via Caddy (automatic or manual cert) | ✅ |
| TURN/STUN relay via coturn | ✅ |
| S3-compatible recording storage via MinIO | ✅ |
| React, Vue 3, Vanilla JS UI kits | ✅ |
| Framework-agnostic TypeScript SDK | ✅ |
| Native iOS + Android SDKs & UI kits | ✅ |
Quick Start
Prerequisites
- Docker 24+ with Compose v2
openssl (for generating secrets)
1. Configure
cd docker
cp .env.example .env
Edit .env — at minimum replace every REPLACE_WITH_* value. You can generate secrets with:
openssl rand -base64 32 # for API_KEY, API_SECRET
openssl rand -base64 32 # for REDIS_PASSWORD, LIVEKIT_API_SECRET, etc.
2. Start
docker compose up -d
This brings up: Caddy, LiveKit, Egress, coturn, the RTCstack API, Redis, and MinIO.
3. Verify
curl http://localhost:3246/v1/health
{
"status": "ok",
"version": "0.1.0",
"livekit": "connected",
"redis": "connected",
"minio": "connected",
"features": {
"recording": true,
"transcriptionLive": false,
"transcriptionPost": false
}
}
4. Get a token and connect
curl -X POST http://localhost:3246/v1/token \
-H "Content-Type: application/json" \
-H "X-Api-Key: <API_KEY>" \
-H "X-RTCstack-Timestamp: $(date +%s)" \
-H "X-RTCstack-Signature: sha256=<hmac>" \
-d '{ "roomId": "my-room", "userId": "alice", "displayName": "Alice", "role": "host" }'
Use the returned token and url with the SDK to connect.
Project Structure
rtcStack/
├── apps/
│ ├── api/ # Fastify REST API (Node.js / TypeScript)
│ ├── playground/ # Interactive dev playground
│ └── examples/
│ ├── react/ # React reference app
│ ├── vue/ # Vue 3 reference app
│ └── vanilla/ # Vanilla JS reference app
│
├── packages/
│ ├── sdk/ # @rtcstack/sdk — framework-agnostic SDK
│ ├── ui-react/ # @rtcstack/ui-react — React component library
│ ├── ui-vue/ # @rtcstack/ui-vue — Vue 3 component library
│ └── ui-vanilla/ # @rtcstack/ui-vanilla — Vanilla JS library
│
├── mobile/ # Native SDKs + UI kits
│ ├── ios/ # RTCstackKit / RTCstackUI (Swift Package)
│ └── android/ # com.rtcstack:sdk / :ui-compose (Gradle)
│
├── services/
│ ├── whisper/ # Whisper STT HTTP service (Python)
│ ├── stt-live/ # Live transcription agent (Python / LiveKit Agents)
│ └── stt-worker/ # Post-call transcription worker (Node.js)
│
├── docker/
│ ├── docker-compose.yml # Main stack
│ ├── docker-compose.stt.yml # Standalone STT stack (GPU machine)
│ ├── .env.example # Environment template
│ ├── Caddyfile # Reverse proxy config
│ ├── livekit.yaml # LiveKit server config
│ ├── egress.yaml # Recording config
│ └── turnserver.conf # coturn TURN/STUN config
│
├── turbo.json # Turborepo task pipeline
└── pnpm-workspace.yaml
Packages
@rtcstack/sdk
Framework-agnostic TypeScript SDK. The only thing you need to build a custom UI.
import { createCall } from '@rtcstack/sdk'
const call = createCall({ token, url })
await call.connect()
call.on('participantJoined', (p) => console.log(p.name, 'joined'))
call.on('transcriptReceived', (seg) => console.log(seg.speaker, ':', seg.text))
call.on('speakingStarted', (id, name) => console.log(name, 'is speaking…'))
await call.toggleMic()
await call.startScreenShare()
await call.sendMessage('Hello!')
await call.disconnect()
Key APIs:
| API | Description |
|---|
call.connect() / disconnect() | Join / leave the room |
call.participants | Map<id, Participant> — all remote participants |
call.localParticipant | Local participant with media state |
call.activeSpeakers | Current speakers sorted by audio level |
call.messages | Chat history (capped at 500) |
call.devices | Enumerated camera/mic/speaker devices |
call.toggleMic() / toggleCamera() | Toggle local tracks |
call.startScreenShare() / stopScreenShare() | Screen sharing |
call.sendMessage(text) | Broadcast text chat |
call.sendReaction(emoji) | Send floating emoji reaction |
call.switchDevice(kind, deviceId) | Switch active device |
call.setLayout(layout) | 'grid' | 'speaker' | 'spotlight' |
call.pin(participantId) | Pin a participant |
Events:
| Event | Payload |
|---|
connectionStateChanged | ConnectionState |
participantJoined / participantLeft | Participant |
participantUpdated | Participant |
activeSpeakerChanged | Participant[] |
screenShareStarted / screenShareStopped | Participant / id |
messageReceived | Message |
reactionReceived | (from: string, emoji: string) |
transcriptReceived | TranscriptSegment |
speakingStarted | (speakerId: string, speakerName: string) |
speakingStopped | speakerId: string |
recordingStarted / recordingStopped | — |
reconnecting / reconnected | — |
disconnected | reason?: string |
devicesChanged | DeviceList |
For testing without a LiveKit server:
import { createMockCall } from '@rtcstack/sdk/mock'
const call = createMockCall()
@rtcstack/ui-react
React 18 component library. Batteries included — full conference room or individual composable pieces.
import { CallProvider, VideoConference } from '@rtcstack/ui-react'
import '@rtcstack/ui-react/dist/styles.css'
function App() {
return (
<CallProvider call={call}>
<VideoConference />
</CallProvider>
)
}
Hooks:
const participants = useParticipants()
const local = useLocalParticipant()
const speakers = useActiveSpeakers()
const messages = useMessages()
const transcripts = useTranscription() // TranscriptSegment[]
const speaking = useSpeakingIndicators() // Map<speakerId, speakerName>
const { toggleMic } = useMediaControls()
@rtcstack/ui-vue
Vue 3 component library with Composition API composables.
<script setup>
import { VideoConference } from '@rtcstack/ui-vue'
import '@rtcstack/ui-vue/dist/styles.css'
</script>
<template>
<VideoConference />
</template>
Composables mirror the React hooks: useParticipants(), useLocalParticipant(), useTranscription(), useSpeakingIndicators(), useMediaControls(), etc.
@rtcstack/ui-vanilla
No-framework JavaScript library. Mount a full conference room into any HTMLElement.
import { mountVideoConference } from '@rtcstack/ui-vanilla'
const { unmount } = mountVideoConference(document.getElementById('room'), { call })
// When done:
unmount()
Native mobile (iOS + Android)
The same Call surface, event model, and data-channel wire format, implemented natively so mobile
and web clients share rooms:
- iOS —
RTCstackKit / RTCstackUI (Swift Package, wraps client-sdk-swift)
- Android —
com.rtcstack:sdk / com.rtcstack:ui-compose (wraps io.livekit:livekit-android)
Build/test/integration details: mobile/README.md. Cross-platform wire-format
contract: packages/sdk/WIRE_FORMAT.md.
API Reference
All endpoints live under /v1/. Every request (except /v1/health) must be authenticated — see Authentication.
Health
| Method | Path | Auth | Description |
|---|
| GET | /v1/health | None | Status and feature flags |
Tokens
| Method | Path | Description |
|---|
| POST | /v1/token | Generate a LiveKit JWT for a user |
| POST | /v1/token/refresh | Refresh an expiring token |
POST /v1/token body:
{
"roomId": "string",
"userId": "string",
"displayName": "string",
"role": "host | moderator | participant | viewer",
"metadata": {}
}
Response:
{
"token": "<jwt>",
"url": "wss://your-domain/livekit",
"expiresAt": 1714000000
}
Rooms
| Method | Path | Description |
|---|
| POST | /v1/rooms | Create a room |
| GET | /v1/rooms | List active rooms |
| GET | /v1/rooms/:roomId | Room details |
| DELETE | /v1/rooms/:roomId | Close room (disconnects all participants) |
Participants
| Method | Path | Description |
|---|
| GET | /v1/rooms/:roomId/participants | List participants |
| DELETE | /v1/rooms/:roomId/participants/:participantId | Kick participant |
| POST | /v1/rooms/:roomId/participants/:participantId/mute | Server-side mic mute |
Recording
| Method | Path | Description |
|---|
| POST | /v1/rooms/:roomId/recording/start | Start composite recording |
| POST | /v1/rooms/:roomId/recording/stop | Stop recording |
| GET | /v1/rooms/:roomId/recording | Recording status and egress ID |
Recordings are stored in MinIO under the rtcstack-recordings bucket.
Ingress (RTMP / WHIP)
| Method | Path | Description |
|---|
| POST | /v1/rooms/:roomId/ingress | Create RTMP or WHIP ingress endpoint |
| GET | /v1/rooms/:roomId/ingress | List ingress endpoints |
| DELETE | /v1/rooms/:roomId/ingress/:ingressId | Stop ingress |
Webhooks