Skip to content
Conduit

mcp server

MeetingMinutesTaker

Three-system pipeline: record meetings, generate LLM-powered minutes, search and manage them

PietKonijn007/MeetingMinutesTakercommunitylistedunreviewed

Set up

Runs on your computer. Pick your agent and operating system.

Before you start

  • uv (`curl -LsSf https://astral.sh/uv/install.sh | sh`)
~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "meetingminutestaker": {
      "command": "uvx",
      "args": [
        "meeting-minutes"
      ]
    }
  }
}

same format Cursor, GitHub Copilot CLI, Junie, JetBrains AI Assistant, Augment (Auggie CLI), Factory Droid, Kiro, Devin CLI +12

Values in angle brackets are placeholders. Restart Claude Desktop after editing its config.Claude Desktop docs

README

synced Oct 23, 2026

Meeting Minutes Taker

Stop losing what happened in meetings. Record any meeting — Zoom, Teams, Slack, Google Meet, or in-person — and get structured, searchable minutes automatically. No cloud recording bots that show up as participants. No monthly SaaS subscriptions. Everything runs locally on your own hardware.

Why this exists

You spend hours each week in meetings. Key decisions get made, action items get assigned, follow-ups get promised — and most of it evaporates within 24 hours. Generic meeting bots create walls of text nobody reads. This tool produces minutes people actually use: structured by meeting type, with decisions, action items, risks, and follow-ups extracted into dedicated sections.

What makes it different

  • Private by design — Audio capture happens at the OS level via system audio loopback. Nothing joins your meeting as a participant. Nothing leaves your machine unless you choose a cloud LLM. Run fully offline with Whisper + Ollama.
  • Meeting-type intelligence — A standup gets a per-person Done/Today/Blockers breakdown. A decision meeting gets an options matrix with rationale. A customer call gets client requests and commitments. An incident review gets a blameless timeline and contributing factors. 18 built-in templates covering the real exec calendar (board, leadership, vendor, architecture review, interview debrief, three 1:1 variants, and more) — fully customizable.
  • Talk to your meetings — Ask "What did Jon commit to about lead times since April?" and get a synthesized answer with citations to the specific meetings, powered by local semantic search over your entire meeting history.
  • Speaker identification — Automatic speaker diarization labels who said what. Enter names once and they propagate through transcripts, minutes, action items, and decisions.
  • Action items you actually own — Extracted action items land as proposals on the meeting page. You Accept (turns into a tracked action), Edit (fix wording / owner / due date before accepting), or Reject the LLM's suggestions. Only confirmed actions reach the global tracker, the prior-action carryover that feeds the next meeting's prompt, the Obsidian export, and the DOCX export — so the tracker stays a list of things you actually agreed to, not a stream of LLM guesses.
  • Actually searchable — Full-text keyword search AND semantic vector search across all transcripts, minutes, and structured data. Find meetings by what was discussed, not just when they happened.
  • Runs on your hardware, optimized for it — Auto-detects Apple Silicon (Metal), NVIDIA (CUDA), AMD (ROCm), or CPU and configures Whisper and pyannote accordingly. A 13-minute meeting transcribes in under a minute on an M-series Mac or modern GPU.

Platform support

PlatformStatusNotes
macOS (12+, Apple Silicon or Intel)✅ First-classTurnkey: install.sh wires up BlackHole audio loopback, launchd auto-start, Metal acceleration. Most tested path.
Linux (x86_64 or ARM64)✅ SupportedPipeline works fully. Audio loopback setup is manual (PipeWire/PulseAudio monitor source or snd-aloop). CUDA and ROCm auto-detected.
Windows⚙️ PartialPipeline, LLM, and search all work. Audio loopback setup (WASAPI) is manual — no install script yet.

If you're on macOS, everything works out of the box. On Linux/Windows, the processing pipeline runs great; you'll just need to point the app at the right audio device yourself.


How It Works

Record Audio ──► Transcribe ──► Generate Minutes ──► Store & Search
  (System 1)      (Whisper)      (Claude / Ollama)     (SQLite + FTS5)

System 1 — Recording & Transcription: Captures audio from virtual and physical meetings via system audio loopback (BlackHole on macOS), transcribes with a pluggable transcription engine — Faster Whisper (CTranslate2, default) or Whisper.cpp (GGML quantized, lower memory) — including Distil-Whisper models with Metal/CUDA acceleration. Identifies speakers with pyannote.audio (GPU-accelerated via MPS/CUDA), maps speaker labels to user-provided names in first-speaking order, enriches with calendar metadata, and supports live note-taking during recording. Separate mm rediarize command can re-run speaker diarization on existing audio without re-transcribing. Hardware auto-detection recommends optimal models for your GPU/RAM.

System 2 — Minutes Generation: Auto-detects meeting type using an LLM classifier (Claude Haiku, with a calendar-title + content + attendee-count keyword fallback), routes transcripts to meeting-type-specific prompt templates, generates structured minutes via LLM. Supports four providers: Anthropic Claude (tool_use for guaranteed JSON), OpenAI, OpenRouter (200+ models), and Ollama for fully local/offline summarization (JSON-mode structured generation). Produces four layers of output per meeting: a ~100-word executive TL;DR, a short summary, a detailed_notes narrative (length controlled by generation.length_mode = concise / standard / verbose), and structured lists for decisions, action items, risks, open questions, follow-ups, and a ready-to-send email draft. Action items are emitted as proposals that you confirm before they enter the tracker (see "Action item review workflow" below). Extracts sentiment, meeting effectiveness, a confidentiality classification, and carries forward only confirmed open action items from prior meetings — automatically closing them in the DB when they're acknowledged as done in a later meeting. Vendor-feedback sub-sections are driven by a configurable generation.vendors list (default [AWS, NetApp]). Supports custom LLM instructions provided during recording.

