Metadata-Version: 2.4
Name: teams-transcripts
Version: 0.5.0
Summary: Download Microsoft Teams transcripts, chat, and meeting metadata from the macOS desktop app
Project-URL: Homepage, https://gitlab.com/jamiemills-choreograph/teams-transcripts
Project-URL: Repository, https://gitlab.com/jamiemills-choreograph/teams-transcripts
Project-URL: Issues, https://gitlab.com/jamiemills-choreograph/teams-transcripts/-/issues
Keywords: macos,mcp,microsoft-teams,transcripts,webvtt
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: MacOS X
Classifier: Operating System :: MacOS
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Conferencing
Classifier: Topic :: Text Processing
Requires-Python: >=3.10
Requires-Dist: anthropic[vertex]
Requires-Dist: mcp>=1.27.0
Requires-Dist: pydantic-settings>=2.0
Requires-Dist: pydantic>=2.0
Requires-Dist: websockets
Provides-Extra: test
Requires-Dist: pytest>=8.0; extra == 'test'
Requires-Dist: pyyaml>=6.0; extra == 'test'
Description-Content-Type: text/markdown

# Teams Transcript Downloader

A command-line tool that downloads Microsoft Teams meeting transcripts, chat
messages, and participant metadata as WebVTT (`.vtt`) or structured JSON.  It
connects to the Teams desktop app via the Chrome DevTools Protocol (CDP) and
navigates the UI programmatically.  An LLM agent (Claude on Vertex AI) handles
bounded non-deterministic steps such as selecting ambiguous search results,
selecting recurring-meeting occurrences, and disambiguating iframe targets.

## Run with uvx

On macOS with [uv](https://docs.astral.sh/uv/) installed, run the app directly
from PyPI without a persistent installation:

```sh
uvx teams-transcripts --help
uvx teams-transcripts \
  --meeting "Weekly Standup" \
  --datetime 2026-07-11 \
  --gcp-project your-project
```

To run one of the package's differently named commands, specify the package:

```sh
uvx --from teams-transcripts teams-transcripts-mcp
uvx --from teams-transcripts transcript-download --help
```

`uvx` creates an isolated temporary environment. The Teams desktop app, CDP,
and authentication prerequisites below still apply.

## How it works

The script executes an 8-step pipeline:

| Step | Description |
|------|-------------|
| 1 | Verify Teams is running with CDP enabled; relaunch if needed |
| 2 | Confirm CDP is responding on the configured port |
| 3 | Discover Teams page targets via CDP |
| 3b | _(when `--tenant` specified)_ Verify tenant; switch if needed |
| 3c | _(when `--meeting-id` used)_ Resolve UUID to meeting subject and date via MCPS API |
| 4 | Type the meeting name into the Teams search bar and navigate to the chat |
| 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 |
| 6 | Locate the transcript iframe target when transcript output is requested |
| 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 |
| 8 | Validate generated transcript content when transcript output is requested |

### Download approaches

When transcript output is requested, the script detects which approach to use
based on the Download button state:

**Approach A -- Native blob interception** is used when you have download
permission.  The script clicks the Download button and intercepts the blob URL
to capture the VTT content byte-for-byte.

**Approach B -- React fibre tree extraction** is used when download is blocked
(the button shows "You don't have permission").  The script walks the React
component tree to extract all transcript entries directly from the in-memory
data structure, then generates a well-formed VTT file.

### Speaker name resolution

Speaker names are resolved using the Recap Speakers tab.  The script extracts
a GUID-to-display-name mapping and applies it to clean up:

- **Vendor-prefixed names**: `v-John Doe (Metosin)` becomes `John Doe`
- **Suffixed names**: `Jane Smith GRM` becomes `Jane Smith`
- **Synthetic placeholders**: `@1` becomes `Speaker 1`

Names that are already clean (e.g. internal staff) are left unchanged.

### Meeting participant data

The tool extracts categorised participant information from four sources:

| Category | Source | Description |
|----------|--------|-------------|
| Organiser | Roster popover (fibre tree) | The meeting creator, identified by `organizerId` from the chat topic menu |
| Invited | Roster popover (fibre tree) | All members of the meeting chat thread, with names, emails, and Azure AD object IDs |
| Speakers | Recap Speakers tab | Members who spoke during the meeting, cross-referenced with the roster by display name |
| Attendees | Event/Call `<partlist>` XML in chat messages | Members who joined the call, cross-referenced with the roster by Azure AD object ID |

For the **invited** category, RSVP tracking data can optionally be enriched
from the meeting Details tab.  The tool navigates to Details, opens the
Tracking panel, clicks "Show more attendees" if present, and parses the
aria-labels to extract each invitee's calendar response status (accepted,
tentative, declined, or no response).  This enrichment is soft-failure:
if the Tracking panel is unavailable, the pipeline continues without RSVP
data.

The roster popover is accessed by clicking the participant count button in
the chat header.  This retrieves the full member list (names, emails,
object IDs) for all members, regardless of meeting size.  The legacy
`membersLimited` property in the fibre tree is intentionally limited to
~3 members by Teams and is only used as a fallback.

## Prerequisites

### 1. Microsoft Teams desktop app (macOS)

Teams must be running with CDP remote debugging enabled.  If Teams is already
running without CDP, the script will kill and relaunch it automatically with
the full set of required flags.

To launch manually, only the CDP port is strictly required:

```sh
export WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS='--remote-debugging-port=9333'
open -a 'Microsoft Teams'
```

When the script relaunches Teams itself, it sets additional Chromium flags for
reliable automation:

| Flag | Purpose |
|------|---------|
| `--disable-backgrounding-occluded-windows` | Keep rendering when Teams is behind other windows |
| `--disable-renderer-backgrounding` | Keep renderer at full priority when unfocused |
| `--disable-hang-monitor` | Suppress "page unresponsive" dialogs |
| `--disable-ipc-flooding-protection` | Prevent throttling of rapid CDP commands |
| `--disable-smooth-scrolling` | Remove animation delays |
| `--run-all-compositor-stages-before-draw` | Ensure fully composited frames for screenshots |
| `--hide-scrollbars` | Cleaner screenshots |
| `--mute-audio` | Suppress notification and meeting sounds |
| `--silent-debugger-extension-api` | Hide "debugging" infobars |
| `--force-device-scale-factor=2` | Retina-quality screenshots for the LLM agent |

### 2. Google Cloud credentials

The LLM agent uses Claude via Vertex AI, so you need Application Default
Credentials (ADC) for a GCP project with the Vertex AI API enabled:

```sh
gcloud auth application-default login
```

