Skip to navigation

External Meeting Control & Command Patterns

View as Markdown

Drive a live bot from your own server: speak audio into the call, post chat messages, change the bot’s picture and cut playback off mid-sentence, all over one WebSocket. Works on Zoom, Google Meet and Microsoft Teams, with the per-platform differences listed below.


Platform support

CommandZoomGoogle MeetMicrosoft Teams
sendaudio (JSON)Yes. sample_rate must be 8000, 16000, 24000, 32000, 44100 or 48000 (anything else is played as 48000)Yes, but always played as 48 kHz: sample_rate is ignored. Send 48 kHz audio, or use the binary frameYes, at the sample_rate you send
sendaudio (binary frame)YesYesYes
sendmsgYes (reads msg)Yes (reads message or msg)Yes (reads msg)
interruptYes. Holds further audio until you send resume_audioYes. The next sendaudio plays normallyYes. Holds further audio until you send resume_audio
resume_audioYes. Replies with resume_audio_ackNot needed (ignored)Yes
sendimg / sendimg_urlYesNot on this channel. Use send_imageYes
sendchatNot on this channelNot on this channelNot on this channel

Send both message and msg in sendmsg so one payload works everywhere. The rest of the capability matrix is on Meeting Platforms Supported.


Overview

When you create a bot with the socket_connection_url configuration, MeetStream opens a WebSocket connection from the bot to your server. You send JSON commands over this connection to control what the bot does inside the meeting.

Enabling the Control Channel

Include socket_connection_url in your Create Bot API request:

{
"meeting_link": "https://meet.google.com/abc-defg-hij",
"bot_name": "My Assistant",
"socket_connection_url": {
"websocket_url": "wss://your-server.com/control"
}
}

The websocket_url is a WebSocket endpoint you host. MeetStream connects to it as a client.


Connection Lifecycle

1. Bot connects to your WebSocket endpoint

The bot opens the connection when it joins the meeting, so have your server listening before you create the bot.

2. Bot sends a JSON text handshake

{
"type": "ready",
"bot_id": "bot_abc123",
"message": "Ready to receive messages"
}

After you receive this, the bot is ready to accept commands.

3. You send JSON commands

Commands are JSON text WebSocket frames. The bot executes them in the meeting. On Google Meet the bot also acknowledges sendaudio, sendmsg and interrupt on the same socket, for example {"command": "sendmsg", "bot_id": "...", "status": "sent"} (status is sent, failed or cleared).

4. Connection closes when the bot leaves

The WebSocket closes with a normal 1000 close code when the bot exits.

Timeline

Control Command Lifecycle


Command Reference

All commands share this structure:

{
"command": "<command_name>",
"bot_id": "<bot_id>",
...fields specific to the command
}

sendaudio: play audio in the meeting

Plays audio through the bot’s virtual microphone so all meeting participants hear it. Use this for text-to-speech output, pre-recorded audio prompts, or any audio your application generates.

{
"command": "sendaudio",
"bot_id": "bot_abc123",
"audiochunk": "<base64-encoded PCM16 LE audio bytes>",
"sample_rate": 48000,
"encoding": "pcm16",
"channels": 1,
"endianness": "little"
}
FieldTypeRequiredDescription
commandstringyes"sendaudio"
bot_idstringyesThe bot identifier from the handshake
audiochunkstringyesBase64-encoded audio bytes
sample_rateintyesSample rate in Hz. Use 48000: it is the only rate that plays correctly on every platform (Google Meet ignores this field and assumes 48000).
encodingstringno"pcm16" (only supported format)
channelsintno1 (mono only)
endiannessstringno"little"

Audio Encoding Requirements

The audiochunk field must be a base64-encoded string of raw PCM16 signed little-endian audio bytes. No WAV headers and no MP3: raw samples only.

PropertyValue
FormatSigned 16-bit integer (PCM16)
Byte orderLittle-endian
Sample rate48,000 Hz (required on Google Meet, recommended everywhere)
Channels1 (mono)
ContainerNone, raw samples only
Transport encodingBase64

Encoding audio: Python

