Recall.ai has raised a $38M series B!
More resources
Filter by category
View all
Tutorials
Best Practices
Tutorials
Deep Dive
Zoom Developer Forum
.avif) Google Meet Developer Forum
Microsoft Teams Developer Forum
Announcements
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
\ A guide to programmatic screen capture on macOS\ \ Deep Dive](/content/blog/macos-screencapture-api/index.html)
\ Joining multiple simultaneous meetings through the Zoom SDK\ \ Zoom Developer Forum](/content/blog/zoom-sdk-simultaneous-meeting-limitations/index.html)
\ How to get transcripts from Microsoft Teams\ \ Tutorials](/content/blog/how-to-get-transcripts-from-microsoft-teams/index.html)
\ How to Get Recordings and Transcripts from Slack Huddles\ \ Tutorials](/content/blog/get-recordings-and-transcripts-from-slack-huddles-api/index.html)
\ Understanding the Zoom On Behalf Of (OBF) Token\ \ Deep Dive](/content/blog/zoom-obf/index.html)
\ What is a meeting bot?\ \ Deep Dive](/content/blog/what-is-a-meeting-bot/index.html)
\ Zoom SDK Credentials\ \ Deep Dive](/content/blog/zoom-sdk-credentials/index.html)
\ What is a Zoom Access Key (ZAK) token?\ \ Deep Dive](/content/blog/what-is-a-zoom-access-key-zak-token/index.html)
\ How WebSockets cost us $1M on our AWS bill\ \ Deep Dive](/content/blog/how-websockets-cost-us-1m-on-our-aws-bill/index.html)
\ The fix for a segfault that never shipped\ \ Deep Dive](/content/blog/the-fix-for-a-segfault-that-never-shipped/index.html)
\ Postgres Postmaster does not scale\ \ Deep Dive](/content/blog/postgres-postmaster-does-not-scale/index.html)
\ How to access audio streams in the Zoom Web SDK?\ \ Zoom Developer Forum](/content/blog/how-to-access-audio-streams-in-the-zoom-web-sdk/index.html)
\ How to determine if audio is connected on the Zoom Web SDK?\ \ Zoom Developer Forum](/content/blog/how-to-determine-if-audio-is-connected-on-the-zoom-web-sdk/index.html)
\ Recall.ai Series B: The API for Meeting Recording\ \ Announcements](/content/blog/recall-ai-series-b-the-api-for-meeting-recording/index.html)
\ How to build a Microsoft Teams Bot\ \ Tutorials](/content/blog/how-to-build-a-microsoft-teams-bot/index.html)
\ What is the Google Meet Media API?\ \ Best Practices](/content/blog/what-is-the-google-meet-media-api/index.html)
\ Postgres LISTEN/NOTIFY does not scale\ \ Deep Dive](/content/blog/postgres-listen-notify-does-not-scale/index.html)
\ Code Walkthrough: Generating Meeting Action Items With Recall.ai\ \ Tutorials](/content/blog/generating-meeting-action-items-with-recall-ai/index.html)
\ What are speaker labels and how to obtain them\ \ Tutorials](/content/blog/speaker-labels-and-names-explained/index.html)
\ 7 APIs to get Zoom transcripts: A comprehensive guide\ \ Best Practices](/content/blog/7-apis-to-get-zoom-transcripts-a-comprehensive-guide/index.html)
\ How to get transcripts from Zoom\ \ Tutorials](/content/blog/how-to-get-transcripts-from-zoom/index.html)
\ How to record Microsoft Teams\ \ Tutorials](/content/blog/how-to-get-recordings-from-microsoft-teams/index.html)
\ New Recall.ai Pricing for 2026: $0.50 per Hour of Meeting Recording\ \ Announcements](/content/blog/new-recall-ai-pricing-for-2026/index.html)
\ Open source Microsoft Teams recording software\ \ Microsoft Teams Developer Forum](/content/blog/microsoft-teams-recording-software-open-source/index.html)
\ How to pick a meeting note taker\ \ Best Practices](/content/blog/how-to-pick-a-meeting-note-taker/index.html)
\ How to Get a Recording from the Google Meet API\ \ Best Practices](/content/blog/how-to-get-a-recording-from-the-google-meet-api/index.html)
\ How to get Google Meet transcripts programmatically\ \ Best Practices](/content/blog/how-to-get-transcripts-from-google-meet-developer-edition/index.html)
\ What is Zoom RTMS?\ \ Best Practices](/content/blog/what-is-zoom-rtms/index.html)
\ How to build a desktop recording app (Like Granola)\ \ Tutorials](/content/blog/how-to-build-a-desktop-recording-app/index.html)
\ Speaker Diarization: What it is and how to get diarized transcripts\ \ Best Practices](/content/blog/speaker-diarization/index.html)
\ Understanding Zoom RTMS Behavior and Its Constraints\ \ Best Practices](/content/blog/understanding-zoom-rtms-behavior-and-its-constraints/index.html)
\ How to build a meeting notetaker\ \ Tutorials](/content/blog/how-to-build-a-meeting-notetaker/index.html)
\ How to build a meeting bot \ \ Tutorials](/content/blog/how-to-build-a-meeting-bot/index.html)
\ When to build a Microsoft Teams bot with Puppeteer\ \ Microsoft Teams Developer Forum](/content/blog/puppeteer-microsoft-teams-bot/index.html)
\ How to join a Zoom meeting using the Zoom Meeting SDK\ \ Zoom Developer Forum](/content/blog/how-to-join-a-zoom-meeting-using-the-zoom-meeting-sdk/index.html)
\ Microsoft Teams open source bot\ \ Microsoft Teams Developer Forum](/content/blog/microsoft-teams-open-source-bot/index.html)
\ How to Build a Google Meet Bot from Scratch\ \ Tutorials](/content/blog/how-i-built-an-in-house-google-meet-bot/index.html)
.avif)\ Is there open source Google Meet recording software?\ \ Google Meet Developer Forum](/content/blog/is-there-open-source-google-meet-recording-software/index.html)
.avif)\ Puppeteer Google Meet Bot\ \ Google Meet Developer Forum](/content/blog/puppeteer-google-meet-bot/index.html)
\ Zoom recording software open source\ \ Zoom Developer Forum](/content/blog/zoom-recording-software-open-source/index.html)
\ How to build a Chrome recording extension using tabCapture API\ \ Tutorials](/content/blog/how-to-build-a-chrome-recording-extension/index.html)
\ Zoom Signature Invalid\ \ Zoom Developer Forum](/content/blog/zoom-signature-invalid/index.html)
\ How to get automatic recordings from a Zoom bot\ \ Zoom Developer Forum](/content/blog/zoom-bot-automatic-recording/index.html)
\ Retrieving participant names through the Zoom SDK\ \ Zoom Developer Forum](/content/blog/zoom-sdk-retrieving-participant-names/index.html)
\ How to Access Live Transcription Data from Zoom Web SDK (Component View)\ \ Zoom Developer Forum](/content/blog/how-to-access-live-transcription-data-from-zoom-web-sdk-component-view/index.html)
\ How to build a Zoom bot from scratch\ \ Tutorials](/content/blog/how-to-build-a-zoom-bot/index.html)
\ Create a Google Meet Programmatically\ \ Tutorials](/content/blog/create-a-google-meet-programmatically/index.html)
\ How to join Zoom using Puppeteer\ \ Zoom Developer Forum](/content/blog/how-to-join-zoom-using-puppeteer/index.html)
\ Handling Zoom SDK recording permissions\ \ Zoom Developer Forum](/content/blog/zoom-sdk-recording-permissions/index.html)
\ How to Enable a Zoom Bot to Skip the Waiting Room\ \ Zoom Developer Forum](/content/blog/zoom-bot-skip-waiting-room/index.html)
\ How do I sync audio and video when processing raw audio/video from the Zoom SDK?\ \ Zoom Developer Forum](/content/blog/how-do-i-sync-audio-and-video-when-processing-raw-audio-video-from-the-zoom-sdk/index.html)
\ Fixing DLL warnings in the Zoom Windows Meeting SDK\ \ Zoom Developer Forum](/content/blog/fixing-dll-warnings-in-the-zoom-windows-meeting-sdk/index.html)
\ Zoom join tokens for local recording\ \ Best Practices](/content/blog/zoom-join-tokens-for-local-recording/index.html)
\ How to get recordings from Zoom using a Zoom recording bot API\ \ Tutorials](/content/blog/how-to-get-recordings-from-zoom/index.html)
\ How to manage ZAK Tokens for Zoom bots\ \ Zoom Developer Forum](/content/blog/how-to-manage-zak-tokens-for-zoom-bots/index.html)
\ How to build a Zoom notetaker\ \ Tutorials](/content/blog/how-to-build-a-zoom-notetaker/index.html)
\ Recall.ai Announces Participant Emails\ \ Announcements](/content/blog/recall-ai-announces-participant-emails/index.html)
\ How often should I expect to receive the Zoom recording completed webhook for a single meeting?\ \ Zoom Developer Forum](/content/blog/how-often-should-i-expect-to-receive-the-zoom-recording-completed-webhook-for-a-single-meeting/index.html)
\ Why are waiting rooms causing issues with the Zoom Web Meeting SDK?\ \ Zoom Developer Forum](/content/blog/why-are-waiting-rooms-causing-issues-with-the-zoom-web-meeting-sdk/index.html)
\ Why is my Zoom recording completed webhook so delayed?\ \ Zoom Developer Forum](/content/blog/why-is-my-zoom-recording-completed-webhook-so-delayed/index.html)
\ Why aren't transcription webhooks firing for my Zoom meetings?\ \ Zoom Developer Forum](/content/blog/why-arent-transcription-webhooks-firing-for-my-zoom-meetings/index.html)
\ How do I track participants across Zoom breakout rooms?\ \ Zoom Developer Forum](/content/blog/how-do-i-track-participants-across-zoom-breakout-rooms/index.html)
\ Can I identify meeting participants by email on Zoom?\ \ Zoom Developer Forum](/content/blog/can-i-identify-meeting-participants-by-email-on-zoom/index.html)
\ How do I capture participant data in real time on Zoom?\ \ Zoom Developer Forum](/content/blog/how-do-i-capture-participant-data-in-real-time-on-zoom/index.html)
\ Tracking persistent Zoom meeting ID's for participants who leave and rejoin\ \ Zoom Developer Forum](/content/blog/tracking-persistent-zoom-meeting-ids-for-participants-who-leave-and-rejoin/index.html)
\ Debugging issues with automatic cloud recording\ \ Zoom Developer Forum](/content/blog/debugging-issues-with-automatic-cloud-recording/index.html)
\ Tracking Zoom meetings when using Zoom PMI\ \ Zoom Developer Forum](/content/blog/tracking-zoom-meetings-when-using-zoom-pmi/index.html)
\ Encoding raw video from the Zoom SDK to MP4\ \ Zoom Developer Forum](/content/blog/encoding-raw-video-from-the-zoom-sdk/index.html)
\ Handling crashes in the Zoom Windows Meeting SDK\ \ Zoom Developer Forum](/content/blog/handling-crashes-in-the-zoom-windows-meeting-sdk/index.html)
\ Encoding raw video frames from the Zoom SDK to JPEG\ \ Zoom Developer Forum](/content/blog/decoding-raw-video-frames-from-the-zoom-sdk/index.html)
\ Recording meetings without a local recording token\ \ Zoom Developer Forum](/content/blog/recording-meetings-without-a-local-recording-token/index.html)
\ Resolving intermittent errors while downloading Zoom transcripts\ \ Zoom Developer Forum](/content/blog/resolving-intermittent-errors-while-downloading-zoom-transcripts/index.html)
\ Applying account-level settings to meetings created via the Zoom API\ \ Zoom Developer Forum](/content/blog/applying-account-level-settings-to-meetings-created-via-the-zoom-api/index.html)
\ Why can't my bot join meetings hosted by people outside of my Zoom organization?\ \ Zoom Developer Forum](/content/blog/why-cant-my-bot-join-meetings-hosted-by-people-outside-of-my-zoom-organization/index.html)
\ Zoom Cloud Recording API: Transcript Tutorial, Common Pitfalls, and Workarounds\ \ Tutorials](/content/blog/zoom-transcript-api/index.html)
\ Zoom Meeting Bots: What They Are and Solutions\ \ Deep Dive](/content/blog/zoom-bots/index.html)
\ Can I access gallery view video on the Linux SDK?\ \ Zoom Developer Forum](/content/blog/can-i-access-gallery-view-video-on-the-linux-sdk/index.html)
\ How to access the Zoom Archive Files API\ \ Zoom Developer Forum](/content/blog/how-to-access-the-zoom-archive-files-api/index.html)
\ Resolving Web SDK local recording errors\ \ Zoom Developer Forum](/content/blog/resolving-web-sdk-local-recording-errors/index.html)
\ How to resolve permissions errors with the Zoom Users API\ \ Zoom Developer Forum](/content/blog/how-to-resolve-permissions-errors-with-the-zoom-users-api/index.html)
\ How to track participant name changes in breakout rooms\ \ Zoom Developer Forum](/content/blog/how-to-track-participant-name-changes-in-breakout-rooms/index.html)
\ Why is the transcript.completed webhook not firing?\ \ Zoom Developer Forum](/content/blog/zoom-sdk-webhook-not-firing/index.html)
\ Debugging Zoom SDK failed video capture\ \ Zoom Developer Forum](/content/blog/zoom-sdk-debugging-video-capture/index.html)
\ Capturing video and audio streams through the Zoom SDK\ \ Zoom Developer Forum](/content/blog/zoom-sdk-receiving-video-streams/index.html)
\ Can you suppress the Zoom recording consent prompt through the SDK?\ \ Zoom Developer Forum](/content/blog/zoom-sdk-suppressing-consent-prompt/index.html)
\ How to stream video to a meeting through the Zoom SDK\ \ Zoom Developer Forum](/content/blog/zoom-sdk-streaming-video-to-meeting/index.html)
\ Sending chat messages through the Zoom SDK\ \ Zoom Developer Forum](/content/blog/zoom-sdk-sending-chat-messages/index.html)
\ Handling Zoom app OAuth redirect URL requirements\ \ Zoom Developer Forum](/content/blog/zoom-app-oauth-redirect-url-requirement/index.html)
\ Debugging missing Zoom transcription webhooks\ \ Zoom Developer Forum](/content/blog/zoom-sdk-missing-transcription-webhooks/index.html)
\ Handling Zoom SDK recording compliance requirements\ \ Zoom Developer Forum](/content/blog/zoom-sdk-compliance-requirements/index.html)
\ Handling Zoom app verification\ \ Zoom Developer Forum](/content/blog/zoom-app-domain-verification/index.html)
\ How to join registration-required meetings with a Zoom bot\ \ Zoom Developer Forum](/content/blog/zoom-sdk-joining-registration-required-meetings/index.html)
\ How to use a Zoom bot to join meetings without passcodes\ \ Zoom Developer Forum](/content/blog/zoom-sdk-joining-meetings-without-passcodes/index.html)
\ Debugging stuck Zoom SDK bots\ \ Zoom Developer Forum](/content/blog/zoom-sdk-docker-issues/index.html)
\ How to create a voice agent to send to Zoom meetings\ \ Zoom Developer Forum](/content/blog/zoom-sdk-ai-voice-agent/index.html)
\ How to capture Zoom audio and video with a meeting bot\ \ Zoom Developer Forum](/content/blog/create-audio-video-capture-bots/index.html)
\ How to transcribe a Zoom meeting in real time\ \ Zoom Developer Forum](/content/blog/accessing-real-time-transcription-api/index.html)
\ How to receive raw video from the Zoom SDK\ \ Zoom Developer Forum](/content/blog/accessing-host-video-stream/index.html)
\ Announcing the Recall.ai and Speechmatics Integration\ \ Announcements](/content/blog/announcing-the-recall-ai-and-speechmatics-integration/index.html)
\ Can I access raw audio data using the Zoom Web SDK?\ \ Zoom Developer Forum](/content/blog/can-i-access-raw-audio-data-using-the-zoom-web-sdk/index.html)
Tutorials
How to build a Microsoft Teams Bot
Updated at:
April 17, 2026
Written By:
Gerry Saporito
Table of Contents
- Choosing an implementation: Graph API vs. Playwright
- Building the Microsoft Teams bot server
- What it takes to make this bot scale
- When a handful of bots isn’t enough
- Wrapping up this project
- Appendix
In this post, I built a bot that joins a Microsoft Teams call and streams the transcript in real time.
As a developer experience engineer at Recall.ai, I’ve heard many stories of developers struggling to build their own Microsoft Teams bots. In an effort to better understand the challenges that developers face when tasked with getting transcripts from Microsoft Teams meetings, I set out to build my own Microsoft Teams bot from scratch.
To guide the project, I kept the requirements simple. The bot needed to:
Retrieve transcripts in real-time.
Operate without requiring a heavy lift from participants.
The code is open-source and can be found in the Microsoft Teams Meeting Bot GitHub repository. Watch the demo below to see the bot emitting transcripts in real-time 👇
Open-Source Microsoft Teams Meeting Bot Demo
Open-Source Microsoft Teams Meeting Bot Demo
406 views
Copy link
2
1.2×
2 min 11 sec⚡️2 min 44 sec2 min 11 sec1 min 49 sec1 min 27 sec1 min 17 sec1 min 5 sec52 sec
😍
Anonymous
2
A
Thank you, Gerry! 🙏 +1 other comment
406 views
Copy link
2
1.2×
2 min 11 sec⚡️2 min 44 sec2 min 11 sec1 min 49 sec1 min 27 sec1 min 17 sec1 min 5 sec52 sec
😍
Anonymous
2
A
Thank you, Gerry! 🙏 +1 other comment
- ❤️
heart
1
- 👍
yes
2
- 🔥
fire
3
- 👏
clap
4
- 🙌
yay
5
- 👀
eyes
6
More reactions
7
✋
Frequently Used
💯
🎉
✅
❌
👀
✨
🚀
➕
🙏
🔥
😆
🤔
😱
👋
🌈
❤️
👏
🐞
Smileys & Emotion
People & Body
Animals & Nature
Food & Drink
Activities
Travel & Places
Objects
Symbols
Flags
Make frequently used emojis my default
Comment
Comment
C
2 Comments
A
Anonymous2:11
Sept 15, 2025
Thank you, Gerry! 🙏
Reply
A
Anonymous2:11
Sept 15, 2025
Thank you, Gerry! 🙏
Reply
We also have separate posts on how to get meeting transcripts for Zoom and Google Meets meetings:
If you’re looking for bot-less ways to get the transcript from meetings, check out the Recall.ai Desktop Recording SDK.
Choosing an implementation: Graph API vs. Playwright
When I first sat down to figure out how to build a Microsoft Teams recording bot, two paths opened up in front of me: use the official Microsoft Graph API, or use browser-automations. I’ll dive into both paths and talk about what I considered when evaluating each solution. You can also see a quick comparison in the Appendix under Summary of implementations evaluated.
Option 1 - Retrieving meeting captions from the Microsoft Graph API
Getting transcripts from Microsoft Teams can be approached in a few ways. One option highlighted in Microsoft’s documentation is to use the Microsoft Graph API, which includes the callTranscript() method to fetch transcripts once a meeting has ended.
The core problem with using the Microsoft Graph API was that the Microsoft Teams app you built had to be installed on the tenant that hosted the call. That meant if your user met with another company, and the other company hosted the call, their IT department had to have installed and authorized your Microsoft Teams app in their organization’s Microsoft Teams tenant before it could be used. It wasn’t enough for the app to be installed on your user’s own tenant.
In practice, this made it almost impossible to capture meetings hosted by other organizations, which was exactly the scenario where developers most often needed bots. Additionally I also wouldn’t be able to record the meeting if I wasn’t the host. The option of using the Microsoft Graph API was not sufficient for this project because it would only work if the host had installed the Microsoft Teams app on their tenant.
Option 2 - Scraping meeting captions using browser automations with Playwright (the option I picked)
This was the straightforward option because browser automations allowed me to build a flexible, permissionless bot that had all the same capabilities of any other participant. The only concern was the brittle nature of this implementation as changes to the Microsoft Teams client DOM breaks the bot.
Because my priorities were to make a real-time transcription bot which ran seamlessly without involving participants, the brittleness of this option was a tradeoff I could accept.
The final meeting bot architecture
Now that I’d chosen the browser automation implementation, my next task was to map out the architecture. I decided to build two servers with distinct responsibilities:
Bot launcher server - a dedicated service whose only job is to programmatically spin up new Microsoft Teams bot instances whenever a call needs to be joined.
Microsoft Teams bot server - where the bot instances actually run. Each one uses browser automation to join a meeting and pull captions in real time.
By separating these roles, I kept the bot-launching logic isolated while letting the bots themselves focus solely on the automations required inside the meeting.
Bot launcher server (deploy bots, receive captions)
The bot launcher server’s role was to deploy Microsoft Teams Bot instances on demand. It also receives the real-time transcription webhook or websocket events from the bot.
The flow began with a call to the /api/bot endpoint, which deployed a new Microsoft Teams bot. This endpoint accepted the following parameters:
Meeting url (required) - the Microsoft Teams meeting to join.
Notifier URLs (required) - a list of endpoints to receive transcript utterances in real time.
Bot port (optional) - useful when running multiple bots on the same machine, since it lets you control which port a bot binds to.
Bot id (optional) - a custom id for logging and debugging.
When a participant speaks in the call, the bot launcher server receives real-time transcription events either by subscribing to the webhook endpoint /api/wh/botor listening to the WebSocket on /api/ws/bot.
Microsoft Teams bot server (join meetings, scrape and emit captions)
Once started, the bot parses the meeting URL, loads the meeting lobby where it grants permissions and fills out the display name, and enters the waiting room.
Once in the call, the bot enables and watches the captions for changes. Then it detects the finalized transcript utterances and emits them back to the bot launcher server.
You can see the entire flow in the Appendix under Finalized Implementation Flow.
Building the Microsoft Teams bot server
After I laid out the architecture and implementation, the next task was to start building out the monorepo. I’ll start by walking you through how I set up the bot to join Microsoft Teams calls.
Step 1: Creating the browser
The first step in building the bot was setting up Playwright which would be used for scripting the browser automations. Playwright made it easy to spin up a browser inside a Docker container with just a few lines of code. It also includes built-in arguments to accept browser permissions and mock hardware like audio and video.
apps/teams-bot/src/procedures/orchestrator.ts
1this.browser = await chromium.launch({
2headless: process.env.NODE_ENV === 'production' ? true : false,
3args: [
4'--no-sandbox', // Turns off Chrome’s security sandbox so it can run in restricted/containerized environments.
5'--disable-setuid-sandbox', // Prevents startup crashes in docker systems without it.
6'--disable-dev-shm-usage', // Avoids the “Aw, Snap!” crashes in Docker due to not enough memory
7'--use-fake-ui-for-media-stream',// Automatically “clicks allow” on camera/microphone permission prompts
8'--use-fake-device-for-media-stream'// Provides a fake/mock webcam/microphone feed
9 ]
10});
... See all 11 lines
I then opened a new page in the browser and attempted to load the meeting URL.
Step 2: Processing the Microsoft Teams meeting URL
After opening the Playwright script, the next step was to open the meeting lobby page. However, Microsoft Teams links are app launcher links, not direct links to join the meeting lobby.
Hurdle 1: The unskippable “Launch this in Microsoft Teams” Popup
I realized quickly that the meeting URL didn't allow direct joining, instead opening a dialog box that said “Open in app or browser” which Playwright can’t interact with. The solution was to tweak the query parameters to bypass the dialog and go straight to the browser join flow.
apps/teams-bot/src/procedures/join-procedure.ts
1const response = awaitfetch(meetingUrl, { redirect: 'follow' });
2const launchUrl = newURL(response.url);
3launchUrl.searchParams.set('msLaunch', 'false');
4launchUrl.searchParams.set('type', 'meetup-join');
5launchUrl.searchParams.set('directDl', 'true');
6launchUrl.searchParams.set('enableMobilePage', 'true');
7launchUrl.searchParams.set('suppressPrompt', 'true');
8return launchUrl;
This allowed the bot to interact with the DOM because it skipped the popup.
Hurdle 2: Forcing Microsoft Teams to provide a consistent DOM experience
Immediately after I resolved the first hurdle, I encountered another issue: the DOM selectors changed depending on whether the browser was headed or headless. As it turns out, Microsoft Teams serves different DOMs depending on the participant’s browser, making it difficult to reliably find the right selectors.
To work around this, I locked the Microsoft Teams client build by setting a specific user agent so the DOM selectors remained consistent.
apps/teams-bot/src/procedures/orchestrator.ts
1const context = awaitthis.browser.newContext({
2viewport: { width: 1280, height: 720 },
3userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
4});
Once I set the above user agent, choosing the selectors on headed vs headless browsers became straightforward.
Step 3: Joining the Microsoft Teams meeting
After parsing the direct join link, I loaded the page, entered the bot's display name, and found the "Join meeting" button. Identifying DOM selectors and filling inputs was straightforward. That said, the flow became a lot more interesting when the bot clicked the “Join meeting” button.
Hurdle 3: Handling Microsoft Teams’ many join states
Once the bot clicked the ‘Join meeting’ button, the number of possible states expanded significantly. Microsoft Teams threw the bot into all sorts of states: in waiting room, denied entry, and various error states, each with its own DOM structure and selectors.
I could have detected and supported every state with selectors, but that makes the bot complex and fragile. A simpler approach was to wait for the bot to enter the meeting lobby and fire a timeout if it never entered.
apps/teams-bot/src/procedures/join-procedure.ts
1asyncisInMeetingLobby(page) {
2try {
3const meetingLobbyText = 'Someone will let you in shortly';
4awaitexpect(page.getByText(meetingLobbyText)).toBeVisible({ timeout: waitForSeconds * 1000 });
5returntrue;
6 } catch (error) {
7console.error(error)
8returnfalse;
9 }
10}
... See all 11 lines
This approach worked well because my goal was to reliably know when the bot had entered the meeting and to throw/shut down if it didn’t.
Step 4: Enable and scrape real-time captions
By this point, the bot was in the call as a participant and the next step was to start captions by clicking the “Enable captions” button.
The tricky part was scraping captions, since Microsoft Teams buries them deep in the DOM. I had to trace through multiple layers of span and div elements to find a consistent selector that reliably exposed the caption text.
Captions in Microsoft Teams are brittle by design: they rely on deeply nested, unstable DOM elements so even small frontend changes can break this captions flow.
Then I encountered the hardest part: the captions inside the elements were dynamic.
Hurdle 4: Scraping dynamic captions in Microsoft Teams
After fixing the selector, another issue appeared: Microsoft Teams first injected a caption node, then updated it with finalized text. The bot grabbed the partial caption too early, since the text initally appeared incomplete before being updated again moments later.
The only way around this was to watch for changes on the node. I used a MutationObserver on the caption container to track real-time changes and updates to caption content, letting the bot capture the new transcripts as Microsoft Teams injected or updated them.
apps/teams-bot/src/procedures/captions-procedure.ts
1awaitthis.page.evaluate(() => {
2const targetNode = document.querySelector('div[data-tid="closed-caption-renderer-wrapper"]');
3if (!targetNode) return;
4const observer = newMutationObserver((mutationsList) => {
5for (const mutation of mutationsList) {
6if (mutation.type !== 'childList') continue;
7 mutation.addedNodes.forEach((node) => {
8const captionMessage = (node asHTMLElement).querySelector?.('.fui-ChatMessageCompact');
9if (!captionMessage) return;
10const authorElement = captionMessage.querySelector('span[data-tid="author"]');
11const contentElement = captionMessage.querySelector('span[data-tid="closed-caption-text"]');
12if (!authorElement || !contentElement) return;
13// Watch updates to the caption text in real time
14const textObserver = newMutationObserver(() => {
15const name = authorElement.textContent?.trim() ?? 'Unknown';
16const text = (contentElement asany).innerText?.trim() ?? '';
17 (windowasany).onCaption({ participant: { name }, text, started_at: { absolute_timestamp: newDate().toISOString() } });
18 });
19 textObserver.observe(contentElement, { childList: true, subtree: true, characterData: true });
20 });
21 }
22 });
23 observer.observe(targetNode, { childList: true, subtree: true });
24});
... See all 25 lines
This
MutationObserverimplementation wasn’t very efficient as it created a new observer for every new caption element, leading to memory and performance issues over time. A better approach would be to use a single observer on the caption container, filtering and tracking child changes within it.
I was able to pull the captions from the meeting reliably by this point, though this relief was short lived as another issue surfaced once I started streaming the captions.
Hurdle 5: Detecting finalized captions
A new problem popped up once I started streaming captions: I wasn’t filtering for captions that have been finalized. Microsoft Teams updated the same caption element multiple times when a participant was speaking, so I’d end up saving partials for the same caption like:
1Gerry: Hey
2Gerry: Hey thisis a
3Gerry: Hey thisis a dynamic
4Gerry: Hey thisis a dynamic Capt
5Gerry: Hey thisis a dynamic caption
6Gerry: Hey thisis a dynamic caption.
The fix was to only emit captions that have been finalized. I fumbled around the captions for a while until I realized Microsoft Teams has a quirk: it finalized a caption by adding ending punctuation (e.g. !, ?, .)! With this in mind, I then buffered each caption and waited for the ending punctuation to indicate a caption has finalized.
apps/teams-bot/src/procedures/captions-procedure.ts
1const terminalPunctuationRegex = /[.,!?]/g;
2if (!terminalPunctuationRegex.test(newCaption.text)) {
3return;
4}
This wasn’t the end though, as there was another edge case: some captions already included ending punctuation before the caption was finalized. You can see this behavior in the example below which was a single caption that created two transcripts parts.
1Gerry: I spent $500.00 today
2Gerry: I spent $500.00 today at the store.
To avoid saving duplicates, I normalized both captions by stripping punctuation from both the new and last saved caption, then compared them. If they matched, I skipped it which was good enough for my use case.
apps/teams-bot/src/procedures/captions-procedure.ts
1const punctuationRegex = /[.,'"!~-?]/g;
2const newCaptionText = newCaption.text.replace(punctuationRegex, '');
3const latestCaptionText = this.state.captions[this.state.captions.length - 1]?.text.replace(punctuationRegex, '');
4
5// Skipping caption event because it was the same as the latest caption
6if (newCaptionText === latestCaptionText) {
7return;
8}
While this heuristic wasn’t perfect for all languages or styles (some captions don't end with punctuation), it significantly reduced spammy duplicates in meetings spoken in English.
For a more reliable build, you'll likely need a deeper integration with the Microsoft Teams API, either through their official Graph API or by intercepting caption events from the Microsoft Teams server.
By this point, I was confident that I had the finalized captions and could safely emit them to the notifier URLs.
Step 5: Emitting captions to the destination server
Compared to the hurdles of scraping and finalizing captions, sending finalized captions was straightforward. I wrapped the speaker's name, caption text, and a timestamp into a payload, then pushed it to each NOTIFIER_URLS endpoint in real time.
apps/teams-bot/src/procedures/captions-procedure.ts
1if (this.notifier) {
2this.notifier.sendEventToServer({
3payload: {
4event: 'captions.data',
5data: {
6botId: this.state.botId,
7captionFragment: newCaption
8 }
9 }
10 });
11}
... See all 12 lines
Hurray! This bot could now stream real-time captions from the meeting!
What it takes to make this bot scale
With that, I've shown you how to build a prototype bot for Microsoft Teams meetings to scrape captions. Scaling this to manage thousands of bots introduces new challenges though: orchestration, policy, UI changes, and the inherent difficulty of automating and maintaining the Microsoft Teams client. The focus shifts from caption extraction to maintaining hundreds of bots online, synchronized, and resilient against Microsoft Teams' complexities.
Here are some issues you'll encounter when running bots at scale.
Managing a fleet of bots
Building a bot is relatively straightforward - the challenge is managing thousands of concurrent bots.
Each bot involves:
On-demand Docker image deployments across multiple servers.
Precise scheduling to ensure timely arrival and transcription.
Continuous monitoring of health and resource usage across the fleet.
Robust handling of retries and replacements if a bot crashes mid-call.
This is like running a mini "serverless" platform, but with long-lived, stateful browser workloads.
Tracking Microsoft Teams meeting lifecycle states
Bots must accurately track their status within the Microsoft Teams meeting lifecycle (e.g., waiting room, lobby, in-call, captions enabled, captions flowing). Losing track of state means the bot might miss captions, wait to join meetings they’ve already entered, or fail to detect when a host removes the bot.
Accurate state handling requires:
Detecting transitions (join success, captions enabled/disabled, user kicked, failed to enter meeting).
Recovering gracefully if the state machine desyncs (e.g., Microsoft Teams UI didn’t render as expected).
Exposing states reliably to know if a bot is “healthy”.
Chasing Microsoft Teams’ UI rollouts
The Microsoft Teams web client lacks the stability of an API, as Microsoft often makes updates to the DOM. These changes, which can be gradual, A/B tested, or browser-dependent, will change selectors or layouts without notice. This can lead to broken bots until an engineer updates the bot's selectors to reflect the new DOM.
Handling Microsoft Teams failure modes
Even beyond Microsoft Teams client changes, there are failure modes outside my control. Some meetings don’t allow captions, and tenant or host policies can silently disable them. I’ve seen captions never appear or stop mid-call without warning. This leads to needing detection and fallback paths - surfacing issues to the host and triggering alerts, retries, or restarts when captions don’t appear within X seconds.
When a handful of bots isn’t enough
By now I’ve realized the real challenge isn’t scraping captions - it’s keeping fleets of bots resilient against Microsoft Teams’ quirks, policies, and crashes. Once you hit that wall, you have two options:
To build your own bot: keep investing in infrastructure, orchestration, and maintenance to chase Microsoft Teams’ UI and behavior changes.
To use a hosted meeting bot service: use a hosted solution like Recall.ai’s meeting bot API that has already solved these scale and reliability problems.
If you’re just experimenting, building your own bot makes sense. But for production-ready bots that need to scale, you should use hosted platforms like Recall.ai which are built to take on that burden. You can see a sample of how to do this in a few lines of code in the Appendix under Implement with Recall.ai.
Wrapping up this project
So here I am at the end of this project, having learned that building a Microsoft Teams bot to pull transcripts is deceptively complex. Beyond the different implementation paths, the real challenge was wrestling with the quirks of the Microsoft Teams meeting client. Getting the first prototype working felt like a huge win, but it didn’t take long to realize that was just the tip of the iceberg - the easy part was making it work once; the hard part was making it work reliably.
Key lessons I took away:
The Graph API had major limitations that made flexible, reliable bots hard to build.
Scraping captions with Playwright worked, but it’s inherently brittle.
If your goal is to learn, hacking together a bot is a great exercise. But if your goal is reliable, large-scale caption capture, you’ll find offloading your bot infrastructure as the better solution.
If you’re interested in production-ready bots, I recommend checking out Recall.ai’s meeting bot API. You can sign up and deploy your own meeting bots with a single API call, so you spend less time on infrastructure and more time building features your users actually care about.
Appendix
Summary of implementations evaluated
| Approach | Pros | Cons |
|---|---|---|
| Graph API | Officially supported, no DOM hacks, stable auth flow | High-friction flow requiring host permissions, no real-time transcript events |
| Brower Automations (Playwright) | Full control over captions as they appear, quick to prototype | Brittle (UI changes break selectors) |
Finalized implementation flow
Implement with Recall.ai
Below is a script on how to send a bot to a Microsoft Teams call and receive real-time transcripts through Recall.ai’s Microsoft Teams Meeting Bot API. This script includes the API call to the Recall.ai API and starts a simple express server to listen for real-time transcript webhooks
index.js
1// Fill in the following variables
2constPORT = 3000;
3constRECALL_API_TOKEN = 'RECALL_API_KEY';
4constMICROSOFT_TEAMS_MEETING_URL = 'https://teams.microsoft.com/l/meetup-join/\[...\]'
5constTRANSCRIPT_WEBHOOK_URL = 'https://your-ngrok-url.ngrok.io/webhook'// Ensure ngrok is pointing to the port defined above
6
7// Recall.ai API Implementation
8constcreateBot = async () => {
9const response = awaitfetch(
10'https://us-west-2.recall.ai/api/v1/bot',
11 {
12method: 'POST',
13headers: {
14'Content-Type': 'application/json',
15'Authorization': `Token ${RECALL_API_TOKEN}`
16 },
17body: JSON.stringify({
18meeting_url: MICROSOFT_TEAMS_MEETING_URL,
19recording_config: {
20transcript: {
21provider: {
22//Recommended Option: Recall Transcription (used meeting captions because of parity with the Microsoft Teams bot I built)
23meeting_captions: {},
24 },
25 },
26realtime_endpoints: [{
27type: "webhook",
28url: TRANSCRIPT_WEBHOOK_URL,
29events: ["transcript.data"],
30 }],
31 }
32 })
33 }
34 )
35if (!response.ok) {
36thrownewError(`HTTP error! status: ${response.status}`);
37 }
38
39const bot = await response.json();
40console.log(bot);
41return bot;
42}
43
44createBot()
45
46// Server Implementation
47const express = require("express");
48const app = express();
49app.use(express.json());
50
51app.post("/webhook", async (req, res) => {
52try {
53const { event, data } = req.body;
54
55if (event === "transcript.data") {
56const participant = data.data.participant.name;
57const words = data.data.words;
58// Log sentence words for participant
59const sentence = words.map((word) => word.text).join(" ");
60console.log(`${participant}: ${sentence}`);
61 }
62
63 res.status(200).json({ success: true, message: "Webhook received" });
64 } catch (error) {
65console.error("Error processing webhook:", error);
66 res.status(500).json({ success: false, error: error.message });
67 }
68});
69
70app.listen(PORT, () => {
71console.log(`Server running at http://localhost:${PORT}\`);
72});
... See all 73 lines
Some common questions
Is using Recall.ai's Meeting Bot API easier than building a meeting bot?
Recall.ai's Meeting Bot API is much easier than building a meeting bot yourself, but most crucially, it removes the maintanence and abstracts away the need to have separate integrations for separate platforms if your users live on more than just Microsoft Teams (e.g., Google Meet, Zoom, etc).
Is there a Microsoft Teams transcript API?
Check out Recall.ai's Microsoft Teams Transcription API page to learn about the ways to get transcripts from a Microsoft Teams meeting.
Is there a Microsoft Teams recording API?
Similarly, Recall.ai's Microsoft Teams Recording API page is where you can find information on accessing audio and video in real time from Microsoft Teams.
How does joining a Microsoft Teams Meeting with Playwright compare to using Puppeteer?
Playwright and Puppeteer are similarly reliable for automating tasks like joining a Microsoft Teams meeting through the web client. Playwright’s built-in waiting, permission handling, and multi-browser support can make the join flow a bit smoother out of the box, while Puppeteer may require more manual waits or configuration to handle the same steps. In practice, both tools can achieve the same results. The choice mainly depends on your project’s setup and preferred automation style.
How can I build a Selenium Microsoft Teams bot to join meetings automatically?
Similar to how you'd build a bot to join a Microsoft Teams with Playwright or Puppeteer, you can build a Selenium Microsoft Teams bot by launching a browser with Selenium, opening the Microsoft Teams meeting URL, entering a display name, and clicking the “Join” button on the web client. From there, you can use waits to detect when the meeting has started or ended and handle pop-ups or permission prompts. Selenium can automate this reliably, though it may need a bit more setup for timing and permissions compared to Playwright or Puppeteer.
How do I build a meeting bot using a meeting bot API?
Check out our blog on how to build a meeting bot to build a meeting bot using an API to get recordings and transcripts.
Can I build a bot for other meeting platforms?
If you're looking for a blog on how to use Recall.ai to build a meeting bot that works for Zoom, Google Meet, Microsoft Teams and more from scratch, the blog linked above on how to build a meeting bot is the easiest way to create a bot that will work for all of the major meeting platforms. You can also find blogs on how to build a Google Meet bot from scratch or Zoom bot from scratch. Even find ways to send a bot to Slack Huddles.
Written By:
Gerry Saporito
Table of Contents
The API to get transcripts, recordings, and metadata from meetings.
Need help? Email us at support@recall.ai
Get transcripts, recordings and metadata from meetings
Product
Meeting Bot API\ \ \ example Desktop Recording SDK\ \ \ example Mobile Recording SDK\ \ \ example Calendar Integration\ \ \ example Storage and Playback\ \ \ example
Company
Careers\ \ \ We're hiring! Blog\ \ \ example Case Studies\ \ \ example
Security
Security Portal\ \ \ example SOC 2\ \ \ example ISO 27001\ \ \ example HIPAA\ \ \ example GDPR\ \ \ example CCPA\ \ \ example
Legal
Privacy\ \ \ We're hiring! Terms of Use\ \ \ We're hiring!
Resources
API Docs\ \ \ example Pricing\ \ \ example Notetaker API\ \ \ example Meeting Recording API\ \ \ example Transcription API\ \ \ example Meeting Transcription API\ \ \ example Compliance Recording API\ \ \ example Speaker Diarization API\ \ \ example Slack Huddles API\ \ \ example
Compare
Recall.ai vs Attendee\ \ \ example Recall.ai vs Meeting BaaS\ \ \ example Recall.ai vs Nylas Notetaker\ \ \ example Recall.ai vs Skribby\ \ \ example
More
Meeting Bots
Google Meet Bot API\ \ \ example Microsoft Teams Bot API\ \ \ example Zoom Meeting Bot API\ \ \ example
Recording
Google Meet Recording API\ \ \ example Microsoft Teams Recording API\ \ \ example Zoom Meeting Recording API\ \ \ example
Transcription
Google Meet Transcription API\ \ \ example Microsoft Teams Transcription API\ \ \ example Zoom Meeting Transcription API\ \ \ example