Zoom Transcript Sync (transfer-notes)
Manage <>'s end-to-end automated meeting-note pipeline. The pipeline runs continuously in the background — this skill is for installing, troubleshooting, retriggering, and tuning it.
How the pipeline works
- Hammerspoon (
~/.hammerspoon/init.lua) watches for Zoom meeting windows usinghs.window.filter. - When a meeting window appears → fires
superwhisper://recordURL scheme → Superwhisper starts recording in Voice mode (raw transcript only, no LLM processing by Superwhisper). - When the meeting window closes → fires
superwhisper://recordagain (toggle) → Superwhisper stops. After a 15s delay (lets Superwhisper finalize) → invokes~/.hammerspoon/process-zoom-transcript.sh. - The script finds the newest Superwhisper recording, filters out short ones (< 30s or < 200 chars), then shells out to
claude -pwith a prompt that:- Reads the raw transcript from
meta.json'sresultfield - Queries Google Calendar (
gws calendar events list) for the event that was happening during the recording window — pulls the real meeting title and attendees - Writes a structured markdown file to
<vault>/zoom/YYYY-MM-DD HHMM - <real meeting title>.md(frontmatter + Summary + Discussed topics + Action items + collapsed full transcript) - Appends structured notes (Summary + Discussed topics + Action items, without the transcript callout) to the current daily note under
## Meetings, with a wikilink to the full file
- Reads the raw transcript from
- A macOS notification fires when complete.
State is managed in Hammerspoon: isRecording flag + 2.5s debounce prevent double-toggles from rapid Zoom window events (pre-meeting window flashes in/out).
Constants and file paths
- Hammerspoon config:
~/.hammerspoon/init.lua - Processing script:
~/.hammerspoon/process-zoom-transcript.sh - Superwhisper recordings:
~/Documents/superwhisper/recordings/<unix_timestamp>/meta.json - Vault output:
<<VAULT_PATH>>/zoom/ - Daily note:
<<VAULT_PATH>>/daily/YYYY-MM-DD.md - Log:
/tmp/zoom-transcript.log - Claude CLI:
/usr/local/bin/claude(absolute path required — Hammerspoon's PATH doesn't include/usr/local/bin) - Zoom window titles watched:
"Zoom Meeting","Zoom Webinar" - Debounce: 2.5s (coalesces rapid window events)
- Post-meeting delay before processing: 15s (lets Superwhisper finalize)
- Skip thresholds: duration < 30s OR transcript < 200 chars
Common operations
Troubleshoot: "my last meeting didn't get transcribed"
Check in this order:
- Read the log:
tail -50 /tmp/zoom-transcript.log. Expect lines like=== <date> ===thenProcessing recording <id> (<sec>s, <chars> chars)thenDone. - Common log signatures:
Skipped: <N>s, <M> chars→ recording below the 30s/200char threshold. Normal for mic checks; if it's a real meeting, lower the threshold in the script.claude: command not found→ PATH issue. Script should hardcode/usr/local/bin/claude.skipped: not a meeting→ Claude's content filter decided the transcript wasn't a real meeting. Look at the rawresultin the newest Superwhisper recording to see what it actually captured.- No new log entries at all → Hammerspoon never fired the script. Check: (a) Hammerspoon running? (menu bar icon), (b) Accessibility permission granted? (System Settings → Privacy & Security → Accessibility), (c) window title actually "Zoom Meeting"? (Zoom occasionally changes this — see "Debug Zoom window titles" below).
- Check the newest recording:
ls -1t ~/Documents/superwhisper/recordings/ | head -3→ look atmeta.jsonto verify Superwhisper actually recorded.
Retrigger manually for a specific recording
The script always processes the newest recording. To force processing of a specific older recording, touch it first:
touch ~/Documents/superwhisper/recordings/<timestamp>/
bash ~/.hammerspoon/process-zoom-transcript.sh
Fake a recording to test the pipeline
When you need to verify the Claude → vault write path without joining a real Zoom (e.g., after changing the summarization prompt), write a synthetic meta.json with meeting-like content:
TS=$(date +%s)
DIR="$HOME/Documents/superwhisper/recordings/$TS"
mkdir -p "$DIR"
cat > "$DIR/meta.json" <<'JSON'
{
"datetime": "<ISO datetime>",
"duration": 420000,
"modeName": "Voice",
"result": "<at least 200 chars of meeting-like content — multiple topics, named people, decisions. If it sounds like a mic test, Claude will skip it.>"
}
JSON
bash ~/.hammerspoon/process-zoom-transcript.sh
Reinstall the pipeline on a new Mac
brew install --cask hammerspoonand launch it. Grant Accessibility permission.- Ensure Superwhisper is installed and
superwhisper://recordURL scheme is enabled in its settings. - Drop
init.luainto~/.hammerspoon/(see Reference: init.lua below). - Drop
process-zoom-transcript.shinto~/.hammerspoon/andchmod +xit (see Reference: process-zoom-transcript.sh below). - Confirm
/usr/local/bin/claudeexists (which claude). If Claude CLI is elsewhere, update the hardcoded path in the script. - Confirm
/usr/bin/jqexists. - Reload Hammerspoon config (menu bar → Reload Config). Expect a floating alert confirming reload.
- Test: fake a recording (see above) and verify a new file appears in
<vault>/zoom/.
Debug Zoom window titles
If Hammerspoon stops firing because Zoom changed its window title, replace the init.lua with the diagnostic version that logs every Zoom window's title on creation:
local zoomFilter = hs.window.filter.new(false):setAppFilter("zoom.us")
zoomFilter:subscribe(hs.window.filter.windowCreated, function(win)
hs.alert.show("Zoom window: '" .. (win:title() or "nil") .. "'", 4)
end)
Join a meeting, note the exact title Zoom uses, then update the allowTitles list in the production init.lua to include it.
Tune the summarization prompt
The prompt Claude uses to format the vault file lives inside process-zoom-transcript.sh as the PROMPT variable. To adjust:
- Make summaries longer/shorter → change "3-5 bullets" instruction.
- Change the file structure → edit the file-structure spec in the prompt.
- Add routing (e.g. 1:1s to
people/<name>.mdinstead ofzoom/) → add a classification instruction to the prompt. The existing prompt puts everything underzoom/intentionally; adding routing is a later enhancement. - Change the daily-note pointer format → edit step 4 of the prompt.
After editing the prompt, re-run against the latest recording to verify output quality before relying on it for a real meeting.
Disable the automation temporarily
Quit Hammerspoon from the menu bar, or comment out the two zoomFilter:subscribe lines in init.lua and reload config.
Known quirks
- Option+Space conflict with Granola: the pipeline uses the
superwhisper://recordURL scheme specifically because Granola also binds Option+Space, so sending a raw keystroke lets Granola win the race. Don't revert to keystroke-based triggering unless Granola is uninstalled. - Whisper "thank you" hallucination: if you see a new vault file with just a "thank you" transcript, it means Superwhisper recorded near-silence and Whisper hallucinated — the real bug is the pipeline fired start/stop in rapid succession. The debounce should prevent this, but if you see it recurring, the debounce window may need to be larger than 2.5s.
- Voice mode only: Superwhisper has other modes (Default, Meeting Minutes) but the pipeline explicitly uses Voice so Claude — not Superwhisper — controls summarization. Don't switch to Meeting Minutes mode unless you're also willing to rewrite the prompt.
- Headless Claude needs
--dangerously-skip-permissions: the script runs under Hammerspoon with no TTY, so Claude can't prompt for Edit/Write approvals. This is why the flag is present. Acceptable risk on <>'s own machine running his own script. - Frontmatter is queryable via Obsidian Bases: every vault file gets
datetime,source,duration_sec,recording_idin YAML frontmatter. Build a Base overzoom/if you want a sortable index.
What this skill doesn't do (on purpose)
- No classification routing to
people/orteam/: every meeting lands inzoom/with a calendar-derived title. The old transfer-notes routed 1:1s intopeople/<name>.mdbased on SuperWhisper Meeting Minutes mode — dead code because <> uses Voice mode. If <> wants per-person aggregation, the place to add it is the Claude prompt inprocess-zoom-transcript.sh(detect 2-person events and append topeople/<other-person>.mdinstead). - No bulk backfill — this skill manages the continuous automation only. For retroactive sync (e.g., "process yesterday's recordings", "backfill last week"), use the
meeting-syncskill. - No Superwhisper mode switching: the pipeline assumes Voice mode. Switching modes is a Superwhisper settings change, not a skill operation.
Companion skill
meeting-sync= the manual/bulk version of this pipeline. Processes Superwhisper recordings for a given date range, matches each to a calendar event, writes tozoom/and daily note. Same output format as this skill's auto-pipeline. Invoke when auto-pipeline missed a meeting, when you recorded outside Zoom (Google Meet, Teams, phone), or for date-range backfills. (Renamed fromgranola-sync2026-05-07.)
Reference: init.lua
-- Trigger Superwhisper recording for Zoom meetings + process transcript after.
-- Zoom fires multiple window events when joining (pre-meeting window flashes in/out),
-- and superwhisper://record is a toggle, so we debounce and track state.
local isRecording = false
local debounceTimer = nil
local function hasMeetingWindow()
local wins = hs.window.filter.new(false)
:setAppFilter("zoom.us", { allowTitles = { "Zoom Meeting", "Zoom Webinar" } })
:getWindows()
return #wins > 0
end
local function processTranscript()
local script = os.getenv("HOME") .. "/.hammerspoon/process-zoom-transcript.sh"
hs.task.new("/bin/bash", nil, { script }):start()
end
local function syncRecordingState()
if debounceTimer then debounceTimer:stop() end
debounceTimer = hs.timer.doAfter(2.5, function()
local inMeeting = hasMeetingWindow()
if inMeeting and not isRecording then
hs.urlevent.openURL("superwhisper://record")
isRecording = true
hs.notify.new({ title = "Superwhisper", informativeText = "Started (Zoom joined)" }):send()
elseif not inMeeting and isRecording then
hs.urlevent.openURL("superwhisper://record")
isRecording = false
hs.notify.new({ title = "Superwhisper", informativeText = "Stopped (Zoom ended) — processing..." }):send()
hs.timer.doAfter(15, processTranscript)
end
end)
end
local zoomFilter = hs.window.filter.new(false)
:setAppFilter("zoom.us", { allowTitles = { "Zoom Meeting", "Zoom Webinar" } })
zoomFilter:subscribe(hs.window.filter.windowCreated, syncRecordingState)
zoomFilter:subscribe(hs.window.filter.windowDestroyed, syncRecordingState)
Reference: process-zoom-transcript.sh
The script is the source of truth — read the live file directly rather than trusting a cached copy in this doc (prevents drift):
cat ~/.hammerspoon/process-zoom-transcript.sh
High-level behavior the script orchestrates:
- Find the newest Superwhisper recording folder
- Extract
result(transcript),datetime,durationfrommeta.json - Skip if < 30s or < 200 chars
- Hand off to Claude via
/usr/local/bin/claude --dangerously-skip-permissions -pwith a prompt that tells Claude to:- Query Google Calendar (
gws calendar events liston <>'s personal calendar) for the event overlapping the recording window - Filter out non-meeting events (workingLocation, focusTime, Home/Office/BLOCK blockers, all-day events)
- Write full notes (summary + discussed topics + action items + collapsed full transcript) to
<vault>/zoom/YYYY-MM-DD HHMM - <real title>.md - Append structured notes (same content minus transcript callout) to daily note under
## Meetings - Skip entirely if transcript is clearly not a real meeting
- Query Google Calendar (
If the script gets out of sync with this doc or needs modification, edit the live file at ~/.hammerspoon/process-zoom-transcript.sh — the doc describes intent, not implementation.