Or point the `--adc` flag (or `GOOGLE_ADC` env var) at a service account key
file.

### 3. Python dependencies

The script requires Python 3.10+ and is managed with
[uv](https://docs.astral.sh/uv/). To install all dependencies:

```sh
uv sync --locked
```

This creates a virtual environment in `.venv/` and installs the locked
dependencies from `uv.lock`.

## Usage

### Download a transcript

```sh
teams-transcripts (--meeting NAME | --meeting-id UUID) [--datetime YYYY-MM-DD[THH:MM]] [--output PATH] [options]
```

Or with `uv run` (no wrapper script needed):

```sh
uv run teams-transcripts (--meeting NAME | --meeting-id UUID) [--datetime YYYY-MM-DD[THH:MM]] [--output PATH] [options]
```

Or via the Python module:

```sh
uv run python -m teams_transcripts (--meeting NAME | --meeting-id UUID) [--datetime YYYY-MM-DD[THH:MM]] [--output PATH] [options]
```

### List available tenants

```sh
teams-transcripts --list-tenants [--format json] [--output PATH]
```

Lists all Azure AD tenants the signed-in user can access, marks the current
tenant with `*`, then exits.  Use the name or domain value from the output
with `--tenant` in a subsequent download invocation.

### List meetings with transcripts

```sh
teams-transcripts --list-transcripts [DAYS] [--format json] [--output PATH] [--tenant DOMAIN]
```

Lists all meetings with transcripts that the signed-in user attended.  `DAYS`
must be `1` or greater.  This
queries the Teams MCPS (Meeting Content and Presence Service) API via CDP
worker injection -- no Graph API permissions or app registrations required.

The optional `DAYS` argument controls how many days back to search (default:
1 = today only).

**Multi-tenant behaviour:** when `--tenant` is specified, only the matching
tenant is queried.  Without `--tenant`, all signed-in tenants are queried
and results aggregated into a single output.

Examples:

```sh
# Today's meetings with transcripts (all tenants)
teams-transcripts --list-transcripts

# Last 7 days
teams-transcripts --list-transcripts 7

# Last 14 days, specific tenant, JSON output to file
teams-transcripts --list-transcripts 14 --tenant choreograph.com --format json --output meetings.json

# Last 30 days, table output
teams-transcripts --list-transcripts 30
```

Text output:

```
Date        Subject                                           T  R  ID                                    Call ID
----------  ------------------------------------------------  -  -  ------------------------------------  ------------------------------------
2026-04-29  Campaign Connect Leads                            Y  Y  f0a4b1c3-228f-4f24-8efb-6736215a3072  aae0280e-5b7e-473d-8a4b-99de3cf74a08
2026-04-29  OMS Forum                                         Y  Y  3f9c903d-339a-4be7-a796-c13001c6af01  b12f445a-6c1e-4e2d-9f01-4e8a6b7c9d0e
2026-04-29  Data connectors development                       Y  Y  6d5d5a9f-357a-4f3c-a2be-d489454f6762  c23e556b-7d2f-5f3e-a012-5f9b7c8d0e1f
2026-04-29  UAP mid-Weekly                                    Y  Y  39158372-e829-454e-ad92-3d8812527aa9  d34f667c-8e3g-6g4f-b123-6g0c8d9e1f2g
2026-04-29  Dive into data for Data Connectors                Y  Y  67c13927-9b23-4f30-ae67-a489b3842994  e45g778d-9f4h-7h5g-c234-7h1d9e0f2g3h
2026-04-29  UAP / Trafficking / architecture                  Y  Y  589a1e41-3361-4231-8fc0-582e42fe87c3  f56h889e-0g5i-8i6h-d345-8i2e0f1g3h4i
2026-04-28  Optimization Tech leads Fortnightly Sync session  Y  Y  2b4c1f5c-636d-4d78-a087-c2cbf3887fd8  g67i990f-1h6j-9j7i-e456-9j3f1g2h4i5j
2026-04-27  Campaign Connect Leads                            Y  Y  f0a4b1c3-228f-4f24-8efb-6736215a3072  h78j001g-2i7k-0k8j-f567-0k4g2h3i5j6k

8 meeting(s) found.
```

Columns: **Date** (ISO), **Subject** (meeting title, max 48 chars),
**T** (has transcript: Y/N), **R** (has recording: Y/N),
**ID** (meeting UUID -- use with `--meeting-id` to download),
**Call ID** (per-occurrence identifier -- use with `--call-id` for
unambiguous targeting of recurring meetings; shown only when MCPS returns
call IDs).

Note: for recurring meetings (e.g. "Campaign Connect Leads" above), the
**ID** column shows the same UUID for all occurrences (they share a single
chat thread).  The **Call ID** column uniquely identifies each occurrence.

JSON output (`--format json`):

```json
[
  {
    "subject": "Campaign Connect Leads",
    "date": "2026-04-29",
    "thread_id": "19:meeting_ZjBhNGIxYzMtMjI4Zi00ZjI0LThlZmItNjczNjIxNWEzMDcy@thread.v2",
    "call_id": null,
    "ical_uid": "040000008200E00074C5B7101A82E008...",
    "has_transcript": true,
    "has_recording": true,
    "transcript_url": "https://tenant-my.sharepoint.com/personal/...transcripts/.../content",
    "recording_url": "https://tenant-my.sharepoint.com/personal/.../Meeting%20Recording.mp4"
  }
]
```

The JSON output includes SharePoint URLs for transcripts/recordings when MCPS
returns them.  These URLs can be used for direct download with appropriate
authentication.

The legacy entry point `transcript-download` (backed by the original monolith
in `legacy/transcript_download.py`) is still registered but is no longer the
primary interface.

### Required arguments (download mode)

One of the following is required (mutually exclusive):

| Argument | Description |
|----------|-------------|
| `--meeting NAME` | Meeting title as it appears in Teams search results |
| `--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`. |
| `--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`. |

### Optional arguments

| Argument | Default | Description |
|----------|---------|-------------|
| `--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). |
| `--output PATH` | stdout | Output file path.  When omitted, content is written to stdout.  File format is determined by `--format`. |
| `--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. |
| `--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`. |
| `--quiet` / `-q` | off | Suppress all diagnostic output on stderr.  Errors and `--format json` output are still emitted. |
| `--verbose` / `-v` | off | Emit additional diagnostic detail on stderr.  Mutually exclusive with `--quiet`. |
| `--gcp-project PROJECT_ID` | `$GOOGLE_CLOUD_PROJECT` | GCP project for Vertex AI |
| `--port PORT` | `9333` | CDP remote debugging port.  Must be between 1 and 65535. |
| `--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. |
| `--gcp-region REGION` | `$CLOUD_ML_REGION` or `global` | Vertex AI region |
| `--model MODEL_ID` | `$MODEL_ID` or `claude-haiku-4-5` | Anthropic model for the agent |
| `--adc PATH` | `$GOOGLE_ADC` or `~/.config/gcloud/...` | Path to ADC JSON file |
| `--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. |
| `--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. |
| `--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. |
| `--transcript {yes,no}` | `yes` | Include transcript cues in output.  When `no`, the transcript download is skipped and output is forced to JSON. |
| `--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. |
| `--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). |

