Per-Participant Video Recorder Bot: A Build Guide

Build a bot that records separate video and audio streams for Zoom, Google Meet, or Teams participants, then downloads each file with MeetStream.

A per-participant video recorder bot joins a Zoom, Google Meet, or Microsoft Teams call and saves a separate video and audio file for each participant, instead of one merged recording of the whole meeting. Most meeting bots default to a single composite file, gallery view or active speaker view, which is fine for a general archive but useless if you need to isolate one person's face and voice. This guide walks through building one on top of MeetStream's meeting bot API, from bot creation through saving files per participant.


What Is Per-Participant Video Recording?

Composite recording captures whatever the meeting client renders on screen, usually gallery view or active speaker view. Zoom's own cloud recording works this way, and so does the default output of most meeting bots. One file, one shared frame, everyone in it at once.

Per-participant recording is different. Each participant gets their own video stream and, typically, their own audio track. No cropping a shared frame down to one face, no guessing which square in the grid belonged to which speaker.

The table below sums up the practical difference.

Decision

Composite recording

Per-participant recording

Output

One meeting-view file

Separate webcam segments per participant

Best for

Playback and meeting archives

Editing, analysis, coaching, interviews, speaker clips

Layout

Active speaker, gallery, or shared-content view

No shared grid; each source stays isolated

Audio

Usually one mixed track

Optional separate speaker files on supported platforms

Processing

Simple download and playback

Requires mapping, synchronization, storage, and lifecycle handling

MeetStream flags

`video_required: true`

`video_separate_streams: true` plus optional `audio_separate_streams: true`

How Per-Participant Recording Works

A per-participant video recording API needs three moving parts:

  1. A bot that joins the call. Send a meeting_link, get back a bot_id. The bot joins like any other participant.
  2. A webhook receiver. The API posts lifecycle events, joining, in meeting, recording, stopped, to a URL you control, so your server knows the bot's state without polling constantly.
  3. A retrieval step. Once the meeting ends, poll two endpoints, one for video streams and one for audio streams, until downloadable URLs come back.

For local development, that webhook receiver needs a public URL, since the API can't call localhost. An ngrok tunnel handles that, opening automatically when the app starts and passing its URL to the bot creation request as callback_url.

End-to-end workflow of MeetStream's per-participant recording API, showing bot creation, webhook events, transcript retrieval, participant-specific recording downloads, and post-processing outputs.

How to Build It: Step by Step

Project Setup

Start with the per-participant-video-recorder example from MeetStream Labs

git clone https://github.com/meetstream-ai/labs
cd labs/per-participant-video-recorder
npm install
cp .env.example .env
MEETSTREAM_API_KEY=your_meetstream_api_key
NGROK_AUTHTOKEN=your_ngrok_authtoken
MEETING_LINK=https://meet.google.com/xxx-xxxx-xxx

ngrok is only used to expose your local server during development. In production, you would use your own public server URL.


1. Create the Bot with Separated Streams

The bot creation payload is where multi-track video recording for meeting bots actually gets turned on:

{
  "meeting_link": "https://meet.google.com/abc-defg-hij",
  "bot_name": "MeetStream Recorder",
  "callback_url": "https://example.ngrok.app/webhook",
  "video_required": true,
  "audio_required": true,
  "video_separate_streams": true,
  "audio_separate_streams": true
}

Those two flags are the whole trick. Set them, and the response stops handing back one merged file and starts handing back one stream per participant. Full parameter details live in MeetStream's bot creation guide and the dedicated per-participant video and per-participant audio pages.


2. Handle the Webhook Lifecycle

Once the bot joins, expect a sequence of events at your webhook endpoint: joining, in the waiting room if the host hasn't admitted it yet, in meeting, recording, and eventually stopped when the meeting wraps up. The full event list is documented in MeetStream's webhooks and events guide.

