teams-transcripts 0.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. legacy/__init__.py +0 -0
  2. legacy/transcript_download.py +3969 -0
  3. teams_transcripts/__init__.py +1 -0
  4. teams_transcripts/__main__.py +288 -0
  5. teams_transcripts/adapters/__init__.py +1 -0
  6. teams_transcripts/adapters/cdp/__init__.py +1 -0
  7. teams_transcripts/adapters/cdp/browser.py +196 -0
  8. teams_transcripts/adapters/cdp/connection.py +168 -0
  9. teams_transcripts/adapters/cdp/helpers.py +449 -0
  10. teams_transcripts/adapters/cdp/teams.py +463 -0
  11. teams_transcripts/adapters/vertex.py +274 -0
  12. teams_transcripts/domain/__init__.py +1 -0
  13. teams_transcripts/domain/chat.py +115 -0
  14. teams_transcripts/domain/html.py +126 -0
  15. teams_transcripts/domain/mcps.py +343 -0
  16. teams_transcripts/domain/models.py +531 -0
  17. teams_transcripts/domain/participants.py +292 -0
  18. teams_transcripts/domain/scoring.py +106 -0
  19. teams_transcripts/domain/speakers.py +130 -0
  20. teams_transcripts/domain/summary.py +266 -0
  21. teams_transcripts/domain/timestamps.py +350 -0
  22. teams_transcripts/domain/vtt.py +444 -0
  23. teams_transcripts/infrastructure/__init__.py +1 -0
  24. teams_transcripts/infrastructure/config.py +987 -0
  25. teams_transcripts/infrastructure/errors.py +120 -0
  26. teams_transcripts/infrastructure/logging.py +69 -0
  27. teams_transcripts/infrastructure/signals.py +143 -0
  28. teams_transcripts/mcp_server.py +818 -0
  29. teams_transcripts/ports/__init__.py +1 -0
  30. teams_transcripts/ports/agent.py +58 -0
  31. teams_transcripts/ports/browser.py +126 -0
  32. teams_transcripts/services/__init__.py +1 -0
  33. teams_transcripts/services/downloader.py +1283 -0
  34. teams_transcripts/services/extraction.py +1603 -0
  35. teams_transcripts/services/listing.py +826 -0
  36. teams_transcripts/services/navigation.py +1721 -0
  37. teams_transcripts/services/output.py +84 -0
  38. teams_transcripts/services/tenant.py +684 -0
  39. teams_transcripts-0.5.0.dist-info/METADATA +1438 -0
  40. teams_transcripts-0.5.0.dist-info/RECORD +42 -0
  41. teams_transcripts-0.5.0.dist-info/WHEEL +4 -0
  42. teams_transcripts-0.5.0.dist-info/entry_points.txt +4 -0