### Environment variables

All optional arguments can be set via environment variables as fallbacks:

| Variable | Used by |
|----------|---------|
| `GOOGLE_CLOUD_PROJECT` | `--gcp-project` (required if flag not provided) |
| `CLOUD_ML_REGION` | `--gcp-region` |
| `MODEL_ID` | `--model` |
| `GOOGLE_ADC` | `--adc` |
| `TEAMS_TENANT` | `--tenant` |

### Examples

Download a single-occurrence meeting transcript:

```sh
uv run teams-transcripts \
  --meeting 'Project Kickoff' \
  --datetime 2026-04-20 \
  --output ./transcripts/kickoff.vtt \
  --gcp-project my-vertex-project
```

Download a recurring meeting (the script selects the correct occurrence) based upon the datetime:

```sh
uv run teams-transcripts \
  --meeting 'Weekly Standup' \
  --datetime 2026-04-14 \
  --output ./transcripts/standup-2026-04-14.vtt
```

Disambiguate two meetings with the same name on the same day by adding a time:

```sh
uv run teams-transcripts \
  --meeting 'Weekly Standup' \
  --datetime 2026-04-14T14:00 \
  --output ./transcripts/standup-afternoon.vtt
```

Pipe VTT to stdout (no file written):

```sh
uv run teams-transcripts \
  --meeting 'Project Kickoff' \
  --datetime 2026-04-20 | less
```

Download by meeting ID (from `--list-transcripts` output):

```sh
uv run teams-transcripts \
  --meeting-id 2b4c1f5c-636d-4d78-a087-c2cbf3887fd8 \
  --output ./transcripts/meeting.vtt
```

This eliminates ambiguity when multiple meetings share the same name.  The UUID
is shown in the `ID` column of `--list-transcripts` table output.  The meeting
date is resolved automatically from the UUID via the MCPS API.

Download a specific occurrence of a recurring meeting by call ID:

```sh
uv run teams-transcripts \
  --call-id aae0280e-5b7e-473d-8a4b-99de3cf74a08 \
  --output ./transcripts/standup-2026-04-29.vtt
```

The call ID is shown in the `Call ID` column of `--list-transcripts` table
output.  Unlike the meeting UUID (which is shared across all occurrences of a
recurring series), the call ID uniquely identifies a specific occurrence.  This
is the preferred method for recurring meetings.

Get structured JSON output to a file (nothing on stdout):

```sh
uv run teams-transcripts \
  --meeting 'Project Kickoff' \
  --datetime 2026-04-20 \
  --output ./kickoff.json --format json
```

The JSON file includes the full structured transcript as a `"transcript"` array
of cue objects:

```json
{
  "status": "ok",
  "meeting": "Project Kickoff",
  "start": "2026-04-20T10:00+01:00",
  "end": "2026-04-20T11:00+01:00",
  "organiser": {"name": "Alice", "email": "alice@example.com"},
  "invited": [
    {"name": "Alice", "email": "alice@example.com", "rsvp_status": "organizer"},
    {"name": "Bob", "email": "bob@example.com", "rsvp_status": "accepted"},
    {"name": "Charlie", "email": "charlie@example.com", "rsvp_status": "tentative"}
  ],
  "speakers_list": [
    {"name": "Alice", "email": "alice@example.com"},
    {"name": "Bob", "email": "bob@example.com"}
  ],
  "attendees": [
    {"name": "Alice", "email": "alice@example.com"},
    {"name": "Bob", "email": "bob@example.com"},
    {"name": "Charlie", "email": "charlie@example.com"}
  ],
  "transcript": [
    {
      "id": 1, "start": "00:00:03.000", "end": "00:00:25.000",
      "start_sec": 3.0, "end_sec": 25.0,
      "speaker": "Alice",
      "text": "So I do have this concept of account sets."
    }
  ],
  "chat": [
    {
      "author": "Bob",
      "text": "Here is the link: https://example.com/doc",
      "reactions": [{"type": "like", "count": 2, "users": ["Alice", "Charlie"]}],
      "offset": "00:05:12.000"
    }
  ],
  "parts": 1,
  "cues": 342,
  "speakers": {"Alice": 180, "Bob": 162},
  "file_size": 48210,
  "chat_messages": 2
}
```

Get structured JSON on stdout (agent-friendly, no file):

```sh
uv run teams-transcripts \
  --meeting 'Project Kickoff' \
  --datetime 2026-04-20 --format json
```

The same schema is written to stdout.  With `--output`, the JSON is written to
the file and stdout is empty.

Dry-run to check a meeting exists without downloading:

```sh
uv run teams-transcripts \
  --meeting 'Weekly Standup' \
  --datetime 2026-04-14 --dry-run --format json
```

Quiet mode for cron or agent use (suppress diagnostic logs):

```sh
uv run teams-transcripts \
  --meeting 'Weekly Standup' \
  --datetime 2026-04-14 \
  --output ./standup.vtt -q
```

Use a specific model and region:

```sh
uv run teams-transcripts \
  --meeting 'Quarterly Review' \
  --datetime 2026-03-31 \
  --output ./review.vtt \
  --model claude-sonnet-4-20250514 \
  --gcp-region us-east5
```

### Tenant selection

When your Microsoft account has access to multiple Azure AD tenants
(organisations), you can list them and target a specific one:

```sh
# List all available tenants
uv run teams-transcripts --list-tenants
```

Text output:

```
Available tenants (* = current):

    NAME                    DOMAIN                             TENANT ID
  * ABC Corp               abccorp.onmicrosoft.com            86cbe1bb-213f-4271-b174-bd59d03c87a3
    Contoso Ltd             contoso.onmicrosoft.com            2b755fa1-23d1-48f3-98fc-6fdc1dc48d69

Use --tenant NAME_OR_DOMAIN to select a tenant for download.
```

JSON output:

```sh
uv run teams-transcripts --list-tenants --format json
```