import base64
import numpy as np
def encode_pcm16_to_base64(pcm_bytes: bytes) -> str:
"""Encode raw PCM16 LE bytes to base64 for the sendaudio command."""
return base64.b64encode(pcm_bytes).decode("utf-8")
def float32_to_pcm16_bytes(float_audio: np.ndarray) -> bytes:
"""Convert float32 audio (-1.0 to 1.0) to PCM16 LE bytes."""
clipped = np.clip(float_audio, -1.0, 1.0)
return (clipped * 32767).astype(np.int16).tobytes()
def resample_to_48k(pcm_bytes: bytes, source_rate: int) -> bytes:
"""Resample PCM16 LE audio from any sample rate to 48kHz."""
if source_rate == 48000:
return pcm_bytes
samples = np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32)
num_output = int(len(samples) * 48000 / source_rate)
t_in = np.linspace(0, 1, len(samples), endpoint=False)
t_out = np.linspace(0, 1, num_output, endpoint=False)
resampled = np.interp(t_out, t_in, samples)
return np.clip(resampled, -32768, 32767).astype(np.int16).tobytes()
def build_sendaudio_command(bot_id: str, pcm_bytes: bytes, sample_rate: int = 48000) -> dict:
"""Build a complete sendaudio command ready to send over the WebSocket."""
return {
"command": "sendaudio",
"bot_id": bot_id,
"audiochunk": encode_pcm16_to_base64(pcm_bytes),
"sample_rate": sample_rate,
"encoding": "pcm16",
"channels": 1,
"endianness": "little",
}

Encoding audio: JavaScript / Node.js

function encodePcm16ToBase64(pcmBuffer) {
return pcmBuffer.toString("base64");
}
function float32ToPcm16Buffer(float32Array) {
const pcm = Buffer.alloc(float32Array.length * 2);
for (let i = 0; i < float32Array.length; i++) {
const clamped = Math.max(-1, Math.min(1, float32Array[i]));
pcm.writeInt16LE(Math.round(clamped * 32767), i * 2);
}
return pcm;
}
function buildSendAudioCommand(botId, pcmBuffer, sampleRate = 48000) {
return {
command: "sendaudio",
bot_id: botId,
audiochunk: encodePcm16ToBase64(pcmBuffer),
sample_rate: sampleRate,
encoding: "pcm16",
channels: 1,
endianness: "little",
};
}

Sending a WAV file: Python

import wave
def send_wav_file(ws, bot_id: str, wav_path: str):
"""Read a WAV file and send it as a sendaudio command."""
with wave.open(wav_path, "rb") as wf:
assert wf.getsampwidth() == 2, "WAV must be 16-bit"
assert wf.getnchannels() == 1, "WAV must be mono"
pcm_bytes = wf.readframes(wf.getnframes())
source_rate = wf.getframerate()
pcm_48k = resample_to_48k(pcm_bytes, source_rate)
command = build_sendaudio_command(bot_id, pcm_48k)
ws.send(json.dumps(command))

Chunked Audio Streaming

For long audio (TTS streams, file playback), send audio in chunks rather than one large command. A good chunk size is 0.5 to 2 seconds:

CHUNK_SAMPLES = 48000 # 1 second at 48kHz
CHUNK_BYTES = CHUNK_SAMPLES * 2 # 2 bytes per sample
for i in range(0, len(pcm_bytes), CHUNK_BYTES):
chunk = pcm_bytes[i : i + CHUNK_BYTES]
command = build_sendaudio_command(bot_id, chunk)
await ws.send(json.dumps(command))
await asyncio.sleep(0.8) # pace slightly below real-time to avoid queue gaps

Binary audio frames

Instead of base64 JSON you can send raw PCM in a binary WebSocket frame. The header carries the sample rate, so this is the way to send non-48 kHz audio to a Google Meet bot:

BytesContent
00x02
1 to 4Sample rate, unsigned 32-bit little-endian
5Channels (1)
6 onwardPCM16 little-endian samples
import struct
def build_binary_audio_frame(pcm_bytes: bytes, sample_rate: int = 48000) -> bytes:
return bytes([0x02]) + struct.pack("<IB", sample_rate, 1) + pcm_bytes
await ws.send(build_binary_audio_frame(pcm_chunk, 24000))

When the bot cannot speak

  • Zoom: the bot has to be able to unmute itself. If the host has muted the bot and it cannot unmute, audio is dropped. Audio sent before the bot’s microphone is ready is also dropped.
  • All platforms: audio sent while the bot is still joining or waiting in the lobby is not heard by anyone. Start speaking after bot.inmeeting.

