Building a bot that joins a Microsoft Teams meeting and captures what people say is not as simple as calling an API.
Microsoft does not provide a public API that lets an arbitrary bot join a live meeting in real time. The Graph API can fetch a transcript after a meeting ends, but that requires specific Microsoft 365 admin policies to be enabled, and transcript availability is not immediate.
The practical approach is browser automation. You script a real browser to open the Teams web client, navigate to the meeting, join as a participant, enable captions, and read what appears in the captions container. That is exactly what this guide covers, using Node.js and Playwright.
Quick RecapCreate a dedicated Microsoft account for the bot Install Playwright and set up a Node.js project Automate the Microsoft login flow and save the session using storageState Modify the Teams meeting URL with ?launchAgent=join_only&type=meetup-join to bypass the app-picker dialog Fill in the display name and click the join button on the pre-join screen Enable captions through the More menu using data-tid selectors Poll the captions container every two seconds and write new lines to a file Use saved session cookies on repeat runs to avoid repeated login flows
|
How to Build a Microsoft Teams Meeting Bot
What You Need Before You Start?
- Node.js version 18 or above
- A dedicated Microsoft account for the bot (do not use a personal or work account)
- A Microsoft Teams meeting link for testing
- Basic knowledge of JavaScript and async/await
Use a fresh Microsoft account created only for this bot. Repeatedly automating login flows on a real account risks triggering Microsoft's bot detection or locking the account.
Step 1: Set Up Your Project
Create a project folder and initialise it.
bash mkdir teams-bot cd teams-bot npm init -y |
Install Playwright and its browser dependencies. Playwright works better than Puppeteer for Teams specifically because it handles permission prompts and browser arguments more cleanly out of the box.
bash npm install playwright npx playwright install chromium |
Create your main file.
Step 2: Launch the Browser
Start a Chromium browser with the flags needed to fake microphone and camera access. Without these flags, Teams shows a permissions prompt that the bot cannot click through automatically.
javascript const { chromium } = require('playwright');
async function launchBrowser() { const browser = await chromium.launch({ headless: false, // Keep visible while developing args: [ '--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream', '--no-sandbox', '--disable-setuid-sandbox' ] });
const context = await browser.newContext({ permissions: ['microphone', 'camera'] });
const page = await context.newPage(); return { browser, page }; } |
Step 3: Sign In to Microsoft
The bot needs to be signed in to a Microsoft account before Teams will let it join a meeting. Automate the Microsoft login flow.
javascript const MS_EMAIL = 'your-bot-account@outlook.com'; const MS_PASSWORD = 'your-bot-password';
async function signIn(page) { await page.goto('https://login.microsoftonline.com', { waitUntil: 'networkidle' });
// Enter email await page.fill('input[type="email"]', MS_EMAIL); await page.click('input[type="submit"]');
// Enter password await page.waitForSelector('input[type="password"]', { state: 'visible' }); await page.fill('input[type="password"]', MS_PASSWORD); await page.click('input[type="submit"]');
// Handle "Stay signed in?" prompt try { await page.waitForSelector('#idBtn_Back', { timeout: 5000 }); await page.click('#idBtn_Back'); // Click "No" to keep session simple } catch (e) { // Prompt did not appear, continue }
console.log('Signed in successfully.'); } |
Run this with headless: false the first time. Microsoft sometimes asks for additional verification on new logins. Handle that manually once, then save session cookies so you do not have to log in again on every run.
javascript // After signing in successfully, save the session await page.context().storageState({ path: 'auth.json' });
// On subsequent runs, load the saved session instead of logging in const context = await browser.newContext({ storageState: 'auth.json', permissions: ['microphone', 'camera'] }); |
Step 4: Navigate to the Meeting
This is the part that catches most people. A standard Teams meeting link looks like this:
https://teams.microsoft.com/l/meetup-join/19%3A...
If you navigate to that URL directly, Teams shows a dialog that says "Open in app or browser." Playwright cannot interact with that dialog because it is handled by the operating system. You bypass it by adding a specific query parameter to force the browser join flow directly.
javascript async function joinMeeting(page, meetingUrl) { // Append ?launchAgent=join_only to skip the app-picker dialog const browserJoinUrl = meetingUrl.includes('?') ? `${meetingUrl}&launchAgent=join_only&type=meetup-join` : `${meetingUrl}?launchAgent=join_only&type=meetup-join`;
await page.goto(browserJoinUrl, { waitUntil: 'networkidle' });
// Dismiss any cookie consent banners try { await page.click('button:has-text("Accept")', { timeout: 3000 }); } catch (e) {}
// Wait for the pre-join screen to load await page.waitForSelector('[data-tid="prejoin-display-name-input"]', { timeout: 15000 });
// Set the bot's display name await page.fill('[data-tid="prejoin-display-name-input"]', 'Meeting Bot');
// Turn off microphone on the pre-join screen try { const micToggle = page.locator('[data-tid="toggle-mute"]'); const isMuted = await micToggle.getAttribute('aria-pressed'); if (isMuted === 'false') { await micToggle.click(); } } catch (e) {}
// Click the Join button await page.click('[data-tid="prejoin-join-button"]'); console.log('Clicked join. Waiting to enter meeting...');
// Wait until the in-meeting controls appear await page.waitForSelector('[data-tid="call-controls"]', { timeout: 30000 }); console.log('Entered the meeting.'); } |
If the meeting has a lobby, a host will need to admit the bot before it enters the main call. After admission, the call-controls element becomes visible and the bot proceeds.
Step 5: Enable Live Captions
Captions are how the bot reads what people say during the meeting. You enable them through the meeting controls menu.
javascript async function enableCaptions(page) { // Open the "More" menu in the bottom control bar await page.waitForSelector('[data-tid="callingButtons-showMoreBtn"]', { timeout: 10000 }); await page.click('[data-tid="callingButtons-showMoreBtn"]');
await page.waitForTimeout(1000);
// Click "Turn on live captions" try { await page.click('button:has-text("Turn on live captions")', { timeout: 5000 }); console.log('Captions enabled.'); } catch (e) { // Try alternative text if Teams UI has updated await page.click('button:has-text("captions")', { timeout: 5000 }); console.log('Captions enabled via fallback selector.'); }
// Wait for the captions container to appear in the DOM await page.waitForSelector('[data-tid="closed-captions-renderer"]', { timeout: 10000 }); } |
One important note: captions in Teams are buried deep in nested DOM elements and update dynamically. The selectors above are correct as of early 2025, but Microsoft updates the Teams web client regularly. If a selector stops working, open Teams in a regular Chrome window, inspect the captions container, and find the current data-tid attribute.
Step 6: Capture and Save the Transcript
Poll the captions container every two seconds and write new lines to a file.
javascript const fs = require('fs');
async function captureTranscript(page, durationMs = 3600000) { const outputFile = 'transcript.txt'; const seen = new Set();
console.log('Capturing transcript...');
const startTime = Date.now();
while (Date.now() - startTime < durationMs) { try { // Grab all rendered caption lines currently visible const lines = await page.$$eval( '[data-tid="closed-captions-renderer"] span', spans => spans.map(s => s.innerText.trim()).filter(t => t.length > 0) );
for (const line of lines) { if (!seen.has(line)) { seen.add(line); const entry = `[${new Date().toISOString()}] ${line}`; console.log(entry); fs.appendFileSync(outputFile, entry + '\n'); } } } catch (e) { // Page may be transitioning; continue polling }
await page.waitForTimeout(2000); }
console.log(`Transcript saved to ${outputFile}`); } |
Step 7: Put It All Together
javascript const { chromium } = require('playwright'); const fs = require('fs');
const MS_EMAIL = 'your-bot-account@outlook.com'; const MS_PASSWORD = 'your-bot-password'; const MEETING_URL = 'https://teams.microsoft.com/l/meetup-join/YOUR_MEETING_URL'; const USE_SAVED_SESSION = fs.existsSync('auth.json');
(async () => { const browser = await chromium.launch({ headless: false, args: [ '--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream', '--no-sandbox' ] });
const context = await browser.newContext({ storageState: USE_SAVED_SESSION ? 'auth.json' : undefined, permissions: ['microphone', 'camera'] });
const page = await context.newPage();
if (!USE_SAVED_SESSION) { await signIn(page); await context.storageState({ path: 'auth.json' }); }
await joinMeeting(page, MEETING_URL); await enableCaptions(page); await captureTranscript(page);
await browser.close(); })(); |
Run the bot:
Common Problems and How to Fix Them
The meeting link opens an app-picker dialog. Add ?launchAgent=join_only&type=meetup-join to the URL as shown in Step 4. This forces the browser join path.
The bot gets stuck in the lobby. Your bot appears as a participant with the display name you set. A meeting host needs to admit it manually unless the meeting settings allow everyone in. For testing, create a meeting with the lobby disabled.
The captions selector stops working. Microsoft ships Teams web client updates regularly. Open Teams in a normal browser window, enable captions, right-click the caption text, and click Inspect. Find the nearest parent element with a data-tid attribute and update your selector.
Caption text drops words. Teams captions paraphrase and truncate aggressively. They are not a verbatim transcription. For higher accuracy, route the meeting audio to an ASR service like Whisper or Deepgram instead of reading captions from the DOM.
Conclusion
A single bot running in headless Chromium works well for prototypes and internal tools. When you start running many bots concurrently, each one is a full browser process consuming significant CPU and memory.
You will also need to handle bot scheduling, retries when joins fail, monitoring for crashes mid-call, and keeping up with Teams UI updates that break your selectors. These are real ongoing maintenance costs.
If you need meeting bots in production without that overhead, Meetstream.ai handles all of it for you. It provides a clean API to send bots to Teams meetings, delivers real-time speaker-attributed transcripts, and stores recordings without you maintaining any browser infrastructure.