Don't start downloading the moment the stopped event arrives. The API still needs a short window to process the raw capture into downloadable files, so treat that event as the signal to start polling, not the signal that files are ready.

import express from 'express';

const router = express.Router();

router.post('/webhook', express.json(), (req, res) => {
  const { event, bot_id } = req.body;
  console.log(`[${bot_id}] event: ${event}`);

  res.sendStatus(200);

  if (event === 'bot.stopped') {
    onMeetingStopped(bot_id);
  }
});

export default router;

3. Fetch the Per-Participant Streams

This is where the response shape needs some care. Calling the recording streams endpoint can return a structure grouped by participant, with each participant holding a list of stream segments, or a flatter list where each entry already has a participant object, a download URL, and a stream type like webcam. Both point at the same thing: a downloadable file tied to one person. Write the parsing logic to handle either shape rather than assume one, and the audio streams endpoint follows the same pattern on the audio side.

async function waitForRecordings({ botId, maxAttempts = 30, intervalMs = 10000 }) {
  for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
    const [video, audio] = await Promise.all([
      getRecordingStreams(botId),
      getAudioStreams(botId),
    ]);

    if (video.ready && audio.ready) {
      await dumpDebugResponses(video.data, audio.data);
      return { videoData: video.data, audioData: audio.data };
    }

    console.log(`Recordings not ready yet (attempt ${attempt}/${maxAttempts})`);
    await sleep(intervalMs);
  }

  throw new Error('Gave up waiting for recordings to finish processing.');
}

// Handles both the nested (grouped-by-participant) shape and the flat
// top-level array shape.
function getMediaRecords(data) {
  if (Array.isArray(data)) return data;
  const records = [];
  for (const participant of data.participants ?? []) {
    for (const stream of participant.streams ?? []) {
      records.push({ ...stream, participant_name: participant.name });
    }
  }
  return records;
}

Writing the raw API response to disk as you go, something like a debug JSON file saved next to that run's recordings, pays for itself the first time a participant's file doesn't show up and you need to see exactly what came back.


4. Save Files Per Participant

Once each participant has a downloadable URL per stream type, the file layout follows naturally:

recordings/
  2026-07-15_14-30-00/
    Person Name/
      webcam.mp4
      audio.webm
    Another Person/
      webcam.mp4
      screen_share.mp4

Each run gets its own timestamped folder, so recordings from different meetings never collide. Video and audio are saved as separate files rather than merged into one track, which keeps the pipeline simple and leaves the merge decision, if you want one, to whatever tool consumes the files downstream.


5. Stop the Bot Gracefully

If you're driving this from a terminal, a single Ctrl+C should remove the bot from the call, wait for the stopped event to actually arrive, then run the same save logic used at the natural end of a meeting. If the process (and its tunnel) disappears before that event lands, the webhook has nowhere to deliver to, and later deliveries in the log will fail. Keeping the process alive through a graceful shutdown avoids that.

async function finalizeMeeting(botId) {
  await removeBot(botId);
  const stopped = await waitForBotStopped({ maxAttempts: 5, intervalMs: 2000 });

  if (!stopped) {
    console.warn('Bot stop was not confirmed after retries, proceeding anyway.');
  }

  const { videoData, audioData } = await waitForRecordings({ botId });
  await saveParticipantFiles(videoData, audioData, outputDir);
  await tunnel.close();
  process.exit(0);
}

async function waitForBotStopped({ maxAttempts, intervalMs }) {
  for (let i = 0; i < maxAttempts; i += 1) {
    if (botStoppedEventReceived) return true;
    await sleep(intervalMs);
  }
  return false;
}

process.on('SIGINT', () => finalizeMeeting(currentBotId));

Zoom Per-Participant Recording API vs Built-In Cloud Recording

