Build an automatic meeting notes pipeline on a Mac
Automatic meeting notes workflow on a Mac: capture the audio, transcribe to JSON with timestamps, summarize to Markdown, and file a note with front matter.
A meeting notes pipeline is four small programs in a row: capture produces an audio file, transcribe turns it into text with timestamps, summarize turns that into structured Markdown, and file writes the Markdown into your notes folder with front matter and a link back to the audio. This guide assembles the four stages on a Mac, gives you the script that runs them, and adds a watcher so a new recording becomes a note by itself.
Keep the four stages separate
The important decision is made before you write any code: each stage reads a file from disk and writes a file to disk, and nothing lives only in memory.
The temptation is one script that does everything in a single pass. It works on the happy path and is miserable the first time the last step fails: your notes service returns a 401, the script exits, and forty minutes of transcription goes with it. Four stages on disk are resumable (a failure at stage 4 leaves stages 1 to 3 in place), debuggable (when a summary is wrong, open the transcript and see which of the two was at fault) and swappable (stage 2 can be local today and a hosted API tomorrow, because the interface is a JSON file rather than a function signature).
The interfaces:
| Stage | Reads | Writes |
|---|---|---|
| 1. Capture | the meeting | <name>.wav, 16 kHz mono |
| 2. Transcribe | the WAV | <name>.json with segments and timestamps |
| 3. Summarize | the JSON | <name>.summary.md |
| 4. File | the summary | a note in your vault, with front matter |
Stage 1: capture
Getting a meeting into an audio file is its own problem, because macOS does not record what comes out of the speakers without help; the three working routes are in record Zoom and Teams meetings on Mac with system audio. Tell the other participants you are recording.
For the pipeline, what matters is where the file lands and what it is called. Pick one folder and one convention, then never deviate:
mkdir -p ~/Meetings/inbox ~/Meetings/work ~/Meetings/notes
Name recordings YYYY-MM-DD-slug.ext, for example 2026-08-30-pricing-review.m4a. The date sorts, the slug becomes the note title, and a watcher derives the rest from the file name. Put nothing else in inbox.
Then normalize, so every later stage sees the same shape whatever recorded it. Local speech models generally want 16-bit PCM WAV at 16 kHz, one channel:
ffmpeg -i ~/Meetings/inbox/2026-08-30-pricing-review.m4a \
-ar 16000 -ac 1 -c:a pcm_s16le \
~/Meetings/work/2026-08-30-pricing-review.wav
Mono is not a compromise: speech models are trained on it, and a two-channel call recording is usually the same mix twice.
Stage 2: transcribe
Put both backends behind one shell function so the rest of the pipeline never knows which ran. The local one uses whisper.cpp, set up in run Whisper locally on a Mac, or Parakeet, set up in run Parakeet locally on Apple Silicon. A third option is to point the same function at a transcription server you run yourself, which is worth it once several machines share one model; self-host a transcription server covers that build. The flags are -oj for a JSON file and -of for the output base name, both in the tool's own help output:
transcribe_local() {
whisper-cli -m "$MODEL" -f "$1" -l auto -oj -of "${1%.wav}"
}
That writes <name>.json: a top-level object with a transcription array whose elements carry text, a timestamps object holding from and to as HH:MM:SS,mmm strings, and an offsets object with the same boundaries in milliseconds.
The hosted backend posts the file to a speech API: POST https://api.openai.com/v1/audio/transcriptions, a multipart form with file and model fields and a bearer token, per that provider's speech-to-text guide:
transcribe_api() {
curl -s --request POST \
--url https://api.openai.com/v1/audio/transcriptions \
--header "Authorization: Bearer $OPENAI_API_KEY" \
--header 'Content-Type: multipart/form-data' \
--form "file=@$1" \
--form model=whisper-1 \
--form response_format=verbose_json > "${1%.wav}.json"
}
The two write different JSON shapes, so stage 3 needs one jq expression per backend; that is the price of swappability. Hotkey-driven use of a cloud speech API is in set up a cloud dictation hotkey on a Mac, the billing arithmetic in what cloud transcription APIs actually cost.
Keep the timestamps even though the summary will not use them. They cost nothing and they are the only route back to the audio: when someone disputes what was agreed, a timestamped quote means opening the recording at 41:07 instead of re-listening to an hour.
If more than two people were present, add speaker labels; speaker diarization on a Mac, locally shows how. A summarizer that sees who said what attributes decisions to real names instead of "it was decided".
Stage 3: summarize
The setup, installing a runtime, pulling a model, raising the context window and chunking transcripts that do not fit, is in summarize transcripts locally on a Mac. What this stage adds is the prompt, because that is where nearly all of the quality lives.
Save this as ~/Meetings/prompt.txt:
You are writing meeting notes from a raw transcript for a colleague who was not
in the room. The transcript is machine-generated and may contain errors.
Rules:
- Use only what is in the transcript. Never infer, never fill a gap.
- If a section has no content in the transcript, write "None recorded" under it.
- Do not soften or resolve disagreement. Record it as disagreement.
- An action item is only an action item if someone accepted it. Something that
was merely suggested belongs under Open questions.
- Keep every name, number, date and product name exactly as it appears.
- Output Markdown only, with exactly these headings, in this order.
## Summary
Two or three sentences on what the meeting was for and where it ended up.
## Attendees
The names that appear as speakers or are addressed directly.
## Decisions
- The decision, and who made it, if the transcript says.
## Action items
- Owner: task (deadline if one was stated, otherwise "no date")
## Open questions
- Raised and left unresolved.
## Notable quotes
- "quote" [timestamp]
Transcript follows.
Every instruction is doing a job. "Use only what is in the transcript" is the anti-invention clause and matters more than any other line. "Write None recorded" gives the model a legal way to produce nothing, which stops it manufacturing three action items for a status call that had none. "Do not soften disagreement" is there because summarizers are trained to be agreeable and will turn "I think that is the wrong call" into "the team aligned on". The action-item definition separates accepted from suggested, the most common failure in machine-written minutes. "Keep every name and number exactly" protects details a summary smooths away, and the fixed headings make the output parseable, so stage 4 splits on ##.
The local invocation feeds prompt and transcript on standard input:
jq -r '.transcription[] | "[\(.timestamps.from)] \(.text)"' meeting.json > meeting.txt
cat ~/Meetings/prompt.txt meeting.txt | ollama run llama3.1 > meeting.summary.md
The same prompt works unchanged against a hosted model; only the request shape changes, the prompt becoming a message in a JSON body posted to the provider's chat endpoint and the reply arriving as a JSON field you extract. For a transcript longer than the context window, use the map-reduce chunking from the local summarizing guide with this prompt as the reduce step. Do not raise the window and hope.
Stage 4: file it
Any Markdown-based notes app, a plain folder included, needs one thing: a .md file with YAML front matter on top. In Obsidian these are properties, documented as YAML at the very beginning of the file inside --- delimiters, with tags and aliases as reserved list-type names and dates written YYYY-MM-DD.
---
date: 2026-08-30
title: Pricing review
attendees:
- Ana
- Marek
- Priya
duration_minutes: 47
source_audio: "/Users/you/Meetings/archive/2026-08-30-pricing-review.m4a"
transcript: "[[2026-08-30-pricing-review.transcript]]"
tags:
- meeting
- pricing
---
Write that block, append the summary underneath, save it into the vault folder. That is the entire integration: no plugin, no API, which is why a plain Markdown vault is the most durable target you can pick. Duration comes from the audio:
ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 recording.m4a
If your notes live in a hosted service, push the note through its documented API instead. In Notion, creating a page is a POST to https://api.notion.com/v1/pages with three headers, Authorization: Bearer <token>, Notion-Version: 2026-03-11 and Content-Type: application/json, and a body carrying a parent object, a properties object for the title and a children array of blocks, per the create-a-page reference. The version header is required on every request and its current value, checked on 30 August 2026, is on their versioning page; confirm it there before you write the call, because it moves. Note that this route also converts your Markdown into the service's block objects, which is real work.
Automating it with a watcher
A per-user launchd agent starts a job when a directory changes. Save this as ~/Library/LaunchAgents/com.you.meetingpipeline.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.you.meetingpipeline</string>
<key>ProgramArguments</key>
<array>
<string>/Users/you/Meetings/pipeline.sh</string>
</array>
<key>WatchPaths</key>
<array>
<string>/Users/you/Meetings/inbox</string>
</array>
<key>ThrottleInterval</key>
<integer>30</integer>
<key>StandardOutPath</key>
<string>/Users/you/Meetings/pipeline.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/Meetings/pipeline.log</string>
</dict>
</plist>
Load it with the current subcommand, not the legacy load:
launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.you.meetingpipeline.plist
Now the gotcha, documented by Apple rather than folklore. The launchd.plist manual page says of WatchPaths that "filesystem event monitoring is highly race-prone, and it is entirely possible for modifications to be missed", and that "when modifications are caught, there is no guarantee that the file will be in a consistent state when the job is launched".
Read that literally, because it dictates the design. The job fires on directory change, not on a file being finished: when the recorder creates a zero-byte file, again on every flush, again when you rename something, and occasionally not at all. So the script must skip files still being written, skip files already processed, and survive running twice in the same second.
DONE=~/Meetings/.processed
mkdir -p "$DONE"
is_stable() {
local a b
a=$(stat -f%z "$1"); sleep 5; b=$(stat -f%z "$1")
[ "$a" = "$b" ] && [ "$a" -gt 0 ]
}
marker_for() { echo "$DONE/$(shasum -a 256 "$1" | cut -d' ' -f1)"; }
Hashing rather than trusting the file name means a re-recorded meeting with the same name is processed again and a merely renamed file is not. ThrottleInterval set to 30 stops a burst of directory events starting thirty overlapping jobs; the default is one spawn every 10 seconds. If your recorder writes atomically into the folder, QueueDirectories, which keeps the job alive while a directory is not empty, is the better key.
The whole pipeline
#!/bin/bash
set -euo pipefail
BASE=~/Meetings
MODEL=~/whisper-models/ggml-large-v3-turbo.bin
VAULT=~/Meetings/notes
DONE=$BASE/.processed
FROM_STAGE=${1:-1}
# is_stable, marker_for and transcribe_local are the functions defined above.
mkdir -p "$BASE"/{inbox,work,archive} "$VAULT" "$DONE"
log() { printf '%s %s\n' "$(date '+%F %T')" "$*"; }
for src in "$BASE"/inbox/*; do
[ -e "$src" ] || continue
name=$(basename "${src%.*}")
w="$BASE/work/$name"
if ! is_stable "$src"; then log "SKIP still writing: $name"; continue; fi
marker=$(marker_for "$src")
if [ -e "$marker" ]; then log "SKIP already done: $name"; continue; fi
log "START $name"
if [ "$FROM_STAGE" -le 1 ] || [ ! -f "$w.wav" ]; then
log " stage 1 normalize"
ffmpeg -nostdin -loglevel error -y -i "$src" \
-ar 16000 -ac 1 -c:a pcm_s16le "$w.wav"
fi
if [ "$FROM_STAGE" -le 2 ] || [ ! -f "$w.json" ]; then
log " stage 2 transcribe"
transcribe_local "$w.wav"
fi
if [ "$FROM_STAGE" -le 3 ] || [ ! -f "$w.summary.md" ]; then
log " stage 3 summarize"
jq -r '.transcription[] | "[\(.timestamps.from)] \(.text)"' "$w.json" > "$w.txt"
cat "$BASE/prompt.txt" "$w.txt" | ollama run llama3.1 > "$w.summary.md"
fi
log " stage 4 file"
dur=$(ffprobe -v error -show_entries format=duration \
-of default=noprint_wrappers=1:nokey=1 "$src")
mv "$src" "$BASE/archive/$(basename "$src")"
{
echo "---"
echo "date: ${name:0:10}"
echo "title: \"${name#????-??-??-}\""
echo "duration_minutes: $(awk -v d="$dur" 'BEGIN{printf "%.0f", d/60}')"
echo "source_audio: \"$BASE/archive/$(basename "$src")\""
echo "tags:"
echo " - meeting"
echo "---"
echo
cat "$w.summary.md"
} > "$VAULT/$name.md"
touch "$marker"
log "DONE $name -> $VAULT/$name.md"
done
Run ./pipeline.sh for a normal pass, or ./pipeline.sh 3 to re-summarize everything with a changed prompt without transcribing again. set -euo pipefail stops a failure loudly instead of writing an empty note, the per-stage [ ! -f ... ] guards make a second run cheap, and every interpolated front-matter value is quoted, because a meeting title containing a colon otherwise breaks the YAML. Delete a marker to force a reprocess.
What it costs and what breaks
Local configuration. No per-minute charge, ever. You pay a few gigabytes of disk for the two models and a few minutes of Mac time per hour of audio, spent after the meeting rather than during it. Memory is the real constraint; how much memory a Mac needs covers what fits. Re-running costs nothing.
API configuration. Two metered calls per meeting: transcription billed against audio duration, summarization against tokens in and out. Chunking a long meeting multiplies the second, and every re-run is billed afresh. Current rates and the arithmetic for a realistic weekly load are in what cloud transcription APIs actually cost, checked against each provider's own pricing page.
What breaks in practice, roughly in order of frequency:
- A meeting that is actually three meetings. Two people arrive early and chat, the real meeting runs, three stay after. The summarizer treats it as one event and attributes the side conversation to the group.
- A hallucinated tail. The recording ends with two minutes of an empty room and the speech model writes fluent sentences over it. Those land in your notes as decisions.
- An invented action item. Someone said "maybe we should email the vendor" and nobody responded, and it appears with an owner. The prompt above reduces this a lot; it does not eliminate it.
- Silent failure at 2am. The model file moved, the key expired, the disk filled.
WatchPathsfires, the script dies, nothing tells you. Read the log, or have stage 4 write a note saying FAILED rather than nothing.
The maintenance reality. You now own a small system. Model names change, tool flags change between releases, the notes API adds a required header, a macOS update resets a folder permission. None of it is hard, all of it lands on you, usually on the morning you needed the notes. That is the honest trade: the value is control, and the price is your time.
If you'd rather not maintain this
Frequently asked questions
How do I automatically turn a recording into a meeting note?
Run four steps that each write a file: normalize the audio to 16 kHz mono WAV, transcribe it to JSON with timestamps, summarize that into Markdown with fixed headings, and write the Markdown into your notes folder with YAML front matter. Trigger the chain with a launchd agent watching the recordings folder, and keep the steps separate so you can re-run one without redoing the rest.
Can this pipeline run entirely offline?
Yes, if both models are local. The only network use is then the one-time model downloads, and nothing about the meeting leaves the machine. Swapping either stage to a hosted API sends that stage's data to the provider, so check what they document about retention and training first, and confirm anything legal with your own advisor.
Why keep timestamps if the summary does not show them?
Because they are the link back to the audio. A note saying a decision was made at 41:07 can be checked in seconds; without offsets you re-listen to the whole recording. Timestamps also let you confirm whether a strange sentence in the summary came from real speech or from silence.
How do I stop the summarizer inventing action items?
Define the term in the prompt: an action item is something a named person accepted, and anything merely suggested goes under open questions. Give the model a way to produce nothing, such as writing "None recorded" under an empty heading, so it is not pushed into filling the section. Then check the transcript, because a summarizer working from a hallucinated passage will produce a confident action item from words nobody said.