sendmsg: send a chat message

Posts a text message in the meeting’s chat panel, visible to all participants.

{
"command": "sendmsg",
"bot_id": "bot_abc123",
"message": "Hello from the bot!",
"msg": "Hello from the bot!"
}
FieldTypeRequiredDescription
commandstringyes"sendmsg"
bot_idstringyesThe bot identifier
messagestringyesThe chat message text
msgstringyesSame text as message

Why both message and msg? Zoom and Teams bots read msg; Google Meet bots read message (or msg). Always include both with the same value.

Every sendmsg posts a new chat message visible to everyone in the meeting. Chat must be enabled in the meeting for the bot to post; see chat and visuals for platform notes.

Example: Python

def build_chat_command(bot_id: str, text: str) -> dict:
return {
"command": "sendmsg",
"bot_id": bot_id,
"message": text,
"msg": text,
}
await ws.send(json.dumps(build_chat_command("bot_abc123", "Meeting notes are ready!")))

Example: JavaScript

function buildChatCommand(botId, text) {
return {
command: "sendmsg",
bot_id: botId,
message: text,
msg: text,
};
}
ws.send(JSON.stringify(buildChatCommand("bot_abc123", "Meeting notes are ready!")));

sendchat is not a control-channel command

Earlier versions of this page described a sendchat command with role and is_final for streaming tokens into chat. The bot does not act on sendchat over this WebSocket on any platform. Use sendmsg instead, and send only final text: every chat command posts a new message, so streaming partial tokens would flood the meeting chat.


interrupt: stop audio playback

Immediately stops any audio currently playing through the bot’s speaker and clears the audio playback queue. Use this for barge-in (when a human starts speaking while the bot is talking) or to cancel a response.

{
"command": "interrupt",
"bot_id": "bot_abc123",
"action": "clear_audio_queue"
}
FieldTypeRequiredDescription
commandstringyes"interrupt"
bot_idstringyesThe bot identifier
actionstringyes"clear_audio_queue"

Platform behavior

PlatformWhat interrupt does
Google MeetStops playback and clears the queue. The next sendaudio plays normally. The bot replies {"command": "interrupt", "status": "cleared"}
ZoomStops playback and clears the queue, then drops further sendaudio until you send resume_audio
Microsoft TeamsStops playback and clears the queue, then drops further sendaudio until you send resume_audio

resume_audio: allow audio again after an interrupt

{
"command": "resume_audio",
"bot_id": "bot_abc123",
"resume_id": "turn-42"
}

resume_id is any string you choose. On Zoom the bot answers with {"command": "resume_audio_ack", "bot_id": "...", "resume_id": "turn-42", "success": true, "error": ""}; success: false with error: "zoom_self_unmute_failed" means the bot could not unmute itself. Google Meet ignores resume_audio, so it is safe to always send it.

Example: barge-in pattern (works on every platform)

async def handle_barge_in(ws, bot_id: str):
"""Stop the bot's audio when a user starts speaking."""
await ws.send(json.dumps({
"command": "interrupt",
"bot_id": bot_id,
"action": "clear_audio_queue",
}))
async def start_next_response(ws, bot_id: str, turn_id: str):
"""Call before sending the first sendaudio of the next response."""
await ws.send(json.dumps({
"command": "resume_audio",
"bot_id": bot_id,
"resume_id": turn_id,
}))

sendimg: set the bot’s video frame (base64)

Sets the bot’s camera feed to an image. Zoom and Teams only; on Google Meet use the send_image REST endpoint instead.

{
"command": "sendimg",
"bot_id": "bot_abc123",
"img": "<base64-encoded image data>"
}
FieldTypeRequiredDescription
commandstringyes"sendimg"
bot_idstringyesThe bot identifier
imgstringyesBase64-encoded image (JPEG or PNG; animated GIFs play on Zoom and Teams)

Example: Python

import base64
def build_image_command(bot_id: str, image_path: str) -> dict:
with open(image_path, "rb") as f:
img_b64 = base64.b64encode(f.read()).decode("utf-8")
return {
"command": "sendimg",
"bot_id": bot_id,
"img": img_b64,
}
await ws.send(json.dumps(build_image_command("bot_abc123", "avatar.png")))

