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:
- 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.
- 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-captureprocessor, 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.
encodeVoiceMessageAsMp3decodes a whole blob viadecodeAudioData, which for an hour of audio is over a gigabyte ofFloat32. 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: trueto 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:
status—draftorfinal. 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 andSpeaker Notherwise,role: "unknown"throughout. NoTRADING_VOCAB, no Broker/Client framing. - Analysis uses
buildMeetingAnalysisPrompt, which returns the MoM JSON atmax_tokens: 8192(double the call budget — a truncated response loses the whole document).
Two prompt rules carry most of the output quality:
- Relative deadlines resolve against the real meeting date; anything unresolvable stays
nullrather than becoming an invented date. - 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:
- Appgain/Ikhair staff inside an internal organisation (
RESTRICTED_FEATURES+hasInternalStaffBypass), and - any organisation an admin has explicitly granted it —
meetings_manualin Billing admin → Org entitlements, ormeetingsinenabled_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.meetingsandADDON_PRICE_IDS.meetingsareprod_…/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-webhookinvokesprocess-call-recordingwith a payload it does not accept (recordingUrl/meetingTopicinstead ofcall_record_id), so it 400s. Fixing it now means creating arecord_kind='meeting'row from the Zoom payload and invoking with its id — tracked separately.
Related¶
docs/02-user-guide/call-intelligence.md— the call pipeline these meetings shareCLAUDE.md§15 — Call Intelligence & Telephony