System 3 — Storage & Search: Stores everything in SQLite with full-text search (FTS5) and semantic vector search (via sqlite-vec + sentence-transformers). Provides a CLI for searching, browsing, and managing meetings and action items. "Chat with your meetings" feature uses RAG (Retrieval-Augmented Generation) to answer natural-language questions across all your meeting history — e.g., "Summarize all actions Jon Porter has taken on lead times since April 1st". Supports encryption at rest, configurable retention policies, and in-calendar search with filters.

Supported Meeting Types

Every template emits a shared baseline — TL;DR, decisions, action items, risks, open questions, confidentiality classification, and a follow-up email draft — plus the type-specific sections below. Empty sections are omitted (no "Not discussed" filler).

Team & cadence

TypeTemplate Focus
standupPer-person Done / Today / Blockers
team_meetingDecisions, financial review, blockers, strategic updates
retrospectiveWent well, didn't go well, improvements
planningGoals, tasks, estimates, risks
brainstormIdeas generated, themes, top ideas
decision_meetingOptions, pros/cons, decision, rationale, reversibility

1:1 (perspective-aware)

TypeTemplate Focus
one_on_one_direct_reportManager→report: mood, wins, objectives, blockers, feedback, coaching, engagement signals
one_on_one_leaderUser→boss/skip-level: direction received, leader commitments, political/strategic context
one_on_one_peerPeer 1:1: alignment, disagreements, cross-team dependencies, commitments both ways
one_on_oneGeneric 1:1 fallback when the perspective isn't identifiable

Exec & cross-functional

TypeTemplate Focus
leadership_meetingPeer-exec staff meeting: cross-functional decisions, priority trade-offs, resource allocation
board_meetingBoard/investor update: resolutions, management update, financial update, asks of the board
architecture_reviewADR-style: problem, options matrix, decision, reversibility, migration plan
incident_reviewBlameless post-mortem: timeline, contributing factors, prevent/detect/mitigate actions

External

TypeTemplate Focus
customer_meetingClient requests, service feedback, blockers, commitments both ways, next steps
vendor_meetingQBR / procurement: vendor commitments, roadmap, SLA performance, pricing, our asks
interview_debriefHire/no-hire recommendation with per-competency evidence and level fit

Fallback

TypeTemplate Focus
generalUsed when classification confidence is low; TL;DR + decisions + actions + open questions

Quick Install

git clone https://github.com/PietKonijn007/MeetingMinutesTaker.git
cd MeetingMinutesTaker
./install.sh

The install script will:

  • Check Python 3.11+ and Node.js (installs via Homebrew if missing)
  • Install BlackHole 2ch for audio capture
  • Create a Python virtual environment and install all dependencies
  • Build the web frontend
  • Initialize the database
  • Prompt for API keys (Anthropic, HuggingFace)
  • Set up auto-start on login (macOS Launch Agent)

After installation:

mm service start              # Start the server
open http://localhost:8080     # Open the web UI

Service Management

mm service install    # Install auto-start (runs on login)
mm service start      # Start now
mm service stop       # Stop the server
mm service status     # Check if running
mm service logs       # View server logs
mm service logs -f    # Follow logs in real-time
mm service uninstall  # Remove auto-start

Quick Start (Manual)

Prerequisites

Install

git clone https://github.com/PietKonijn007/MeetingMinutesTaker.git
cd MeetingMinutesTaker
pip install -e ".[dev]"
mm init

Set API keys

export ANTHROPIC_API_KEY="sk-ant-..."    # Required for minutes generation (Anthropic provider)
export HF_TOKEN="hf_..."                 # Required for speaker diarization
export OPENAI_API_KEY="sk-..."           # Optional (OpenAI provider or fallback)
export OPENROUTER_API_KEY="sk-or-..."    # Optional (OpenRouter provider — access 200+ models)
export MM_API_KEY="your-secret-key"      # Optional — protects the REST API (see Security below)
# No API key needed for Ollama — it runs locally

Or create a .env file at the project root (takes priority over environment variables):

ANTHROPIC_API_KEY=sk-ant-...
HF_TOKEN=hf_...
OPENAI_API_KEY=sk-...
OPENROUTER_API_KEY=sk-or-...
MM_API_KEY=your-secret-key               # Optional — protects the REST API
OLLAMA_BASE_URL=http://localhost:11434   # Optional, defaults to localhost

Configure audio

Pick one backend (both are auto-detected — see User Guide §3 for full instructions):

  • BlackHole (free, default). Set your system output to Meeting Output (Multi-Output Device sending audio to speakers + BlackHole). Select Meeting Capture (Aggregate Device combining mic + BlackHole) as input, or use auto-detect.
  • Rogue Amoeba Loopback ($99, optional). One virtual device combining mic + system audio. No Multi-Output or Aggregate Device needed. Name the device "Meeting Capture" and auto-detect handles the rest. See User Guide §3.9.
# config/config.yaml
recording:
  audio_device: "Meeting Capture"   # name works for either backend; or "auto"

Leave audio settings in Zoom/Meet/Teams at their defaults — the system-level routing handles everything.

Record a meeting

mm record start       # Begin recording
# ... attend your meeting ...
mm record stop        # Stop, transcribe, generate minutes, index

Search and browse

mm search "database migration"                    # Full-text search
mm search "budget" --type decision_meeting        # Filter by type
mm search "sprint" --after 2026-03-01             # Filter by date
mm list                                           # Recent meetings
mm show <meeting_id>                              # View meeting details
mm actions                                        # Confirmed open action items (the tracker)
mm actions --proposed                             # Proposals awaiting your review
mm actions --all-states                           # Confirmed + proposed + rejected
mm actions --owner alice@company.com              # Filter by owner
mm actions complete <action_id>                   # Mark done

