Build a Dockerized AI Meeting Bot

Containerize a bot that joins Google Meet, records, and summarizes meetings.

Introduction

30 Second Summary

Meetings are where decisions happen, but nobody wants to be the person scribbling notes the whole time. What if you had a bot that could join your calls, record everything, and tell you exactly who said what?

In this project, you will build a fully containerized AI meeting bot that joins Google Meet calls, records audio and video, produces speaker-diarized transcripts, and generates structured summaries with action items attributed to individual speakers.

What You'll Build

A self-contained Docker container running a FastAPI server that accepts Google Meet links via a REST API, automates browser-based meeting attendance with Playwright, and processes recordings through OpenAI's diarization and summarization models.

By the end of this project, you'll have:

  • A Docker container with a virtual display and virtual audio that joins Google Meet calls as a guest, records both audio and video, and automatically leaves when the meeting ends.
  • A speaker diarization pipeline that identifies individual speakers in the transcript and attributes key points to them.
  • A FastAPI backend with REST endpoints to trigger the bot, check job status, and download the video recording, diarized transcript, and AI-generated summary.
  • Secret Mission: Add webhook notifications so the bot alerts external services when processing completes.

Are there any prerequisites?

You should be comfortable with Python and have Docker Desktop installed on your machine. You also need an OpenAI API key for speaker diarization and summarization.

Set Up the Project Structure

Before you write any bot logic, you need a clean project folder with all the files your meeting bot will use. This project has eight files, each with a distinct job in the recording and transcription pipeline.

In this step, you will scaffold the project directory, configure your environment variables, and pin the exact versions of every Python dependency.

In this step, get ready to:
  • Create the project directory with all eight required files.
  • Configure the .env file with your API key and Whisper endpoint.
  • Populate requirements.txt with pinned Python dependencies.
Create the project directory and files

Your meeting bot needs eight files. Some handle browser automation, some handle transcription, and some handle the REST API. You will create them all now as empty files and fill them in over the next steps.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir meeting-bot
cd meeting-bot
  • Create all eight project files by running this command:

macOS

touch Dockerfile entrypoint.sh bot.py transcribe.py api.py run.py requirements.txt .env

Windows

New-Item Dockerfile, entrypoint.sh, bot.py, transcribe.py, api.py, run.py, requirements.txt, .env

What are these eight files?

  • Dockerfile defines the Docker container with all system-level dependencies (virtual display, audio, browser).
  • entrypoint.sh is the startup script that initializes the virtual display and audio inside the container.
  • bot.py handles browser automation. It joins Google Meet, records audio and video, and leaves when the meeting ends.
  • transcribe.py sends the recorded audio to OpenAI for speaker-diarized transcription and generates an AI summary.
  • api.py is the FastAPI server that exposes REST endpoints to trigger the bot and check results.
  • run.py is a CLI entrypoint for running the bot directly from the command line.
  • requirements.txt pins the exact Python package versions the project depends on.
  • .env stores your API keys and configuration values outside of code.
  • Confirm all files were created by running:
ls -a

You should see all eight files listed: Dockerfile, entrypoint.sh, bot.py, transcribe.py, api.py, run.py, requirements.txt, and .env.

Don't see all eight files?

  • The .env file is hidden by default. Use ls -a (not just ls) to see files that start with a dot.
  • If a file is missing, double-check the spelling in your touch or New-Item command. File names are case-sensitive on macOS and Linux.
  • Make sure you ran the command from inside the meeting-bot folder, not from your Desktop.

Still stuck? Ask for help.

Configure your environment variables

The .env file keeps sensitive credentials and server-specific configuration out of your code. Your bot needs an OpenAI API key for diarized transcription and summarization, plus the address of your self-hosted Whisper instance.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type VS Code and press Enter to open it.
  • In VS Code, click File in the top menu bar.
  • Click Open Folder.
  • Select the meeting-bot folder on your Desktop, then click Open.
  • In the Explorer sidebar on the left, click .env to open it.
  • Paste the following configuration into .env:
OPENAI_API_KEY=your-api-key-here
WHISPER_BASE_URL=http://[[VLLM_SERVER="your-vllm-server:8000"]]/v1
WHISPER_MODEL=openai/whisper-large-v3

What do these variables do?

  • OPENAI_API_KEY authenticates with the OpenAI API. The bot uses this for speaker-diarized transcription and AI-powered summarization.
  • WHISPER_BASE_URL points to your self-hosted vLLM Whisper instance. The /v1 path makes it compatible with the OpenAI client SDK.
  • WHISPER_MODEL tells the client which model your vLLM server is running. The default is openai/whisper-large-v3.
  • Replace your-api-key-here with your actual OpenAI API key.
  • Replace the WHISPER_BASE_URL value with your vLLM server's actual address (for example, http://192.168.1.50:8000/v1).
  • Save .env with Cmd+S (macOS) or Ctrl+S (Windows).

Don't have your OpenAI API key handy?

You can find your key at platform.openai.com/api-keys. If you have not created one yet, click Create new secret key and copy the value into your .env file.

Need more help getting set up?

✔️ Awesome, I've got everything!

Make sure you have saved .env with your real values filled in.

ⓧ I'd like to double check the full code

Here is the complete .env file. Your actual values will differ from the placeholders below.

OPENAI_API_KEY=your-api-key-here
WHISPER_BASE_URL=http://your-vllm-server:8000/v1
WHISPER_MODEL=openai/whisper-large-v3
Pin the Python dependencies

Pinning exact version numbers in requirements.txt ensures every build of your Docker image uses the same packages. This prevents surprise breakages from upstream updates.

  • In the Explorer sidebar, click requirements.txt to open it.
  • Paste the following dependencies into requirements.txt:
playwright==1.59.0
openai==1.82.0
python-dotenv==1.1.0
fastapi==0.136.1
uvicorn==0.34.0

What do these packages do?

  • playwright (1.59.0) is the browser automation library. Playwright launches Chromium inside the container to join Google Meet.
  • openai (1.82.0) is the official Python SDK for the OpenAI API. The bot uses it for diarized transcription and meeting summarization.
  • python-dotenv (1.1.0) loads variables from your .env file into the Python environment at runtime.
  • fastapi (0.136.1) is the web framework for the bot's REST API endpoints.
  • uvicorn (0.34.0) is the ASGI server that runs FastAPI in production.
  • Save requirements.txt with Cmd+S (macOS) or Ctrl+S (Windows).

Seeing a warning about unresolved packages?

VS Code extensions like Pylance may flag these packages because they are not installed locally. That is expected. The packages will be installed inside the Docker container when you build it in the next step.

Need help with a different issue?

✔️ Awesome, I've got everything!

Make sure you have saved requirements.txt before moving on.

ⓧ I'd like to double check the full code

Here is the complete requirements.txt file. Make sure yours matches exactly.

playwright==1.59.0
openai==1.82.0
python-dotenv==1.1.0
fastapi==0.136.1
uvicorn==0.34.0

Your project scaffolding is in place. Next up, you will write the Dockerfile that bundles a browser, a virtual display, and a virtual audio system into a single container.

Build the Dockerfile

Your project structure is ready with all the files in place. Now you need to define the environment where your bot will actually run.

The meeting bot requires a browser, virtual audio devices, a virtual display, and recording tools. A Docker container packages all of these into a single, portable environment that works the same way on any machine.

In this step, get ready to:
  • Write a Dockerfile based on the official Playwright Python image.
  • Install system-level dependencies for audio capture, virtual display, and video recording.
  • Configure PulseAudio permissions and set up the application layer.

Before you start, make sure Docker Desktop is running. You will need it ready for later steps when you build and run the image.

Choose the base image and install system packages

Every Dockerfile starts with a base image. Your bot needs a browser to join Google Meet, so you will start from Microsoft's official Playwright Python image. This image comes with Chromium and all its dependencies pre-installed.

On top of that, you need four system packages that the base image does not include. These create the virtual audio and display devices your bot uses to record meetings inside the container.

  • Open Dockerfile in your code editor and add the following code:
# Use Playwright's official image with Chromium pre-installed
FROM mcr.microsoft.com/playwright/python:v1.59.0-noble

