How to Build a Microsoft Teams Meeting Bot?

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 Recap

  1. Create a dedicated Microsoft account for the bot

  2. Install Playwright and set up a Node.js project

  3. Automate the Microsoft login flow and save the session using storageState

  4. Modify the Teams meeting URL with ?launchAgent=join_only&type=meetup-join to bypass the app-picker dialog

  5. Fill in the display name and click the join button on the pre-join screen

  6. Enable captions through the More menu using data-tid selectors

  7. Poll the captions container every two seconds and write new lines to a file

  8. 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.

bash

touch bot.js

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:

bash

node bot.js

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.