CLI Reference

CommandDescription
mm initCreate database and data directories
mm record startStart recording
mm record stopStop recording and process
mm search <query>Full-text search (supports --type, --after, --before)
mm listList meetings (supports --person, --limit)
mm show <id>Show meeting details and minutes
mm actionsList confirmed open action items (supports --owner, --overdue, --status, --proposed, --all-states)
mm actions complete <id>Mark action item as done
mm generate <id>Generate minutes from transcript
mm reprocess <id>Re-run generation + ingestion (skips transcription/diarization)
mm rediarize <id>Re-run speaker diarization on existing audio (skips transcription)
mm status <id>Show per-stage pipeline state for a meeting (capture → transcribe → diarize → generate → ingest → embed → export)
mm resume <id>Resume pipeline from the first non-succeeded stage (supports --from-stage, --all)
mm doctorRun eleven first-run diagnostic checks (Python, ffmpeg, audio device, tokens, LLM reachability, DB, disk, GPU, models, sqlite-vec, WeasyPrint natives). Supports --json. Exits non-zero on any failure.
mm repairRun startup health checks and optionally rebuild derived indexes (FTS, embedding vectors, voice samples). Supports --dry-run, --check=<name>, --yes.
mm embedGenerate semantic search embeddings for all meetings (run once to backfill)
mm embed <id>Embed a single meeting
mm series detectDetect recurring-meeting series (REC-1) and upsert them
mm series listList all detected series with cadence + member count
mm series show <id>Show detail for a single series
mm stats rebuildRebuild the topic-clusters cache used by the Stats → Topics panel (ANA-1)
mm export <id>Export a meeting to PDF / DOCX / Markdown (supports --format, --with-transcript, --out)
mm export --series=<id>Bulk-export every meeting in a series as a ZIP bundle
mm delete <id>Delete meeting and all associated data
mm cleanupRun retention policy cleanup (delete expired data)
mm generate-keyGenerate a new encryption key for at-rest encryption
mm copilot <person>Generate a 1:1 prep dossier — commitments, sentiment, talk patterns, talking points (supports --focus, --json)
mm mcpStart the MCP server (stdio transport) for AI agent integration (Claude Code, Cursor, etc.)
mm serveStart the web UI + API server (supports --host, --port; refuses to drift if the port is busy)
mm upgradePull latest code from main and rebuild (supports --branch override)
mm service installInstall macOS Launch Agent for auto-start on login
mm service uninstallRemove the macOS Launch Agent
mm service startStart the service now
mm service stopStop the service
mm service statusShow service status and API health
mm service logsShow server logs (supports --follow, --lines)

MCP Server (AI Agent Integration)

The built-in MCP server lets AI agents (Claude Code, Cursor, Windsurf, etc.) query your meeting history, search transcripts, manage action items, and post annotations — all through the Model Context Protocol.

Setup for Claude Code — add to your project's .claude/settings.json (or ~/.claude/settings.json for global):

{
  "mcpServers": {
    "meeting-minutes": {
      "command": "mm",
      "args": ["mcp"]
    }
  }
}

Available tools:

ToolDescription
list_meetingsBrowse meetings with type/date filters
get_meetingFull detail: minutes, attendees, action items, decisions
searchFull-text search with inline filters (type:standup after:2026-01-01)
list_action_itemsOpen/overdue items, filterable by owner/status
update_action_itemChange status, owner, or due date
get_transcriptRaw transcript text
list_decisionsDecisions filtered by meeting or person
post_noteAttach an annotation (question/blocker/followup/observation)
list_notesList annotations on a meeting
list_seriesRecurring meeting series
one_on_one_prepGenerate a 1:1 prep dossier for a person

Resources (auto-loaded context):

  • meetings://recent — last 5 meetings
  • meetings://actions/open — all confirmed open action items

Pipeline state machine

Every meeting flows through a seven-stage pipeline: capture → transcribe → diarize → generate → ingest → embed → export. Each stage's status (pending / running / succeeded / failed / skipped), attempt count, timestamps, and last error are persisted in the pipeline_stages table, so a crash or failure at any stage is visible and recoverable.

  • Run mm status <meeting_id> to see the current stage table.
  • Run mm resume <meeting_id> to re-run from the first non-succeeded stage; pass --from-stage=<stage> to override the starting point or --all to resume every meeting with a failed stage.
  • On server restart (mm serve lifespan): any stage still marked running beyond the interruption threshold (30 min) is flipped to failed with last_error='interrupted'. The server does not auto-resume — the user decides when to re-run.
  • Retention cleanup preserves audio for meetings whose pipeline has not reached a terminal state, so mm resume remains possible until the meeting's final stages succeed or are skipped.

REST equivalents: GET /api/meetings/:id/pipeline, POST /api/meetings/:id/resume, GET /api/pipeline/interrupted.

First-run diagnostics (/onboarding)

On first launch with an empty database, mm serve routes the browser to /onboarding, which calls GET /api/doctor and renders each of the eleven checks as a green/yellow/red card with a copy-paste fix command. Every failing check has its own Retry button so you can fix the underlying issue and re-check without leaving the page. From the command line, mm doctor prints the same table; add --json for programmatic consumption.

Disk-space preflight (DSK-1)

Before starting a recording the web UI calls GET /api/recording/preflight?planned_minutes=<n> and compares the estimated FLAC size against the free space on the data_dir partition. The tier drives a warning modal:

  • green — silent start
  • yellow / orange — warning dialog with the twenty oldest audio files eligible for safe deletion (only meetings whose pipeline has reached a terminal state appear), and a "Start anyway" button
  • red — same cleanup UI but requires an explicit "Yes, I understand" double-confirm

