Generated banner for Zoom Rtms
Generated logo for Zoom Rtms

Zoom Rtms

AnthropicOfficialCreated by ZoomDistributed by Anthropic

  • Skill
  • Tool integration
  • Automation and integrations
  • MIT
  • No issues found

From the author

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.
— the description by Zoom

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

  1. Download the skill as a ZIP:

    https://mp-staging.autogineer.com/agent-skills/anthropics/zoom-rtms/download
  2. Unzip 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

New to RTMS? Follow this path:

  1. Connection Architecture - Two-phase WebSocket design

  2. SDK Quickstart - Fastest way to receive media (recommended)

  3. Manual WebSocket - Full protocol control without SDK

  4. Media Types - Audio, video, transcript, chat, screen share

Complete Implementation:

  • RTMS Bot - End-to-end bot implementation guide

Reference:

Having issues?

Supported Products

ProductWebhook EventPayload IDApp Type
Meetingsmeeting.rtms_started / meeting.rtms_stoppedmeeting_uuidGeneral App
Webinarswebinar.rtms_started / webinar.rtms_stoppedmeeting_uuid (same!)General App
Video SDKsession.rtms_started / session.rtms_stoppedsession_idVideo SDK App
Zoom Contact Center VoiceProduct-specific RTMS/ZCC Voice eventsProduct-specific stream/session identifiersContact 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 TypeFormatUse Cases
AudioPCM (L16), G.711, G.722, OpusTranscription, voice analysis, recording
VideoH.264, JPG, PNGRecording, AI vision, thumbnails, active participant selection
Screen ShareH.264, JPG, PNGContent capture, slide extraction
TranscriptJSON textMeeting notes, search, compliance
ChatJSON textArchive, 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_language and enable_lid. Default behavior is LID enabled. Set enable_lid: false to force a fixed language.

  • Single individual video stream subscription: RTMS can now stream one participant's camera feed at a time when data_opt is set to VIDEO_SINGLE_INDIVIDUAL_STREAM.

  • Graceful client-initiated shutdown: backends can send STREAM_CLOSE_REQ over the signaling socket and wait for STREAM_CLOSE_RESP.

  • Media keep-alive tolerance increased: media socket keep-alive timeout is now 65 seconds, not 35.

Two Approaches
ApproachBest ForComplexity
SDK (@zoom/rtms)Most use casesLow - handles WebSocket complexity
Manual WebSocketCustom protocols, other languagesHigh - 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.

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:

TypeValueDescription
Audio1PCM audio samples
Video2H.264/JPG video frames
Screen Share4Separate from video!
Transcript8Real-time speech-to-text
Chat16In-meeting chat messages
All32All media types

Example: Audio + Transcript = 1 | 8 = 9

Critical Gotchas

IssueSolution
Only 1 connection allowedNew connections kick out existing ones. Track active sessions!
Respond 200 immediatelyIf webhook delays, Zoom retries creating duplicate connections
Heartbeat mandatoryRespond to msg_type 12 with msg_type 13, or connection dies
Reconnection is YOUR jobRTMS doesn't auto-reconnect. Media keep-alive tolerance is now about 65s; signaling remains around 60s
Transcript language driftUse src_language plus enable_lid: false when you want fixed-language transcription instead of automatic language switching
Single participant video onlyVIDEO_SINGLE_INDIVIDUAL_STREAM supports one participant at a time. A new VIDEO_SUBSCRIPTION_REQ overrides the previous selection
Graceful close is explicit nowUse 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=true
Manual Implementation Variables
ZOOM_CLIENT_ID=your_client_id
ZOOM_CLIENT_SECRET=your_client_secret
ZOOM_SECRET_TOKEN=your_webhook_token   # For webhook validation

Zoom App Setup

