Skip to content

Meetings & Minutes of Meeting

Overview

/meetings records an in-room meeting straight from the browser, runs it through the same transcription and analysis pipeline as Call Intelligence, and produces a formal Minutes of Meeting document — attendees, agenda, discussion, decisions, action items, risks and next meeting — that a person can edit, export to PDF, turn into ConnectGain tasks, and send to attendees.

This closes two gaps:

  1. Mic capture was not native. The only path into the AI pipeline was to record on a phone and upload an audio file through Call Intelligence.
  2. There was no MoM deliverable. The pipeline produced a 2–3 sentence summary and a flat action-item list, rendered read-only.

Capture is record-then-analyse, not live streaming transcription. Text appears when processing finishes, not while people are speaking.


How it fits together

Browser recorder ──► call_records (record_kind='meeting') ──► process-call-recording
 AudioWorklet Supabase Storage │
 → Worker (lamejs) call-recordings/{org}/{id}/… ├─ meeting transcription prompt
 → MP3 ├─ resumable byte-sliced passes
 → IndexedDB (crash recovery) └─ meeting analysis
 meeting_minutes
 ┌─────────────────┬─────────────────┬─────┴──────────┐
 ▼ ▼ ▼ ▼
 PDF export ConnectGain share link email
 (print pipeline) tasks (shared_links) (email-send)

The recorder

Why MP3 at 16 kHz mono / 32 kbps

Not an arbitrary choice — each number is forced by something downstream:

Constraint Consequence
process-call-recording can byte-slice only WAV and MP3 A MediaRecorder recorder emits WebM/Opus and falls into prompt-windowing, where the model is asked to seek inside an hour-long file. MP3 gets deterministic per-clip coverage.
The pipeline assumes recordings ≤ 50 MB 32 kbps mono ≈ 14.4 MB/hour, so ~3.5 hours fits. At 64 kbps a meeting could only run 1h44.
Byte-slicing converts seconds → byte offsets The bitrate must be constant. VBR would drift and mis-cut clips.
16 kHz is the speech band the model transcribes from Higher rates cost size without improving the transcript.

The hard cap is 3 h 20 m, at which point the recording finalises itself and is sent for processing — a truncated-but-processed meeting beats a rejected upload.

Audio path

getUserMedia (mono, AEC/NS/AGC)
 [+ optional getDisplayMedia audio for hybrid meetings]
 → summing GainNode (mono) → AnalyserNode (level meter)
 → AudioWorkletNode('meeting-pcm-capture')
 → Web Worker: Int16 → 1152-sample frames → lamejs
 → MP3 chunks → IndexedDB

Notes that matter:

  • The worklet is a plain pass-through. It deliberately does not reuse the voice agent's pcm-capture processor, whose RMS noise gate is right for a headset VAD loop and wrong for a room — the quiet person at the far end of the table is exactly the audio you must not drop.
  • Encoding is live. encodeVoiceMessageAsMp3 decodes a whole blob via decodeAudioData, which for an hour of audio is over a gigabyte of Float32. Incremental encoding keeps memory bounded by the MP3 itself.
  • Duration comes from encoded sample count, not wall clock. Paused time must not inflate it, or the proportional byte-slicing mis-cuts the whole file.
  • Chrome needs video: true to offer the tab-audio checkbox, and stopping the video track ends the capture — so the track is kept alive and never rendered.

Crash recovery

Every encoded chunk is written to IndexedDB (connectgain-meeting-recorder) as it is produced, with a localStorage pointer so the page can answer "is there something to recover?" before opening the database. A crashed tab, a refresh or a browser restart leaves the meeting recoverable from /meetings, which offers Save and process or Discard.

The call_records row is created only when the recording finishes — recovery creates it too. Creating it up front would leave an orphan row after an abandoned session that an AGENT could not delete (the DELETE policy is ADMIN/OWNER only), and there is deliberately no "in progress" record state as a result: an unfinished meeting exists only in the browser.

This covers tab crash, refresh, browser restart and OOM. It does not cover device loss — recovering from another machine would need part-uploads plus a server-side finalise step.

Browser support

Capability Requirement
Recording getUserMedia + AudioWorklet + Worker (secure context)
Tab/system audio getDisplayMedia with an audio track — Chromium desktop only
Wake lock navigator.wakeLock; absent on Safari/Firefox, recording still works

Data model

call_records.record_kind

Meetings share call_records with calls, separated by record_kind ('call' | 'meeting', default 'call'). A real column rather than a metadata flag because it has to be indexable and usable inside an RLS policy.

Queries that must filter:

Site Filter
the platform .eq("record_kind", "call") — the single choke point feeding the records list, analytics dashboard and all 13 report components
the platform .eq("record_kind", "call") — a meeting would skew deal sentiment averages
suggest-call-scorecard rejects meetings — a scorecard grades how an agent handled a customer

Queries that intentionally do NOT filter: CallMinutesCard, CallTokenUsageCard and admin-get-call-token-usage. Meetings consume the same minutes and tokens, so quota and billing views must count them. CallTokenUsageCard honours this through the AI ledger too: it asks the app for ["call_intelligence", "meetings"], not for calls alone. The per-feature split lives on /billing/ai-usage instead — see Org-wide AI consumption.

the platform asserts these filters, because dropping one leaks meetings into sales analytics with no error and no failing render.

Meeting visibility

The existing call_records SELECT policy resolves to agent_id = auth.uid OR can_see_all_call_records OR OWNER/ADMIN, which would hide a meeting from every attendee except whoever pressed record. A second permissive policy grants org-wide SELECT and UPDATE for record_kind = 'meeting' only, so the rule governing calls is untouched.

meeting_minutes

