Vibecode Granola
track this build6 phases, 21 steps, beginner friendly0%A local recorder plus Whisper transcription plus an LLM note template covers the core personal loop in one sitting. The paid product is mainly polish, meeting context, sync, and team workflow.
You are building a lean indie version of Granola. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Granola · indie build A local meeting-notes app for your Mac: a menu bar button records system audio and your microphone, whisper.cpp transcribes on your machine, an LLM turns the transcript plus your rough notes into a summary with decisions and action items, and everything lands as Markdown files in a folder you own. Nothing leaves the machine except the optional LLM call. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | App | Swift 6, SwiftUI, MenuBarExtra, macOS 14+ | system audio capture is a native API; wrapping it in Electron is where these projects stall | | Audio | ScreenCaptureKit for system audio, AVAudioEngine for the mic | no virtual audio driver to install, and two separate taps you can mix | | Transcription | whisper.cpp via the whisper-cli binary | local, free, good enough on Apple Silicon | | Summaries | An LLM API behind a small provider interface | the one network call, swappable and optional | | Storage | Markdown files in ~/MeetingNotes | greppable, portable, no index to corrupt | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac with Apple Silicon running macOS 14 or newer** · the Mac you have - Why: ScreenCaptureKit audio capture needs macOS 13+, and local transcription is only pleasant on Apple Silicon. - Get it: Apple menu > About This Mac. Update through System Settings > General > Software Update if below 14. - Verify: sw_vers prints ProductVersion 14 or higher - [ ] **Xcode 16 or newer** · free - Why: Swift, SwiftUI and the macOS SDK come with it. The Command Line Tools alone are not enough for a signed menu bar app. - Get it: Install from the Mac App Store (it is large; start the download first). Open it once to accept the licence and install components. - Verify: xcodebuild -version prints Xcode 16 or higher - [ ] **Homebrew, cmake and ffmpeg** · free - Why: cmake builds whisper.cpp; ffmpeg is the easiest way to inspect and convert audio while you debug Phase 1. - Get it: Install Homebrew from brew.sh, then: brew install cmake ffmpeg - Verify: cmake --version and ffmpeg -version both print - [ ] **whisper.cpp built from source** · free - Why: The whisper-cli binary is what Phase 2 shells out to. - Get it: git clone https://github.com/ggml-org/whisper.cpp && cd whisper.cpp && cmake -B build && cmake --build build --config Release. The binary is build/bin/whisper-cli. - Verify: ./build/bin/whisper-cli -h prints usage - [ ] **The medium.en whisper model (about 1.5 GB)** · free, 1.5 GB of disk - Why: Without a model file there is no transcription. medium.en is the quality-speed balance for English meetings. - Get it: Inside the whisper.cpp folder: ./models/download-ggml-model.sh medium.en. It saves to models/ggml-medium.en.bin. Use small.en (about 500 MB) if disk or speed is tight. - Verify: ls -lh models/ggml-medium.en.bin shows about 1.5G - [ ] **An LLM API key (OpenAI or Anthropic)** (optional) · pay per use; a one-hour meeting summary costs a few cents - Why: Phase 3 sends the transcript to a model for the summary. Without a key the app still records and transcribes; it just skips the summary. - Get it: Anthropic: console.anthropic.com > API Keys > Create Key. OpenAI: platform.openai.com/api-keys > Create new secret key. Copy it once; it is not shown again. Put it in .env, never in the source. - [ ] **A Groq or OpenAI key for hosted transcription** (optional) · pay per use; Groq has a free tier - Why: An optional fast path when local transcription is too slow on your machine. Local stays the default. - Get it: Groq: console.groq.com > API Keys. OpenAI: the same key as above works for the audio endpoint. - [ ] **Screen Recording and Microphone permissions** · free - Why: System audio arrives under the Screen Recording permission (no separate audio permission exists); the mic needs its own. Both are granted once per app build. - Get it: macOS prompts on first use. If you dismiss the prompt, the app must be relaunched before it can ask again; otherwise System Settings > Privacy & Security > Screen Recording and > Microphone. ## Quick start ```sh swift run capture-cli --seconds 30 --out /tmp/test.wav afinfo /tmp/test.wav /path/to/whisper-cli -m /path/to/ggml-medium.en.bin -f /tmp/test.wav ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Sync across devices and a mobile app. That is most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and support when a recording is lost. - sync across devices - the mobile app - automatic calendar joins - someone else's servers & support If one of those is essential to you, that is the reason to keep paying for Granola, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Granola The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local meeting-notes app like Granola for macOS. Build it in phases, in the order below. Do not write the whole app in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. Phase 1 is the phase that kills this project · get audio capture proven before any UI exists. ### Stack (fixed, do not substitute) - Swift 6 and SwiftUI, macOS 14+, a `MenuBarExtra` app. Not Electron · system audio capture here is a native API, and wrapping it in a bridge is the reason most attempts at this stall. - ScreenCaptureKit for system audio, AVAudioEngine for the microphone. - whisper.cpp for local transcription, invoked as the `whisper-cli` binary. - No database. Markdown files on disk are the storage layer. ### Data model (decide this before Phase 1) One meeting is one folder: `~/MeetingNotes/YYYY-MM-DD-HHMM-<slug>/` containing `audio.wav`, `transcript.txt`, and `notes.md`. `notes.md` carries YAML frontmatter (title, date, duration, participants, model used) so the folder stays greppable and portable with no app-specific index to corrupt. ### Phase 1 · Audio capture, proven as a CLI first Build: a command-line target that records N seconds and writes a WAV, before any UI exists. Capture system audio with ScreenCaptureKit and the microphone with AVAudioEngine, as two separate taps, then mix them into one mono track. Details that decide whether this works: - ScreenCaptureKit delivers system audio under the screen-recording permission · there is no separate audio permission, and no virtual audio driver is needed. Do not tell the user to install BlackHole or Loopback. - `SCStream` still requires a screen output to be added even when you only want audio. Add one and set `minimumFrameInterval` to a very large value so no real frames are processed. On macOS 14.2+ you may instead use Core Audio process taps (`AudioHardwareCreateProcessTap`) for genuine audio-only capture · pick one, and say in a comment which and why. - Capture the microphone through AVAudioEngine rather than `SCStreamConfiguration` microphone capture, which has known output-corruption reports. - Write 16-bit PCM WAV, 16 kHz, mono. Not 44.1 kHz, not float, not stereo. whisper-cli accepts 16-bit WAV only, and every hour lost to this project is lost here. Resample explicitly with AVAudioConverter. Done when: the CLI records 30 seconds while music plays and you speak, and the resulting WAV plays back with both audible, is exactly 16 kHz mono 16-bit (verify with `afinfo`), and `whisper-cli` accepts it without a format error. Do not build yet: transcription quality, UI, LLM, files. ### Phase 2 · Transcription Build: a transcription step that shells out to whisper-cli. - Fetch the model with `./models/download-ggml-model.sh medium.en`, producing `models/ggml-medium.en.bin`. Document the download size before it downloads. - Invoke `whisper-cli -m models/ggml-medium.en.bin -f audio.wav -otxt`. - If `OPENAI_API_KEY` or `GROQ_API_KEY` is present in `.env`, offer the hosted Whisper endpoint as an opt-in fast path. Local stays the default · the whole point is that the recording never leaves the machine unless asked. - Report progress. A 45-minute meeting on the medium model is minutes of work on CPU, and a UI that looks frozen will be assumed broken. Done when: a 5-minute recording produces a transcript whose first and last sentences are recognisably correct, and killing the app mid-transcription leaves the audio file intact and resumable rather than a half-written transcript. ### Phase 3 · Summarization Build: send the transcript to an LLM (key in `.env`, model configurable) with a fixed prompt that returns a 5-bullet summary, decisions made, and action items with owners. Chunk transcripts that exceed the context window and summarize the chunk summaries · do not silently truncate, which produces confident notes about the first third of a meeting. Done when: a transcript with three clear decisions and two assigned tasks yields notes naming all five, and a 90-minute transcript summarizes without an API error. Do not build yet: the UI. ### Phase 4 · Notes on disk Build: write the meeting folder, `notes.md` with frontmatter and summary above a divider and the full transcript below it, plus the retained audio. Derive the title from the summary. Never overwrite an existing folder · suffix a counter. Done when: two meetings started in the same minute produce two folders, and every file opens correctly in a plain Markdown editor with no app running. ### Phase 5 · Menu bar app Build: the `MenuBarExtra` UI · Start/Stop with elapsed time, a recording indicator, a live jot-notes field whose text is passed to the LLM alongside the transcript (this is the actual Granola idea · your rough notes steer the summary), a list of recent meetings, and Reveal in Finder. Handle the permission prompt explicitly: if screen recording is denied, say which System Settings pane to open rather than failing silently. Done when: a full meeting runs start to notes without touching a terminal, and a first launch on a machine that has never granted permission explains itself. ### Phase 6 · Offline, packaging, README Build: verify the whole path works with the network off when the model is present and no LLM key is set (transcript saved, summary skipped with a clear note). Sign the app locally, document the Gatekeeper first-run step, and write the README. Done when: airplane mode produces a complete transcript and an honest note saying the summary was skipped. ### Out of scope (and why) - Sync across devices and a mobile app. Those are most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and someone else's support when a recording is lost. ### README must contain - The exact permissions to grant and where, with the reason for each. - Model download size and disk cost per hour of retained audio. - A plain warning: recording a meeting can require consent from everyone in it, and the legal answer depends on where the participants are, not where you are. ===== AGENTS.md ===== # Agent instructions · Granola indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6, SwiftUI, MenuBarExtra, macOS 14+, ScreenCaptureKit for system audio, AVAudioEngine for the mic, whisper.cpp via the whisper-cli binary, An LLM API behind a small provider interface, Markdown files in ~/MeetingNotes. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Do not tell the user to install BlackHole or Loopback. ScreenCaptureKit delivers system audio under the screen-recording grant with no driver. - The permission prompt appears once per launch. If it is dismissed the app must be relaunched; detect the denied state and say so. ===== BUILD_PLAN.md ===== # Build plan · Granola A local meeting-notes app for your Mac: a menu bar button records system audio and your microphone, whisper.cpp transcribes on your machine, an LLM turns the transcript plus your rough notes into a summary with decisions and action items, and everything lands as Markdown files in a folder you own. Nothing leaves the machine except the optional LLM call. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Audio capture, proven as a CLI first Before any UI exists, prove you can record system audio and the mic into a WAV that whisper-cli accepts. This is the phase that kills the project if skipped. ### Steps 1. Create an Xcode project with two targets: a command-line tool and the menu bar app The CLI target is for this phase; the app target waits until Phase 5. Both share a Swift package with the capture code. Files: `MeetingNotes.xcodeproj`, `Sources/Capture/` 2. Capture system audio with ScreenCaptureKit Build an SCStreamConfiguration with capturesAudio true. SCStream still requires a screen output to be added even for audio only: add one and set minimumFrameInterval to a very large value so no real frames are processed. On macOS 14.2+ you may instead use Core Audio process taps (AudioHardwareCreateProcessTap) for genuine audio-only capture; pick one and say which in a comment. 3. Capture the microphone with AVAudioEngine, separately Do not use SCStreamConfiguration's microphone capture; it has known output-corruption reports. Tap the input node and collect buffers. 4. Mix both taps and write 16 kHz mono 16-bit PCM WAV Resample explicitly with AVAudioConverter. Not 44.1 kHz, not float, not stereo. whisper-cli accepts 16-bit WAV only; every hour lost to this project is lost here. 5. Run the CLI for 30 seconds while music plays and you speak ```sh swift run capture-cli --seconds 30 --out /tmp/test.wav afinfo /tmp/test.wav /path/to/whisper-cli -m /path/to/ggml-medium.en.bin -f /tmp/test.wav ``` ### Done when - [ ] afinfo reports 16000 Hz, 1 channel, 16-bit for the WAV - [ ] Playing the WAV back you hear both the music and your voice - [ ] whisper-cli accepts the file without a format error and prints text - [ ] Denying Screen Recording produces a clear error naming the System Settings pane, not a silent empty file ### Watch out - Do not tell the user to install BlackHole or Loopback. ScreenCaptureKit delivers system audio under the screen-recording grant with no driver. - The permission prompt appears once per launch. If it is dismissed the app must be relaunched; detect the denied state and say so. ## Phase 2 · Transcription A transcript from a WAV, local by default, with progress you can see and a hosted fast path when a key exists. ### Steps 1. Shell out to whisper-cli with -otxt Process with WHISPER_BIN, WHISPER_MODEL and the audio path. Read the .txt it writes. Capture stderr for progress lines. ```sh whisper-cli -m $WHISPER_MODEL -f audio.wav -otxt -of transcript ``` 2. Report progress A 45-minute meeting on the medium model is minutes of CPU. Parse whisper's progress output into a percentage; a UI that looks frozen will be assumed broken. 3. Add the hosted path behind TRANSCRIBE_MODE When hosted and a Groq or OpenAI key exists, post the WAV to the Whisper endpoint. Local remains the default: the recording should not leave the machine unless asked. 4. Make it resumable Write the transcript to a temp name and rename on completion. Killing the app mid-transcription must leave the audio intact and no half-written transcript. ### Done when - [ ] A 5-minute recording produces a transcript whose first and last sentences are recognisably correct - [ ] Progress moves during transcription - [ ] Killing the app mid-transcription leaves audio.wav intact and no transcript.txt - [ ] With TRANSCRIBE_MODE=hosted and a key, the same file transcribes via the API ## Phase 3 · Summarization A fixed prompt turns the transcript and your jotted notes into a 5-bullet summary, decisions and action items with owners, and long meetings are chunked rather than truncated. ### Steps 1. Write the provider interface and one implementation summarize(transcript, notes) returning structured text. Anthropic first; OpenAI as a second implementation selected by which key exists. 2. Chunk transcripts that exceed the context window Split on paragraph boundaries into chunks under the model's limit, summarize each, then summarize the summaries. Never silently truncate: that produces confident notes about the first third of a meeting. 3. Pass the user's rough notes alongside the transcript This is the actual Granola idea: your jottings steer what the summary emphasises. ### Done when - [ ] A transcript with three clear decisions and two assigned tasks yields notes naming all five - [ ] A 90-minute transcript summarizes without an API error - [ ] With no key present the step is skipped with a clear note in the output, not an error ## Phase 4 · Notes on disk One folder per meeting, plain Markdown with frontmatter, never overwriting. ### Steps 1. Write NOTES_DIR/YYYY-MM-DD-HHMM-<slug>/ with audio.wav, transcript.txt and notes.md notes.md: YAML frontmatter (title, date, duration, participants, model), the summary, a divider, then the full transcript. 2. Derive the title from the summary and never overwrite If the folder exists, suffix a counter. Two meetings in the same minute must produce two folders. ### Done when - [ ] Two meetings started in the same minute produce two folders - [ ] notes.md opens correctly in a plain Markdown editor with no app running - [ ] The audio file is kept next to the notes ## Phase 5 · Menu bar app Start and stop from the menu bar, jot notes while recording, see recent meetings, and get told exactly what to do when a permission is missing. ### Steps 1. Build the MenuBarExtra with Start/Stop and elapsed time A recording indicator that is unmistakable. Wire the capture code from Phase 1. 2. Add the live jot-notes field Text typed during the meeting is passed to the summarizer in Phase 3. 3. List recent meetings with Reveal in Finder Read NOTES_DIR; newest first. 4. Handle permissions explicitly If Screen Recording or Microphone is denied, show which System Settings pane to open and the relaunch caveat, instead of failing silently. ### Done when - [ ] A full meeting runs start to notes without touching a terminal - [ ] First launch on a machine that has never granted permission explains itself - [ ] The jotted notes visibly influence the summary ## Phase 6 · Offline, packaging, README Works with the network off when the model is present, signed for your own machine, documented. ### Steps 1. Test with Wi-Fi off and no LLM key Transcript saved; summary skipped with an honest note in notes.md. 2. Sign locally and document the Gatekeeper first-run step Sign with your development certificate for your own use. Right-click > Open the first time. 3. Write the README Permissions and where to grant them, model download size, disk cost per hour of audio, and a plain warning that recording a meeting can require consent from everyone in it depending on where they are. Files: `README.md` ### Done when - [ ] Airplane mode produces a complete transcript and a note saying the summary was skipped - [ ] A fresh Mac goes from clone to first recording using only the README ## Not in this build - Sync across devices and a mobile app. That is most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and support when a recording is lost. ## After v1, if you want it - Speaker diarization with a local model so action items get real owners - Ollama as a local summarizer behind the same provider interface for a fully offline pipeline ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where meeting folders are written. Any folder you sync or back up. NOTES_DIR=~/MeetingNotes # Required. Path to the binary you built. WHISPER_BIN=/Users/you/whisper.cpp/build/bin/whisper-cli # Required. Path to the model you downloaded. WHISPER_MODEL=/Users/you/whisper.cpp/models/ggml-medium.en.bin # Optional · secret. Anthropic console. Leave empty to skip summaries. ANTHROPIC_API_KEY=sk-ant-... # Optional · secret. OpenAI dashboard. Used for summaries if no Anthropic key, and for hosted Whisper if enabled. OPENAI_API_KEY=sk-... # Required. Model name for summaries. Anything the provider offers. LLM_MODEL=claude-sonnet-5 # Optional · secret. console.groq.com. Enables the hosted transcription fast path. GROQ_API_KEY=gsk_... # Required. local or hosted. Local is the default and works offline. TRANSCRIBE_MODE=local
You are building a lean indie version of Granola. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Granola · indie build A local meeting-notes app for your Mac: a menu bar button records system audio and your microphone, whisper.cpp transcribes on your machine, an LLM turns the transcript plus your rough notes into a summary with decisions and action items, and everything lands as Markdown files in a folder you own. Nothing leaves the machine except the optional LLM call. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | App | Swift 6, SwiftUI, MenuBarExtra, macOS 14+ | system audio capture is a native API; wrapping it in Electron is where these projects stall | | Audio | ScreenCaptureKit for system audio, AVAudioEngine for the mic | no virtual audio driver to install, and two separate taps you can mix | | Transcription | whisper.cpp via the whisper-cli binary | local, free, good enough on Apple Silicon | | Summaries | An LLM API behind a small provider interface | the one network call, swappable and optional | | Storage | Markdown files in ~/MeetingNotes | greppable, portable, no index to corrupt | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac with Apple Silicon running macOS 14 or newer** · the Mac you have - Why: ScreenCaptureKit audio capture needs macOS 13+, and local transcription is only pleasant on Apple Silicon. - Get it: Apple menu > About This Mac. Update through System Settings > General > Software Update if below 14. - Verify: sw_vers prints ProductVersion 14 or higher - [ ] **Xcode 16 or newer** · free - Why: Swift, SwiftUI and the macOS SDK come with it. The Command Line Tools alone are not enough for a signed menu bar app. - Get it: Install from the Mac App Store (it is large; start the download first). Open it once to accept the licence and install components. - Verify: xcodebuild -version prints Xcode 16 or higher - [ ] **Homebrew, cmake and ffmpeg** · free - Why: cmake builds whisper.cpp; ffmpeg is the easiest way to inspect and convert audio while you debug Phase 1. - Get it: Install Homebrew from brew.sh, then: brew install cmake ffmpeg - Verify: cmake --version and ffmpeg -version both print - [ ] **whisper.cpp built from source** · free - Why: The whisper-cli binary is what Phase 2 shells out to. - Get it: git clone https://github.com/ggml-org/whisper.cpp && cd whisper.cpp && cmake -B build && cmake --build build --config Release. The binary is build/bin/whisper-cli. - Verify: ./build/bin/whisper-cli -h prints usage - [ ] **The medium.en whisper model (about 1.5 GB)** · free, 1.5 GB of disk - Why: Without a model file there is no transcription. medium.en is the quality-speed balance for English meetings. - Get it: Inside the whisper.cpp folder: ./models/download-ggml-model.sh medium.en. It saves to models/ggml-medium.en.bin. Use small.en (about 500 MB) if disk or speed is tight. - Verify: ls -lh models/ggml-medium.en.bin shows about 1.5G - [ ] **An LLM API key (OpenAI or Anthropic)** (optional) · pay per use; a one-hour meeting summary costs a few cents - Why: Phase 3 sends the transcript to a model for the summary. Without a key the app still records and transcribes; it just skips the summary. - Get it: Anthropic: console.anthropic.com > API Keys > Create Key. OpenAI: platform.openai.com/api-keys > Create new secret key. Copy it once; it is not shown again. Put it in .env, never in the source. - [ ] **A Groq or OpenAI key for hosted transcription** (optional) · pay per use; Groq has a free tier - Why: An optional fast path when local transcription is too slow on your machine. Local stays the default. - Get it: Groq: console.groq.com > API Keys. OpenAI: the same key as above works for the audio endpoint. - [ ] **Screen Recording and Microphone permissions** · free - Why: System audio arrives under the Screen Recording permission (no separate audio permission exists); the mic needs its own. Both are granted once per app build. - Get it: macOS prompts on first use. If you dismiss the prompt, the app must be relaunched before it can ask again; otherwise System Settings > Privacy & Security > Screen Recording and > Microphone. ## Quick start ```sh swift run capture-cli --seconds 30 --out /tmp/test.wav afinfo /tmp/test.wav /path/to/whisper-cli -m /path/to/ggml-medium.en.bin -f /tmp/test.wav ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Sync across devices and a mobile app. That is most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and support when a recording is lost. - sync across devices - the mobile app - automatic calendar joins - someone else's servers & support If one of those is essential to you, that is the reason to keep paying for Granola, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Granola The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local meeting-notes app like Granola for macOS. Build it in phases, in the order below. Do not write the whole app in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. Phase 1 is the phase that kills this project · get audio capture proven before any UI exists. ### Stack (fixed, do not substitute) - Swift 6 and SwiftUI, macOS 14+, a `MenuBarExtra` app. Not Electron · system audio capture here is a native API, and wrapping it in a bridge is the reason most attempts at this stall. - ScreenCaptureKit for system audio, AVAudioEngine for the microphone. - whisper.cpp for local transcription, invoked as the `whisper-cli` binary. - No database. Markdown files on disk are the storage layer. ### Data model (decide this before Phase 1) One meeting is one folder: `~/MeetingNotes/YYYY-MM-DD-HHMM-<slug>/` containing `audio.wav`, `transcript.txt`, and `notes.md`. `notes.md` carries YAML frontmatter (title, date, duration, participants, model used) so the folder stays greppable and portable with no app-specific index to corrupt. ### Phase 1 · Audio capture, proven as a CLI first Build: a command-line target that records N seconds and writes a WAV, before any UI exists. Capture system audio with ScreenCaptureKit and the microphone with AVAudioEngine, as two separate taps, then mix them into one mono track. Details that decide whether this works: - ScreenCaptureKit delivers system audio under the screen-recording permission · there is no separate audio permission, and no virtual audio driver is needed. Do not tell the user to install BlackHole or Loopback. - `SCStream` still requires a screen output to be added even when you only want audio. Add one and set `minimumFrameInterval` to a very large value so no real frames are processed. On macOS 14.2+ you may instead use Core Audio process taps (`AudioHardwareCreateProcessTap`) for genuine audio-only capture · pick one, and say in a comment which and why. - Capture the microphone through AVAudioEngine rather than `SCStreamConfiguration` microphone capture, which has known output-corruption reports. - Write 16-bit PCM WAV, 16 kHz, mono. Not 44.1 kHz, not float, not stereo. whisper-cli accepts 16-bit WAV only, and every hour lost to this project is lost here. Resample explicitly with AVAudioConverter. Done when: the CLI records 30 seconds while music plays and you speak, and the resulting WAV plays back with both audible, is exactly 16 kHz mono 16-bit (verify with `afinfo`), and `whisper-cli` accepts it without a format error. Do not build yet: transcription quality, UI, LLM, files. ### Phase 2 · Transcription Build: a transcription step that shells out to whisper-cli. - Fetch the model with `./models/download-ggml-model.sh medium.en`, producing `models/ggml-medium.en.bin`. Document the download size before it downloads. - Invoke `whisper-cli -m models/ggml-medium.en.bin -f audio.wav -otxt`. - If `OPENAI_API_KEY` or `GROQ_API_KEY` is present in `.env`, offer the hosted Whisper endpoint as an opt-in fast path. Local stays the default · the whole point is that the recording never leaves the machine unless asked. - Report progress. A 45-minute meeting on the medium model is minutes of work on CPU, and a UI that looks frozen will be assumed broken. Done when: a 5-minute recording produces a transcript whose first and last sentences are recognisably correct, and killing the app mid-transcription leaves the audio file intact and resumable rather than a half-written transcript. ### Phase 3 · Summarization Build: send the transcript to an LLM (key in `.env`, model configurable) with a fixed prompt that returns a 5-bullet summary, decisions made, and action items with owners. Chunk transcripts that exceed the context window and summarize the chunk summaries · do not silently truncate, which produces confident notes about the first third of a meeting. Done when: a transcript with three clear decisions and two assigned tasks yields notes naming all five, and a 90-minute transcript summarizes without an API error. Do not build yet: the UI. ### Phase 4 · Notes on disk Build: write the meeting folder, `notes.md` with frontmatter and summary above a divider and the full transcript below it, plus the retained audio. Derive the title from the summary. Never overwrite an existing folder · suffix a counter. Done when: two meetings started in the same minute produce two folders, and every file opens correctly in a plain Markdown editor with no app running. ### Phase 5 · Menu bar app Build: the `MenuBarExtra` UI · Start/Stop with elapsed time, a recording indicator, a live jot-notes field whose text is passed to the LLM alongside the transcript (this is the actual Granola idea · your rough notes steer the summary), a list of recent meetings, and Reveal in Finder. Handle the permission prompt explicitly: if screen recording is denied, say which System Settings pane to open rather than failing silently. Done when: a full meeting runs start to notes without touching a terminal, and a first launch on a machine that has never granted permission explains itself. ### Phase 6 · Offline, packaging, README Build: verify the whole path works with the network off when the model is present and no LLM key is set (transcript saved, summary skipped with a clear note). Sign the app locally, document the Gatekeeper first-run step, and write the README. Done when: airplane mode produces a complete transcript and an honest note saying the summary was skipped. ### Out of scope (and why) - Sync across devices and a mobile app. Those are most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and someone else's support when a recording is lost. ### README must contain - The exact permissions to grant and where, with the reason for each. - Model download size and disk cost per hour of retained audio. - A plain warning: recording a meeting can require consent from everyone in it, and the legal answer depends on where the participants are, not where you are. ===== AGENTS.md ===== # Agent instructions · Granola indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6, SwiftUI, MenuBarExtra, macOS 14+, ScreenCaptureKit for system audio, AVAudioEngine for the mic, whisper.cpp via the whisper-cli binary, An LLM API behind a small provider interface, Markdown files in ~/MeetingNotes. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Do not tell the user to install BlackHole or Loopback. ScreenCaptureKit delivers system audio under the screen-recording grant with no driver. - The permission prompt appears once per launch. If it is dismissed the app must be relaunched; detect the denied state and say so. ===== BUILD_PLAN.md ===== # Build plan · Granola A local meeting-notes app for your Mac: a menu bar button records system audio and your microphone, whisper.cpp transcribes on your machine, an LLM turns the transcript plus your rough notes into a summary with decisions and action items, and everything lands as Markdown files in a folder you own. Nothing leaves the machine except the optional LLM call. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Audio capture, proven as a CLI first Before any UI exists, prove you can record system audio and the mic into a WAV that whisper-cli accepts. This is the phase that kills the project if skipped. ### Steps 1. Create an Xcode project with two targets: a command-line tool and the menu bar app The CLI target is for this phase; the app target waits until Phase 5. Both share a Swift package with the capture code. Files: `MeetingNotes.xcodeproj`, `Sources/Capture/` 2. Capture system audio with ScreenCaptureKit Build an SCStreamConfiguration with capturesAudio true. SCStream still requires a screen output to be added even for audio only: add one and set minimumFrameInterval to a very large value so no real frames are processed. On macOS 14.2+ you may instead use Core Audio process taps (AudioHardwareCreateProcessTap) for genuine audio-only capture; pick one and say which in a comment. 3. Capture the microphone with AVAudioEngine, separately Do not use SCStreamConfiguration's microphone capture; it has known output-corruption reports. Tap the input node and collect buffers. 4. Mix both taps and write 16 kHz mono 16-bit PCM WAV Resample explicitly with AVAudioConverter. Not 44.1 kHz, not float, not stereo. whisper-cli accepts 16-bit WAV only; every hour lost to this project is lost here. 5. Run the CLI for 30 seconds while music plays and you speak ```sh swift run capture-cli --seconds 30 --out /tmp/test.wav afinfo /tmp/test.wav /path/to/whisper-cli -m /path/to/ggml-medium.en.bin -f /tmp/test.wav ``` ### Done when - [ ] afinfo reports 16000 Hz, 1 channel, 16-bit for the WAV - [ ] Playing the WAV back you hear both the music and your voice - [ ] whisper-cli accepts the file without a format error and prints text - [ ] Denying Screen Recording produces a clear error naming the System Settings pane, not a silent empty file ### Watch out - Do not tell the user to install BlackHole or Loopback. ScreenCaptureKit delivers system audio under the screen-recording grant with no driver. - The permission prompt appears once per launch. If it is dismissed the app must be relaunched; detect the denied state and say so. ## Phase 2 · Transcription A transcript from a WAV, local by default, with progress you can see and a hosted fast path when a key exists. ### Steps 1. Shell out to whisper-cli with -otxt Process with WHISPER_BIN, WHISPER_MODEL and the audio path. Read the .txt it writes. Capture stderr for progress lines. ```sh whisper-cli -m $WHISPER_MODEL -f audio.wav -otxt -of transcript ``` 2. Report progress A 45-minute meeting on the medium model is minutes of CPU. Parse whisper's progress output into a percentage; a UI that looks frozen will be assumed broken. 3. Add the hosted path behind TRANSCRIBE_MODE When hosted and a Groq or OpenAI key exists, post the WAV to the Whisper endpoint. Local remains the default: the recording should not leave the machine unless asked. 4. Make it resumable Write the transcript to a temp name and rename on completion. Killing the app mid-transcription must leave the audio intact and no half-written transcript. ### Done when - [ ] A 5-minute recording produces a transcript whose first and last sentences are recognisably correct - [ ] Progress moves during transcription - [ ] Killing the app mid-transcription leaves audio.wav intact and no transcript.txt - [ ] With TRANSCRIBE_MODE=hosted and a key, the same file transcribes via the API ## Phase 3 · Summarization A fixed prompt turns the transcript and your jotted notes into a 5-bullet summary, decisions and action items with owners, and long meetings are chunked rather than truncated. ### Steps 1. Write the provider interface and one implementation summarize(transcript, notes) returning structured text. Anthropic first; OpenAI as a second implementation selected by which key exists. 2. Chunk transcripts that exceed the context window Split on paragraph boundaries into chunks under the model's limit, summarize each, then summarize the summaries. Never silently truncate: that produces confident notes about the first third of a meeting. 3. Pass the user's rough notes alongside the transcript This is the actual Granola idea: your jottings steer what the summary emphasises. ### Done when - [ ] A transcript with three clear decisions and two assigned tasks yields notes naming all five - [ ] A 90-minute transcript summarizes without an API error - [ ] With no key present the step is skipped with a clear note in the output, not an error ## Phase 4 · Notes on disk One folder per meeting, plain Markdown with frontmatter, never overwriting. ### Steps 1. Write NOTES_DIR/YYYY-MM-DD-HHMM-<slug>/ with audio.wav, transcript.txt and notes.md notes.md: YAML frontmatter (title, date, duration, participants, model), the summary, a divider, then the full transcript. 2. Derive the title from the summary and never overwrite If the folder exists, suffix a counter. Two meetings in the same minute must produce two folders. ### Done when - [ ] Two meetings started in the same minute produce two folders - [ ] notes.md opens correctly in a plain Markdown editor with no app running - [ ] The audio file is kept next to the notes ## Phase 5 · Menu bar app Start and stop from the menu bar, jot notes while recording, see recent meetings, and get told exactly what to do when a permission is missing. ### Steps 1. Build the MenuBarExtra with Start/Stop and elapsed time A recording indicator that is unmistakable. Wire the capture code from Phase 1. 2. Add the live jot-notes field Text typed during the meeting is passed to the summarizer in Phase 3. 3. List recent meetings with Reveal in Finder Read NOTES_DIR; newest first. 4. Handle permissions explicitly If Screen Recording or Microphone is denied, show which System Settings pane to open and the relaunch caveat, instead of failing silently. ### Done when - [ ] A full meeting runs start to notes without touching a terminal - [ ] First launch on a machine that has never granted permission explains itself - [ ] The jotted notes visibly influence the summary ## Phase 6 · Offline, packaging, README Works with the network off when the model is present, signed for your own machine, documented. ### Steps 1. Test with Wi-Fi off and no LLM key Transcript saved; summary skipped with an honest note in notes.md. 2. Sign locally and document the Gatekeeper first-run step Sign with your development certificate for your own use. Right-click > Open the first time. 3. Write the README Permissions and where to grant them, model download size, disk cost per hour of audio, and a plain warning that recording a meeting can require consent from everyone in it depending on where they are. Files: `README.md` ### Done when - [ ] Airplane mode produces a complete transcript and a note saying the summary was skipped - [ ] A fresh Mac goes from clone to first recording using only the README ## Not in this build - Sync across devices and a mobile app. That is most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and support when a recording is lost. ## After v1, if you want it - Speaker diarization with a local model so action items get real owners - Ollama as a local summarizer behind the same provider interface for a fully offline pipeline ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where meeting folders are written. Any folder you sync or back up. NOTES_DIR=~/MeetingNotes # Required. Path to the binary you built. WHISPER_BIN=/Users/you/whisper.cpp/build/bin/whisper-cli # Required. Path to the model you downloaded. WHISPER_MODEL=/Users/you/whisper.cpp/models/ggml-medium.en.bin # Optional · secret. Anthropic console. Leave empty to skip summaries. ANTHROPIC_API_KEY=sk-ant-... # Optional · secret. OpenAI dashboard. Used for summaries if no Anthropic key, and for hosted Whisper if enabled. OPENAI_API_KEY=sk-... # Required. Model name for summaries. Anything the provider offers. LLM_MODEL=claude-sonnet-5 # Optional · secret. console.groq.com. Enables the hosted transcription fast path. GROQ_API_KEY=gsk_... # Required. local or hosted. Local is the default and works offline. TRANSCRIBE_MODE=local
You are building a production product version of Granola. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Granola · product brief ## Problem A local recorder plus Whisper transcription plus an LLM note template covers the core personal loop in one sitting. The paid product is mainly polish, meeting context, sync, and team workflow. ## Product outcome A privacy-first meeting notes app you could hand to colleagues: notarized, self-updating, local by default, with a clear consent posture. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - macOS or Windows audio capture library - OpenAI/Anthropic API key or local Whisper model - local folder or SQLite database ## Explicit non-goals for v1 - Sync across devices and a mobile app. That is most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and support when a recording is lost. - sync across devices - the mobile app - automatic calendar joins - someone else's servers & support ## Success criteria - A notarized build installs and records on a Mac that has never had the developer tools - Airplane-mode test passes - The consent warning is in the README and the first-run screen - One update delivered through Sparkle to a real install ===== BRIEF.md ===== # Build brief · Granola The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local meeting-notes app like Granola for macOS. Build it in phases, in the order below. Do not write the whole app in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. Phase 1 is the phase that kills this project · get audio capture proven before any UI exists. ### Stack (fixed, do not substitute) - Swift 6 and SwiftUI, macOS 14+, a `MenuBarExtra` app. Not Electron · system audio capture here is a native API, and wrapping it in a bridge is the reason most attempts at this stall. - ScreenCaptureKit for system audio, AVAudioEngine for the microphone. - whisper.cpp for local transcription, invoked as the `whisper-cli` binary. - No database. Markdown files on disk are the storage layer. ### Data model (decide this before Phase 1) One meeting is one folder: `~/MeetingNotes/YYYY-MM-DD-HHMM-<slug>/` containing `audio.wav`, `transcript.txt`, and `notes.md`. `notes.md` carries YAML frontmatter (title, date, duration, participants, model used) so the folder stays greppable and portable with no app-specific index to corrupt. ### Phase 1 · Audio capture, proven as a CLI first Build: a command-line target that records N seconds and writes a WAV, before any UI exists. Capture system audio with ScreenCaptureKit and the microphone with AVAudioEngine, as two separate taps, then mix them into one mono track. Details that decide whether this works: - ScreenCaptureKit delivers system audio under the screen-recording permission · there is no separate audio permission, and no virtual audio driver is needed. Do not tell the user to install BlackHole or Loopback. - `SCStream` still requires a screen output to be added even when you only want audio. Add one and set `minimumFrameInterval` to a very large value so no real frames are processed. On macOS 14.2+ you may instead use Core Audio process taps (`AudioHardwareCreateProcessTap`) for genuine audio-only capture · pick one, and say in a comment which and why. - Capture the microphone through AVAudioEngine rather than `SCStreamConfiguration` microphone capture, which has known output-corruption reports. - Write 16-bit PCM WAV, 16 kHz, mono. Not 44.1 kHz, not float, not stereo. whisper-cli accepts 16-bit WAV only, and every hour lost to this project is lost here. Resample explicitly with AVAudioConverter. Done when: the CLI records 30 seconds while music plays and you speak, and the resulting WAV plays back with both audible, is exactly 16 kHz mono 16-bit (verify with `afinfo`), and `whisper-cli` accepts it without a format error. Do not build yet: transcription quality, UI, LLM, files. ### Phase 2 · Transcription Build: a transcription step that shells out to whisper-cli. - Fetch the model with `./models/download-ggml-model.sh medium.en`, producing `models/ggml-medium.en.bin`. Document the download size before it downloads. - Invoke `whisper-cli -m models/ggml-medium.en.bin -f audio.wav -otxt`. - If `OPENAI_API_KEY` or `GROQ_API_KEY` is present in `.env`, offer the hosted Whisper endpoint as an opt-in fast path. Local stays the default · the whole point is that the recording never leaves the machine unless asked. - Report progress. A 45-minute meeting on the medium model is minutes of work on CPU, and a UI that looks frozen will be assumed broken. Done when: a 5-minute recording produces a transcript whose first and last sentences are recognisably correct, and killing the app mid-transcription leaves the audio file intact and resumable rather than a half-written transcript. ### Phase 3 · Summarization Build: send the transcript to an LLM (key in `.env`, model configurable) with a fixed prompt that returns a 5-bullet summary, decisions made, and action items with owners. Chunk transcripts that exceed the context window and summarize the chunk summaries · do not silently truncate, which produces confident notes about the first third of a meeting. Done when: a transcript with three clear decisions and two assigned tasks yields notes naming all five, and a 90-minute transcript summarizes without an API error. Do not build yet: the UI. ### Phase 4 · Notes on disk Build: write the meeting folder, `notes.md` with frontmatter and summary above a divider and the full transcript below it, plus the retained audio. Derive the title from the summary. Never overwrite an existing folder · suffix a counter. Done when: two meetings started in the same minute produce two folders, and every file opens correctly in a plain Markdown editor with no app running. ### Phase 5 · Menu bar app Build: the `MenuBarExtra` UI · Start/Stop with elapsed time, a recording indicator, a live jot-notes field whose text is passed to the LLM alongside the transcript (this is the actual Granola idea · your rough notes steer the summary), a list of recent meetings, and Reveal in Finder. Handle the permission prompt explicitly: if screen recording is denied, say which System Settings pane to open rather than failing silently. Done when: a full meeting runs start to notes without touching a terminal, and a first launch on a machine that has never granted permission explains itself. ### Phase 6 · Offline, packaging, README Build: verify the whole path works with the network off when the model is present and no LLM key is set (transcript saved, summary skipped with a clear note). Sign the app locally, document the Gatekeeper first-run step, and write the README. Done when: airplane mode produces a complete transcript and an honest note saying the summary was skipped. ### Out of scope (and why) - Sync across devices and a mobile app. Those are most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and someone else's support when a recording is lost. ### README must contain - The exact permissions to grant and where, with the reason for each. - Model download size and disk cost per hour of retained audio. - A plain warning: recording a meeting can require consent from everyone in it, and the legal answer depends on where the participants are, not where you are. ===== ARCHITECTURE.md ===== # Architecture · Granola ## Stack | Part | Choice | Why | | --- | --- | --- | | App | Swift 6, SwiftUI, MenuBarExtra, macOS 14+ | system audio capture is a native API; wrapping it in Electron is where these projects stall | | Audio | ScreenCaptureKit for system audio, AVAudioEngine for the mic | no virtual audio driver to install, and two separate taps you can mix | | Transcription | whisper.cpp via the whisper-cli binary | local, free, good enough on Apple Silicon | | Summaries | An LLM API behind a small provider interface | the one network call, swappable and optional | | Storage | Markdown files in ~/MeetingNotes | greppable, portable, no index to corrupt | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Capture | ScreenCaptureKit and AVAudioEngine taps, mixing, WAV writing | Core Audio process taps on 14.2+, or any source producing 16 kHz mono 16-bit WAV | | Transcriber | whisper-cli invocation, progress, hosted fallback | WhisperKit in-process, or any engine writing the same transcript.txt | | Summarizer | the provider interface, chunking, the fixed prompt | Any LLM provider; local models via Ollama behind the same interface | | Vault | the meeting folder layout and frontmatter | Obsidian-compatible folder; a sync tool sits outside the app | | Shell | MenuBarExtra UI and permissions guidance | The only SwiftUI code; everything else is a package | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `NOTES_DIR` · required · Where meeting folders are written. Any folder you sync or back up. - `WHISPER_BIN` · required · Path to the binary you built. - `WHISPER_MODEL` · required · Path to the model you downloaded. - `ANTHROPIC_API_KEY` · optional, secret · Anthropic console. Leave empty to skip summaries. - `OPENAI_API_KEY` · optional, secret · OpenAI dashboard. Used for summaries if no Anthropic key, and for hosted Whisper if enabled. - `LLM_MODEL` · required · Model name for summaries. Anything the provider offers. - `GROQ_API_KEY` · optional, secret · console.groq.com. Enables the hosted transcription fast path. - `TRANSCRIBE_MODE` · required · local or hosted. Local is the default and works offline. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Granola product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Swift 6, SwiftUI, MenuBarExtra, macOS 14+, ScreenCaptureKit for system audio, AVAudioEngine for the mic, whisper.cpp via the whisper-cli binary, An LLM API behind a small provider interface, Markdown files in ~/MeetingNotes. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Do not tell the user to install BlackHole or Loopback. ScreenCaptureKit delivers system audio under the screen-recording grant with no driver. - The permission prompt appears once per launch. If it is dismissed the app must be relaunched; detect the denied state and say so. ===== MILESTONES.md ===== # Delivery milestones · Granola Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Audio capture, proven as a CLI first Before any UI exists, prove you can record system audio and the mic into a WAV that whisper-cli accepts. This is the phase that kills the project if skipped. ### Steps 1. Create an Xcode project with two targets: a command-line tool and the menu bar app The CLI target is for this phase; the app target waits until Phase 5. Both share a Swift package with the capture code. Files: `MeetingNotes.xcodeproj`, `Sources/Capture/` 2. Capture system audio with ScreenCaptureKit Build an SCStreamConfiguration with capturesAudio true. SCStream still requires a screen output to be added even for audio only: add one and set minimumFrameInterval to a very large value so no real frames are processed. On macOS 14.2+ you may instead use Core Audio process taps (AudioHardwareCreateProcessTap) for genuine audio-only capture; pick one and say which in a comment. 3. Capture the microphone with AVAudioEngine, separately Do not use SCStreamConfiguration's microphone capture; it has known output-corruption reports. Tap the input node and collect buffers. 4. Mix both taps and write 16 kHz mono 16-bit PCM WAV Resample explicitly with AVAudioConverter. Not 44.1 kHz, not float, not stereo. whisper-cli accepts 16-bit WAV only; every hour lost to this project is lost here. 5. Run the CLI for 30 seconds while music plays and you speak ```sh swift run capture-cli --seconds 30 --out /tmp/test.wav afinfo /tmp/test.wav /path/to/whisper-cli -m /path/to/ggml-medium.en.bin -f /tmp/test.wav ``` ### Done when - [ ] afinfo reports 16000 Hz, 1 channel, 16-bit for the WAV - [ ] Playing the WAV back you hear both the music and your voice - [ ] whisper-cli accepts the file without a format error and prints text - [ ] Denying Screen Recording produces a clear error naming the System Settings pane, not a silent empty file ### Watch out - Do not tell the user to install BlackHole or Loopback. ScreenCaptureKit delivers system audio under the screen-recording grant with no driver. - The permission prompt appears once per launch. If it is dismissed the app must be relaunched; detect the denied state and say so. ## M2 · Transcription A transcript from a WAV, local by default, with progress you can see and a hosted fast path when a key exists. ### Steps 1. Shell out to whisper-cli with -otxt Process with WHISPER_BIN, WHISPER_MODEL and the audio path. Read the .txt it writes. Capture stderr for progress lines. ```sh whisper-cli -m $WHISPER_MODEL -f audio.wav -otxt -of transcript ``` 2. Report progress A 45-minute meeting on the medium model is minutes of CPU. Parse whisper's progress output into a percentage; a UI that looks frozen will be assumed broken. 3. Add the hosted path behind TRANSCRIBE_MODE When hosted and a Groq or OpenAI key exists, post the WAV to the Whisper endpoint. Local remains the default: the recording should not leave the machine unless asked. 4. Make it resumable Write the transcript to a temp name and rename on completion. Killing the app mid-transcription must leave the audio intact and no half-written transcript. ### Done when - [ ] A 5-minute recording produces a transcript whose first and last sentences are recognisably correct - [ ] Progress moves during transcription - [ ] Killing the app mid-transcription leaves audio.wav intact and no transcript.txt - [ ] With TRANSCRIBE_MODE=hosted and a key, the same file transcribes via the API ## M3 · Summarization A fixed prompt turns the transcript and your jotted notes into a 5-bullet summary, decisions and action items with owners, and long meetings are chunked rather than truncated. ### Steps 1. Write the provider interface and one implementation summarize(transcript, notes) returning structured text. Anthropic first; OpenAI as a second implementation selected by which key exists. 2. Chunk transcripts that exceed the context window Split on paragraph boundaries into chunks under the model's limit, summarize each, then summarize the summaries. Never silently truncate: that produces confident notes about the first third of a meeting. 3. Pass the user's rough notes alongside the transcript This is the actual Granola idea: your jottings steer what the summary emphasises. ### Done when - [ ] A transcript with three clear decisions and two assigned tasks yields notes naming all five - [ ] A 90-minute transcript summarizes without an API error - [ ] With no key present the step is skipped with a clear note in the output, not an error ## M4 · Notes on disk One folder per meeting, plain Markdown with frontmatter, never overwriting. ### Steps 1. Write NOTES_DIR/YYYY-MM-DD-HHMM-<slug>/ with audio.wav, transcript.txt and notes.md notes.md: YAML frontmatter (title, date, duration, participants, model), the summary, a divider, then the full transcript. 2. Derive the title from the summary and never overwrite If the folder exists, suffix a counter. Two meetings in the same minute must produce two folders. ### Done when - [ ] Two meetings started in the same minute produce two folders - [ ] notes.md opens correctly in a plain Markdown editor with no app running - [ ] The audio file is kept next to the notes ## M5 · Menu bar app Start and stop from the menu bar, jot notes while recording, see recent meetings, and get told exactly what to do when a permission is missing. ### Steps 1. Build the MenuBarExtra with Start/Stop and elapsed time A recording indicator that is unmistakable. Wire the capture code from Phase 1. 2. Add the live jot-notes field Text typed during the meeting is passed to the summarizer in Phase 3. 3. List recent meetings with Reveal in Finder Read NOTES_DIR; newest first. 4. Handle permissions explicitly If Screen Recording or Microphone is denied, show which System Settings pane to open and the relaunch caveat, instead of failing silently. ### Done when - [ ] A full meeting runs start to notes without touching a terminal - [ ] First launch on a machine that has never granted permission explains itself - [ ] The jotted notes visibly influence the summary ## M6 · Offline, packaging, README Works with the network off when the model is present, signed for your own machine, documented. ### Steps 1. Test with Wi-Fi off and no LLM key Transcript saved; summary skipped with an honest note in notes.md. 2. Sign locally and document the Gatekeeper first-run step Sign with your development certificate for your own use. Right-click > Open the first time. 3. Write the README Permissions and where to grant them, model download size, disk cost per hour of audio, and a plain warning that recording a meeting can require consent from everyone in it depending on where they are. Files: `README.md` ### Done when - [ ] Airplane mode produces a complete transcript and a note saying the summary was skipped - [ ] A fresh Mac goes from clone to first recording using only the README ## M7 · Distribute it (production only) Only for the product path: an app other people can install without terrifying Gatekeeper dialogs, that updates itself, and that tells you when it crashes. ### Steps 1. Join the Apple Developer Program and sign with a Developer ID Required to notarize. Xcode > Signing & Capabilities with the Developer ID Application certificate. ```sh xcrun notarytool submit MeetingNotes.zip --keychain-profile notary --wait xcrun stapler staple MeetingNotes.app ``` 2. Add Sparkle for updates Host an appcast XML on your domain; sign updates with the EdDSA key Sparkle generates. 3. Add opt-in crash reporting A local crash log the user can send you, or Sentry's macOS SDK behind an explicit opt-in toggle. Never on by default for a privacy-first app. ### Done when - [ ] A notarized build opens on a second Mac with no right-click workaround - [ ] Publishing a new appcast entry makes the installed app offer the update - [ ] A deliberate crash produces a report only when the toggle is on ===== OPERATIONS.md ===== # Operations · Granola ## Backup NOTES_DIR is plain files: point Time Machine, iCloud Drive or Syncthing at it. The app holds no other state. ## Restore Copy the folder back. The recent-meetings list is derived from the folder, so there is nothing else to restore. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a distributed app: opt-in crash reports and an update-check success rate from the appcast host's logs. ## Incident checklist If an API key is leaked, rotate it at the provider and ship a release that clears the stored key. If a transcription bug corrupts notes, the audio.wav next to each note allows regeneration. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A notarized build installs and records on a Mac that has never had the developer tools - [ ] Airplane-mode test passes - [ ] The consent warning is in the README and the first-run screen - [ ] One update delivered through Sparkle to a real install ## Launch constraint Do not market omitted Granola capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where meeting folders are written. Any folder you sync or back up. NOTES_DIR=~/MeetingNotes # Required. Path to the binary you built. WHISPER_BIN=/Users/you/whisper.cpp/build/bin/whisper-cli # Required. Path to the model you downloaded. WHISPER_MODEL=/Users/you/whisper.cpp/models/ggml-medium.en.bin # Optional · secret. Anthropic console. Leave empty to skip summaries. ANTHROPIC_API_KEY=sk-ant-... # Optional · secret. OpenAI dashboard. Used for summaries if no Anthropic key, and for hosted Whisper if enabled. OPENAI_API_KEY=sk-... # Required. Model name for summaries. Anything the provider offers. LLM_MODEL=claude-sonnet-5 # Optional · secret. console.groq.com. Enables the hosted transcription fast path. GROQ_API_KEY=gsk_... # Required. local or hosted. Local is the default and works offline. TRANSCRIBE_MODE=local
# Granola · indie build A local meeting-notes app for your Mac: a menu bar button records system audio and your microphone, whisper.cpp transcribes on your machine, an LLM turns the transcript plus your rough notes into a summary with decisions and action items, and everything lands as Markdown files in a folder you own. Nothing leaves the machine except the optional LLM call. Estimated effort: **weekend**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | App | Swift 6, SwiftUI, MenuBarExtra, macOS 14+ | system audio capture is a native API; wrapping it in Electron is where these projects stall | | Audio | ScreenCaptureKit for system audio, AVAudioEngine for the mic | no virtual audio driver to install, and two separate taps you can mix | | Transcription | whisper.cpp via the whisper-cli binary | local, free, good enough on Apple Silicon | | Summaries | An LLM API behind a small provider interface | the one network call, swappable and optional | | Storage | Markdown files in ~/MeetingNotes | greppable, portable, no index to corrupt | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **A Mac with Apple Silicon running macOS 14 or newer** · the Mac you have - Why: ScreenCaptureKit audio capture needs macOS 13+, and local transcription is only pleasant on Apple Silicon. - Get it: Apple menu > About This Mac. Update through System Settings > General > Software Update if below 14. - Verify: sw_vers prints ProductVersion 14 or higher - [ ] **Xcode 16 or newer** · free - Why: Swift, SwiftUI and the macOS SDK come with it. The Command Line Tools alone are not enough for a signed menu bar app. - Get it: Install from the Mac App Store (it is large; start the download first). Open it once to accept the licence and install components. - Verify: xcodebuild -version prints Xcode 16 or higher - [ ] **Homebrew, cmake and ffmpeg** · free - Why: cmake builds whisper.cpp; ffmpeg is the easiest way to inspect and convert audio while you debug Phase 1. - Get it: Install Homebrew from brew.sh, then: brew install cmake ffmpeg - Verify: cmake --version and ffmpeg -version both print - [ ] **whisper.cpp built from source** · free - Why: The whisper-cli binary is what Phase 2 shells out to. - Get it: git clone https://github.com/ggml-org/whisper.cpp && cd whisper.cpp && cmake -B build && cmake --build build --config Release. The binary is build/bin/whisper-cli. - Verify: ./build/bin/whisper-cli -h prints usage - [ ] **The medium.en whisper model (about 1.5 GB)** · free, 1.5 GB of disk - Why: Without a model file there is no transcription. medium.en is the quality-speed balance for English meetings. - Get it: Inside the whisper.cpp folder: ./models/download-ggml-model.sh medium.en. It saves to models/ggml-medium.en.bin. Use small.en (about 500 MB) if disk or speed is tight. - Verify: ls -lh models/ggml-medium.en.bin shows about 1.5G - [ ] **An LLM API key (OpenAI or Anthropic)** (optional) · pay per use; a one-hour meeting summary costs a few cents - Why: Phase 3 sends the transcript to a model for the summary. Without a key the app still records and transcribes; it just skips the summary. - Get it: Anthropic: console.anthropic.com > API Keys > Create Key. OpenAI: platform.openai.com/api-keys > Create new secret key. Copy it once; it is not shown again. Put it in .env, never in the source. - [ ] **A Groq or OpenAI key for hosted transcription** (optional) · pay per use; Groq has a free tier - Why: An optional fast path when local transcription is too slow on your machine. Local stays the default. - Get it: Groq: console.groq.com > API Keys. OpenAI: the same key as above works for the audio endpoint. - [ ] **Screen Recording and Microphone permissions** · free - Why: System audio arrives under the Screen Recording permission (no separate audio permission exists); the mic needs its own. Both are granted once per app build. - Get it: macOS prompts on first use. If you dismiss the prompt, the app must be relaunched before it can ask again; otherwise System Settings > Privacy & Security > Screen Recording and > Microphone. ## Quick start ```sh swift run capture-cli --seconds 30 --out /tmp/test.wav afinfo /tmp/test.wav /path/to/whisper-cli -m /path/to/ggml-medium.en.bin -f /tmp/test.wav ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Sync across devices and a mobile app. That is most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and support when a recording is lost. - sync across devices - the mobile app - automatic calendar joins - someone else's servers & support If one of those is essential to you, that is the reason to keep paying for Granola, and the README should say so rather than pretend.
# Build brief · Granola The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local meeting-notes app like Granola for macOS. Build it in phases, in the order below. Do not write the whole app in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. Phase 1 is the phase that kills this project · get audio capture proven before any UI exists. ### Stack (fixed, do not substitute) - Swift 6 and SwiftUI, macOS 14+, a `MenuBarExtra` app. Not Electron · system audio capture here is a native API, and wrapping it in a bridge is the reason most attempts at this stall. - ScreenCaptureKit for system audio, AVAudioEngine for the microphone. - whisper.cpp for local transcription, invoked as the `whisper-cli` binary. - No database. Markdown files on disk are the storage layer. ### Data model (decide this before Phase 1) One meeting is one folder: `~/MeetingNotes/YYYY-MM-DD-HHMM-<slug>/` containing `audio.wav`, `transcript.txt`, and `notes.md`. `notes.md` carries YAML frontmatter (title, date, duration, participants, model used) so the folder stays greppable and portable with no app-specific index to corrupt. ### Phase 1 · Audio capture, proven as a CLI first Build: a command-line target that records N seconds and writes a WAV, before any UI exists. Capture system audio with ScreenCaptureKit and the microphone with AVAudioEngine, as two separate taps, then mix them into one mono track. Details that decide whether this works: - ScreenCaptureKit delivers system audio under the screen-recording permission · there is no separate audio permission, and no virtual audio driver is needed. Do not tell the user to install BlackHole or Loopback. - `SCStream` still requires a screen output to be added even when you only want audio. Add one and set `minimumFrameInterval` to a very large value so no real frames are processed. On macOS 14.2+ you may instead use Core Audio process taps (`AudioHardwareCreateProcessTap`) for genuine audio-only capture · pick one, and say in a comment which and why. - Capture the microphone through AVAudioEngine rather than `SCStreamConfiguration` microphone capture, which has known output-corruption reports. - Write 16-bit PCM WAV, 16 kHz, mono. Not 44.1 kHz, not float, not stereo. whisper-cli accepts 16-bit WAV only, and every hour lost to this project is lost here. Resample explicitly with AVAudioConverter. Done when: the CLI records 30 seconds while music plays and you speak, and the resulting WAV plays back with both audible, is exactly 16 kHz mono 16-bit (verify with `afinfo`), and `whisper-cli` accepts it without a format error. Do not build yet: transcription quality, UI, LLM, files. ### Phase 2 · Transcription Build: a transcription step that shells out to whisper-cli. - Fetch the model with `./models/download-ggml-model.sh medium.en`, producing `models/ggml-medium.en.bin`. Document the download size before it downloads. - Invoke `whisper-cli -m models/ggml-medium.en.bin -f audio.wav -otxt`. - If `OPENAI_API_KEY` or `GROQ_API_KEY` is present in `.env`, offer the hosted Whisper endpoint as an opt-in fast path. Local stays the default · the whole point is that the recording never leaves the machine unless asked. - Report progress. A 45-minute meeting on the medium model is minutes of work on CPU, and a UI that looks frozen will be assumed broken. Done when: a 5-minute recording produces a transcript whose first and last sentences are recognisably correct, and killing the app mid-transcription leaves the audio file intact and resumable rather than a half-written transcript. ### Phase 3 · Summarization Build: send the transcript to an LLM (key in `.env`, model configurable) with a fixed prompt that returns a 5-bullet summary, decisions made, and action items with owners. Chunk transcripts that exceed the context window and summarize the chunk summaries · do not silently truncate, which produces confident notes about the first third of a meeting. Done when: a transcript with three clear decisions and two assigned tasks yields notes naming all five, and a 90-minute transcript summarizes without an API error. Do not build yet: the UI. ### Phase 4 · Notes on disk Build: write the meeting folder, `notes.md` with frontmatter and summary above a divider and the full transcript below it, plus the retained audio. Derive the title from the summary. Never overwrite an existing folder · suffix a counter. Done when: two meetings started in the same minute produce two folders, and every file opens correctly in a plain Markdown editor with no app running. ### Phase 5 · Menu bar app Build: the `MenuBarExtra` UI · Start/Stop with elapsed time, a recording indicator, a live jot-notes field whose text is passed to the LLM alongside the transcript (this is the actual Granola idea · your rough notes steer the summary), a list of recent meetings, and Reveal in Finder. Handle the permission prompt explicitly: if screen recording is denied, say which System Settings pane to open rather than failing silently. Done when: a full meeting runs start to notes without touching a terminal, and a first launch on a machine that has never granted permission explains itself. ### Phase 6 · Offline, packaging, README Build: verify the whole path works with the network off when the model is present and no LLM key is set (transcript saved, summary skipped with a clear note). Sign the app locally, document the Gatekeeper first-run step, and write the README. Done when: airplane mode produces a complete transcript and an honest note saying the summary was skipped. ### Out of scope (and why) - Sync across devices and a mobile app. Those are most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and someone else's support when a recording is lost. ### README must contain - The exact permissions to grant and where, with the reason for each. - Model download size and disk cost per hour of retained audio. - A plain warning: recording a meeting can require consent from everyone in it, and the legal answer depends on where the participants are, not where you are.
# Agent instructions · Granola indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Swift 6, SwiftUI, MenuBarExtra, macOS 14+, ScreenCaptureKit for system audio, AVAudioEngine for the mic, whisper.cpp via the whisper-cli binary, An LLM API behind a small provider interface, Markdown files in ~/MeetingNotes. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Do not tell the user to install BlackHole or Loopback. ScreenCaptureKit delivers system audio under the screen-recording grant with no driver. - The permission prompt appears once per launch. If it is dismissed the app must be relaunched; detect the denied state and say so.
# Build plan · Granola A local meeting-notes app for your Mac: a menu bar button records system audio and your microphone, whisper.cpp transcribes on your machine, an LLM turns the transcript plus your rough notes into a summary with decisions and action items, and everything lands as Markdown files in a folder you own. Nothing leaves the machine except the optional LLM call. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Audio capture, proven as a CLI first Before any UI exists, prove you can record system audio and the mic into a WAV that whisper-cli accepts. This is the phase that kills the project if skipped. ### Steps 1. Create an Xcode project with two targets: a command-line tool and the menu bar app The CLI target is for this phase; the app target waits until Phase 5. Both share a Swift package with the capture code. Files: `MeetingNotes.xcodeproj`, `Sources/Capture/` 2. Capture system audio with ScreenCaptureKit Build an SCStreamConfiguration with capturesAudio true. SCStream still requires a screen output to be added even for audio only: add one and set minimumFrameInterval to a very large value so no real frames are processed. On macOS 14.2+ you may instead use Core Audio process taps (AudioHardwareCreateProcessTap) for genuine audio-only capture; pick one and say which in a comment. 3. Capture the microphone with AVAudioEngine, separately Do not use SCStreamConfiguration's microphone capture; it has known output-corruption reports. Tap the input node and collect buffers. 4. Mix both taps and write 16 kHz mono 16-bit PCM WAV Resample explicitly with AVAudioConverter. Not 44.1 kHz, not float, not stereo. whisper-cli accepts 16-bit WAV only; every hour lost to this project is lost here. 5. Run the CLI for 30 seconds while music plays and you speak ```sh swift run capture-cli --seconds 30 --out /tmp/test.wav afinfo /tmp/test.wav /path/to/whisper-cli -m /path/to/ggml-medium.en.bin -f /tmp/test.wav ``` ### Done when - [ ] afinfo reports 16000 Hz, 1 channel, 16-bit for the WAV - [ ] Playing the WAV back you hear both the music and your voice - [ ] whisper-cli accepts the file without a format error and prints text - [ ] Denying Screen Recording produces a clear error naming the System Settings pane, not a silent empty file ### Watch out - Do not tell the user to install BlackHole or Loopback. ScreenCaptureKit delivers system audio under the screen-recording grant with no driver. - The permission prompt appears once per launch. If it is dismissed the app must be relaunched; detect the denied state and say so. ## Phase 2 · Transcription A transcript from a WAV, local by default, with progress you can see and a hosted fast path when a key exists. ### Steps 1. Shell out to whisper-cli with -otxt Process with WHISPER_BIN, WHISPER_MODEL and the audio path. Read the .txt it writes. Capture stderr for progress lines. ```sh whisper-cli -m $WHISPER_MODEL -f audio.wav -otxt -of transcript ``` 2. Report progress A 45-minute meeting on the medium model is minutes of CPU. Parse whisper's progress output into a percentage; a UI that looks frozen will be assumed broken. 3. Add the hosted path behind TRANSCRIBE_MODE When hosted and a Groq or OpenAI key exists, post the WAV to the Whisper endpoint. Local remains the default: the recording should not leave the machine unless asked. 4. Make it resumable Write the transcript to a temp name and rename on completion. Killing the app mid-transcription must leave the audio intact and no half-written transcript. ### Done when - [ ] A 5-minute recording produces a transcript whose first and last sentences are recognisably correct - [ ] Progress moves during transcription - [ ] Killing the app mid-transcription leaves audio.wav intact and no transcript.txt - [ ] With TRANSCRIBE_MODE=hosted and a key, the same file transcribes via the API ## Phase 3 · Summarization A fixed prompt turns the transcript and your jotted notes into a 5-bullet summary, decisions and action items with owners, and long meetings are chunked rather than truncated. ### Steps 1. Write the provider interface and one implementation summarize(transcript, notes) returning structured text. Anthropic first; OpenAI as a second implementation selected by which key exists. 2. Chunk transcripts that exceed the context window Split on paragraph boundaries into chunks under the model's limit, summarize each, then summarize the summaries. Never silently truncate: that produces confident notes about the first third of a meeting. 3. Pass the user's rough notes alongside the transcript This is the actual Granola idea: your jottings steer what the summary emphasises. ### Done when - [ ] A transcript with three clear decisions and two assigned tasks yields notes naming all five - [ ] A 90-minute transcript summarizes without an API error - [ ] With no key present the step is skipped with a clear note in the output, not an error ## Phase 4 · Notes on disk One folder per meeting, plain Markdown with frontmatter, never overwriting. ### Steps 1. Write NOTES_DIR/YYYY-MM-DD-HHMM-<slug>/ with audio.wav, transcript.txt and notes.md notes.md: YAML frontmatter (title, date, duration, participants, model), the summary, a divider, then the full transcript. 2. Derive the title from the summary and never overwrite If the folder exists, suffix a counter. Two meetings in the same minute must produce two folders. ### Done when - [ ] Two meetings started in the same minute produce two folders - [ ] notes.md opens correctly in a plain Markdown editor with no app running - [ ] The audio file is kept next to the notes ## Phase 5 · Menu bar app Start and stop from the menu bar, jot notes while recording, see recent meetings, and get told exactly what to do when a permission is missing. ### Steps 1. Build the MenuBarExtra with Start/Stop and elapsed time A recording indicator that is unmistakable. Wire the capture code from Phase 1. 2. Add the live jot-notes field Text typed during the meeting is passed to the summarizer in Phase 3. 3. List recent meetings with Reveal in Finder Read NOTES_DIR; newest first. 4. Handle permissions explicitly If Screen Recording or Microphone is denied, show which System Settings pane to open and the relaunch caveat, instead of failing silently. ### Done when - [ ] A full meeting runs start to notes without touching a terminal - [ ] First launch on a machine that has never granted permission explains itself - [ ] The jotted notes visibly influence the summary ## Phase 6 · Offline, packaging, README Works with the network off when the model is present, signed for your own machine, documented. ### Steps 1. Test with Wi-Fi off and no LLM key Transcript saved; summary skipped with an honest note in notes.md. 2. Sign locally and document the Gatekeeper first-run step Sign with your development certificate for your own use. Right-click > Open the first time. 3. Write the README Permissions and where to grant them, model download size, disk cost per hour of audio, and a plain warning that recording a meeting can require consent from everyone in it depending on where they are. Files: `README.md` ### Done when - [ ] Airplane mode produces a complete transcript and a note saying the summary was skipped - [ ] A fresh Mac goes from clone to first recording using only the README ## Not in this build - Sync across devices and a mobile app. That is most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and support when a recording is lost. ## After v1, if you want it - Speaker diarization with a local model so action items get real owners - Ollama as a local summarizer behind the same provider interface for a fully offline pipeline
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where meeting folders are written. Any folder you sync or back up. NOTES_DIR=~/MeetingNotes # Required. Path to the binary you built. WHISPER_BIN=/Users/you/whisper.cpp/build/bin/whisper-cli # Required. Path to the model you downloaded. WHISPER_MODEL=/Users/you/whisper.cpp/models/ggml-medium.en.bin # Optional · secret. Anthropic console. Leave empty to skip summaries. ANTHROPIC_API_KEY=sk-ant-... # Optional · secret. OpenAI dashboard. Used for summaries if no Anthropic key, and for hosted Whisper if enabled. OPENAI_API_KEY=sk-... # Required. Model name for summaries. Anything the provider offers. LLM_MODEL=claude-sonnet-5 # Optional · secret. console.groq.com. Enables the hosted transcription fast path. GROQ_API_KEY=gsk_... # Required. local or hosted. Local is the default and works offline. TRANSCRIBE_MODE=local
# Granola · product brief ## Problem A local recorder plus Whisper transcription plus an LLM note template covers the core personal loop in one sitting. The paid product is mainly polish, meeting context, sync, and team workflow. ## Product outcome A privacy-first meeting notes app you could hand to colleagues: notarized, self-updating, local by default, with a clear consent posture. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - macOS or Windows audio capture library - OpenAI/Anthropic API key or local Whisper model - local folder or SQLite database ## Explicit non-goals for v1 - Sync across devices and a mobile app. That is most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and support when a recording is lost. - sync across devices - the mobile app - automatic calendar joins - someone else's servers & support ## Success criteria - A notarized build installs and records on a Mac that has never had the developer tools - Airplane-mode test passes - The consent warning is in the README and the first-run screen - One update delivered through Sparkle to a real install
# Build brief · Granola The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a local meeting-notes app like Granola for macOS. Build it in phases, in the order below. Do not write the whole app in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. Phase 1 is the phase that kills this project · get audio capture proven before any UI exists. ### Stack (fixed, do not substitute) - Swift 6 and SwiftUI, macOS 14+, a `MenuBarExtra` app. Not Electron · system audio capture here is a native API, and wrapping it in a bridge is the reason most attempts at this stall. - ScreenCaptureKit for system audio, AVAudioEngine for the microphone. - whisper.cpp for local transcription, invoked as the `whisper-cli` binary. - No database. Markdown files on disk are the storage layer. ### Data model (decide this before Phase 1) One meeting is one folder: `~/MeetingNotes/YYYY-MM-DD-HHMM-<slug>/` containing `audio.wav`, `transcript.txt`, and `notes.md`. `notes.md` carries YAML frontmatter (title, date, duration, participants, model used) so the folder stays greppable and portable with no app-specific index to corrupt. ### Phase 1 · Audio capture, proven as a CLI first Build: a command-line target that records N seconds and writes a WAV, before any UI exists. Capture system audio with ScreenCaptureKit and the microphone with AVAudioEngine, as two separate taps, then mix them into one mono track. Details that decide whether this works: - ScreenCaptureKit delivers system audio under the screen-recording permission · there is no separate audio permission, and no virtual audio driver is needed. Do not tell the user to install BlackHole or Loopback. - `SCStream` still requires a screen output to be added even when you only want audio. Add one and set `minimumFrameInterval` to a very large value so no real frames are processed. On macOS 14.2+ you may instead use Core Audio process taps (`AudioHardwareCreateProcessTap`) for genuine audio-only capture · pick one, and say in a comment which and why. - Capture the microphone through AVAudioEngine rather than `SCStreamConfiguration` microphone capture, which has known output-corruption reports. - Write 16-bit PCM WAV, 16 kHz, mono. Not 44.1 kHz, not float, not stereo. whisper-cli accepts 16-bit WAV only, and every hour lost to this project is lost here. Resample explicitly with AVAudioConverter. Done when: the CLI records 30 seconds while music plays and you speak, and the resulting WAV plays back with both audible, is exactly 16 kHz mono 16-bit (verify with `afinfo`), and `whisper-cli` accepts it without a format error. Do not build yet: transcription quality, UI, LLM, files. ### Phase 2 · Transcription Build: a transcription step that shells out to whisper-cli. - Fetch the model with `./models/download-ggml-model.sh medium.en`, producing `models/ggml-medium.en.bin`. Document the download size before it downloads. - Invoke `whisper-cli -m models/ggml-medium.en.bin -f audio.wav -otxt`. - If `OPENAI_API_KEY` or `GROQ_API_KEY` is present in `.env`, offer the hosted Whisper endpoint as an opt-in fast path. Local stays the default · the whole point is that the recording never leaves the machine unless asked. - Report progress. A 45-minute meeting on the medium model is minutes of work on CPU, and a UI that looks frozen will be assumed broken. Done when: a 5-minute recording produces a transcript whose first and last sentences are recognisably correct, and killing the app mid-transcription leaves the audio file intact and resumable rather than a half-written transcript. ### Phase 3 · Summarization Build: send the transcript to an LLM (key in `.env`, model configurable) with a fixed prompt that returns a 5-bullet summary, decisions made, and action items with owners. Chunk transcripts that exceed the context window and summarize the chunk summaries · do not silently truncate, which produces confident notes about the first third of a meeting. Done when: a transcript with three clear decisions and two assigned tasks yields notes naming all five, and a 90-minute transcript summarizes without an API error. Do not build yet: the UI. ### Phase 4 · Notes on disk Build: write the meeting folder, `notes.md` with frontmatter and summary above a divider and the full transcript below it, plus the retained audio. Derive the title from the summary. Never overwrite an existing folder · suffix a counter. Done when: two meetings started in the same minute produce two folders, and every file opens correctly in a plain Markdown editor with no app running. ### Phase 5 · Menu bar app Build: the `MenuBarExtra` UI · Start/Stop with elapsed time, a recording indicator, a live jot-notes field whose text is passed to the LLM alongside the transcript (this is the actual Granola idea · your rough notes steer the summary), a list of recent meetings, and Reveal in Finder. Handle the permission prompt explicitly: if screen recording is denied, say which System Settings pane to open rather than failing silently. Done when: a full meeting runs start to notes without touching a terminal, and a first launch on a machine that has never granted permission explains itself. ### Phase 6 · Offline, packaging, README Build: verify the whole path works with the network off when the model is present and no LLM key is set (transcript saved, summary skipped with a clear note). Sign the app locally, document the Gatekeeper first-run step, and write the README. Done when: airplane mode produces a complete transcript and an honest note saying the summary was skipped. ### Out of scope (and why) - Sync across devices and a mobile app. Those are most of the subscription. - Automatic calendar joining and bot attendees. - Someone else's servers and someone else's support when a recording is lost. ### README must contain - The exact permissions to grant and where, with the reason for each. - Model download size and disk cost per hour of retained audio. - A plain warning: recording a meeting can require consent from everyone in it, and the legal answer depends on where the participants are, not where you are.
# Architecture · Granola ## Stack | Part | Choice | Why | | --- | --- | --- | | App | Swift 6, SwiftUI, MenuBarExtra, macOS 14+ | system audio capture is a native API; wrapping it in Electron is where these projects stall | | Audio | ScreenCaptureKit for system audio, AVAudioEngine for the mic | no virtual audio driver to install, and two separate taps you can mix | | Transcription | whisper.cpp via the whisper-cli binary | local, free, good enough on Apple Silicon | | Summaries | An LLM API behind a small provider interface | the one network call, swappable and optional | | Storage | Markdown files in ~/MeetingNotes | greppable, portable, no index to corrupt | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Capture | ScreenCaptureKit and AVAudioEngine taps, mixing, WAV writing | Core Audio process taps on 14.2+, or any source producing 16 kHz mono 16-bit WAV | | Transcriber | whisper-cli invocation, progress, hosted fallback | WhisperKit in-process, or any engine writing the same transcript.txt | | Summarizer | the provider interface, chunking, the fixed prompt | Any LLM provider; local models via Ollama behind the same interface | | Vault | the meeting folder layout and frontmatter | Obsidian-compatible folder; a sync tool sits outside the app | | Shell | MenuBarExtra UI and permissions guidance | The only SwiftUI code; everything else is a package | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `NOTES_DIR` · required · Where meeting folders are written. Any folder you sync or back up. - `WHISPER_BIN` · required · Path to the binary you built. - `WHISPER_MODEL` · required · Path to the model you downloaded. - `ANTHROPIC_API_KEY` · optional, secret · Anthropic console. Leave empty to skip summaries. - `OPENAI_API_KEY` · optional, secret · OpenAI dashboard. Used for summaries if no Anthropic key, and for hosted Whisper if enabled. - `LLM_MODEL` · required · Model name for summaries. Anything the provider offers. - `GROQ_API_KEY` · optional, secret · console.groq.com. Enables the hosted transcription fast path. - `TRANSCRIBE_MODE` · required · local or hosted. Local is the default and works offline. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Granola product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Swift 6, SwiftUI, MenuBarExtra, macOS 14+, ScreenCaptureKit for system audio, AVAudioEngine for the mic, whisper.cpp via the whisper-cli binary, An LLM API behind a small provider interface, Markdown files in ~/MeetingNotes. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Do not tell the user to install BlackHole or Loopback. ScreenCaptureKit delivers system audio under the screen-recording grant with no driver. - The permission prompt appears once per launch. If it is dismissed the app must be relaunched; detect the denied state and say so.
# Delivery milestones · Granola Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Audio capture, proven as a CLI first Before any UI exists, prove you can record system audio and the mic into a WAV that whisper-cli accepts. This is the phase that kills the project if skipped. ### Steps 1. Create an Xcode project with two targets: a command-line tool and the menu bar app The CLI target is for this phase; the app target waits until Phase 5. Both share a Swift package with the capture code. Files: `MeetingNotes.xcodeproj`, `Sources/Capture/` 2. Capture system audio with ScreenCaptureKit Build an SCStreamConfiguration with capturesAudio true. SCStream still requires a screen output to be added even for audio only: add one and set minimumFrameInterval to a very large value so no real frames are processed. On macOS 14.2+ you may instead use Core Audio process taps (AudioHardwareCreateProcessTap) for genuine audio-only capture; pick one and say which in a comment. 3. Capture the microphone with AVAudioEngine, separately Do not use SCStreamConfiguration's microphone capture; it has known output-corruption reports. Tap the input node and collect buffers. 4. Mix both taps and write 16 kHz mono 16-bit PCM WAV Resample explicitly with AVAudioConverter. Not 44.1 kHz, not float, not stereo. whisper-cli accepts 16-bit WAV only; every hour lost to this project is lost here. 5. Run the CLI for 30 seconds while music plays and you speak ```sh swift run capture-cli --seconds 30 --out /tmp/test.wav afinfo /tmp/test.wav /path/to/whisper-cli -m /path/to/ggml-medium.en.bin -f /tmp/test.wav ``` ### Done when - [ ] afinfo reports 16000 Hz, 1 channel, 16-bit for the WAV - [ ] Playing the WAV back you hear both the music and your voice - [ ] whisper-cli accepts the file without a format error and prints text - [ ] Denying Screen Recording produces a clear error naming the System Settings pane, not a silent empty file ### Watch out - Do not tell the user to install BlackHole or Loopback. ScreenCaptureKit delivers system audio under the screen-recording grant with no driver. - The permission prompt appears once per launch. If it is dismissed the app must be relaunched; detect the denied state and say so. ## M2 · Transcription A transcript from a WAV, local by default, with progress you can see and a hosted fast path when a key exists. ### Steps 1. Shell out to whisper-cli with -otxt Process with WHISPER_BIN, WHISPER_MODEL and the audio path. Read the .txt it writes. Capture stderr for progress lines. ```sh whisper-cli -m $WHISPER_MODEL -f audio.wav -otxt -of transcript ``` 2. Report progress A 45-minute meeting on the medium model is minutes of CPU. Parse whisper's progress output into a percentage; a UI that looks frozen will be assumed broken. 3. Add the hosted path behind TRANSCRIBE_MODE When hosted and a Groq or OpenAI key exists, post the WAV to the Whisper endpoint. Local remains the default: the recording should not leave the machine unless asked. 4. Make it resumable Write the transcript to a temp name and rename on completion. Killing the app mid-transcription must leave the audio intact and no half-written transcript. ### Done when - [ ] A 5-minute recording produces a transcript whose first and last sentences are recognisably correct - [ ] Progress moves during transcription - [ ] Killing the app mid-transcription leaves audio.wav intact and no transcript.txt - [ ] With TRANSCRIBE_MODE=hosted and a key, the same file transcribes via the API ## M3 · Summarization A fixed prompt turns the transcript and your jotted notes into a 5-bullet summary, decisions and action items with owners, and long meetings are chunked rather than truncated. ### Steps 1. Write the provider interface and one implementation summarize(transcript, notes) returning structured text. Anthropic first; OpenAI as a second implementation selected by which key exists. 2. Chunk transcripts that exceed the context window Split on paragraph boundaries into chunks under the model's limit, summarize each, then summarize the summaries. Never silently truncate: that produces confident notes about the first third of a meeting. 3. Pass the user's rough notes alongside the transcript This is the actual Granola idea: your jottings steer what the summary emphasises. ### Done when - [ ] A transcript with three clear decisions and two assigned tasks yields notes naming all five - [ ] A 90-minute transcript summarizes without an API error - [ ] With no key present the step is skipped with a clear note in the output, not an error ## M4 · Notes on disk One folder per meeting, plain Markdown with frontmatter, never overwriting. ### Steps 1. Write NOTES_DIR/YYYY-MM-DD-HHMM-<slug>/ with audio.wav, transcript.txt and notes.md notes.md: YAML frontmatter (title, date, duration, participants, model), the summary, a divider, then the full transcript. 2. Derive the title from the summary and never overwrite If the folder exists, suffix a counter. Two meetings in the same minute must produce two folders. ### Done when - [ ] Two meetings started in the same minute produce two folders - [ ] notes.md opens correctly in a plain Markdown editor with no app running - [ ] The audio file is kept next to the notes ## M5 · Menu bar app Start and stop from the menu bar, jot notes while recording, see recent meetings, and get told exactly what to do when a permission is missing. ### Steps 1. Build the MenuBarExtra with Start/Stop and elapsed time A recording indicator that is unmistakable. Wire the capture code from Phase 1. 2. Add the live jot-notes field Text typed during the meeting is passed to the summarizer in Phase 3. 3. List recent meetings with Reveal in Finder Read NOTES_DIR; newest first. 4. Handle permissions explicitly If Screen Recording or Microphone is denied, show which System Settings pane to open and the relaunch caveat, instead of failing silently. ### Done when - [ ] A full meeting runs start to notes without touching a terminal - [ ] First launch on a machine that has never granted permission explains itself - [ ] The jotted notes visibly influence the summary ## M6 · Offline, packaging, README Works with the network off when the model is present, signed for your own machine, documented. ### Steps 1. Test with Wi-Fi off and no LLM key Transcript saved; summary skipped with an honest note in notes.md. 2. Sign locally and document the Gatekeeper first-run step Sign with your development certificate for your own use. Right-click > Open the first time. 3. Write the README Permissions and where to grant them, model download size, disk cost per hour of audio, and a plain warning that recording a meeting can require consent from everyone in it depending on where they are. Files: `README.md` ### Done when - [ ] Airplane mode produces a complete transcript and a note saying the summary was skipped - [ ] A fresh Mac goes from clone to first recording using only the README ## M7 · Distribute it (production only) Only for the product path: an app other people can install without terrifying Gatekeeper dialogs, that updates itself, and that tells you when it crashes. ### Steps 1. Join the Apple Developer Program and sign with a Developer ID Required to notarize. Xcode > Signing & Capabilities with the Developer ID Application certificate. ```sh xcrun notarytool submit MeetingNotes.zip --keychain-profile notary --wait xcrun stapler staple MeetingNotes.app ``` 2. Add Sparkle for updates Host an appcast XML on your domain; sign updates with the EdDSA key Sparkle generates. 3. Add opt-in crash reporting A local crash log the user can send you, or Sentry's macOS SDK behind an explicit opt-in toggle. Never on by default for a privacy-first app. ### Done when - [ ] A notarized build opens on a second Mac with no right-click workaround - [ ] Publishing a new appcast entry makes the installed app offer the update - [ ] A deliberate crash produces a report only when the toggle is on
# Operations · Granola ## Backup NOTES_DIR is plain files: point Time Machine, iCloud Drive or Syncthing at it. The app holds no other state. ## Restore Copy the folder back. The recent-meetings list is derived from the folder, so there is nothing else to restore. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring For a distributed app: opt-in crash reports and an update-check success rate from the appcast host's logs. ## Incident checklist If an API key is leaked, rotate it at the provider and ship a release that clears the stored key. If a transcription bug corrupts notes, the audio.wav next to each note allows regeneration. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A notarized build installs and records on a Mac that has never had the developer tools - [ ] Airplane-mode test passes - [ ] The consent warning is in the README and the first-run screen - [ ] One update delivered through Sparkle to a real install ## Launch constraint Do not market omitted Granola capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Where meeting folders are written. Any folder you sync or back up. NOTES_DIR=~/MeetingNotes # Required. Path to the binary you built. WHISPER_BIN=/Users/you/whisper.cpp/build/bin/whisper-cli # Required. Path to the model you downloaded. WHISPER_MODEL=/Users/you/whisper.cpp/models/ggml-medium.en.bin # Optional · secret. Anthropic console. Leave empty to skip summaries. ANTHROPIC_API_KEY=sk-ant-... # Optional · secret. OpenAI dashboard. Used for summaries if no Anthropic key, and for hosted Whisper if enabled. OPENAI_API_KEY=sk-... # Required. Model name for summaries. Anything the provider offers. LLM_MODEL=claude-sonnet-5 # Optional · secret. console.groq.com. Enables the hosted transcription fast path. GROQ_API_KEY=gsk_... # Required. local or hosted. Local is the default and works offline. TRANSCRIBE_MODE=local
$ choose a build depth, inspect the files, then open the complete pack in your agent
They pay because it is always on, nicely integrated with calendar/work calls, and trustworthy enough not to lose meeting history.
xsync across devices
xthe mobile app
xautomatic calendar joins
xsomeone else's servers & support
Don't feel like building it? These folks already made it free.
all 5 free alternatives to Granola →· no votes, no pay-to-list · just what's real
Granola pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| basic | $0/user | $0/user | Unlimited meetings; only the most recent 30 days of meeting history remain accessible. |
| business | $14/user | — | Unlimited meetings and unlimited meeting-history access. |
| enterprise | $35/user | — | Starts at $35/user/month; unlimited meetings/history plus SSO and admin controls. |
free tierUnlimited meetings, but only the latest 30 days of meeting history are accessible.
billingmonthly only, no annual plan; subscriptions are per workspace and seats are prorated
hidden costsThe same person can be billed separately in multiple paid workspaces. Active seats are prorated when added or removed; pending invitations are not billed. No per-minute, storage or meeting overage fees are published.
verified 2026-08-11 · source ↗
Is Granola free?
The free Basic plan includes AI notes but only limited meeting history. Paid is Business at $14/mo (checked 2026-08-07).
Vibecode Granola
Yes. A competent AI coding agent (Claude Code, Codex, Cursor) can build a usable personal Granola replacement in one session with the prompt on this page. It runs on your own machine or server with no subscription.
How much does Granola cost?
Granola costs about $14/month (Business, checked 2026-08-07), which is $168 per year. That's what you save by replacing it with one prompt.
What do I lose by replacing Granola?
Honestly: sync across devices; the mobile app; automatic calendar joins; someone else's servers & support. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Granola?
Yes: Meetily (A local meeting recorder that writes the transcript and summary without inviting a bot or your legal department.) Anarlog (Granola rearranged, literally: local recording, local transcript and your own model.) OpenWhispr (A cross-platform local notepad that records the call, separates speakers and keeps searchable notes.) All 5 curated free alternatives are at vibecodeit.com/granola/alternatives. The prompt is for when you want it exactly your way.