# Install system packages for virtual audio, display, and recording
RUN apt-get update && apt-get install -y \
    pulseaudio \
    dbus \
    xvfb \
    ffmpeg \
    && rm -rf /var/lib/apt/lists/*

What does this code do?

  • FROM sets the base image to Microsoft's Playwright Python image at version v1.59.0-noble. This gives you Chromium and Python pre-installed with zero manual browser setup.
  • pulseaudio is a sound server that creates virtual audio devices inside the container. The bot captures meeting audio through it.
  • dbus is a message bus system that PulseAudio requires to start properly.
  • xvfb creates a virtual display so Chromium can render the Google Meet UI without a physical monitor.
  • ffmpeg records both the virtual display and audio streams into video and audio files.
  • rm -rf /var/lib/apt/lists/* cleans up the package cache to keep the final image small.

Why the Playwright image instead of a plain Python image?

Installing Chromium and all its system dependencies manually is painful. The official Playwright image bundles all of that for you.

It also has better async support and built-in stealth features compared to alternatives like Selenium. One base image handles the hardest part of browser automation in Docker.

  • Save Dockerfile.

You won't see any visible change yet. The next section adds the rest of the file.

Configure permissions and set up the application layer

PulseAudio only allows users in the pulse-access group to use it. Without the right permissions, the audio server refuses to start and your bot records silence.

After fixing permissions, you will set up the application layer. This copies your project files into the container and installs the Python packages from requirements.txt.

  • In Dockerfile, add the following code below the apt-get block you just wrote:
# Grant PulseAudio access to the root user
RUN adduser root pulse-access

# Set the working directory inside the container
WORKDIR /app

# Install Python dependencies first (enables Docker layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Copy all project files into the container
COPY . .

# Make the entrypoint script executable
RUN chmod +x entrypoint.sh

# Run the entrypoint script when the container starts
ENTRYPOINT ["./entrypoint.sh"]

What does this code do?

  • adduser root pulse-access adds the root user to the pulse-access group. Without this, PulseAudio refuses to start inside the container.
  • COPY requirements.txt . followed by pip install copies the requirements file first and installs dependencies as a separate layer. Docker caches this layer, so re-builds skip the install step unless your dependencies change.
  • COPY . . copies all your project files (bot.py, api.py, etc.) into the container's /app directory.
  • chmod +x entrypoint.sh makes the entrypoint script executable so Docker can run it.
  • ENTRYPOINT ["./entrypoint.sh"] tells Docker to run the entrypoint script every time the container starts. This script will initialize the virtual display and audio devices.

Why copy requirements.txt separately?

Docker builds images in layers. Each instruction creates a new layer that Docker caches.

By copying requirements.txt and running pip install before copying your source code, Docker reuses the cached dependency layer whenever you change your Python files. This saves minutes on every rebuild.

  • Save Dockerfile.

You won't build the image just yet. The entrypoint script needs to be written first, which happens in the next step.

Seeing a syntax error in your Dockerfile?

  • Make sure each line in the apt-get install block ends with a backslash \ except for the last line. No extra spaces after the backslash.
  • Check that the ENTRYPOINT uses square brackets and double quotes: ENTRYPOINT ["./entrypoint.sh"]. Missing quotes or brackets cause startup failures.
  • Verify there are no blank lines between the backslash-continued lines in the RUN apt-get block. A blank line breaks the continuation.

Help me fix a syntax error in my Dockerfile for the AI meeting bot

Double check your complete Dockerfile matches the full code below.

✔️ Awesome, I've got everything!

Great. Make sure you have saved your Dockerfile.

ⓧ I'd like to double check the full code

Here is the complete Dockerfile. Make sure yours matches exactly.

FROM mcr.microsoft.com/playwright/python:v1.59.0-noble

RUN apt-get update && apt-get install -y \
    pulseaudio \
    dbus \
    xvfb \
    ffmpeg \
    && rm -rf /var/lib/apt/lists/*

RUN adduser root pulse-access

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY . .

RUN chmod +x entrypoint.sh

ENTRYPOINT ["./entrypoint.sh"]

Your Dockerfile is complete. It defines the full environment your bot needs, from browser to audio to video recording. Next up, you will write the entrypoint script that brings all these services to life when the container starts.

Create the Audio and Display Entrypoint

Your Dockerfile defines what software lives inside the container. But the container still needs a script that starts all the virtual services when it boots up.

The meeting bot needs a virtual screen to render the browser and a virtual audio device to capture meeting audio. In this step, you'll write the entrypoint.sh script that initializes these services, then build the Docker image to confirm everything works.

In this step, get ready to:
  • Write the entrypoint script that starts D-Bus, PulseAudio, and Xvfb.
  • Understand how the audio and video recording pipeline works inside the container.
  • Build the Docker image and verify the services start correctly.
Write the entrypoint script

The entrypoint.sh script is the first thing that runs when your container starts. It sets up three critical services before launching your application.

  • Open entrypoint.sh in your code editor.
  • Add the D-Bus initialization block by copying this code:
#!/bin/bash
set -e

# Initialize D-Bus (required for PulseAudio)
mkdir -p /var/run/dbus
dbus-uuidgen > /var/lib/dbus/machine-id
dbus-daemon --config-file=/usr/share/dbus-1/system.conf --print-address > /dev/null 2>&1 &

What does this code do?

set -e tells the script to stop immediately if any command fails. This prevents the container from running in a broken state.

D-Bus is a message bus system that PulseAudio needs to communicate with other system services. Without it, PulseAudio refuses to start. dbus-uuidgen generates a unique machine ID, and dbus-daemon starts the message bus in the background.

  • Save entrypoint.sh.
  • You won't see a change yet. The next chunk adds PulseAudio setup.
  • Below the D-Bus block in entrypoint.sh, add the PulseAudio setup by copying this code:
# Clean PulseAudio state and start daemon
rm -rf /var/run/pulse /var/lib/pulse /root/.config/pulse
pulseaudio -D --verbose --exit-idle-time=-1 --system --disallow-exit > /dev/null 2>&1

# Create virtual audio sink for recording
pactl load-module module-null-sink sink_name=recording_sink sink_properties=device.description="Recording_Sink"
pactl set-default-sink recording_sink

What does this code do?

  • The rm -rf line clears any leftover PulseAudio state from previous container runs. Stale state files cause "connection refused" errors.
  • pulseaudio -D --system starts PulseAudio as a background system daemon. --exit-idle-time=-1 keeps it running even when no audio is playing. --disallow-exit prevents it from shutting itself down.
  • module-null-sink creates a virtual audio device called recording_sink. The browser's audio goes here. Later, ffmpeg will record from this sink's monitor source.
  • pactl set-default-sink makes recording_sink the default audio output. Any audio the browser plays automatically routes here.
  • Save entrypoint.sh.
  • You won't see a change yet. The next chunk adds the virtual display and startup logic.
  • Add the virtual display and server startup by copying this final block below the PulseAudio section:
# Start virtual display
Xvfb :99 -screen 0 1280x720x24 > /dev/null 2>&1 &
export DISPLAY=:99

# Wait for services to be ready
sleep 2

# Check for CLI mode vs server mode
if [ "$1" = "--cli" ]; then
    shift
    exec python run.py "$@"
else
    exec uvicorn api:app --host 0.0.0.0 --port 8080
fi

What does this code do?

  • Xvfb creates a virtual screen at display :99 with a resolution of 1280x720. The browser renders its UI onto this invisible screen. Later, ffmpeg captures this screen to record the meeting video.
  • export DISPLAY=:99 tells all graphical applications (like Chromium) to use this virtual screen.
  • sleep 2 gives PulseAudio and Xvfb a moment to finish starting before the application launches.
  • The if/else block checks for a --cli flag. If present, it runs run.py for direct command-line usage. Otherwise, it starts the FastAPI server with uvicorn on port 8080.
  • Save entrypoint.sh.

Script not saving correctly?

  • Make sure the shebang line #!/bin/bash is the very first line of the file with no blank lines above it.
  • Check that you're editing entrypoint.sh in your project directory, not a different file.
  • If you're on Windows, make sure your editor saves the file with LF line endings, not CRLF. Bash scripts break with Windows-style line endings.
  • Help me fix my entrypoint.sh script

✔️ Awesome, I've got everything!

Great. Double check you've saved entrypoint.sh.

ⓧ I'd like to double check the full code

#!/bin/bash
set -e

# Initialize D-Bus (required for PulseAudio)
mkdir -p /var/run/dbus
dbus-uuidgen > /var/lib/dbus/machine-id
dbus-daemon --config-file=/usr/share/dbus-1/system.conf --print-address > /dev/null 2>&1 &

# Clean PulseAudio state and start daemon
rm -rf /var/run/pulse /var/lib/pulse /root/.config/pulse
pulseaudio -D --verbose --exit-idle-time=-1 --system --disallow-exit > /dev/null 2>&1

# Create virtual audio sink for recording
pactl load-module module-null-sink sink_name=recording_sink sink_properties=device.description="Recording_Sink"
pactl set-default-sink recording_sink

# Start virtual display
Xvfb :99 -screen 0 1280x720x24 > /dev/null 2>&1 &
export DISPLAY=:99

# Wait for services to be ready
sleep 2

# Check for CLI mode vs server mode
if [ "$1" = "--cli" ]; then
    shift
    exec python run.py "$@"
else
    exec uvicorn api:app --host 0.0.0.0 --port 8080
fi
Understand the audio and video pipeline

Before building the image, take a moment to understand how audio and video recording works inside the container. Every piece of the pipeline stays entirely within the container.

How does the recording pipeline work?

The browser (Chromium) renders the Google Meet UI onto the Xvfb virtual screen and outputs meeting audio to the PulseAudio virtual sink (recording_sink).

ffmpeg runs two separate capture processes. One records audio from the PulseAudio monitor source (recording_sink.monitor) as a clean WAV file for transcription. The other records the Xvfb display using x11grab combined with the audio to produce an MP4 video for playback.

Two separate ffmpeg processes keep the transcription input (clean WAV) separate from the playback file (MP4 with video and audio). The transcription API needs a clean audio file, not a video container.

Why a virtual display and audio instead of real hardware?

A Docker container has no physical monitor or speakers. Xvfb and PulseAudio simulate these devices in software. The browser thinks it has a real screen and real audio output. ffmpeg captures from these virtual devices just like it would from real hardware.

This means the bot runs on any server without needing a GPU, a display, or a sound card. The entire recording pipeline is self-contained inside the container.

Build the Docker image and verify the services

With the entrypoint script written, it is time to build the Docker image and confirm the virtual services can start inside the container.

  • Make sure Docker Desktop is running.
  • Build the Docker image by running this command from your project directory:
docker build -t meeting-bot .

What does this command do?

docker build reads your Dockerfile and creates an image with all the software and files your bot needs. The -t meeting-bot flag gives the image a name you can reference later. The . tells Docker to use the current directory as the build context.

  • Wait for the build to complete. The first build downloads the Playwright base image and installs dependencies, so it may take a few minutes.
  • You should see Successfully tagged meeting-bot:latest (or a similar success message with the image ID) at the end of the output.

Build failing?

  • If you see a "permission denied" error on entrypoint.sh, check that your Dockerfile includes the RUN chmod +x entrypoint.sh line.
  • If you see errors about missing files, make sure all project files (entrypoint.sh, requirements.txt, etc.) exist in the same directory where you run the build command.
  • If Docker Desktop is not running, you will see a "Cannot connect to the Docker daemon" error. Start Docker Desktop first, then try the build again.
  • Help me debug my Docker build error

Now verify that PulseAudio and Xvfb can actually start inside the container.

  • Run this test command to start the virtual services and check their status:
docker run --rm --entrypoint bash meeting-bot -c "
mkdir -p /var/run/dbus &&
dbus-uuidgen > /var/lib/dbus/machine-id &&
dbus-daemon --config-file=/usr/share/dbus-1/system.conf > /dev/null 2>&1 &
sleep 1 &&
rm -rf /var/run/pulse /var/lib/pulse /root/.config/pulse &&
pulseaudio -D --system --exit-idle-time=-1 --disallow-exit > /dev/null 2>&1 &&
pactl load-module module-null-sink sink_name=recording_sink > /dev/null &&
pactl info | grep 'Server Name' &&
Xvfb :99 -screen 0 1280x720x24 > /dev/null 2>&1 &
sleep 1 &&
echo 'Xvfb running on display :99' &&
echo 'All services started successfully'
"

What does this command do?

This command starts a temporary container with --entrypoint bash to override the default entrypoint. It manually runs each service initialization step from your script so you can verify each one starts. The --rm flag automatically removes the container when it exits.

We override the entrypoint because the default one tries to start the FastAPI server, which is not ready yet. This test focuses only on the virtual services.

  • You should see output that includes Server Name: pulseaudio followed by Xvfb running on display :99 and All services started successfully.

PulseAudio or Xvfb not starting?

  • If you see a "Connection refused" error from PulseAudio, the D-Bus daemon may not have started. Check that the dbus package is listed in your Dockerfile's apt-get install line.
  • If PulseAudio complains about permissions, verify that your Dockerfile includes RUN adduser root pulse-access. This grants the root user permission to run PulseAudio as a system daemon.
  • If Xvfb is not found, make sure xvfb is in your Dockerfile's apt-get install line. Rebuild the image after any Dockerfile changes.
  • Help me troubleshoot PulseAudio or Xvfb inside my Docker container

Your container now has a working virtual display and audio pipeline. Next up, you'll write the bot that uses Playwright to join a Google Meet call and record it.

Write the Meeting Bot with Playwright

Your Docker container now has a working virtual display and audio pipeline. The infrastructure is ready for a browser to render into.

Now you need the actual bot that controls that browser. Playwright will launch a Chromium instance inside the container, navigate to a Google Meet link, join as a guest, and capture both audio and video using the virtual devices you set up. The bot also needs to know when the meeting is over so it can leave on its own.

In this step, get ready to:
  • Write browser automation that joins a Google Meet call as "AI Notetaker."
  • Start two ffmpeg subprocesses to record audio and video from the virtual devices.
  • Implement a smart auto-leave loop that detects when all other participants have left.
Write the participant detection helper

Google Meet does not expose a clean "meeting ended" event. Instead, the bot needs to check for visual signals on the page that indicate everyone else has left.

There are three reliable signals: the "You're the only one here" message, the "The call has ended" screen, and the disappearance of the Leave call button. The helper function checks all three.

  • Open bot.py in your code editor and add the imports and participant detection function:
import asyncio
import subprocess
import os
import time
from playwright.async_api import async_playwright


async def check_participants_left(page) -> bool:
    """Check if all other participants have left the meeting."""
    try:
        # Method 1: Check for "You're the only one here" message
        alone_message = page.locator('text="You\'re the only one here"')
        if await alone_message.is_visible(timeout=1000):
            return True
    except Exception:
        pass

    try:
        # Method 2: Check for the "call ended" or "removed from meeting" screen
        call_ended = page.locator('text="The call has ended"')
        if await call_ended.is_visible(timeout=1000):
            return True
    except Exception:
        pass

    try:
        # Method 3: Check if "Leave call" button disappeared (kicked or meeting ended)
        leave_button = page.locator('[aria-label="Leave call"]')
        if not await leave_button.is_visible(timeout=1000):
            return True
    except Exception:
        pass

    return False

What does this code do?

The imports bring in asyncio for async control flow, subprocess for launching ffmpeg processes, time for tracking recording duration, and Playwright's async API for browser automation.

check_participants_left uses Playwright locators to look for three DOM signals. Each check is wrapped in a try/except because any of these elements might not exist at a given moment. If none of the three signals fire, the function returns False (meeting is still active).

Why text-based selectors instead of CSS classes?

Google Meet's CSS class names change frequently between deployments. Text-based selectors like button:has-text("Ask to join") and aria-label attributes are more stable because they are tied to visible UI text and accessibility labels. These change far less often than internal class names.

  • Save bot.py.

You will not see a change yet. The next chunk builds the main function that uses this helper.

Now for the core automation. The bot needs to launch a Chromium browser with specific flags, navigate to the Google Meet link, enter a guest name, mute its mic and camera, and click the join button.

  • Below the check_participants_left function, add the start of the main join_and_record function:

async def join_and_record(
    meet_link: str, max_duration: int, audio_path: str, video_path: str
):
    """Join a Google Meet, record audio and video, and leave when empty."""
    print(f"Joining meeting: {meet_link}")
    print(f"Max recording duration: {max_duration} seconds")

    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=False,
            args=[
                "--use-fake-ui-for-media-stream",
                "--disable-notifications",
                "--no-sandbox",
                "--disable-setuid-sandbox",
                "--disable-gpu",
                "--disable-dev-shm-usage",
                "--autoplay-policy=no-user-gesture-required",
            ],
        )
        context = await browser.new_context(
            permissions=["microphone", "camera"],
        )
        page = await context.new_page()

        # Navigate to the meeting
        await page.goto(meet_link, wait_until="networkidle", timeout=30000)
        await page.wait_for_timeout(3000)

        # Enter guest name
        name_input = page.locator('input[aria-label="Your name"]')
        if await name_input.is_visible(timeout=5000):
            await name_input.fill("")
            await name_input.fill("AI Notetaker")

        # Turn off microphone and camera if toggle buttons are visible
        try:
            mic_button = page.locator(
                '[data-is-muted="false"][aria-label*="microphone"]'
            )
            if await mic_button.is_visible(timeout=3000):
                await mic_button.click()
        except Exception:
            pass

        try:
            cam_button = page.locator(
                '[data-is-muted="false"][aria-label*="camera"]'
            )
            if await cam_button.is_visible(timeout=3000):
                await cam_button.click()
        except Exception:
            pass

        # Click "Ask to join"
        ask_to_join = page.locator('button:has-text("Ask to join")')
        if await ask_to_join.is_visible(timeout=10000):
            await ask_to_join.click()
            print("Clicked 'Ask to join'. Waiting for admission...")
        else:
            join_now = page.locator('button:has-text("Join now")')
            if await join_now.is_visible(timeout=5000):
                await join_now.click()
                print("Clicked 'Join now'.")

What does this code do?

  • headless=False launches a visible browser window. This sounds counterintuitive inside a container, but the browser renders onto the Xvfb virtual display at :99. That is what ffmpeg captures for the video recording.
  • --use-fake-ui-for-media-stream tells Chromium to auto-accept microphone and camera permission prompts instead of showing a dialog.
  • The bot first tries Ask to join (guest join with waiting room). If that button does not exist, it falls back to Join now (direct entry when the host has disabled the waiting room).
  • The mic and camera toggles use data-is-muted="false" to only click them if they are currently unmuted. This avoids accidentally toggling them ON.

Why guest join instead of Google login?

Automating a Google login is fragile. It involves 2FA, CAPTCHAs, and UI changes that break regularly. Guest join is simpler and more reliable. The trade-off is that the meeting host must admit the bot from the waiting room. This is the same approach used by most open-source meeting bots in production.

  • Save bot.py.

You will not see a change yet. The function is incomplete and will be extended in the next two sections.

Seeing a syntax error in your editor?

The join_and_record function is not finished yet. Your editor may flag it because the async with block has no return statement. This is expected and will be resolved as you add the remaining code.

Help me fix a syntax error in bot.py

Start audio and video recording after admission

After clicking join, the bot has to wait for the host to admit it. Once inside the meeting, it starts two separate ffmpeg processes. One records clean audio from the PulseAudio virtual sink as a WAV file (this is what gets sent to the transcription API). The other captures the Xvfb display as an MP4 video with audio muxed in (for playback).

  • In bot.py, add the admission wait and recording logic directly below the "Clicked 'Join now'." print statement, at the same indentation level (inside the async with block):
        # Wait for admission (look for the meeting UI to load)
        try:
            await page.wait_for_selector(
                '[aria-label="Leave call"]', timeout=120000
            )
            print("Successfully joined the meeting!")
        except Exception:
            print(
                "Timed out waiting for admission. The host may not have let you in."
            )
            await browser.close()
            return False

        # Start audio recording from PulseAudio monitor (no -t flag, we stop manually)
        print("Starting audio recording...")
        audio_process = subprocess.Popen(
            [
                "ffmpeg",
                "-y",
                "-f", "pulse",
                "-i", "recording_sink.monitor",
                "-ac", "1",
                "-ar", "16000",
                audio_path,
            ],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )

        # Start video recording from Xvfb display with audio (no -t flag)
        print("Starting video recording...")
        video_process = subprocess.Popen(
            [
                "ffmpeg",
                "-y",
                "-f", "x11grab",
                "-video_size", "1280x720",
                "-framerate", "15",
                "-i", ":99",
                "-f", "pulse",
                "-i", "recording_sink.monitor",
                "-c:v", "libx264",
                "-preset", "ultrafast",
                "-c:a", "aac",
                video_path,
            ],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )

What does this code do?

  • wait_for_selector('[aria-label="Leave call"]') blocks until the Leave call button appears. This button only shows up once the bot is inside the meeting. The 120-second timeout gives the host time to admit the bot from the waiting room.
  • The audio ffmpeg process records from recording_sink.monitor (the PulseAudio virtual sink you set up in entrypoint.sh). It saves mono audio at 16kHz, which is the ideal format for speech-to-text APIs.
  • The video ffmpeg process uses -f x11grab to capture the Xvfb display at :99. It also captures audio from the same PulseAudio source and muxes it into the MP4. The ultrafast preset keeps CPU usage low since meeting recordings do not need high compression quality.
  • Neither ffmpeg process uses a -t duration flag. The bot will stop them manually by calling .terminate() when it is time to leave.

Why two separate ffmpeg processes?

The transcription API needs a clean audio file (WAV, mono, 16kHz). The video recording needs a full MP4 with audio muxed in for playback. Running two ffmpeg instances keeps each output optimized for its purpose.

  • Save bot.py.

The function is still incomplete. The next section adds the logic that decides when to stop recording.

Implement the smart auto-leave polling loop

A simple sleep timer would waste recording time if the meeting ends early, or cut off the bot mid-conversation if it runs long. Instead, the bot polls every 10 seconds to check if all other participants have left.

It waits a 30-second grace period first to let the meeting settle. Then the polling loop calls the check_participants_left helper you wrote earlier. The bot exits the loop when either everyone else has left OR the max duration is reached.

  • In bot.py, add the polling loop and cleanup logic directly below the video ffmpeg subprocess.Popen block, at the same indentation level:
        # Poll for meeting end or max duration
        start_time = time.time()
        check_interval = 10  # seconds between participant checks
        # Grace period before checking (let meeting settle)
        await asyncio.sleep(30)

        while True:
            elapsed = time.time() - start_time

            # Check max duration
            if elapsed >= max_duration:
                print(f"Max duration ({max_duration}s) reached. Leaving meeting.")
                break

            # Check if everyone else has left
            if await check_participants_left(page):
                print("All other participants have left. Leaving meeting.")
                break

            # Wait before next check
            await asyncio.sleep(check_interval)

        # Stop recordings gracefully
        actual_duration = int(time.time() - start_time)
        audio_process.terminate()
        audio_process.wait(timeout=10)
        video_process.terminate()
        video_process.wait(timeout=10)
        print(f"Recording stopped after {actual_duration} seconds.")
        print(f"Audio saved to {audio_path}")
        print(f"Video saved to {video_path}")

        await browser.close()
        return True

What does this code do?

  • asyncio.sleep(30) is the grace period. Right after joining, Google Meet shows animations and layout changes. Polling too early would produce false positives.
  • The while True loop checks two exit conditions every 10 seconds: the max duration limit and whether all other participants have left. Either condition breaks the loop.
  • .terminate() sends SIGTERM to each ffmpeg process, followed by .wait(timeout=10) to let ffmpeg finalize the output files. This ensures the WAV and MP4 files are properly closed and readable.
  • The function returns True on success (recordings saved) or False if it never got into the meeting (timed out waiting for admission).
  • Save bot.py.

The bot script is now complete. You have a participant detection helper, browser automation for joining, two ffmpeg recording processes, and a smart polling loop for auto-leave.

Seeing indentation errors?

All three code chunks (join logic, recording, polling) must be inside the async with async_playwright() as p: block. Make sure they are indented 8 spaces (two levels) from the function definition. The check_participants_left function should be at the top level (no indentation beyond the function keyword).

Help me fix an indentation error in my bot.py file

Double check your complete bot.py matches the full file below.

✔️ Awesome, I've got everything!

Great. Double check you have saved bot.py.

ⓧ I'd like to double check the full code

import asyncio
import subprocess
import os
import time
from playwright.async_api import async_playwright


async def check_participants_left(page) -> bool:
    """Check if all other participants have left the meeting."""
    try:
        # Method 1: Check for "You're the only one here" message
        alone_message = page.locator('text="You\'re the only one here"')
        if await alone_message.is_visible(timeout=1000):
            return True
    except Exception:
        pass

    try:
        # Method 2: Check for the "call ended" or "removed from meeting" screen
        call_ended = page.locator('text="The call has ended"')
        if await call_ended.is_visible(timeout=1000):
            return True
    except Exception:
        pass

    try:
        # Method 3: Check if "Leave call" button disappeared (kicked or meeting ended)
        leave_button = page.locator('[aria-label="Leave call"]')
        if not await leave_button.is_visible(timeout=1000):
            return True
    except Exception:
        pass

    return False


async def join_and_record(
    meet_link: str, max_duration: int, audio_path: str, video_path: str
):
    """Join a Google Meet, record audio and video, and leave when empty."""
    print(f"Joining meeting: {meet_link}")
    print(f"Max recording duration: {max_duration} seconds")

    async with async_playwright() as p:
        browser = await p.chromium.launch(
            headless=False,
            args=[
                "--use-fake-ui-for-media-stream",
                "--disable-notifications",
                "--no-sandbox",
                "--disable-setuid-sandbox",
                "--disable-gpu",
                "--disable-dev-shm-usage",
                "--autoplay-policy=no-user-gesture-required",
            ],
        )
        context = await browser.new_context(
            permissions=["microphone", "camera"],
        )
        page = await context.new_page()

        # Navigate to the meeting
        await page.goto(meet_link, wait_until="networkidle", timeout=30000)
        await page.wait_for_timeout(3000)

        # Enter guest name
        name_input = page.locator('input[aria-label="Your name"]')
        if await name_input.is_visible(timeout=5000):
            await name_input.fill("")
            await name_input.fill("AI Notetaker")

        # Turn off microphone and camera if toggle buttons are visible
        try:
            mic_button = page.locator(
                '[data-is-muted="false"][aria-label*="microphone"]'
            )
            if await mic_button.is_visible(timeout=3000):
                await mic_button.click()
        except Exception:
            pass

        try:
            cam_button = page.locator(
                '[data-is-muted="false"][aria-label*="camera"]'
            )
            if await cam_button.is_visible(timeout=3000):
                await cam_button.click()
        except Exception:
            pass

        # Click "Ask to join"
        ask_to_join = page.locator('button:has-text("Ask to join")')
        if await ask_to_join.is_visible(timeout=10000):
            await ask_to_join.click()
            print("Clicked 'Ask to join'. Waiting for admission...")
        else:
            join_now = page.locator('button:has-text("Join now")')
            if await join_now.is_visible(timeout=5000):
                await join_now.click()
                print("Clicked 'Join now'.")

        # Wait for admission (look for the meeting UI to load)
        try:
            await page.wait_for_selector(
                '[aria-label="Leave call"]', timeout=120000
            )
            print("Successfully joined the meeting!")
        except Exception:
            print(
                "Timed out waiting for admission. The host may not have let you in."
            )
            await browser.close()
            return False

        # Start audio recording from PulseAudio monitor (no -t flag, we stop manually)
        print("Starting audio recording...")
        audio_process = subprocess.Popen(
            [
                "ffmpeg",
                "-y",
                "-f", "pulse",
                "-i", "recording_sink.monitor",
                "-ac", "1",
                "-ar", "16000",
                audio_path,
            ],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )

        # Start video recording from Xvfb display with audio (no -t flag)
        print("Starting video recording...")
        video_process = subprocess.Popen(
            [
                "ffmpeg",
                "-y",
                "-f", "x11grab",
                "-video_size", "1280x720",
                "-framerate", "15",
                "-i", ":99",
                "-f", "pulse",
                "-i", "recording_sink.monitor",
                "-c:v", "libx264",
                "-preset", "ultrafast",
                "-c:a", "aac",
                video_path,
            ],
            stdout=subprocess.DEVNULL,
            stderr=subprocess.DEVNULL,
        )

        # Poll for meeting end or max duration
        start_time = time.time()
        check_interval = 10  # seconds between participant checks
        # Grace period before checking (let meeting settle)
        await asyncio.sleep(30)

        while True:
            elapsed = time.time() - start_time

            # Check max duration
            if elapsed >= max_duration:
                print(f"Max duration ({max_duration}s) reached. Leaving meeting.")
                break

            # Check if everyone else has left
            if await check_participants_left(page):
                print("All other participants have left. Leaving meeting.")
                break

            # Wait before next check
            await asyncio.sleep(check_interval)

        # Stop recordings gracefully
        actual_duration = int(time.time() - start_time)
        audio_process.terminate()
        audio_process.wait(timeout=10)
        video_process.terminate()
        video_process.wait(timeout=10)
        print(f"Recording stopped after {actual_duration} seconds.")
        print(f"Audio saved to {audio_path}")
        print(f"Video saved to {video_path}")

        await browser.close()
        return True

Audio file size limit

OpenAI's transcription API has a 25 MB file size limit. At 16kHz mono WAV, this allows roughly 13 minutes of recording. For longer meetings, you would need to split the audio file before transcription. The base project does not cover splitting, but it is a possible extension.

The bot can now join a meeting, record everything, and leave on its own. Next up, you will write the transcription and summarization pipeline that turns those recordings into a speaker-diarized transcript and actionable summary.

Add Diarized Transcription and Speaker-Attributed Summarization

Your bot can now join a Google Meet call, record audio and video, and leave when the meeting empties. The raw recordings are useful, but a busy professional does not have time to re-watch an entire meeting.

In this step, you'll write transcribe.py to handle two AI-powered tasks. First, it sends the recorded audio to OpenAI's diarization model to identify who said what. Then it passes the labeled transcript to GPT-5.4-mini for a structured summary with speaker-attributed key points and action items.

In this step, get ready to:
  • Set up the OpenAI client for transcription and summarization.
  • Write a diarization function that identifies individual speakers in the audio.
  • Build a summarization pipeline that attributes key points and action items to specific speakers.
Set up the transcription module

The transcribe.py file needs an OpenAI client that can call both the diarization model and the chat completion model. A single client handles both since they use the same API key.

  • Open transcribe.py in your editor. This file is currently empty.
  • Add the imports and client setup by copying the following code:
import os
import json
from openai import OpenAI
from dotenv import load_dotenv

# Load environment variables from .env file
load_dotenv()

# Client for OpenAI API (diarized transcription + summarization)
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

What does this code do?

  • load_dotenv() reads your .env file so environment variables like OPENAI_API_KEY are available to the script.
  • openai_client is a single client instance that handles both diarized transcription and chat-based summarization through the same API key.
  • Save transcribe.py.
Add speaker diarization

A plain transcript gives you all the words, but no way to tell who said them. Speaker diarization solves this by labeling each segment of audio with a speaker identifier (Speaker A, Speaker B, and so on).

  • Below the openai_client line in transcribe.py, add the diarization function by copying the following code:

def transcribe_with_diarization(audio_path: str) -> dict:
    """Transcribe audio with speaker diarization using OpenAI."""
    print(f"Transcribing with speaker diarization: {audio_path}")

    with open(audio_path, "rb") as audio_file:
        transcription = openai_client.audio.transcriptions.create(
            model="gpt-4o-transcribe-diarize",
            file=audio_file,
            response_format="diarized_json",
            chunking_strategy="auto",
        )

    # Parse the diarized response
    diarized_data = (
        json.loads(transcription)
        if isinstance(transcription, str)
        else transcription
    )

    # Build a readable diarized transcript
    segments = diarized_data.get("segments", [])
    lines = []
    for segment in segments:
        speaker = segment.get("speaker", "Unknown")
        text = segment.get("text", "").strip()
        if text:
            lines.append(f"[Speaker {speaker}]: {text}")

    diarized_text = "\n".join(lines)
    plain_text = diarized_data.get("text", "")

    print(f"Transcription complete. {len(segments)} speaker segments found.")
    return {
        "diarized_text": diarized_text,
        "plain_text": plain_text,
        "segments": segments,
    }

What does this code do?

  • gpt-4o-transcribe-diarize is OpenAI's speech-to-text model that labels which speaker said each segment of audio.
  • response_format="diarized_json" returns structured data with speaker labels instead of plain text.
  • chunking_strategy="auto" is required for audio longer than 30 seconds. Without it, the API rejects long recordings.
  • The function parses each speaker segment and formats them as [Speaker A]: text lines for readability.

Why diarization instead of plain transcription?

A plain transcript merges everyone's words into a single stream. Diarization preserves who said each thing. This is what lets the summarizer attribute action items to specific people instead of just listing them.

  • Save transcribe.py.

You won't be able to test this function until the full pipeline runs in Step 7. The next function handles summarization.

Seeing a red underline on the OpenAI import?

  • This is expected if your local Python environment does not have the openai package installed. The package is installed inside the Docker container via requirements.txt, so the code will work when it runs there.
  • If you want the underline to go away locally, install the dependencies with pip install -r requirements.txt in your terminal.

Help me fix import errors in transcribe.py

Audio file size limit

OpenAI's transcription API has a 25 MB file size limit. At 16kHz mono WAV (the format your bot records in), this allows roughly 13 minutes of recording. For longer meetings, you would need to split the audio file before sending it.

Add speaker-attributed summarization

A diarized transcript tells you who said what, but it can still be long. The summarization function condenses it into a structured format with key points and action items attributed to specific speakers.

  • Below the transcribe_with_diarization function in transcribe.py, add the summarization function by copying the following code:

def summarize_transcript(diarized_text: str) -> str:
    """Generate a speaker-attributed meeting summary using GPT-5.4-mini."""
    print("Generating speaker-attributed meeting summary...")

    response = openai_client.chat.completions.create(
        model="gpt-5.4-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    "You are a meeting assistant. Analyze the following meeting "
                    "transcript with speaker labels and produce a structured summary. "
                    "Include:\n"
                    "1. A brief overview (2-3 sentences)\n"
                    "2. Key Points (bulleted list, attribute each to the speaker "
                    "who made it)\n"
                    "3. Action Items (bulleted list with the responsible speaker)\n"
                    "4. Overall Sentiment (positive, neutral, or negative with "
                    "brief explanation)\n"
                    "5. Speaker Participation (brief note on each speaker's role "
                    "and contributions)"
                ),
            },
            {
                "role": "user",
                "content": (
                    "Here is the meeting transcript with speaker labels:\n\n"
                    f"{diarized_text}"
                ),
            },
        ],
    )

    summary = response.choices[0].message.content
    print("Summary generated.")
    return summary

What does this code do?

  • The system prompt instructs gpt-5.4-mini to produce five sections: an overview, speaker-attributed key points, action items with responsible speakers, overall sentiment, and speaker participation notes.
  • Because the diarized transcript already has [Speaker A]: labels, the model can attribute each key point and action item to the right person.
  • Save transcribe.py.

The next function ties diarization and summarization together into one pipeline that other files can call.

Wire up the processing pipeline

The API and CLI entrypoints need a single function they can call with an audio file path and an output directory. The process_recording function is that entry point.

  • Below the summarize_transcript function, add the pipeline function by copying the following code:

def process_recording(audio_path: str, output_dir: str) -> dict:
    """Transcribe with diarization and summarize a meeting recording."""
    os.makedirs(output_dir, exist_ok=True)

    # Transcribe with speaker diarization via OpenAI
    result = transcribe_with_diarization(audio_path)

    transcript_path = os.path.join(output_dir, "transcript_diarized.txt")
    with open(transcript_path, "w") as f:
        f.write(result["diarized_text"])
    print(f"Diarized transcript saved to {transcript_path}")

    # Save plain transcript too
    plain_path = os.path.join(output_dir, "transcript_plain.txt")
    with open(plain_path, "w") as f:
        f.write(result["plain_text"])

    # Summarize with speaker attribution via OpenAI
    summary = summarize_transcript(result["diarized_text"])
    summary_path = os.path.join(output_dir, "summary.txt")
    with open(summary_path, "w") as f:
        f.write(summary)
    print(f"Summary saved to {summary_path}")

    return {
        "transcript_path": transcript_path,
        "plain_transcript_path": plain_path,
        "summary_path": summary_path,
        "num_speakers": len(
            set(s.get("speaker", "") for s in result["segments"])
        ),
    }

What does this code do?

  • process_recording is the main entry point that the API and CLI will call. It runs diarization first, saves both the diarized and plain transcripts, then generates the summary.
  • It saves three output files: transcript_diarized.txt (with speaker labels), transcript_plain.txt (raw text), and summary.txt (the structured summary).
  • num_speakers counts unique speaker labels so the API can report how many people spoke in the meeting.
  • Save transcribe.py.

The file now contains the complete transcription and summarization pipeline. You won't be able to test it until the full end-to-end run in Step 7.

Worried about getting the code right?

Double check your indentation. Python is sensitive to whitespace. Every function body should be indented with 4 spaces, and nested blocks (like the for loop inside transcribe_with_diarization) should have 8 spaces.

Help me check my transcribe.py for errors

✔️ Awesome, I've got everything!

Great. Double check you've saved transcribe.py before moving on.

ⓧ I'd like to double check the full code

Here is the complete transcribe.py file. Compare it with yours line by line.

import os
import json
from openai import OpenAI
from dotenv import load_dotenv

load_dotenv()

# Client for OpenAI API (diarized transcription + summarization)
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))


def transcribe_with_diarization(audio_path: str) -> dict:
    """Transcribe audio with speaker diarization using OpenAI."""
    print(f"Transcribing with speaker diarization: {audio_path}")

    with open(audio_path, "rb") as audio_file:
        transcription = openai_client.audio.transcriptions.create(
            model="gpt-4o-transcribe-diarize",
            file=audio_file,
            response_format="diarized_json",
            chunking_strategy="auto",
        )

    # Parse the diarized response
    diarized_data = (
        json.loads(transcription)
        if isinstance(transcription, str)
        else transcription
    )

    # Build a readable diarized transcript
    segments = diarized_data.get("segments", [])
    lines = []
    for segment in segments:
        speaker = segment.get("speaker", "Unknown")
        text = segment.get("text", "").strip()
        if text:
            lines.append(f"[Speaker {speaker}]: {text}")

    diarized_text = "\n".join(lines)
    plain_text = diarized_data.get("text", "")

    print(f"Transcription complete. {len(segments)} speaker segments found.")
    return {
        "diarized_text": diarized_text,
        "plain_text": plain_text,
        "segments": segments,
    }


def summarize_transcript(diarized_text: str) -> str:
    """Generate a speaker-attributed meeting summary using GPT-5.4-mini."""
    print("Generating speaker-attributed meeting summary...")

    response = openai_client.chat.completions.create(
        model="gpt-5.4-mini",
        messages=[
            {
                "role": "system",
                "content": (
                    "You are a meeting assistant. Analyze the following meeting "
                    "transcript with speaker labels and produce a structured summary. "
                    "Include:\n"
                    "1. A brief overview (2-3 sentences)\n"
                    "2. Key Points (bulleted list, attribute each to the speaker "
                    "who made it)\n"
                    "3. Action Items (bulleted list with the responsible speaker)\n"
                    "4. Overall Sentiment (positive, neutral, or negative with "
                    "brief explanation)\n"
                    "5. Speaker Participation (brief note on each speaker's role "
                    "and contributions)"
                ),
            },
            {
                "role": "user",
                "content": (
                    "Here is the meeting transcript with speaker labels:\n\n"
                    f"{diarized_text}"
                ),
            },
        ],
    )

    summary = response.choices[0].message.content
    print("Summary generated.")
    return summary


def process_recording(audio_path: str, output_dir: str) -> dict:
    """Transcribe with diarization and summarize a meeting recording."""
    os.makedirs(output_dir, exist_ok=True)

    # Transcribe with speaker diarization via OpenAI
    result = transcribe_with_diarization(audio_path)

    transcript_path = os.path.join(output_dir, "transcript_diarized.txt")
    with open(transcript_path, "w") as f:
        f.write(result["diarized_text"])
    print(f"Diarized transcript saved to {transcript_path}")

    # Save plain transcript too
    plain_path = os.path.join(output_dir, "transcript_plain.txt")
    with open(plain_path, "w") as f:
        f.write(result["plain_text"])

    # Summarize with speaker attribution via OpenAI
    summary = summarize_transcript(result["diarized_text"])
    summary_path = os.path.join(output_dir, "summary.txt")
    with open(summary_path, "w") as f:
        f.write(summary)
    print(f"Summary saved to {summary_path}")

    return {
        "transcript_path": transcript_path,
        "plain_transcript_path": plain_path,
        "summary_path": summary_path,
        "num_speakers": len(
            set(s.get("speaker", "") for s in result["segments"])
        ),
    }

Your transcription and summarization pipeline is ready. Next up, you'll build the FastAPI backend that exposes REST endpoints so the bot can be triggered and monitored over HTTP.

Build the FastAPI Backend

Your bot can join meetings, record audio and video, transcribe with speaker diarization, and generate summaries. But right now, the only way to trigger any of this is by calling Python functions directly.

In this step, you'll wrap everything behind a FastAPI REST API so you can trigger the bot, monitor progress, and download results with simple HTTP requests. You'll also create a CLI entrypoint for direct command-line usage.

In this step, get ready to:
  • Write a FastAPI server with Pydantic request and response models.
  • Build a background pipeline that orchestrates recording, transcription, and summarization per job.
  • Create a CLI entrypoint for running the bot without the API.
Set up the API server and data models

The API needs to accept a meeting link, kick off the bot in the background, and let the caller check back later for results. To support this, you need request and response models that define the shape of data flowing in and out.

  • Open api.py in your code editor and add the imports, app setup, and data models by pasting this code:
import asyncio
import os
import uuid
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from bot import join_and_record
from transcribe import process_recording

app = FastAPI(title="AI Meeting Bot API")

# In-memory job store
jobs: dict = {}

OUTPUT_BASE = "/app/output"


class JoinRequest(BaseModel):
    meet_link: str
    duration: int = 3600


class JobStatus(BaseModel):
    job_id: str
    status: str
    meet_link: str
    duration: int
    files: dict = {}
    num_speakers: int = 0
    error: str = ""

What does this code do?

The imports pull in your existing join_and_record function from bot.py and process_recording from transcribe.py. This is how the API triggers the full pipeline you built in previous steps.

The jobs dictionary stores all job state in memory. JoinRequest defines what the caller sends (a meeting link and optional max duration). JobStatus defines what comes back (status, output files, speaker count, and any errors).

You won't see a change yet. The next chunk adds the pipeline function that uses these models.

Add the background pipeline and API endpoints

When a caller sends a meeting link, the API needs to run the entire record-transcribe-summarize pipeline without blocking the HTTP response. An async background task handles this, updating the job status dictionary as it progresses through each phase.

  • Below the JobStatus class, add the run_pipeline function by pasting this code:
async def run_pipeline(job_id: str, meet_link: str, duration: int):
    """Run the full meeting bot pipeline as a background task."""
    output_dir = os.path.join(OUTPUT_BASE, job_id)
    audio_path = os.path.join(output_dir, "meeting_audio.wav")
    video_path = os.path.join(output_dir, "meeting_video.mp4")
    os.makedirs(output_dir, exist_ok=True)

    try:
        # Phase 1: Record (bot auto-leaves when meeting empties)
        jobs[job_id]["status"] = "recording"
        success = await join_and_record(
            meet_link, duration, audio_path, video_path
        )

        if not success:
            jobs[job_id]["status"] = "failed"
            jobs[job_id]["error"] = "Failed to join meeting"
            return

        # Phase 2: Transcribe and summarize
        if os.path.exists(audio_path) and os.path.getsize(audio_path) > 0:
            jobs[job_id]["status"] = "transcribing"
            results = process_recording(audio_path, output_dir)

            jobs[job_id]["status"] = "completed"
            jobs[job_id]["files"] = {
                "video": "meeting_video.mp4",
                "transcript_diarized": "transcript_diarized.txt",
                "transcript_plain": "transcript_plain.txt",
                "summary": "summary.txt",
            }
            jobs[job_id]["num_speakers"] = results["num_speakers"]
        else:
            jobs[job_id]["status"] = "failed"
            jobs[job_id]["error"] = "No audio recorded"

    except Exception as e:
        jobs[job_id]["status"] = "failed"
        jobs[job_id]["error"] = str(e)

What does this pipeline do?

This function is the orchestrator. It creates a unique output directory per job, then progresses through two phases.

  • Phase 1 (recording): Calls your join_and_record function. The job status changes to recording so callers can track progress.
  • Phase 2 (transcription): If audio was captured, it calls process_recording which runs diarized transcription and summarization. The status moves to transcribing, then completed.
  • The entire function is wrapped in a try/except so any unexpected error sets the job to failed instead of crashing the server.

You won't see a change yet. The next chunk adds the endpoint handlers that trigger this pipeline.

  • Below the run_pipeline function, add the three API endpoint handlers by pasting this code:
@app.post("/jobs", response_model=JobStatus)
async def create_job(request: JoinRequest):
    """Start a new meeting bot job."""
    job_id = str(uuid.uuid4())[:8]

    jobs[job_id] = {
        "job_id": job_id,
        "status": "pending",
        "meet_link": request.meet_link,
        "duration": request.duration,
        "files": {},
        "num_speakers": 0,
        "error": "",
    }

    asyncio.create_task(
        run_pipeline(job_id, request.meet_link, request.duration)
    )

    return JobStatus(**jobs[job_id])


@app.get("/jobs/{job_id}", response_model=JobStatus)
async def get_job(job_id: str):
    """Get the status of a meeting bot job."""
    if job_id not in jobs:
        raise HTTPException(status_code=404, detail="Job not found")
    return JobStatus(**jobs[job_id])


@app.get("/jobs/{job_id}/files/{filename}")
async def get_file(job_id: str, filename: str):
    """Download an output file from a completed job."""
    if job_id not in jobs:
        raise HTTPException(status_code=404, detail="Job not found")

    file_path = os.path.join(OUTPUT_BASE, job_id, filename)
    if not os.path.exists(file_path):
        raise HTTPException(status_code=404, detail="File not found")

    return FileResponse(file_path, filename=filename)

What do these endpoints do?

  • POST /jobs generates a short job ID, stores the initial job state as pending, and fires off run_pipeline as an async background task. The response comes back immediately with the job ID.
  • GET /jobs/{job_id} lets you poll the job status. It returns the current status, output file names, and speaker count once processing completes.
  • GET /jobs/{job_id}/files/{filename} serves the actual output files (video, transcript, summary) for download. It checks both that the job and the file exist before responding.

Why asyncio.create_task instead of running synchronously?

A meeting recording can last minutes or hours. If the API waited for the pipeline to finish before responding, the HTTP request would time out long before the meeting ended. asyncio.create_task runs the pipeline in the background so the POST /jobs response returns instantly with a job ID the caller can use to check back later.

  • Save api.py.

You won't see a visible change yet. You'll test the API inside the Docker container in the next step.

✔️ Awesome, I've got everything!

Great. Double check you've saved api.py.

ⓧ I'd like to double check the full code

import asyncio
import os
import uuid
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel
from bot import join_and_record
from transcribe import process_recording

app = FastAPI(title="AI Meeting Bot API")

# In-memory job store
jobs: dict = {}

OUTPUT_BASE = "/app/output"


class JoinRequest(BaseModel):
    meet_link: str
    duration: int = 3600


class JobStatus(BaseModel):
    job_id: str
    status: str
    meet_link: str
    duration: int
    files: dict = {}
    num_speakers: int = 0
    error: str = ""


async def run_pipeline(job_id: str, meet_link: str, duration: int):
    """Run the full meeting bot pipeline as a background task."""
    output_dir = os.path.join(OUTPUT_BASE, job_id)
    audio_path = os.path.join(output_dir, "meeting_audio.wav")
    video_path = os.path.join(output_dir, "meeting_video.mp4")
    os.makedirs(output_dir, exist_ok=True)

    try:
        # Phase 1: Record (bot auto-leaves when meeting empties)
        jobs[job_id]["status"] = "recording"
        success = await join_and_record(
            meet_link, duration, audio_path, video_path
        )

        if not success:
            jobs[job_id]["status"] = "failed"
            jobs[job_id]["error"] = "Failed to join meeting"
            return

        # Phase 2: Transcribe and summarize
        if os.path.exists(audio_path) and os.path.getsize(audio_path) > 0:
            jobs[job_id]["status"] = "transcribing"
            results = process_recording(audio_path, output_dir)

            jobs[job_id]["status"] = "completed"
            jobs[job_id]["files"] = {
                "video": "meeting_video.mp4",
                "transcript_diarized": "transcript_diarized.txt",
                "transcript_plain": "transcript_plain.txt",
                "summary": "summary.txt",
            }
            jobs[job_id]["num_speakers"] = results["num_speakers"]
        else:
            jobs[job_id]["status"] = "failed"
            jobs[job_id]["error"] = "No audio recorded"

    except Exception as e:
        jobs[job_id]["status"] = "failed"
        jobs[job_id]["error"] = str(e)


@app.post("/jobs", response_model=JobStatus)
async def create_job(request: JoinRequest):
    """Start a new meeting bot job."""
    job_id = str(uuid.uuid4())[:8]

    jobs[job_id] = {
        "job_id": job_id,
        "status": "pending",
        "meet_link": request.meet_link,
        "duration": request.duration,
        "files": {},
        "num_speakers": 0,
        "error": "",
    }

    asyncio.create_task(
        run_pipeline(job_id, request.meet_link, request.duration)
    )

    return JobStatus(**jobs[job_id])


@app.get("/jobs/{job_id}", response_model=JobStatus)
async def get_job(job_id: str):
    """Get the status of a meeting bot job."""
    if job_id not in jobs:
        raise HTTPException(status_code=404, detail="Job not found")
    return JobStatus(**jobs[job_id])


@app.get("/jobs/{job_id}/files/{filename}")
async def get_file(job_id: str, filename: str):
    """Download an output file from a completed job."""
    if job_id not in jobs:
        raise HTTPException(status_code=404, detail="Job not found")

    file_path = os.path.join(OUTPUT_BASE, job_id, filename)
    if not os.path.exists(file_path):
        raise HTTPException(status_code=404, detail="File not found")

    return FileResponse(file_path, filename=filename)
Create the CLI entrypoint

The API server is great for production use, but sometimes you want to run the bot directly from the command line without starting a web server. A CLI entrypoint lets you pass a meeting link and get results in one shot.

  • Open run.py and add the full CLI script by pasting this code:
import asyncio
import argparse
import os
from bot import join_and_record
from transcribe import process_recording


def main():
    parser = argparse.ArgumentParser(
        description="AI Meeting Bot for Google Meet"
    )
    parser.add_argument(
        "--meet-link", required=True, help="Google Meet link to join"
    )
    parser.add_argument(
        "--duration",
        type=int,
        default=3600,
        help="Max recording duration in seconds (default: 3600). Bot leaves early if meeting empties.",
    )
    parser.add_argument(
        "--output-dir", default="/app/output", help="Directory for output files"
    )
    args = parser.parse_args()

    audio_path = os.path.join(args.output_dir, "meeting_audio.wav")
    video_path = os.path.join(args.output_dir, "meeting_video.mp4")
    os.makedirs(args.output_dir, exist_ok=True)

    # Step 1: Join meeting and record audio + video
    print("=" * 50)
    print("AI Meeting Bot Starting")
    print("=" * 50)
    success = asyncio.run(
        join_and_record(args.meet_link, args.duration, audio_path, video_path)
    )

    if not success:
        print("Failed to join the meeting. Exiting.")
        return

    # Step 2: Transcribe with diarization and summarize
    if os.path.exists(audio_path) and os.path.getsize(audio_path) > 0:
        print("\n" + "=" * 50)
        print("Processing Recording")
        print("=" * 50)
        results = process_recording(audio_path, args.output_dir)
        print("\n" + "=" * 50)
        print("Done! Output files:")
        print(f"  Video:      {video_path}")
        print(f"  Transcript: {results['transcript_path']}")
        print(f"  Summary:    {results['summary_path']}")
        print(f"  Speakers:   {results['num_speakers']} detected")
        print("=" * 50)
    else:
        print(
            "No audio was recorded. The meeting may have been too short or silent."
        )


if __name__ == "__main__":
    main()

What does this script do?

  • argparse parses three command-line arguments: --meet-link (required), --duration (defaults to 3600 seconds), and --output-dir (defaults to /app/output).
  • It runs the same two-phase pipeline as the API (record, then transcribe and summarize), but synchronously with asyncio.run() instead of a background task.
  • At the end, it prints the paths to all output files and the number of speakers detected.

How does entrypoint.sh choose between the API and CLI?

Your entrypoint.sh script (written in Step 3) already handles this. By default, it starts the FastAPI server with uvicorn on port 8080. If you pass --cli as the first argument, it runs run.py instead.

  • Save run.py.

You won't see output yet. In the next step, you'll rebuild the Docker image and test the full system end-to-end.

✔️ Awesome, I've got everything!

Great. Double check you've saved run.py.

ⓧ I'd like to double check the full code

import asyncio
import argparse
import os
from bot import join_and_record
from transcribe import process_recording


def main():
    parser = argparse.ArgumentParser(
        description="AI Meeting Bot for Google Meet"
    )
    parser.add_argument(
        "--meet-link", required=True, help="Google Meet link to join"
    )
    parser.add_argument(
        "--duration",
        type=int,
        default=3600,
        help="Max recording duration in seconds (default: 3600). Bot leaves early if meeting empties.",
    )
    parser.add_argument(
        "--output-dir", default="/app/output", help="Directory for output files"
    )
    args = parser.parse_args()

    audio_path = os.path.join(args.output_dir, "meeting_audio.wav")
    video_path = os.path.join(args.output_dir, "meeting_video.mp4")
    os.makedirs(args.output_dir, exist_ok=True)

    # Step 1: Join meeting and record audio + video
    print("=" * 50)
    print("AI Meeting Bot Starting")
    print("=" * 50)
    success = asyncio.run(
        join_and_record(args.meet_link, args.duration, audio_path, video_path)
    )

    if not success:
        print("Failed to join the meeting. Exiting.")
        return

    # Step 2: Transcribe with diarization and summarize
    if os.path.exists(audio_path) and os.path.getsize(audio_path) > 0:
        print("\n" + "=" * 50)
        print("Processing Recording")
        print("=" * 50)
        results = process_recording(audio_path, args.output_dir)
        print("\n" + "=" * 50)
        print("Done! Output files:")
        print(f"  Video:      {video_path}")
        print(f"  Transcript: {results['transcript_path']}")
        print(f"  Summary:    {results['summary_path']}")
        print(f"  Speakers:   {results['num_speakers']} detected")
        print("=" * 50)
    else:
        print(
            "No audio was recorded. The meeting may have been too short or silent."
        )


if __name__ == "__main__":
    main()

Seeing import errors in your editor?

Your editor might show red squiggles under from bot import join_and_record or from transcribe import process_recording. These imports resolve inside the Docker container where all files live in /app. The squiggles are safe to ignore.

Help me understand why my Python imports show errors locally but work inside Docker.

Your entire codebase is complete. You have a bot that joins and records, a transcription pipeline with speaker diarization, a REST API for remote control, and a CLI for direct usage. Next up, you'll rebuild the Docker image and test the full system end-to-end against a real Google Meet call.

Test the Full System End-to-End

Your FastAPI backend is complete, with endpoints to trigger the bot, check job status, and download results. Every piece of the puzzle is in place.

Now it's time to bring everything together and run the full system against a real Google Meet call. You'll build the final Docker image, start the container, send it a meeting link, and watch the bot join, record, transcribe, and summarize on its own.

In this step, get ready to:
  • Build and start the Docker container with the FastAPI server.
  • Trigger the bot against a real Google Meet call and monitor its progress.
  • Download and verify the video, diarized transcript, and summary files.
Build and start the Docker container

All of your project files are finalized. Time to rebuild the Docker image so it picks up every change, then start the container with the FastAPI server.

  • If you have an existing container named meeting-bot from a previous build, stop and remove it by running:
docker stop meeting-bot && docker rm meeting-bot

What if there's no existing container?

If you see an error like No such container: meeting-bot, that's fine. It just means no container with that name exists yet. Continue to the next command.

  • Build the final Docker image by running this command from your project folder:
docker build -t meeting-bot .

What does this command do?

This reads your Dockerfile and builds a fresh image tagged meeting-bot. It installs PulseAudio, Xvfb, ffmpeg, your Python dependencies, and copies all project files into the image.

The build will take a few minutes. When it finishes, you should see a Successfully tagged meeting-bot:latest message.

Build failed?

  • Make sure Docker Desktop is running. You should see the Docker icon in your system tray.
  • Check that your terminal is in the project folder that contains the Dockerfile.
  • If you see a pip install error, double check that requirements.txt has the exact package versions listed earlier.
  • Docker build failed and I can't figure out why.
  • Start the container in detached mode with port 8080 exposed by running:
docker run -d --name meeting-bot -p 8080:8080 meeting-bot

What does this command do?

  • -d runs the container in the background so you can keep using the terminal.
  • --name meeting-bot gives the container a human-readable name.
  • -p 8080:8080 maps port 8080 on your machine to port 8080 inside the container, where the FastAPI server is listening.
  • Verify the container started correctly by checking its logs:
docker logs meeting-bot

You should see output showing D-Bus initialized, PulseAudio started, the virtual audio sink loaded, and Uvicorn running on http://0.0.0.0:8080.

Container exited immediately?

  • Run docker logs meeting-bot to see the error output. PulseAudio or D-Bus errors usually mean the entrypoint script has a typo.
  • If port 8080 is already in use, you'll see an address binding error. Stop whatever is using that port or change the mapping to -p 9090:8080.
  • Make sure entrypoint.sh uses Linux-style line endings (LF, not CRLF). Windows editors sometimes save with CRLF, which breaks bash scripts in Docker.
  • My meeting-bot container keeps crashing on startup.
Trigger the bot and monitor the job

The bot needs a real Google Meet call to join. You'll create a meeting, send the link to the bot via the API, then admit it from the waiting room.

Before you start the test

You need a Google Meet call with at least one other participant (you). Open Google Meet in a regular browser and start a new meeting. Keep this browser tab open throughout the test.

The bot joins as a guest named "AI Notetaker", so the meeting host (you) will need to admit it from the waiting room. Keep an eye out for the admission prompt.

Keep the test meeting short

The OpenAI transcription API has a 25 MB file size limit. At 16kHz mono WAV, that allows roughly 13 minutes of audio. Aim for a 2-5 minute test meeting to stay well within the limit.

  • Open Google Meet at meet.google.com in your browser and start a new meeting.
  • Copy the meeting link from the browser address bar.
  • In a separate terminal window, send a POST request to trigger the bot by running:
curl -X POST http://localhost:8080/jobs \
  -H "Content-Type: application/json" \
  -d '{"meet_link": "[[MEET_LINK="https://meet.google.com/your-meeting-code"]]", "duration": 300}'

What does this request do?

  • meet_link is the Google Meet URL the bot will navigate to.
  • duration sets the maximum recording time to 300 seconds (5 minutes). The bot will leave earlier if all other participants leave first.

You should see a JSON response containing a job_id and a status of pending. Copy the job_id value. You will need it for the next commands.

  • Switch to your Google Meet browser tab. Within a minute or two, you should see "AI Notetaker" asking to join in the waiting room.
  • Click Admit to let the bot into the meeting.
  • Speak for a minute or two so the bot has some audio to transcribe. If possible, have a second person join briefly so the diarization has multiple speakers to identify.
  • Poll the job status to watch the bot's progress by running:
curl http://localhost:8080/jobs/[[JOB_ID="your job ID from the POST response"]]

What should I see?

The status field progresses through these stages:

  • pending means the job was created and the bot is launching.
  • recording means the bot joined the meeting and is capturing audio and video.
  • transcribing means the recording stopped and the bot is running diarized transcription and summarization.
  • completed means all output files are ready for download.

Run the curl command again every 15-30 seconds to watch the progression. The bot polls for participants every 10 seconds while recording.

  • To test the auto-leave behavior, leave the Google Meet call from your browser. The bot should detect that all other participants have left and stop recording on its own.
  • Poll the job status again after leaving. Within 30 seconds, the status should change from recording to transcribing, then to completed.

How does the auto-leave detection work?

The bot checks for three signals every 10 seconds: the "You're the only one here" message, a "The call has ended" screen, or the disappearance of the "Leave call" button. Any of these triggers the bot to stop recording and begin transcription.

Bot never joined or status stuck on pending?

  • Check the container logs with docker logs meeting-bot for error messages. Look for Playwright launch errors or navigation timeouts.
  • Make sure you admitted "AI Notetaker" from the Google Meet waiting room. The bot times out after 2 minutes if not admitted.
  • Verify your Google Meet link is correct and includes the full URL starting with https://meet.google.com/.
  • If the status shows failed, check the error field in the job status response for a specific message.
  • Help me debug why my meeting bot won't join the call.

Status stuck on transcribing?

  • Transcription and summarization can take 1-3 minutes depending on the recording length. Wait a bit longer and poll again.
  • Check that your OPENAI_API_KEY in the .env file is valid and has available credits. The diarized transcription and summarization both call the OpenAI API.
  • My bot recorded but transcription is failing.
Download and verify the output files

The job status response includes a files dictionary listing every output file the bot produced. You can download each one through the file endpoint.

  • Download the meeting video by running:
curl -o meeting_video.mp4 http://localhost:8080/jobs/[[JOB_ID="your job ID"]]/files/meeting_video.mp4
  • Download the diarized transcript by running:
curl -o transcript_diarized.txt http://localhost:8080/jobs/[[JOB_ID="your job ID"]]/files/transcript_diarized.txt
  • Download the meeting summary by running:
curl -o summary.txt http://localhost:8080/jobs/[[JOB_ID="your job ID"]]/files/summary.txt
  • Confirm the files downloaded by running:
ls -lh meeting_video.mp4 transcript_diarized.txt summary.txt

You should see all three files listed with non-zero file sizes.

  • Open the diarized transcript to verify speaker labels by running:
cat transcript_diarized.txt

You should see lines formatted as [Speaker A]: ... with speaker labels identifying who said what. If multiple people spoke, you'll see different speaker labels (A, B, C).

  • Open the summary to verify the speaker-attributed output by running:
cat summary.txt

The summary should include an overview, key points attributed to specific speakers, action items with responsible speakers, overall sentiment, and a speaker participation breakdown.

  • Play the video file meeting_video.mp4 in any media player. You should see the Google Meet interface as the bot saw it, complete with audio.

What just happened end-to-end?

You sent a single HTTP request with a meeting link. The bot launched a headless Chromium browser inside the container, joined the meeting as a guest, recorded both audio and video using ffmpeg and the virtual PulseAudio sink, detected when you left, then stopped recording automatically.

The audio was sent to OpenAI's gpt-4o-transcribe-diarize model for speaker-labeled transcription. Then the diarized transcript was sent to GPT-5.4-mini to produce a structured summary with key points and action items attributed to individual speakers. All of this ran as a background task while the API remained responsive.

Files are empty or download failed?

  • Make sure the job status shows completed before downloading. Files are only available after the full pipeline finishes.
  • If the video file is 0 bytes, ffmpeg may not have captured the Xvfb display. Check docker logs meeting-bot for ffmpeg errors.
  • If the transcript is empty, the audio recording may have been silent. Make sure you unmuted yourself and spoke during the test meeting.
  • My output files are missing or empty after a completed job.

Your AI meeting bot is fully operational. It joins meetings, records everything, identifies speakers, and produces actionable summaries. Next up, you'll extend it with webhook notifications so external services can be alerted when processing completes.

Secret mission

Add Webhook Notifications

Your meeting bot processes recordings and saves results to disk. But in a production system, you would want to notify another service automatically when processing finishes. Can you add a webhook callback so the bot sends an HTTP POST to any URL when a job completes or fails?

Before you go

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs locally via Docker, so there are no ongoing cloud infrastructure costs. However, each meeting you process uses the OpenAI API for diarized transcription and summarization, costing roughly $0.01 to $0.05 per meeting.

Cost warning

The OpenAI API charges per use. Each meeting processed sends audio to the gpt-4o-transcribe-diarize model for diarization and to gpt-5.4-mini for summarization. If you are not actively using the bot, no API calls are made and no costs are incurred.

If you want to avoid accidental charges, you can revoke or rotate your OpenAI API key from your OpenAI dashboard.

Resources you used:

  • Docker image built from the Dockerfile (local).
  • Docker container running the meeting bot and FastAPI server (local).
  • Project files: bot.py, transcribe.py, api.py, run.py, entrypoint.sh, requirements.txt, and .env (local).
  • Output files from processed meetings: video recordings, diarized transcripts, and summaries (local, inside the container or mounted volume).
  • OpenAI API key (stored in your .env file).

Keep everything running

No action needed. Choose this if you are still actively using the meeting bot or plan to process more meetings soon.

  • Your Docker container stays running and the FastAPI server continues to accept requests on port 8080.
  • No costs are incurred unless you send the bot into a meeting. The OpenAI API only charges when you actually process audio.

Pause - I'll come back to this later

Stop the Docker container to free up system resources. Your image and project files stay on disk so you can restart later.

  • Stop the running container by pressing Ctrl+C in the terminal where it is running, or find and stop it via Docker Desktop.
  • Your Docker image remains on your machine. To restart later, run the same docker run command you used in the testing step.
  • Your project files and .env remain untouched on your local machine.

Delete - I don't want to use this again

Remove all project resources from your machine and start fresh.

  • Stop any running containers by pressing Ctrl+C or stopping them via Docker Desktop.
  • Remove the Docker image by opening your terminal and running:
docker image ls
# Find the meeting bot image name/ID, then:
docker image rm <image-name-or-id>
  • Delete the project folder from your Desktop.
  • If you no longer need the OpenAI API key, revoke it from your OpenAI dashboard at platform.openai.com.

Mission accomplished

That's a Wrap!

Nice work! You just built a fully containerized AI meeting bot with a REST API from scratch.

You've learned how to:

  • Built a Docker container with a virtual display (Xvfb) and virtual audio (PulseAudio) that uses Playwright to join Google Meet as a guest, record audio and video with ffmpeg, and automatically leave when all other participants have left.
  • Integrated OpenAI's gpt-4o-transcribe-diarize model to produce speaker-diarized transcripts, and built an AI summarization pipeline that attributes key points and action items to individual speakers.
  • Built a FastAPI backend with job management so the bot can be triggered, monitored, and files downloaded through a REST API.
  • Secret Mission: Added webhook notifications to alert external services when processing completes.

Ready to quiz yourself?