sendimg_url: set the bot’s video frame (URL)

Same as sendimg but provides a URL instead of inline base64. The bot downloads the image and sets it as its video frame. Zoom and Teams only; on Google Meet use send_image.

{
"command": "sendimg_url",
"bot_id": "bot_abc123",
"img_url": "https://example.com/bot-avatar.png"
}
FieldTypeRequiredDescription
commandstringyes"sendimg_url"
bot_idstringyesThe bot identifier
img_urlstringyesPublicly reachable https:// URL to a JPEG, PNG or GIF image

Full Server Examples

Python Server (FastAPI)

A complete server that accepts both the audio and control channels, logs audio, and sends a welcome message.

import asyncio
import base64
import json
import numpy as np
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
control_sockets: dict[str, WebSocket] = {}
def decode_audio_frame(data: bytes):
"""Return (speaker_id, speaker_name, sample_rate, pcm_bytes), or None."""
if len(data) < 5:
return None
if data[0] == 0x01: # Google Meet and Microsoft Teams: 48 kHz mono
sample_rate, off = 48000, 1
elif data[0] == 0x04: # Zoom: sample rate and channels in the header
sample_rate, off = int.from_bytes(data[1:5], "little"), 6
else:
return None
sid_len = int.from_bytes(data[off : off + 2], "little")
off += 2
speaker_id = data[off : off + sid_len].decode("utf-8")
off += sid_len
sname_len = int.from_bytes(data[off : off + 2], "little")
off += 2
speaker_name = data[off : off + sname_len].decode("utf-8")
off += sname_len
return speaker_id, speaker_name, sample_rate, data[off:]
async def send_command(bot_id: str, command: dict):
ws = control_sockets.get(bot_id)
if ws:
await ws.send_text(json.dumps(command))
@app.websocket("/{bot_id}/audio")
async def audio_endpoint(websocket: WebSocket, bot_id: str):
"""Receives live meeting audio from the MeetStream bot."""
await websocket.accept()
try:
while True:
raw = await websocket.receive()
if "text" in raw and raw["text"]:
handshake = json.loads(raw["text"])
print(f"[{bot_id}] Audio handshake: {handshake}")
continue
if "bytes" in raw and raw["bytes"]:
result = decode_audio_frame(raw["bytes"])
if result is None:
continue
speaker_id, speaker_name, sample_rate, pcm_bytes = result
duration_ms = (len(pcm_bytes) / 2 / sample_rate) * 1000
print(f"[{bot_id}] [{speaker_name}] {duration_ms:.0f}ms audio")
except WebSocketDisconnect:
print(f"[{bot_id}] Audio disconnected")
@app.websocket("/{bot_id}/control")
async def control_endpoint(websocket: WebSocket, bot_id: str):
"""Receives commands from and sends commands to the MeetStream bot."""
await websocket.accept()
control_sockets[bot_id] = websocket
try:
while True:
text = await websocket.receive_text()
data = json.loads(text)
if data.get("type") == "ready":
print(f"[{bot_id}] Control ready")
# Send a welcome chat message
await send_command(bot_id, {
"command": "sendmsg",
"bot_id": bot_id,
"message": "Bot is now live!",
"msg": "Bot is now live!",
})
except WebSocketDisconnect:
print(f"[{bot_id}] Control disconnected")
finally:
control_sockets.pop(bot_id, None)

Run:

pip install fastapi uvicorn websockets numpy
uvicorn server:app --host 0.0.0.0 --port 8000

Node.js Server (ws)

