developers
Puppeteer Google Meet bot — the join flow that works in 2026, and everything that breaks
You want a program in a Google Meet call. For almost everyone, Google Meet has no API for that: the Meet REST API creates spaces and reads recordings and transcripts after the fact, and the newer Meet Media API, which does hand an app real-time audio, video and participant metadata, is in Developer Preview as of September 2026 — the project, the OAuth principal and every participant in the call must be enrolled in Google's preview programme, and encrypted or watermarked meetings are excluded. So for a real meeting with real guests, the program has to be a browser. This is the Puppeteer version of that browser, with the parts most tutorials leave out: how the bot gets admitted, how the audio actually gets recorded, and the list of things that break, in the order they will break for you.
The join logic is not invented for this post. The selectors are the ones the open-source screenappai/meeting-bot (MIT) uses in production as of September 2026 — that project is Playwright, so what follows is the Puppeteer translation, and the file to diff against when Meet changes its UI is its src/bots/GoogleMeetBot.ts. If you just want the transcript and not the bot, the last section is the shortcut.
What the bot has to get through
A guest opening a Meet link sees, in order:
- The pre-join page: a name field ("What's your name?"), camera and microphone previews, and possibly a "Continue without microphone and camera" prompt when no devices are found.
- A button that is "Ask to join" (the host must admit you), "Join now" (you are allowed straight in), or occasionally "Join anyway".
- Either the lobby — "The host hasn't let you in yet" — or the call, recognisable by the in-call toolbar: a People button and a Leave call button.
- A series of "Got it" modals over the first minute (layout tips, "you're presenting" notices) that sit on top of the video you are trying to record.
Everything in the script maps to one of those four.
Launch Chromium so it looks like a person and hears nothing it shouldn't
import puppeteer from 'puppeteer';
const browser = await puppeteer.launch({
headless: false, // Meet's WebRTC path behaves better with a real (virtual) display
args: [
'--no-sandbox',
'--window-size=1280,720',
'--use-fake-ui-for-media-stream', // never show the "allow camera?" prompt
'--use-fake-device-for-media-stream', // and give it a fake camera/mic to allow
'--disable-blink-features=AutomationControlled',
'--autoplay-policy=no-user-gesture-required',
],
defaultViewport: null,
});
const page = await browser.newPage();
await page.setUserAgent('Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36');
headless: false is deliberate. Run it under Xvfb (below) rather than in headless mode: headless Chromium has historically had a different WebRTC and media path, and "the bot joins but the tile is black" is usually this.
The join flow
const BOT_NAME = 'Notes bot';
const JOIN_WAIT_MS = 15 * 60 * 1000; // how long to sit in the lobby before giving up
await page.goto(meetingUrl, { waitUntil: 'domcontentloaded' });
// 1. The name field. Meet's guest page has exactly one text input.
const name = await page.waitForSelector('input[type="text"]', { timeout: 15000 });
await name.click({ clickCount: 3 });
await name.type(BOT_NAME);
// 2. "Continue without microphone and camera", if it appears.
await clickByText(page, /Continue without microphone and camera/i).catch(() => {});
// 3. Whichever join button Meet is showing today.
let asked = false;
for (let attempt = 0; attempt < 3 && !asked; attempt++) {
asked = await clickByText(page, /^(Ask to join|Join now|Join anyway)$/);
if (!asked) await new Promise(r => setTimeout(r, 2000));
}
if (!asked) throw new Error('no join button — Meet changed its pre-join page, or this link needs a signed-in account');
// 4. Wait for the call — or a refusal.
const t0 = Date.now();
while (Date.now() - t0 < JOIN_WAIT_MS) {
const inCall = await page.$('button[aria-label^="People"], button[aria-label="Leave call"]');
if (inCall) break;
const body = await page.evaluate(() => document.body.innerText);
if (/denied your request to join|You can't join this video call/i.test(body)) throw new Error('the host refused the bot');
await new Promise(r => setTimeout(r, 2000));
}
// 5. Clear the "Got it" modals that would sit over the recording.
for (let i = 0; i < 8; i++) {
const hit = await clickByText(page, /^Got it$/);
if (!hit) break;
await new Promise(r => setTimeout(r, 500));
}
async function clickByText(page, re) {
return page.evaluate(src => {
const re = new RegExp(src.source, src.flags);
const el = [...document.querySelectorAll('button, [role="button"]')].find(b => re.test(b.textContent.trim()));
if (!el) return false;
el.click();
return true;
}, { source: re.source, flags: re.flags });
}
Three things about that code are worth the space:
- Text, not CSS classes. Meet's class names are obfuscated and rotate; button labels and
aria-labels change rarely. The production bot keeps a list of labels (including German —Teilnahme erbitten,Jetzt teilnehmen) because a bot running under a European account gets a localised UI. - The lobby is a loop with a deadline. "Ask to join" means a human has to click Admit. If nobody does, the bot must leave on its own or you will pay for a container that sits in a waiting room for an hour.
- Refusal is detected from text. There is no event for "the host said no"; the page just changes. Watch the body.
Detecting that the meeting is over
Meet does not tell the bot the call ended either. The production pattern is a check every ten seconds: if the People button's count reads 1 for longer than a configured idle limit, everyone else has left; if the page navigates to the "You left the meeting" or "The meeting has ended" screen, it is over; if a maximum duration passes, leave regardless.
async function participantCount(page) {
return page.evaluate(() => {
const b = document.querySelector('button[aria-label^="People"]');
const m = b && b.getAttribute('aria-label').match(/\d+/);
return m ? Number(m[0]) : null;
});
}
async function leave(page) {
const btn = await page.$('button[aria-label="Leave call"]');
if (btn) await btn.click();
}
Recording the call — the part that is not browser code
Puppeteer cannot record audio. The browser plays the meeting into whatever sound device the OS gives it, so the OS is where you record. The standard Linux container recipe:
# a screen for the browser to draw on
Xvfb :99 -screen 0 1280x720x24 -nolisten tcp &
export DISPLAY=:99
# a sound card that goes nowhere, which we then tap
pulseaudio --start --exit-idle-time=-1
pactl load-module module-null-sink sink_name=meet sink_properties=device.description=meet
export PULSE_SINK=meet
# launch the Puppeteer script here; once it reports "in call":
ffmpeg -y -f x11grab -video_size 1280x720 -framerate 25 -i :99 \
-f pulse -i meet.monitor \
-c:v libx264 -preset veryfast -pix_fmt yuv420p -c:a aac -b:a 128k recording.mp4
Start FFmpeg after admission (the production bot waits a configurable "audio stabilisation" delay first) or the first minutes of your file are a lobby. Stop it with SIGINT, not SIGKILL, so the MP4 gets its trailer written and is playable.
If you only need audio for transcription, drop the x11grab input and record -f pulse -i meet.monitor to WAV; it is a tenth of the CPU.
What breaks, in the order it will break
This is the list every open-source Meet bot's issue tracker converges on — puppeteer-extra #334 is a years-long thread of exactly these — in the order they tend to arrive.
- A link that needs a signed-in account. Google Workspace admins can require that guests be signed in, and some organisations turn off "Ask to join" for external guests entirely. No selector helps; you would need a real Google account for the bot, a persistent Chrome profile, and a way to keep that account from being flagged. The production bot supports persistent profiles for this reason and its README is candid that "meetings requiring sign-in, enterprise SSO, passwords or authenticated waiting rooms" are out of scope.
- Nobody admits it. In a meeting where the host is presenting, the "Someone wants to join" toast is easy to miss. Your bot times out in the lobby, and the user blames the bot.
- The pre-join page changes. Meet ships UI changes without notice; a label becomes an icon, a button moves into a menu, a new consent dialog appears for the whole first week of a rollout. The reason the production project retries the join button three times with fifteen seconds between is that the button sometimes renders late.
- Bot detection.
navigator.webdriver, a missingchrome.runtime, a headless user-agent string, no plugins, no window chrome — each is a tell, and Google's abuse systems use some of them. The--disable-blink-features=AutomationControlledflag and a normal user-agent remove the obvious ones; nothing removes them all, and a bot that joins forty meetings a day from one IP is a pattern by itself. - The audio is silent. Almost always PulseAudio: the browser started before the sink existed,
PULSE_SINKwas not in its environment, or the container has no/dev/shmlarge enough for Chromium and it silently dropped to a broken audio path. Checkpactl list sink-inputswhile the bot is in a call; you should see Chromium. - It cannot tell who spoke. This one does not break; it was never there. The bot hears one mixed stream. Meet exposes an active-speaker highlight you can read from the DOM, and it is exactly as reliable as a highlight — fine for one polite speaker at a time, wrong the moment two people talk over each other or a room shares a microphone. Every notetaker built this way has the same complaint in its reviews; we wrote up why in The AI put your words in someone else's mouth.
- Cost. One Chromium rendering one call is a couple of CPU cores and a few gigabytes of RAM for the length of the meeting. That is fine for ten meetings. At a thousand a day it is a fleet, with a scheduler, health checks and someone on call when Meet changes the button.
When you should not build this at all
If the goal is a transcript and notes from a Meet call rather than a bot you control, you do not need any of the above. Two shortcuts:
- Rent the browser. Recall.ai and MeetingBaaS run the container and the join logic and send you webhooks; expect roughly $0.45–$0.65 per meeting-hour with a transcript at list price. TeamMeet's own notetaker is built this way — paste a Meet link, the transcript and recording land in the dashboard — and every plan includes some hours of it free.
- Hold the meeting somewhere with a door for programs. On TeamMeet a bot is a WebSocket and a key: no browser, no Xvfb, no admission dialog when the bot started the meeting, and each transcript line arrives with the speaker attached because every participant's browser transcribes its own microphone. The complete bot is thirty lines; it is in How to build a meeting bot, verified against the live server.
Build the Puppeteer bot when you need the bot itself — your own fleet, your own perimeter, your own selectors to babysit. It is a fine thing to build once. It is a poor thing to bet a product on, which is a lesson the code above cannot teach you and this paragraph can.
Sources
- screenappai/meeting-bot,
src/bots/GoogleMeetBot.ts— https://github.com/screenappai/meeting-bot - Google Meet REST API overview — https://developers.google.com/meet/api/guides/overview
- Google Meet Media API overview (Developer Preview) — https://developers.google.com/meet/media-api/guides/overview
- puppeteer-extra, issue #334 "Supporting Google Meet" — https://github.com/berstend/puppeteer-extra/issues/334
- Puppeteer documentation — https://pptr.dev/
- Recall.ai, Puppeteer Google Meet Bot (Open Source) — https://www.recall.ai/blog/puppeteer-google-meet-bot
- TeamMeet agent API — https://teammeet.ai/developers