Zoom Rtms
OpenAIOfficialCreated by ZoomDistributed by OpenAI
- Skill
- Tool integration
- Automation and integrations
- MIT
- No issues found
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
The zoom-rtms skill provides the necessary protocols and architectural guidance for integrating Zoom Realtime Media Streams. It covers the WebSocket-based connection lifecycle, including signaling and media handshakes, heartbeat management, and reconnection strategies. The skill supports processing various media types such as audio, video, screen sharing, transcripts, and chat, while providing reference data for message types and event enums.
What it does
- Establishes WebSocket connections for signaling and media streams.
- Handles media handshake requests for audio, video, screen share, transcript, and chat data.
- Provides reference tables for RTMS message types and event enums.
- Defines mandatory heartbeat and reconnection logic for stream stability.
- Supports configuration for transcript language identification.
- Outlines the process for extracting stream IDs and generating HMAC-SHA256 signatures.
When to use it
- When you need to ingest raw meeting or contact center media into a backend pipeline.
- When building services for transcription, AI analysis, or recording of Zoom sessions.
- When you need to subscribe to specific meeting events or participant media streams.
- When you require programmatic access to media data without a visible participant bot.
Advantages
- Provides a structured approach to managing the complex WebSocket lifecycle.
- Includes comprehensive references for message types and event handling.
- Offers clear guidance on signature generation and security requirements.
- Enables granular control over media subscription and transcript settings.
Usage examples
- “How do I initiate a connection to an RTMS stream?”
- The agent will guide you through receiving the rtms_started webhook, generating an HMAC-SHA256 signature, and performing the signaling and media handshakes.
- “What is the correct way to handle heartbeats in RTMS?”
- The agent will explain that you must respond to msg_type 12 (Keep Alive Request) with msg_type 13 (Keep Alive Response) to maintain the connection.
Install
Download the skill as a ZIP:
https://autogineer.com/agent-skills/openai/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
Use this skill when the integration needs real-time meeting or contact-center media as a backend stream. If the workflow needs a visible participant bot, compare against build-zoom-bot and zoom-meeting-sdk-linux before choosing RTMS.
Workflow
Confirm the source surface: Meetings RTMS, Contact Center voice media, or another documented RTMS stream.
Decide whether RTMS is sufficient or whether a Meeting SDK bot is required for participant identity, meeting controls, or UI-visible behavior.
Implement the WebSocket lifecycle first: event subscription, connection validation, media start, heartbeat, reconnect, and shutdown.
Process media types intentionally: audio, video, screen share, chat, live transcript, and metadata have different payload and timing constraints.
Design downstream pipelines for buffering, transcription, AI analysis, recording, or tool invocation.
Debug by isolating app setup, webhook/event delivery, stream authorization, network connectivity, and media payload decoding.
References
Full preserved guide: references/full-guide.md
Connection architecture: concepts/connection-architecture.md
Lifecycle flow: concepts/lifecycle-flow.md
Media types: references/media-types.md
Data types: references/data-types.md
RTMS bot example: examples/rtms-bot.md
Common issues: troubleshooting/common-issues.md
Files
- ATTRIBUTION.mdAdded by AutoGineer860 B
- LICENSELicence file added by AutoGineer1.1 KiB
- RUNBOOK.md2.6 KiB
- SKILL.md1.6 KiB
- agents/openai.yaml140 B
- concepts/connection-architecture.md9.1 KiB
- concepts/lifecycle-flow.md13.4 KiB
- examples/ai-integration.md11.3 KiB
- examples/manual-websocket.md16.8 KiB
- examples/rtms-bot.md25.9 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/full-guide.md22.9 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: OpenAI Codex
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.
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
- OpenAI (Official)
- Distributed by
- OpenAI
- Source
- openai/plugins · plugins/zoom/skills/rtms · commit d420d13
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from plugins/zoom/LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by Zoom or OpenAI.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download zoom-rtms.zip