A watchdog thread polls free disk space every 30 seconds while recording and triggers a graceful stop if free space drops below half of the remaining estimate — this guarantees a valid (possibly truncated) FLAC rather than a corrupted tail. mm record start in non-interactive mode (launchd) refuses red-tier preflights; interactive shells always let you override with --force.

Passive speaker identity (SPK-1)

There is no separate "enroll your voice" step. Speaker centroids are learned from real meeting audio: each time you name a speaker (e.g. SPEAKER_00 → Jon) in the Transcript tab, the mean embedding for that cluster is saved as a voice sample for Jon. On subsequent meetings, every new cluster is scored against every known person's centroid via cosine similarity:

  • ≥ 0.85 — the Transcript tab pre-fills the name with a green suggested badge
  • 0.70 – 0.85 — pre-fill with a yellow ? Name badge; one click to accept, or type a different name to override
  • below 0.70 — field is blank; if the cluster has more than 30 seconds of speech, an inline "Create new person" row appears (name + optional email, POST /api/persons) so you can name them without leaving the page

Corrections are handled explicitly: relabeling a cluster from Jon to Sarah flips Jon's prior sample to confirmed=false so it stops contributing to his centroid. Clusters with less than 5 seconds of speech produce no sample at all (too noisy). Only the 20 most recent confirmed samples contribute to any given centroid, so representations stay current even as a voice changes over time. Samples live in person_voice_samples and are deleted by ON DELETE CASCADE when the person or meeting is removed — nothing extra to configure for retention.

Recurring-meeting series (REC-1)

Meetings that share the exact attendee set and meeting type across 3+ instances are grouped into a series — think "weekly 1:1 with Jon" or "biweekly planning with the core team". Detection runs automatically after every pipeline completion (best-effort, never breaks the pipeline) and on demand via mm series detect or POST /api/series/detect. The cadence is classified from the median inter-meeting interval (weekly / biweekly / monthly / irregular).

Browse series at /series — click through to /series/:id for:

  • A chronological timeline of all member meetings
  • Cross-meeting open action items with a "first seen" meeting link
  • Recent decisions across every instance
  • Recurring topics (parking-lot entries + discussion points that appear in ≥ 2 members)

Every meeting detail page shows a "Part of series: {title} →" pill when the meeting belongs to a series. Storage: meeting_series + meeting_series_members.

Cross-meeting analytics (ANA-1)

The Stats page adds four tabbed panels backed by pure SQL (no new persistent storage except the topic-cluster cache):

  1. Commitments — per-person assigned / completed / overdue action-item counts over a rolling 90-day window, with a 12-week completion sparkline.
  2. Topics — embedding clusters of discussion_point and parking_lot chunks that show up in ≥ 3 meetings without a decision whose embedding is close (cosine ≥ 0.7). Backed by a topic_clusters_cache table rebuilt lazily on page load if older than 24 h or manually via mm stats rebuild. Degrades gracefully to an empty state when sqlite-vec isn't loadable.
  3. Sentiment — numeric timeseries using the mapping positive=1.0, constructive=0.7, neutral=0.5, tense=0.3, negative=0.0. Supports per-person or per-type filters via query params.
  4. Effectiveness — per meeting type, % of meetings with each meeting_effectiveness attribute set (clear agenda, decisions made, actions assigned, unresolved items).

Every endpoint also accepts a series=<series_id> query parameter to scope the analytics to a single series's members.

Pre-meeting briefings (BRF-1)

The /brief page pulls together everything you already know about a set of attendees before you walk in: last meeting, cadence, matching series, open commitments (with overdue flagged), unresolved topics, recent sentiment sparklines, recent decisions, and the top relevant transcript excerpts. A pinned "Start recording" footer lets you kick off a session with a pre-filled title, meeting type, attendee list, and a carry-forward note built from the overdue commitments.

Deep-links:

  • /people/:id has a Start a briefing → button.
  • /series/:id has a Start a briefing for the next one → button that passes all attendees + the meeting type through to /brief.

All six sections are pure SQL queries over existing tables — no LLM call by default. Flip brief.summarize_with_llm: true in config.yaml to attach a two-sentence synthesis built from the aggregates.

Topic + focus-area briefs (BRF-2)

On top of BRF-1 the brief endpoint accepts a topic (free-text description of what the meeting is about) and a list of focus_items (specific things to look for, e.g. "Outstanding asks from Jon", "What did we decide about SLA penalties?"). The brief reroutes its context-excerpts retrieval to the topic and runs an independent RAG pass for each focus item, producing a "What you asked about" section with grounded answers and citations to the action items / decisions / transcript chunks they came from.

When focus retrieval finds nothing relevant (similarity below brief.focus.min_score) the answer is the literal "No relevant history found." and no LLM call is made — keeping briefs cheap and free of hallucinated answers.

The brief can be downloaded as a stable, deterministic markdown file (GET /api/brief/export?format=md) or generated from the CLI:

mm brief --attendees "Jon Porter,Sarah Lee" \
         --topic "Q3 vendor pricing review" \
         --focus "Outstanding asks from Jon" \
         --focus "What did we decide about SLA penalties?"

Briefs are persisted under data/briefs/ (markdown + JSON sidecar) and cached in the meeting_briefs table — re-requesting an unchanged brief returns the cached row without re-running retrieval or the LLM. There is no scheduled / background generation; briefs are produced only on demand.

When brief.talking_points.enabled is on (defaults to following summarize_with_llm), the brief also includes a short list of LLM-generated suggested talking points. Every emitted point must cite a concrete artifact already in the payload — a post-generation validator drops uncited points to prevent generic "discuss progress" filler.

