# Zoom Meeting Assistant

Records finish on Zoom → this picks up the webhook → transcribes with speaker
labels → asks Claude to turn that into minutes (subject, summary, key points,
action items per person) → writes the result to `output/`.

## Two ways to get the transcript

Set `TRANSCRIPTION_MODE` in `.env`:

| Mode | Source | Setup | Trade-off |
|---|---|---|---|
| `fast` | Zoom's own AI Companion transcript (the `audio_transcript` VTT file) | Just Zoom credentials | Limited to Zoom's transcript quality/diarization; only works for meetings actually held on Zoom |
| `full` | Raw audio → AssemblyAI (transcription + speaker diarization) | Zoom + AssemblyAI credentials | More moving parts, but one consistent pipeline — the same AssemblyAI step would work for a phone recording or an in-person meeting you upload later |

If you're not sure, start with `fast` to get something working end to end,
then switch to `full` once you know you need better speaker separation or
non-Zoom audio.

## The web app (Record + History)

Open `http://your-server/app` from a laptop or tablet browser — this is the
part you actually interact with, separate from the Zoom automation above.

- **Record tab**: name the meeting (optional), tap the button to start
  capturing audio from that device's own microphone, tap again to stop.
  This works for *any* meeting happening in the room — Zoom or not — since
  it doesn't go through Zoom's recording pipeline at all; it uploads
  straight to the same AssemblyAI + Claude pipeline the webhook path uses.
  A minute or two later, the minutes appear right there in the browser.
- **History tab**: every meeting this project has ever produced minutes
  for — whether Zoom triggered it automatically or someone hit Record on
  a tablet — shows up here, searchable by subject.

Two things worth knowing before you rely on this:

- **Browsers require HTTPS (or `localhost`) for microphone access.** If you
  open `/app` over plain `http://` on anything other than localhost, the
  Record button will fail to get microphone permission — this is a browser
  security rule, not a bug here. See the HTTPS step in the enterprise
  section below.
- **Set `APP_PASSWORD`** before this is reachable by anyone other than you.
  With it set, both `/app` and the API behind it prompt for a shared
  password (HTTP Basic Auth — the browser handles the prompt, no login
  page to build). `/webhook` and `/health` are deliberately exempt, since
  Zoom and your uptime monitor don't know that password.

## 1. Create a Zoom Server-to-Server OAuth app

This automates *your own* Zoom account — no per-user login flow needed.