Zoom's own cloud recording is simple to turn on but produces a single composite file by default. Zoom's developer documentation on cloud recording confirms that separate per-user video files exist as a distinct, account-enabled option, retrieved through the participant_video_files property rather than the standard recording response. A bot-based approach gets you separated streams directly, works regardless of the host's Zoom plan, and doesn't require that account-level configuration step. The tradeoff is that you're running a small service, the webhook server, the tunnel, the polling logic, instead of flipping one setting in Zoom's admin panel.


Building a Google Meet Bot That Records Each Participant Individually

Google Meet needs no special integration step the way Zoom does. Send the meeting_link, set video_separate_streams and audio_separate_streams to true, and the rest of the flow is identical to what's described above. Google's own Google Meet recording documentation confirms that native recording requires a qualifying Google Workspace edition and captures only the active speaker or shared screen, not separated per-participant streams. A bot is the more direct path if you're on a personal account or specifically need separated tracks.


Where This Fits: Virtual Events, Panels, and Coaching Platforms

A video recorder bot for virtual events benefits from per-participant separation in an obvious way. A panel with four speakers is easier to edit into individual clips, or caption per speaker, when each panelist already has their own file instead of a shared frame that needs cropping.

The same logic applies to interview and coaching platforms. Video recording architecture for AI coaching and interview tools usually needs a clean, isolated view of one person's face and voice, since that's the input scoring models or feedback systems actually work on. Per-participant recording gives that directly, without a separate cropping or diarization step to isolate the person being evaluated.


Common Mistakes to Avoid

  • Treating the stopped event as "ready to download." It only means the meeting ended. Poll the recording and audio endpoints until they report success before trying to save anything.
  • Assuming one fixed response shape. The per-participant streams response can come back nested or flat. Parse for both.
  • Letting the tunnel die before the stopped event arrives. If the process exits early, the webhook has nowhere to deliver to.
  • Skipping the debug JSON. Saving the raw API response alongside each run's recordings turns a missing file into a five-minute fix instead of a guessing game.

Best Practices for Storing and Syncing Per-Participant Recordings

Give every run its own timestamped folder so recordings from different meetings never collide, then nest a folder per participant inside that. Save each stream type, webcam, screen share, audio, under a clear filename rather than a generated ID. Writing the raw API response to disk alongside the recordings, as noted above, makes troubleshooting a missing file far faster later.


FAQ

How do I record each meeting participant as a separate video file?

Create a bot with video_separate_streams set to true, and audio_separate_streams for the audio side. Once the meeting ends and processing finishes, call the recording streams and audio streams endpoints. Each entry maps to one participant's stream, which gets downloaded and saved into that participant's own folder.

What's the difference between composite recording and per-participant recording in meeting bots?

Composite recording captures the shared meeting view as one file. Per-participant recording captures each person's own stream separately. Composite is simpler and fine for a general archive. Per-participant is what's needed if anything downstream operates on individual speakers.

Can meeting bots record individual participant video streams instead of one composite view?

Yes, provided the bot API supports separated capture rather than only a merged recording. Look for a flag like video_separate_streams on bot creation. Without it, the default is a single composite file.

Do meeting bot APIs support multi-track video export?

Some do. It's worth checking the specific API's documentation for a per-participant or per-stream flag on bot creation, since not every meeting bot defaults to, or offers, separated tracks.

Is per-participant video recording possible without violating meeting platform policies?

Bot-based recording still needs to follow the same consent and notice requirements as any other meeting recording, which vary by platform and jurisdiction. The technical ability to separate streams doesn't change those compliance obligations. Check the specific platform's recording policy, and your own organization's requirements, before deploying a recorder into live calls.


Next Step

Ready to try it. Explore MeetStream's meeting bot API and read the full documentation to start building a per-participant video recorder bot for your own meetings.


Full source is in MeetStream Labs under per-participant-video-recorder . Clone it, fill in two keys, run npm start.

Built with the MeetStream API. Supports Google Meet, Zoom, and Microsoft Teams.

💡
If you found this helpful, check out our other blogs, such as How to Build a Post-Call Transcription Bot with MeetStream