Desktop notifications (NOT-1)

macOS Notification Center alerts fire on two pipeline events: every successful completion (Meeting ready: <title> with duration and action-item count) and any stage failure (Pipeline failed: <title> with stage + short error). Clicking the banner opens the meeting in the web UI. Ships disabled on non-macOS (via pync, which is macOS-only). Toggle in config.yaml:

notifications:
  enabled: true             # defaults to platform detection (true on darwin)
  sound: true
  click_url_base: http://localhost:8080/meeting

Exports (EXP-1)

Export meeting minutes to PDF, DOCX, or Markdown — with an optional --with-transcript flag to append the full diarized transcript. The meeting detail page has an Export ▾ menu; the series page has an Export all meetings (ZIP) button that bundles every member meeting into one archive.

mm export <meeting_id> --format=pdf --with-transcript
mm export <meeting_id> --format=docx --out ~/Downloads/minutes.docx
mm export --series=<series_id> --format=pdf --out ~/Documents/

The HTTP surface mirrors the CLI: GET /api/meetings/:id/export?format=pdf|docx|md[&with_transcript=true] and GET /api/series/:id/export?format=.... A missing native dep (WeasyPrint requires libpango, python-docx is pure-Python) yields a clean 501 with an install hint instead of a crash.

On macOS the WeasyPrint native libs (pango, cairo, gdk-pixbuf, libffi) install automatically via install.sh and mm upgrade, and DYLD_FALLBACK_LIBRARY_PATH is set at process startup — fresh installs don't need any manual steps. mm doctor check #11 flags the dependency as warn (PDF export is optional) if anything is missing.

DOCX users can drop a styled template at templates/export/docx_template.docx; python-docx inherits its heading + paragraph styles.

Action item review workflow (proposed → confirmed)

Action items extracted from a transcript are proposals, not commitments. Every newly-extracted item lands with proposal_state = "proposed" and stays out of:

  • the global /actions tracker (default filter is Confirmed)
  • the prior-action carryover injected into the next meeting's prompt
  • the rendered ## Action Items section in data/minutes/{id}.md
  • the Obsidian export, DOCX export, embeddings used by the chat feature, and the open_actions count on the Stats overview

Until you review them. The meeting detail page's Actions tab shows a "N proposed actions to review" banner with Accept all / Reject all, plus per-row Accept · Edit · Reject controls. Editing lets you fix the wording, owner, or due date before accepting. Rejected items are kept as proposal_state = "rejected" so the same suggestion isn't re-confirmed by accident on regenerate.

On every Accept / Reject the server re-renders the on-disk minutes JSON + Markdown, refreshes the FTS index, and re-exports to Obsidian — so the curated set is what shows up in search, exports, and your vault. Regenerate a meeting and the items go back to proposed (you re-review). The ## Action Items section in the markdown is empty until at least one item is confirmed.

The global /actions page has a chip filter — Confirmed (default) / Proposed / All — so you can sweep an entire backlog from one place. A small "Confirm all proposals from before a date…" affordance is there for the one-time clear right after upgrading: the migration that introduces this workflow flips every historical action item to proposed, so on first run after the upgrade you'll see them all queued for review.

REST surface:

  • GET /api/action-items?proposal_state={confirmed|proposed|rejected|all} — defaults to confirmed.
  • PATCH /api/action-items/{id} — accepts {status, proposal_state, description, owner, due_date} (all optional).
  • POST /api/action-items/bulk-review/{meeting_id} — body {confirm: [ids], reject: [ids]}.
  • POST /api/action-items/confirm-before — body {before_date: "YYYY-MM-DD"}, the post-migration backlog clear.

Web UI

A browser-based interface built with Svelte + Tailwind CSS at localhost:8080 with calendar view, action items, decisions, people, series (REC-1), stats (with four ANA-1 tabbed panels), recording controls, template manager, and settings.

# Start the server
mm serve

# Open in your browser
open http://localhost:8080

Pages: Meetings (calendar view with day list + inline detail + search with filters; meeting cards show an "N to review" badge when the meeting has proposed actions waiting), Chat (talk to your meetings — ask natural-language questions across all meeting history with citations), Brief (BRF-1 pre-meeting briefing with six data sections + inline Start Recording panel), Meeting Detail (Actions tab surfaces the Accept / Edit / Reject review banner for proposed items), Action Items (Confirmed / Proposed / All chip filter + admin "Confirm all proposals from before a date" sweep), Decisions, People, Stats (charts), Record (live waveform + concurrent pipeline status + live note-taking), Templates (view/edit/create prompt templates), Settings (LLM provider/model selection with custom model support, Performance & Hardware, Security with API key authentication and encryption at rest, Retention, and CORS config).

Features: Dark mode, full-text search with Cmd+K, in-calendar search with type filter chips, keyboard navigation, responsive layout, meeting type color coding, WebSocket-based real-time updates, concurrent pipeline processing (record a new meeting while the previous one processes in background), auto-detect capture device, auto-save recovery every 5 minutes during recording, live note-taking during recording (title, speaker names, notes, custom LLM instructions) with markdown support, a single PREVIEW toggle that pairs the editor with a live rendered preview pane, a 2D-resizable notes textarea, tab-switch persistence (form fields survive navigation away from /record until Stop is pressed), and a confirm-gated Cancel button that discards the in-flight audio without producing a meeting record, inline-editable meeting title on the detail page (rewrites the embedded title in the minutes JSON/MD, refreshes the FTS index, and renames the Obsidian export), structured card-based minutes view with collapsible discussion topics, color-coded transcript per-speaker with inline "Name speakers" editor, post-hoc external-notes tab (paste notes from Teams/Zoom/Meet/Otter to auto-rename speakers + regenerate the summary), people management (edit / delete / merge duplicate entities with automatic historical attribution updates), Performance & Hardware settings (Apple Silicon MPS toggle), encryption at rest, retention policies with automatic cleanup.