const { WebSocketServer } = require("ws");
const http = require("http");
const server = http.createServer();
const wss = new WebSocketServer({ server });
const controlSockets = new Map();
function decodeAudioFrame(buf) {
if (buf.length < 5) return null;
let sampleRate, off;
if (buf[0] === 0x01) { // Google Meet and Microsoft Teams: 48 kHz mono
sampleRate = 48000; off = 1;
} else if (buf[0] === 0x04) { // Zoom: sample rate and channels in the header
sampleRate = buf.readUInt32LE(1); off = 6;
} else {
return null;
}
const sidLen = buf.readUInt16LE(off);
off += 2;
const speakerId = buf.subarray(off, off + sidLen).toString("utf-8");
off += sidLen;
const snameLen = buf.readUInt16LE(off);
off += 2;
const speakerName = buf.subarray(off, off + snameLen).toString("utf-8");
off += snameLen;
return { speakerId, speakerName, sampleRate, pcmData: buf.subarray(off) };
}
function sendCommand(botId, command) {
const ws = controlSockets.get(botId);
if (ws && ws.readyState === 1) ws.send(JSON.stringify(command));
}
wss.on("connection", (ws, req) => {
const [, botId, channel] = req.url.split("/");
if (channel === "audio") {
console.log(`[${botId}] Audio connected`);
ws.on("message", (data, isBinary) => {
if (!isBinary) {
console.log(`[${botId}] Audio handshake:`, JSON.parse(data.toString()));
return;
}
const frame = decodeAudioFrame(data);
if (frame) {
const ms = ((frame.pcmData.length / 2) / frame.sampleRate * 1000).toFixed(0);
console.log(`[${botId}] [${frame.speakerName}] ${ms}ms`);
}
});
} else if (channel === "control") {
controlSockets.set(botId, ws);
console.log(`[${botId}] Control connected`);
ws.on("message", (data) => {
const msg = JSON.parse(data.toString());
if (msg.type === "ready") {
console.log(`[${botId}] Control ready`);
sendCommand(botId, {
command: "sendmsg",
bot_id: botId,
message: "Bot is now live!",
msg: "Bot is now live!",
});
}
});
ws.on("close", () => controlSockets.delete(botId));
}
});
server.listen(8000, () => console.log("Listening on :8000"));

Using Both Channels Together

When you enable both live_audio_required and socket_connection_url, the bot opens two independent WebSocket connections to your server. The audio channel’s frame format differs by platform (see real-time audio streaming); the decoders above handle all three. A typical Create Bot request:

{
"meeting_link": "https://meet.google.com/abc-defg-hij",
"bot_name": "My Assistant",
"live_audio_required": {
"websocket_url": "wss://your-server.com/{bot_id}/audio"
},
"socket_connection_url": {
"websocket_url": "wss://your-server.com/{bot_id}/control"
}
}

Pattern: receive audio, process, respond

# Audio channel: receive meeting audio
speaker_id, speaker_name, sample_rate, pcm_bytes = decode_audio_frame(binary_message)
# Your processing pipeline
transcript = your_stt_service.transcribe(pcm_bytes)
response = your_llm.generate(transcript)
tts_audio = your_tts_service.synthesize(response) # 48 kHz PCM16 mono
# Control channel: send the response back into the meeting
await send_command(bot_id, {"command": "resume_audio", "bot_id": bot_id, "resume_id": "turn-1"})
await send_command(bot_id, build_sendaudio_command(bot_id, tts_audio))
await send_command(bot_id, {
"command": "sendmsg",
"bot_id": bot_id,
"message": response,
"msg": response,
})

Pattern: barge-in detection

# When you detect the user started speaking while bot audio is playing:
await send_command(bot_id, {
"command": "interrupt",
"bot_id": bot_id,
"action": "clear_audio_queue",
})
# Then process the new user input, send resume_audio, and play the fresh response

Command Summary

CommandPurposeKey fields
sendaudioPlay audio through the bot’s speakeraudiochunk (base64 PCM16 LE), sample_rate (use 48000)
sendmsgPost a chat messagemessage, msg (same text in both)
interruptStop audio playback and clear the queueaction: "clear_audio_queue"
resume_audioAllow audio again after interrupt (Zoom, Teams)resume_id
sendimgSet the bot’s video frame from base64 (Zoom, Teams)img
sendimg_urlSet the bot’s video frame from a URL (Zoom, Teams)img_url

Audio specs: quick reference

DirectionFormatEncodingSample rateChannelsTransport
Incoming (meeting to you), Google Meet and TeamsPCM16 signed LERaw binary, header 0x0148,000 Hz1 (mono)Binary WebSocket frame
Incoming (meeting to you), ZoomPCM16 signed LERaw binary, header 0x04 with the rate32,000 Hz (read it from the header)1 (mono)Binary WebSocket frame
Outgoing (you to meeting)PCM16 signed LEBase64 in JSON, or binary 0x02 frame48,000 Hz1 (mono)JSON text or binary WebSocket frame