For Meetings and Webinars (General App)
  1. Go to marketplace.zoom.us -> Develop -> Build App

  2. Choose General App -> User-Managed

  3. Features -> Access -> Enable Event Subscription

  4. Add Events -> Search "rtms" -> Select:

    • meeting.rtms_started

    • meeting.rtms_stopped

    • webinar.rtms_started (if using webinars)

    • webinar.rtms_stopped (if using webinars)

  5. Scopes -> Add Scopes -> Search "rtms" -> Add:

    • meeting:read:meeting_audio

    • meeting:read:meeting_video

    • meeting:read:meeting_transcript

    • meeting:read:meeting_chat

    • webinar: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)
  1. Go to marketplace.zoom.us -> Develop -> Build App

  2. Choose Video SDK App

  3. Use your SDK Key and SDK Secret (not OAuth Client ID/Secret)

  4. Add Events:

    • session.rtms_started

    • session.rtms_stopped

Sample Repositories

Official Samples
RepositoryDescription
rtms-samplesRTMSManager, boilerplates, AI samples
rtms-quickstart-jsJavaScript SDK quickstart
rtms-quickstart-pyPython SDK quickstart
rtms-sdk-cppC++ SDK
zoom-rtmsMain SDK repository
AI Integration Samples
SampleDescription
rtms-meeting-assistant-starter-kitAI meeting assistant with summaries
arlo-meeting-assistantProduction meeting assistant with DB
videosdk-rtms-transcribe-audioWhisper transcription

Complete Documentation

Concepts
Examples
References
Troubleshooting

Resources


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:

  1. Run preflight checks first -> RUNBOOK.md

  2. Understand the architecture -> concepts/connection-architecture.md

    • Two-phase WebSocket: Signaling + Media

    • Why RTMS doesn't use bots

  3. Choose your approach -> SDK or Manual

  4. Understand the lifecycle -> concepts/lifecycle-flow.md

    • Webhook -> Signaling -> Media -> Streaming

  5. Configure media types -> references/media-types.md

    • Audio, video, transcript, chat, screen share

  6. 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 solutions

By Use Case

I want to get meeting transcripts
  1. SDK Quickstart - Fastest approach

  2. Media Types - Transcript configuration

  3. AI Integration - Whisper, Deepgram, AssemblyAI

I want to record meetings
  1. Media Types - Audio + Video configuration

  2. SDK Quickstart - Receiving media

  3. AI Integration - Gap-filled recording

I want to build an AI meeting assistant
  1. AI Integration - Complete patterns

  2. SDK Quickstart - Media ingestion

  3. Lifecycle Flow - Event handling

I want to build a complete RTMS bot
  1. RTMS Bot - Complete implementation guide

  2. Lifecycle Flow - Webhook to streaming flow

  3. Connection Architecture - Two-phase design

I need full protocol control
  1. Manual WebSocket - START HERE

  2. Connection Architecture - Two-phase design

  3. Data Types - All message types and enums

  4. Connection - Protocol details

I'm getting connection errors
  1. Common Issues - Diagnostic checklist

  2. Connection Architecture - Verify flow

  3. Webhooks - Validation and timing

I want to understand the architecture
  1. Connection Architecture - Two-phase WebSocket

  2. Lifecycle Flow - Complete flow diagram

  3. Data Types - Protocol constants


By Product

