Vibecode Fellow
track this build4 phases, 12 steps, beginner friendly0%The visible meeting management loop is buildable, but a credible replacement needs more than the first screen. Fellow earns its keep through capture, integrations, reliability, so expect a weekend or multi-day build and a narrower personal scope.
You are building a lean indie version of Fellow. 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 ===== # Fellow · indie build A local meeting notes pipeline whose hard problems are chunking long audio without losing words at the seams, extracting decisions and actions that are anchored to what was actually said, and being honest about speaker labels it cannot reliably produce. 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 | | --- | --- | --- | | Runtime | Python 3.12 with FastAPI, one process on your own machine | the recordings are other people's voices and they stay here | | Audio | FFmpeg to sixteen kilohertz mono, checked against the model's current requirement | the model expects one sample rate and gives nonsense for another | | Transcription | A local speech model through whisper.cpp, no audio leaving the machine | the moment audio goes to an API the consent question changes | | Storage | SQLite with immutable transcript segments | the transcript is the evidence and the notes are a view of it | | Extraction | Anchored extraction only, with a local model or an optional key | an unanchored action item is a rumour with a tick box | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the pipeline and the local interface. - Get it: python.org, or your package manager. - Verify: python3 --version prints 3.12 or higher - [ ] **FFmpeg** · free - Why: Every container and codec a colleague sends you arrives differently, and the conversion to the model's documented input format is the step that decides whether the transcript is right. - Get it: Your package manager, or ffmpeg.org. - Verify: ffmpeg -version prints a version and converts a sample to the target format - [ ] **A local speech model and its documented input format** · free, a download of a few hundred megabytes upward - Why: The model file is the transcription quality, and its documented sample rate and channel count are what the conversion step has to hit exactly. - Get it: Download a model for your chosen local runtime and read its current input requirements. - Verify: A one minute sample transcribes and the documented format is written into your configuration - [ ] **A long recording you are entitled to use** · free - Why: Chunking and seam handling only fail on long files, and a three minute sample will pass every test while a ninety minute one finds the bug. - Get it: A recording of your own, or a public domain long-form recording. - Verify: It is over an hour and contains speech across every chunk boundary - [ ] **Your consent and retention policy** · free - Why: Both are decisions about other people's voices, and deciding them after the first awkward request means deciding them badly. - Get it: Write down what you confirm at import, how long audio is kept, and what deletion removes. - Verify: All three appear in the configuration and the README ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - A meeting bot that joins calls. - Speaker attribution presented as fact. - Calendar and CRM integrations. - Cross-team analytics over other people's meetings. - live multi-speaker accuracy - calendar and CRM integrations - cross-call team analytics - meeting-bot auto-join If one of those is essential to you, that is the reason to keep paying for Fellow, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Fellow 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 private meeting notes pipeline that turns a recording into decisions and actions you can check to replace Fellow. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules about other people's voices. This tool transcribes a recording you already have. It does not join meetings, it does not record anything itself, and it never presents itself as a silent participant. Recording law varies and some of it has teeth: California Penal Code section 632 makes it an offence to record a confidential communication without the consent of all parties, punishable by a fine of up to two thousand five hundred dollars per violation and up to a year in county jail, and the statute also makes the recording inadmissible. So the import screen asks you to confirm consent was obtained, stores that confirmation with the recording, and the README says plainly that the tool cannot give you legal advice about your own jurisdiction. Second, never invent a decision. Every decision and every action item carries a timestamp and the verbatim words it came from, and anything the model produces that cannot be anchored to the transcript is dropped rather than shown. A confidently wrong action item assigned to a colleague is worse than an empty list, because somebody will act on it. ### Stack (fixed, do not substitute) - Runtime: Python 3.12 with FastAPI, one process on your own machine · the recordings are other people's voices and they stay here - Audio: FFmpeg to sixteen kilohertz mono, checked against the model's current requirement · the model expects one sample rate and gives nonsense for another - Transcription: A local speech model through whisper.cpp, no audio leaving the machine · the moment audio goes to an API the consent question changes - Storage: SQLite with immutable transcript segments · the transcript is the evidence and the notes are a view of it - Extraction: Anchored extraction only, with a local model or an optional key · an unanchored action item is a rumour with a tick box ### Data model (create this before Phase 1) - recordings(id, filename, sha256, duration_s, imported_at, consent_confirmed_at, consent_note, delete_after) - chunks(recording_id, ordinal, start_ms, end_ms, overlap_ms, transcribed_at, model_name) · the seams are recorded because that is where words go missing - segments(id, recording_id, start_ms, end_ms, text, confidence, speaker_label) · immutable, and speaker_label is usually null and honest about it - meetings(id, recording_id, title, held_at, attendees_json, agenda_md) · what you knew before the recording existed - items(id, meeting_id, kind, text, segment_id, start_ms, quote, owner, due_on, status) · kind is decision, action or question, and segment_id is not optional - searches · a full-text index over segments, rebuilt from segments and therefore disposable - Every item in the notes points at a segment. An extraction that cannot name the moment it came from does not become a row. ### Phase 1 · Audio in, normalised, with consent recorded Build: Accept common audio and video containers, extract the audio with FFmpeg and convert to the sample rate and channel count the transcription model currently documents. Ask for and store a consent confirmation with a free-text note before anything is transcribed. Trap: Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. Done when: An imported file is converted to the documented sample rate and channel count and the conversion is asserted by inspecting the intermediate file. Import is refused until the consent confirmation is given, tested by attempting to skip it. The original file is hashed and the hash stored, so a re-import is recognised rather than duplicated. A file FFmpeg cannot decode fails with the FFmpeg error shown rather than an empty transcript. Do not build yet: No transcription, notes, search or export. ### Phase 2 · Long audio without losing the seams Build: Split long audio into chunks with a deliberate overlap, prefer a split at a silence rather than at a fixed offset, transcribe each chunk with timestamps, then merge by removing the duplicated text in the overlap. Store segments immutably with their chunk of origin. Trap: Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. Done when: A recording split into chunks produces the same words at each seam as the same recording transcribed whole, compared on a test file with speech across every boundary. Overlap regions are deduplicated without dropping a word, asserted with a crafted file. Segment timestamps are continuous and monotonic across the whole recording. A chunk that fails leaves the rest of the transcript usable and marks the gap explicitly. Do not build yet: No notes, extraction, search or export yet. ### Phase 3 · Decisions and actions that point at the tape Build: Extract decisions, action items and open questions from the transcript. Each one must cite a segment identifier and a quote that appears verbatim in that segment. Verify the quote by string match before storing, and discard anything that fails the check. Trap: Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. Done when: Every stored item has a segment identifier and a quote that is found verbatim in that segment, asserted for the whole table in the suite. An extraction whose quote does not match is discarded and counted, and the count is visible. Clicking an item jumps to that moment in the transcript and the audio. Speaker attribution is either omitted or clearly marked as a guess, and no owner is assigned to an action item unless a name was spoken. Do not build yet: No search, export or retention yet. ### Phase 4 · Finding the meeting where somebody said it Build: Index segments for full-text search, rebuildable from the segments table. Return results as a meeting, a timestamp and surrounding context, with the audio seekable to that point. Filter by date, attendee and item kind. Trap: Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. Done when: A phrase spoken once in one meeting is found and the audio seeks to within a few seconds of it. The index rebuilds from segments alone with the index file deleted, exercised in the suite. Results show surrounding context rather than an isolated match. Filtering by date range and item kind narrows results correctly, tested on a seeded set of meetings. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Notes that leave, audio that expires Build: Export a meeting as Markdown with agenda, decisions, actions and timestamps, plus the full transcript as a separate file. Apply a retention period that deletes audio while keeping the transcript, and offer a per-meeting deletion that removes audio, transcript and items together. Keep everything on localhost and state it. Trap: Keeping every recording forever because storage is cheap turns a notes tool into an archive of colleagues' voices that nobody agreed to, and the first time somebody asks you to delete a meeting you discover the audio is also in three exports and a cache. Set a default retention for audio from the first release, keep transcripts longer if you like, and make deletion remove every copy including derived files. Done when: Exported Markdown contains every item with its timestamp and quote, and re-importing that file is not required for it to be readable. Audio is deleted at the retention boundary while transcripts and items survive, tested with a clock shift. Deleting a meeting removes audio, chunks, segments, items and any cached derivative, verified by searching the data directory afterwards. The service binds to localhost and the README states that no audio leaves the machine in the default configuration. Do not build yet: Nothing after this. ### Out of scope (and why) - Joining meetings as a bot, which is the feature that makes Fellow a product and also the feature that walks straight into consent law. - Speaker diarisation presented as fact, since reliable multi-speaker attribution on real conference audio is a research problem and a wrong label attributes a decision to the wrong person. - Calendar and CRM integrations, each of which is an authentication project with its own review process. - Cross-team meeting analytics, which needs other people's meetings and a conversation about surveillance you should have before writing code. ### README must contain - That the tool transcribes recordings you already have and never joins or records a meeting itself - That recording law varies, that all-party consent applies in some jurisdictions, and that the tool records your confirmation but cannot advise you - That every decision and action carries a timestamp and a verbatim quote, and unanchored extractions are discarded - The audio sample rate the model requires and how the conversion is asserted - The retention period for audio, what deletion removes, and that nothing leaves the machine by default ===== AGENTS.md ===== # Agent instructions · Fellow indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Python 3.12 with FastAPI, one process on your own machine, FFmpeg to sixteen kilohertz mono, checked against the model's current requirement, A local speech model through whisper.cpp, no audio leaving the machine, SQLite with immutable transcript segments, Anchored extraction only, with a local model or an optional key. 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 - Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. - Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. - Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. - Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. ===== BUILD_PLAN.md ===== # Build plan · Fellow A local meeting notes pipeline whose hard problems are chunking long audio without losing words at the seams, extracting decisions and actions that are anchored to what was actually said, and being honest about speaker labels it cannot reliably produce. 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 in, normalised, with consent recorded A file is accepted, converted correctly, and stored with a record of why you were allowed to have it. ### Steps 1. Convert explicitly and assert the result The model's documented input format is a requirement, not a suggestion. Files: `src/audio.py`, `tests/test_convert.py` 2. Record consent before anything else A confirmation stored with the recording, and a README that does not pretend to be a lawyer. Files: `src/ingest.py`, `src/templates/import.html` 3. Hash the original Re-importing the same meeting twice is the most common user error in this category. Files: `src/ingest.py` ### Done when - [ ] Converted audio matches the documented input format - [ ] Import is refused without a consent confirmation - [ ] A duplicate import is recognised by hash - [ ] A bad file shows the decoder error ### Watch out - Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. ## Phase 2 · Long audio without losing the seams A ninety minute recording transcribes in chunks that overlap and rejoin without dropping words. ### Steps 1. Overlap every chunk and split at silence The seam is where the words go, so put the seam somewhere quiet. Files: `src/chunk.py`, `tests/test_seams.py` 2. Merge by matching text, not by offset Clocks drift across chunk boundaries and text does not. Files: `src/merge.py` 3. Mark failed chunks as gaps A gap you can see beats a sentence that was never there. Files: `src/transcribe.py` ### Done when - [ ] Seam words match a whole-file transcript - [ ] Overlaps deduplicate without losing words - [ ] Timestamps are continuous and monotonic - [ ] A failed chunk shows as an explicit gap ### Watch out - Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. ## Phase 3 · Decisions and actions that point at the tape Every extracted item carries a timestamp and the verbatim words it came from. ### Steps 1. Require a quote and verify it by string match The check is trivial and it is the difference between notes and fiction. Files: `src/extract.py`, `tests/test_anchoring.py` 2. Discard unanchored items and count them A visible discard count tells you when the extraction is going wrong. Files: `src/extract.py` 3. Link every item to its moment An item you can hear is an item somebody can disagree with. Files: `src/templates/meeting.html` ### Done when - [ ] Every item has a segment id and a verified quote - [ ] Unanchored extractions are discarded and counted - [ ] Items link to the transcript and the audio - [ ] Owners are only assigned when a name was spoken ### Watch out - Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. ## Phase 4 · Finding the meeting where somebody said it Search spans every meeting and lands on the moment, not the file. ### Steps 1. Index segments, not summaries The unsummarised half is the half you will search for. Files: `src/search.py` 2. Make the index rebuildable Then it is disposable, and a schema change stops being frightening. Files: `src/search.py`, `tests/test_reindex.py` 3. Seek the audio from a result Reading the words is half of it; hearing the tone is the other half. Files: `src/templates/search.html` ### Done when - [ ] A one-off phrase is found and the audio seeks to it - [ ] The index rebuilds from segments alone - [ ] Results carry surrounding context - [ ] Date and kind filters narrow correctly ### Watch out - Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. ## Not in this build - A meeting bot that joins calls. - Speaker attribution presented as fact. - Calendar and CRM integrations. - Cross-team analytics over other people's meetings. ## After v1, if you want it - A template per meeting type, which shapes the extraction without loosening the anchoring rule - Action items pushed to your own task tool, once the anchoring has been trusted for a month - A weekly digest of open actions across meetings, built entirely from anchored items ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Recordings, segments, items. The transcript is the evidence. DATABASE_PATH=./data/meetings.db # Required. Converted audio. Subject to the retention period below. AUDIO_DIR=./data/audio # Required. The local speech model. Its documented input format drives the conversion. MODEL_PATH=./models/ggml-medium.bin # Required. From the model's documentation, not from memory. Asserted in a test. TARGET_SAMPLE_RATE=16000 # Required. Chunk length before overlap. Long enough to keep context, short enough to fail cheaply. CHUNK_SECONDS=600 # Required. The seam insurance. Deduplicated by text match, not by clock. CHUNK_OVERLAP_SECONDS=10 # Required. Audio expires; transcripts and items do not. Stated in the README. AUDIO_RETENTION_DAYS=60 # Optional · secret. Optional. Without it a local model does the extraction and the tool still works. EXTRACTION_API_KEY=sk-...redacted
You are building a lean indie version of Fellow. 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 ===== # Fellow · indie build A local meeting notes pipeline whose hard problems are chunking long audio without losing words at the seams, extracting decisions and actions that are anchored to what was actually said, and being honest about speaker labels it cannot reliably produce. 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 | | --- | --- | --- | | Runtime | Python 3.12 with FastAPI, one process on your own machine | the recordings are other people's voices and they stay here | | Audio | FFmpeg to sixteen kilohertz mono, checked against the model's current requirement | the model expects one sample rate and gives nonsense for another | | Transcription | A local speech model through whisper.cpp, no audio leaving the machine | the moment audio goes to an API the consent question changes | | Storage | SQLite with immutable transcript segments | the transcript is the evidence and the notes are a view of it | | Extraction | Anchored extraction only, with a local model or an optional key | an unanchored action item is a rumour with a tick box | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the pipeline and the local interface. - Get it: python.org, or your package manager. - Verify: python3 --version prints 3.12 or higher - [ ] **FFmpeg** · free - Why: Every container and codec a colleague sends you arrives differently, and the conversion to the model's documented input format is the step that decides whether the transcript is right. - Get it: Your package manager, or ffmpeg.org. - Verify: ffmpeg -version prints a version and converts a sample to the target format - [ ] **A local speech model and its documented input format** · free, a download of a few hundred megabytes upward - Why: The model file is the transcription quality, and its documented sample rate and channel count are what the conversion step has to hit exactly. - Get it: Download a model for your chosen local runtime and read its current input requirements. - Verify: A one minute sample transcribes and the documented format is written into your configuration - [ ] **A long recording you are entitled to use** · free - Why: Chunking and seam handling only fail on long files, and a three minute sample will pass every test while a ninety minute one finds the bug. - Get it: A recording of your own, or a public domain long-form recording. - Verify: It is over an hour and contains speech across every chunk boundary - [ ] **Your consent and retention policy** · free - Why: Both are decisions about other people's voices, and deciding them after the first awkward request means deciding them badly. - Get it: Write down what you confirm at import, how long audio is kept, and what deletion removes. - Verify: All three appear in the configuration and the README ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - A meeting bot that joins calls. - Speaker attribution presented as fact. - Calendar and CRM integrations. - Cross-team analytics over other people's meetings. - live multi-speaker accuracy - calendar and CRM integrations - cross-call team analytics - meeting-bot auto-join If one of those is essential to you, that is the reason to keep paying for Fellow, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Fellow 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 private meeting notes pipeline that turns a recording into decisions and actions you can check to replace Fellow. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules about other people's voices. This tool transcribes a recording you already have. It does not join meetings, it does not record anything itself, and it never presents itself as a silent participant. Recording law varies and some of it has teeth: California Penal Code section 632 makes it an offence to record a confidential communication without the consent of all parties, punishable by a fine of up to two thousand five hundred dollars per violation and up to a year in county jail, and the statute also makes the recording inadmissible. So the import screen asks you to confirm consent was obtained, stores that confirmation with the recording, and the README says plainly that the tool cannot give you legal advice about your own jurisdiction. Second, never invent a decision. Every decision and every action item carries a timestamp and the verbatim words it came from, and anything the model produces that cannot be anchored to the transcript is dropped rather than shown. A confidently wrong action item assigned to a colleague is worse than an empty list, because somebody will act on it. ### Stack (fixed, do not substitute) - Runtime: Python 3.12 with FastAPI, one process on your own machine · the recordings are other people's voices and they stay here - Audio: FFmpeg to sixteen kilohertz mono, checked against the model's current requirement · the model expects one sample rate and gives nonsense for another - Transcription: A local speech model through whisper.cpp, no audio leaving the machine · the moment audio goes to an API the consent question changes - Storage: SQLite with immutable transcript segments · the transcript is the evidence and the notes are a view of it - Extraction: Anchored extraction only, with a local model or an optional key · an unanchored action item is a rumour with a tick box ### Data model (create this before Phase 1) - recordings(id, filename, sha256, duration_s, imported_at, consent_confirmed_at, consent_note, delete_after) - chunks(recording_id, ordinal, start_ms, end_ms, overlap_ms, transcribed_at, model_name) · the seams are recorded because that is where words go missing - segments(id, recording_id, start_ms, end_ms, text, confidence, speaker_label) · immutable, and speaker_label is usually null and honest about it - meetings(id, recording_id, title, held_at, attendees_json, agenda_md) · what you knew before the recording existed - items(id, meeting_id, kind, text, segment_id, start_ms, quote, owner, due_on, status) · kind is decision, action or question, and segment_id is not optional - searches · a full-text index over segments, rebuilt from segments and therefore disposable - Every item in the notes points at a segment. An extraction that cannot name the moment it came from does not become a row. ### Phase 1 · Audio in, normalised, with consent recorded Build: Accept common audio and video containers, extract the audio with FFmpeg and convert to the sample rate and channel count the transcription model currently documents. Ask for and store a consent confirmation with a free-text note before anything is transcribed. Trap: Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. Done when: An imported file is converted to the documented sample rate and channel count and the conversion is asserted by inspecting the intermediate file. Import is refused until the consent confirmation is given, tested by attempting to skip it. The original file is hashed and the hash stored, so a re-import is recognised rather than duplicated. A file FFmpeg cannot decode fails with the FFmpeg error shown rather than an empty transcript. Do not build yet: No transcription, notes, search or export. ### Phase 2 · Long audio without losing the seams Build: Split long audio into chunks with a deliberate overlap, prefer a split at a silence rather than at a fixed offset, transcribe each chunk with timestamps, then merge by removing the duplicated text in the overlap. Store segments immutably with their chunk of origin. Trap: Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. Done when: A recording split into chunks produces the same words at each seam as the same recording transcribed whole, compared on a test file with speech across every boundary. Overlap regions are deduplicated without dropping a word, asserted with a crafted file. Segment timestamps are continuous and monotonic across the whole recording. A chunk that fails leaves the rest of the transcript usable and marks the gap explicitly. Do not build yet: No notes, extraction, search or export yet. ### Phase 3 · Decisions and actions that point at the tape Build: Extract decisions, action items and open questions from the transcript. Each one must cite a segment identifier and a quote that appears verbatim in that segment. Verify the quote by string match before storing, and discard anything that fails the check. Trap: Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. Done when: Every stored item has a segment identifier and a quote that is found verbatim in that segment, asserted for the whole table in the suite. An extraction whose quote does not match is discarded and counted, and the count is visible. Clicking an item jumps to that moment in the transcript and the audio. Speaker attribution is either omitted or clearly marked as a guess, and no owner is assigned to an action item unless a name was spoken. Do not build yet: No search, export or retention yet. ### Phase 4 · Finding the meeting where somebody said it Build: Index segments for full-text search, rebuildable from the segments table. Return results as a meeting, a timestamp and surrounding context, with the audio seekable to that point. Filter by date, attendee and item kind. Trap: Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. Done when: A phrase spoken once in one meeting is found and the audio seeks to within a few seconds of it. The index rebuilds from segments alone with the index file deleted, exercised in the suite. Results show surrounding context rather than an isolated match. Filtering by date range and item kind narrows results correctly, tested on a seeded set of meetings. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Notes that leave, audio that expires Build: Export a meeting as Markdown with agenda, decisions, actions and timestamps, plus the full transcript as a separate file. Apply a retention period that deletes audio while keeping the transcript, and offer a per-meeting deletion that removes audio, transcript and items together. Keep everything on localhost and state it. Trap: Keeping every recording forever because storage is cheap turns a notes tool into an archive of colleagues' voices that nobody agreed to, and the first time somebody asks you to delete a meeting you discover the audio is also in three exports and a cache. Set a default retention for audio from the first release, keep transcripts longer if you like, and make deletion remove every copy including derived files. Done when: Exported Markdown contains every item with its timestamp and quote, and re-importing that file is not required for it to be readable. Audio is deleted at the retention boundary while transcripts and items survive, tested with a clock shift. Deleting a meeting removes audio, chunks, segments, items and any cached derivative, verified by searching the data directory afterwards. The service binds to localhost and the README states that no audio leaves the machine in the default configuration. Do not build yet: Nothing after this. ### Out of scope (and why) - Joining meetings as a bot, which is the feature that makes Fellow a product and also the feature that walks straight into consent law. - Speaker diarisation presented as fact, since reliable multi-speaker attribution on real conference audio is a research problem and a wrong label attributes a decision to the wrong person. - Calendar and CRM integrations, each of which is an authentication project with its own review process. - Cross-team meeting analytics, which needs other people's meetings and a conversation about surveillance you should have before writing code. ### README must contain - That the tool transcribes recordings you already have and never joins or records a meeting itself - That recording law varies, that all-party consent applies in some jurisdictions, and that the tool records your confirmation but cannot advise you - That every decision and action carries a timestamp and a verbatim quote, and unanchored extractions are discarded - The audio sample rate the model requires and how the conversion is asserted - The retention period for audio, what deletion removes, and that nothing leaves the machine by default ===== AGENTS.md ===== # Agent instructions · Fellow indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Python 3.12 with FastAPI, one process on your own machine, FFmpeg to sixteen kilohertz mono, checked against the model's current requirement, A local speech model through whisper.cpp, no audio leaving the machine, SQLite with immutable transcript segments, Anchored extraction only, with a local model or an optional key. 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 - Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. - Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. - Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. - Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. ===== BUILD_PLAN.md ===== # Build plan · Fellow A local meeting notes pipeline whose hard problems are chunking long audio without losing words at the seams, extracting decisions and actions that are anchored to what was actually said, and being honest about speaker labels it cannot reliably produce. 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 in, normalised, with consent recorded A file is accepted, converted correctly, and stored with a record of why you were allowed to have it. ### Steps 1. Convert explicitly and assert the result The model's documented input format is a requirement, not a suggestion. Files: `src/audio.py`, `tests/test_convert.py` 2. Record consent before anything else A confirmation stored with the recording, and a README that does not pretend to be a lawyer. Files: `src/ingest.py`, `src/templates/import.html` 3. Hash the original Re-importing the same meeting twice is the most common user error in this category. Files: `src/ingest.py` ### Done when - [ ] Converted audio matches the documented input format - [ ] Import is refused without a consent confirmation - [ ] A duplicate import is recognised by hash - [ ] A bad file shows the decoder error ### Watch out - Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. ## Phase 2 · Long audio without losing the seams A ninety minute recording transcribes in chunks that overlap and rejoin without dropping words. ### Steps 1. Overlap every chunk and split at silence The seam is where the words go, so put the seam somewhere quiet. Files: `src/chunk.py`, `tests/test_seams.py` 2. Merge by matching text, not by offset Clocks drift across chunk boundaries and text does not. Files: `src/merge.py` 3. Mark failed chunks as gaps A gap you can see beats a sentence that was never there. Files: `src/transcribe.py` ### Done when - [ ] Seam words match a whole-file transcript - [ ] Overlaps deduplicate without losing words - [ ] Timestamps are continuous and monotonic - [ ] A failed chunk shows as an explicit gap ### Watch out - Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. ## Phase 3 · Decisions and actions that point at the tape Every extracted item carries a timestamp and the verbatim words it came from. ### Steps 1. Require a quote and verify it by string match The check is trivial and it is the difference between notes and fiction. Files: `src/extract.py`, `tests/test_anchoring.py` 2. Discard unanchored items and count them A visible discard count tells you when the extraction is going wrong. Files: `src/extract.py` 3. Link every item to its moment An item you can hear is an item somebody can disagree with. Files: `src/templates/meeting.html` ### Done when - [ ] Every item has a segment id and a verified quote - [ ] Unanchored extractions are discarded and counted - [ ] Items link to the transcript and the audio - [ ] Owners are only assigned when a name was spoken ### Watch out - Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. ## Phase 4 · Finding the meeting where somebody said it Search spans every meeting and lands on the moment, not the file. ### Steps 1. Index segments, not summaries The unsummarised half is the half you will search for. Files: `src/search.py` 2. Make the index rebuildable Then it is disposable, and a schema change stops being frightening. Files: `src/search.py`, `tests/test_reindex.py` 3. Seek the audio from a result Reading the words is half of it; hearing the tone is the other half. Files: `src/templates/search.html` ### Done when - [ ] A one-off phrase is found and the audio seeks to it - [ ] The index rebuilds from segments alone - [ ] Results carry surrounding context - [ ] Date and kind filters narrow correctly ### Watch out - Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. ## Not in this build - A meeting bot that joins calls. - Speaker attribution presented as fact. - Calendar and CRM integrations. - Cross-team analytics over other people's meetings. ## After v1, if you want it - A template per meeting type, which shapes the extraction without loosening the anchoring rule - Action items pushed to your own task tool, once the anchoring has been trusted for a month - A weekly digest of open actions across meetings, built entirely from anchored items ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. Recordings, segments, items. The transcript is the evidence. DATABASE_PATH=./data/meetings.db # Required. Converted audio. Subject to the retention period below. AUDIO_DIR=./data/audio # Required. The local speech model. Its documented input format drives the conversion. MODEL_PATH=./models/ggml-medium.bin # Required. From the model's documentation, not from memory. Asserted in a test. TARGET_SAMPLE_RATE=16000 # Required. Chunk length before overlap. Long enough to keep context, short enough to fail cheaply. CHUNK_SECONDS=600 # Required. The seam insurance. Deduplicated by text match, not by clock. CHUNK_OVERLAP_SECONDS=10 # Required. Audio expires; transcripts and items do not. Stated in the README. AUDIO_RETENTION_DAYS=60 # Optional · secret. Optional. Without it a local model does the extraction and the tool still works. EXTRACTION_API_KEY=sk-...redacted
You are building a production product version of Fellow. 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 ===== # Fellow · product brief ## Problem The visible meeting management loop is buildable, but a credible replacement needs more than the first screen. Fellow earns its keep through capture, integrations, reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A notes tool where the transcript is right because the audio was converted properly, the seams are covered, every decision points at the moment somebody said it, and the recordings do not quietly become an archive. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Python 3.12 - FFmpeg - local Whisper model or optional API key in .env ## Explicit non-goals for v1 - A meeting bot that joins calls. - Speaker attribution presented as fact. - Calendar and CRM integrations. - Cross-team analytics over other people's meetings. - live multi-speaker accuracy - calendar and CRM integrations - cross-call team analytics - meeting-bot auto-join ## Success criteria - Converted audio is confirmed to match the documented input format - Seam words are confirmed identical to a whole-file transcript - Every item is confirmed to carry a verified verbatim quote - The search index is confirmed rebuildable from segments alone - Deletion is confirmed to leave nothing in the data directory ===== BRIEF.md ===== # Build brief · Fellow 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 private meeting notes pipeline that turns a recording into decisions and actions you can check to replace Fellow. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules about other people's voices. This tool transcribes a recording you already have. It does not join meetings, it does not record anything itself, and it never presents itself as a silent participant. Recording law varies and some of it has teeth: California Penal Code section 632 makes it an offence to record a confidential communication without the consent of all parties, punishable by a fine of up to two thousand five hundred dollars per violation and up to a year in county jail, and the statute also makes the recording inadmissible. So the import screen asks you to confirm consent was obtained, stores that confirmation with the recording, and the README says plainly that the tool cannot give you legal advice about your own jurisdiction. Second, never invent a decision. Every decision and every action item carries a timestamp and the verbatim words it came from, and anything the model produces that cannot be anchored to the transcript is dropped rather than shown. A confidently wrong action item assigned to a colleague is worse than an empty list, because somebody will act on it. ### Stack (fixed, do not substitute) - Runtime: Python 3.12 with FastAPI, one process on your own machine · the recordings are other people's voices and they stay here - Audio: FFmpeg to sixteen kilohertz mono, checked against the model's current requirement · the model expects one sample rate and gives nonsense for another - Transcription: A local speech model through whisper.cpp, no audio leaving the machine · the moment audio goes to an API the consent question changes - Storage: SQLite with immutable transcript segments · the transcript is the evidence and the notes are a view of it - Extraction: Anchored extraction only, with a local model or an optional key · an unanchored action item is a rumour with a tick box ### Data model (create this before Phase 1) - recordings(id, filename, sha256, duration_s, imported_at, consent_confirmed_at, consent_note, delete_after) - chunks(recording_id, ordinal, start_ms, end_ms, overlap_ms, transcribed_at, model_name) · the seams are recorded because that is where words go missing - segments(id, recording_id, start_ms, end_ms, text, confidence, speaker_label) · immutable, and speaker_label is usually null and honest about it - meetings(id, recording_id, title, held_at, attendees_json, agenda_md) · what you knew before the recording existed - items(id, meeting_id, kind, text, segment_id, start_ms, quote, owner, due_on, status) · kind is decision, action or question, and segment_id is not optional - searches · a full-text index over segments, rebuilt from segments and therefore disposable - Every item in the notes points at a segment. An extraction that cannot name the moment it came from does not become a row. ### Phase 1 · Audio in, normalised, with consent recorded Build: Accept common audio and video containers, extract the audio with FFmpeg and convert to the sample rate and channel count the transcription model currently documents. Ask for and store a consent confirmation with a free-text note before anything is transcribed. Trap: Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. Done when: An imported file is converted to the documented sample rate and channel count and the conversion is asserted by inspecting the intermediate file. Import is refused until the consent confirmation is given, tested by attempting to skip it. The original file is hashed and the hash stored, so a re-import is recognised rather than duplicated. A file FFmpeg cannot decode fails with the FFmpeg error shown rather than an empty transcript. Do not build yet: No transcription, notes, search or export. ### Phase 2 · Long audio without losing the seams Build: Split long audio into chunks with a deliberate overlap, prefer a split at a silence rather than at a fixed offset, transcribe each chunk with timestamps, then merge by removing the duplicated text in the overlap. Store segments immutably with their chunk of origin. Trap: Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. Done when: A recording split into chunks produces the same words at each seam as the same recording transcribed whole, compared on a test file with speech across every boundary. Overlap regions are deduplicated without dropping a word, asserted with a crafted file. Segment timestamps are continuous and monotonic across the whole recording. A chunk that fails leaves the rest of the transcript usable and marks the gap explicitly. Do not build yet: No notes, extraction, search or export yet. ### Phase 3 · Decisions and actions that point at the tape Build: Extract decisions, action items and open questions from the transcript. Each one must cite a segment identifier and a quote that appears verbatim in that segment. Verify the quote by string match before storing, and discard anything that fails the check. Trap: Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. Done when: Every stored item has a segment identifier and a quote that is found verbatim in that segment, asserted for the whole table in the suite. An extraction whose quote does not match is discarded and counted, and the count is visible. Clicking an item jumps to that moment in the transcript and the audio. Speaker attribution is either omitted or clearly marked as a guess, and no owner is assigned to an action item unless a name was spoken. Do not build yet: No search, export or retention yet. ### Phase 4 · Finding the meeting where somebody said it Build: Index segments for full-text search, rebuildable from the segments table. Return results as a meeting, a timestamp and surrounding context, with the audio seekable to that point. Filter by date, attendee and item kind. Trap: Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. Done when: A phrase spoken once in one meeting is found and the audio seeks to within a few seconds of it. The index rebuilds from segments alone with the index file deleted, exercised in the suite. Results show surrounding context rather than an isolated match. Filtering by date range and item kind narrows results correctly, tested on a seeded set of meetings. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Notes that leave, audio that expires Build: Export a meeting as Markdown with agenda, decisions, actions and timestamps, plus the full transcript as a separate file. Apply a retention period that deletes audio while keeping the transcript, and offer a per-meeting deletion that removes audio, transcript and items together. Keep everything on localhost and state it. Trap: Keeping every recording forever because storage is cheap turns a notes tool into an archive of colleagues' voices that nobody agreed to, and the first time somebody asks you to delete a meeting you discover the audio is also in three exports and a cache. Set a default retention for audio from the first release, keep transcripts longer if you like, and make deletion remove every copy including derived files. Done when: Exported Markdown contains every item with its timestamp and quote, and re-importing that file is not required for it to be readable. Audio is deleted at the retention boundary while transcripts and items survive, tested with a clock shift. Deleting a meeting removes audio, chunks, segments, items and any cached derivative, verified by searching the data directory afterwards. The service binds to localhost and the README states that no audio leaves the machine in the default configuration. Do not build yet: Nothing after this. ### Out of scope (and why) - Joining meetings as a bot, which is the feature that makes Fellow a product and also the feature that walks straight into consent law. - Speaker diarisation presented as fact, since reliable multi-speaker attribution on real conference audio is a research problem and a wrong label attributes a decision to the wrong person. - Calendar and CRM integrations, each of which is an authentication project with its own review process. - Cross-team meeting analytics, which needs other people's meetings and a conversation about surveillance you should have before writing code. ### README must contain - That the tool transcribes recordings you already have and never joins or records a meeting itself - That recording law varies, that all-party consent applies in some jurisdictions, and that the tool records your confirmation but cannot advise you - That every decision and action carries a timestamp and a verbatim quote, and unanchored extractions are discarded - The audio sample rate the model requires and how the conversion is asserted - The retention period for audio, what deletion removes, and that nothing leaves the machine by default ===== ARCHITECTURE.md ===== # Architecture · Fellow ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Python 3.12 with FastAPI, one process on your own machine | the recordings are other people's voices and they stay here | | Audio | FFmpeg to sixteen kilohertz mono, checked against the model's current requirement | the model expects one sample rate and gives nonsense for another | | Transcription | A local speech model through whisper.cpp, no audio leaving the machine | the moment audio goes to an API the consent question changes | | Storage | SQLite with immutable transcript segments | the transcript is the evidence and the notes are a view of it | | Extraction | Anchored extraction only, with a local model or an optional key | an unanchored action item is a rumour with a tick box | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ingest | Conversion, hashing and the consent record. | Any converter, provided the output format is asserted. | | Chunking | Overlapped splits and text-matched merges. | Any splitter, provided seam words are tested against a whole-file transcript. | | Extraction | Anchored items only. | Any model, local or hosted, provided the quote check runs before anything is stored. | | Search | A rebuildable index over segments. | Any index, provided it is built from segments and can be deleted. | | Retention | Audio expiry and complete deletion. | Any schedule, provided deletion covers derived files. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Recordings, segments, items. The transcript is the evidence. - `AUDIO_DIR` · required · Converted audio. Subject to the retention period below. - `MODEL_PATH` · required · The local speech model. Its documented input format drives the conversion. - `TARGET_SAMPLE_RATE` · required · From the model's documentation, not from memory. Asserted in a test. - `CHUNK_SECONDS` · required · Chunk length before overlap. Long enough to keep context, short enough to fail cheaply. - `CHUNK_OVERLAP_SECONDS` · required · The seam insurance. Deduplicated by text match, not by clock. - `AUDIO_RETENTION_DAYS` · required · Audio expires; transcripts and items do not. Stated in the README. - `EXTRACTION_API_KEY` · optional, secret · Optional. Without it a local model does the extraction and the tool still works. ## 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 · Fellow product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Python 3.12 with FastAPI, one process on your own machine, FFmpeg to sixteen kilohertz mono, checked against the model's current requirement, A local speech model through whisper.cpp, no audio leaving the machine, SQLite with immutable transcript segments, Anchored extraction only, with a local model or an optional key. - 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 - Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. - Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. - Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. - Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. - Keeping every recording forever because storage is cheap turns a notes tool into an archive of colleagues' voices that nobody agreed to, and the first time somebody asks you to delete a meeting you discover the audio is also in three exports and a cache. Set a default retention for audio from the first release, keep transcripts longer if you like, and make deletion remove every copy including derived files. ===== MILESTONES.md ===== # Delivery milestones · Fellow Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Audio in, normalised, with consent recorded A file is accepted, converted correctly, and stored with a record of why you were allowed to have it. ### Steps 1. Convert explicitly and assert the result The model's documented input format is a requirement, not a suggestion. Files: `src/audio.py`, `tests/test_convert.py` 2. Record consent before anything else A confirmation stored with the recording, and a README that does not pretend to be a lawyer. Files: `src/ingest.py`, `src/templates/import.html` 3. Hash the original Re-importing the same meeting twice is the most common user error in this category. Files: `src/ingest.py` ### Done when - [ ] Converted audio matches the documented input format - [ ] Import is refused without a consent confirmation - [ ] A duplicate import is recognised by hash - [ ] A bad file shows the decoder error ### Watch out - Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. ## M2 · Long audio without losing the seams A ninety minute recording transcribes in chunks that overlap and rejoin without dropping words. ### Steps 1. Overlap every chunk and split at silence The seam is where the words go, so put the seam somewhere quiet. Files: `src/chunk.py`, `tests/test_seams.py` 2. Merge by matching text, not by offset Clocks drift across chunk boundaries and text does not. Files: `src/merge.py` 3. Mark failed chunks as gaps A gap you can see beats a sentence that was never there. Files: `src/transcribe.py` ### Done when - [ ] Seam words match a whole-file transcript - [ ] Overlaps deduplicate without losing words - [ ] Timestamps are continuous and monotonic - [ ] A failed chunk shows as an explicit gap ### Watch out - Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. ## M3 · Decisions and actions that point at the tape Every extracted item carries a timestamp and the verbatim words it came from. ### Steps 1. Require a quote and verify it by string match The check is trivial and it is the difference between notes and fiction. Files: `src/extract.py`, `tests/test_anchoring.py` 2. Discard unanchored items and count them A visible discard count tells you when the extraction is going wrong. Files: `src/extract.py` 3. Link every item to its moment An item you can hear is an item somebody can disagree with. Files: `src/templates/meeting.html` ### Done when - [ ] Every item has a segment id and a verified quote - [ ] Unanchored extractions are discarded and counted - [ ] Items link to the transcript and the audio - [ ] Owners are only assigned when a name was spoken ### Watch out - Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. ## M4 · Finding the meeting where somebody said it Search spans every meeting and lands on the moment, not the file. ### Steps 1. Index segments, not summaries The unsummarised half is the half you will search for. Files: `src/search.py` 2. Make the index rebuildable Then it is disposable, and a schema change stops being frightening. Files: `src/search.py`, `tests/test_reindex.py` 3. Seek the audio from a result Reading the words is half of it; hearing the tone is the other half. Files: `src/templates/search.html` ### Done when - [ ] A one-off phrase is found and the audio seeks to it - [ ] The index rebuilds from segments alone - [ ] Results carry surrounding context - [ ] Date and kind filters narrow correctly ### Watch out - Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. ## M5 · Notes that leave, audio that expires (production only) Meetings export as Markdown with their anchors, and recordings do not accumulate forever. ### Steps 1. Export Markdown with anchors intact A note without its timestamp is an assertion. Files: `src/export.py` 2. Expire audio, keep transcripts The voices are the sensitive part; the words are the useful part. Files: `src/retention.py`, `tests/test_retention.py` 3. Delete every copy, then check the directory Derived files are how a deletion becomes a half-deletion. Files: `src/delete.py` ### Done when - [ ] Exports carry timestamps and quotes - [ ] Audio expires while transcripts remain - [ ] Deletion leaves nothing in the data directory - [ ] The service binds to localhost by default ### Watch out - Keeping every recording forever because storage is cheap turns a notes tool into an archive of colleagues' voices that nobody agreed to, and the first time somebody asks you to delete a meeting you discover the audio is also in three exports and a cache. Set a default retention for audio from the first release, keep transcripts longer if you like, and make deletion remove every copy including derived files. ===== OPERATIONS.md ===== # Operations · Fellow ## Backup The database and the transcripts. Audio is deliberately the part you do not hoard, so a backup that quietly preserves expired recordings defeats the retention policy you wrote. ## Restore Restore, rebuild the search index from segments, and confirm that items still resolve to their segments. An item whose segment is missing is a note with no evidence and should be surfaced, not hidden. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Discarded extraction count per meeting, failed chunks, and audio older than the retention period. A rising discard count means the extraction prompt or model has drifted and the notes are getting worse before anyone notices. ## Incident checklist A wrong action item is the incident that matters, and the response is the quote: open the segment, listen, and either correct the item or delete it. A transcript that reads oddly throughout is almost always the sample rate. 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 - [ ] Converted audio is confirmed to match the documented input format - [ ] Seam words are confirmed identical to a whole-file transcript - [ ] Every item is confirmed to carry a verified verbatim quote - [ ] The search index is confirmed rebuildable from segments alone - [ ] Deletion is confirmed to leave nothing in the data directory ## Launch constraint Do not market omitted Fellow 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. Recordings, segments, items. The transcript is the evidence. DATABASE_PATH=./data/meetings.db # Required. Converted audio. Subject to the retention period below. AUDIO_DIR=./data/audio # Required. The local speech model. Its documented input format drives the conversion. MODEL_PATH=./models/ggml-medium.bin # Required. From the model's documentation, not from memory. Asserted in a test. TARGET_SAMPLE_RATE=16000 # Required. Chunk length before overlap. Long enough to keep context, short enough to fail cheaply. CHUNK_SECONDS=600 # Required. The seam insurance. Deduplicated by text match, not by clock. CHUNK_OVERLAP_SECONDS=10 # Required. Audio expires; transcripts and items do not. Stated in the README. AUDIO_RETENTION_DAYS=60 # Optional · secret. Optional. Without it a local model does the extraction and the tool still works. EXTRACTION_API_KEY=sk-...redacted
# Fellow · indie build A local meeting notes pipeline whose hard problems are chunking long audio without losing words at the seams, extracting decisions and actions that are anchored to what was actually said, and being honest about speaker labels it cannot reliably produce. 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 | | --- | --- | --- | | Runtime | Python 3.12 with FastAPI, one process on your own machine | the recordings are other people's voices and they stay here | | Audio | FFmpeg to sixteen kilohertz mono, checked against the model's current requirement | the model expects one sample rate and gives nonsense for another | | Transcription | A local speech model through whisper.cpp, no audio leaving the machine | the moment audio goes to an API the consent question changes | | Storage | SQLite with immutable transcript segments | the transcript is the evidence and the notes are a view of it | | Extraction | Anchored extraction only, with a local model or an optional key | an unanchored action item is a rumour with a tick box | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Python 3.12 or newer** · free - Why: Runs the pipeline and the local interface. - Get it: python.org, or your package manager. - Verify: python3 --version prints 3.12 or higher - [ ] **FFmpeg** · free - Why: Every container and codec a colleague sends you arrives differently, and the conversion to the model's documented input format is the step that decides whether the transcript is right. - Get it: Your package manager, or ffmpeg.org. - Verify: ffmpeg -version prints a version and converts a sample to the target format - [ ] **A local speech model and its documented input format** · free, a download of a few hundred megabytes upward - Why: The model file is the transcription quality, and its documented sample rate and channel count are what the conversion step has to hit exactly. - Get it: Download a model for your chosen local runtime and read its current input requirements. - Verify: A one minute sample transcribes and the documented format is written into your configuration - [ ] **A long recording you are entitled to use** · free - Why: Chunking and seam handling only fail on long files, and a three minute sample will pass every test while a ninety minute one finds the bug. - Get it: A recording of your own, or a public domain long-form recording. - Verify: It is over an hour and contains speech across every chunk boundary - [ ] **Your consent and retention policy** · free - Why: Both are decisions about other people's voices, and deciding them after the first awkward request means deciding them badly. - Get it: Write down what you confirm at import, how long audio is kept, and what deletion removes. - Verify: All three appear in the configuration and the README ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - A meeting bot that joins calls. - Speaker attribution presented as fact. - Calendar and CRM integrations. - Cross-team analytics over other people's meetings. - live multi-speaker accuracy - calendar and CRM integrations - cross-call team analytics - meeting-bot auto-join If one of those is essential to you, that is the reason to keep paying for Fellow, and the README should say so rather than pretend.
# Build brief · Fellow 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 private meeting notes pipeline that turns a recording into decisions and actions you can check to replace Fellow. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules about other people's voices. This tool transcribes a recording you already have. It does not join meetings, it does not record anything itself, and it never presents itself as a silent participant. Recording law varies and some of it has teeth: California Penal Code section 632 makes it an offence to record a confidential communication without the consent of all parties, punishable by a fine of up to two thousand five hundred dollars per violation and up to a year in county jail, and the statute also makes the recording inadmissible. So the import screen asks you to confirm consent was obtained, stores that confirmation with the recording, and the README says plainly that the tool cannot give you legal advice about your own jurisdiction. Second, never invent a decision. Every decision and every action item carries a timestamp and the verbatim words it came from, and anything the model produces that cannot be anchored to the transcript is dropped rather than shown. A confidently wrong action item assigned to a colleague is worse than an empty list, because somebody will act on it. ### Stack (fixed, do not substitute) - Runtime: Python 3.12 with FastAPI, one process on your own machine · the recordings are other people's voices and they stay here - Audio: FFmpeg to sixteen kilohertz mono, checked against the model's current requirement · the model expects one sample rate and gives nonsense for another - Transcription: A local speech model through whisper.cpp, no audio leaving the machine · the moment audio goes to an API the consent question changes - Storage: SQLite with immutable transcript segments · the transcript is the evidence and the notes are a view of it - Extraction: Anchored extraction only, with a local model or an optional key · an unanchored action item is a rumour with a tick box ### Data model (create this before Phase 1) - recordings(id, filename, sha256, duration_s, imported_at, consent_confirmed_at, consent_note, delete_after) - chunks(recording_id, ordinal, start_ms, end_ms, overlap_ms, transcribed_at, model_name) · the seams are recorded because that is where words go missing - segments(id, recording_id, start_ms, end_ms, text, confidence, speaker_label) · immutable, and speaker_label is usually null and honest about it - meetings(id, recording_id, title, held_at, attendees_json, agenda_md) · what you knew before the recording existed - items(id, meeting_id, kind, text, segment_id, start_ms, quote, owner, due_on, status) · kind is decision, action or question, and segment_id is not optional - searches · a full-text index over segments, rebuilt from segments and therefore disposable - Every item in the notes points at a segment. An extraction that cannot name the moment it came from does not become a row. ### Phase 1 · Audio in, normalised, with consent recorded Build: Accept common audio and video containers, extract the audio with FFmpeg and convert to the sample rate and channel count the transcription model currently documents. Ask for and store a consent confirmation with a free-text note before anything is transcribed. Trap: Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. Done when: An imported file is converted to the documented sample rate and channel count and the conversion is asserted by inspecting the intermediate file. Import is refused until the consent confirmation is given, tested by attempting to skip it. The original file is hashed and the hash stored, so a re-import is recognised rather than duplicated. A file FFmpeg cannot decode fails with the FFmpeg error shown rather than an empty transcript. Do not build yet: No transcription, notes, search or export. ### Phase 2 · Long audio without losing the seams Build: Split long audio into chunks with a deliberate overlap, prefer a split at a silence rather than at a fixed offset, transcribe each chunk with timestamps, then merge by removing the duplicated text in the overlap. Store segments immutably with their chunk of origin. Trap: Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. Done when: A recording split into chunks produces the same words at each seam as the same recording transcribed whole, compared on a test file with speech across every boundary. Overlap regions are deduplicated without dropping a word, asserted with a crafted file. Segment timestamps are continuous and monotonic across the whole recording. A chunk that fails leaves the rest of the transcript usable and marks the gap explicitly. Do not build yet: No notes, extraction, search or export yet. ### Phase 3 · Decisions and actions that point at the tape Build: Extract decisions, action items and open questions from the transcript. Each one must cite a segment identifier and a quote that appears verbatim in that segment. Verify the quote by string match before storing, and discard anything that fails the check. Trap: Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. Done when: Every stored item has a segment identifier and a quote that is found verbatim in that segment, asserted for the whole table in the suite. An extraction whose quote does not match is discarded and counted, and the count is visible. Clicking an item jumps to that moment in the transcript and the audio. Speaker attribution is either omitted or clearly marked as a guess, and no owner is assigned to an action item unless a name was spoken. Do not build yet: No search, export or retention yet. ### Phase 4 · Finding the meeting where somebody said it Build: Index segments for full-text search, rebuildable from the segments table. Return results as a meeting, a timestamp and surrounding context, with the audio seekable to that point. Filter by date, attendee and item kind. Trap: Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. Done when: A phrase spoken once in one meeting is found and the audio seeks to within a few seconds of it. The index rebuilds from segments alone with the index file deleted, exercised in the suite. Results show surrounding context rather than an isolated match. Filtering by date range and item kind narrows results correctly, tested on a seeded set of meetings. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Notes that leave, audio that expires Build: Export a meeting as Markdown with agenda, decisions, actions and timestamps, plus the full transcript as a separate file. Apply a retention period that deletes audio while keeping the transcript, and offer a per-meeting deletion that removes audio, transcript and items together. Keep everything on localhost and state it. Trap: Keeping every recording forever because storage is cheap turns a notes tool into an archive of colleagues' voices that nobody agreed to, and the first time somebody asks you to delete a meeting you discover the audio is also in three exports and a cache. Set a default retention for audio from the first release, keep transcripts longer if you like, and make deletion remove every copy including derived files. Done when: Exported Markdown contains every item with its timestamp and quote, and re-importing that file is not required for it to be readable. Audio is deleted at the retention boundary while transcripts and items survive, tested with a clock shift. Deleting a meeting removes audio, chunks, segments, items and any cached derivative, verified by searching the data directory afterwards. The service binds to localhost and the README states that no audio leaves the machine in the default configuration. Do not build yet: Nothing after this. ### Out of scope (and why) - Joining meetings as a bot, which is the feature that makes Fellow a product and also the feature that walks straight into consent law. - Speaker diarisation presented as fact, since reliable multi-speaker attribution on real conference audio is a research problem and a wrong label attributes a decision to the wrong person. - Calendar and CRM integrations, each of which is an authentication project with its own review process. - Cross-team meeting analytics, which needs other people's meetings and a conversation about surveillance you should have before writing code. ### README must contain - That the tool transcribes recordings you already have and never joins or records a meeting itself - That recording law varies, that all-party consent applies in some jurisdictions, and that the tool records your confirmation but cannot advise you - That every decision and action carries a timestamp and a verbatim quote, and unanchored extractions are discarded - The audio sample rate the model requires and how the conversion is asserted - The retention period for audio, what deletion removes, and that nothing leaves the machine by default
# Agent instructions · Fellow indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Python 3.12 with FastAPI, one process on your own machine, FFmpeg to sixteen kilohertz mono, checked against the model's current requirement, A local speech model through whisper.cpp, no audio leaving the machine, SQLite with immutable transcript segments, Anchored extraction only, with a local model or an optional key. 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 - Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. - Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. - Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. - Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration.
# Build plan · Fellow A local meeting notes pipeline whose hard problems are chunking long audio without losing words at the seams, extracting decisions and actions that are anchored to what was actually said, and being honest about speaker labels it cannot reliably produce. 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 in, normalised, with consent recorded A file is accepted, converted correctly, and stored with a record of why you were allowed to have it. ### Steps 1. Convert explicitly and assert the result The model's documented input format is a requirement, not a suggestion. Files: `src/audio.py`, `tests/test_convert.py` 2. Record consent before anything else A confirmation stored with the recording, and a README that does not pretend to be a lawyer. Files: `src/ingest.py`, `src/templates/import.html` 3. Hash the original Re-importing the same meeting twice is the most common user error in this category. Files: `src/ingest.py` ### Done when - [ ] Converted audio matches the documented input format - [ ] Import is refused without a consent confirmation - [ ] A duplicate import is recognised by hash - [ ] A bad file shows the decoder error ### Watch out - Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. ## Phase 2 · Long audio without losing the seams A ninety minute recording transcribes in chunks that overlap and rejoin without dropping words. ### Steps 1. Overlap every chunk and split at silence The seam is where the words go, so put the seam somewhere quiet. Files: `src/chunk.py`, `tests/test_seams.py` 2. Merge by matching text, not by offset Clocks drift across chunk boundaries and text does not. Files: `src/merge.py` 3. Mark failed chunks as gaps A gap you can see beats a sentence that was never there. Files: `src/transcribe.py` ### Done when - [ ] Seam words match a whole-file transcript - [ ] Overlaps deduplicate without losing words - [ ] Timestamps are continuous and monotonic - [ ] A failed chunk shows as an explicit gap ### Watch out - Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. ## Phase 3 · Decisions and actions that point at the tape Every extracted item carries a timestamp and the verbatim words it came from. ### Steps 1. Require a quote and verify it by string match The check is trivial and it is the difference between notes and fiction. Files: `src/extract.py`, `tests/test_anchoring.py` 2. Discard unanchored items and count them A visible discard count tells you when the extraction is going wrong. Files: `src/extract.py` 3. Link every item to its moment An item you can hear is an item somebody can disagree with. Files: `src/templates/meeting.html` ### Done when - [ ] Every item has a segment id and a verified quote - [ ] Unanchored extractions are discarded and counted - [ ] Items link to the transcript and the audio - [ ] Owners are only assigned when a name was spoken ### Watch out - Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. ## Phase 4 · Finding the meeting where somebody said it Search spans every meeting and lands on the moment, not the file. ### Steps 1. Index segments, not summaries The unsummarised half is the half you will search for. Files: `src/search.py` 2. Make the index rebuildable Then it is disposable, and a schema change stops being frightening. Files: `src/search.py`, `tests/test_reindex.py` 3. Seek the audio from a result Reading the words is half of it; hearing the tone is the other half. Files: `src/templates/search.html` ### Done when - [ ] A one-off phrase is found and the audio seeks to it - [ ] The index rebuilds from segments alone - [ ] Results carry surrounding context - [ ] Date and kind filters narrow correctly ### Watch out - Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. ## Not in this build - A meeting bot that joins calls. - Speaker attribution presented as fact. - Calendar and CRM integrations. - Cross-team analytics over other people's meetings. ## After v1, if you want it - A template per meeting type, which shapes the extraction without loosening the anchoring rule - Action items pushed to your own task tool, once the anchoring has been trusted for a month - A weekly digest of open actions across meetings, built entirely from anchored items
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. Recordings, segments, items. The transcript is the evidence. DATABASE_PATH=./data/meetings.db # Required. Converted audio. Subject to the retention period below. AUDIO_DIR=./data/audio # Required. The local speech model. Its documented input format drives the conversion. MODEL_PATH=./models/ggml-medium.bin # Required. From the model's documentation, not from memory. Asserted in a test. TARGET_SAMPLE_RATE=16000 # Required. Chunk length before overlap. Long enough to keep context, short enough to fail cheaply. CHUNK_SECONDS=600 # Required. The seam insurance. Deduplicated by text match, not by clock. CHUNK_OVERLAP_SECONDS=10 # Required. Audio expires; transcripts and items do not. Stated in the README. AUDIO_RETENTION_DAYS=60 # Optional · secret. Optional. Without it a local model does the extraction and the tool still works. EXTRACTION_API_KEY=sk-...redacted
# Fellow · product brief ## Problem The visible meeting management loop is buildable, but a credible replacement needs more than the first screen. Fellow earns its keep through capture, integrations, reliability, so expect a weekend or multi-day build and a narrower personal scope. ## Product outcome A notes tool where the transcript is right because the audio was converted properly, the seams are covered, every decision points at the moment somebody said it, and the recordings do not quietly become an archive. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Python 3.12 - FFmpeg - local Whisper model or optional API key in .env ## Explicit non-goals for v1 - A meeting bot that joins calls. - Speaker attribution presented as fact. - Calendar and CRM integrations. - Cross-team analytics over other people's meetings. - live multi-speaker accuracy - calendar and CRM integrations - cross-call team analytics - meeting-bot auto-join ## Success criteria - Converted audio is confirmed to match the documented input format - Seam words are confirmed identical to a whole-file transcript - Every item is confirmed to carry a verified verbatim quote - The search index is confirmed rebuildable from segments alone - Deletion is confirmed to leave nothing in the data directory
# Build brief · Fellow 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 private meeting notes pipeline that turns a recording into decisions and actions you can check to replace Fellow. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Two rules about other people's voices. This tool transcribes a recording you already have. It does not join meetings, it does not record anything itself, and it never presents itself as a silent participant. Recording law varies and some of it has teeth: California Penal Code section 632 makes it an offence to record a confidential communication without the consent of all parties, punishable by a fine of up to two thousand five hundred dollars per violation and up to a year in county jail, and the statute also makes the recording inadmissible. So the import screen asks you to confirm consent was obtained, stores that confirmation with the recording, and the README says plainly that the tool cannot give you legal advice about your own jurisdiction. Second, never invent a decision. Every decision and every action item carries a timestamp and the verbatim words it came from, and anything the model produces that cannot be anchored to the transcript is dropped rather than shown. A confidently wrong action item assigned to a colleague is worse than an empty list, because somebody will act on it. ### Stack (fixed, do not substitute) - Runtime: Python 3.12 with FastAPI, one process on your own machine · the recordings are other people's voices and they stay here - Audio: FFmpeg to sixteen kilohertz mono, checked against the model's current requirement · the model expects one sample rate and gives nonsense for another - Transcription: A local speech model through whisper.cpp, no audio leaving the machine · the moment audio goes to an API the consent question changes - Storage: SQLite with immutable transcript segments · the transcript is the evidence and the notes are a view of it - Extraction: Anchored extraction only, with a local model or an optional key · an unanchored action item is a rumour with a tick box ### Data model (create this before Phase 1) - recordings(id, filename, sha256, duration_s, imported_at, consent_confirmed_at, consent_note, delete_after) - chunks(recording_id, ordinal, start_ms, end_ms, overlap_ms, transcribed_at, model_name) · the seams are recorded because that is where words go missing - segments(id, recording_id, start_ms, end_ms, text, confidence, speaker_label) · immutable, and speaker_label is usually null and honest about it - meetings(id, recording_id, title, held_at, attendees_json, agenda_md) · what you knew before the recording existed - items(id, meeting_id, kind, text, segment_id, start_ms, quote, owner, due_on, status) · kind is decision, action or question, and segment_id is not optional - searches · a full-text index over segments, rebuilt from segments and therefore disposable - Every item in the notes points at a segment. An extraction that cannot name the moment it came from does not become a row. ### Phase 1 · Audio in, normalised, with consent recorded Build: Accept common audio and video containers, extract the audio with FFmpeg and convert to the sample rate and channel count the transcription model currently documents. Ask for and store a consent confirmation with a free-text note before anything is transcribed. Trap: Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. Done when: An imported file is converted to the documented sample rate and channel count and the conversion is asserted by inspecting the intermediate file. Import is refused until the consent confirmation is given, tested by attempting to skip it. The original file is hashed and the hash stored, so a re-import is recognised rather than duplicated. A file FFmpeg cannot decode fails with the FFmpeg error shown rather than an empty transcript. Do not build yet: No transcription, notes, search or export. ### Phase 2 · Long audio without losing the seams Build: Split long audio into chunks with a deliberate overlap, prefer a split at a silence rather than at a fixed offset, transcribe each chunk with timestamps, then merge by removing the duplicated text in the overlap. Store segments immutably with their chunk of origin. Trap: Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. Done when: A recording split into chunks produces the same words at each seam as the same recording transcribed whole, compared on a test file with speech across every boundary. Overlap regions are deduplicated without dropping a word, asserted with a crafted file. Segment timestamps are continuous and monotonic across the whole recording. A chunk that fails leaves the rest of the transcript usable and marks the gap explicitly. Do not build yet: No notes, extraction, search or export yet. ### Phase 3 · Decisions and actions that point at the tape Build: Extract decisions, action items and open questions from the transcript. Each one must cite a segment identifier and a quote that appears verbatim in that segment. Verify the quote by string match before storing, and discard anything that fails the check. Trap: Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. Done when: Every stored item has a segment identifier and a quote that is found verbatim in that segment, asserted for the whole table in the suite. An extraction whose quote does not match is discarded and counted, and the count is visible. Clicking an item jumps to that moment in the transcript and the audio. Speaker attribution is either omitted or clearly marked as a guess, and no owner is assigned to an action item unless a name was spoken. Do not build yet: No search, export or retention yet. ### Phase 4 · Finding the meeting where somebody said it Build: Index segments for full-text search, rebuildable from the segments table. Return results as a meeting, a timestamp and surrounding context, with the audio seekable to that point. Filter by date, attendee and item kind. Trap: Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. Done when: A phrase spoken once in one meeting is found and the audio seeks to within a few seconds of it. The index rebuilds from segments alone with the index file deleted, exercised in the suite. Results show surrounding context rather than an isolated match. Filtering by date range and item kind narrows results correctly, tested on a seeded set of meetings. Do not build yet: No export, retention or deletion yet. ### Phase 5 · Notes that leave, audio that expires Build: Export a meeting as Markdown with agenda, decisions, actions and timestamps, plus the full transcript as a separate file. Apply a retention period that deletes audio while keeping the transcript, and offer a per-meeting deletion that removes audio, transcript and items together. Keep everything on localhost and state it. Trap: Keeping every recording forever because storage is cheap turns a notes tool into an archive of colleagues' voices that nobody agreed to, and the first time somebody asks you to delete a meeting you discover the audio is also in three exports and a cache. Set a default retention for audio from the first release, keep transcripts longer if you like, and make deletion remove every copy including derived files. Done when: Exported Markdown contains every item with its timestamp and quote, and re-importing that file is not required for it to be readable. Audio is deleted at the retention boundary while transcripts and items survive, tested with a clock shift. Deleting a meeting removes audio, chunks, segments, items and any cached derivative, verified by searching the data directory afterwards. The service binds to localhost and the README states that no audio leaves the machine in the default configuration. Do not build yet: Nothing after this. ### Out of scope (and why) - Joining meetings as a bot, which is the feature that makes Fellow a product and also the feature that walks straight into consent law. - Speaker diarisation presented as fact, since reliable multi-speaker attribution on real conference audio is a research problem and a wrong label attributes a decision to the wrong person. - Calendar and CRM integrations, each of which is an authentication project with its own review process. - Cross-team meeting analytics, which needs other people's meetings and a conversation about surveillance you should have before writing code. ### README must contain - That the tool transcribes recordings you already have and never joins or records a meeting itself - That recording law varies, that all-party consent applies in some jurisdictions, and that the tool records your confirmation but cannot advise you - That every decision and action carries a timestamp and a verbatim quote, and unanchored extractions are discarded - The audio sample rate the model requires and how the conversion is asserted - The retention period for audio, what deletion removes, and that nothing leaves the machine by default
# Architecture · Fellow ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Python 3.12 with FastAPI, one process on your own machine | the recordings are other people's voices and they stay here | | Audio | FFmpeg to sixteen kilohertz mono, checked against the model's current requirement | the model expects one sample rate and gives nonsense for another | | Transcription | A local speech model through whisper.cpp, no audio leaving the machine | the moment audio goes to an API the consent question changes | | Storage | SQLite with immutable transcript segments | the transcript is the evidence and the notes are a view of it | | Extraction | Anchored extraction only, with a local model or an optional key | an unanchored action item is a rumour with a tick box | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Ingest | Conversion, hashing and the consent record. | Any converter, provided the output format is asserted. | | Chunking | Overlapped splits and text-matched merges. | Any splitter, provided seam words are tested against a whole-file transcript. | | Extraction | Anchored items only. | Any model, local or hosted, provided the quote check runs before anything is stored. | | Search | A rebuildable index over segments. | Any index, provided it is built from segments and can be deleted. | | Retention | Audio expiry and complete deletion. | Any schedule, provided deletion covers derived files. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_PATH` · required · Recordings, segments, items. The transcript is the evidence. - `AUDIO_DIR` · required · Converted audio. Subject to the retention period below. - `MODEL_PATH` · required · The local speech model. Its documented input format drives the conversion. - `TARGET_SAMPLE_RATE` · required · From the model's documentation, not from memory. Asserted in a test. - `CHUNK_SECONDS` · required · Chunk length before overlap. Long enough to keep context, short enough to fail cheaply. - `CHUNK_OVERLAP_SECONDS` · required · The seam insurance. Deduplicated by text match, not by clock. - `AUDIO_RETENTION_DAYS` · required · Audio expires; transcripts and items do not. Stated in the README. - `EXTRACTION_API_KEY` · optional, secret · Optional. Without it a local model does the extraction and the tool still works. ## 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 · Fellow product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Python 3.12 with FastAPI, one process on your own machine, FFmpeg to sixteen kilohertz mono, checked against the model's current requirement, A local speech model through whisper.cpp, no audio leaving the machine, SQLite with immutable transcript segments, Anchored extraction only, with a local model or an optional key. - 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 - Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. - Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. - Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. - Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. - Keeping every recording forever because storage is cheap turns a notes tool into an archive of colleagues' voices that nobody agreed to, and the first time somebody asks you to delete a meeting you discover the audio is also in three exports and a cache. Set a default retention for audio from the first release, keep transcripts longer if you like, and make deletion remove every copy including derived files.
# Delivery milestones · Fellow Estimated effort: **weekend** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Audio in, normalised, with consent recorded A file is accepted, converted correctly, and stored with a record of why you were allowed to have it. ### Steps 1. Convert explicitly and assert the result The model's documented input format is a requirement, not a suggestion. Files: `src/audio.py`, `tests/test_convert.py` 2. Record consent before anything else A confirmation stored with the recording, and a README that does not pretend to be a lawyer. Files: `src/ingest.py`, `src/templates/import.html` 3. Hash the original Re-importing the same meeting twice is the most common user error in this category. Files: `src/ingest.py` ### Done when - [ ] Converted audio matches the documented input format - [ ] Import is refused without a consent confirmation - [ ] A duplicate import is recognised by hash - [ ] A bad file shows the decoder error ### Watch out - Feeding a forty four kilohertz stereo file straight to a model that documents sixteen kilohertz mono produces a transcript that looks plausible and is quietly wrong, which is the worst failure mode available here because nobody notices. Convert explicitly with FFmpeg, assert the output format in a test, and re-read the model's current requirement rather than trusting this sentence. ## M2 · Long audio without losing the seams A ninety minute recording transcribes in chunks that overlap and rejoin without dropping words. ### Steps 1. Overlap every chunk and split at silence The seam is where the words go, so put the seam somewhere quiet. Files: `src/chunk.py`, `tests/test_seams.py` 2. Merge by matching text, not by offset Clocks drift across chunk boundaries and text does not. Files: `src/merge.py` 3. Mark failed chunks as gaps A gap you can see beats a sentence that was never there. Files: `src/transcribe.py` ### Done when - [ ] Seam words match a whole-file transcript - [ ] Overlaps deduplicate without losing words - [ ] Timestamps are continuous and monotonic - [ ] A failed chunk shows as an explicit gap ### Watch out - Cutting a long recording at exact intervals slices a word in half at every boundary, and the two halves come back as two wrong words that read perfectly well, so the error is invisible in the output and shows up months later in a quoted decision. Overlap the chunks, split at silence where you can find one, and reconcile the overlap by matching text rather than by trusting the clock. ## M3 · Decisions and actions that point at the tape Every extracted item carries a timestamp and the verbatim words it came from. ### Steps 1. Require a quote and verify it by string match The check is trivial and it is the difference between notes and fiction. Files: `src/extract.py`, `tests/test_anchoring.py` 2. Discard unanchored items and count them A visible discard count tells you when the extraction is going wrong. Files: `src/extract.py` 3. Link every item to its moment An item you can hear is an item somebody can disagree with. Files: `src/templates/meeting.html` ### Done when - [ ] Every item has a segment id and a verified quote - [ ] Unanchored extractions are discarded and counted - [ ] Items link to the transcript and the audio - [ ] Owners are only assigned when a name was spoken ### Watch out - Asking a model for action items and storing whatever comes back produces a tidy list containing one item nobody said, assigned to somebody who was not there, and it looks exactly like the four correct ones beside it. Require a verbatim quote and a segment identifier for every item, check the quote actually occurs in the segment, and drop the ones that do not rather than showing them with a warning. ## M4 · Finding the meeting where somebody said it Search spans every meeting and lands on the moment, not the file. ### Steps 1. Index segments, not summaries The unsummarised half is the half you will search for. Files: `src/search.py` 2. Make the index rebuildable Then it is disposable, and a schema change stops being frightening. Files: `src/search.py`, `tests/test_reindex.py` 3. Seek the audio from a result Reading the words is half of it; hearing the tone is the other half. Files: `src/templates/search.html` ### Done when - [ ] A one-off phrase is found and the audio seeks to it - [ ] The index rebuilds from segments alone - [ ] Results carry surrounding context - [ ] Date and kind filters narrow correctly ### Watch out - Indexing the generated notes instead of the transcript makes the half of every meeting that nobody summarised unsearchable, which is precisely the half you go looking for six months later. Index the segments, treat the notes as a view, and make the index rebuildable so a schema change is a reindex rather than a migration. ## M5 · Notes that leave, audio that expires (production only) Meetings export as Markdown with their anchors, and recordings do not accumulate forever. ### Steps 1. Export Markdown with anchors intact A note without its timestamp is an assertion. Files: `src/export.py` 2. Expire audio, keep transcripts The voices are the sensitive part; the words are the useful part. Files: `src/retention.py`, `tests/test_retention.py` 3. Delete every copy, then check the directory Derived files are how a deletion becomes a half-deletion. Files: `src/delete.py` ### Done when - [ ] Exports carry timestamps and quotes - [ ] Audio expires while transcripts remain - [ ] Deletion leaves nothing in the data directory - [ ] The service binds to localhost by default ### Watch out - Keeping every recording forever because storage is cheap turns a notes tool into an archive of colleagues' voices that nobody agreed to, and the first time somebody asks you to delete a meeting you discover the audio is also in three exports and a cache. Set a default retention for audio from the first release, keep transcripts longer if you like, and make deletion remove every copy including derived files.
# Operations · Fellow ## Backup The database and the transcripts. Audio is deliberately the part you do not hoard, so a backup that quietly preserves expired recordings defeats the retention policy you wrote. ## Restore Restore, rebuild the search index from segments, and confirm that items still resolve to their segments. An item whose segment is missing is a note with no evidence and should be surfaced, not hidden. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Discarded extraction count per meeting, failed chunks, and audio older than the retention period. A rising discard count means the extraction prompt or model has drifted and the notes are getting worse before anyone notices. ## Incident checklist A wrong action item is the incident that matters, and the response is the quote: open the segment, listen, and either correct the item or delete it. A transcript that reads oddly throughout is almost always the sample rate. 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 - [ ] Converted audio is confirmed to match the documented input format - [ ] Seam words are confirmed identical to a whole-file transcript - [ ] Every item is confirmed to carry a verified verbatim quote - [ ] The search index is confirmed rebuildable from segments alone - [ ] Deletion is confirmed to leave nothing in the data directory ## Launch constraint Do not market omitted Fellow 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. Recordings, segments, items. The transcript is the evidence. DATABASE_PATH=./data/meetings.db # Required. Converted audio. Subject to the retention period below. AUDIO_DIR=./data/audio # Required. The local speech model. Its documented input format drives the conversion. MODEL_PATH=./models/ggml-medium.bin # Required. From the model's documentation, not from memory. Asserted in a test. TARGET_SAMPLE_RATE=16000 # Required. Chunk length before overlap. Long enough to keep context, short enough to fail cheaply. CHUNK_SECONDS=600 # Required. The seam insurance. Deduplicated by text match, not by clock. CHUNK_OVERLAP_SECONDS=10 # Required. Audio expires; transcripts and items do not. Stated in the README. AUDIO_RETENTION_DAYS=60 # Optional · secret. Optional. Without it a local model does the extraction and the tool still works. EXTRACTION_API_KEY=sk-...redacted
$ choose a build depth, inspect the files, then open the complete pack in your agent
Fellow: Customers pay for automatic capture, dependable speaker handling, search across calls, and notes arriving without manual file wrangling.
xlive multi-speaker accuracy
xcalendar and CRM integrations
xcross-call team analytics
xmeeting-bot auto-join
Fellow pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | Maximum 5 seats; 5 AI notes lifetime per user and 5 AI recordings lifetime per user. |
| team | $11/user | $7/user | Maximum 20 seats; 10 AI notes per user/month and 10 AI recordings per user. |
| business | $23/user | $15/user | Maximum 50 seats; unlimited AI notes and recordings. |
| enterprise | — | $25/user | Starts at 10 users; unlimited seats, AI notes, and recordings. |
free tierUp to 5 seats; each user gets 5 AI notes lifetime and 5 AI recordings lifetime.
billingmonthly + annual; Enterprise starts at 10 users and publishes annual pricing only
hidden costsEnterprise has a 10-seat minimum, and the lower plans have hard workspace caps of 20 and 50 seats.
verified 2026-08-14 · source ↗
Vibecode Fellow
Kinda. The core of Fellow is buildable in a weekend with the prompt on this page, but there are real gaps: live multi-speaker accuracy, calendar and CRM integrations. Read the honest list above before committing.
How much does Fellow cost?
Fellow's pricing is usage-based or varies by plan · The official pricing URL is recorded, but a stable current amount was not safely recoverable in this pass. Do not publish a number until rechecked..
What do I lose by replacing Fellow?
Honestly: live multi-speaker accuracy; calendar and CRM integrations; cross-call team analytics; meeting-bot auto-join. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Fellow?
Yes: whisper.cpp (Local speech-to-text engine suitable for private transcription.). Using prior art is also vibecoding; the prompt is for when you want it exactly your way.