Meeting titles

Pick a title up-front during recording, or leave it blank and the LLM will generate one from the transcript. Either way, the title is editable post-hoc on the meeting detail page (click the Edit button next to the heading). A rename:

  • updates the title in the database and the full-text-search index,
  • rewrites the # Title heading inside data/minutes/{id}.md and the metadata.title field inside data/minutes/{id}.json,
  • mirrors the new title to the notes sidecar so a later regeneration won't overwrite it,
  • and renames the Obsidian export from {date} {old_safe_title}.md to {date} {new_safe_title}.md.

Internal data files under recordings/, transcripts/, minutes/, and notes/ are keyed by meeting_id (UUID) and are not renamed — only their contents change where the title is embedded.

External notes (post-hoc)

Each meeting detail page has an External notes tab between Minutes and Transcript. Paste the notes exported from a meeting app (Teams, Zoom, Google Meet/Gemini, Otter, etc.) and hit Save & update minutes. The server:

  1. Archives the verbatim paste to data/external_notes/{meeting_id}.md (one file per meeting, overwritten on resubmit).
  2. Kicks off a background job that (a) asks the LLM to map SPEAKER_xx labels to real names using the external notes as ground truth, and (b) re-runs the full minutes-generation pipeline with the notes injected as additional context.
  3. Appends a verbatim ## External notes section to the meeting's markdown (and Obsidian file) so your raw paste is preserved through any future regeneration.

The tab shows a status pill (processing / ready / error) and polls every few seconds — no manual refresh needed. Submission is async: the endpoint returns 202 immediately; the background reprocess typically takes 15–60 s depending on the model and transcript length.

Attachments — files, links, and pasted images

Each meeting has an Attachments tab on the detail page; the same panel also appears on the Record page once a recording starts so you can attach context mid-meeting (drop in a slide deck, paste a chart screenshot, drop a link to the spec being discussed).

Supported inputs:

  • Files: PDF, DOCX, PPTX, PNG, JPG, HEIC. 50 MB cap.
  • Links: any HTTP(S) URL. Fetched server-side; readable text extracted via trafilatura.
  • Pasted images: clipboard image paste captured globally on the page.

Per attachment, the worker runs in the background:

  1. Extracts text — text-layer for PDF, OCR fallback for scanned PDFs (tesseract + poppler), full-document order for DOCX, slide titles + body + speaker notes for PPTX, OCR for images, readable-text extraction for links.
  2. Stores the extracted content as a sidecar markdown at data/attachments/{meeting_id}/{attachment_id}.md.
  3. Calls the LLM with a separate prompt (tiered length: short for screenshots, comprehensive for long docs) to produce a summary that grounds the extracted text. The system prompt forbids paraphrasing numbers, dates, and proper nouns — verbatim quotes only.
  4. Marks the attachment ready.

When minutes are generated (or regenerated) for the meeting, the pipeline:

  • Briefly waits for any in-flight summaries.
  • Splices a ## ATTACHED MATERIAL block into the LLM prompt — the model treats it as ground truth.
  • Post-appends a verbatim ## Attachments section to the rendered minutes (and the JSON's embedded markdown) listing each attachment with its summary and a link to the raw source, so a reader can crosscheck the minutes against the original material.

System dependencies (auto-installed by install.sh on macOS): tesseract (image and scanned-PDF OCR), poppler (rendering scanned PDFs to images for OCR). mm doctor reports both as warnings (not failures) — image and scanned-PDF attachments need them, but other kinds work without.