I'm building for Zoom Meetings
I'm building for Zoom Webinars
  • Same as meetings, but webhook event is webinar.rtms_started. Payload still uses meeting_uuid (NOT webinar_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 uses session_id (NOT meeting_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

SDKManual
Handles WebSocket complexityFull protocol control
Automatic reconnectionDIY reconnection
Less codeMore code
Best for most use casesBest for custom requirements
3. Critical Gotchas (MOST COMMON ISSUES)

troubleshooting/common-issues.md

  1. Respond 200 immediately - Delayed webhook responses cause duplicates

  2. Only 1 connection per stream - New connections kick out existing

  3. Heartbeat required - Must respond to keep-alive or connection dies

  4. Track active sessions - Prevent duplicate join attempts


Key Learnings

Critical Discoveries:
  1. Two-Phase WebSocket Design

    • Signaling: Control plane (handshake, heartbeat, start/stop)

    • Media: Data plane (audio, video, transcript, chat, share)

    • See: Connection Architecture

  2. Webhook Response Timing

    • MUST respond 200 BEFORE any processing

    • Delayed response -> Zoom retries -> duplicate connections

    • See: Common Issues

  3. Heartbeat is Mandatory

    • Signaling: Receive msg_type 12, respond with msg_type 13

    • Media: Same pattern

    • Failure to respond = connection closed

    • See: Connection

  4. Signature Generation

    • Format: HMAC-SHA256(clientSecret, "clientId,meetingUuid,streamId")

    • For Video SDK, use session_id in place of meetingUuid

    • Webinars still use meeting_uuid (not webinar_uuid)

    • Required for both signaling and media handshakes

    • See: Manual WebSocket

  5. 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

  6. 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"

-> Common Issues

"Duplicate connections"

-> Webhook timing

"No audio/video data"

-> Media Types - Check configuration

"How do I implement manually?"

-> Manual WebSocket

"What message types exist?"

-> Data Types

"How do I integrate AI?"

-> AI Integration


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

examples/rtms-bot.md

RTMS Bot (Real-Time Media Streams)

Build resilient RTMS bots that access meeting audio, video, transcription, screen share, and chat without joining as a visible participant.

Overview

RTMS bots are invisible read-only services that subscribe to meeting media streams via WebSockets. They do NOT appear in the participant list.

Use this approach when:

  • You only need to observe/transcribe (no interaction needed)

  • You want invisible operation

  • You're processing external meetings (with permission)

  • You want minimal resource usage

Alternative: See Meeting SDK Bot (Linux) for visible participant bots with full meeting control.

Architecture

┌─────────────────────────────────────────────────────────────────────┐
│                        RTMS BOT FLOW                                 │
└─────────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────────┐
│ 1. Trigger RTMS: REST API or In-Meeting Start                       │
│    └── POST /meetings/{meetingId}/rtms                              │
│    └── Or: Start RTMS manually from Zoom client                     │
└────────────────────────────┬────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────────┐
│ 2. Wait for Webhook: meeting.rtms_started                           │
│    └── Zoom sends signaling + media WebSocket URLs                  │
│    └── No webhook = RTMS unavailable (no polling fallback)          │
└────────────────────────────┬────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────────┐
│ 3. Connect: Signaling WebSocket (Handshake with HMAC)               │
│    └── Generate HMAC-SHA256 signature                               │
│    └── Send handshake message                                       │
│    └── Receive session confirmation                                 │
└────────────────────────────┬────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────────┐
│ 4. Connect: Media WebSocket (Subscribe to Streams)                  │
│    └── Subscribe to: audio, video, transcription, share, chat       │
│    └── Send keep-alive pings                                        │
└────────────────────────────┬────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────────┐
│ 5. Process Media Data                                               │
│    └── Audio: Opus/PCM streams per speaker                          │
│    └── Video: H.264 encoded frames                                  │
│    └── Transcription: Real-time text with speaker labels            │
└────────────────────────────┬────────────────────────────────────────┘
                             │
                             ▼
┌─────────────────────────────────────────────────────────────────────┐
│ 6. Mid-Stream: Connection Monitoring                                │
│    └── Detect WebSocket close → Exponential backoff retry           │
│    └── Stop after N reconnection attempts                           │
└─────────────────────────────────────────────────────────────────────┘

Skills Required

SkillPurpose
zoom-rest-apiTrigger RTMS start (optional - can also start manually)
rtmsWebSocket connection, media processing
webhooksReceive meeting.rtms_started event

Prerequisites

  • Zoom app with RTMS features enabled

  • Webhook endpoint (HTTPS, publicly accessible)

  • Event subscriptions: meeting.rtms_started, meeting.rtms_stopped

  • Scopes: meeting:read:admin, meeting:write:admin (if triggering via API)

  • RTMS SDK or native WebSocket implementation

Configuration

Retry Parameters (Customizable)
// config.js or environment variables
const rtmsConfig = {
    // WebSocket connection (initial)
    connection_timeout_ms: 10000,        // Handshake timeout (default: 10s)
    connection_max_attempts: 5,          // Max connection attempts (default: 5)
    connection_retry_delay_ms: 5000,     // Constant retry: 5s (default: 5s)
    
    // Mid-stream reconnection (network failures)
    reconnect_max_attempts: 3,           // Max reconnection attempts (default: 3)
    reconnect_base_delay_ms: 2000,       // Initial delay: 2s (default: 2s)
    // Exponential backoff: 2s, 4s, 8s...
    
    // Keep-alive ping
    keepalive_interval_ms: 5000,         // Send ping every 5s (default: 5s, min: 3s)
    keepalive_timeout_ms: 15000,         // Expect pong within 15s (default: 15s)
    
    // Webhook wait timeout
    webhook_wait_timeout_ms: 300000      // Wait 5min for webhook (default: 5min)
};

// Load from environment variables (recommended for production)
function loadConfig() {
    return {
        connection_timeout_ms: 
            parseInt(process.env.RTMS_CONNECTION_TIMEOUT_MS) || 10000,
        connection_max_attempts: 
            parseInt(process.env.RTMS_CONNECTION_MAX_ATTEMPTS) || 5,
        connection_retry_delay_ms: 
            parseInt(process.env.RTMS_CONNECTION_RETRY_DELAY_MS) || 5000,
        reconnect_max_attempts: 
            parseInt(process.env.RTMS_RECONNECT_MAX_ATTEMPTS) || 3,
        reconnect_base_delay_ms: 
            parseInt(process.env.RTMS_RECONNECT_BASE_DELAY_MS) || 2000,
        keepalive_interval_ms: 
            Math.max(parseInt(process.env.RTMS_KEEPALIVE_INTERVAL_MS) || 5000, 3000),
        keepalive_timeout_ms: 
            parseInt(process.env.RTMS_KEEPALIVE_TIMEOUT_MS) || 15000,
        webhook_wait_timeout_ms: 
            parseInt(process.env.RTMS_WEBHOOK_WAIT_TIMEOUT_MS) || 300000
    };
}
Customization Guide
ParameterDefaultWhen to IncreaseWhen to Decrease
connection_max_attempts5Slow/congested networksFast failure detection needed
connection_retry_delay_ms5000 (5s)High network latencyLocal network, low latency
reconnect_max_attempts3Critical meetings, unstable networkCost-sensitive, batch processing
reconnect_base_delay_ms2000 (2s)International connectionsLocal network
keepalive_interval_ms5000 (5s)Aggressive connection monitoringReduce bandwidth overhead
webhook_wait_timeout_ms300000 (5min)Meetings may start lateFast failure detection

Recommended Ranges:

  • Connection attempts: 3-10

  • Connection retry delay: 2s-15s

  • Reconnect attempts: 2-5

  • Reconnect base delay: 1s-5s

  • Keep-alive interval: 3s-30s (min: 3s per Zoom docs)

Examples:

# High-priority production bot (aggressive)
export RTMS_CONNECTION_MAX_ATTEMPTS=10
export RTMS_CONNECTION_RETRY_DELAY_MS=3000      # 3s
export RTMS_RECONNECT_MAX_ATTEMPTS=5
export RTMS_RECONNECT_BASE_DELAY_MS=1000        # 1s
export RTMS_KEEPALIVE_INTERVAL_MS=3000          # 3s (minimum)

# Cost-sensitive batch processing (conservative)
export RTMS_CONNECTION_MAX_ATTEMPTS=3
export RTMS_CONNECTION_RETRY_DELAY_MS=10000     # 10s
export RTMS_RECONNECT_MAX_ATTEMPTS=2
export RTMS_RECONNECT_BASE_DELAY_MS=5000        # 5s
export RTMS_KEEPALIVE_INTERVAL_MS=15000         # 15s

# Development/testing (fail fast)
export RTMS_CONNECTION_MAX_ATTEMPTS=2
export RTMS_CONNECTION_RETRY_DELAY_MS=2000      # 2s
export RTMS_RECONNECT_MAX_ATTEMPTS=1
export RTMS_RECONNECT_BASE_DELAY_MS=1000        # 1s
export RTMS_WEBHOOK_WAIT_TIMEOUT_MS=60000       # 1min

Step 1: Trigger RTMS (Optional - REST API)

You can start RTMS programmatically or manually from the Zoom client.

Option A: REST API Trigger
# Start RTMS for a meeting
curl -X POST "https://api.zoom.us/v2/meetings/{meetingId}/rtms" \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "meeting"
  }'

Response:

{
  "rtms_id": "abc123def456",
  "status": "starting"
}
Option B: Manual Start (In-Meeting)

Host clicks Apps → Your RTMS App → Start RTMS

Both trigger the same webhook → meeting.rtms_started

Step 2: Wait for Webhook (Required)

CRITICAL: RTMS requires a webhook. There is NO polling alternative. If webhook doesn't arrive, RTMS is unavailable.

Webhook Handler
const express = require('express');
const crypto = require('crypto');
const app = express();

const config = loadConfig();
const pendingConnections = new Map();  // Track webhook waiters

app.post('/webhook', express.json(), async (req, res) => {
    // 1. Verify webhook signature
    const signature = req.headers['x-zm-signature'];
    const timestamp = req.headers['x-zm-request-timestamp'];
    
    if (!verifyWebhookSignature(req.body, signature, timestamp)) {
        return res.status(403).send('Invalid signature');
    }
    
    // 2. Respond immediately (Zoom expects 200 within 3s)
    res.status(200).send();
    
    // 3. Process webhook asynchronously
    const event = req.body;
    
    if (event.event === 'meeting.rtms_started') {
        console.log('[WEBHOOK] RTMS started for meeting:', event.payload.object.uuid);
        
        const rtmsInfo = {
            meetingUuid: event.payload.object.uuid,
            signalingUrl: event.payload.object.signaling_url,
            mediaUrl: event.payload.object.media_url,
            sessionKey: event.payload.object.session_key
        };
        
        // Notify waiting connection
        const waiter = pendingConnections.get(rtmsInfo.meetingUuid);
        if (waiter) {
            waiter.resolve(rtmsInfo);
            pendingConnections.delete(rtmsInfo.meetingUuid);
        } else {
            // No waiter - proactive start
            connectToRTMS(rtmsInfo);
        }
    }
});

function verifyWebhookSignature(body, signature, timestamp) {
    const message = `v0:${timestamp}:${JSON.stringify(body)}`;
    const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET_TOKEN);
    const computed = 'v0=' + hmac.update(message).digest('hex');
    return crypto.timingSafeEqual(
        Buffer.from(signature),
        Buffer.from(computed)
    );
}
Wait for Webhook (with Timeout)
async function waitForRTMSWebhook(meetingUuid) {
    return new Promise((resolve, reject) => {
        const timeoutId = setTimeout(() => {
            pendingConnections.delete(meetingUuid);
            reject(new Error(
                `RTMS webhook not received within ${config.webhook_wait_timeout_ms}ms. ` +
                `Possible causes: ` +
                `(1) Meeting hasn't started, ` +
                `(2) RTMS not enabled for this meeting, ` +
                `(3) Webhook endpoint unreachable.`
            ));
        }, config.webhook_wait_timeout_ms);
        
        pendingConnections.set(meetingUuid, {
            resolve: (rtmsInfo) => {
                clearTimeout(timeoutId);
                resolve(rtmsInfo);
            },
            reject
        });
    });
}