One row per meeting (UNIQUE (call_record_id)), holding the document sections as JSONB plus:

  • statusdraft or final. Once final, only the organiser or an admin can edit (enforced in the RLS policy, not just the UI).
  • edited_fields — section keys a human has touched. Regeneration preserves these, so correcting a name and asking for better minutes never discards the correction.
  • ai_payload — the verbatim model output, for diffing against a regeneration.
  • version — bumped on every generation.

notes is plain text on purpose. Storing rich HTML would put a sanitisation burden on the in-app view, the PDF, the emailed body and the public share page.


The AI pass

Meeting-specific prompts

Meetings branch inside process-call-recording on record_kind:

  • Transcription uses buildMeetingTranscriptionPrompt — multi-party diarisation, speakers as real names when matched to the organiser's roster and Speaker N otherwise, role: "unknown" throughout. No TRADING_VOCAB, no Broker/Client framing.
  • Analysis uses buildMeetingAnalysisPrompt, which returns the MoM JSON at max_tokens: 8192 (double the call budget — a truncated response loses the whole document).

Two prompt rules carry most of the output quality:

  1. Relative deadlines resolve against the real meeting date; anything unresolvable stays null rather than becoming an invented date.
  2. An action item's owner must be a name actually spoken, or null.

Skipped for meetings: compliance flags, agent quality, instruments, instruction type, disclosures, call type/classification, trackers, watchlist, sentiment timeline. Kept: interaction_stats, which gives useful per-speaker talk ratios for free.

Long meetings

MAX_TRANSCRIPTION_PASSES = 24 at 3-minute windows is roughly 72 minutes of coverage in theory and less in practice, since a truncated clip can burn up to three passes. A 3-hour meeting is ~60 clips — more than one invocation's wall clock allows either way.

Meetings therefore transcribe 8 clips per invocation and re-invoke themselves from metadata.transcription_progress.covered_until_sec, up to 9 invocations (≈3.5 h, matching the size ceiling). Status stays transcribing throughout, and the list shows real coverage ("Transcribing 48/96 min"). Calls keep the original single-invocation behaviour exactly.

Regenerating

generate-meeting-minutes re-runs only the analysis step from the stored transcript, so correcting attendee names costs one text call instead of two dozen audio passes. It derives the organisation from the caller's own profile, never from the request body.


The deliverables

PDF

the platform renders the document and hands it to the platform. There is no PDF library in the project by design: jsPDF's built-in fonts are WinAnsi and cannot represent Arabic at all, while the browser shapes and bidi-orders it correctly, paginates, and keeps the text selectable.

The same renderer produces the emailed body, so there is exactly one place where minutes become HTML and one escaping surface to get right. Empty sections are omitted — a short meeting should produce short minutes, not a form full of blanks.

Tasks

Task creation is explicit, unlike the call path's silent auto-create. Room audio mis-attributes owners often enough that filling other people's queues without confirmation is worse than one click. Owners are pre-selected only on an unambiguous match — a shared first name leaves the field blank rather than guessing. The created task_id is written back onto the action item, so a second click cannot duplicate it. Rows reuse the existing call_tasks join.

Sharing

Share links reuse shared_links (entity_type = 'meeting_minutes') and are served by get-shared-content under the service role. The shared payload never selects the transcript or the recording path, and with sensitive data disabled it also strips organiser notes and attendee email addresses.

Email

email-send requires a configured email_accounts row. When none exists the dialog says so and offers three working alternatives — copy the share link, export the PDF, or open the user's own mail client via mailto: — rather than failing at send time.


Billing

A meeting consumes call minutes exactly like a call: increment_call_minutes and reportCallIntelligenceOverage run unchanged, so a 60-minute meeting is 60 minutes against the 250 included, then $0.26/min. Records are tagged metadata.billable_source = 'meeting' so call and meeting spend can be split in a report later. The recorder pre-flights check_call_minutes_available and warns before an hour of audio is captured.

Availability (beta)

/meetings has its own meetings feature flag, separate from call_intelligence, because it is priced separately. During the beta it is reachable by:

  1. Appgain/Ikhair staff inside an internal organisation (RESTRICTED_FEATURES + hasInternalStaffBypass), and
  2. any organisation an admin has explicitly granted it — meetings_manual in Billing admin → Org entitlements, or meetings in enabled_features.

Everyone else sees the locked page offering the Meetings add-on at $10/month.

The add-on is not purchasable yet. ADDON_PRODUCT_IDS.meetings and ADDON_PRICE_IDS.meetings are prod_… / price_… placeholders, following the same pattern Reply Assistant uses. Someone has to create the $10/mo product and price in Stripe and paste the real ids into the platform and the platform before a customer can actually buy it. Until then the manual grant is the only route for a non-staff org.

Minutes allocation is a live question. Meetings draw on the Call Intelligence minutes pool, so an org that buys only the $10 Meetings add-on has no allocation of its own — the recorder will warn and processing may hit quota_exceeded. Either the Meetings add-on needs its own included minutes, or it should require Call Intelligence. This needs a product decision before the add-on goes on sale.

Retention for meetings is 2 years (MEETING_RETENTION_YEARS), not the 7-year brokerage horizon calls use.


Known limitations

  • No live transcription. Text appears after processing, not while people speak.
  • Recovery is same-device. IndexedDB covers tab crash, refresh and restart, not device loss.
  • Tab/system audio is Chromium desktop only, and mic AEC cannot cancel it — headphones are recommended for hybrid meetings.
  • Zoom cloud recordings are still not transcribed. zoom-webhook invokes process-call-recording with a payload it does not accept (recordingUrl/meetingTopic instead of call_record_id), so it 400s. Fixing it now means creating a record_kind='meeting' row from the Zoom payload and invoking with its id — tracked separately.