This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Arlo Meeting Assistant is an open-source Zoom Apps reference implementation demonstrating how to build intelligent meeting assistants that capture real-time transcripts using RTMS (Real-Time Media Streams) — without requiring a meeting bot. The app runs natively inside Zoom meetings and provides AI-powered summaries, action items, and transcript search.
Current Phase: v1.0 — see SPEC.md for the full feature specification and version milestones.
docker-compose up --build # Start all services (Postgres, Backend, Frontend, RTMS)
docker-compose up --build -V # Rebuild with fresh node_modules (use after adding/removing npm deps)
docker-compose logs -f backend # View backend logs
docker-compose restart backend # Restart specific service
docker-compose down -v && docker-compose up --build # Clean restart (deletes DB data)npm run dev # Start all services concurrently (backend + frontend + rtms)
npm run dev:backend # Backend only (nodemon for auto-restart)
npm run dev:frontend # Frontend only (CRA dev server)
npm run dev:rtms # RTMS service only
npm run setup # Install all workspace dependenciesnpm run db:migrate # Run migrations (from root)
npm run db:generate # Generate Prisma client after schema changes
npm run db:studio # Open Prisma Studio GUI at localhost:5555
npm run db:reset # Reset database (WARNING: deletes all data)
# Or from backend directory:
cd backend
npx prisma migrate dev --name description_of_change # Create new migrationcd frontend && npx react-scripts buildngrok http 3000 # Random domain (changes each restart)
ngrok http 3000 --domain=yourname-arlo.ngrok-free.app # Static domain (recommended)
# Then update PUBLIC_URL in .env-
In-Meeting Zoom App (
frontend/) — React 18 + Base UI + Zoom Apps SDK- Runs embedded in Zoom client during meetings
- Live transcript display, AI suggestions, highlights
- Start/stop RTMS via
zoomSdk.callZoomApi('startRTMS')
-
Backend API (
backend/) — Node.js/Express + PostgreSQL + Prisma- Zoom OAuth 2.0 (PKCE flow), session management with httpOnly cookies
- REST API for meetings, transcripts, search, AI, highlights
- WebSocket server for live transcript broadcast
- AI orchestration via OpenRouter (free models, no API key required)
-
RTMS Service (
rtms/) — @zoom/rtms v1.0.2- Webhook handlers for
meeting.rtms_started/meeting.rtms_stopped - WebSocket-based transcript ingestion from Zoom
- Webhook handlers for
Note: A Post-Meeting Web App (Next.js) is planned but not yet implemented.
Zoom RTMS WebSocket → RTMS Service → Backend (normalize, buffer, batch insert to Postgres)
→ WebSocket broadcast → Frontend (live transcript display, < 1s end-to-end)
Implemented in useZoomAuth hook (frontend/src/hooks/useZoomAuth.js) — single source of truth for auth.
1. Frontend: GET /api/auth/authorize → { codeChallenge, state }
2. Frontend: Register onAuthorized listener BEFORE calling authorize() (avoids race condition)
3. Frontend: zoomSdk.authorize({ codeChallenge, state })
4. Zoom fires onAuthorized → { code } (NOTE: SDK does NOT return state — use closure from step 1)
5. Frontend: POST /api/auth/callback { code, state } (credentials: 'include')
6. Backend: Exchanges code for tokens, stores AES-256-GCM encrypted in Postgres, creates session cookie
7. Frontend: login(user, wsToken) → navigate to /home
Session restoration: On app load, AuthContext calls GET /api/auth/me to restore session from httpOnly cookie. A loading spinner displays during this check to prevent auth-screen flash.
User info fallback: If user:read OAuth scope is not configured, backend decodes JWT access token payload for user ID and name.
server.js— Express app setup, middleware, route mounting, rate limiting, graceful shutdownconfig.js— Environment variable validationlib/prisma.js— Singleton PrismaClient (all route/service modules import from here)routes/— 9 route modules: auth, meetings, ai, home, rtms, search, highlights, zoom-meetings, preferencesservices/— auth (token/PKCE/encryption), openrouter (LLM), websocket (broadcast), zoomApi (Zoom REST helper with token refresh + mutex)middleware/auth.js—requireAuthandoptionalAuthsession middleware
App.js— HashRouter, route definitions, provider hierarchy: Theme → ZoomSdk → Auth → Meeting → Toastindex.css— Design tokens, typography (Source Serif 4 + Inter), light/dark theme variablesviews/— 14 views (Auth, Home, MeetingsList, MeetingDetail, InMeeting, SearchResults, Settings, Upcoming, GuestNoMeeting, GuestInMeeting, LandingPage, Onboarding, OAuthError, NotFound)contexts/— AuthContext (session), ZoomSdkContext (SDK init), MeetingContext (active meeting + WS), ThemeContext (light/dark), ToastContexthooks/useZoomAuth.js— In-client OAuth PKCE flow hookutils/formatters.js— Shared utilities (formatTimestamp, formatDuration, formatMeetingDate)components/AppShell.js— Persistent header (back, logo, search, theme toggle, settings) +<Outlet />components/ui/— Unstyled primitives: Button, Card, Badge, Input, Textarea, LoadingSpinner
backend/prisma/schema.prisma— PostgreSQL schema- Key models: User, Meeting, Speaker, TranscriptSegment, Highlight, VttFile, UserToken, ParticipantEvent
Speakerhas@@unique([meetingId, zoomParticipantId])compound constraintTranscriptSegment.seqNois UNIQUE per meeting (idempotency)- Full-text search uses Postgres GIN index on
textcolumn - All queries filtered by
ownerId(row-level data isolation) - Highlight/AiCitation timestamp fields use
BigInt(epoch milliseconds)
The root package.json defines 3 workspaces: backend, frontend, rtms. Install dependencies into a specific workspace:
npm install <package> -w backend # Add to backend
npm install <package> -w frontend # Add to frontend
npm install <package> -w rtms # Add to rtmsDocker Compose runs npx prisma db push --skip-generate (not migrations) on backend startup to sync the schema. This means schema changes via schema.prisma are applied automatically when rebuilding containers — no migration files needed for development. Use docker-compose up --build -V when npm dependencies change (recreates volumes).
The frontend uses @base-ui/react for accessible, unstyled components, styled with plain CSS and CSS custom properties.
CRA 5 does NOT support package.json exports subpath patterns. Always import from the main entry:
// CORRECT
import { Tabs, Collapsible, Tooltip } from '@base-ui/react';
// WRONG — fails at runtime in CRA
import { Tabs } from '@base-ui/react/tabs';Tabs (MeetingDetailView, InMeetingView), ScrollArea (MeetingDetailView, InMeetingView).
- CSS data attributes for state:
[data-active],[data-pressed],[data-panel-open] - Design tokens in
frontend/src/index.cssunder:root— usevar(--color-*),var(--radius-*)etc. - Dark mode:
.darkclass on<html>, toggled via ThemeContext, stored inlocalStorage('arlo-theme') - OS dark mode detection via
prefers-color-schememedia query (default when no saved preference) - Fonts: Source Serif 4 (serif headings/body) + Inter (UI chrome) — self-hosted WOFF2 in
frontend/public/fonts/ - Icons:
lucide-reactthroughout the app - Max width: 900px on
#rootwithborder-xfor contained layout - Separator component not available (CRA subpath issue) — use plain
<hr>instead
GET /api/auth/authorize— Get PKCE challenge for in-client OAuthPOST /api/auth/callback— Exchange code for tokens (in-client PKCE)GET /api/auth/me— Get current authenticated userGET /api/auth/start— Redirect to Zoom OAuth (web/Marketplace install flow)GET /api/auth/callback— Handle Zoom OAuth redirect (web flow, exchanges code with client_secret)POST /api/auth/logout— Clear session
GET /api/meetings— List user's meetings (params: from, to, limit, cursor)GET /api/meetings/:id— Meeting detailsGET /api/meetings/:id/transcript— Paginated segments (params: from_ms, to_ms, limit, after_seq)GET /api/meetings/:id/vtt— Download WebVTT filePATCH /api/meetings/:id— Rename meetingDELETE /api/meetings/:id— Delete meetingPOST /api/meetings/:id/generate-title— AI-generate title from transcript/summary
GET /api/search— Full-text search (params: q, meeting_id, from, to)POST /api/ai/chat— Chat with transcripts (SSE stream)POST /api/ai/suggest— In-meeting AI suggestions
GET /api/home/highlights— This week's meeting highlights (usesoptionalAuth)GET /api/home/reminders— Yesterday's reminders (usesoptionalAuth)
POST /api/ai/summary— Generate/cache meeting summary (cached inMeeting.summary)GET /api/meetings/:id/export/markdown— Export meeting as Markdown
- Routes in
backend/src/routes/highlights.js
GET /api/preferences— Get user preferencesPUT /api/preferences— Update user preferences (shallow merge)
GET /api/zoom-meetings— List upcoming meetings from Zoom calendar (proxiesGET /v2/users/me/meetings?type=upcoming)POST /api/zoom-meetings/:meetingId/auto-open— Register auto-open via Zoomopen_appsAPIDELETE /api/zoom-meetings/:meetingId/auto-open— Remove auto-open registration
Security: JWT token is required for all WebSocket connections. Anonymous access is not allowed.
Connection: ws://host/ws?meeting_id={uuid}&token={jwt} # token is REQUIRED
Client → Server: { type: 'subscribe', meetingId: 'uuid' }
Server → Client: { type: 'transcript.segment', data: { meetingId, segment: {...} } }
Server → Client: { type: 'ai.suggestion', data: { meetingId, suggestion: {...} } }
Server → Client: { type: 'meeting.status', data: { meetingId, status: '...' } }
Required in .env (copy from .env.example):
ZOOM_CLIENT_ID=... # From Zoom Marketplace
ZOOM_CLIENT_SECRET=...
ZOOM_APP_ID=... # Marketplace App ID (for open_apps API, different from Client ID)
PUBLIC_URL=https://... # ngrok HTTPS URL
DATABASE_URL=postgresql://... # Postgres connection string
SESSION_SECRET=... # 64 chars: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
TOKEN_ENCRYPTION_KEY=... # 64 chars (AES-256): node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
OPENROUTER_API_KEY=... # Optional — free models work without it
DEFAULT_MODEL=google/gemini-2.0-flash-thinking-exp:free
LOG_LEVEL=info # Set to 'debug' to enable transcript/PII logging- Create or edit route file in
backend/src/routes/{module}.js - Add authentication middleware from
backend/src/middleware/auth.jsif needed - Mount route in
backend/src/server.js:app.use('/api/{module}', require('./routes/{module}'))
- Update
backend/prisma/schema.prisma - Run
npm run db:migrate(orcd backend && npx prisma migrate dev --name add_table_name) - Run
npm run db:generate
- Start ngrok:
ngrok http 3000 --domain=your-domain.ngrok-free.app - Update
PUBLIC_URLin.env - Update Home URL and OAuth Redirect URL in Zoom Marketplace
docker-compose restart backend- In Zoom: Apps → Find your app → Open
- Right-click app → Inspect Element (DevTools)
- Domain Allowlist — Add
appssdk.zoom.us - OAuth Redirect URL —
https://{your-domain}/api/auth/callback - RTMS Scopes — Enable Transcripts (requires RTMS access approval from Zoom)
- Meeting Scopes —
meeting:read(upcoming meetings list),meeting:write:open_app(auto-open registration) - SDK Capabilities — See
.claude/skills/zoom-apps/02-sdk-setup.md - Home URL — Your ngrok/production URL
- Event Subscriptions —
meeting.rtms_started,meeting.rtms_stopped - App ID — Copy from Marketplace app overview page → set as
ZOOM_APP_IDin.env(different from Client ID) - App Manifest (Beta) — If enrolled in the manifest beta, upload
zoom-app-manifest.jsonto pre-configure steps 1-7 (replace placeholder URLs first). See README for details.
- Access tokens stored encrypted (AES-256-GCM authenticated encryption, 32-byte key from
TOKEN_ENCRYPTION_KEY) in Postgres, auto-refresh before expiry - httpOnly session cookies, never expose tokens to frontend
- All API calls from frontend use
fetchwithcredentials: 'include' - HTTP headers required by Zoom Apps:
Strict-Transport-Security,X-Content-Type-Options,Content-Security-Policy,Referrer-Policy - Rate limiting: Global (1000/15min), auth endpoints (30/15min), AI endpoints (20/1min) via
express-rate-limit - Ownership checks: All meeting routes enforce
ownerId: req.user.id— users can only access their own data - WebSocket auth: JWT token required for all WebSocket connections — anonymous access not allowed
- Timing-safe comparisons:
crypto.timingSafeEqualwith length checks in signature verification - RTMS webhook HMAC:
x-zm-signatureverification on backend before forwarding, with 5-minute replay protection - Token refresh mutex: Per-user lock prevents concurrent Zoom token refresh race conditions
- PII logging gated: Transcript content and user data only logged when
LOG_LEVEL=debug - RTMS service not exposed: Port 3002 is internal-only (Docker network), not mapped to host
/SPEC.md— Authoritative feature specification and version milestones.claude/skills/zoom-apps/— General Zoom Apps development guides (SDK, OAuth, RTMS, security)/docs/ARCHITECTURE.md— System architecture details/docs/PROJECT_STATUS.md— Current status and next actions/docs/TROUBLESHOOTING.md— Common issues and fixes
- No automated tests exist yet (manual testing only)
- Frontend uses CRA (react-scripts), NOT Next.js — migration to Vite planned
- If RTMS stream starts before the app is opened in a meeting, the "Start" chat notice is never sent. The disclaimer/notice should be sent when the app opens and detects RTMS is already active.
- HomeView weekly digest, action items, and recurring topics sections use hardcoded mock data (API endpoints planned)
- In-memory PKCE store does not survive restart or scale to replicas (use Redis for production)