// Usage
try {
    console.log('[RTMS] Waiting for webhook...');
    const rtmsInfo = await waitForRTMSWebhook(MEETING_UUID);
    console.log('[RTMS] Webhook received, connecting...');
    await connectToRTMS(rtmsInfo);
} catch (error) {
    console.error('[RTMS] ABORT:', error.message);
}

Error if no webhook: ABORT. No webhook = RTMS unavailable. No polling alternative.

Step 3: Connect to Signaling WebSocket

Connection with Retry
const WebSocket = require('ws');

async function connectSignalingWithRetry(signalingUrl, sessionKey) {
    for (let attempt = 1; attempt <= config.connection_max_attempts; attempt++) {
        console.log(`[SIGNALING] Attempt ${attempt}/${config.connection_max_attempts}`);
        
        try {
            const ws = await connectSignalingSocket(signalingUrl, sessionKey);
            console.log('[SIGNALING] Connected successfully');
            return ws;
        } catch (error) {
            console.error(`[SIGNALING] Attempt ${attempt} failed:`, error.message);
            
            if (attempt < config.connection_max_attempts) {
                const delay = config.connection_retry_delay_ms;
                console.log(`[SIGNALING] Retrying in ${delay}ms...`);
                await sleep(delay);
            }
        }
    }
    
    throw new Error(
        `Failed to connect signaling WebSocket after ${config.connection_max_attempts} attempts`
    );
}

