HN-010 · Local-First Meeting Note Taker
Honey Notes
ShippedDesign + Build20263 DAYS · 553 TESTS
Outcome
The meeting note taker built for the Halix team — macOS and Windows, with every recording staying on the machine.







Demo data — the meeting and everyone in it are fictional; the transcripts shown were generated from synthesized speech. No real client call appears here.
Honey Notes is a local-first meeting note taker, built for Halix Solutions' own use — because paying a monthly subscription for something buildable overnight felt wrong, and because a wanted feature should ship the same day, not sit on someone else's roadmap. No bot joins the call. It records both halves of the conversation itself — the mic on one track, the other side arriving through the speakers on another — runs whisper.cpp on-device, and summarizes only when asked. One design decision carries the whole app: keep the two sides separate, and speaker attribution comes free.
Three days, because the alternative was a subscription
Halix runs on client calls, and a call that isn't captured is a set of decisions that exist only in memory. The market answer is a monthly fee for a bot that joins your meetings. I built the alternative instead: 26–28 July, 25 commits. Day two ended with a feature-complete v1 — record, pause, transcribe, play back, summarize. Day three added the Windows port and Google Calendar. What keeps three days from reading as a warning is the test suite: 37 files, nearly a line of test for every line of source.
- Feature-complete v1 on day two — record, pause, transcribe, play back, summarize
- Day three: the Windows port and Google Calendar
- Internal tool for Halix Solutions — nothing for sale
Two tracks captured separately — speaker attribution comes free
Honey Notes never joins a call as a bot. It sits on the machine and captures the meeting the way the machine already hears it: the microphone as one track, the system audio — the other side's voice coming out of the speakers — as a second. The two are never mixed, and that does the work a diarization model normally does: every segment already knows who said it, because it knows which file it came from. On stop, each WAV runs through whisper.cpp locally and the two segment lists merge by timestamp into one interleaved conversation.
- Mic and system audio captured separately, never mixed — no diarization model anywhere
- Two segment lists merged by timestamp into one interleaved conversation
- Names snapshot onto the meeting row before the first sample — a rename never rewrites an old transcript
The same recording, captured backwards on each platform
System audio is the hard half, and each OS demanded the opposite answer. On macOS, a Core Audio tap captures it in the main process — Chromium's loopback path returns pure silence there (electron#49607). On Windows, WASAPI loopback through Chromium's getDisplayMedia works, but only in the renderer. So the two platforms run reverse architectures: main captures on one, the window captures on the other. One function in the main process picks the strategy and tells the renderer which side it owns. Both paths append to the same WAV; pause, transcription, and the merge never know the difference.
- macOS: Core Audio tap in the main process; Windows: WASAPI loopback in the renderer
- One function in main decides the strategy — the renderer is told, never guesses
- Both paths write the same WAV, so everything downstream is shared
Privacy by architecture, not policy
Audio never leaves the machine: transcription is whisper.cpp running on-device. The transcript crosses the network exactly once — the only call that ever carries meeting data, to MiniMax, and only on an explicit Summarize press, behind a one-time consent gate enforced in the main process so a renderer bug can't skip it. Google Calendar is read-only, and every Google call lives in main, which is why the renderer's CSP names no Google origin. If a change ever needed one added, that's proof a call leaked into the window — the CSP is a tripwire, not configuration.
- Meeting data crosses the network exactly once — MiniMax, on an explicit Summarize press
- Consent gate enforced in the main process — a renderer bug can't skip it
- Renderer CSP names no Google origin — a tripwire, not config
A summary that invents a person is refused
Summaries come back in five fixed sections — TL;DR, Decisions, Action Items, Open Questions, Timeline — and an empty section says “None”, so a missing heading always means a malformed response. Then the check that matters: every action item must be owned by one of the two speakers in that meeting. An owner outside that set means the model invented a third person — the hallucination that actually does damage in a meeting summary, because a fabricated name reads exactly as plausibly as a real one. That response is refused, not stored.
- Every action item must be owned by one of the meeting's two speakers
- An owner outside that set means a hallucinated person — refused, not stored
- Credits
- Solo — built as an internal tool for Halix Solutions
- AI
- Designed the system and directed Claude Code through the entire build — reviewing every diff and making every architecture and product call myself.