Attachments also feed into:

  • Search. Extracted attachment text is folded into the FTS5 minutes index, so keyword search hits the body of attached PDFs / DOCX / OCR'd images.
  • Chat. Each ready summary contributes one attachment_summary chunk to the embedding store, so "what did the Q3 deck say about EMEA?" surfaces the right material with a clear citation.
  • Speaker rename. During minutes generation, the LLM that maps SPEAKER_xx labels to human names also sees attachment summaries — title-slide presenter names and explicit attendee lists are useful tie-breakers (weighted lower than meeting-app notes, since attachment authors aren't always participants).
  • Long documents. Inputs over ~100k characters get a two-phase map-reduce summarization (chunk-by-chunk → synthesis) so book-length PDFs are summarized end-to-end rather than truncated.

API:

  • POST /api/meetings/{id}/attachments — multipart upload (file).
  • POST /api/meetings/{id}/attachments/link — JSON { url, title?, caption? }.
  • GET /api/meetings/{id}/attachments — list with status.
  • GET /api/attachments/{id} — detail (parsed sidecar, summary, extracted text).
  • GET /api/attachments/{id}/raw — original bytes (or 410 if pruned by retention).
  • DELETE /api/attachments/{id}.

Development (with hot reload):

mm serve                          # API on :8080
cd web && npm install && npm run dev   # Svelte on :3000, proxies /api → :8080

REST API

The backend is a FastAPI application serving at :8080 covering meetings, search, action items, decisions, people (including POST /api/persons for inline creation), stats, recording, speaker suggestions (GET /api/meetings/:id/speaker-suggestions), series (GET /api/series, GET /api/series/:id, POST /api/series/detect, GET /api/meetings/:id/series), analytics panels (GET /api/stats/commitments, /api/stats/sentiment, /api/stats/effectiveness, /api/stats/unresolved-topics), and configuration. When MM_API_KEY is set, all endpoints require an X-Api-Key header (except /api/health). Auto-generated interactive API documentation is available at http://localhost:8080/docs.

Configuration

Configuration lives at config/config.yaml (fallback: ~/.meeting-minutes/config.yaml).

Key settings:

user:
  person_id: ""                   # Link to a person in the DB (optional, overrides name)
  name: "Jurgen"                  # Your name — used in prompts and copilot
  role: "Engineering Manager"     # Your role — adds context to meeting minutes

pipeline:
  mode: automatic              # automatic | semi_automatic | manual

recording:
  audio_device: "Meeting Capture"
  sample_rate: 16000
  auto_stop_silence_minutes: 5

transcription:
  primary_engine: whisper            # whisper | whisper-cpp
  whisper_model: medium              # tiny | base | small | medium | large-v3

diarization:
  enabled: true
  engine: pyannote                  # pyannote (local) | pyannote-ai (cloud) | pyannote-mlx (Apple Silicon)
  model: pyannote/speaker-diarization-community-1
  # Cloud backend (engine: pyannote-ai) — paid, hosted by the pyannote authors.
  # Install: pip install -e '.[diarize-cloud]'  + set $PYANNOTEAI_API_KEY
  pyannote_ai:
    tier: community-1               # community-1 (€0.04/hr) | precision-2 (€0.11/hr, best DER)
  # MLX backend (engine: pyannote-mlx) — experimental Apple Silicon path.
  # Install: pip install -e '.[diarize-mlx]'

generation:
  llm:
    primary_provider: anthropic       # anthropic | openai | openrouter | ollama
    model: claude-sonnet-4-6          # Model ID (provider-specific)
    temperature: 0.2
    fallback_provider: openai         # null = disabled; tried after primary exhausts all retries
    fallback_model: gpt-4o
    fallback_temperature: null        # null = inherit from primary
    fallback_max_output_tokens: null   # null = inherit from primary
    fallback_retry_attempts: null      # null = inherit from primary
    fallback_timeout_seconds: null     # null = inherit from primary
    ollama:
      base_url: http://localhost:11434  # Ollama server URL
      timeout_seconds: 300              # Local models can be slower
  vendors: [AWS, NetApp]              # Per-vendor feedback sub-sections in templates
  length_mode: concise                # concise | standard | verbose — detailed-notes length
  generate_email_draft: true          # Emit a follow-up email draft artifact
  confidentiality_default: auto       # auto | public | internal | confidential | restricted
  close_acknowledged_actions: true    # Auto-close prior open actions acknowledged in a meeting
  prior_actions_lookback_meetings: 5  # How many prior meetings to pull open actions from

storage:
  sqlite_path: db/meetings.db

security:
  encryption_enabled: false
  encryption_key: ""              # Fernet key (prefer MM_ENCRYPTION_KEY env var)
  api_key: ""                     # API key for REST auth (prefer MM_API_KEY env var)

api:
  cors_origins:
    - "http://localhost:8080"
    - "http://127.0.0.1:8080"
  host: "127.0.0.1"
  port: 8080

retention:
  enabled: false
  audio_retention_days: 90        # Delete audio files after N days
  transcript_retention_days: null  # null = keep forever
  minutes_retention_days: null     # null = keep forever

See the full configuration reference in the User Guide.

Security

API key authentication

By default, the REST API has no authentication — suitable for single-user localhost deployments. To protect against unauthorized access (especially if you expose the server to a network), set an API key:

# Option 1: environment variable (recommended)
export MM_API_KEY="your-secret-key"

# Option 2: .env file (auto-loaded, gitignored, chmod 0600)
echo 'MM_API_KEY="your-secret-key"' >> .env

# Option 3: Settings UI → Security → API Key Authentication → Generate + Save

When MM_API_KEY is set, all /api/* requests must include a matching X-Api-Key header. The /api/health endpoint is exempt for monitoring probes. The web UI stores the key in the browser's localStorage and attaches it automatically.

Generate a secure random key from the CLI:

python -c "import secrets; print(secrets.token_urlsafe(32))"

File permissions

The database file and its parent directory are hardened to owner-only access (0600 / 0700) on startup. Backup files and the backup directory receive the same treatment. The recording state file (used for CLI ↔ server interop) is stored in ~/.meeting-minutes/ with 0600 permissions instead of the world-readable /tmp.

Encryption at rest

Transcript and minutes JSON files can be encrypted with Fernet symmetric encryption. Enable in Settings → Security or in config.yaml:

security:
  encryption_enabled: true

Set the key via MM_ENCRYPTION_KEY env var (preferred) or generate one in the Settings UI. When enabled, database backup files are also Fernet-encrypted on creation; restore_backup decrypts them transparently.

Rate limiting

To protect against runaway scripts or brute-force probing, you can cap the number of /api/* requests per client IP per minute:

# config/config.yaml
api:
  rate_limit_rpm: 120   # 0 (default) = disabled

Excess requests get 429 Too Many Requests with a Retry-After: 60 header. The sliding window is per-IP and in-memory (resets on server restart).

SQL injection hardening

The search engine uses parameterized SQL bind parameters everywhere — including the dynamic meeting_id IN (...) clause built from FTS5 results — eliminating string-interpolation injection vectors.

Data sent to external providers

Meeting transcripts, attendee names, and attachment contents are sent to cloud LLM providers (Anthropic, OpenAI, OpenRouter) for minutes generation, chat, and attachment summarization. Audio may be sent to pyannoteAI if cloud diarization is enabled. Transcription and embeddings run locally. See SECURITY_AUDIT.md for the full data flow analysis.

Project Structure

MeetingMinutesTaker/
├── src/meeting_minutes/
│   ├── models.py              # Shared Pydantic data models
│   ├── config.py              # Configuration loading (YAML)
│   ├── encryption.py          # Fernet encryption at rest
│   ├── env.py                 # .env file loading (dotenv)
│   ├── recording_state.py     # Recording state file (CLI ↔ server interop)
│   ├── logging.py             # Structured JSON logging
│   ├── pipeline.py            # Pipeline orchestrator (with retry)
│   ├── retention.py           # Data retention policy engine
│   ├── hardware.py            # GPU/RAM detection and model recommendations
│   ├── embeddings.py          # Semantic search: chunk + embed + sqlite-vec index
│   ├── chat.py                # RAG chat engine: query parsing + hybrid retrieval + LLM
│   ├── system1/               # Audio capture & transcription
│   │   ├── capture.py         #   AudioCaptureEngine (sounddevice, circular buffer)
│   │   ├── transcribe.py      #   Transcription engine factory (faster-whisper, whisper.cpp)
│   │   ├── diarize.py         #   DiarizationEngine — façade over pluggable backends
│   │   ├── diarization_backends/   # pyannote-local | pyannote-ai | pyannote-mlx
│   │   └── output.py          #   TranscriptJSONWriter
│   ├── system2/               # Minutes generation
│   │   ├── ingest.py          #   TranscriptIngester (validation, speaker mapping)
│   │   ├── router.py          #   PromptRouter (LLM classifier + template selection)
│   │   ├── prompts.py         #   PromptTemplateEngine (Jinja2)
│   │   ├── llm_client.py      #   LLMClient (Anthropic/OpenAI/OpenRouter/Ollama, retry, fallback)
│   │   ├── schema.py          #   StructuredMinutesResponse (tool_use JSON schema)
│   │   ├── parser.py          #   MinutesParser (extract sections, actions, decisions)
│   │   ├── quality.py         #   QualityChecker (coverage, hallucination, length)
│   │   └── output.py          #   MinutesJSONWriter
│   ├── system3/               # Storage & search
│   │   ├── db.py              #   SQLAlchemy ORM + FTS5 virtual table
│   │   ├── storage.py         #   StorageEngine (upsert, person dedup)
│   │   ├── search.py          #   SearchEngine (FTS5, BM25, filter parsing)
│   │   ├── ingest.py          #   MinutesIngester
│   │   └── cli.py             #   Typer CLI (mm command)
│   └── api/                   # FastAPI REST API + WebSocket
│       ├── main.py            #   App factory, CORS, auth middleware, static file serving
│       ├── auth.py            #   API key authentication middleware
│       ├── deps.py            #   Dependency injection
│       ├── schemas.py         #   Pydantic response models
│       ├── ws.py              #   WebSocket (recording status, pipeline progress)
│       └── routes/            #   Route modules (meetings, search, actions, security, retention, etc.)
├── data/
│   └── notes/                 # Live note-taking data (speaker names, notes, instructions)
├── web/                       # Svelte frontend (SvelteKit + Tailwind CSS)
│   └── src/
│       ├── lib/components/    #   14 reusable components
│       ├── lib/stores/        #   Theme + recording state stores
│       └── routes/            #   9 pages (calendar, detail, actions, templates, stats, etc.)
├── templates/                 # Jinja2 meeting-type prompt templates
├── tests/                     # 135 tests (property-based + unit)
├── config/config.yaml         # Default configuration
├── alembic/                   # Database migrations
├── docs/USER_GUIDE.md         # Full setup and usage guide
└── specs/                     # System specifications

Pipeline Modes

ModeBehavior
automaticRecord stop triggers the full chain: transcribe, generate, index
semi_automaticTranscription runs automatically; you manually trigger mm generate
manualEach step triggered independently via CLI

Tech Stack

ComponentTechnology
Audio capturesounddevice + BlackHole (macOS)
Transcriptionfaster-whisper (default, Metal/CUDA), whisper.cpp (GGML, lower memory)
Speaker diarizationpyannote.audio
LLMAnthropic Claude (primary), OpenRouter (200+ models), OpenAI, Ollama (local)
DatabaseSQLite + SQLAlchemy
Full-text searchSQLite FTS5 with BM25 ranking
Semantic searchsentence-transformers (bge-small) + sqlite-vec
Chat/RAGHybrid retrieval (FTS5 + vector) → LLM synthesis with citations
CLItyper + rich
TemplatesJinja2
API serverFastAPI + uvicorn
FrontendSvelte + SvelteKit + Tailwind CSS
Event buswatchdog (filesystem watcher)
Testingpytest + hypothesis (property-based)

Testing

pip install -e ".[dev]"
pytest tests/ -v

451 tests covering all 40 correctness properties from the design spec, plus unit tests for every component.

Documentation

  • User Guide — Installation, audio setup, configuration, CLI usage, troubleshooting
  • Architecture — System architecture, data flow, deployment topologies
  • System 1 Spec — Audio capture, transcription, diarization
  • System 2 Spec — Minutes generation, templates, LLM integration
  • System 3 Spec — Storage, search, API, CLI
  • Web UI Spec — Browser-based GUI (Svelte + FastAPI), all pages, components, API
  • Design Doc — Component interfaces, data models, correctness properties
  • Requirements — Formal acceptance criteria

License

Copyright © 2026 Jurgen Hofkens. All rights reserved.

This project is licensed under the GNU Affero General Public License v3.0 (AGPL-3.0) — see LICENSE for the full text.

In short:

  • Personal, research, and non-commercial use — free, under AGPL-3.0.
  • Modifications, forks, and network-delivered services built on this code must also be released under AGPL-3.0, with full source available to users.
  • Commercial use that cannot comply with AGPL-3.0 (e.g. you want to incorporate this into a closed-source product or service) requires a separate commercial license — see COMMERCIAL.md.

Contributions are welcome under the Contributor License Agreement — see CONTRIBUTING.md for how to submit a pull request.

↑↓ navigate openesc close