function connectSignalingSocket(signalingUrl, sessionKey) {
    return new Promise((resolve, reject) => {
        const ws = new WebSocket(signalingUrl);
        const timeoutId = setTimeout(() => {
            ws.close();
            reject(new Error('Signaling connection timeout'));
        }, config.connection_timeout_ms);
        
        ws.on('open', () => {
            console.log('[SIGNALING] WebSocket opened, sending handshake...');
            
            // Generate HMAC signature
            const timestamp = Date.now();
            const message = `${timestamp}:${sessionKey}`;
            const signature = crypto
                .createHmac('sha256', WEBHOOK_SECRET_TOKEN)
                .update(message)
                .digest('hex');
            
            // Send handshake
            ws.send(JSON.stringify({
                type: 'handshake',
                timestamp,
                signature
            }));
        });
        
        ws.on('message', (data) => {
            const msg = JSON.parse(data);
            
            if (msg.type === 'handshake_response') {
                clearTimeout(timeoutId);
                
                if (msg.status === 'success') {
                    console.log('[SIGNALING] Handshake successful');
                    resolve(ws);
                } else {
                    ws.close();
                    reject(new Error(`Handshake failed: ${msg.error}`));
                }
            }
        });
        
        ws.on('error', (error) => {
            clearTimeout(timeoutId);
            reject(error);
        });
        
        ws.on('close', (code, reason) => {
            clearTimeout(timeoutId);
            reject(new Error(`Connection closed: ${code} ${reason}`));
        });
    });
}