1. Go to [marketplace.zoom.us](https://marketplace.zoom.us/) → **Develop** → **Build App** → **Server-to-Server OAuth**
2. **App Credentials** tab → copy `Account ID`, `Client ID`, `Client Secret` into `.env`
3. **Scopes** tab → add `cloud_recording:read:list_recording_files:admin` (classic scope equivalent: `recording:read`)
4. **Feature** → **Event Subscriptions** → add a subscription:
   - Event notification endpoint URL: `https://YOUR_PUBLIC_URL/webhook`
   - Add events: `Recording Completed`, `All Recordings have completed` (`recording.completed`)
   - Copy the **Secret Token** shown here into `ZOOM_WEBHOOK_SECRET_TOKEN` in `.env`
5. Make sure cloud recording is turned on for the account/users you care about (Zoom web portal → **Settings** → **Recording**)

Zoom will send a CRC (Challenge-Response Check) the moment you save that
webhook URL — your server needs to already be running and reachable for
this to succeed (see local dev below).

## 2. Get the other API keys

- **AssemblyAI** (only if `TRANSCRIPTION_MODE=full`): [assemblyai.com/dashboard/signup](https://www.assemblyai.com/dashboard/signup) → API key
- **Anthropic**: [console.anthropic.com/settings/keys](https://console.anthropic.com/settings/keys)

## 3. Configure and run

```bash
cp .env.example .env
# fill in the values described above
npm install
npm start
```

## 4. Local development (Zoom needs a public HTTPS URL)

```bash
npm install -g ngrok
ngrok http 3000
# copy the https://xxxx.ngrok.io URL into the webhook subscription in step 1
```

Then hold a short test meeting with recording on, end it, and watch your
server's console — a `.json` and `.md` file should appear in `output/`
a minute or two later (recording processing isn't instant on Zoom's side).

## Enterprise deployment (meeting room / shared account use)

The setup above is enough to prove the pipeline works. Running it for a
shared meeting room adds a few things worth doing properly:

### Auto-record so nobody has to remember
Zoom web portal → **Settings** → **Recording** → turn on **Automatic
recording** (cloud) for the meeting room's user/account. Otherwise minutes
only get generated for meetings where someone remembered to click Record.

### Deploy with Docker instead of `npm start` on a laptop
```bash
docker compose up -d --build
```
`Dockerfile` + `docker-compose.yml` are included — this runs identically on
a small VPS, AWS ECS/EC2, Google Cloud Run, Azure Container Apps, or
whatever your org already uses. Put a real domain + TLS in front of it
(Zoom requires HTTPS with a CA-signed cert, not a self-signed one) — either
your platform's load balancer or a Caddy/nginx + Let's Encrypt sidecar.

### Move secrets out of the `.env` file
`.env` is fine for local testing; for a real deployment, use whatever your
host provides — AWS Secrets Manager / Parameter Store, GCP Secret Manager,
Azure Key Vault, or your container platform's built-in secret injection —
so credentials aren't sitting in plaintext on a filesystem.

### Delivery
- **Email** (`SMTP_*` + `MINUTES_RECIPIENTS` in `.env`) is wired up by
  default and sends minutes to a distribution list after every meeting —
  works with any SMTP provider (your company mail server, SES, SendGrid,
  Postmark).
- **Slack/Teams alerts** (`ALERT_WEBHOOK_URL`): fires if a meeting's
  processing fails, so a silent error doesn't just vanish into a log file
  nobody reads. Either tool's "incoming webhook" integration gives you a URL
  that drops straight into this variable.

### Data retention
- Raw audio is **never written to disk** in either transcription mode — it
  only ever exists in memory while being passed to AssemblyAI or parsed.
- Set `RETAIN_RAW_TRANSCRIPT=false` if your policy is to keep only the
  generated minutes, not a verbatim transcript.
- `output/` is a local archive/fallback. For real multi-instance use, point
  it at S3/GCS or a database instead of a container volume — the `docker-
  compose.yml` volume is a starting point, not the final answer.
- Decide who can actually read `output/` (or wherever you redirect it to) —
  meeting content is exactly the kind of thing that shouldn't be world-
  readable on a shared server. This build doesn't add its own access
  control; put it behind whatever auth your storage layer already offers.

### Recording consent / policy
Zoom shows an in-meeting recording banner automatically when cloud
recording is on, but a company recording policy (what gets recorded, who's
notified, how long it's kept, who can request deletion) is a people/legal
decision this project can't make for you — worth a short sign-off from
whoever owns that at your org before rolling this out broadly.

### Readiness / monitoring
`/health` now reports which required environment variables are missing
(`503` + a `missing_env` list) rather than just "ok" — point your
platform's health check or an uptime monitor at it.

## Testing

```bash
npm test                        # unit tests: signature math, VTT parsing, retry/backoff, HTML escaping, auth, minutes reading, prompt building
node test/integration-test.js   # boots the real server, hits it over HTTP — CRC, signatures, readiness, web app routes, auth gate
```

31 tests total (19 unit + 12 integration), all passing without any live
credentials.

**What these prove:** the webhook plumbing (CRC response, HMAC signature
verification, event routing, dedup), the retry/backoff logic, the
readiness check, the History API (list/detail, including reading real
fixture files), and the `APP_PASSWORD` gate are all correct — this is
everything that has to work for Zoom to accept your endpoint and for the
web app to behave safely, and all of it is testable without live
credentials.

**What these don't prove:** that the Zoom recordings API, AssemblyAI, and
Anthropic calls succeed against the real services, or that microphone
recording works in an actual browser — that depends on your own account
credentials/quota and on trying it from a real device. The request/response
handling for all three follows their documented formats, but the first real
webhook and the first real tap of the Record button are the real tests.

## Extending this

- **A real Slack/Teams bot post** (not just a raw webhook alert): the
  Zoom plugin in your catalog includes a Team Chat skill if you'd rather
  post a formatted card into Zoom Team Chat specifically; for Slack/Teams
  proper, swap `src/notify/alert.js`'s plain JSON POST for that platform's
  message-card format.
- **In-person recordings**: `src/transcription/assemblyai.js` doesn't know
  or care that its audio came from Zoom — point it at any uploaded audio
  file (e.g. from a phone recording) and it works the same way. That's the
  natural next step if you want this to also cover in-person meetings.
- **Zoom's Scribe API** is a separate first-party transcription service
  (different auth: Build-platform JWT rather than the S2S OAuth used here)
  worth a look if you'd rather stay entirely inside Zoom's own stack instead
  of adding AssemblyAI.
- **Storage**: swap the local `output/` files for a database or object
  storage once this moves past single-machine testing.