```json
[
  {
    "tenantId": "86cbe1bb-213f-4271-b174-bd59d03c87a3",
    "tenantName": "ABC Corp",
    "domain": "abccorp.onmicrosoft.com",
    "current": true
  },
  {
    "tenantId": "2b755fa1-23d1-48f3-98fc-6fdc1dc48d69",
    "tenantName": "Contoso Ltd",
    "domain": "contoso.onmicrosoft.com",
    "current": false
  }
]
```

Write the tenant list to a file:

```sh
uv run teams-transcripts --list-tenants --output tenants.json --format json
```

Download a transcript from a specific tenant (auto-switches if needed):

```sh
uv run teams-transcripts \
  --meeting 'Weekly Standup' \
  --datetime 2026-04-14 \
  --output ./standup.vtt \
  --tenant contoso.onmicrosoft.com
```

When `--tenant` is specified, the tool:

1. Detects which tenant Teams is currently signed into
2. Lists all available tenants for the signed-in user
3. If the current tenant matches, proceeds normally
4. If the current tenant does not match but the requested tenant is in the
   list, kills Teams and relaunches it on the correct tenant automatically
5. If the requested tenant is not in the available list at all, exits with
   code 6 (`TENANT_NOT_FOUND`)

The `--tenant` value is matched case-insensitively against both the tenant
name and the primary domain name from the tenant list.

### Component selection

By default all three components are extracted: transcript, chat, and metadata.
Use the `--transcript`, `--chat`, and `--metadata` flags to select which
components to include.  At least one must be `yes`, except with `--dry-run`,
where all three may be `no` for a pure meeting-exists check.

Extract only chat messages (no transcript download, faster):

```sh
uv run teams-transcripts \
  --meeting 'Weekly Standup' \
  --datetime 2026-04-14 \
  --transcript no --metadata no --format json
```

Extract only participant metadata:

```sh
uv run teams-transcripts \
  --meeting 'Weekly Standup' \
  --datetime 2026-04-14 \
  --transcript no --chat no --format json
```

Extract chat and metadata without the transcript:

```sh
uv run teams-transcripts \
  --meeting 'Weekly Standup' \
  --datetime 2026-04-14 \
  --transcript no --format json
```

When `--transcript no` is set, the output format is forced to JSON (VTT without
transcript cues is meaningless).  Explicitly passing `--format vtt` alongside
`--transcript no` is an error.

When `--chat no --metadata yes` is set, chat messages are omitted from output,
but Event/Call messages may still be read internally to build attendee metadata.

The JSON output is composable: each component adds only its own fields.  When
all three are `yes` (the default), the output is identical to the standard
full-extraction JSON schema.

## MCP server