Step 4: Connect to Media WebSocket

async function connectMediaWithRetry(mediaUrl, signalingWs) {
    for (let attempt = 1; attempt <= config.connection_max_attempts; attempt++) {
        console.log(`[MEDIA] Attempt ${attempt}/${config.connection_max_attempts}`);
        
        try {
            const ws = await connectMediaSocket(mediaUrl);
            console.log('[MEDIA] Connected successfully');
            subscribeToStreams(ws);
            setupKeepAlive(ws);
            return ws;
        } catch (error) {
            console.error(`[MEDIA] Attempt ${attempt} failed:`, error.message);
            
            if (attempt < config.connection_max_attempts) {
                const delay = config.connection_retry_delay_ms;
                console.log(`[MEDIA] Retrying in ${delay}ms...`);
                await sleep(delay);
            }
        }
    }
    
    throw new Error(
        `Failed to connect media WebSocket after ${config.connection_max_attempts} attempts`
    );
}

function subscribeToStreams(mediaWs) {
    // Subscribe to all available streams
    mediaWs.send(JSON.stringify({
        type: 'subscribe',
        streams: ['audio', 'video', 'transcription', 'share', 'chat']
    }));
    
    console.log('[MEDIA] Subscribed to: audio, video, transcription, share, chat');
}

Step 5: Keep-Alive Management