@@ -0,0 +1,1438 @@
1
+ Metadata-Version: 2.4
2
+ Name: teams-transcripts
3
+ Version: 0.5.0
4
+ Summary: Download Microsoft Teams transcripts, chat, and meeting metadata from the macOS desktop app
5
+ Project-URL: Homepage, https://gitlab.com/jamiemills-choreograph/teams-transcripts
6
+ Project-URL: Repository, https://gitlab.com/jamiemills-choreograph/teams-transcripts
7
+ Project-URL: Issues, https://gitlab.com/jamiemills-choreograph/teams-transcripts/-/issues
8
+ Keywords: macos,mcp,microsoft-teams,transcripts,webvtt
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Environment :: MacOS X
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Communications :: Conferencing
18
+ Classifier: Topic :: Text Processing
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: anthropic[vertex]
21
+ Requires-Dist: mcp>=1.27.0
22
+ Requires-Dist: pydantic-settings>=2.0
23
+ Requires-Dist: pydantic>=2.0
24
+ Requires-Dist: websockets
25
+ Provides-Extra: test
26
+ Requires-Dist: pytest>=8.0; extra == 'test'
27
+ Requires-Dist: pyyaml>=6.0; extra == 'test'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # Teams Transcript Downloader
31
+
32
+ A command-line tool that downloads Microsoft Teams meeting transcripts, chat
33
+ messages, and participant metadata as WebVTT (`.vtt`) or structured JSON. It
34
+ connects to the Teams desktop app via the Chrome DevTools Protocol (CDP) and
35
+ navigates the UI programmatically. An LLM agent (Claude on Vertex AI) handles
36
+ bounded non-deterministic steps such as selecting ambiguous search results,
37
+ selecting recurring-meeting occurrences, and disambiguating iframe targets.
38
+
39
+ ## Run with uvx
40
+
41
+ On macOS with [uv](https://docs.astral.sh/uv/) installed, run the app directly
42
+ from PyPI without a persistent installation:
43
+
44
+ ```sh
45
+ uvx teams-transcripts --help
46
+ uvx teams-transcripts \
47
+ --meeting "Weekly Standup" \
48
+ --datetime 2026-07-11 \
49
+ --gcp-project your-project
50
+ ```
51
+
52
+ To run one of the package's differently named commands, specify the package:
53
+
54
+ ```sh
55
+ uvx --from teams-transcripts teams-transcripts-mcp
56
+ uvx --from teams-transcripts transcript-download --help
57
+ ```
58
+
59
+ `uvx` creates an isolated temporary environment. The Teams desktop app, CDP,
60
+ and authentication prerequisites below still apply.
61
+
62
+ ## How it works
63
+
64
+ The script executes an 8-step pipeline:
65
+
66
+ | Step | Description |
67
+ |------|-------------|
68
+ | 1 | Verify Teams is running with CDP enabled; relaunch if needed |
69
+ | 2 | Confirm CDP is responding on the configured port |
70
+ | 3 | Discover Teams page targets via CDP |
71
+ | 3b | _(when `--tenant` specified)_ Verify tenant; switch if needed |
72
+ | 3c | _(when `--meeting-id` used)_ Resolve UUID to meeting subject and date via MCPS API |
73
+ | 4 | Type the meeting name into the Teams search bar and navigate to the chat |
74
+ | 5 | Open Recap when needed, select the correct date occurrence for recurring meetings, extract speaker names from the Speakers sub-tab, then switch to the Transcript tab only when transcript output is requested |
75
+ | 6 | Locate the transcript iframe target when transcript output is requested |
76
+ | 7 | Download the transcript when requested, extract roster members and output chat messages when requested, read Event/Call messages and RSVP tracking when needed for metadata, build categorised participant lists, then build VTT or structured JSON |
77
+ | 8 | Validate generated transcript content when transcript output is requested |
78
+
79
+ ### Download approaches
80
+
81
+ When transcript output is requested, the script detects which approach to use
82
+ based on the Download button state:
83
+
84
+ **Approach A -- Native blob interception** is used when you have download
85
+ permission. The script clicks the Download button and intercepts the blob URL
86
+ to capture the VTT content byte-for-byte.
87
+
88
+ **Approach B -- React fibre tree extraction** is used when download is blocked
89
+ (the button shows "You don't have permission"). The script walks the React
90
+ component tree to extract all transcript entries directly from the in-memory
91
+ data structure, then generates a well-formed VTT file.
92
+
93
+ ### Speaker name resolution
94
+
95
+ Speaker names are resolved using the Recap Speakers tab. The script extracts
96
+ a GUID-to-display-name mapping and applies it to clean up:
97
+
98
+ - **Vendor-prefixed names**: `v-John Doe (Metosin)` becomes `John Doe`
99
+ - **Suffixed names**: `Jane Smith GRM` becomes `Jane Smith`
100
+ - **Synthetic placeholders**: `@1` becomes `Speaker 1`
101
+
102
+ Names that are already clean (e.g. internal staff) are left unchanged.
103
+
104
+ ### Meeting participant data
105
+
106
+ The tool extracts categorised participant information from four sources:
107
+
108
+ | Category | Source | Description |
109
+ |----------|--------|-------------|
110
+ | Organiser | Roster popover (fibre tree) | The meeting creator, identified by `organizerId` from the chat topic menu |
111
+ | Invited | Roster popover (fibre tree) | All members of the meeting chat thread, with names, emails, and Azure AD object IDs |
112
+ | Speakers | Recap Speakers tab | Members who spoke during the meeting, cross-referenced with the roster by display name |
113
+ | Attendees | Event/Call `<partlist>` XML in chat messages | Members who joined the call, cross-referenced with the roster by Azure AD object ID |
114
+
115
+ For the **invited** category, RSVP tracking data can optionally be enriched
116
+ from the meeting Details tab. The tool navigates to Details, opens the
117
+ Tracking panel, clicks "Show more attendees" if present, and parses the
118
+ aria-labels to extract each invitee's calendar response status (accepted,
119
+ tentative, declined, or no response). This enrichment is soft-failure:
120
+ if the Tracking panel is unavailable, the pipeline continues without RSVP
121
+ data.
122
+
123
+ The roster popover is accessed by clicking the participant count button in
124
+ the chat header. This retrieves the full member list (names, emails,
125
+ object IDs) for all members, regardless of meeting size. The legacy
126
+ `membersLimited` property in the fibre tree is intentionally limited to
127
+ ~3 members by Teams and is only used as a fallback.
128
+
129
+ ## Prerequisites
130
+
131
+ ### 1. Microsoft Teams desktop app (macOS)
132
+
133
+ Teams must be running with CDP remote debugging enabled. If Teams is already
134
+ running without CDP, the script will kill and relaunch it automatically with
135
+ the full set of required flags.
136
+
137
+ To launch manually, only the CDP port is strictly required:
138
+
139
+ ```sh
140
+ export WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS='--remote-debugging-port=9333'
141
+ open -a 'Microsoft Teams'
142
+ ```
143
+
144
+ When the script relaunches Teams itself, it sets additional Chromium flags for
145
+ reliable automation:
146
+
147
+ | Flag | Purpose |
148
+ |------|---------|
149
+ | `--disable-backgrounding-occluded-windows` | Keep rendering when Teams is behind other windows |
150
+ | `--disable-renderer-backgrounding` | Keep renderer at full priority when unfocused |
151
+ | `--disable-hang-monitor` | Suppress "page unresponsive" dialogs |
152
+ | `--disable-ipc-flooding-protection` | Prevent throttling of rapid CDP commands |
153
+ | `--disable-smooth-scrolling` | Remove animation delays |
154
+ | `--run-all-compositor-stages-before-draw` | Ensure fully composited frames for screenshots |
155
+ | `--hide-scrollbars` | Cleaner screenshots |
156
+ | `--mute-audio` | Suppress notification and meeting sounds |
157
+ | `--silent-debugger-extension-api` | Hide "debugging" infobars |
158
+ | `--force-device-scale-factor=2` | Retina-quality screenshots for the LLM agent |
159
+
160
+ ### 2. Google Cloud credentials
161
+
162
+ The LLM agent uses Claude via Vertex AI, so you need Application Default
163
+ Credentials (ADC) for a GCP project with the Vertex AI API enabled:
164
+
165
+ ```sh
166
+ gcloud auth application-default login
167
+ ```
168
+
169
+ Or point the `--adc` flag (or `GOOGLE_ADC` env var) at a service account key
170
+ file.
171
+
172
+ ### 3. Python dependencies
173
+
174
+ The script requires Python 3.10+ and is managed with
175
+ [uv](https://docs.astral.sh/uv/). To install all dependencies:
176
+
177
+ ```sh
178
+ uv sync --locked
179
+ ```
180
+
181
+ This creates a virtual environment in `.venv/` and installs the locked
182
+ dependencies from `uv.lock`.
183
+
184
+ ## Usage
185
+
186
+ ### Download a transcript
187
+
188
+ ```sh
189
+ teams-transcripts (--meeting NAME | --meeting-id UUID) [--datetime YYYY-MM-DD[THH:MM]] [--output PATH] [options]
190
+ ```
191
+
192
+ Or with `uv run` (no wrapper script needed):
193
+
194
+ ```sh
195
+ uv run teams-transcripts (--meeting NAME | --meeting-id UUID) [--datetime YYYY-MM-DD[THH:MM]] [--output PATH] [options]
196
+ ```
197
+
198
+ Or via the Python module:
199
+
200
+ ```sh
201
+ uv run python -m teams_transcripts (--meeting NAME | --meeting-id UUID) [--datetime YYYY-MM-DD[THH:MM]] [--output PATH] [options]
202
+ ```
203
+
204
+ ### List available tenants
205
+
206
+ ```sh
207
+ teams-transcripts --list-tenants [--format json] [--output PATH]
208
+ ```
209
+
210
+ Lists all Azure AD tenants the signed-in user can access, marks the current
211
+ tenant with `*`, then exits. Use the name or domain value from the output
212
+ with `--tenant` in a subsequent download invocation.
213
+
214
+ ### List meetings with transcripts
215
+
216
+ ```sh
217
+ teams-transcripts --list-transcripts [DAYS] [--format json] [--output PATH] [--tenant DOMAIN]
218
+ ```
219
+
220
+ Lists all meetings with transcripts that the signed-in user attended. `DAYS`
221
+ must be `1` or greater. This
222
+ queries the Teams MCPS (Meeting Content and Presence Service) API via CDP
223
+ worker injection -- no Graph API permissions or app registrations required.
224
+
225
+ The optional `DAYS` argument controls how many days back to search (default:
226
+ 1 = today only).
227
+
228
+ **Multi-tenant behaviour:** when `--tenant` is specified, only the matching
229
+ tenant is queried. Without `--tenant`, all signed-in tenants are queried
230
+ and results aggregated into a single output.
231
+
232
+ Examples:
233
+
234
+ ```sh
235
+ # Today's meetings with transcripts (all tenants)
236
+ teams-transcripts --list-transcripts
237
+
238
+ # Last 7 days
239
+ teams-transcripts --list-transcripts 7
240
+
241
+ # Last 14 days, specific tenant, JSON output to file
242
+ teams-transcripts --list-transcripts 14 --tenant choreograph.com --format json --output meetings.json
243
+
244
+ # Last 30 days, table output
245
+ teams-transcripts --list-transcripts 30
246
+ ```
247
+
248
+ Text output:
249
+
250
+ ```
251
+ Date Subject T R ID Call ID
252
+ ---------- ------------------------------------------------ - - ------------------------------------ ------------------------------------
253
+ 2026-04-29 Campaign Connect Leads Y Y f0a4b1c3-228f-4f24-8efb-6736215a3072 aae0280e-5b7e-473d-8a4b-99de3cf74a08
254
+ 2026-04-29 OMS Forum Y Y 3f9c903d-339a-4be7-a796-c13001c6af01 b12f445a-6c1e-4e2d-9f01-4e8a6b7c9d0e
255
+ 2026-04-29 Data connectors development Y Y 6d5d5a9f-357a-4f3c-a2be-d489454f6762 c23e556b-7d2f-5f3e-a012-5f9b7c8d0e1f
256
+ 2026-04-29 UAP mid-Weekly Y Y 39158372-e829-454e-ad92-3d8812527aa9 d34f667c-8e3g-6g4f-b123-6g0c8d9e1f2g
257
+ 2026-04-29 Dive into data for Data Connectors Y Y 67c13927-9b23-4f30-ae67-a489b3842994 e45g778d-9f4h-7h5g-c234-7h1d9e0f2g3h
258
+ 2026-04-29 UAP / Trafficking / architecture Y Y 589a1e41-3361-4231-8fc0-582e42fe87c3 f56h889e-0g5i-8i6h-d345-8i2e0f1g3h4i
259
+ 2026-04-28 Optimization Tech leads Fortnightly Sync session Y Y 2b4c1f5c-636d-4d78-a087-c2cbf3887fd8 g67i990f-1h6j-9j7i-e456-9j3f1g2h4i5j
260
+ 2026-04-27 Campaign Connect Leads Y Y f0a4b1c3-228f-4f24-8efb-6736215a3072 h78j001g-2i7k-0k8j-f567-0k4g2h3i5j6k
261
+
262
+ 8 meeting(s) found.
263
+ ```
264
+
265
+ Columns: **Date** (ISO), **Subject** (meeting title, max 48 chars),
266
+ **T** (has transcript: Y/N), **R** (has recording: Y/N),
267
+ **ID** (meeting UUID -- use with `--meeting-id` to download),
268
+ **Call ID** (per-occurrence identifier -- use with `--call-id` for
269
+ unambiguous targeting of recurring meetings; shown only when MCPS returns
270
+ call IDs).
271
+
272
+ Note: for recurring meetings (e.g. "Campaign Connect Leads" above), the
273
+ **ID** column shows the same UUID for all occurrences (they share a single
274
+ chat thread). The **Call ID** column uniquely identifies each occurrence.
275
+
276
+ JSON output (`--format json`):
277
+
278
+ ```json
279
+ [
280
+ {
281
+ "subject": "Campaign Connect Leads",
282
+ "date": "2026-04-29",
283
+ "thread_id": "19:meeting_ZjBhNGIxYzMtMjI4Zi00ZjI0LThlZmItNjczNjIxNWEzMDcy@thread.v2",
284
+ "call_id": null,
285
+ "ical_uid": "040000008200E00074C5B7101A82E008...",
286
+ "has_transcript": true,
287
+ "has_recording": true,
288
+ "transcript_url": "https://tenant-my.sharepoint.com/personal/...transcripts/.../content",
289
+ "recording_url": "https://tenant-my.sharepoint.com/personal/.../Meeting%20Recording.mp4"
290
+ }
291
+ ]
292
+ ```
293
+
294
+ The JSON output includes SharePoint URLs for transcripts/recordings when MCPS
295
+ returns them. These URLs can be used for direct download with appropriate
296
+ authentication.
297
+
298
+ The legacy entry point `transcript-download` (backed by the original monolith
299
+ in `legacy/transcript_download.py`) is still registered but is no longer the
300
+ primary interface.
301
+
302
+ ### Required arguments (download mode)
303
+
304
+ One of the following is required (mutually exclusive):
305
+
306
+ | Argument | Description |
307
+ |----------|-------------|
308
+ | `--meeting NAME` | Meeting title as it appears in Teams search results |
309
+ | `--meeting-id UUID` | Meeting UUID from `--list-transcripts` ID column. Identifies a meeting thread. For non-recurring meetings this is unique; for recurring meetings all occurrences share the same UUID. Use `--call-id` instead for unambiguous recurring-meeting targeting. Incompatible with `--meeting`, `--call-id`, and `--datetime`. |
310
+ | `--call-id CALL_ID` | Call identifier from `--list-transcripts` Call ID column. Uniquely identifies a specific occurrence of a meeting, including recurring meetings. The tool resolves the call ID to a meeting subject, date, and thread via the MCPS API. Incompatible with `--meeting`, `--meeting-id`, and `--datetime`. |
311
+
312
+ ### Optional arguments
313
+
314
+ | Argument | Default | Description |
315
+ |----------|---------|-------------|
316
+ | `--datetime YYYY-MM-DD[THH:MM]` | today | Target meeting date, optionally with time. Defaults to today's date when omitted. Date only selects the first occurrence on that date. Adding a time (e.g. `2026-04-22T14:00`) disambiguates when there are multiple meetings with the same name on the same day. Incompatible with `--meeting-id` and `--call-id` (date is resolved automatically). |
317
+ | `--output PATH` | stdout | Output file path. When omitted, content is written to stdout. File format is determined by `--format`. |
318
+ | `--format {vtt,json}` | `vtt` | Output format. `vtt` writes WebVTT content. `json` writes structured JSON with a `transcript` array of cue objects. Both write to `--output` (file) or stdout. |
319
+ | `--dry-run` | off | Find the meeting, load Recap only when transcript or metadata was requested, detect parts, extract safe roster metadata when metadata or chat was requested, then exit without downloading transcripts, extracting chat, or opening Details tracking. Incompatible with `--output`. |
320
+ | `--quiet` / `-q` | off | Suppress all diagnostic output on stderr. Errors and `--format json` output are still emitted. |
321
+ | `--verbose` / `-v` | off | Emit additional diagnostic detail on stderr. Mutually exclusive with `--quiet`. |
322
+ | `--gcp-project PROJECT_ID` | `$GOOGLE_CLOUD_PROJECT` | GCP project for Vertex AI |
323
+ | `--port PORT` | `9333` | CDP remote debugging port. Must be between 1 and 65535. |
324
+ | `--force-restart` | off | Override the live-call safeguard for any restart required by this invocation. May interrupt an active call; use only when interruption is explicitly intended. |
325
+ | `--gcp-region REGION` | `$CLOUD_ML_REGION` or `global` | Vertex AI region |
326
+ | `--model MODEL_ID` | `$MODEL_ID` or `claude-haiku-4-5` | Anthropic model for the agent |
327
+ | `--adc PATH` | `$GOOGLE_ADC` or `~/.config/gcloud/...` | Path to ADC JSON file |
328
+ | `--tenant DOMAIN` | `$TEAMS_TENANT` | Azure AD tenant domain to verify (e.g. `abccorp.com`). When specified, the tool checks that Teams is on the correct tenant and automatically switches if needed. Exits with code 6 if the tenant is not in the available list. |
329
+ | `--list-tenants` | off | List all available Azure AD tenants and exit. Incompatible with `--meeting`, `--meeting-id`, `--call-id`, and `--datetime`. See [Tenant selection](#tenant-selection) below. |
330
+ | `--list-transcripts [DAYS]` | off | List meetings with transcripts for the last N days (default: 1 = today, must be `>= 1`) and exit. Incompatible with `--meeting`, `--meeting-id`, `--call-id`, and `--datetime`. Without `--tenant`, aggregates across all signed-in tenants. See [List meetings with transcripts](#list-meetings-with-transcripts) below. |
331
+ | `--transcript {yes,no}` | `yes` | Include transcript cues in output. When `no`, the transcript download is skipped and output is forced to JSON. |
332
+ | `--chat {yes,no}` | `yes` | Include chat messages in output. When `no`, chat messages are omitted. If `--metadata yes`, the tool may still read Event/Call messages internally to build attendee metadata. |
333
+ | `--metadata {yes,no}` | `yes` | Include meeting participant metadata in output. When `no`, participant data (organiser, invited, speakers, attendees, RSVP) is omitted from the output. Roster extraction still runs if `--chat yes` (for author name resolution). |
334
+
335
+ ### Environment variables
336
+
337
+ All optional arguments can be set via environment variables as fallbacks:
338
+
339
+ | Variable | Used by |
340
+ |----------|---------|
341
+ | `GOOGLE_CLOUD_PROJECT` | `--gcp-project` (required if flag not provided) |
342
+ | `CLOUD_ML_REGION` | `--gcp-region` |
343
+ | `MODEL_ID` | `--model` |
344
+ | `GOOGLE_ADC` | `--adc` |
345
+ | `TEAMS_TENANT` | `--tenant` |
346
+
347
+ ### Examples
348
+
349
+ Download a single-occurrence meeting transcript:
350
+
351
+ ```sh
352
+ uv run teams-transcripts \
353
+ --meeting 'Project Kickoff' \
354
+ --datetime 2026-04-20 \
355
+ --output ./transcripts/kickoff.vtt \
356
+ --gcp-project my-vertex-project
357
+ ```
358
+
359
+ Download a recurring meeting (the script selects the correct occurrence) based upon the datetime:
360
+
361
+ ```sh
362
+ uv run teams-transcripts \
363
+ --meeting 'Weekly Standup' \
364
+ --datetime 2026-04-14 \
365
+ --output ./transcripts/standup-2026-04-14.vtt
366
+ ```
367
+
368
+ Disambiguate two meetings with the same name on the same day by adding a time:
369
+
370
+ ```sh
371
+ uv run teams-transcripts \
372
+ --meeting 'Weekly Standup' \
373
+ --datetime 2026-04-14T14:00 \
374
+ --output ./transcripts/standup-afternoon.vtt
375
+ ```
376
+
377
+ Pipe VTT to stdout (no file written):
378
+
379
+ ```sh
380
+ uv run teams-transcripts \
381
+ --meeting 'Project Kickoff' \
382
+ --datetime 2026-04-20 | less
383
+ ```
384
+
385
+ Download by meeting ID (from `--list-transcripts` output):
386
+
387
+ ```sh
388
+ uv run teams-transcripts \
389
+ --meeting-id 2b4c1f5c-636d-4d78-a087-c2cbf3887fd8 \
390
+ --output ./transcripts/meeting.vtt
391
+ ```
392
+
393
+ This eliminates ambiguity when multiple meetings share the same name. The UUID
394
+ is shown in the `ID` column of `--list-transcripts` table output. The meeting
395
+ date is resolved automatically from the UUID via the MCPS API.
396
+
397
+ Download a specific occurrence of a recurring meeting by call ID:
398
+
399
+ ```sh
400
+ uv run teams-transcripts \
401
+ --call-id aae0280e-5b7e-473d-8a4b-99de3cf74a08 \
402
+ --output ./transcripts/standup-2026-04-29.vtt
403
+ ```
404
+
405
+ The call ID is shown in the `Call ID` column of `--list-transcripts` table
406
+ output. Unlike the meeting UUID (which is shared across all occurrences of a
407
+ recurring series), the call ID uniquely identifies a specific occurrence. This
408
+ is the preferred method for recurring meetings.
409
+
410
+ Get structured JSON output to a file (nothing on stdout):
411
+
412
+ ```sh
413
+ uv run teams-transcripts \
414
+ --meeting 'Project Kickoff' \
415
+ --datetime 2026-04-20 \
416
+ --output ./kickoff.json --format json
417
+ ```
418
+
419
+ The JSON file includes the full structured transcript as a `"transcript"` array
420
+ of cue objects:
421
+
422
+ ```json
423
+ {
424
+ "status": "ok",
425
+ "meeting": "Project Kickoff",
426
+ "start": "2026-04-20T10:00+01:00",
427
+ "end": "2026-04-20T11:00+01:00",
428
+ "organiser": {"name": "Alice", "email": "alice@example.com"},
429
+ "invited": [
430
+ {"name": "Alice", "email": "alice@example.com", "rsvp_status": "organizer"},
431
+ {"name": "Bob", "email": "bob@example.com", "rsvp_status": "accepted"},
432
+ {"name": "Charlie", "email": "charlie@example.com", "rsvp_status": "tentative"}
433
+ ],
434
+ "speakers_list": [
435
+ {"name": "Alice", "email": "alice@example.com"},
436
+ {"name": "Bob", "email": "bob@example.com"}
437
+ ],
438
+ "attendees": [
439
+ {"name": "Alice", "email": "alice@example.com"},
440
+ {"name": "Bob", "email": "bob@example.com"},
441
+ {"name": "Charlie", "email": "charlie@example.com"}
442
+ ],
443
+ "transcript": [
444
+ {
445
+ "id": 1, "start": "00:00:03.000", "end": "00:00:25.000",
446
+ "start_sec": 3.0, "end_sec": 25.0,
447
+ "speaker": "Alice",
448
+ "text": "So I do have this concept of account sets."
449
+ }
450
+ ],
451
+ "chat": [
452
+ {
453
+ "author": "Bob",
454
+ "text": "Here is the link: https://example.com/doc",
455
+ "reactions": [{"type": "like", "count": 2, "users": ["Alice", "Charlie"]}],
456
+ "offset": "00:05:12.000"
457
+ }
458
+ ],
459
+ "parts": 1,
460
+ "cues": 342,
461
+ "speakers": {"Alice": 180, "Bob": 162},
462
+ "file_size": 48210,
463
+ "chat_messages": 2
464
+ }
465
+ ```
466
+
467
+ Get structured JSON on stdout (agent-friendly, no file):
468
+
469
+ ```sh
470
+ uv run teams-transcripts \
471
+ --meeting 'Project Kickoff' \
472
+ --datetime 2026-04-20 --format json
473
+ ```
474
+
475
+ The same schema is written to stdout. With `--output`, the JSON is written to
476
+ the file and stdout is empty.
477
+
478
+ Dry-run to check a meeting exists without downloading:
479
+
480
+ ```sh
481
+ uv run teams-transcripts \
482
+ --meeting 'Weekly Standup' \
483
+ --datetime 2026-04-14 --dry-run --format json
484
+ ```
485
+
486
+ Quiet mode for cron or agent use (suppress diagnostic logs):
487
+
488
+ ```sh
489
+ uv run teams-transcripts \
490
+ --meeting 'Weekly Standup' \
491
+ --datetime 2026-04-14 \
492
+ --output ./standup.vtt -q
493
+ ```
494
+
495
+ Use a specific model and region:
496
+
497
+ ```sh
498
+ uv run teams-transcripts \
499
+ --meeting 'Quarterly Review' \
500
+ --datetime 2026-03-31 \
501
+ --output ./review.vtt \
502
+ --model claude-sonnet-4-20250514 \
503
+ --gcp-region us-east5
504
+ ```
505
+
506
+ ### Tenant selection
507
+
508
+ When your Microsoft account has access to multiple Azure AD tenants
509
+ (organisations), you can list them and target a specific one:
510
+
511
+ ```sh
512
+ # List all available tenants
513
+ uv run teams-transcripts --list-tenants
514
+ ```
515
+
516
+ Text output:
517
+
518
+ ```
519
+ Available tenants (* = current):
520
+
521
+ NAME DOMAIN TENANT ID
522
+ * ABC Corp abccorp.onmicrosoft.com 86cbe1bb-213f-4271-b174-bd59d03c87a3
523
+ Contoso Ltd contoso.onmicrosoft.com 2b755fa1-23d1-48f3-98fc-6fdc1dc48d69
524
+
525
+ Use --tenant NAME_OR_DOMAIN to select a tenant for download.
526
+ ```
527
+
528
+ JSON output:
529
+
530
+ ```sh
531
+ uv run teams-transcripts --list-tenants --format json
532
+ ```
533
+
534
+ ```json
535
+ [
536
+ {
537
+ "tenantId": "86cbe1bb-213f-4271-b174-bd59d03c87a3",
538
+ "tenantName": "ABC Corp",
539
+ "domain": "abccorp.onmicrosoft.com",
540
+ "current": true
541
+ },
542
+ {
543
+ "tenantId": "2b755fa1-23d1-48f3-98fc-6fdc1dc48d69",
544
+ "tenantName": "Contoso Ltd",
545
+ "domain": "contoso.onmicrosoft.com",
546
+ "current": false
547
+ }
548
+ ]
549
+ ```
550
+
551
+ Write the tenant list to a file:
552
+
553
+ ```sh
554
+ uv run teams-transcripts --list-tenants --output tenants.json --format json
555
+ ```
556
+
557
+ Download a transcript from a specific tenant (auto-switches if needed):
558
+
559
+ ```sh
560
+ uv run teams-transcripts \
561
+ --meeting 'Weekly Standup' \
562
+ --datetime 2026-04-14 \
563
+ --output ./standup.vtt \
564
+ --tenant contoso.onmicrosoft.com
565
+ ```
566
+
567
+ When `--tenant` is specified, the tool:
568
+
569
+ 1. Detects which tenant Teams is currently signed into
570
+ 2. Lists all available tenants for the signed-in user
571
+ 3. If the current tenant matches, proceeds normally
572
+ 4. If the current tenant does not match but the requested tenant is in the
573
+ list, kills Teams and relaunches it on the correct tenant automatically
574
+ 5. If the requested tenant is not in the available list at all, exits with
575
+ code 6 (`TENANT_NOT_FOUND`)
576
+
577
+ The `--tenant` value is matched case-insensitively against both the tenant
578
+ name and the primary domain name from the tenant list.
579
+
580
+ ### Component selection
581
+
582
+ By default all three components are extracted: transcript, chat, and metadata.
583
+ Use the `--transcript`, `--chat`, and `--metadata` flags to select which
584
+ components to include. At least one must be `yes`, except with `--dry-run`,
585
+ where all three may be `no` for a pure meeting-exists check.
586
+
587
+ Extract only chat messages (no transcript download, faster):
588
+
589
+ ```sh
590
+ uv run teams-transcripts \
591
+ --meeting 'Weekly Standup' \
592
+ --datetime 2026-04-14 \
593
+ --transcript no --metadata no --format json
594
+ ```
595
+
596
+ Extract only participant metadata:
597
+
598
+ ```sh
599
+ uv run teams-transcripts \
600
+ --meeting 'Weekly Standup' \
601
+ --datetime 2026-04-14 \
602
+ --transcript no --chat no --format json
603
+ ```
604
+
605
+ Extract chat and metadata without the transcript:
606
+
607
+ ```sh
608
+ uv run teams-transcripts \
609
+ --meeting 'Weekly Standup' \
610
+ --datetime 2026-04-14 \
611
+ --transcript no --format json
612
+ ```
613
+
614
+ When `--transcript no` is set, the output format is forced to JSON (VTT without
615
+ transcript cues is meaningless). Explicitly passing `--format vtt` alongside
616
+ `--transcript no` is an error.
617
+
618
+ When `--chat no --metadata yes` is set, chat messages are omitted from output,
619
+ but Event/Call messages may still be read internally to build attendee metadata.
620
+
621
+ The JSON output is composable: each component adds only its own fields. When
622
+ all three are `yes` (the default), the output is identical to the standard
623
+ full-extraction JSON schema.
624
+
625
+ ## MCP server
626
+
627
+ The package includes a [Model Context Protocol](https://modelcontextprotocol.io/)
628
+ (MCP) server that exposes the extraction tools over STDIO. This allows any
629
+ MCP-compatible client (Claude Desktop, Cursor, OpenCode, etc.) to call the
630
+ tools directly.
631
+
632
+ ### Running the MCP server
633
+
634
+ ```sh
635
+ # STDIO transport (default, for subprocess spawning by MCP clients)
636
+ teams-transcripts-mcp
637
+
638
+ # SSE transport on a specific port (for network MCP clients)
639
+ teams-transcripts-mcp --port 8080
640
+ ```
641
+
642
+ Or with `uv run`:
643
+
644
+ ```sh
645
+ uv run teams-transcripts-mcp
646
+ uv run teams-transcripts-mcp --port 8080
647
+ ```
648
+
649
+ Or via the Python module:
650
+
651
+ ```sh
652
+ uv run python -m teams_transcripts.mcp_server
653
+ uv run python -m teams_transcripts.mcp_server --port 8080
654
+ ```
655
+
656
+ When `--port` is omitted, the server communicates over STDIO (the standard
657
+ MCP transport for local subprocess spawning). When `--port` is specified,
658
+ the server runs as an SSE HTTP server on that port, accessible over the
659
+ network.
660
+
661
+ The server inherits environment variables for GCP configuration
662
+ (see [Environment variables](#environment-variables)).
663
+
664
+ ### Available tools
665
+
666
+ | Tool | Description |
667
+ |------|-------------|
668
+ | `restart_teams` | Safely relaunch Teams with CDP flags and wait for readiness |
669
+ | `list_tenants` | List available Azure AD tenants |
670
+ | `list_transcripts` | Discover meetings with transcripts (last N days) |
671
+ | `download` | **Unified entry point** -- accepts UUID or name+date, validates args |
672
+ | `download_by_meeting_id` | Download transcript + chat + metadata by UUID |
673
+ | `download_transcript_by_meeting_id` | Download only transcript by UUID |
674
+ | `download_chat_by_meeting_id` | Download only chat messages by UUID |
675
+ | `download_metadata_by_meeting_id` | Download only participant metadata by UUID |
676
+ | `download_all` | Download transcript + chat + metadata by name + date |
677
+ | `download_transcript` | Download only the transcript (WebVTT cues) by name + date |
678
+ | `download_chat` | Download only chat messages by name + date |
679
+ | `download_meeting_metadata` | Download only participant metadata by name + date |
680
+
681
+ **Discovery tools** (`list_tenants`, `list_transcripts`) accept an optional
682
+ `port` parameter and return JSON arrays.
683
+
684
+ **Unified download tool** (`download`) is the recommended entry point for
685
+ AI agents. It accepts either `meeting_id` (preferred) or `meeting` + `date`,
686
+ validates argument combinations before launching the subprocess, and supports
687
+ `dry_run=True` to verify a meeting can be found without transcript download,
688
+ output chat extraction, Event/Call attendance extraction, or Details tracking
689
+ extraction. In dry-run mode, all three component flags may be `False` for a
690
+ pure meeting-exists check.
691
+
692
+ **UUID-based download tools** (`download_by_meeting_id`, etc.) accept a
693
+ `meeting_id` (UUID from `list_transcripts` output), optional `tenant`,
694
+ `port`, and `dry_run`. The meeting subject and date are resolved
695
+ automatically from the UUID.
696
+
697
+ **Name-based download tools** (`download_all`, etc.) accept `meeting`
698
+ (required), `date` (required), and optional `time`, `tenant`, `port`, and
699
+ `dry_run` parameters.
700
+
701
+ #### Recommended workflow
702
+
703
+ ```
704
+ 1. list_transcripts(days=7) → discover meetings, get UUIDs
705
+ 2. download(meeting_id=uuid) → extract transcript by UUID (preferred)
706
+ OR download(meeting_id=uuid, dry_run=True) → verify meeting exists first
707
+ ```
708
+
709
+ Or for a known meeting:
710
+
711
+ ```
712
+ 1. download(meeting="Weekly Standup", date="2026-04-29")
713
+ ```
714
+
715
+ ### Configuration for Claude Desktop
716
+
717
+ Add to your `claude_desktop_config.json`:
718
+
719
+ ```json
720
+ {
721
+ "mcpServers": {
722
+ "teams-transcripts": {
723
+ "command": "/path/to/teams-transcripts/bin/teams-transcripts-mcp",
724
+ "env": {
725
+ "GOOGLE_CLOUD_PROJECT": "your-gcp-project"
726
+ }
727
+ }
728
+ }
729
+ }
730
+ ```
731
+
732
+ Or using `uv run`:
733
+
734
+ ```json
735
+ {
736
+ "mcpServers": {
737
+ "teams-transcripts": {
738
+ "command": "uv",
739
+ "args": ["run", "--directory", "/path/to/teams-transcripts", "teams-transcripts-mcp"],
740
+ "env": {
741
+ "GOOGLE_CLOUD_PROJECT": "your-gcp-project"
742
+ }
743
+ }
744
+ }
745
+ }
746
+ ```
747
+
748
+ ### Architecture
749
+
750
+ Each MCP tool shells out to the `teams-transcripts` CLI with `--format json
751
+ --quiet` and returns the parsed JSON response. The subprocess approach provides:
752
+
753
+ - Process isolation (Teams CDP locks are per-process)
754
+ - Clean signal handling (the child manages its own SIGINT/SIGTERM)
755
+ - No import-time side effects in the MCP server process
756
+
757
+ Discovery tools (`list_tenants`, `list_transcripts`) return JSON arrays.
758
+ Download tools return a JSON object with the extraction results. UUID-based
759
+ tools use `--meeting-id` internally; name-based tools use `--meeting` +
760
+ `--datetime`.
761
+
762
+ ### Safe Teams restarts
763
+
764
+ Every automatic Teams restart checks for a possible live call immediately
765
+ before killing the app. The check samples CoreAudio activity for the Teams app
766
+ and its helper processes three times, then checks the macOS Accessibility tree
767
+ for a visible Leave or Hang-up control. The result is `active`, `inactive`, or
768
+ `unknown`; both `active` and `unknown` block automatic CDP recovery and tenant
769
+ switching.
770
+
771
+ Blocked CLI flows exit with code 2 and the machine-stable `RESTART_BLOCKED`
772
+ error. MCP callers receive the same error through download/listing tools. The
773
+ `restart_teams` MCP tool instead returns a structured result containing
774
+ `status`, `restarted`, `cdp_ready`, `port`, `call_state`, and `forced`. A block
775
+ also includes `reason: active_call` or `reason: call_state_unknown`.
776
+
777
+ The CLI flag `--force-restart` overrides the safeguard for any automatic CDP
778
+ recovery or tenant switch required by that invocation. The MCP equivalent is
779
+ `restart_teams(force=True)`. Both may interrupt a live call and must be used
780
+ only when interruption is explicitly intended. Without an override, downloads,
781
+ listing, tenant switching, and recovery continue to fail closed.
782
+
783
+ ## Output format
784
+
785
+ ### VTT file structure
786
+
787
+ The output is a standard [WebVTT](https://www.w3.org/TR/webvtt1/) file
788
+ containing three sections:
789
+
790
+ **1. Meeting metadata** -- a `NOTE Meeting Metadata` comment block at the top:
791
+
792
+ ```
793
+ WEBVTT
794
+
795
+ NOTE Meeting Metadata
796
+ Meeting: Weekly Standup
797
+ Organiser: Alice <alice@example.com>
798
+ Invited: Alice, Bob, Charlie
799
+ Speakers: Alice, Bob
800
+ Attendees: Alice, Bob, Charlie
801
+ Start: 2026-04-14T10:00+01:00
802
+ End: 2026-04-14T10:30+01:00
803
+ ```
804
+
805
+ For larger meetings (more than 10 invited or attendees), counts are shown
806
+ instead of names, with an RSVP breakdown when tracking data is available:
807
+
808
+ ```
809
+ NOTE Meeting Metadata
810
+ Meeting: Q1 2026 All Hands
811
+ Organiser: Suman Landa <suman.landa@example.com>
812
+ Invited: 90 members (50 accepted, 5 tentative, 2 declined, 33 no response)
813
+ Speakers: Alice, Bob, Charlie, Dave, Eve, Fran
814
+ Attendees: 77 participants
815
+ Start: 2026-04-01T09:00+01:00
816
+ End: 2026-04-01T10:30+01:00
817
+ ```
818
+
819
+ Fields:
820
+
821
+ - **Meeting**: meeting title.
822
+ - **Organiser**: meeting creator with email in `Name <email>` format.
823
+ - **Invited**: all members on the meeting chat thread. Listed by name
824
+ when 10 or fewer; shown as a count with RSVP summary when more than 10.
825
+ RSVP statuses are: accepted, tentative, declined, no response.
826
+ - **Speakers**: members who spoke during the meeting (from Recap).
827
+ - **Attendees**: members who joined the call (from Event/Call partlist).
828
+ May be incomplete for large meetings due to server-side truncation.
829
+ - **Start** / **End**: ISO 8601 timestamps with timezone offset.
830
+
831
+ **2. Chat messages** -- a `NOTE Chat` comment block containing a JSON array of
832
+ messages sent during the meeting:
833
+
834
+ ```
835
+ NOTE Chat
836
+ [
837
+ {
838
+ "author": "Bob",
839
+ "text": "Link to the design doc: https://example.com/doc",
840
+ "reactions": [
841
+ {"type": "like", "count": 2, "users": ["Alice", "Charlie"]}
842
+ ],
843
+ "offset": "00:12:34.567"
844
+ }
845
+ ]
846
+ ```
847
+
848
+ Each message includes: author name, plain text (HTML stripped, links
849
+ preserved), reactions (with per-user attribution where resolvable), and an
850
+ `offset` from meeting start in `HH:MM:SS.mmm` format where computable. The
851
+ `offset` field is `null` when it cannot be computed. The `reactions` field is
852
+ `null` when there are no reactions.
853
+
854
+ **3. Transcript cues** -- standard WebVTT cues with speaker identification:
855
+
856
+ ```
857
+ 1
858
+ 00:00:03.000 --> 00:00:25.000
859
+ <v Alice>So I do have this concept of account sets. It's called
860
+ platform account sets in the code base, which basically...</v>
861
+
862
+ 2
863
+ 00:00:27.000 --> 00:00:48.000
864
+ <v Bob>Looks something like this. For example, this is our dev
865
+ accounts.</v>
866
+ ```
867
+
868
+ Each cue contains a sequential number, start and end timestamps in
869
+ `HH:MM:SS.mmm` format, the speaker name in a `<v Name>` voice span, and the
870
+ spoken text.
871
+
872
+ For multi-part meetings (where Teams splits the recording), cue timestamps are
873
+ offset so that Part 2 continues from where Part 1 ended, producing a single
874
+ continuous timeline.
875
+
876
+ ### JSON output schemas
877
+
878
+ When `--format json` is used, a single JSON object is written to stdout (when
879
+ `--output` is omitted) or to the specified file (when `--output` is given).
880
+ In file mode, nothing is written to stdout.
881
+
882
+ **Success (all components)**:
883
+
884
+ ```json
885
+ {
886
+ "status": "ok",
887
+ "meeting": "Weekly Standup",
888
+ "start": "2026-04-14T10:00+01:00",
889
+ "end": "2026-04-14T10:30+01:00",
890
+ "organiser": {"name": "Alice", "email": "alice@example.com"},
891
+ "invited": [
892
+ {"name": "Alice", "email": "alice@example.com", "rsvp_status": "organizer"},
893
+ {"name": "Bob", "email": "bob@example.com", "rsvp_status": "accepted"},
894
+ {"name": "Charlie", "email": "charlie@example.com", "rsvp_status": "declined"}
895
+ ],
896
+ "speakers_list": [
897
+ {"name": "Alice", "email": "alice@example.com"},
898
+ {"name": "Bob", "email": "bob@example.com"}
899
+ ],
900
+ "attendees": [
901
+ {"name": "Alice", "email": "alice@example.com"},
902
+ {"name": "Bob", "email": "bob@example.com"},
903
+ {"name": "Charlie", "email": "charlie@example.com"}
904
+ ],
905
+ "chat": [
906
+ {
907
+ "author": "Bob",
908
+ "text": "Here is the link: https://example.com/doc",
909
+ "reactions": [{"type": "like", "count": 2, "users": ["Alice", "Charlie"]}],
910
+ "offset": "00:05:12.000"
911
+ },
912
+ {
913
+ "author": "Alice",
914
+ "text": "Thanks Bob",
915
+ "reactions": null,
916
+ "offset": "00:05:45.000"
917
+ }
918
+ ],
919
+ "transcript": [
920
+ {
921
+ "id": 1, "start": "00:00:03.000", "end": "00:00:25.000",
922
+ "start_sec": 3.0, "end_sec": 25.0,
923
+ "speaker": "Alice",
924
+ "text": "So I do have this concept of account sets."
925
+ },
926
+ {
927
+ "id": 2, "start": "00:00:27.000", "end": "00:00:48.000",
928
+ "start_sec": 27.0, "end_sec": 48.0,
929
+ "speaker": "Bob",
930
+ "text": "Looks something like this."
931
+ }
932
+ ],
933
+ "parts": 1,
934
+ "cues": 342,
935
+ "speakers": {"Alice": 180, "Bob": 162},
936
+ "file_size": 48210,
937
+ "chat_messages": 2
938
+ }
939
+ ```
940
+
941
+ **Dry-run** (`--dry-run --format json`):
942
+
943
+ ```json
944
+ {
945
+ "status": "ok",
946
+ "dry_run": true,
947
+ "meeting": "Weekly Standup",
948
+ "start": "2026-04-14T10:00+01:00",
949
+ "end": "2026-04-14T10:30+01:00",
950
+ "organiser": {"name": "Alice", "email": "alice@example.com"},
951
+ "invited": [
952
+ {"name": "Alice", "email": "alice@example.com"},
953
+ {"name": "Bob", "email": "bob@example.com"},
954
+ {"name": "Charlie", "email": "charlie@example.com"}
955
+ ],
956
+ "speakers_list": [
957
+ {"name": "Alice", "email": "alice@example.com"},
958
+ {"name": "Bob", "email": "bob@example.com"}
959
+ ],
960
+ "parts": 1,
961
+ "speakers": {"Alice": 0, "Bob": 0}
962
+ }
963
+ ```
964
+
965
+ Speaker counts are zero in dry-run mode (no transcript downloaded). The
966
+ `"cues"`, `"file_size"`, `"transcript"`, and `"chat_messages"` keys are
967
+ absent. `"attendees"` is absent in dry-run because Event/Call partlists are
968
+ only available after chat extraction, which dry-run skips.
969
+
970
+ **Error** (any non-zero exit):
971
+
972
+ ```json
973
+ {
974
+ "status": "error",
975
+ "code": "MEETING_NOT_FOUND",
976
+ "message": "Could not find the target 2026-04-14 in any of the first 4 search results.",
977
+ "step": "step5"
978
+ }
979
+ ```
980
+
981
+ The `"code"` field is a machine-stable error code (see `--help` for the full
982
+ list). The `"step"` field indicates which pipeline step failed (absent for
983
+ argument validation errors).
984
+
985
+ **Conditional keys**: `"end"` is absent if the meeting time text could not be
986
+ parsed. `"organiser"` is absent if the organiser could not be identified.
987
+ `"invited"` is absent if roster extraction failed entirely (popover
988
+ unavailable). `"speakers_list"` is absent if no speakers were found.
989
+ `"attendees"` is absent if no Event/Call partlist was available (e.g.
990
+ dry-run mode, or the meeting had no call join/leave events).
991
+ `"chat_messages"` is absent (not zero) when there are no chat messages.
992
+ `"rsvp_status"` is included on each invited member only when RSVP tracking
993
+ data was available; it is omitted (not empty) when the Details tab Tracking
994
+ panel could not be read.
995
+
996
+ ### Component-specific JSON schemas
997
+
998
+ The JSON output is composable. Each component (`--transcript`, `--chat`,
999
+ `--metadata`) contributes only its own fields. Base fields (`status`,
1000
+ `meeting`, `start`, `end`) are always present regardless of which components
1001
+ are selected.
1002
+
1003
+ **Transcript only** (`--transcript yes --chat no --metadata no`):
1004
+
1005
+ ```json
1006
+ {
1007
+ "status": "ok",
1008
+ "meeting": "Weekly Standup",
1009
+ "start": "2026-04-14T10:00+01:00",
1010
+ "end": "2026-04-14T10:30+01:00",
1011
+ "parts": 1,
1012
+ "cues": 342,
1013
+ "speakers": {"Alice": 180, "Bob": 162},
1014
+ "file_size": 48210,
1015
+ "transcript": [
1016
+ {
1017
+ "id": 1, "start": "00:00:03.000", "end": "00:00:25.000",
1018
+ "start_sec": 3.0, "end_sec": 25.0,
1019
+ "speaker": "Alice",
1020
+ "text": "So I do have this concept of account sets."
1021
+ }
1022
+ ]
1023
+ }
1024
+ ```
1025
+
1026
+ Transcript-specific fields: `parts`, `cues`, `speakers` (name-to-cue-count
1027
+ map), `file_size`, and `transcript` (array of cue objects).
1028
+
1029
+ **Chat only** (`--transcript no --chat yes --metadata no`):
1030
+
1031
+ ```json
1032
+ {
1033
+ "status": "ok",
1034
+ "meeting": "Weekly Standup",
1035
+ "start": "2026-04-14T10:00+01:00",
1036
+ "end": "2026-04-14T10:30+01:00",
1037
+ "chat": [
1038
+ {
1039
+ "author": "Bob",
1040
+ "text": "Here is the link: https://example.com/doc",
1041
+ "reactions": [{"type": "like", "count": 2, "users": ["Alice", "Charlie"]}],
1042
+ "offset": "00:05:12.000"
1043
+ },
1044
+ {
1045
+ "author": "Alice",
1046
+ "text": "Thanks Bob",
1047
+ "reactions": null,
1048
+ "offset": "00:05:45.000"
1049
+ }
1050
+ ]
1051
+ }
1052
+ ```
1053
+
1054
+ Chat-specific fields: `chat` (array of message objects). Each message has:
1055
+ - `author` (string): display name of the sender
1056
+ - `text` (string): plain text content (HTML stripped, links preserved)
1057
+ - `reactions` (array or null): list of reaction objects, each with `type`,
1058
+ `count`, and `users` (array of display names)
1059
+ - `offset` (string or null): time offset from meeting start in `HH:MM:SS.mmm`
1060
+ format when computable
1061
+
1062
+ **Metadata only** (`--transcript no --chat no --metadata yes`):
1063
+
1064
+ ```json
1065
+ {
1066
+ "status": "ok",
1067
+ "meeting": "Weekly Standup",
1068
+ "start": "2026-04-14T10:00+01:00",
1069
+ "end": "2026-04-14T10:30+01:00",
1070
+ "organiser": {"name": "Alice", "email": "alice@example.com"},
1071
+ "invited": [
1072
+ {"name": "Alice", "email": "alice@example.com", "rsvp_status": "organizer"},
1073
+ {"name": "Bob", "email": "bob@example.com", "rsvp_status": "accepted"},
1074
+ {"name": "Charlie", "email": "charlie@example.com", "rsvp_status": "tentative"}
1075
+ ],
1076
+ "speakers_list": [
1077
+ {"name": "Alice", "email": "alice@example.com"},
1078
+ {"name": "Bob", "email": "bob@example.com"}
1079
+ ],
1080
+ "attendees": [
1081
+ {"name": "Alice", "email": "alice@example.com"},
1082
+ {"name": "Bob", "email": "bob@example.com"},
1083
+ {"name": "Charlie", "email": "charlie@example.com"}
1084
+ ]
1085
+ }
1086
+ ```
1087
+
1088
+ Metadata-specific fields:
1089
+ - `organiser` (object): `{name, email}` of the meeting creator
1090
+ - `invited` (array): members on the chat thread, each with `name`, `email`,
1091
+ and optionally `rsvp_status` (one of: `organizer`, `accepted`, `tentative`,
1092
+ `declined`, `none`)
1093
+ - `speakers_list` (array): members who spoke, each with `name` and `email`
1094
+ - `attendees` (array): members who joined the call, each with `name` and
1095
+ `email`
1096
+
1097
+ **Chat + metadata** (`--transcript no --chat yes --metadata yes`):
1098
+
1099
+ ```json
1100
+ {
1101
+ "status": "ok",
1102
+ "meeting": "Weekly Standup",
1103
+ "start": "2026-04-14T10:00+01:00",
1104
+ "end": "2026-04-14T10:30+01:00",
1105
+ "organiser": {"name": "Alice", "email": "alice@example.com"},
1106
+ "invited": [
1107
+ {"name": "Alice", "email": "alice@example.com", "rsvp_status": "accepted"},
1108
+ {"name": "Bob", "email": "bob@example.com", "rsvp_status": "accepted"}
1109
+ ],
1110
+ "speakers_list": [
1111
+ {"name": "Alice", "email": "alice@example.com"},
1112
+ {"name": "Bob", "email": "bob@example.com"}
1113
+ ],
1114
+ "attendees": [
1115
+ {"name": "Alice", "email": "alice@example.com"},
1116
+ {"name": "Bob", "email": "bob@example.com"}
1117
+ ],
1118
+ "chat": [
1119
+ {
1120
+ "author": "Bob",
1121
+ "text": "Starting now",
1122
+ "reactions": null,
1123
+ "offset": "00:00:05.000"
1124
+ }
1125
+ ]
1126
+ }
1127
+ ```
1128
+
1129
+ **List tenants** (`--list-tenants --format json`):
1130
+
1131
+ ```json
1132
+ [
1133
+ {
1134
+ "tenantId": "86cbe1bb-213f-4271-b174-bd59d03c87a3",
1135
+ "tenantName": "ABC Corp",
1136
+ "domain": "abccorp.onmicrosoft.com",
1137
+ "current": true
1138
+ },
1139
+ {
1140
+ "tenantId": "2b755fa1-23d1-48f3-98fc-6fdc1dc48d69",
1141
+ "tenantName": "Contoso Ltd",
1142
+ "domain": "contoso.onmicrosoft.com",
1143
+ "current": false
1144
+ }
1145
+ ]
1146
+ ```
1147
+
1148
+ ### JSON field reference
1149
+
1150
+ | Field | Type | Present when | Description |
1151
+ |-------|------|-------------|-------------|
1152
+ | `status` | string | always | `"ok"` on success, `"error"` on failure |
1153
+ | `meeting` | string | always (success) | Meeting title |
1154
+ | `start` | string | always (success) | ISO 8601 meeting start time |
1155
+ | `end` | string | when parseable | ISO 8601 meeting end time |
1156
+ | `dry_run` | boolean | `--dry-run` mode | Always `true` when present |
1157
+ | `organiser` | object | `--metadata yes` | `{name, email}` |
1158
+ | `invited` | array | `--metadata yes` | `[{name, email, rsvp_status?}]` |
1159
+ | `speakers_list` | array | `--metadata yes` | `[{name, email}]` |
1160
+ | `attendees` | array | `--metadata yes` + attendance data available | `[{name, email}]` |
1161
+ | `chat` | array | `--chat yes` + messages exist | `[{author, text, reactions, offset}]` |
1162
+ | `chat_messages` | integer | all-components mode | Count of chat messages |
1163
+ | `parts` | integer | `--transcript yes` | Number of transcript parts |
1164
+ | `cues` | integer | `--transcript yes` | Total cue count |
1165
+ | `speakers` | object | `--transcript yes` | `{name: cue_count}` map |
1166
+ | `file_size` | integer | `--transcript yes` | VTT content size in bytes |
1167
+ | `transcript` | array | `--transcript yes` | `[{id, start, end, start_sec, end_sec, speaker, text}]` |
1168
+ | `code` | string | error | Machine-stable error code |
1169
+ | `message` | string | error | Human-readable error description |
1170
+ | `step` | string | error (runtime) | Pipeline step that failed |
1171
+
1172
+ ## Recurring meetings
1173
+
1174
+ For recurring meetings, the script:
1175
+
1176
+ 1. Navigates to the meeting chat group
1177
+ 2. Opens the Recap tab and reads the occurrence dropdown
1178
+ 3. If the default occurrence does not match the target date, opens the
1179
+ dropdown and scrolls through available dates
1180
+ 4. Uses the LLM agent to identify and select the correct occurrence
1181
+
1182
+ Teams typically retains the most recent 5 or so occurrences in the dropdown.
1183
+ Older occurrences may no longer be available.
1184
+
1185
+ If the first search result leads to a chat group that does not contain the
1186
+ target date, the script automatically retries with the next search result
1187
+ (up to 4 attempts).
1188
+
1189
+ ## Search modes
1190
+
1191
+ Teams uses two different search UI patterns depending on the context:
1192
+
1193
+ - **Panel mode**: results appear in a side panel with text-based matching.
1194
+ The script scores results by word overlap with the meeting name.
1195
+ - **Popup mode**: results appear in a dropdown overlay. The script uses the
1196
+ LLM agent to select the correct result from a screenshot.
1197
+
1198
+ The mode is auto-detected; no configuration is needed.
1199
+
1200
+ ## Limitations
1201
+
1202
+ - **macOS only**: the script uses macOS-specific commands (`open -a`, `pkill`)
1203
+ to manage the Teams process.
1204
+ - **Occurrence dropdown depth**: Teams only retains recent occurrences (roughly
1205
+ the last 5). Older recurring meeting dates cannot be retrieved.
1206
+ - **Attendee list truncation**: the Event/Call `<partlist>` XML that Teams
1207
+ stores in chat messages is truncated server-side for large meetings. For
1208
+ example, a meeting declaring `count="77"` may only include 25 `<part>`
1209
+ elements. The attendee list in the output may therefore be incomplete.
1210
+ This is a Teams limitation with no client-side workaround.
1211
+ - **RSVP data is calendar responses, not actual attendance**: the tracking
1212
+ panel shows who accepted/declined/tentatively accepted the calendar
1213
+ invitation. It does not reflect who actually joined the call. Actual
1214
+ attendance is determined by the `<partlist>` (see above).
1215
+ - **RSVP extraction is soft-failure**: if the Details tab or Tracking panel
1216
+ is unavailable (e.g. the meeting type does not support tracking, or the
1217
+ UI layout changes), the pipeline continues without RSVP data. The
1218
+ `rsvp_status` field will be absent from JSON output in this case.
1219
+ - **Conference room accounts**: room/resource accounts appear with their
1220
+ system names (e.g. `UK-LON-1SB-05S19@oursystems.com`) as that is what
1221
+ Teams stores as the display name.
1222
+
1223
+ ## Exit codes
1224
+
1225
+ | Code | Name | Meaning |
1226
+ |------|------|---------|
1227
+ | 0 | `EXIT_OK` | Success |
1228
+ | 1 | `EXIT_BAD_ARGS` | Invalid arguments or missing configuration |
1229
+ | 2 | `EXIT_CDP_UNAVAILABLE` | Teams/CDP not running or not responding |
1230
+ | 3 | `EXIT_MEETING_NOT_FOUND` | Meeting not found after all retries |
1231
+ | 4 | `EXIT_DOWNLOAD_FAILED` | Transcript download or permission error |
1232
+ | 5 | `EXIT_VERIFICATION_FAILED` | Output file is invalid |
1233
+ | 6 | `EXIT_TENANT_NOT_FOUND` | Requested tenant not in available tenants list |
1234
+ | 130 | `EXIT_INTERRUPTED` | Interrupted by SIGINT (Ctrl+C) |
1235
+ | 143 | `EXIT_TERMINATED` | Terminated by SIGTERM |
1236
+
1237
+ ## Error codes
1238
+
1239
+ Each error includes a machine-stable code string used as a `[PREFIX]` on stderr
1240
+ and as the `"code"` field in JSON error output. These are stable identifiers
1241
+ suitable for programmatic matching.
1242
+
1243
+ | Code | Meaning |
1244
+ |------|---------|
1245
+ | `BAD_ARGS` | Invalid CLI arguments or missing configuration |
1246
+ | `CDP_UNAVAILABLE` | Teams/CDP not running or not responding |
1247
+ | `RESTART_BLOCKED` | Teams restart blocked by an active or unknown call state |
1248
+ | `NO_TARGETS` | No Teams page targets found via CDP |
1249
+ | `SEARCH_FAILED` | Search results did not appear in the UI |
1250
+ | `MEETING_NOT_FOUND` | Meeting not found in search results |
1251
+ | `RECAP_NOT_FOUND` | Recap tab not found after navigation |
1252
+ | `TRANSCRIPT_NOT_FOUND` | Transcript tab not found |
1253
+ | `IFRAME_NOT_FOUND` | No transcript iframe target among CDP targets |
1254
+ | `DOWNLOAD_FAILED` | VTT download failed |
1255
+ | `VERIFICATION_FAILED` | Output file content is invalid |
1256
+ | `TENANT_NOT_FOUND` | Requested tenant not in available tenants list |
1257
+ | `INTERRUPTED` | Process interrupted by SIGINT |
1258
+ | `TERMINATED` | Process terminated by SIGTERM |
1259
+
1260
+ ## Agent and automation usage
1261
+
1262
+ The CLI is designed for use by both humans and automated agents. Key features
1263
+ for machine consumption:
1264
+
1265
+ - **stdout/stderr separation**: all diagnostic output goes to stderr. stdout
1266
+ carries only data -- VTT content in text mode, or a JSON object in JSON mode.
1267
+ When `--output` is given, nothing is written to stdout (both formats).
1268
+ Use `--quiet` to suppress diagnostics entirely.
1269
+ - **`--format json`**: emits a single JSON object. On success, the object
1270
+ contains meeting metadata, speaker counts, cue count, and a `"transcript"`
1271
+ array of structured cue objects. On failure, the object contains
1272
+ `"status": "error"`, a machine-stable `"code"` string, and a human message.
1273
+ - **Semantic exit codes**: agents can branch on the exit code without parsing
1274
+ error messages. See the table above.
1275
+ - **Machine-stable error codes**: each error includes a short code prefix in
1276
+ text mode (e.g. `[MEETING_NOT_FOUND]`) and a `"code"` field in JSON mode.
1277
+ Codes are listed in `--help` under "error codes".
1278
+ - **`--dry-run`**: finds the meeting, loads Recap only when needed, extracts
1279
+ safe roster metadata when requested, and detects parts without downloading
1280
+ the transcript, extracting chat/attendance data, or opening Details tracking.
1281
+ Useful for validation before committing to a full download.
1282
+ - **Signal handling**: SIGINT and SIGTERM are handled cleanly. The first
1283
+ signal sets a shutdown flag and the process exits at the next safe point
1284
+ (within 500 ms). A second signal force-exits immediately. JSON error
1285
+ output is emitted on signal exit when `--format json` is active.
1286
+
1287
+ ### Output modes
1288
+
1289
+ | `--output` | `--format` | `--dry-run` | stdout | file |
1290
+ |------------|------------|-------------|--------|------|
1291
+ | PATH | vtt | no | (empty) | VTT |
1292
+ | PATH | json | no | (empty) | JSON |
1293
+ | omitted | vtt | no | raw VTT content | (none) |
1294
+ | omitted | json | no | structured JSON | (none) |
1295
+ | omitted | vtt | yes | (empty) | (none) |
1296
+ | omitted | json | yes | JSON metadata (no transcript) | (none) |
1297
+ | PATH | any | yes | **error** (incompatible) | -- |
1298
+ | `--list-tenants` | vtt | -- | tenant table | (none) |
1299
+ | `--list-tenants` | json | -- | tenant JSON array | (none) |
1300
+ | `--list-tenants` + PATH | vtt | -- | (empty) | tenant table |
1301
+ | `--list-tenants` + PATH | json | -- | (empty) | tenant JSON |
1302
+ | `--list-transcripts` | table | -- | meeting table | (none) |
1303
+ | `--list-transcripts` | json | -- | meeting JSON array | (none) |
1304
+ | `--list-transcripts` | vtt | -- | **error** (incompatible) | -- |
1305
+ | `--list-transcripts` + PATH | table | -- | (empty) | meeting table |
1306
+ | `--list-transcripts` + PATH | json | -- | (empty) | meeting JSON |
1307
+
1308
+ ## Development
1309
+
1310
+ ### Setup
1311
+
1312
+ ```sh
1313
+ git clone <repo-url> && cd teams-transcripts
1314
+ uv sync --all-groups --locked
1315
+ lefthook install
1316
+ ```
1317
+
1318
+ `uv sync` creates the virtual environment in `.venv/` and installs all
1319
+ runtime and development dependencies from the lockfile. Lefthook is an
1320
+ external development prerequisite; install it with `brew install lefthook`
1321
+ on macOS before running `lefthook install` to set up the pre-commit hooks.
1322
+
1323
+ To regenerate the live regression fixtures:
1324
+
1325
+ ```sh
1326
+ uv run pytest tests/regression/test_regression.py -m live --update-fixtures
1327
+ ```
1328
+
1329
+ See [Generating regression tests](#generating-regression-tests) for details.
1330
+
1331
+ After setup, the wrapper scripts in `bin/` are ready to use:
1332
+
1333
+ ```sh
1334
+ ./bin/teams-transcripts --help
1335
+ ./bin/teams-transcripts-mcp --help
1336
+ ```
1337
+
1338
+ These delegate to the venv entry points without requiring `uv run`.
1339
+
1340
+ ### Pre-commit hooks
1341
+
1342
+ This project uses [Lefthook](https://github.com/evilmartians/lefthook) for
1343
+ pre-commit checks. The following checks run in parallel on every commit:
1344
+
1345
+ | Check | Tool | Description |
1346
+ |-------|------|-------------|
1347
+ | `ruff-check` | [Ruff](https://docs.astral.sh/ruff/) | Lint with an extended rule set (Pyflakes, pycodestyle, Bugbear, Bandit, and more) |
1348
+ | `ruff-format` | Ruff | Verify formatting matches the canonical style |
1349
+ | `ty-check` | [ty](https://github.com/astral-sh/ty) | Type checking with `--error-on-warning` |
1350
+
1351
+ All three checks must pass before a commit is accepted. Configuration is in
1352
+ `pyproject.toml`.
1353
+
1354
+ ### Running checks manually
1355
+
1356
+ ```sh
1357
+ uv run ruff check teams_transcripts/ legacy/ tests/ scripts/
1358
+ uv run ruff format --check teams_transcripts/ legacy/ tests/ scripts/
1359
+ uv run ty check --error-on-warning teams_transcripts/ legacy/ tests/ scripts/
1360
+ ```
1361
+
1362
+ These checks exclude `tests/regression/`, which is a local, generated test tree
1363
+ containing private meeting configuration and fixtures.
1364
+
1365
+ ### Adding a dependency
1366
+
1367
+ ```sh
1368
+ uv add <package>
1369
+ ```
1370
+
1371
+ This updates both `pyproject.toml` and `uv.lock`.
1372
+
1373
+ ### Running tests
1374
+
1375
+ ```sh
1376
+ # Safe local suite (live, destructive, and fixture-data tests are deselected)
1377
+ uv run pytest
1378
+
1379
+ # Tracked tests only (for fresh clones without regression/ directory)
1380
+ uv run pytest tests/ --ignore=tests/regression
1381
+
1382
+ # Verbose output with test names
1383
+ uv run pytest -v
1384
+
1385
+ # Specific test file
1386
+ uv run pytest tests/test_domain_models.py
1387
+
1388
+ # Live regression tests (requires Teams running with CDP on port 9333)
1389
+ uv run pytest -m live
1390
+
1391
+ # Override the safe default and run every collected test (may relaunch Teams)
1392
+ uv run pytest -m ""
1393
+ ```
1394
+
1395
+ The test suite uses four custom pytest markers. The default marker expression
1396
+ excludes `live`, `destructive`, and `fixtures`; passing `-m` explicitly
1397
+ overrides that default.
1398
+
1399
+ | Marker | Requires | Description |
1400
+ |--------|----------|-------------|
1401
+ | `live` | Running Teams + CDP | Regression tests against real meetings |
1402
+ | `offline` | Generated local configuration | Offline integration accuracy tests |
1403
+ | `destructive` | Running Teams | Tests that kill and relaunch the Teams process |
1404
+ | `fixtures` | Generated fixture files | Offline tests that validate fixture VTT content |
1405
+
1406
+ ### Generating regression tests
1407
+
1408
+ > The `tests/regression/fixtures/` directory and local meeting config may contain
1409
+ > real meeting data. Treat regenerated fixtures as sensitive and do not publish
1410
+ > them outside the repository context.
1411
+
1412
+ The regression test suite compares tool output byte-for-byte against reference
1413
+ VTT files in `tests/regression/fixtures/`. These must be generated from a live
1414
+ Teams session:
1415
+
1416
+ ```sh
1417
+ uv run pytest tests/regression/test_regression.py -m live --update-fixtures
1418
+ ```
1419
+
1420
+ **Requirements:**
1421
+
1422
+ - Microsoft Teams running with CDP enabled on port 9333
1423
+ - Valid GCP credentials for Vertex AI (the LLM agent)
1424
+ - The 9 meetings defined in `tests/regression/test_meetings.yaml` accessible in Teams
1425
+
1426
+ This downloads each meeting's transcript and saves it as
1427
+ `tests/regression/fixtures/regression-{A-I}.vtt`. Once generated, the fixture
1428
+ validation tests run without Teams access.
1429
+
1430
+ To verify fixture-dependent tests pass after generation:
1431
+
1432
+ ```sh
1433
+ uv run pytest tests/regression/ -m "not live and not destructive"
1434
+ ```
1435
+
1436
+ The regression suite validates the 9 reference meetings in three modes:
1437
+ download (byte-for-byte comparison against fixtures), dry-run (JSON metadata
1438
+ schema), and format-json (output schema validation).