Docs / Admin
Minutes and the AI-draft approval workflow
Summary: Notes attached to a consultation — minutes, action items, decisions, insights, and follow-ups — can be typed in manually or generated by AI from an uploaded recording. This article covers both paths in detail, with the emphasis on the one non-negotiable rule in the codebase: AI-generated notes are always a draft. They do not enter the system of record until a human approves them. For where minutes fit in the overall event lifecycle, see Running a consultation.
Audience: Staff can add manual notes and upload recordings. Reviewer role or above is required to approve/reject AI drafts.
Prerequisites: A consultation record must exist. Reviewer-level role for the approval step (requireRole("reviewer", ...)).
Overview
Key concepts:
- Note (
consultation_notestable) — one of five types:minutes,action_item,decision,insight,follow_up. - Review status — a three-value enum:
pending,approved,rejected. Manual notes default toapproved. AI-generated notes are always inserted aspending. - The minutes-pipeline — an Inngest workflow (
src/lib/platform/workflows/functions/minutes-pipeline.ts) triggered by aconsultation/recording.uploadedevent, which downloads the file, extracts text, sends it to the AI provider chain, and writes pending notes. - AI provider chain — Mistral (EU-resident, primary) → Gemini 2.5 Flash → Claude Haiku, defined centrally in
src/lib/platform/ai/config.ts.
Screenshot: Notes tab on a consultation, showing an action-item note with the amber "AI draft · pending approval" badge and Approve/Reject buttons beneath it.
Adding a note manually
Path: Consultation detail → Notes tab → the "Add Note" form at the bottom.
Fields (AddNoteInput / addNoteSchema in src/lib/modules/consultations/validators.ts):
| Field | Constraint |
|---|---|
noteType | One of minutes, action_item, decision, insight, follow_up (defaults to minutes if omitted) |
content | Required, max 10,000 characters |
assignedTo | Optional uuid |
dueDate | Optional date |
addNoteAction calls services.addNote, which inserts with reviewStatus defaulting to "approved" per the AddNoteInput type comment: "Defaults to 'approved'. AI-generated drafts must pass 'pending'." Manual notes are visible in reports and outputs immediately — no review step. An note.added audit event is logged with noteType and reviewStatus in its metadata.
The Notes tab groups notes by type into five sections (Action Items, Decisions, Insights, Minutes, Follow-ups) — a section only renders if it has at least one note.
Uploading a recording for AI extraction
Path: Consultation detail → recording upload (surfaced in the UI where available for your build).
uploadRecordingAction (src/lib/modules/consultations/upload-recording.ts, requires staff role):
- Uploads the file via
uploadFromFormData(formData, "file", "consultations", consultationId)to Cloudflare R2 storage. - Fires the Inngest event
consultation/recording.uploadedwith{ consultationId, fileKey, mimeType, uploadedBy }.
There is no client-side file-type restriction visible in this action — validation of what counts as an acceptable recording format happens downstream in OCR/text extraction, not here.
The minutes-pipeline workflow, step by step
Defined once, centrally, in src/lib/platform/workflows/functions/minutes-pipeline.ts — registered with idempotency: "event.data.fileKey" and retries: 3 (capped below Inngest's default of 4, so a malformed file doesn't retry forever).
-
download-file— pulls the file from R2 viadownload(fileKey), reads it fully into a buffer. -
extract-text— runsextractText(buffer, mimeType)fromsrc/lib/platform/ocrto get a text transcript. -
ai-extract-minutes— callsextractStructured<MinutesData>(prompt, "minutes_extraction")fromsrc/lib/platform/ai. The prompt truncates the transcript to the first 8,000 characters and asks for JSON withsummary,actionItems({description, assignee, deadline}[]),decisions(string[]),attendees(string[]),nextSteps(string[]). -
load-tenant— resolves the consultation'stenant_iddirectly, since an Inngest run has no request context to read tenant scoping from; the run is skipped (status: "skipped", reason: "unresolved tenant") if this fails. -
save-notes— writes notes withreviewStatus: "pending"andcreatedBy: null, taggedmetadata: { source: "ai_minutes_pipeline" }, wrapped inwithTenant(tenantId, ...)so tenant-scoped queries resolve correctly from a backgrounded job:- One
minutes-type note fromstructured.summary(or a fallback: the first 2,000 characters of the raw transcript if no summary was extracted). - One
action_itemnote per extracted action item, content formatted as"{description} [{assignee}]"when an assignee is present, withassignedToset to the extracted assignee string. - One
decisionnote per extracted decision string.
Note: the pipeline does not currently write
insightorfollow_upnotes, and extractedattendees/nextStepsare not persisted as separate records — they inform the summary text but are otherwise discarded after extraction (verify — this may be a planned gap, not a deliberate omission). - One
-
log-run— records the run vialogWorkflowRun({ workflowKey: "minutes-pipeline", status: "success", durationMs, summary, metadata: { consultationId, actionItemCount, decisionCount } }), visible in the admin workflow run log. -
notify-reviewer— emails the consultation's creator (consultations.created_by) with subject"Minutes Pending Your Approval: {title}". Verbatim body includes: "AI-extracted minutes for {title} are awaiting your approval.", the action item/decision counts, and: "Important: These are AI-generated drafts saved as pending. They are NOT part of the consultation record until you review and approve them on the consultation page."
If no reviewer/creator email is resolvable, the notify step silently returns without sending — extraction still completes and notes are still saved as pending.
The AI provider chain and data residency
This is the same shared AI layer used across the platform (src/lib/platform/ai/) — minutes extraction is not a special case.
extractStructured() (src/lib/platform/ai/helpers.ts) internally calls generateText({ taskType: "classification", ... }) — note that despite the pipeline calling it for "minutes_extraction" conceptually, the actual AiTaskType sent through is "classification", which resolves to the same provider chain as every other task in TASK_PROVIDER_MAP:
mistral → gemini → claude_haiku
- Mistral (
mistral-large-latest) is EU-resident (France) and receives the full-fidelity, unredacted prompt. - Gemini (
gemini-2.5-flash) and Claude Haiku (claude-haiku-4-5-20251001) are non-EU. Persrc/lib/platform/ai/redact.ts, any request routed to a non-EU provider is passed throughredactPII()first, which masks email addresses ([EMAIL]), phone-shaped digit sequences ([PHONE]), and long bare digit runs of 9+ digits ([ID]) — deliberately not attempting to strip names, since that needs NER and risks corrupting the prompt. generateText()walks the chain in order, skipping any provider without a configured API key, and only falls through to the next provider on an error. It throwsAiAllProvidersFailedError(with per-provider error detail) only if every provider in the chain fails or lacks a key.
Practically: as long as MISTRAL_API_KEY is configured, transcripts stay in the EU and are never redacted. If Mistral is unavailable, the transcript sent to Gemini or Haiku will already have emails/phone numbers/long ID numbers masked.
Approving or rejecting an AI draft
Path: Consultation detail → Notes tab. Pending notes show an amber badge with the exact text "AI draft · pending approval". Rejected notes show a red "Rejected" badge.
reviewNoteAction(noteId, status, consultationId) requires reviewer role (one tier above staff in the role hierarchy public < lgu < staff < reviewer < lead < admin). It calls services.reviewNote, which writes review_status and logs note.approved or note.rejected with consultationId and noteType in the audit metadata.
- Approve →
review_status = 'approved'. The note now counts inlistNotes({ reviewStatus: "approved" })-scoped queries used by exports and reports. - Reject →
review_status = 'rejected'. The note remains in the database (not deleted) but is excluded from the system of record.
There is no bulk-approve — each pending note is approved or rejected individually from its own inline form.
Why pending notes are excluded from the record
listNotes() accepts an explicit reviewStatus filter and a pendingOnly flag (note: despite the name, pendingOnly filters on is_completed = false, not on review status — the two concepts are separate). Any downstream consumer that wants only confirmed content (exports, reports, policy-input summaries) must filter for reviewStatus: "approved" explicitly; the note itself carries no other "hidden" flag. The UI enforces the human gate by putting Approve/Reject buttons only on pending-status notes and by badge-labeling anything not yet approved.
Completing an action item
Separately from review status, an action-item note can be marked isCompleted = true via completeNoteAction → services.completeNote, logged as note.completed. This is independent of reviewStatus — in principle a pending action item could be marked complete before it's ever approved, since the code does not gate completeNote on review status (verify if this matters for your workflow; it appears to be an unenforced edge case rather than a documented rule).
Roles and permissions
| Action | Minimum role | Source |
|---|---|---|
| Add a manual note | staff | requireRole("staff", { module: "consultations" }) |
| Upload a recording | staff | requireRole("staff") in upload-recording.ts |
| Mark a note complete | staff | requireRole("staff", ...) |
| Approve / reject an AI draft | reviewer | requireRole("reviewer", { module: "consultations" }) — the only note-related action gated above staff |
Best practices
- Never treat a pending note as fact. Anything tagged "AI draft · pending approval" has not been human-verified — do not cite it in reports, exports, or downstream decisions until approved.
- Approve or reject promptly. The reviewer-notification email fires once, at extraction time. There is no reminder loop specific to stale pending minutes (the daily action-reminders workflow covers overdue action items with due dates, not pending review status).
- Check the workflow run log if extraction seems to have silently failed.
logWorkflowRunrecords every run underworkflowKey: "minutes-pipeline", visible via the automation module's run-log listing. - Confirm
MISTRAL_API_KEYis configured if EU-only processing of consultation transcripts is a compliance requirement — a fallback to Gemini or Haiku means the transcript (redacted) leaves the EU.
Warnings
AI-extracted attendees and next-steps are not persisted as separate records. The pipeline extracts them from the transcript but only feeds them into the summary text; they do not become their own note rows or update the attendee list. Do not expect the attendee table to auto-populate from a recording.
Rejecting a note does not delete it. note.rejected notes remain queryable in the database — they're excluded from approved-only views, not purged. If sensitive content needs to be fully removed, that requires a separate, explicit action outside this workflow (not present in this codebase).
The pipeline truncates transcripts to 8,000 characters before sending to the AI provider — very long recordings will have minutes generated only from the first portion of the transcript.
Troubleshooting
Minutes never appear after uploading a recording
Symptom: No new notes show up in the Notes tab after an upload.
Likely causes:
- The upload itself failed (check for an upload confirmation toast/error).
- The Inngest run failed after 3 retries (
retries: 3on the function definition) — e.g., OCR extraction failed, or the AI provider chain exhausted all three providers. - The run was silently skipped because tenant resolution failed (
status: "skipped", reason: "unresolved tenant"— an internal consistency issue, not user-facing).
Fix:
- Check the workflow run log (admin workflows view) for the
minutes-pipelineentry and its status/summary. - Re-upload if the file upload itself failed.
- Fall back to typing minutes manually in the Notes tab — manual notes are
approvedimmediately.
"AI-extracted minutes ... NOT part of the consultation record until you review and approve them"
Symptom: This exact sentence appears in the notify-reviewer email, and staff are confused about why the minutes don't show up in a report or export.
Cause: This is expected behavior, not a bug — it is the literal business rule the email is designed to communicate. Reports/exports that filter for approved content will not include pending notes.
Fix: Open the consultation, go to Notes, and click Approve on each pending item you've verified is accurate.
Reviewer button (Approve/Reject) is missing
Symptom: A staff user can see pending notes but has no Approve/Reject controls.
Cause: reviewNoteAction requires the reviewer role or above — staff alone cannot approve.
Fix: Ask an admin to promote the account to reviewer (or have someone with reviewer-level access perform the approval) at /admin/users.
FAQ
Q: Do manual notes ever need approval?
No. addNote defaults reviewStatus to "approved" — manual entry skips the review gate entirely. Only the AI extraction pipeline writes pending notes.
Q: Which AI provider actually processes the transcript? Whichever is first available in the chain — Mistral (EU) is tried first for every request; Gemini and Claude Haiku are fallbacks only, and both receive a redacted prompt.
Q: Can I re-run extraction on the same recording?
The function is idempotent on event.data.fileKey, so re-firing the same upload event for the same file key will not create duplicate note sets from that specific idempotency key. A distinct new upload (new fileKey) runs independently.
Q: What note types can an AI draft produce?
Only minutes (the summary) and action_item/decision from the structured extraction, per the current pipeline code. insight and follow_up notes are manual-only today.
Related articles
- Running a consultation — full event lifecycle including Step 6 (minutes) at a higher level
- Consultation attendees and agenda
- Roles and access