function setupKeepAlive(ws) {
    let lastPongReceived = Date.now();
    let keepAliveInterval;
    let timeoutCheck;
    
    // Send ping periodically
    keepAliveInterval = setInterval(() => {
        if (ws.readyState === WebSocket.OPEN) {
            ws.ping();
            console.log('[KEEPALIVE] Ping sent');
        }
    }, config.keepalive_interval_ms);
    
    // Check for pong timeout
    timeoutCheck = setInterval(() => {
        const timeSinceLastPong = Date.now() - lastPongReceived;
        
        if (timeSinceLastPong > config.keepalive_timeout_ms) {
            console.error('[KEEPALIVE] Pong timeout, closing connection');
            clearInterval(keepAliveInterval);
            clearInterval(timeoutCheck);
            ws.close(1000, 'Keep-alive timeout');
        }
    }, 1000);
    
    ws.on('pong', () => {
        lastPongReceived = Date.now();
        console.log('[KEEPALIVE] Pong received');
    });
    
    ws.on('close', () => {
        clearInterval(keepAliveInterval);
        clearInterval(timeoutCheck);
    });
}

Step 6: Mid-Stream Reconnection

class ResilientRTMSConnection {
    constructor(rtmsInfo, config) {
        this.rtmsInfo = rtmsInfo;
        this.config = config;
        this.reconnectionAttempt = 0;
        this.signalingWs = null;
        this.mediaWs = null;
    }
    
    async connect() {
        try {
            this.signalingWs = await connectSignalingWithRetry(
                this.rtmsInfo.signalingUrl,
                this.rtmsInfo.sessionKey
            );
            
            this.mediaWs = await connectMediaWithRetry(
                this.rtmsInfo.mediaUrl,
                this.signalingWs
            );
            
            this.setupReconnectionHandlers();
            
        } catch (error) {
            console.error('[RTMS] Initial connection failed:', error);
            throw error;
        }
    }
    
    setupReconnectionHandlers() {
        const handleDisconnection = (wsType) => async (code, reason) => {
            console.error(`[${wsType}] Disconnected: ${code} ${reason}`);
            
            this.reconnectionAttempt++;
            
            if (this.reconnectionAttempt > this.config.reconnect_max_attempts) {
                console.error(
                    `[RECONNECT] Giving up after ${this.reconnectionAttempt} attempts`
                );
                this.cleanup();
                return;
            }
            
            // Exponential backoff: 2s, 4s, 8s...
            const delay = this.config.reconnect_base_delay_ms 
                          * Math.pow(2, this.reconnectionAttempt - 1);
            
            console.log(
                `[RECONNECT] Attempt ${this.reconnectionAttempt}/` +
                `${this.config.reconnect_max_attempts} in ${delay}ms...`
            );
            
            await sleep(delay);
            
            try {
                await this.connect();
                console.log('[RECONNECT] Successfully reconnected');
                this.reconnectionAttempt = 0;  // Reset counter
            } catch (error) {
                console.error('[RECONNECT] Failed:', error.message);
                // Handler will be called again if connection fails
            }
        };
        
        this.signalingWs.on('close', handleDisconnection('SIGNALING'));
        this.mediaWs.on('close', handleDisconnection('MEDIA'));
        
        this.signalingWs.on('error', (error) => {
            console.error('[SIGNALING] Error:', error.message);
        });
        
        this.mediaWs.on('error', (error) => {
            console.error('[MEDIA] Error:', error.message);
        });
    }
    
    cleanup() {
        if (this.signalingWs) this.signalingWs.close();
        if (this.mediaWs) this.mediaWs.close();
    }
}
Customizing Reconnection Behavior
// Example: Capped exponential backoff (max 30s)
const delay = Math.min(
    config.reconnect_base_delay_ms * Math.pow(2, reconnectionAttempt - 1),
    30000  // Cap at 30s
);

// Example: Linear backoff instead of exponential
const delay = config.reconnect_base_delay_ms * reconnectionAttempt;