The package includes a [Model Context Protocol](https://modelcontextprotocol.io/)
(MCP) server that exposes the extraction tools over STDIO.  This allows any
MCP-compatible client (Claude Desktop, Cursor, OpenCode, etc.) to call the
tools directly.

### Running the MCP server

```sh
# STDIO transport (default, for subprocess spawning by MCP clients)
teams-transcripts-mcp

# SSE transport on a specific port (for network MCP clients)
teams-transcripts-mcp --port 8080
```

Or with `uv run`:

```sh
uv run teams-transcripts-mcp
uv run teams-transcripts-mcp --port 8080
```

Or via the Python module:

```sh
uv run python -m teams_transcripts.mcp_server
uv run python -m teams_transcripts.mcp_server --port 8080
```

When `--port` is omitted, the server communicates over STDIO (the standard
MCP transport for local subprocess spawning).  When `--port` is specified,
the server runs as an SSE HTTP server on that port, accessible over the
network.

The server inherits environment variables for GCP configuration
(see [Environment variables](#environment-variables)).

### Available tools

| Tool | Description |
|------|-------------|
| `restart_teams` | Safely relaunch Teams with CDP flags and wait for readiness |
| `list_tenants` | List available Azure AD tenants |
| `list_transcripts` | Discover meetings with transcripts (last N days) |
| `download` | **Unified entry point** -- accepts UUID or name+date, validates args |
| `download_by_meeting_id` | Download transcript + chat + metadata by UUID |
| `download_transcript_by_meeting_id` | Download only transcript by UUID |
| `download_chat_by_meeting_id` | Download only chat messages by UUID |
| `download_metadata_by_meeting_id` | Download only participant metadata by UUID |
| `download_all` | Download transcript + chat + metadata by name + date |
| `download_transcript` | Download only the transcript (WebVTT cues) by name + date |
| `download_chat` | Download only chat messages by name + date |
| `download_meeting_metadata` | Download only participant metadata by name + date |

**Discovery tools** (`list_tenants`, `list_transcripts`) accept an optional
`port` parameter and return JSON arrays.

**Unified download tool** (`download`) is the recommended entry point for
AI agents.  It accepts either `meeting_id` (preferred) or `meeting` + `date`,
validates argument combinations before launching the subprocess, and supports
`dry_run=True` to verify a meeting can be found without transcript download,
output chat extraction, Event/Call attendance extraction, or Details tracking
extraction.  In dry-run mode, all three component flags may be `False` for a
pure meeting-exists check.

**UUID-based download tools** (`download_by_meeting_id`, etc.) accept a
`meeting_id` (UUID from `list_transcripts` output), optional `tenant`,
`port`, and `dry_run`.  The meeting subject and date are resolved
automatically from the UUID.

**Name-based download tools** (`download_all`, etc.) accept `meeting`
(required), `date` (required), and optional `time`, `tenant`, `port`, and
`dry_run` parameters.

#### Recommended workflow

```
1. list_transcripts(days=7)                → discover meetings, get UUIDs
2. download(meeting_id=uuid)               → extract transcript by UUID (preferred)
   OR download(meeting_id=uuid, dry_run=True)  → verify meeting exists first
```

Or for a known meeting:

```
1. download(meeting="Weekly Standup", date="2026-04-29")
```

### Configuration for Claude Desktop

Add to your `claude_desktop_config.json`:

```json
{
  "mcpServers": {
    "teams-transcripts": {
      "command": "/path/to/teams-transcripts/bin/teams-transcripts-mcp",
      "env": {
        "GOOGLE_CLOUD_PROJECT": "your-gcp-project"
      }
    }
  }
}
```

Or using `uv run`:

```json
{
  "mcpServers": {
    "teams-transcripts": {
      "command": "uv",
      "args": ["run", "--directory", "/path/to/teams-transcripts", "teams-transcripts-mcp"],
      "env": {
        "GOOGLE_CLOUD_PROJECT": "your-gcp-project"
      }
    }
  }
}
```

### Architecture

Each MCP tool shells out to the `teams-transcripts` CLI with `--format json
--quiet` and returns the parsed JSON response.  The subprocess approach provides:

- Process isolation (Teams CDP locks are per-process)
- Clean signal handling (the child manages its own SIGINT/SIGTERM)
- No import-time side effects in the MCP server process

Discovery tools (`list_tenants`, `list_transcripts`) return JSON arrays.
Download tools return a JSON object with the extraction results.  UUID-based
tools use `--meeting-id` internally; name-based tools use `--meeting` +
`--datetime`.

### Safe Teams restarts

Every automatic Teams restart checks for a possible live call immediately
before killing the app. The check samples CoreAudio activity for the Teams app
and its helper processes three times, then checks the macOS Accessibility tree
for a visible Leave or Hang-up control. The result is `active`, `inactive`, or
`unknown`; both `active` and `unknown` block automatic CDP recovery and tenant
switching.

Blocked CLI flows exit with code 2 and the machine-stable `RESTART_BLOCKED`
error. MCP callers receive the same error through download/listing tools. The
`restart_teams` MCP tool instead returns a structured result containing
`status`, `restarted`, `cdp_ready`, `port`, `call_state`, and `forced`. A block
also includes `reason: active_call` or `reason: call_state_unknown`.

The CLI flag `--force-restart` overrides the safeguard for any automatic CDP
recovery or tenant switch required by that invocation. The MCP equivalent is
`restart_teams(force=True)`. Both may interrupt a live call and must be used
only when interruption is explicitly intended. Without an override, downloads,
listing, tenant switching, and recovery continue to fail closed.

## Output format

### VTT file structure

The output is a standard [WebVTT](https://www.w3.org/TR/webvtt1/) file
containing three sections:

**1. Meeting metadata** -- a `NOTE Meeting Metadata` comment block at the top:

```
WEBVTT

NOTE Meeting Metadata
Meeting: Weekly Standup
Organiser: Alice <alice@example.com>
Invited: Alice, Bob, Charlie
Speakers: Alice, Bob
Attendees: Alice, Bob, Charlie
Start: 2026-04-14T10:00+01:00
End: 2026-04-14T10:30+01:00
```

For larger meetings (more than 10 invited or attendees), counts are shown
instead of names, with an RSVP breakdown when tracking data is available:

```
NOTE Meeting Metadata
Meeting: Q1 2026 All Hands
Organiser: Suman Landa <suman.landa@example.com>
Invited: 90 members (50 accepted, 5 tentative, 2 declined, 33 no response)
Speakers: Alice, Bob, Charlie, Dave, Eve, Fran
Attendees: 77 participants
Start: 2026-04-01T09:00+01:00
End: 2026-04-01T10:30+01:00
```

Fields:

- **Meeting**: meeting title.
- **Organiser**: meeting creator with email in `Name <email>` format.
- **Invited**: all members on the meeting chat thread.  Listed by name
  when 10 or fewer; shown as a count with RSVP summary when more than 10.
  RSVP statuses are: accepted, tentative, declined, no response.
- **Speakers**: members who spoke during the meeting (from Recap).
- **Attendees**: members who joined the call (from Event/Call partlist).
  May be incomplete for large meetings due to server-side truncation.
- **Start** / **End**: ISO 8601 timestamps with timezone offset.

**2. Chat messages** -- a `NOTE Chat` comment block containing a JSON array of
messages sent during the meeting:

```
NOTE Chat
[
  {
    "author": "Bob",
    "text": "Link to the design doc: https://example.com/doc",
    "reactions": [
      {"type": "like", "count": 2, "users": ["Alice", "Charlie"]}
    ],
    "offset": "00:12:34.567"
  }
]
```

Each message includes: author name, plain text (HTML stripped, links
preserved), reactions (with per-user attribution where resolvable), and an
`offset` from meeting start in `HH:MM:SS.mmm` format where computable.  The
`offset` field is `null` when it cannot be computed.  The `reactions` field is
`null` when there are no reactions.

**3. Transcript cues** -- standard WebVTT cues with speaker identification:

```
1
00:00:03.000 --> 00:00:25.000
<v Alice>So I do have this concept of account sets. It's called
platform account sets in the code base, which basically...</v>

2
00:00:27.000 --> 00:00:48.000
<v Bob>Looks something like this. For example, this is our dev
accounts.</v>
```

Each cue contains a sequential number, start and end timestamps in
`HH:MM:SS.mmm` format, the speaker name in a `<v Name>` voice span, and the
spoken text.

For multi-part meetings (where Teams splits the recording), cue timestamps are
offset so that Part 2 continues from where Part 1 ended, producing a single
continuous timeline.

### JSON output schemas

When `--format json` is used, a single JSON object is written to stdout (when
`--output` is omitted) or to the specified file (when `--output` is given).
In file mode, nothing is written to stdout.

**Success (all components)**:

```json
{
  "status": "ok",
  "meeting": "Weekly Standup",
  "start": "2026-04-14T10:00+01:00",
  "end": "2026-04-14T10:30+01:00",
  "organiser": {"name": "Alice", "email": "alice@example.com"},
  "invited": [
    {"name": "Alice", "email": "alice@example.com", "rsvp_status": "organizer"},
    {"name": "Bob", "email": "bob@example.com", "rsvp_status": "accepted"},
    {"name": "Charlie", "email": "charlie@example.com", "rsvp_status": "declined"}
  ],
  "speakers_list": [
    {"name": "Alice", "email": "alice@example.com"},
    {"name": "Bob", "email": "bob@example.com"}
  ],
  "attendees": [
    {"name": "Alice", "email": "alice@example.com"},
    {"name": "Bob", "email": "bob@example.com"},
    {"name": "Charlie", "email": "charlie@example.com"}
  ],
  "chat": [
    {
      "author": "Bob",
      "text": "Here is the link: https://example.com/doc",
      "reactions": [{"type": "like", "count": 2, "users": ["Alice", "Charlie"]}],
      "offset": "00:05:12.000"
    },
    {
      "author": "Alice",
      "text": "Thanks Bob",
      "reactions": null,
      "offset": "00:05:45.000"
    }
  ],
  "transcript": [
    {
      "id": 1, "start": "00:00:03.000", "end": "00:00:25.000",
      "start_sec": 3.0, "end_sec": 25.0,
      "speaker": "Alice",
      "text": "So I do have this concept of account sets."
    },
    {
      "id": 2, "start": "00:00:27.000", "end": "00:00:48.000",
      "start_sec": 27.0, "end_sec": 48.0,
      "speaker": "Bob",
      "text": "Looks something like this."
    }
  ],
  "parts": 1,
  "cues": 342,
  "speakers": {"Alice": 180, "Bob": 162},
  "file_size": 48210,
  "chat_messages": 2
}
```

**Dry-run** (`--dry-run --format json`):

```json
{
  "status": "ok",
  "dry_run": true,
  "meeting": "Weekly Standup",
  "start": "2026-04-14T10:00+01:00",
  "end": "2026-04-14T10:30+01:00",
  "organiser": {"name": "Alice", "email": "alice@example.com"},
  "invited": [
    {"name": "Alice", "email": "alice@example.com"},
    {"name": "Bob", "email": "bob@example.com"},
    {"name": "Charlie", "email": "charlie@example.com"}
  ],
  "speakers_list": [
    {"name": "Alice", "email": "alice@example.com"},
    {"name": "Bob", "email": "bob@example.com"}
  ],
  "parts": 1,
  "speakers": {"Alice": 0, "Bob": 0}
}
```

Speaker counts are zero in dry-run mode (no transcript downloaded).  The
`"cues"`, `"file_size"`, `"transcript"`, and `"chat_messages"` keys are
absent.  `"attendees"` is absent in dry-run because Event/Call partlists are
only available after chat extraction, which dry-run skips.

**Error** (any non-zero exit):

```json
{
  "status": "error",
  "code": "MEETING_NOT_FOUND",
  "message": "Could not find the target 2026-04-14 in any of the first 4 search results.",
  "step": "step5"
}
```

The `"code"` field is a machine-stable error code (see `--help` for the full
list).  The `"step"` field indicates which pipeline step failed (absent for
argument validation errors).

**Conditional keys**: `"end"` is absent if the meeting time text could not be
parsed.  `"organiser"` is absent if the organiser could not be identified.
`"invited"` is absent if roster extraction failed entirely (popover
unavailable).  `"speakers_list"` is absent if no speakers were found.
`"attendees"` is absent if no Event/Call partlist was available (e.g.
dry-run mode, or the meeting had no call join/leave events).
`"chat_messages"` is absent (not zero) when there are no chat messages.
`"rsvp_status"` is included on each invited member only when RSVP tracking
data was available; it is omitted (not empty) when the Details tab Tracking
panel could not be read.

### Component-specific JSON schemas

The JSON output is composable.  Each component (`--transcript`, `--chat`,
`--metadata`) contributes only its own fields.  Base fields (`status`,
`meeting`, `start`, `end`) are always present regardless of which components
are selected.

**Transcript only** (`--transcript yes --chat no --metadata no`):

```json
{
  "status": "ok",
  "meeting": "Weekly Standup",
  "start": "2026-04-14T10:00+01:00",
  "end": "2026-04-14T10:30+01:00",
  "parts": 1,
  "cues": 342,
  "speakers": {"Alice": 180, "Bob": 162},
  "file_size": 48210,
  "transcript": [
    {
      "id": 1, "start": "00:00:03.000", "end": "00:00:25.000",
      "start_sec": 3.0, "end_sec": 25.0,
      "speaker": "Alice",
      "text": "So I do have this concept of account sets."
    }
  ]
}
```

Transcript-specific fields: `parts`, `cues`, `speakers` (name-to-cue-count
map), `file_size`, and `transcript` (array of cue objects).

**Chat only** (`--transcript no --chat yes --metadata no`):

```json
{
  "status": "ok",
  "meeting": "Weekly Standup",
  "start": "2026-04-14T10:00+01:00",
  "end": "2026-04-14T10:30+01:00",
  "chat": [
    {
      "author": "Bob",
      "text": "Here is the link: https://example.com/doc",
      "reactions": [{"type": "like", "count": 2, "users": ["Alice", "Charlie"]}],
      "offset": "00:05:12.000"
    },
    {
      "author": "Alice",
      "text": "Thanks Bob",
      "reactions": null,
      "offset": "00:05:45.000"
    }
  ]
}
```

Chat-specific fields: `chat` (array of message objects).  Each message has:
- `author` (string): display name of the sender
- `text` (string): plain text content (HTML stripped, links preserved)
- `reactions` (array or null): list of reaction objects, each with `type`,
  `count`, and `users` (array of display names)
- `offset` (string or null): time offset from meeting start in `HH:MM:SS.mmm`
  format when computable

**Metadata only** (`--transcript no --chat no --metadata yes`):

```json
{
  "status": "ok",
  "meeting": "Weekly Standup",
  "start": "2026-04-14T10:00+01:00",
  "end": "2026-04-14T10:30+01:00",
  "organiser": {"name": "Alice", "email": "alice@example.com"},
  "invited": [
    {"name": "Alice", "email": "alice@example.com", "rsvp_status": "organizer"},
    {"name": "Bob", "email": "bob@example.com", "rsvp_status": "accepted"},
    {"name": "Charlie", "email": "charlie@example.com", "rsvp_status": "tentative"}
  ],
  "speakers_list": [
    {"name": "Alice", "email": "alice@example.com"},
    {"name": "Bob", "email": "bob@example.com"}
  ],
  "attendees": [
    {"name": "Alice", "email": "alice@example.com"},
    {"name": "Bob", "email": "bob@example.com"},
    {"name": "Charlie", "email": "charlie@example.com"}
  ]
}
```

Metadata-specific fields:
- `organiser` (object): `{name, email}` of the meeting creator
- `invited` (array): members on the chat thread, each with `name`, `email`,
  and optionally `rsvp_status` (one of: `organizer`, `accepted`, `tentative`,
  `declined`, `none`)
- `speakers_list` (array): members who spoke, each with `name` and `email`
- `attendees` (array): members who joined the call, each with `name` and
  `email`

**Chat + metadata** (`--transcript no --chat yes --metadata yes`):

```json
{
  "status": "ok",
  "meeting": "Weekly Standup",
  "start": "2026-04-14T10:00+01:00",
  "end": "2026-04-14T10:30+01:00",
  "organiser": {"name": "Alice", "email": "alice@example.com"},
  "invited": [
    {"name": "Alice", "email": "alice@example.com", "rsvp_status": "accepted"},
    {"name": "Bob", "email": "bob@example.com", "rsvp_status": "accepted"}
  ],
  "speakers_list": [
    {"name": "Alice", "email": "alice@example.com"},
    {"name": "Bob", "email": "bob@example.com"}
  ],
  "attendees": [
    {"name": "Alice", "email": "alice@example.com"},
    {"name": "Bob", "email": "bob@example.com"}
  ],
  "chat": [
    {
      "author": "Bob",
      "text": "Starting now",
      "reactions": null,
      "offset": "00:00:05.000"
    }
  ]
}
```

**List tenants** (`--list-tenants --format json`):

```json
[
  {
    "tenantId": "86cbe1bb-213f-4271-b174-bd59d03c87a3",
    "tenantName": "ABC Corp",
    "domain": "abccorp.onmicrosoft.com",
    "current": true
  },
  {
    "tenantId": "2b755fa1-23d1-48f3-98fc-6fdc1dc48d69",
    "tenantName": "Contoso Ltd",
    "domain": "contoso.onmicrosoft.com",
    "current": false
  }
]
```

### JSON field reference

| Field | Type | Present when | Description |
|-------|------|-------------|-------------|
| `status` | string | always | `"ok"` on success, `"error"` on failure |
| `meeting` | string | always (success) | Meeting title |
| `start` | string | always (success) | ISO 8601 meeting start time |
| `end` | string | when parseable | ISO 8601 meeting end time |
| `dry_run` | boolean | `--dry-run` mode | Always `true` when present |
| `organiser` | object | `--metadata yes` | `{name, email}` |
| `invited` | array | `--metadata yes` | `[{name, email, rsvp_status?}]` |
| `speakers_list` | array | `--metadata yes` | `[{name, email}]` |
| `attendees` | array | `--metadata yes` + attendance data available | `[{name, email}]` |
| `chat` | array | `--chat yes` + messages exist | `[{author, text, reactions, offset}]` |
| `chat_messages` | integer | all-components mode | Count of chat messages |
| `parts` | integer | `--transcript yes` | Number of transcript parts |
| `cues` | integer | `--transcript yes` | Total cue count |
| `speakers` | object | `--transcript yes` | `{name: cue_count}` map |
| `file_size` | integer | `--transcript yes` | VTT content size in bytes |
| `transcript` | array | `--transcript yes` | `[{id, start, end, start_sec, end_sec, speaker, text}]` |
| `code` | string | error | Machine-stable error code |
| `message` | string | error | Human-readable error description |
| `step` | string | error (runtime) | Pipeline step that failed |

## Recurring meetings

For recurring meetings, the script:

1. Navigates to the meeting chat group
2. Opens the Recap tab and reads the occurrence dropdown
3. If the default occurrence does not match the target date, opens the
   dropdown and scrolls through available dates
4. Uses the LLM agent to identify and select the correct occurrence

Teams typically retains the most recent 5 or so occurrences in the dropdown.
Older occurrences may no longer be available.

If the first search result leads to a chat group that does not contain the
target date, the script automatically retries with the next search result
(up to 4 attempts).

## Search modes

Teams uses two different search UI patterns depending on the context:

- **Panel mode**: results appear in a side panel with text-based matching.
  The script scores results by word overlap with the meeting name.
- **Popup mode**: results appear in a dropdown overlay.  The script uses the
  LLM agent to select the correct result from a screenshot.

The mode is auto-detected; no configuration is needed.

## Limitations

- **macOS only**: the script uses macOS-specific commands (`open -a`, `pkill`)
  to manage the Teams process.
- **Occurrence dropdown depth**: Teams only retains recent occurrences (roughly
  the last 5).  Older recurring meeting dates cannot be retrieved.
- **Attendee list truncation**: the Event/Call `<partlist>` XML that Teams
  stores in chat messages is truncated server-side for large meetings.  For
  example, a meeting declaring `count="77"` may only include 25 `<part>`
  elements.  The attendee list in the output may therefore be incomplete.
  This is a Teams limitation with no client-side workaround.
- **RSVP data is calendar responses, not actual attendance**: the tracking
  panel shows who accepted/declined/tentatively accepted the calendar
  invitation.  It does not reflect who actually joined the call.  Actual
  attendance is determined by the `<partlist>` (see above).
- **RSVP extraction is soft-failure**: if the Details tab or Tracking panel
  is unavailable (e.g. the meeting type does not support tracking, or the
  UI layout changes), the pipeline continues without RSVP data.  The
  `rsvp_status` field will be absent from JSON output in this case.
- **Conference room accounts**: room/resource accounts appear with their
  system names (e.g. `UK-LON-1SB-05S19@oursystems.com`) as that is what
  Teams stores as the display name.

## Exit codes

| Code | Name | Meaning |
|------|------|---------|
| 0 | `EXIT_OK` | Success |
| 1 | `EXIT_BAD_ARGS` | Invalid arguments or missing configuration |
| 2 | `EXIT_CDP_UNAVAILABLE` | Teams/CDP not running or not responding |
| 3 | `EXIT_MEETING_NOT_FOUND` | Meeting not found after all retries |
| 4 | `EXIT_DOWNLOAD_FAILED` | Transcript download or permission error |
| 5 | `EXIT_VERIFICATION_FAILED` | Output file is invalid |
| 6 | `EXIT_TENANT_NOT_FOUND` | Requested tenant not in available tenants list |
| 130 | `EXIT_INTERRUPTED` | Interrupted by SIGINT (Ctrl+C) |
| 143 | `EXIT_TERMINATED` | Terminated by SIGTERM |

## Error codes

Each error includes a machine-stable code string used as a `[PREFIX]` on stderr
and as the `"code"` field in JSON error output.  These are stable identifiers
suitable for programmatic matching.

| Code | Meaning |
|------|---------|
| `BAD_ARGS` | Invalid CLI arguments or missing configuration |
| `CDP_UNAVAILABLE` | Teams/CDP not running or not responding |
| `RESTART_BLOCKED` | Teams restart blocked by an active or unknown call state |
| `NO_TARGETS` | No Teams page targets found via CDP |
| `SEARCH_FAILED` | Search results did not appear in the UI |
| `MEETING_NOT_FOUND` | Meeting not found in search results |
| `RECAP_NOT_FOUND` | Recap tab not found after navigation |
| `TRANSCRIPT_NOT_FOUND` | Transcript tab not found |
| `IFRAME_NOT_FOUND` | No transcript iframe target among CDP targets |
| `DOWNLOAD_FAILED` | VTT download failed |
| `VERIFICATION_FAILED` | Output file content is invalid |
| `TENANT_NOT_FOUND` | Requested tenant not in available tenants list |
| `INTERRUPTED` | Process interrupted by SIGINT |
| `TERMINATED` | Process terminated by SIGTERM |

## Agent and automation usage

The CLI is designed for use by both humans and automated agents.  Key features
for machine consumption:

- **stdout/stderr separation**: all diagnostic output goes to stderr.  stdout
  carries only data -- VTT content in text mode, or a JSON object in JSON mode.
  When `--output` is given, nothing is written to stdout (both formats).
  Use `--quiet` to suppress diagnostics entirely.
- **`--format json`**: emits a single JSON object.  On success, the object
  contains meeting metadata, speaker counts, cue count, and a `"transcript"`
  array of structured cue objects.  On failure, the object contains
  `"status": "error"`, a machine-stable `"code"` string, and a human message.
- **Semantic exit codes**: agents can branch on the exit code without parsing
  error messages.  See the table above.
- **Machine-stable error codes**: each error includes a short code prefix in
  text mode (e.g. `[MEETING_NOT_FOUND]`) and a `"code"` field in JSON mode.
  Codes are listed in `--help` under "error codes".
- **`--dry-run`**: finds the meeting, loads Recap only when needed, extracts
  safe roster metadata when requested, and detects parts without downloading
  the transcript, extracting chat/attendance data, or opening Details tracking.
  Useful for validation before committing to a full download.
- **Signal handling**: SIGINT and SIGTERM are handled cleanly.  The first
  signal sets a shutdown flag and the process exits at the next safe point
  (within 500 ms).  A second signal force-exits immediately.  JSON error
  output is emitted on signal exit when `--format json` is active.

### Output modes

| `--output` | `--format` | `--dry-run` | stdout | file |
|------------|------------|-------------|--------|------|
| PATH | vtt | no | (empty) | VTT |
| PATH | json | no | (empty) | JSON |
| omitted | vtt | no | raw VTT content | (none) |
| omitted | json | no | structured JSON | (none) |
| omitted | vtt | yes | (empty) | (none) |
| omitted | json | yes | JSON metadata (no transcript) | (none) |
| PATH | any | yes | **error** (incompatible) | -- |
| `--list-tenants` | vtt | -- | tenant table | (none) |
| `--list-tenants` | json | -- | tenant JSON array | (none) |
| `--list-tenants` + PATH | vtt | -- | (empty) | tenant table |
| `--list-tenants` + PATH | json | -- | (empty) | tenant JSON |
| `--list-transcripts` | table | -- | meeting table | (none) |
| `--list-transcripts` | json | -- | meeting JSON array | (none) |
| `--list-transcripts` | vtt | -- | **error** (incompatible) | -- |
| `--list-transcripts` + PATH | table | -- | (empty) | meeting table |
| `--list-transcripts` + PATH | json | -- | (empty) | meeting JSON |

## Development

### Setup

```sh
git clone <repo-url> && cd teams-transcripts
uv sync --all-groups --locked
lefthook install
```

`uv sync` creates the virtual environment in `.venv/` and installs all
runtime and development dependencies from the lockfile.  Lefthook is an
external development prerequisite; install it with `brew install lefthook`
on macOS before running `lefthook install` to set up the pre-commit hooks.

To regenerate the live regression fixtures:

```sh
uv run pytest tests/regression/test_regression.py -m live --update-fixtures
```

See [Generating regression tests](#generating-regression-tests) for details.

After setup, the wrapper scripts in `bin/` are ready to use:

```sh
./bin/teams-transcripts --help
./bin/teams-transcripts-mcp --help
```

These delegate to the venv entry points without requiring `uv run`.

### Pre-commit hooks

This project uses [Lefthook](https://github.com/evilmartians/lefthook) for
pre-commit checks.  The following checks run in parallel on every commit:

| Check | Tool | Description |
|-------|------|-------------|
| `ruff-check` | [Ruff](https://docs.astral.sh/ruff/) | Lint with an extended rule set (Pyflakes, pycodestyle, Bugbear, Bandit, and more) |
| `ruff-format` | Ruff | Verify formatting matches the canonical style |
| `ty-check` | [ty](https://github.com/astral-sh/ty) | Type checking with `--error-on-warning` |

All three checks must pass before a commit is accepted.  Configuration is in
`pyproject.toml`.

### Running checks manually

```sh
uv run ruff check teams_transcripts/ legacy/ tests/ scripts/
uv run ruff format --check teams_transcripts/ legacy/ tests/ scripts/
uv run ty check --error-on-warning teams_transcripts/ legacy/ tests/ scripts/
```

These checks exclude `tests/regression/`, which is a local, generated test tree
containing private meeting configuration and fixtures.

### Adding a dependency

```sh
uv add <package>
```

This updates both `pyproject.toml` and `uv.lock`.

### Running tests

```sh
# Safe local suite (live, destructive, and fixture-data tests are deselected)
uv run pytest

# Tracked tests only (for fresh clones without regression/ directory)
uv run pytest tests/ --ignore=tests/regression

# Verbose output with test names
uv run pytest -v

# Specific test file
uv run pytest tests/test_domain_models.py

# Live regression tests (requires Teams running with CDP on port 9333)
uv run pytest -m live

# Override the safe default and run every collected test (may relaunch Teams)
uv run pytest -m ""
```

The test suite uses four custom pytest markers.  The default marker expression
excludes `live`, `destructive`, and `fixtures`; passing `-m` explicitly
overrides that default.

| Marker | Requires | Description |
|--------|----------|-------------|
| `live` | Running Teams + CDP | Regression tests against real meetings |
| `offline` | Generated local configuration | Offline integration accuracy tests |
| `destructive` | Running Teams | Tests that kill and relaunch the Teams process |
| `fixtures` | Generated fixture files | Offline tests that validate fixture VTT content |

### Generating regression tests

> The `tests/regression/fixtures/` directory and local meeting config may contain
> real meeting data.  Treat regenerated fixtures as sensitive and do not publish
> them outside the repository context.

The regression test suite compares tool output byte-for-byte against reference
VTT files in `tests/regression/fixtures/`.  These must be generated from a live
Teams session:

```sh
uv run pytest tests/regression/test_regression.py -m live --update-fixtures
```

**Requirements:**

- Microsoft Teams running with CDP enabled on port 9333
- Valid GCP credentials for Vertex AI (the LLM agent)
- The 9 meetings defined in `tests/regression/test_meetings.yaml` accessible in Teams

This downloads each meeting's transcript and saves it as
`tests/regression/fixtures/regression-{A-I}.vtt`.  Once generated, the fixture
validation tests run without Teams access.

To verify fixture-dependent tests pass after generation:

```sh
uv run pytest tests/regression/ -m "not live and not destructive"
```

The regression suite validates the 9 reference meetings in three modes:
download (byte-for-byte comparison against fixtures), dry-run (JSON metadata
schema), and format-json (output schema validation).
