Zoom Rtms
AnthropicOfficialCreated by ZoomDistributed by Anthropic
- Skill
- Tool integration
- Automation and integrations
- MIT
- No issues found
From the skill’s files
Taken from the skill’s own files (its description, section headings and stated usage). Not an AutoGineer summary, and not written by the author as one.
Reference skill for Zoom RTMS. Use after routing to a live-media workflow when processing real-time audio, video, chat, transcripts, screen share, or contact-center voice streams. Background reference for live Zoom media pipelines. Prefer build-zoom-bot first, then use this skill for stream types, capabilities, and RTMS-specific implementation constraints.
What it does
- Read This First (Critical)
- Quick Links
- Supported Products
- RTMS Overview
- Prerequisites
- Quick Start (SDK - Recommended)
When to use it
The skill’s files do not state this.
Advantages
The skill’s files do not state this.
Usage examples
The skill’s files do not state this.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/anthropics/zoom-rtms/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip zoom-rtms.zip -d .agents/skills
Documentation by Zoom
Zoom Realtime Media Streams (RTMS)
Background reference for live Zoom media pipelines. Prefer build-zoom-bot first, then use this skill for stream types, capabilities, and RTMS-specific implementation constraints.
Zoom Realtime Media Streams (RTMS)
Expert guidance for accessing live audio, video, transcript, chat, and screen share data from Zoom meetings, webinars, Video SDK sessions, and Zoom Contact Center Voice in real-time. RTMS uses a WebSocket-based protocol with open standards and does not require a meeting bot to capture the media plane.
Read This First (Critical)
RTMS is primarily a backend media ingestion service.
Your backend receives and processes live media: audio, video, screen share, chat, transcript.
RTMS is not a frontend UI SDK by itself.
Processing is event-triggered: backend waits for RTMS start webhook events before stream handling begins.
Optional architecture (common):
Add a Zoom App SDK frontend for in-client UI/controls.
Stream backend RTMS outputs to frontend via WebSocket (or SSE, gRPC, queue workers, etc.).
Use RTMS for media/data plane, and use frontend frameworks/Zoom Apps for presentation + user interactions.
Official Documentation: https://developers.zoom.us/docs/rtms/ SDK Reference (JS): https://zoom.github.io/rtms/js/ SDK Reference (Python): https://zoom.github.io/rtms/py/ Sample Repository: https://github.com/zoom/rtms-samples
Quick Links
New to RTMS? Follow this path:
Connection Architecture - Two-phase WebSocket design
SDK Quickstart - Fastest way to receive media (recommended)
Manual WebSocket - Full protocol control without SDK
Media Types - Audio, video, transcript, chat, screen share
Complete Implementation:
RTMS Bot - End-to-end bot implementation guide
Reference:
Lifecycle Flow - Complete webhook-to-streaming flow
Data Types - All enums and constants
Webhooks - Event subscription details
Environment Variables - credential modes and runtime knobs
Quickstart Notes - Secondary quickstart guide
Integrated Index - see the section below in this file
Having issues?
Connection fails -> Common Issues
Duplicate connections -> Webhook Gotchas
No audio/video -> Media Configuration
Start with preflight checks -> 5-Minute Runbook
Supported Products
| Product | Webhook Event | Payload ID | App Type |
|---|---|---|---|
| Meetings | meeting.rtms_started / meeting.rtms_stopped | meeting_uuid | General App |
| Webinars | webinar.rtms_started / webinar.rtms_stopped | meeting_uuid (same!) | General App |
| Video SDK | session.rtms_started / session.rtms_stopped | session_id | Video SDK App |
| Zoom Contact Center Voice | Product-specific RTMS/ZCC Voice events | Product-specific stream/session identifiers | Contact Center / approved RTMS integration |
Once connected, the core signaling/media socket model is shared across products. Meetings, webinars, and Video SDK sessions use the familiar start/stop webhooks. Zoom Contact Center Voice adds its own RTMS/ZCC Voice event family and should be treated as the same transport model with product-specific event payloads.
RTMS Overview
RTMS is a data pipeline that gives your app access to live media from Zoom meetings, webinars, and Video SDK sessions without participant bots. Instead of having automated clients join meetings, use RTMS to collect media data directly from Zoom's infrastructure.
What RTMS Provides
| Media Type | Format | Use Cases |
|---|---|---|
| Audio | PCM (L16), G.711, G.722, Opus | Transcription, voice analysis, recording |
| Video | H.264, JPG, PNG | Recording, AI vision, thumbnails, active participant selection |
| Screen Share | H.264, JPG, PNG | Content capture, slide extraction |
| Transcript | JSON text | Meeting notes, search, compliance |
| Chat | JSON text | Archive, sentiment analysis |
March 2026 Protocol Changes
Zoom Contact Center Voice support: RTMS now covers Contact Center Voice audio and transcript scenarios.
Transcript Language Identification control: transcript media handshakes now support
src_languageandenable_lid. Default behavior is LID enabled. Setenable_lid: falseto force a fixed language.Single individual video stream subscription: RTMS can now stream one participant's camera feed at a time when
data_optis set toVIDEO_SINGLE_INDIVIDUAL_STREAM.Graceful client-initiated shutdown: backends can send
STREAM_CLOSE_REQover the signaling socket and wait forSTREAM_CLOSE_RESP.Media keep-alive tolerance increased: media socket keep-alive timeout is now 65 seconds, not 35.
Two Approaches
| Approach | Best For | Complexity |
|---|---|---|
SDK (@zoom/rtms) | Most use cases | Low - handles WebSocket complexity |
| Manual WebSocket | Custom protocols, other languages | High - full protocol implementation |
Prerequisites
Node.js 20.3.0+ (24 LTS recommended) for JavaScript SDK
Python 3.10+ for Python SDK
Zoom General App (for meetings/webinars) or Video SDK App (for Video SDK) with RTMS feature enabled
Webhook endpoint for RTMS events
Server to receive WebSocket streams
Need RTMS access? Post in Zoom Developer Forum requesting RTMS access with your use case.
Quick Start (SDK - Recommended)
import rtms from "@zoom/rtms";
// All RTMS start/stop events across products
const RTMS_EVENTS = ["meeting.rtms_started", "webinar.rtms_started", "session.rtms_started"];
// Handle webhook events
rtms.onWebhookEvent(({ event, payload }) => {
if (!RTMS_EVENTS.includes(event)) return;
const client = new rtms.Client();
client.onAudioData((data, timestamp, metadata) => {
console.log(`Audio from ${metadata.userName}: ${data.length} bytes`);
});
client.onTranscriptData((data, timestamp, metadata) => {
const text = data.toString('utf8');
console.log(`${metadata.userName}: ${text}`);
});
client.onJoinConfirm((reason) => {
console.log(`Joined session: ${reason}`);
});
// SDK handles all WebSocket connections automatically
// Accepts both meeting_uuid and session_id transparently
client.join(payload);
});Quick Start (Manual WebSocket)
For full control or non-SDK languages, implement the two-phase WebSocket protocol:
const WebSocket = require('ws');
const crypto = require('crypto');
const RTMS_EVENTS = ['meeting.rtms_started', 'webinar.rtms_started', 'session.rtms_started'];
// 1. Generate signature
// For meetings/webinars: uses meeting_uuid. For Video SDK: uses session_id.
function generateSignature(clientId, idValue, streamId, clientSecret) {
const message = `${clientId},${idValue},${streamId}`;
return crypto.createHmac('sha256', clientSecret).update(message).digest('hex');
}
// 2. Handle webhook
app.post('/webhook', (req, res) => {
res.status(200).send(); // CRITICAL: Respond immediately!
const { event, payload } = req.body;
if (RTMS_EVENTS.includes(event)) {
connectToRTMS(payload);
}
});
// 3. Connect to signaling WebSocket
function connectToRTMS(payload) {
const { server_urls, rtms_stream_id } = payload;
// meeting_uuid for meetings/webinars, session_id for Video SDK
const idValue = payload.meeting_uuid || payload.session_id;
const signature = generateSignature(CLIENT_ID, idValue, rtms_stream_id, CLIENT_SECRET);
const signalingWs = new WebSocket(server_urls);
signalingWs.on('open', () => {
signalingWs.send(JSON.stringify({
msg_type: 1, // Handshake request
protocol_version: 1,
meeting_uuid: idValue,
rtms_stream_id,
signature,
media_type: 9 // AUDIO(1) | TRANSCRIPT(8)
}));
});
// ... handle responses, connect to media WebSocket
}See: Manual WebSocket Guide for complete implementation.
Media Type Bitmask
Combine types with bitwise OR:
| Type | Value | Description |
|---|---|---|
| Audio | 1 | PCM audio samples |
| Video | 2 | H.264/JPG video frames |
| Screen Share | 4 | Separate from video! |
| Transcript | 8 | Real-time speech-to-text |
| Chat | 16 | In-meeting chat messages |
| All | 32 | All media types |
Example: Audio + Transcript = 1 | 8 = 9
Critical Gotchas
| Issue | Solution |
|---|---|
| Only 1 connection allowed | New connections kick out existing ones. Track active sessions! |
| Respond 200 immediately | If webhook delays, Zoom retries creating duplicate connections |
| Heartbeat mandatory | Respond to msg_type 12 with msg_type 13, or connection dies |
| Reconnection is YOUR job | RTMS doesn't auto-reconnect. Media keep-alive tolerance is now about 65s; signaling remains around 60s |
| Transcript language drift | Use src_language plus enable_lid: false when you want fixed-language transcription instead of automatic language switching |
| Single participant video only | VIDEO_SINGLE_INDIVIDUAL_STREAM supports one participant at a time. A new VIDEO_SUBSCRIPTION_REQ overrides the previous selection |
| Graceful close is explicit now | Use STREAM_CLOSE_REQ / STREAM_CLOSE_RESP when your backend wants to terminate the stream cleanly |
Environment Variables
SDK Environment Variables
# Required - Authentication
ZM_RTMS_CLIENT=your_client_id # Zoom OAuth Client ID
ZM_RTMS_SECRET=your_client_secret # Zoom OAuth Client Secret
# Optional - Webhook server
ZM_RTMS_PORT=8080 # Default: 8080
ZM_RTMS_PATH=/webhook # Default: /
# Optional - Logging
ZM_RTMS_LOG_LEVEL=info # error, warn, info, debug, trace
ZM_RTMS_LOG_FORMAT=progressive # progressive or json
ZM_RTMS_LOG_ENABLED=trueManual Implementation Variables
ZOOM_CLIENT_ID=your_client_id
ZOOM_CLIENT_SECRET=your_client_secret
ZOOM_SECRET_TOKEN=your_webhook_token # For webhook validationZoom App Setup
For Meetings and Webinars (General App)
Go to marketplace.zoom.us -> Develop -> Build App
Choose General App -> User-Managed
Features -> Access -> Enable Event Subscription
Add Events -> Search "rtms" -> Select:
meeting.rtms_startedmeeting.rtms_stoppedwebinar.rtms_started(if using webinars)webinar.rtms_stopped(if using webinars)
Scopes -> Add Scopes -> Search "rtms" -> Add:
meeting:read:meeting_audiomeeting:read:meeting_videomeeting:read:meeting_transcriptmeeting:read:meeting_chatwebinar:read:webinar_audio(if using webinars)webinar:read:webinar_video(if using webinars)webinar:read:webinar_transcript(if using webinars)webinar:read:webinar_chat(if using webinars)
For Video SDK (Video SDK App)
Go to marketplace.zoom.us -> Develop -> Build App
Choose Video SDK App
Use your SDK Key and SDK Secret (not OAuth Client ID/Secret)
Add Events:
session.rtms_startedsession.rtms_stopped
Sample Repositories
Official Samples
| Repository | Description |
|---|---|
| rtms-samples | RTMSManager, boilerplates, AI samples |
| rtms-quickstart-js | JavaScript SDK quickstart |
| rtms-quickstart-py | Python SDK quickstart |
| rtms-sdk-cpp | C++ SDK |
| zoom-rtms | Main SDK repository |
AI Integration Samples
| Sample | Description |
|---|---|
| rtms-meeting-assistant-starter-kit | AI meeting assistant with summaries |
| arlo-meeting-assistant | Production meeting assistant with DB |
| videosdk-rtms-transcribe-audio | Whisper transcription |
Complete Documentation
Concepts
Connection Architecture - Two-phase WebSocket design
Lifecycle Flow - Webhook to streaming flow
Examples
SDK Quickstart - Using @zoom/rtms SDK
Manual WebSocket - Raw protocol implementation
RTMS Bot - Complete bot implementation guide
AI Integration - Transcription and analysis patterns
References
Media Types - Audio, video, transcript, chat, screen share
Data Types - All enums and constants
Connection - WebSocket protocol details
Webhooks - Event subscription
Troubleshooting
Common Issues - FAQ and solutions
Resources
Official docs: https://developers.zoom.us/docs/rtms/
Data types: https://developers.zoom.us/docs/rtms/data-types/
Media params: https://developers.zoom.us/docs/rtms/media-parameter-definition/
Developer forum: https://devforum.zoom.us/
Need help? Start with Integrated Index section below for complete navigation.
Integrated Index
This section was migrated from SKILL.md.
RTMS provides real-time access to live audio, video, transcript, chat, and screen share from Zoom meetings, webinars, and Video SDK sessions.
Critical Positioning
Treat RTMS as a backend service for receiving and processing media streams.
Backend role: ingest audio/video/share/chat/transcript, run AI/analytics, persist/forward data.
Optional frontend role: Zoom App SDK or web dashboard that consumes processed stream data from backend transport (WebSocket/SSE/other).
Kickoff model: backend waits for RTMS start webhook events, then starts stream processing.
Do not model RTMS as a frontend-only SDK.
Quick Start Path
If you're new to RTMS, follow this order:
Run preflight checks first -> RUNBOOK.md
Understand the architecture -> concepts/connection-architecture.md
Two-phase WebSocket: Signaling + Media
Why RTMS doesn't use bots
Choose your approach -> SDK or Manual
SDK (recommended): examples/sdk-quickstart.md
Manual WebSocket: examples/manual-websocket.md
Understand the lifecycle -> concepts/lifecycle-flow.md
Webhook -> Signaling -> Media -> Streaming
Configure media types -> references/media-types.md
Audio, video, transcript, chat, screen share
Troubleshoot issues -> troubleshooting/common-issues.md
Connection problems, duplicate webhooks, missing data
Documentation Structure
rtms/
├── SKILL.md # Main skill overview
├── SKILL.md # This file - navigation guide
│
├── concepts/ # Core architectural patterns
│ ├── connection-architecture.md # Two-phase WebSocket design
│ └── lifecycle-flow.md # Webhook to streaming flow
│
├── examples/ # Complete working code
│ ├── sdk-quickstart.md # Using @zoom/rtms SDK
│ ├── manual-websocket.md # Raw protocol implementation
│ ├── rtms-bot.md # Complete RTMS bot implementation
│ └── ai-integration.md # Transcription and analysis
│
├── references/ # Reference documentation
│ ├── media-types.md # Audio, video, transcript, chat, share
│ ├── data-types.md # All enums and constants
│ ├── connection.md # WebSocket protocol details
│ └── webhooks.md # Event subscription
│
└── troubleshooting/ # Problem solving guides
└── common-issues.md # FAQ and solutionsBy Use Case
I want to get meeting transcripts
SDK Quickstart - Fastest approach
Media Types - Transcript configuration
AI Integration - Whisper, Deepgram, AssemblyAI
I want to record meetings
Media Types - Audio + Video configuration
SDK Quickstart - Receiving media
AI Integration - Gap-filled recording
I want to build an AI meeting assistant
AI Integration - Complete patterns
SDK Quickstart - Media ingestion
Lifecycle Flow - Event handling
I want to build a complete RTMS bot
RTMS Bot - Complete implementation guide
Lifecycle Flow - Webhook to streaming flow
Connection Architecture - Two-phase design
I need full protocol control
Manual WebSocket - START HERE
Connection Architecture - Two-phase design
Data Types - All message types and enums
Connection - Protocol details
I'm getting connection errors
Common Issues - Diagnostic checklist
Connection Architecture - Verify flow
Webhooks - Validation and timing
I want to understand the architecture
Connection Architecture - Two-phase WebSocket
Lifecycle Flow - Complete flow diagram
Data Types - Protocol constants
By Product
I'm building for Zoom Meetings
Standard RTMS setup. Webhook event:
meeting.rtms_started. Uses General App with OAuth.Start with SDK Quickstart or Manual WebSocket.
I'm building for Zoom Webinars
Same as meetings, but webhook event is
webinar.rtms_started. Payload still usesmeeting_uuid(NOTwebinar_uuid).Add webinar scopes and event subscriptions. See Webhooks.
Only panelist streams are confirmed available. Attendee streams may not be individual.
I'm building for Zoom Video SDK
Webhook event:
session.rtms_started. Payload usessession_id(NOTmeeting_uuid).Requires a Video SDK App with SDK Key/Secret (not OAuth Client ID/Secret).
Once connected, the protocol is identical to meetings.
See Webhooks for payload details.
Key Documents
1. Connection Architecture (CRITICAL)
concepts/connection-architecture.md
RTMS uses two separate WebSocket connections:
Signaling WebSocket: Authentication, control, heartbeats
Media WebSocket: Actual audio/video/transcript data
2. SDK vs Manual (DECISION POINT)
examples/sdk-quickstart.md vs examples/manual-websocket.md
| SDK | Manual |
|---|---|
| Handles WebSocket complexity | Full protocol control |
| Automatic reconnection | DIY reconnection |
| Less code | More code |
| Best for most use cases | Best for custom requirements |
3. Critical Gotchas (MOST COMMON ISSUES)
troubleshooting/common-issues.md
Respond 200 immediately - Delayed webhook responses cause duplicates
Only 1 connection per stream - New connections kick out existing
Heartbeat required - Must respond to keep-alive or connection dies
Track active sessions - Prevent duplicate join attempts
Key Learnings
Critical Discoveries:
Two-Phase WebSocket Design
Signaling: Control plane (handshake, heartbeat, start/stop)
Media: Data plane (audio, video, transcript, chat, share)
Webhook Response Timing
MUST respond 200 BEFORE any processing
Delayed response -> Zoom retries -> duplicate connections
See: Common Issues
Heartbeat is Mandatory
Signaling: Receive msg_type 12, respond with msg_type 13
Media: Same pattern
Failure to respond = connection closed
See: Connection
Signature Generation
Format:
HMAC-SHA256(clientSecret, "clientId,meetingUuid,streamId")For Video SDK, use
session_idin place ofmeetingUuidWebinars still use
meeting_uuid(notwebinar_uuid)Required for both signaling and media handshakes
See: Manual WebSocket
Media Types are Bitmasks
Audio=1, Video=2, Share=4, Transcript=8, Chat=16, All=32
Combine with OR: Audio+Transcript = 1|8 = 9
See: Media Types
Screen Share is SEPARATE from Video
Different msg_type (16 vs 15)
Different media flag (4 vs 2)
Must subscribe separately
See: Media Types
Quick Reference
"Connection fails"
"Duplicate connections"
"No audio/video data"
-> Media Types - Check configuration
"How do I implement manually?"
"What message types exist?"
-> Data Types
"How do I integrate AI?"
Document Version
Based on Zoom RTMS SDK v1.x and official documentation as of 2026.
Happy coding!
Remember: Start with SDK Quickstart for the fastest path, or Manual WebSocket if you need full control.
Files
- ATTRIBUTION.mdAdded by AutoGineer969 B
- LICENSELicence file added by AutoGineer1.1 KiB
- RUNBOOK.md2.6 KiB
- SKILL.md23.4 KiB
- concepts/connection-architecture.md9.1 KiB
- concepts/lifecycle-flow.md13.5 KiB
- examples/ai-integration.md11.4 KiB
- examples/manual-websocket.md16.9 KiB
- examples/rtms-bot.md26.3 KiB
- examples/sdk-quickstart.md9.3 KiB
- references/connection.md8.0 KiB
- references/data-types.md13.7 KiB
- references/environment-variables.md1.3 KiB
- references/media-types.md6.0 KiB
- references/quickstart.md6.6 KiB
- references/webhooks.md7.4 KiB
- troubleshooting/common-issues.md10.4 KiB
examples/manual-websocket.md
Manual WebSocket Implementation
Full RTMS protocol implementation without the SDK. Use this for:
Languages without SDK support
Custom protocol requirements
Learning the underlying protocol
Overview
RTMS requires two WebSocket connections:
Signaling WebSocket - Control plane (handshake, heartbeat, start/stop)
Media WebSocket - Data plane (audio, video, transcript, chat, share)
Complete Implementation
const WebSocket = require('ws');
const crypto = require('crypto');
const express = require('express');
const app = express();
app.use(express.json());
// Configuration
const CLIENT_ID = process.env.ZOOM_CLIENT_ID;
const CLIENT_SECRET = process.env.ZOOM_CLIENT_SECRET;
const SECRET_TOKEN = process.env.ZOOM_SECRET_TOKEN;
// Active connections
const signalingConnections = new Map();
const mediaConnections = new Map();
const activeSessions = new Map();
const activeVideoUsers = new Map();
// ============================================
// SIGNATURE GENERATION
// Uses meeting_uuid for meetings/webinars, session_id for Video SDK
// ============================================
function generateSignature(clientId, idValue, streamId, clientSecret) {
const message = `${clientId},${idValue},${streamId}`;
return crypto.createHmac('sha256', clientSecret)
.update(message)
.digest('hex');
}
// ============================================
// WEBHOOK HANDLER
// ============================================
const RTMS_EVENTS = ['meeting.rtms_started', 'webinar.rtms_started', 'session.rtms_started'];
const RTMS_STOP_EVENTS = ['meeting.rtms_stopped', 'webinar.rtms_stopped', 'session.rtms_stopped'];
app.post('/webhook', (req, res) => {
// CRITICAL: Respond 200 IMMEDIATELY before any processing!
res.status(200).send();
const { event, payload } = req.body;
// Handle URL validation challenge
if (event === 'endpoint.url_validation') {
const hash = crypto
.createHmac('sha256', SECRET_TOKEN)
.update(payload.plainToken)
.digest('hex');
return res.json({
plainToken: payload.plainToken,
encryptedToken: hash
});
}
// Handle RTMS events (meetings, webinars, and Video SDK)
if (RTMS_EVENTS.includes(event)) {
handleRTMSStarted(payload.object);
} else if (RTMS_STOP_EVENTS.includes(event)) {
handleRTMSStopped(payload.object);
}
});
// ============================================
// RTMS START HANDLER
// ============================================
function handleRTMSStarted(payload) {
const { rtms_stream_id, server_urls } = payload;
// meeting_uuid for meetings/webinars, session_id for Video SDK
const idValue = payload.meeting_uuid || payload.session_id;
// Prevent duplicate connections
if (activeSessions.has(rtms_stream_id)) {
console.log('Already connected to this stream, ignoring');
return;
}
activeSessions.set(rtms_stream_id, {
idValue: idValue,
startTime: Date.now()
});
connectToSignaling(idValue, rtms_stream_id, server_urls);
}
// ============================================
// SIGNALING WEBSOCKET
// ============================================
function connectToSignaling(idValue, streamId, serverUrl) {
console.log('Connecting to signaling:', serverUrl);
const signature = generateSignature(CLIENT_ID, idValue, streamId, CLIENT_SECRET);
const ws = new WebSocket(serverUrl);
signalingConnections.set(streamId, ws);
ws.on('open', () => {
console.log('Signaling connected, sending handshake');
ws.send(JSON.stringify({
msg_type: 1, // SIGNALING_HAND_SHAKE_REQ
protocol_version: 1,
meeting_uuid: idValue, // Works for both meeting_uuid and session_id
rtms_stream_id: streamId,
sequence: Math.floor(Math.random() * 1000000),
signature: signature,
media_type: 9 // AUDIO(1) | TRANSCRIPT(8)
}));
});
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
handleSignalingMessage(msg, idValue, streamId);
});
ws.on('close', (code, reason) => {
console.log('Signaling closed:', code, reason.toString());
signalingConnections.delete(streamId);
// Implement reconnection logic if needed
});
ws.on('error', (error) => {
console.error('Signaling error:', error);
});
}
function handleSignalingMessage(msg, idValue, streamId) {
switch (msg.msg_type) {
case 2: // SIGNALING_HAND_SHAKE_RESP
if (msg.status_code === 0) {
console.log('Signaling handshake success');
// Extract media server URL and connect
const mediaUrl = msg.media_server.server_urls.all;
connectToMedia(idValue, streamId, mediaUrl);
} else {
console.error('Signaling handshake failed:', msg.status_code);
}
break;
case 6: // EVENT_UPDATE
handleEventUpdate(msg, streamId);
break;
case 8: // STREAM_STATE_UPDATE
console.log('Stream state:', msg.state);
break;
case 9: // SESSION_STATE_UPDATE
console.log('Session state:', msg.state);
break;
case 12: // KEEP_ALIVE_REQ
const signalingWs = signalingConnections.get(streamId);
if (signalingWs) {
signalingWs.send(JSON.stringify({
msg_type: 13, // KEEP_ALIVE_RESP
timestamp: msg.timestamp
}));
}
break;
}
}
function handleEventUpdate(msg, streamId) {
const eventType = msg.event?.event_type ?? msg.event_type;
const participants = msg.event?.participants ?? [];
switch (eventType) {
case 2: // ACTIVE_SPEAKER_CHANGE
console.log('Active speaker:', msg.user_name);
break;
case 3: // PARTICIPANT_JOIN
console.log('Participant joined:', msg.user_name);
break;
case 4: // PARTICIPANT_LEAVE
console.log('Participant left:', msg.user_name);
break;
case 5: // SHARING_START
console.log('Sharing started by:', msg.user_name);
break;
case 6: // SHARING_STOP
console.log('Sharing stopped');
break;
case 8: // PARTICIPANT_VIDEO_ON
for (const participant of participants) {
const set = activeVideoUsers.get(streamId) || new Set();
set.add(participant.user_id);
activeVideoUsers.set(streamId, set);
}
break;
case 9: // PARTICIPANT_VIDEO_OFF
for (const participant of participants) {
activeVideoUsers.get(streamId)?.delete(participant.user_id);
}
break;
}
}
// ============================================
// MEDIA WEBSOCKET
// ============================================
function connectToMedia(idValue, streamId, mediaUrl) {
console.log('Connecting to media:', mediaUrl);
const signature = generateSignature(CLIENT_ID, idValue, streamId, CLIENT_SECRET);
const ws = new WebSocket(mediaUrl);
mediaConnections.set(streamId, ws);
ws.on('open', () => {
console.log('Media connected, sending handshake');
ws.send(JSON.stringify({
msg_type: 3, // DATA_HAND_SHAKE_REQ
protocol_version: 1,
meeting_uuid: idValue, // Works for both meeting_uuid and session_id
rtms_stream_id: streamId,
signature: signature,
media_type: 9, // AUDIO(1) | TRANSCRIPT(8)
payload_encryption: false,
media_params: {
audio: {
content_type: 2, // RAW_AUDIO
sample_rate: 1, // 16kHz
channel: 1, // Mono
codec: 1, // L16 (PCM)
data_opt: 1, // Mixed stream
send_rate: 20 // 20ms chunks
},
transcript: {
content_type: 5, // TEXT
src_language: 9, // English
enable_lid: false // Fixed language, no auto-switch
}
}
}));
});
ws.on('message', (data) => {
const msg = JSON.parse(data.toString());
handleMediaMessage(msg, streamId);
});
ws.on('close', (code, reason) => {
console.log('Media closed:', code, reason.toString());
mediaConnections.delete(streamId);
});
ws.on('error', (error) => {
console.error('Media error:', error);
});
}
function handleMediaMessage(msg, streamId) {
switch (msg.msg_type) {
case 4: // DATA_HAND_SHAKE_RESP
if (msg.status_code === 0) {
console.log('Media handshake success, sending client ready');
// Tell signaling we're ready to receive
const signalingWs = signalingConnections.get(streamId);
if (signalingWs) {
signalingWs.send(JSON.stringify({
msg_type: 7, // CLIENT_READY_ACK
rtms_stream_id: streamId
}));
}
} else {
console.error('Media handshake failed:', msg.status_code);
}
break;
case 12: // KEEP_ALIVE_REQ
const mediaWs = mediaConnections.get(streamId);
if (mediaWs) {
mediaWs.send(JSON.stringify({
msg_type: 13, // KEEP_ALIVE_RESP
timestamp: msg.timestamp
}));
}
break;
case 14: // MEDIA_DATA_AUDIO
handleAudioData(msg);
break;
case 15: // MEDIA_DATA_VIDEO
handleVideoData(msg);
break;
case 16: // MEDIA_DATA_SHARE
handleShareData(msg);
break;
case 17: // MEDIA_DATA_TRANSCRIPT
handleTranscriptData(msg);
break;
case 18: // MEDIA_DATA_CHAT
handleChatData(msg);
break;
}
}
// ============================================
// MEDIA DATA HANDLERS
// ============================================
function handleAudioData(msg) {
const audioBuffer = Buffer.from(msg.content, 'base64');
console.log(`Audio: ${audioBuffer.length} bytes from ${msg.user_name || 'mixed'}`);
// Process audio:
// - Send to transcription service
// - Save to file
// - Stream to output
}
function handleVideoData(msg) {
const videoBuffer = Buffer.from(msg.content, 'base64');
console.log(`Video: ${videoBuffer.length} bytes from ${msg.user_name}`);
// Process video:
// - Decode H.264/JPG
// - Save frames
// - AI analysis
}
function handleShareData(msg) {
const shareBuffer = Buffer.from(msg.content, 'base64');
console.log(`Share: ${shareBuffer.length} bytes from ${msg.user_name}`);
}
function handleTranscriptData(msg) {
console.log(`[${msg.user_name}]: ${msg.content}`);
// Save transcript, process with AI, etc.
}
function handleChatData(msg) {
console.log(`[Chat] ${msg.user_name}: ${msg.content}`);
}
// ============================================
// RTMS STOP HANDLER
// ============================================
function handleRTMSStopped(payload) {
const streamId = payload.rtms_stream_id;
console.log('RTMS stopped:', streamId);
// Close connections
const signalingWs = signalingConnections.get(streamId);
const mediaWs = mediaConnections.get(streamId);
if (signalingWs) signalingWs.close();
if (mediaWs) mediaWs.close();
// Cleanup
signalingConnections.delete(streamId);
mediaConnections.delete(streamId);
activeSessions.delete(streamId);
}
// ============================================
// START SERVER
// ============================================
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`RTMS server running on port ${PORT}`);
});Message Type Reference
Signaling Messages
| msg_type | Name | Direction | Description |
|---|---|---|---|
| 1 | SIGNALING_HAND_SHAKE_REQ | Client -> Server | Initial handshake |
| 2 | SIGNALING_HAND_SHAKE_RESP | Server -> Client | Handshake response with media URL |
| 5 | EVENT_SUBSCRIPTION | Client -> Server | Subscribe to events |
| 6 | EVENT_UPDATE | Server -> Client | Event notification |
| 7 | CLIENT_READY_ACK | Client -> Server | Ready to receive media |
| 8 | STREAM_STATE_UPDATE | Server -> Client | Stream state changed |
| 9 | SESSION_STATE_UPDATE | Server -> Client | Session state changed |
| 12 | KEEP_ALIVE_REQ | Server -> Client | Heartbeat ping |
| 13 | KEEP_ALIVE_RESP | Client -> Server | Heartbeat pong |
Media Messages
| msg_type | Name | Direction | Description |
|---|---|---|---|
| 3 | DATA_HAND_SHAKE_REQ | Client -> Server | Media handshake with params |
| 4 | DATA_HAND_SHAKE_RESP | Server -> Client | Media handshake response |
| 12 | KEEP_ALIVE_REQ | Server -> Client | Heartbeat ping |
| 13 | KEEP_ALIVE_RESP | Client -> Server | Heartbeat pong |
| 14 | MEDIA_DATA_AUDIO | Server -> Client | Audio data |
| 15 | MEDIA_DATA_VIDEO | Server -> Client | Video data |
| 16 | MEDIA_DATA_SHARE | Server -> Client | Screen share data |
| 17 | MEDIA_DATA_TRANSCRIPT | Server -> Client | Transcript data |
| 18 | MEDIA_DATA_CHAT | Server -> Client | Chat message |
Media Parameters
Audio Parameters
{
content_type: 2, // 1=RTP, 2=RAW_AUDIO
sample_rate: 1, // 0=8kHz, 1=16kHz, 2=32kHz, 3=48kHz
channel: 1, // 1=Mono, 2=Stereo (OPUS only)
codec: 1, // 1=L16, 2=G.711, 3=G.722, 4=OPUS
data_opt: 1, // 1=Mixed, 2=Multi-streams
send_rate: 20 // Chunk size in ms (multiple of 20)
}
function subscribeToParticipantVideo(streamId, userId) {
const signalingWs = signalingConnections.get(streamId);
if (!signalingWs) return;
signalingWs.send(JSON.stringify({
msg_type: 28, // VIDEO_SUBSCRIPTION_REQ
user_id: userId,
subscribe: true,
timestamp: Date.now()
}));
}
function closeStream(streamId) {
const signalingWs = signalingConnections.get(streamId);
if (!signalingWs) return;
signalingWs.send(JSON.stringify({
msg_type: 21, // STREAM_CLOSE_REQ
rtms_stream_id: streamId
}));
}March 2026 Notes
The new
PARTICIPANT_VIDEO_ON/PARTICIPANT_VIDEO_OFFevents tell you which participants currently have subscribable camera streams.To receive one participant camera feed, use
VIDEO_SINGLE_INDIVIDUAL_STREAMin the video media handshake and then sendVIDEO_SUBSCRIPTION_REQ.RTMS currently supports only one individual participant video stream at a time. A new subscription replaces the previous one.
STREAM_CLOSE_REQ/STREAM_CLOSE_RESPlet the backend terminate a stream cleanly.Exact numeric values:
PARTICIPANT_VIDEO_ON = 8PARTICIPANT_VIDEO_OFF = 9STREAM_CLOSE_REQ = 21STREAM_CLOSE_RESP = 22VIDEO_SUBSCRIPTION_REQ = 28VIDEO_SUBSCRIPTION_RESP = 29
Video Parameters
{
content_type: 3, // 3=RAW_VIDEO
codec: 7, // 5=JPG, 6=PNG, 7=H.264
resolution: 2, // 1=SD, 2=HD, 3=FHD, 4=QHD
fps: 25, // 1-30 (JPG/PNG max 5)
data_opt: 3 // 3=Single active speaker
}Screen Share Parameters
{
content_type: 3, // 3=RAW_VIDEO
codec: 5, // 5=JPG, 6=PNG, 7=H.264
resolution: 3, // 1=SD, 2=HD, 3=FHD, 4=QHD
fps: 1 // 1-30 (JPG/PNG max 1)
}Transcript Parameters
{
content_type: 5, // 5=TEXT
src_language: 9, // 9=English
enable_lid: false // Fixed language, no auto-switch
}Status Codes
| Code | Name | Description |
|---|---|---|
| 0 | STATUS_OK | Success |
| 3 | STATUS_INVALID_SIGNATURE | Invalid signature |
| 8 | STATUS_DUPLICATE_SIGNAL_REQUEST | Duplicate signaling connection |
| 16 | STATUS_DUPLICATE_MEDIA_DATA_CONNECTION | Duplicate media connection |
| 40 | STATUS_INVALID_RTMS_SESSION_ID | Invalid RTMS session ID |
| 43 | STATUS_INVALID_MEDIA_TRANSCRIPT_SROUCE_LANGUAGE | Invalid transcript source language |
See Data Types for complete list.
Error Handling
// Implement exponential backoff for reconnection
let retryDelay = 1000;
ws.on('close', (code, reason) => {
console.log('Connection closed:', code, reason);
// Don't reconnect if intentionally closed
if (code === 1000) return;
setTimeout(() => {
reconnect();
}, retryDelay);
retryDelay = Math.min(retryDelay * 2, 30000);
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
// Connection will close, triggering reconnection
});Gap-Filled Audio Recording
Fill gaps with silence for continuous playback:
function handleAudioData(msg, streamId) {
const now = msg.timestamp;
const last = lastTimestamps.get(streamId) || now;
const gap = now - last;
// Fill gaps >= 500ms with silence
if (gap >= 500) {
const silentFrames = Math.floor(gap / 20);
console.log(`Filling ${silentFrames} silent frames`);
for (let i = 0; i < silentFrames; i++) {
const silentFrame = Buffer.alloc(640); // 20ms @ 16kHz mono
writeToFile(silentFrame);
}
}
lastTimestamps.set(streamId, now);
const audioBuffer = Buffer.from(msg.content, 'base64');
writeToFile(audioBuffer);
}Next Steps
SDK Quickstart - SDK handles all this complexity
AI Integration - Transcription and analysis
Data Types - All enums and constants
Requirements and compatibility
Written for: Claude Code, Claude apps
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Claude Code frontmatter
Security
No issues found by our static scan· No issues found
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Created by
- Zoom
- Publisher
- Anthropic (Official)
- Distributed by
- Anthropic
- Source
- anthropics/knowledge-work-plugins · partner-built/zoom-plugin/skills/rtms · commit e050c2e
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from partner-built/zoom-plugin/LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Zoom or Anthropic.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download zoom-rtms.zip