// Example: Jittered backoff (avoid thundering herd)
const baseDelay = config.reconnect_base_delay_ms * Math.pow(2, reconnectionAttempt - 1);
const jitter = Math.random() * 1000;  // Random 0-1000ms
const delay = baseDelay + jitter;

Complete Resilient Bot Example

const config = loadConfig();

async function main() {
    try {
        // 1. Optional: Trigger RTMS via REST API
        console.log('[RTMS] Triggering RTMS start...');
        await triggerRTMSStart(MEETING_ID);
        
        // 2. Wait for webhook
        console.log('[RTMS] Waiting for meeting.rtms_started webhook...');
        const rtmsInfo = await waitForRTMSWebhook(MEETING_UUID);
        
        // 3. Connect with resilience
        const rtms = new ResilientRTMSConnection(rtmsInfo, config);
        await rtms.connect();
        
        console.log('[RTMS] Bot is running, processing streams...');
        
        // 4. Process media data
        rtms.mediaWs.on('message', (data) => {
            const frame = parseMediaFrame(data);
            processMediaFrame(frame);
        });
        
        // 5. Handle graceful shutdown
        process.on('SIGINT', () => {
            console.log('[RTMS] Shutting down...');
            rtms.cleanup();
            process.exit(0);
        });
        
    } catch (error) {
        console.error('[RTMS] ABORT:', error.message);
        process.exit(1);
    }
}

main();

Comparison: RTMS Bot vs Meeting SDK Bot

AspectRTMS BotMeeting SDK Bot
VisibilityInvisible (read-only service)Visible participant
AuthenticationREST API trigger + webhookJWT + OBF token
Join DependencyNo dependency on participantsOwner must be present
Retry LogicNot applicable (webhook-based)Required (owner presence)
Media AccessAudio/video/text/share/chat via WebSocketRaw audio/video/share via SDK
Recording ControlNone (read-only)Full (local, cloud, raw)
InteractionCannot interactCan send chat, reactions
Resource UsageLower (WebSocket only)Higher (full SDK)
Use CasePassive transcription, analyticsInteractive bots, recording, moderation

Choose RTMS Bot when:

  • You only need to observe/transcribe

  • You want minimal resource usage

  • You prefer invisible operation

  • You're processing external meetings (with permission)

Choose Meeting SDK Bot when:

  • You need to interact with the meeting (chat, reactions)

  • You need local recording control

  • You want to be visible in participant list

  • You're processing your own meetings

Troubleshooting

Webhook Never Arrives

Symptom: waitForRTMSWebhook() times out

Solution:

  1. Verify webhook endpoint is HTTPS and publicly accessible

  2. Check Event Subscriptions in Zoom Marketplace: meeting.rtms_started enabled

  3. Verify RTMS was actually started (check Zoom client or REST API response)

  4. Increase webhook_wait_timeout_ms if meeting starts later than expected

  5. Test webhook delivery: curl -X POST YOUR_WEBHOOK_URL

Signaling Handshake Fails

Symptom: Connection closes immediately after handshake

Solution:

  1. Verify HMAC signature generation matches Zoom docs

  2. Check timestamp is current (not stale)

  3. Verify WEBHOOK_SECRET_TOKEN matches Zoom Marketplace config

  4. Check signaling URL hasn't expired (short TTL)

Keep-Alive Timeout

Symptom: Connection closes with "Keep-alive timeout"

Solution:

  1. Network congestion - increase keepalive_timeout_ms

  2. Server overloaded - increase keepalive_interval_ms

  3. Verify ping/pong implementation is correct

  4. Check firewall/proxy not blocking WebSocket pings

Frequent Reconnections

Symptom: Bot reconnects multiple times, then gives up

Solution:

  1. Increase reconnect_max_attempts (e.g., 5 instead of 3)

  2. Increase reconnect_base_delay_ms if network is slow

  3. Monitor server resources (CPU/memory/network)

  4. Check for rate limiting (too many connection attempts)

Resources

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