snappy-transcripts skill
list year? month?readread path offset?readsearch queryread$ npx snappy-skills install snappy-transcripts
$ npx snappy-skills install --all
$ npx snappy-skills update
You need to find or transcribe a meeting. Krisp MCP is the primary source. Whisper on Mac Mini is the fallback.
| Source available | Use |
|---|---|
| Zoom / Meet / Teams call | Krisp MCP (always try first) |
| Phone call recording | Whisper on Mac Mini |
| YouTube video | yt-dlp + Whisper on Mac Mini |
| Local file (mp3/mp4/m4a) | Whisper on Mac Mini |
| Tool | Purpose |
|---|---|
mcp__claude_ai_Krisp__search_meetings |
Full-text search across past meetings |
mcp__claude_ai_Krisp__list_action_items |
Action items with owner + due |
mcp__claude_ai_Krisp__list_upcoming_meetings |
Calendar of upcoming recorded meetings |
mcp__claude_ai_Krisp__get_multiple_documents |
Batch pull full text of multiple meetings |
# Common search patterns
search_meetings({ query: "Jane Smith" }) # by person
search_meetings({ query: "decided OR agreed <topic>" }) # by decision
search_meetings({ query: "amazing OR transformed" }) # testimonial candidates
bashssh robertboulos@Roberts-Mac-mini.local
cd /Users/robertboulos/robot-rob
source venv/bin/activate
whisper /path/to/audio.mp3 --model small --word_timestamps True --output_dir /Users/robertboulos/transcripts/
small (meetings <1hr). Use medium for noisy/multi-accent..srt, .txt, AND .json (segments)YYYY-MM-DD_topic_attendees.{ext}/Users/robertboulos/transcripts/ on Mac MiniFull pipeline docs: whisper-pipeline.md
Transcripts also live locally at ~/.claude/corpus/krisp/YYYY/MM/*.md -- these are full transcripts already pulled from Krisp. Use these for mining (snappy-mine) instead of re-fetching.
MCP tools do NOT propagate to Task-spawned subagents. If you're in a subagent, use the local corpus files or SSH to Mac Mini for Whisper. No Krisp MCP fallback exists as curl.
search_meetings firstget_multiple_documents with ID listword_timestamps=True.txt -- always emit .srt, .txt, and .jsonsnappy-knowledge -- log interaction with transcript_idsnappy-testimonials -- quote mining via quote-mining.mdsnappy-content / snappy-mine -- content idea extractionsnappy-sales -- sales call review via sales-review.mdtypescriptimport { listTranscripts, readTranscript, searchCorpus } from "../snappy-transcripts/api.ts";
Or CLI:
bashnpx tsx ~/.claude/skills/snappy-transcripts/api.ts list 2026 04
npx tsx ~/.claude/skills/snappy-transcripts/api.ts read <path> [offset] [--limit N]
npx tsx ~/.claude/skills/snappy-transcripts/api.ts search <query>
| Function | Purpose |
|---|---|
listTranscripts(year?, month?) |
List transcript files in the corpus (re-export from snappy-corpus) |
readTranscript(path, offset?, limit?) |
Read a transcript in chunks (re-export from snappy-corpus) |
searchCorpus(query) |
Grep across all transcripts (re-export from snappy-corpus) |
Nothing in the collection names this skill.
<!-- SNAPPY-CONTRACT-VERBS-START -->
Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
list |
year?, month? |
read |
npx tsx ~/.claude/skills/snappy-transcripts/api.ts list |
read |
path, offset? |
read |
npx tsx ~/.claude/skills/snappy-transcripts/api.ts read <path> |
search |
query |
read |
npx tsx ~/.claude/skills/snappy-transcripts/api.ts search "<query>" |
When an answer carries face_hint, show it with one snappy_present(<answer>) call.
See /snappy-faces for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
If this AGENTS.md does not cover the case you are working on and you had to read other files to find the answer, you have two jobs before you finish:
1. Fix it now (P -- proportional correction). If the missing information fits in 1-5 lines, edit this AGENTS.md directly -- add the line, the table row, the one-liner, whatever you had to go find. Keep edits surgical. Do not restructure sections or rewrite paragraphs. The goal is: the next agent that hits this case won't have to leave the loader.
2. Log it always. Whether or not you fixed it, append one line to the feedback log:
bashecho "[$(date -u +%FT%TZ)] <skill-name>: <what was missing> [FIXED|LOGGED]" >> ~/.claude/logs/agents-md-feedback.log
Tag FIXED if you patched the loader in step 1. Tag LOGGED if the gap was too large to patch inline (needs a section rewrite, new workflow, etc.) -- those get picked up by drain-skill-regen.sh for a full regen pass (I -- integral correction).
Do not skip this. A missing report means the same gap will happen next time. A missing fix means you chose to let the next agent stumble on the same thing you just solved.
<!-- SKILL-INDEX-START -->
[snappy-transcripts Index]|root: ~/.claude/skills/snappy-transcripts|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,krisp-integration.md,quote-mining.md,sales-review.md,summarization.md,whisper-pipeline.md}
<!-- SKILL-INDEX-END -->
---
name: snappy-transcripts
role: Transcript retrieval. Krisp MCP is primary, Whisper on Mac Mini is fallback.
loaded-by: PreToolUse hook (auto-injected when "snappy-transcripts" is mentioned)
---
# snappy-transcripts -- loader
You need to find or transcribe a meeting. Krisp MCP is the primary source. Whisper on Mac Mini is the fallback.
## Decision: which source?
| Source available | Use |
|------------------|-----|
| Zoom / Meet / Teams call | Krisp MCP (always try first) |
| Phone call recording | Whisper on Mac Mini |
| YouTube video | yt-dlp + Whisper on Mac Mini |
| Local file (mp3/mp4/m4a) | Whisper on Mac Mini |
## Krisp MCP tools
| Tool | Purpose |
|------|---------|
| `mcp__claude_ai_Krisp__search_meetings` | Full-text search across past meetings |
| `mcp__claude_ai_Krisp__list_action_items` | Action items with owner + due |
| `mcp__claude_ai_Krisp__list_upcoming_meetings` | Calendar of upcoming recorded meetings |
| `mcp__claude_ai_Krisp__get_multiple_documents` | Batch pull full text of multiple meetings |
```
# Common search patterns
search_meetings({ query: "Jane Smith" }) # by person
search_meetings({ query: "decided OR agreed <topic>" }) # by decision
search_meetings({ query: "amazing OR transformed" }) # testimonial candidates
```
## Whisper fallback (Mac Mini)
```bash
ssh robertboulos@Roberts-Mac-mini.local
cd /Users/robertboulos/robot-rob
source venv/bin/activate
whisper /path/to/audio.mp3 --model small --word_timestamps True --output_dir /Users/robertboulos/transcripts/
```
- Default model: `small` (meetings <1hr). Use `medium` for noisy/multi-accent.
- Always emit `.srt`, `.txt`, AND `.json` (segments)
- File naming: `YYYY-MM-DD_topic_attendees.{ext}`
- Storage: `/Users/robertboulos/transcripts/` on Mac Mini
Full pipeline docs: [whisper-pipeline.md](whisper-pipeline.md)
## Local corpus (already downloaded)
Transcripts also live locally at `~/.claude/corpus/krisp/YYYY/MM/*.md` -- these are full transcripts already pulled from Krisp. Use these for mining (snappy-mine) instead of re-fetching.
## MCP tools in subagents
MCP tools do NOT propagate to Task-spawned subagents. If you're in a subagent, use the local corpus files or SSH to Mac Mini for Whisper. No Krisp MCP fallback exists as curl.
## Rules
- Do NOT default to Whisper -- try Krisp `search_meetings` first
- Do NOT ask for a Krisp URL -- MCP returns the full document
- Do NOT use vague queries like "stuff" -- use specific keywords (name, topic, sentiment)
- Do NOT pull meetings one at a time in a loop -- use `get_multiple_documents` with ID list
- Do NOT run Whisper without `word_timestamps=True`
- Do NOT store Whisper output as only `.txt` -- always emit `.srt`, `.txt`, and `.json`
- Do NOT use Charlotte MCP for transcript search -- use Krisp MCP
## Downstream consumers
- `snappy-knowledge` -- log interaction with `transcript_id`
- `snappy-testimonials` -- quote mining via [quote-mining.md](quote-mining.md)
- `snappy-content` / `snappy-mine` -- content idea extraction
- `snappy-sales` -- sales call review via [sales-review.md](sales-review.md)
## API module
```typescript
import { listTranscripts, readTranscript, searchCorpus } from "../snappy-transcripts/api.ts";
```
Or CLI:
```bash
npx tsx ~/.claude/skills/snappy-transcripts/api.ts list 2026 04
npx tsx ~/.claude/skills/snappy-transcripts/api.ts read <path> [offset] [--limit N]
npx tsx ~/.claude/skills/snappy-transcripts/api.ts search <query>
```
## API functions
| Function | Purpose |
|----------|---------|
| `listTranscripts(year?, month?)` | List transcript files in the corpus (re-export from snappy-corpus) |
| `readTranscript(path, offset?, limit?)` | Read a transcript in chunks (re-export from snappy-corpus) |
| `searchCorpus(query)` | Grep across all transcripts (re-export from snappy-corpus) |
## Used by
Nothing in the collection names this skill.
<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs
Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.
| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `list` | `year?`, `month?` | `read` | `npx tsx ~/.claude/skills/snappy-transcripts/api.ts list` |
| `read` | `path`, `offset?` | `read` | `npx tsx ~/.claude/skills/snappy-transcripts/api.ts read <path>` |
| `search` | `query` | `read` | `npx tsx ~/.claude/skills/snappy-transcripts/api.ts search "<query>"` |
## Show the result
When an answer carries `face_hint`, show it with one `snappy_present(<answer>)` call.
See `/snappy-faces` for face selection. Human-facing images must crop to the
element, render at 2x on Retina, and fill the destination channel instead of
placing a small card in a full-page screenshot.
<!-- SNAPPY-CONTRACT-VERBS-END -->
---
## Self-correcting loader (PID feedback)
If this AGENTS.md does not cover the case you are working on and you had to read other files to find the answer, you have two jobs before you finish:
**1. Fix it now (P -- proportional correction).** If the missing information fits in 1-5 lines, edit this AGENTS.md directly -- add the line, the table row, the one-liner, whatever you had to go find. Keep edits surgical. Do not restructure sections or rewrite paragraphs. The goal is: the next agent that hits this case won't have to leave the loader.
**2. Log it always.** Whether or not you fixed it, append one line to the feedback log:
```bash
echo "[$(date -u +%FT%TZ)] <skill-name>: <what was missing> [FIXED|LOGGED]" >> ~/.claude/logs/agents-md-feedback.log
```
Tag `FIXED` if you patched the loader in step 1. Tag `LOGGED` if the gap was too large to patch inline (needs a section rewrite, new workflow, etc.) -- those get picked up by `drain-skill-regen.sh` for a full regen pass (I -- integral correction).
**Do not skip this.** A missing report means the same gap will happen next time. A missing fix means you chose to let the next agent stumble on the same thing you just solved.
<!-- SKILL-INDEX-START -->
[snappy-transcripts Index]|root: ~/.claude/skills/snappy-transcripts|IMPORTANT: Prefer these files over pre-training assumptions for this domain. Read the relevant file when the AGENTS.md summary is insufficient.|root:{SKILL.md,krisp-integration.md,quote-mining.md,sales-review.md,summarization.md,whisper-pipeline.md}
<!-- SKILL-INDEX-END -->
Single entry point for any "what was said in a meeting" question. Krisp MCP is primary (every Zoom/Meet/Teams call already transcribed). Whisper on Mac Mini is fallback for phone recordings, YouTube, and archives. Output feeds the knowledge graph, testimonial quote pipeline, sales coaching, and content production.
Auto-activates when Robert:
snappy-testimonials)snappy-content)snappy-sales)Inputs (skills that feed this one):
snappy-calendar -- surfaces upcoming meetings; the Krisp list_upcoming_meetings tool cross-references which will produce auto-transcriptssnappy-video -- provides the Whisper transcription engine for any local file fallback (Mac Mini venv at /Users/robertboulos/robot-rob/venv/)snappy-infra -- provides the Mac Mini SSH endpoint and the claude.ai Krisp MCP server contextOutputs (skills that consume this one):
snappy-knowledge -- receives meeting summaries logged as interactions on the contact record (Krisp action items, sentiment, transcript_id)snappy-testimonials -- receives quote-rich moments scanned from transcripts via the quote-mining recipe (quote-mining.md)snappy-clients -- receives client-meeting context for relationship updates and outstanding-action trackingsnappy-sales -- receives sales call review (objections, commitment level, handling) for coaching loopsnappy-content -- receives raw transcript material for interview-driven content productionsnappy-blog / snappy-youtube / snappy-post -- downstream content channels that read mined momentsChannels (where output is delivered):
snappy-testimonials permission-request flow (which then dispatches through snappy-email / snappy-slack)Orchestrator:
snappy-ops triggers this skill during the morning briefing (pull yesterday's call action items via Krisp list_action_items) and the weekly review (scan for stale action items + quote candidates). snappy-knowledge triggers it during pre-call brief and post-call capture.Decide source first:
| Source available | Use |
|---|---|
| Zoom / Meet / Teams call (auto-recorded) | Krisp MCP -- mcp__claude_ai_Krisp__search_meetings |
| Phone call recording | Whisper on Mac Mini (whisper-pipeline.md) |
| YouTube video | yt-dlp + Whisper on Mac Mini (whisper-pipeline.md) |
| Local file (mp3, mp4, m4a) | Whisper on Mac Mini (whisper-pipeline.md) |
| Pre-Krisp archive | Whisper if not already in ~/transcripts/ on Mac Mini |
Default to Krisp first. Only fall back to Whisper if Krisp didn't capture the source.
| Robert says... | You do... |
|---|---|
| "What did we discuss with [name]?" | Krisp search_meetings query: contact name |
| "Find that call about [topic]" | Krisp search_meetings query: topic keywords |
| "What action items came out of yesterday's calls?" | Krisp list_action_items (no filter, then filter by date) |
| "Pull transcript of [Zoom recording]" | Krisp search_meetings first; if missing, Whisper local file |
| "Transcribe this phone recording" | scp to Mac Mini → Whisper pipeline (whisper-pipeline.md) |
| "Transcribe this YouTube video" | yt-dlp + Whisper (whisper-pipeline.md) |
| "Find a quote from [client] about [topic]" | Quote mining recipe (quote-mining.md) |
| "Mine the [contact] calls for content ideas" | Krisp get_multiple_documents batch + theme extraction |
| "Review yesterday's sales call" | Krisp search_meetings + sales coaching format (sales-review.md) |
| "Summarize this transcript" | Read full text → emit structured summary (summarization.md) |
| "Search my transcripts for [keyword]" | Krisp search_meetings first, then Mac Mini ~/transcripts/ JSON search if needed |
Every Zoom/Meet/Teams call Robert is on gets recorded and transcribed by Krisp. The claude.ai Krisp MCP server exposes seven tools for query access. Always try Krisp first before falling back to Whisper.
| Tool | Purpose |
|---|---|
mcp__claude_ai_Krisp__search_meetings |
Full-text search across every past meeting |
mcp__claude_ai_Krisp__list_action_items |
Action items extracted from meetings (with owner + due) |
mcp__claude_ai_Krisp__list_upcoming_meetings |
Calendar of meetings Krisp will record |
mcp__claude_ai_Krisp__list_activities |
Recent transcripts, notes, edits |
mcp__claude_ai_Krisp__get_multiple_documents |
Batch pull full text of multiple meetings |
mcp__claude_ai_Krisp__date_time |
Workspace timezone date/time for date math |
mcp__claude_ai_Krisp__get_user_preferences |
Workspace timezone, display prefs |
Full tool reference, search recipes, and gotchas in krisp-integration.md.
# Find every meeting with a contact
mcp__claude_ai_Krisp__search_meetings({ query: "Jane Smith" })
# Find decisions made on a topic
mcp__claude_ai_Krisp__search_meetings({ query: "decided OR agreed OR going to <topic>" })
# Find quote candidates for testimonials (positive sentiment words)
mcp__claude_ai_Krisp__search_meetings({
query: "amazing OR great OR incredible OR transformed OR helped me"
})
# Pull action items from yesterday's calls
mcp__claude_ai_Krisp__list_action_items({})
# Batch pull multiple meeting docs (testimonial sourcing)
mcp__claude_ai_Krisp__get_multiple_documents({
document_ids: ["<id1>", "<id2>", "<id3>"]
})
| Wrong | Correct |
|---|---|
| Defaulting to Whisper for any transcript request | Try Krisp search_meetings first -- covers every Zoom/Meet/Teams call automatically |
| Asking the user for a Krisp URL | Krisp MCP returns the full document -- no URL paste needed |
Calling search_meetings with vague queries like "stuff" |
Use specific keywords: contact name, topic, sentiment word |
| Pulling one meeting at a time in a loop | Use get_multiple_documents with a list of IDs |
| Treating Krisp action items as gospel | Confirm with the user before logging them as commitments -- Krisp NLP misses context |
| Forgetting to cite the meeting date and timestamp | Always include date + timestamp when surfacing a quote |
| Logging a Krisp transcript without linking it from the contact's interaction record | Always pass the transcript_id back to snappy-knowledge so the interaction is retrievable |
| Running Whisper without specifying model size | Default small for meetings; medium for noisy / multi-accent; large only for legal/critical |
Running Whisper without word_timestamps=True |
Always set it -- needed for SRT generation and quote citation |
Storing Whisper output only as .txt |
Always emit .srt, .txt, AND .json (segments) so search recipes work |
| Charlotte MCP for transcript search | Charlotte browser tools don't work reliably -- use Krisp MCP or Whisper |
When Krisp doesn't have the source (phone call, YouTube, pre-Krisp archive), use Whisper on the Mac Mini.
Defaults:
small (good balance for meetings <1hr)/Users/robertboulos/robot-rob/venv//Users/robertboulos/transcripts/YYYY-MM-DD_topic_attendees.{txt,srt,json}Full transcription scripts, model selection table, long-recording handling, and YouTube + audio extraction recipes in whisper-pipeline.md.
Krisp documents stay inside Krisp (retrieve via MCP). Whisper transcripts live on the Mac Mini at /Users/robertboulos/transcripts/ with three sidecar files:
| File | Contents |
|---|---|
{name}.txt |
Plain text -- full transcript |
{name}.srt |
SubRip with timestamps -- for video captions / quote citation |
{name}.json |
Segments with start/end timestamps -- for search recipes |
Filename convention: YYYY-MM-DD_topic_attendees.{ext} (lowercase, hyphen-separated).
| Need to... | Read this |
|---|---|
| Use Krisp MCP tools (full reference + search recipes) | krisp-integration.md |
| Run Whisper on a local/phone/YouTube file | whisper-pipeline.md |
| Mine transcripts for testimonial quotes | quote-mining.md |
| Summarize a transcript into structured intel | summarization.md |
| Review a sales call for coaching | sales-review.md |
| Search stored Whisper transcripts on Mac Mini | whisper-pipeline.md#search-stored-transcripts |
| Trigger | Flow |
|---|---|
| Pre-call brief | snappy-knowledge → this skill (Krisp search_meetings) → quote moments embedded in brief |
| Post-call capture | snappy-knowledge → this skill (Krisp list_action_items) → log interaction with transcript_id |
| Testimonial sourcing | snappy-testimonials → this skill (quote-mining.md) → permission-request draft |
| Content idea mining | snappy-content → this skill (Krisp batch via get_multiple_documents) → theme extraction |
| Sales call review | snappy-sales → this skill (sales-review.md) → coaching feedback |
| YouTube repurposing | snappy-youtube → this skill (whisper-pipeline.md#youtube-transcript) → repurpose copy |
| Phone recording archive | snappy-imessage / snappy-whatsapp → this skill (whisper-pipeline.md) |
| Component | Path |
|---|---|
| Krisp MCP server | claude.ai Krisp (in workspace) |
| Whisper venv | /Users/robertboulos/robot-rob/venv/ (Mac Mini) |
| Transcript storage | /Users/robertboulos/transcripts/ (Mac Mini) |
| Mac Mini SSH | robertboulos@Roberts-Mac-mini.local |
| Default model | small |
| Long-recording job log | /tmp/transcribe-job.log (Mac Mini) |
snappy-knowledge -- primary downstream consumer; receives interaction logs with transcript_id referencessnappy-testimonials (NEW) -- primary downstream consumer for quote mining; reads positive-sentiment moments and drafts permission requestssnappy-clients -- receives client-meeting context for relationship updatessnappy-sales -- receives sales call review for coaching loop (sales-review.md)snappy-content -- receives raw transcripts as interview material for content productionsnappy-video -- provides the Whisper transcription engine; same Mac Mini venvsnappy-blog / snappy-youtube / snappy-post -- downstream content channels that read mined momentssnappy-calendar -- upstream source for upcoming meetings (cross-references with Krisp)snappy-infra -- Mac Mini SSH and Xano API contextsnappy-ops -- daily/weekly orchestrator that schedules Krisp action item pullsSkill Status: COMPLETE
Skills whose description overlaps this one enough that a reader could pick the
wrong door. Each row is that skill's own first sentence about itself, so the
choice is made on its words, not on a summary written here.
| Skill | Reach for it instead when |
|---|---|
snappy-analytics |
Centralized analytics and metrics for the entire Snappy operating system. |
snappy-corpus |
The Krisp transcript corpus + nugget mining pipeline: import calls into the corpus, find quot… |
snappy-image |
Centralized image generation, editing, and capture for Snappy: Nano Banana / Gemini, Grok / x… |
snappy-os-operator |
Operate SnappyOS like a pro through product doors only: governed connector reads, staged writ… |
snappy-playbook |
WeTube SS mastermind 6-week curriculum source. |
snappy-voice-control |
Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Ag… |
snappy-walkthrough |
Recipe-driven capture and annotation of step-by-step tutorials. |
---
name: snappy-transcripts
reports_to: build
head: false
description: >
Transcript retrieval, search, and processing for Snappy. Krisp MCP is the primary source for
every Zoom/Meet/Teams call (auto-transcribed, action items extracted). Whisper on the Mac Mini
is the fallback for phone recordings, YouTube, and pre-Krisp archives. Powers pre-call briefs,
post-call capture, testimonial quote mining, content idea extraction, and sales call review.
Triggers on: transcript, transcripts, meeting notes, call recording, transcribe, what did we
discuss, meeting summary, pull transcript, search transcripts, call notes, recording, krisp,
meeting search, action items, quote mining, find quote, search calls, interview material,
meeting context, call recap, what did they say, who said what, call moments, verbatim quote.
---
# Snappy Transcripts
## Purpose
Single entry point for any "what was said in a meeting" question. Krisp MCP is primary (every Zoom/Meet/Teams call already transcribed). Whisper on Mac Mini is fallback for phone recordings, YouTube, and archives. Output feeds the knowledge graph, testimonial quote pipeline, sales coaching, and content production.
## When to Use This Skill
Auto-activates when Robert:
- Asks "what did we discuss with [name]?" or "find that call about [topic]"
- Needs pre-call brief context from past conversations
- Runs post-call capture and wants action items + summary
- Searches for client quotes (handed off to `snappy-testimonials`)
- Mines transcripts for content ideas (handed off to `snappy-content`)
- Reviews a sales call for coaching (handed off to `snappy-sales`)
- Pulls a YouTube video transcript for repurposing
- Transcribes a local recording (phone call, Zoom local backup)
## Workflow
**Inputs (skills that feed this one):**
- `snappy-calendar` -- surfaces upcoming meetings; the Krisp `list_upcoming_meetings` tool cross-references which will produce auto-transcripts
- `snappy-video` -- provides the Whisper transcription engine for any local file fallback (Mac Mini venv at `/Users/robertboulos/robot-rob/venv/`)
- `snappy-infra` -- provides the Mac Mini SSH endpoint and the `claude.ai Krisp` MCP server context
**Outputs (skills that consume this one):**
- `snappy-knowledge` -- receives meeting summaries logged as interactions on the contact record (Krisp action items, sentiment, transcript_id)
- `snappy-testimonials` -- receives quote-rich moments scanned from transcripts via the quote-mining recipe ([quote-mining.md](quote-mining.md))
- `snappy-clients` -- receives client-meeting context for relationship updates and outstanding-action tracking
- `snappy-sales` -- receives sales call review (objections, commitment level, handling) for coaching loop
- `snappy-content` -- receives raw transcript material for interview-driven content production
- `snappy-blog` / `snappy-youtube` / `snappy-post` -- downstream content channels that read mined moments
**Channels (where output is delivered):**
- Direct in-terminal renders for Robert (briefings, summaries, search results)
- Quote candidates handed off via the `snappy-testimonials` permission-request flow (which then dispatches through `snappy-email` / `snappy-slack`)
**Orchestrator:**
- `snappy-ops` triggers this skill during the morning briefing (pull yesterday's call action items via Krisp `list_action_items`) and the weekly review (scan for stale action items + quote candidates). `snappy-knowledge` triggers it during pre-call brief and post-call capture.
## Quick Start
Decide source first:
| Source available | Use |
|------------------|-----|
| Zoom / Meet / Teams call (auto-recorded) | Krisp MCP -- `mcp__claude_ai_Krisp__search_meetings` |
| Phone call recording | Whisper on Mac Mini ([whisper-pipeline.md](whisper-pipeline.md)) |
| YouTube video | yt-dlp + Whisper on Mac Mini ([whisper-pipeline.md](whisper-pipeline.md#youtube-transcript)) |
| Local file (mp3, mp4, m4a) | Whisper on Mac Mini ([whisper-pipeline.md](whisper-pipeline.md#local-file-transcription)) |
| Pre-Krisp archive | Whisper if not already in `~/transcripts/` on Mac Mini |
**Default to Krisp first.** Only fall back to Whisper if Krisp didn't capture the source.
## Quick Decision Map
| Robert says... | You do... |
|----------------|-----------|
| "What did we discuss with [name]?" | Krisp `search_meetings` query: contact name |
| "Find that call about [topic]" | Krisp `search_meetings` query: topic keywords |
| "What action items came out of yesterday's calls?" | Krisp `list_action_items` (no filter, then filter by date) |
| "Pull transcript of [Zoom recording]" | Krisp `search_meetings` first; if missing, Whisper local file |
| "Transcribe this phone recording" | scp to Mac Mini → Whisper pipeline ([whisper-pipeline.md](whisper-pipeline.md)) |
| "Transcribe this YouTube video" | yt-dlp + Whisper ([whisper-pipeline.md](whisper-pipeline.md#youtube-transcript)) |
| "Find a quote from [client] about [topic]" | Quote mining recipe ([quote-mining.md](quote-mining.md)) |
| "Mine the [contact] calls for content ideas" | Krisp `get_multiple_documents` batch + theme extraction |
| "Review yesterday's sales call" | Krisp `search_meetings` + sales coaching format ([sales-review.md](sales-review.md)) |
| "Summarize this transcript" | Read full text → emit structured summary ([summarization.md](summarization.md)) |
| "Search my transcripts for [keyword]" | Krisp `search_meetings` first, then Mac Mini `~/transcripts/` JSON search if needed |
## Krisp MCP -- Primary Source
Every Zoom/Meet/Teams call Robert is on gets recorded and transcribed by Krisp. The `claude.ai Krisp` MCP server exposes seven tools for query access. **Always try Krisp first** before falling back to Whisper.
| Tool | Purpose |
|------|---------|
| `mcp__claude_ai_Krisp__search_meetings` | Full-text search across every past meeting |
| `mcp__claude_ai_Krisp__list_action_items` | Action items extracted from meetings (with owner + due) |
| `mcp__claude_ai_Krisp__list_upcoming_meetings` | Calendar of meetings Krisp will record |
| `mcp__claude_ai_Krisp__list_activities` | Recent transcripts, notes, edits |
| `mcp__claude_ai_Krisp__get_multiple_documents` | Batch pull full text of multiple meetings |
| `mcp__claude_ai_Krisp__date_time` | Workspace timezone date/time for date math |
| `mcp__claude_ai_Krisp__get_user_preferences` | Workspace timezone, display prefs |
Full tool reference, search recipes, and gotchas in [krisp-integration.md](krisp-integration.md).
## Quick Reference -- Krisp Search Patterns
```
# Find every meeting with a contact
mcp__claude_ai_Krisp__search_meetings({ query: "Jane Smith" })
# Find decisions made on a topic
mcp__claude_ai_Krisp__search_meetings({ query: "decided OR agreed OR going to <topic>" })
# Find quote candidates for testimonials (positive sentiment words)
mcp__claude_ai_Krisp__search_meetings({
query: "amazing OR great OR incredible OR transformed OR helped me"
})
# Pull action items from yesterday's calls
mcp__claude_ai_Krisp__list_action_items({})
# Batch pull multiple meeting docs (testimonial sourcing)
mcp__claude_ai_Krisp__get_multiple_documents({
document_ids: ["<id1>", "<id2>", "<id3>"]
})
```
## What AI Agents Get Wrong
| Wrong | Correct |
|-------|---------|
| Defaulting to Whisper for any transcript request | Try Krisp `search_meetings` first -- covers every Zoom/Meet/Teams call automatically |
| Asking the user for a Krisp URL | Krisp MCP returns the full document -- no URL paste needed |
| Calling `search_meetings` with vague queries like "stuff" | Use specific keywords: contact name, topic, sentiment word |
| Pulling one meeting at a time in a loop | Use `get_multiple_documents` with a list of IDs |
| Treating Krisp action items as gospel | Confirm with the user before logging them as commitments -- Krisp NLP misses context |
| Forgetting to cite the meeting date and timestamp | Always include date + timestamp when surfacing a quote |
| Logging a Krisp transcript without linking it from the contact's interaction record | Always pass the `transcript_id` back to `snappy-knowledge` so the interaction is retrievable |
| Running Whisper without specifying model size | Default `small` for meetings; `medium` for noisy / multi-accent; `large` only for legal/critical |
| Running Whisper without `word_timestamps=True` | Always set it -- needed for SRT generation and quote citation |
| Storing Whisper output only as `.txt` | Always emit `.srt`, `.txt`, AND `.json` (segments) so search recipes work |
| Charlotte MCP for transcript search | Charlotte browser tools don't work reliably -- use Krisp MCP or Whisper |
## Whisper Pipeline (Mac Mini Fallback)
When Krisp doesn't have the source (phone call, YouTube, pre-Krisp archive), use Whisper on the Mac Mini.
**Defaults:**
- Model: `small` (good balance for meetings <1hr)
- venv: `/Users/robertboulos/robot-rob/venv/`
- Storage: `/Users/robertboulos/transcripts/`
- File naming: `YYYY-MM-DD_topic_attendees.{txt,srt,json}`
Full transcription scripts, model selection table, long-recording handling, and YouTube + audio extraction recipes in [whisper-pipeline.md](whisper-pipeline.md).
## Storing Transcripts
Krisp documents stay inside Krisp (retrieve via MCP). Whisper transcripts live on the Mac Mini at `/Users/robertboulos/transcripts/` with three sidecar files:
| File | Contents |
|------|---------|
| `{name}.txt` | Plain text -- full transcript |
| `{name}.srt` | SubRip with timestamps -- for video captions / quote citation |
| `{name}.json` | Segments with start/end timestamps -- for search recipes |
Filename convention: `YYYY-MM-DD_topic_attendees.{ext}` (lowercase, hyphen-separated).
## Navigation Guide
| Need to... | Read this |
|------------|-----------|
| Use Krisp MCP tools (full reference + search recipes) | [krisp-integration.md](krisp-integration.md) |
| Run Whisper on a local/phone/YouTube file | [whisper-pipeline.md](whisper-pipeline.md) |
| Mine transcripts for testimonial quotes | [quote-mining.md](quote-mining.md) |
| Summarize a transcript into structured intel | [summarization.md](summarization.md) |
| Review a sales call for coaching | [sales-review.md](sales-review.md) |
| Search stored Whisper transcripts on Mac Mini | [whisper-pipeline.md#search-stored-transcripts](whisper-pipeline.md#search-stored-transcripts) |
## Cross-Skill Workflows (Quick Map)
| Trigger | Flow |
|---------|------|
| Pre-call brief | `snappy-knowledge` → this skill (Krisp `search_meetings`) → quote moments embedded in brief |
| Post-call capture | `snappy-knowledge` → this skill (Krisp `list_action_items`) → log interaction with `transcript_id` |
| Testimonial sourcing | `snappy-testimonials` → this skill ([quote-mining.md](quote-mining.md)) → permission-request draft |
| Content idea mining | `snappy-content` → this skill (Krisp batch via `get_multiple_documents`) → theme extraction |
| Sales call review | `snappy-sales` → this skill ([sales-review.md](sales-review.md)) → coaching feedback |
| YouTube repurposing | `snappy-youtube` → this skill ([whisper-pipeline.md#youtube-transcript](whisper-pipeline.md#youtube-transcript)) → repurpose copy |
| Phone recording archive | `snappy-imessage` / `snappy-whatsapp` → this skill ([whisper-pipeline.md](whisper-pipeline.md)) |
## Key Paths
| Component | Path |
|-----------|------|
| Krisp MCP server | `claude.ai Krisp` (in workspace) |
| Whisper venv | `/Users/robertboulos/robot-rob/venv/` (Mac Mini) |
| Transcript storage | `/Users/robertboulos/transcripts/` (Mac Mini) |
| Mac Mini SSH | `robertboulos@Roberts-Mac-mini.local` |
| Default model | `small` |
| Long-recording job log | `/tmp/transcribe-job.log` (Mac Mini) |
## Related Skills
- **`snappy-knowledge`** -- primary downstream consumer; receives interaction logs with `transcript_id` references
- **`snappy-testimonials`** (NEW) -- primary downstream consumer for quote mining; reads positive-sentiment moments and drafts permission requests
- **`snappy-clients`** -- receives client-meeting context for relationship updates
- **`snappy-sales`** -- receives sales call review for coaching loop ([sales-review.md](sales-review.md))
- **`snappy-content`** -- receives raw transcripts as interview material for content production
- **`snappy-video`** -- provides the Whisper transcription engine; same Mac Mini venv
- **`snappy-blog`** / **`snappy-youtube`** / **`snappy-post`** -- downstream content channels that read mined moments
- **`snappy-calendar`** -- upstream source for upcoming meetings (cross-references with Krisp)
- **`snappy-infra`** -- Mac Mini SSH and Xano API context
- **`snappy-ops`** -- daily/weekly orchestrator that schedules Krisp action item pulls
---
**Skill Status**: COMPLETE
## Near neighbours
Skills whose description overlaps this one enough that a reader could pick the
wrong door. Each row is that skill's own first sentence about itself, so the
choice is made on its words, not on a summary written here.
| Skill | Reach for it instead when |
|---|---|
| `snappy-analytics` | Centralized analytics and metrics for the entire Snappy operating system. |
| `snappy-corpus` | The Krisp transcript corpus + nugget mining pipeline: import calls into the corpus, find quot… |
| `snappy-image` | Centralized image generation, editing, and capture for Snappy: Nano Banana / Gemini, Grok / x… |
| `snappy-os-operator` | Operate SnappyOS like a pro through product doors only: governed connector reads, staged writ… |
| `snappy-playbook` | WeTube SS mastermind 6-week curriculum source. |
| `snappy-voice-control` | Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Ag… |
| `snappy-walkthrough` | Recipe-driven capture and annotation of step-by-step tutorials. |
import assert from "node:assert/strict";
import test from "node:test";
import { HAND_CONTRACT, parseReadArgs } from "./api.ts";
import { exampleHazard } from "../snappy-tool-design/api.ts";
// R59 (2026-09-08): `read(path, offset?, limit?)` taught `read <path> 40` and
// filed the 40 as an offset. RED before the count became a flag.
test("read's first call carries no rule-59 hazard", async () => {
assert.equal(await exampleHazard("snappy-transcripts", "read"), null);
});
test("--limit 2 parses to limit 2, with and without an offset", () => {
const bare = parseReadArgs(["2026/09/call.txt", "--limit", "2"]);
assert.equal(bare.limit, 2);
assert.equal(bare.offset, 0);
assert.equal(bare.path, "2026/09/call.txt");
const withOffset = parseReadArgs(["2026/09/call.txt", "40", "--limit", "2"]);
assert.equal(withOffset.limit, 2);
assert.equal(withOffset.offset, 40);
});
test("a third positional is refused by name instead of being dropped", () => {
const parsed = parseReadArgs(["2026/09/call.txt", "40", "2"]);
assert.match(parsed.refusal ?? "", /the count is a flag: read 2026\/09\/call\.txt 40 --limit 2/);
});
test("the contract declares limit as a flag, never a positional", () => {
assert.deepEqual([...HAND_CONTRACT.verbs.read.args], ["path", "offset?"]);
assert.equal(HAND_CONTRACT.verbs.read.flags.limit, "--limit");
});
import assert from "node:assert/strict";
import test from "node:test";
import { HAND_CONTRACT, parseReadArgs } from "./api.ts";
import { exampleHazard } from "../snappy-tool-design/api.ts";
// R59 (2026-09-08): `read(path, offset?, limit?)` taught `read <path> 40` and
// filed the 40 as an offset. RED before the count became a flag.
test("read's first call carries no rule-59 hazard", async () => {
assert.equal(await exampleHazard("snappy-transcripts", "read"), null);
});
test("--limit 2 parses to limit 2, with and without an offset", () => {
const bare = parseReadArgs(["2026/09/call.txt", "--limit", "2"]);
assert.equal(bare.limit, 2);
assert.equal(bare.offset, 0);
assert.equal(bare.path, "2026/09/call.txt");
const withOffset = parseReadArgs(["2026/09/call.txt", "40", "--limit", "2"]);
assert.equal(withOffset.limit, 2);
assert.equal(withOffset.offset, 40);
});
test("a third positional is refused by name instead of being dropped", () => {
const parsed = parseReadArgs(["2026/09/call.txt", "40", "2"]);
assert.match(parsed.refusal ?? "", /the count is a flag: read 2026\/09\/call\.txt 40 --limit 2/);
});
test("the contract declares limit as a flag, never a positional", () => {
assert.deepEqual([...HAND_CONTRACT.verbs.read.args], ["path", "offset?"]);
assert.equal(HAND_CONTRACT.verbs.read.flags.limit, "--limit");
});
#!/usr/bin/env npx tsx
/**
* snappy-transcripts/api.ts -- Transcript retrieval for all snappy-* skills.
*
* Re-exports from snappy-corpus (the canonical corpus layer).
*
* Usage:
* npx tsx api.ts list # list all transcripts
* npx tsx api.ts list 2026 04 # list transcripts for April 2026
* npx tsx api.ts read <path> [offset] [--limit N] # read transcript in chunks (default 200)
* npx tsx api.ts search <query> # grep across all transcripts
*
* Or import as module:
* import { listTranscripts, readTranscript, searchCorpus } from "../snappy-transcripts/api.ts";
*/
// ONE ROAD, NOT TWO: this skill is a thin surface over snappy-corpus, so the
// `read` grammar (R59, 2026-09-08) is re-exported, never re-written. A second
// copy of a parser is the drift this collection bans by name.
export { listTranscripts, readTranscript, searchCorpus, parseReadArgs } from "../snappy-corpus/api.ts";
import { realpathSync } from "node:fs";
import { listTranscripts, parseReadArgs, readTranscript, searchCorpus } from "../snappy-corpus/api.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { boundRows, limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// --- CLI ---
// ⟨lane CONTRACTS, 2026-09-07⟩ realpathSync IS REQUIRED HERE. Every skill under
// ~/.claude/skills is a SYMLINK into the kernel repo, so `process.argv[1]` is the
// link and `import.meta.url` is its target: without resolving one to the other the
// guard is FALSE under the collection root and this file's whole CLI — including
// `contract` — silently answers nothing. Spec §2 rule 4 says so; five files had
// drifted from it. Measured: `api.ts list` printed nothing through the symlink.
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-transcripts",
description: "Transcript retrieval, search, and processing for Snappy. Krisp MCP is the primary source for every Zoom/Meet/Teams call (auto-transcribed, action items extracted). Whisper on the Mac Mini is the fallback for phone recordings, YouTube, and pre-Krisp archives. Powers pre-call briefs, post-call capture, testimonial quote mining, content idea extraction, and sales call review. Triggers on: transcript, transcripts, meeting notes, call recording, transcribe, what did we discuss, meeting summary, pull transcript, search transcripts, call notes, recording, krisp, meeting search, action items, quote mining, find quote, search calls, interview material, meeting context, call recap, what did they say, who said what, call moments, verbatim quote.",
managed: false,
requires: [] as string[],
refusals: refusalTable("missing_argument", "unknown_verb"),
verbs: {
list: {
args: ["year?","month?"], flags: { limit: "--limit" }, effect: "read", class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
limit: limitSchema(200, "How many transcripts to return, newest first"), year: { type: "string", description: "Four-digit year" }, month: { type: "string", description: "Two-digit month" } } },
},
read: {
args: ["path","offset?"], effect: "read", class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
flags: {"limit":"--limit"},
inputSchema: { properties: {
limit: limitSchema(2000, "How many lines to return", { default: 200 }), path: { type: "string", description: "Path to the transcript file, relative to the transcripts root" }, offset: { type: "integer", description: "Line offset to start reading from" } } },
},
search: {
args: ["query"], flags: { limit: "--limit" }, effect: "read", class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
limit: limitSchema(200, "How many matches to return"), query: { type: "string", description: "Search text" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "list": {
const bound = takeLimit(args, { maximum: 200 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exit(1); }
const now = new Date();
const year = bound.rest[0] || String(now.getFullYear());
const month = bound.rest[1] || String(now.getMonth() + 1).padStart(2, "0");
const all = listTranscripts(year, month);
const files = boundRows(all, bound.limit);
console.log(`${files.length} of ${all.length} transcripts:`);
for (const f of files) console.log(` ${f}`);
break;
}
case "read": {
const { path, offset, limit, refusal } = parseReadArgs(args);
if (!path) { console.error("Usage: api.ts read <path> [offset] [--limit N]"); process.exit(1); }
if (refusal) { console.error(refusal); process.exit(1); }
const result = readTranscript(path, offset, limit);
console.log(`Lines ${offset}-${offset + result.lines.length} of ${result.total}:`);
console.log(result.lines.join("\n"));
break;
}
case "search": {
const bound = takeLimit(args, { maximum: 200 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exit(1); }
const query = bound.rest.join(" ");
if (!query) { console.error("Usage: api.ts search <query> [--limit N]"); process.exit(1); }
const found = searchCorpus(query);
const results = boundRows(found, bound.limit);
console.log(`${results.length} of ${found.length} matches:`);
for (const r of results) {
console.log(` ${r.file}:${r.line}\t${r.text.slice(0, 120)}`);
}
break;
}
default:
console.log("Usage: npx tsx api.ts [list|read|search] ...");
}
})();
}
#!/usr/bin/env npx tsx
/**
* snappy-transcripts/api.ts -- Transcript retrieval for all snappy-* skills.
*
* Re-exports from snappy-corpus (the canonical corpus layer).
*
* Usage:
* npx tsx api.ts list # list all transcripts
* npx tsx api.ts list 2026 04 # list transcripts for April 2026
* npx tsx api.ts read <path> [offset] [--limit N] # read transcript in chunks (default 200)
* npx tsx api.ts search <query> # grep across all transcripts
*
* Or import as module:
* import { listTranscripts, readTranscript, searchCorpus } from "../snappy-transcripts/api.ts";
*/
// ONE ROAD, NOT TWO: this skill is a thin surface over snappy-corpus, so the
// `read` grammar (R59, 2026-09-08) is re-exported, never re-written. A second
// copy of a parser is the drift this collection bans by name.
export { listTranscripts, readTranscript, searchCorpus, parseReadArgs } from "../snappy-corpus/api.ts";
import { realpathSync } from "node:fs";
import { listTranscripts, parseReadArgs, readTranscript, searchCorpus } from "../snappy-corpus/api.ts";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { boundRows, limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
// --- CLI ---
// ⟨lane CONTRACTS, 2026-09-07⟩ realpathSync IS REQUIRED HERE. Every skill under
// ~/.claude/skills is a SYMLINK into the kernel repo, so `process.argv[1]` is the
// link and `import.meta.url` is its target: without resolving one to the other the
// guard is FALSE under the collection root and this file's whole CLI — including
// `contract` — silently answers nothing. Spec §2 rule 4 says so; five files had
// drifted from it. Measured: `api.ts list` printed nothing through the symlink.
/** WHAT THIS HAND ANSWERS, and what each verb does to the world.
* Derived from this file's own CLI dispatch by
* `snappy-hands/contract-derive.ts` — a verb the code does not implement is
* never declared here. Snappy's daemon reads it (`api.ts contract`) to
* validate every call, build the argument words in order, decide whether the
* act runs now or stages for the owner, and hand the child exactly the
* environment keys named in `requires` — never a value, never anything else.
*/
/** THE HOST-FACING FACTS ⟨lane CONTRACTS N–Z, 2026-09-09⟩. `class` is the
* closed effect set snappy-tool-design rule 18 grades; `annotations` are
* DERIVED from it by the ONE derivation in
* `snappy-settings/tool-annotations.ts`, never written per verb, so a class
* and its published hints cannot disagree; `refusals` projects the ONE closed
* table in `snappy-settings/refusal-codes.ts`; `requires` is exactly the
* credential keys this file's own executable reads name, and nothing else. */
export const HAND_CONTRACT = {
skill: "snappy-transcripts",
description: "Transcript retrieval, search, and processing for Snappy. Krisp MCP is the primary source for every Zoom/Meet/Teams call (auto-transcribed, action items extracted). Whisper on the Mac Mini is the fallback for phone recordings, YouTube, and pre-Krisp archives. Powers pre-call briefs, post-call capture, testimonial quote mining, content idea extraction, and sales call review. Triggers on: transcript, transcripts, meeting notes, call recording, transcribe, what did we discuss, meeting summary, pull transcript, search transcripts, call notes, recording, krisp, meeting search, action items, quote mining, find quote, search calls, interview material, meeting context, call recap, what did they say, who said what, call moments, verbatim quote.",
managed: false,
requires: [] as string[],
refusals: refusalTable("missing_argument", "unknown_verb"),
verbs: {
list: {
args: ["year?","month?"], flags: { limit: "--limit" }, effect: "read", class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
limit: limitSchema(200, "How many transcripts to return, newest first"), year: { type: "string", description: "Four-digit year" }, month: { type: "string", description: "Two-digit month" } } },
},
read: {
args: ["path","offset?"], effect: "read", class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
flags: {"limit":"--limit"},
inputSchema: { properties: {
limit: limitSchema(2000, "How many lines to return", { default: 200 }), path: { type: "string", description: "Path to the transcript file, relative to the transcripts root" }, offset: { type: "integer", description: "Line offset to start reading from" } } },
},
search: {
args: ["query"], flags: { limit: "--limit" }, effect: "read", class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
limit: limitSchema(200, "How many matches to return"), query: { type: "string", description: "Search text" } } },
},
},
} as const;
if (import.meta.url === `file://${realpathSync(process.argv[1])}` && process.argv[2] === "contract") {
console.log(JSON.stringify(HAND_CONTRACT, null, 2));
process.exit(0);
}
if (import.meta.url === `file://${realpathSync(process.argv[1])}`) {
(async () => {
const [, , cmd, ...args] = process.argv;
switch (cmd) {
case "list": {
const bound = takeLimit(args, { maximum: 200 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exit(1); }
const now = new Date();
const year = bound.rest[0] || String(now.getFullYear());
const month = bound.rest[1] || String(now.getMonth() + 1).padStart(2, "0");
const all = listTranscripts(year, month);
const files = boundRows(all, bound.limit);
console.log(`${files.length} of ${all.length} transcripts:`);
for (const f of files) console.log(` ${f}`);
break;
}
case "read": {
const { path, offset, limit, refusal } = parseReadArgs(args);
if (!path) { console.error("Usage: api.ts read <path> [offset] [--limit N]"); process.exit(1); }
if (refusal) { console.error(refusal); process.exit(1); }
const result = readTranscript(path, offset, limit);
console.log(`Lines ${offset}-${offset + result.lines.length} of ${result.total}:`);
console.log(result.lines.join("\n"));
break;
}
case "search": {
const bound = takeLimit(args, { maximum: 200 });
if (bound.refusal) { console.log(JSON.stringify(bound.refusal, null, 2)); process.exit(1); }
const query = bound.rest.join(" ");
if (!query) { console.error("Usage: api.ts search <query> [--limit N]"); process.exit(1); }
const found = searchCorpus(query);
const results = boundRows(found, bound.limit);
console.log(`${results.length} of ${found.length} matches:`);
for (const r of results) {
console.log(` ${r.file}:${r.line}\t${r.text.slice(0, 120)}`);
}
break;
}
default:
console.log("Usage: npx tsx api.ts [list|read|search] ...");
}
})();
}
/* components/transcript-faces.css — THE PASSAGE'S INK.
*
* Tokens at the family root ⟨owner order A9⟩. A passage is someone else's
* words, so the face is a quotation: a left rule, a serif-ish reading size, and
* the citation quiet underneath. Nothing here competes with the words. */
.tr-surface {
--tr-accent: oklch(0.5 0.11 62);
--tr-ink: oklch(0.22 0.01 80);
--tr-ink-dim: oklch(0.53 0.01 80);
--tr-line: oklch(0.91 0.006 80);
--tr-ground: oklch(0.985 0.006 80);
max-width: 640px;
border: 1px solid var(--tr-line);
border-radius: 12px;
background: oklch(1 0 0);
color: var(--tr-ink);
font-size: 15px;
line-height: 1.55;
overflow: hidden;
}
.tr-surface h2 { margin: 0; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; }
.tr-surface p { margin: 0; }
.tr-surface time { color: var(--tr-ink-dim); font-size: 12px; }
.tr-head { padding: 14px 16px; border-bottom: 1px solid var(--tr-line); background: var(--tr-ground); }
.tr-sub { color: var(--tr-ink-dim); font-size: 13px; }
.tr-quiet { padding: 16px; color: var(--tr-ink-dim); }
.tr-nugget { margin: 0; padding: 16px; }
.tr-quote {
margin: 0; padding-left: 14px; border-left: 3px solid var(--tr-accent);
font-size: 16px; line-height: 1.6; overflow-wrap: anywhere;
}
.tr-cite { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; margin-top: 10px; font-size: 12px; }
.tr-cite strong { font-weight: 620; font-size: 13px; }
.tr-source { color: var(--tr-accent); font-weight: 650; }
.tr-at { color: var(--tr-ink-dim); font-variant-numeric: tabular-nums; }
.tr-why { margin-top: 10px; color: var(--tr-ink-dim); font-size: 13px; }
.tr-hits__list { margin: 0; padding: 0; list-style: none; }
.tr-hits__list li { padding: 12px 16px; border-bottom: 1px solid var(--tr-line); }
.tr-hits__list li:last-child { border-bottom: none; }
.tr-hits__words { padding-left: 12px; border-left: 2px solid var(--tr-line); overflow-wrap: anywhere; }
.tr-hits__list .tr-cite { margin-top: 6px; padding-left: 12px; }
/* components/transcript-faces.css — THE PASSAGE'S INK.
*
* Tokens at the family root ⟨owner order A9⟩. A passage is someone else's
* words, so the face is a quotation: a left rule, a serif-ish reading size, and
* the citation quiet underneath. Nothing here competes with the words. */
.tr-surface {
--tr-accent: oklch(0.5 0.11 62);
--tr-ink: oklch(0.22 0.01 80);
--tr-ink-dim: oklch(0.53 0.01 80);
--tr-line: oklch(0.91 0.006 80);
--tr-ground: oklch(0.985 0.006 80);
max-width: 640px;
border: 1px solid var(--tr-line);
border-radius: 12px;
background: oklch(1 0 0);
color: var(--tr-ink);
font-size: 15px;
line-height: 1.55;
overflow: hidden;
}
.tr-surface h2 { margin: 0; font-size: 17px; font-weight: 650; letter-spacing: -0.01em; }
.tr-surface p { margin: 0; }
.tr-surface time { color: var(--tr-ink-dim); font-size: 12px; }
.tr-head { padding: 14px 16px; border-bottom: 1px solid var(--tr-line); background: var(--tr-ground); }
.tr-sub { color: var(--tr-ink-dim); font-size: 13px; }
.tr-quiet { padding: 16px; color: var(--tr-ink-dim); }
.tr-nugget { margin: 0; padding: 16px; }
.tr-quote {
margin: 0; padding-left: 14px; border-left: 3px solid var(--tr-accent);
font-size: 16px; line-height: 1.6; overflow-wrap: anywhere;
}
.tr-cite { display: flex; flex-wrap: wrap; align-items: baseline; gap: 8px; margin-top: 10px; font-size: 12px; }
.tr-cite strong { font-weight: 620; font-size: 13px; }
.tr-source { color: var(--tr-accent); font-weight: 650; }
.tr-at { color: var(--tr-ink-dim); font-variant-numeric: tabular-nums; }
.tr-why { margin-top: 10px; color: var(--tr-ink-dim); font-size: 13px; }
.tr-hits__list { margin: 0; padding: 0; list-style: none; }
.tr-hits__list li { padding: 12px 16px; border-bottom: 1px solid var(--tr-line); }
.tr-hits__list li:last-child { border-bottom: none; }
.tr-hits__words { padding-left: 12px; border-left: 2px solid var(--tr-line); overflow-wrap: anywhere; }
.tr-hits__list .tr-cite { margin-top: 6px; padding-left: 12px; }
// components/transcript-faces.tsx — A PASSAGE, AND WHERE IT CAME FROM.
//
// ⟨the owner, 2026-09-09 01:4x: "I want the damn internet in there"⟩
// `snappy-transcripts` and `snappy-corpus` read the same two shapes and the
// library drew neither: ONE PASSAGE worth keeping, and THE PASSAGES THAT
// MATCHED a search. So they are ONE FAMILY and not two — the reads share a
// shape, which is the test the brief set. A corpus nugget and a transcript
// excerpt differ in where they came from and in nothing else that a person
// reading them can see, and two families for one row would be two rows on the
// Platforms band for one idea ⟨CLAUDE.md §4⟩.
//
// AND IT IS NOT KRISP'S FAMILY EITHER, for the opposite reason: a Krisp hit is
// a SPEAKER at a CLOCK TIME inside a meeting, and this one is a DOCUMENT at a
// position. Those are different rows and they draw differently; `krisp-faces.tsx`
// says the same thing from its side.
//
// THE QUOTE IS THE FACE. The words are set as a quotation because that is what
// they are — someone else's, kept verbatim — and every arm carries the source
// line beneath them. A passage with no source has nowhere to draw.
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod";
import type { JSX } from "react";
import { rowPressProps } from "../../../snappy-faces/library/src/components/row-press.tsx";
import "./transcript-faces.css";
// ── ONE PASSAGE ─────────────────────────────────────────────────────────────
export interface TranscriptNuggetViewProps {
readonly words: string;
/** Where it came from: a document, a video, an episode, a meeting. */
readonly source: string;
/** Who said it, when a speaker was recorded. */
readonly speaker?: string | null;
/** WHERE INSIDE THE SOURCE — `12:04`, `p. 3`, `¶ 18`. The source's own
* spelling, never converted: a timecode turned into a paragraph number is a
* claim about a document nobody made. */
readonly at?: string | null;
readonly takenAt?: string | null;
/** Why this one was kept, in the person's own words. */
readonly why?: string | null;
}
export function TranscriptNuggetView(props: TranscriptNuggetViewProps): JSX.Element {
return (
<figure className="tr-surface tr-nugget" aria-label={props.source}>
<blockquote className="tr-quote" data-face-source={props.words}>{props.words}</blockquote>
<figcaption className="tr-cite">
{props.speaker == null ? null : <strong>{props.speaker}</strong>}
<span className="tr-source">{props.source}</span>
{props.at == null ? null : <span className="tr-at">{props.at}</span>}
{props.takenAt == null ? null : <time>{props.takenAt}</time>}
</figcaption>
{props.why == null ? null : <p className="tr-why">{props.why}</p>}
</figure>
);
}
// ── THE PASSAGES THAT MATCHED ───────────────────────────────────────────────
export interface TranscriptHitRow {
readonly words: string;
readonly source: string;
/** THE TRANSCRIPT FILE THIS PASSAGE CAME OUT OF, relative to the transcripts
* root — the word `snappy-transcripts read <path>` takes. `source` is the
* meeting's NAME and cannot address anything; without `path` a search result
* could not be opened. */
readonly path?: string | null;
readonly speaker?: string | null;
readonly at?: string | null;
}
export interface TranscriptHitsViewProps {
readonly hits?: readonly TranscriptHitRow[];
readonly query?: string | null;
readonly total?: number | null;
/** TWENTY, NOT THREE ⟨the owner, 2026-09-09 01:5x⟩. */
readonly clampAt?: number;
}
export function TranscriptHitsView(props: TranscriptHitsViewProps): JSX.Element {
const hits = (props.hits ?? []).slice(0, props.clampAt ?? 20);
const total = props.total ?? hits.length;
return (
<section className="tr-surface tr-hits" aria-label={props.query ?? "Passages"}>
<header className="tr-head">
<h2>{props.query == null ? "Passages" : `“${props.query}”`}</h2>
<p className="tr-sub">{total} {total === 1 ? "passage" : "passages"}</p>
</header>
{hits.length === 0
? <p className="tr-quiet">Nothing in the corpus says this.</p>
: <ul className="tr-hits__list">
{hits.map((hit, i) => (
// THE PASSAGE OPENS ITS TRANSCRIPT ⟨lane list-rows, 2026-09-09⟩:
// `snappy-transcripts read <path>`, a READ, drawn as
// `transcripts-nugget`.
<li key={i} {...rowPressProps("transcripts-hits", hit as unknown as Record<string, unknown>)}>
<p className="tr-hits__words">{hit.words}</p>
<p className="tr-cite">
{hit.speaker == null ? null : <strong>{hit.speaker}</strong>}
<span className="tr-source">{hit.source}</span>
{hit.at == null ? null : <span className="tr-at">{hit.at}</span>}
</p>
</li>
))}
</ul>}
</section>
);
}
// ── THE REGISTRATIONS ───────────────────────────────────────────────────────
export const TranscriptNuggetComponent = defineComponent({
name: "TranscriptNugget",
description: "USE FOR: 'the line worth keeping', 'quote that bit', one passage lifted out of a transcript, a document or an episode. Draws it as the quotation it is, with the source under it. Compact call: TranscriptNugget(words, source). source is REQUIRED: a passage nobody can place is not evidence. Positional after that: speaker, at (WHERE inside the source, in the source's own spelling — '12:04', 'p. 3', '¶ 18' — never converted), takenAt, why (why this one was kept, in the person's own words). For the passages a SEARCH returned use TranscriptHits.",
props: z.object({
words: z.string(),
source: z.string(),
speaker: z.string().nullish(),
at: z.string().nullish(),
takenAt: z.string().nullish(),
why: z.string().nullish(),
}),
component: ({ props }): JSX.Element => (
<TranscriptNuggetView
words={props.words} source={props.source} speaker={props.speaker}
at={props.at} takenAt={props.takenAt} why={props.why}
/>
),
});
export const TranscriptHitsComponent = defineComponent({
name: "TranscriptHits",
description: "USE FOR: 'search the transcripts', 'where did we say that', any snappy-corpus or snappy-transcripts search. Draws each matched passage with the source it came from, twenty by default. Compact call: TranscriptHits(hits, query) where hits is [{words, source, path?, speaker?, at?}]. source is REQUIRED on every row; PASS `path` too — the transcript file the passage came out of — and a row OPENS, running `snappy-transcripts read <path>` and drawing the passage in full as TranscriptNugget. Positional after query: total. For a passage out of a MEETING, with a speaker and a clock time, use KrispTranscriptHits.",
props: z.object({
hits: z.array(z.object({
words: z.string(), source: z.string(), path: z.string().nullish(), speaker: z.string().nullish(), at: z.string().nullish(),
})).nullish(),
query: z.string().nullish(),
total: z.number().nullish(),
}),
component: ({ props }): JSX.Element => (
<TranscriptHitsView hits={props.hits ?? undefined} query={props.query} total={props.total} />
),
});
// components/transcript-faces.tsx — A PASSAGE, AND WHERE IT CAME FROM.
//
// ⟨the owner, 2026-09-09 01:4x: "I want the damn internet in there"⟩
// `snappy-transcripts` and `snappy-corpus` read the same two shapes and the
// library drew neither: ONE PASSAGE worth keeping, and THE PASSAGES THAT
// MATCHED a search. So they are ONE FAMILY and not two — the reads share a
// shape, which is the test the brief set. A corpus nugget and a transcript
// excerpt differ in where they came from and in nothing else that a person
// reading them can see, and two families for one row would be two rows on the
// Platforms band for one idea ⟨CLAUDE.md §4⟩.
//
// AND IT IS NOT KRISP'S FAMILY EITHER, for the opposite reason: a Krisp hit is
// a SPEAKER at a CLOCK TIME inside a meeting, and this one is a DOCUMENT at a
// position. Those are different rows and they draw differently; `krisp-faces.tsx`
// says the same thing from its side.
//
// THE QUOTE IS THE FACE. The words are set as a quotation because that is what
// they are — someone else's, kept verbatim — and every arm carries the source
// line beneath them. A passage with no source has nowhere to draw.
import { defineComponent } from "@openuidev/react-lang";
import { z } from "zod";
import type { JSX } from "react";
import { rowPressProps } from "../../../snappy-faces/library/src/components/row-press.tsx";
import "./transcript-faces.css";
// ── ONE PASSAGE ─────────────────────────────────────────────────────────────
export interface TranscriptNuggetViewProps {
readonly words: string;
/** Where it came from: a document, a video, an episode, a meeting. */
readonly source: string;
/** Who said it, when a speaker was recorded. */
readonly speaker?: string | null;
/** WHERE INSIDE THE SOURCE — `12:04`, `p. 3`, `¶ 18`. The source's own
* spelling, never converted: a timecode turned into a paragraph number is a
* claim about a document nobody made. */
readonly at?: string | null;
readonly takenAt?: string | null;
/** Why this one was kept, in the person's own words. */
readonly why?: string | null;
}
export function TranscriptNuggetView(props: TranscriptNuggetViewProps): JSX.Element {
return (
<figure className="tr-surface tr-nugget" aria-label={props.source}>
<blockquote className="tr-quote" data-face-source={props.words}>{props.words}</blockquote>
<figcaption className="tr-cite">
{props.speaker == null ? null : <strong>{props.speaker}</strong>}
<span className="tr-source">{props.source}</span>
{props.at == null ? null : <span className="tr-at">{props.at}</span>}
{props.takenAt == null ? null : <time>{props.takenAt}</time>}
</figcaption>
{props.why == null ? null : <p className="tr-why">{props.why}</p>}
</figure>
);
}
// ── THE PASSAGES THAT MATCHED ───────────────────────────────────────────────
export interface TranscriptHitRow {
readonly words: string;
readonly source: string;
/** THE TRANSCRIPT FILE THIS PASSAGE CAME OUT OF, relative to the transcripts
* root — the word `snappy-transcripts read <path>` takes. `source` is the
* meeting's NAME and cannot address anything; without `path` a search result
* could not be opened. */
readonly path?: string | null;
readonly speaker?: string | null;
readonly at?: string | null;
}
export interface TranscriptHitsViewProps {
readonly hits?: readonly TranscriptHitRow[];
readonly query?: string | null;
readonly total?: number | null;
/** TWENTY, NOT THREE ⟨the owner, 2026-09-09 01:5x⟩. */
readonly clampAt?: number;
}
export function TranscriptHitsView(props: TranscriptHitsViewProps): JSX.Element {
const hits = (props.hits ?? []).slice(0, props.clampAt ?? 20);
const total = props.total ?? hits.length;
return (
<section className="tr-surface tr-hits" aria-label={props.query ?? "Passages"}>
<header className="tr-head">
<h2>{props.query == null ? "Passages" : `“${props.query}”`}</h2>
<p className="tr-sub">{total} {total === 1 ? "passage" : "passages"}</p>
</header>
{hits.length === 0
? <p className="tr-quiet">Nothing in the corpus says this.</p>
: <ul className="tr-hits__list">
{hits.map((hit, i) => (
// THE PASSAGE OPENS ITS TRANSCRIPT ⟨lane list-rows, 2026-09-09⟩:
// `snappy-transcripts read <path>`, a READ, drawn as
// `transcripts-nugget`.
<li key={i} {...rowPressProps("transcripts-hits", hit as unknown as Record<string, unknown>)}>
<p className="tr-hits__words">{hit.words}</p>
<p className="tr-cite">
{hit.speaker == null ? null : <strong>{hit.speaker}</strong>}
<span className="tr-source">{hit.source}</span>
{hit.at == null ? null : <span className="tr-at">{hit.at}</span>}
</p>
</li>
))}
</ul>}
</section>
);
}
// ── THE REGISTRATIONS ───────────────────────────────────────────────────────
export const TranscriptNuggetComponent = defineComponent({
name: "TranscriptNugget",
description: "USE FOR: 'the line worth keeping', 'quote that bit', one passage lifted out of a transcript, a document or an episode. Draws it as the quotation it is, with the source under it. Compact call: TranscriptNugget(words, source). source is REQUIRED: a passage nobody can place is not evidence. Positional after that: speaker, at (WHERE inside the source, in the source's own spelling — '12:04', 'p. 3', '¶ 18' — never converted), takenAt, why (why this one was kept, in the person's own words). For the passages a SEARCH returned use TranscriptHits.",
props: z.object({
words: z.string(),
source: z.string(),
speaker: z.string().nullish(),
at: z.string().nullish(),
takenAt: z.string().nullish(),
why: z.string().nullish(),
}),
component: ({ props }): JSX.Element => (
<TranscriptNuggetView
words={props.words} source={props.source} speaker={props.speaker}
at={props.at} takenAt={props.takenAt} why={props.why}
/>
),
});
export const TranscriptHitsComponent = defineComponent({
name: "TranscriptHits",
description: "USE FOR: 'search the transcripts', 'where did we say that', any snappy-corpus or snappy-transcripts search. Draws each matched passage with the source it came from, twenty by default. Compact call: TranscriptHits(hits, query) where hits is [{words, source, path?, speaker?, at?}]. source is REQUIRED on every row; PASS `path` too — the transcript file the passage came out of — and a row OPENS, running `snappy-transcripts read <path>` and drawing the passage in full as TranscriptNugget. Positional after query: total. For a passage out of a MEETING, with a speaker and a clock time, use KrispTranscriptHits.",
props: z.object({
hits: z.array(z.object({
words: z.string(), source: z.string(), path: z.string().nullish(), speaker: z.string().nullish(), at: z.string().nullish(),
})).nullish(),
query: z.string().nullish(),
total: z.number().nullish(),
}),
component: ({ props }): JSX.Element => (
<TranscriptHitsView hits={props.hits ?? undefined} query={props.query} total={props.total} />
),
});
/** families/transcripts.tsx — PASSAGES AND THEIR SOURCES, as its own chunk.
*
* ONE FAMILY FOR `snappy-transcripts` AND `snappy-corpus`, because the two
* reads answer the same two shapes — one passage worth keeping, and the
* passages a search matched ⟨`transcript-faces.tsx` states the measurement⟩. */
import type { FaceFamilyModule } from "../../snappy-faces/face-family.ts";
import {
TranscriptHitsView, TranscriptNuggetView,
} from "./components/transcript-faces.tsx";
export const FAMILY: FaceFamilyModule = {
slug: "transcripts",
mounts: {
"transcripts-nugget": TranscriptNuggetView,
"transcripts-hits": TranscriptHitsView,
},
};
/** families/transcripts.tsx — PASSAGES AND THEIR SOURCES, as its own chunk.
*
* ONE FAMILY FOR `snappy-transcripts` AND `snappy-corpus`, because the two
* reads answer the same two shapes — one passage worth keeping, and the
* passages a search matched ⟨`transcript-faces.tsx` states the measurement⟩. */
import type { FaceFamilyModule } from "../../snappy-faces/face-family.ts";
import {
TranscriptHitsView, TranscriptNuggetView,
} from "./components/transcript-faces.tsx";
export const FAMILY: FaceFamilyModule = {
slug: "transcripts",
mounts: {
"transcripts-nugget": TranscriptNuggetView,
"transcripts-hits": TranscriptHitsView,
},
};
{
"query": "batching the reads",
"total": 5,
"hits": [
{
"words": "We stopped batching the reads — the batch was hiding the one slow source.",
"source": "Northstar rollout — go / no-go",
"speaker": "Mara Quill",
"at": "12:04",
"path": "2026/09/northstar-rollout-go-no-go.md"
},
{
"words": "Batching was never for throughput here. It was for tidiness, and tidiness cost us three weeks of not knowing which source was slow.",
"source": "Harbourline weekly",
"speaker": "Tom Ferreira",
"at": "07:12",
"path": "2026/09/harbourline-weekly.md"
},
{
"words": "Once the reads were unbatched the fallback started naming the row, which is the actual fix people noticed.",
"source": "Import job: first clean pass",
"speaker": "Mara Quill",
"at": "¶ 3",
"path": "2026/09/import-job-first-clean-pass.md"
},
{
"words": "If we re-batch for the second pass we lose the thing that made the first one debuggable.",
"source": "Northstar rollout — go / no-go",
"speaker": "Priya Raman",
"at": "22:37",
"path": "2026/09/northstar-rollout-go-no-go.md"
}
]
}
{
"query": "batching the reads",
"total": 5,
"hits": [
{
"words": "We stopped batching the reads — the batch was hiding the one slow source.",
"source": "Northstar rollout — go / no-go",
"speaker": "Mara Quill",
"at": "12:04",
"path": "2026/09/northstar-rollout-go-no-go.md"
},
{
"words": "Batching was never for throughput here. It was for tidiness, and tidiness cost us three weeks of not knowing which source was slow.",
"source": "Harbourline weekly",
"speaker": "Tom Ferreira",
"at": "07:12",
"path": "2026/09/harbourline-weekly.md"
},
{
"words": "Once the reads were unbatched the fallback started naming the row, which is the actual fix people noticed.",
"source": "Import job: first clean pass",
"speaker": "Mara Quill",
"at": "¶ 3",
"path": "2026/09/import-job-first-clean-pass.md"
},
{
"words": "If we re-batch for the second pass we lose the thing that made the first one debuggable.",
"source": "Northstar rollout — go / no-go",
"speaker": "Priya Raman",
"at": "22:37",
"path": "2026/09/northstar-rollout-go-no-go.md"
}
]
}
{
"words": "The batch was hiding the one slow source. We stopped batching the reads and the whole thing got faster and, more to the point, legible — you can now see which source is the problem instead of watching a single number get worse.",
"source": "Northstar rollout — go / no-go",
"speaker": "Mara Quill",
"at": "12:04",
"takenAt": "Sep 8",
"why": "The clearest statement of why the batch came out. Worth quoting in the rollout note."
}
{
"words": "The batch was hiding the one slow source. We stopped batching the reads and the whole thing got faster and, more to the point, legible — you can now see which source is the problem instead of watching a single number get worse.",
"source": "Northstar rollout — go / no-go",
"speaker": "Mara Quill",
"at": "12:04",
"takenAt": "Sep 8",
"why": "The clearest statement of why the batch came out. Worth quoting in the rollout note."
}
Krisp is the primary live-meeting transcription source for Robert. Krisp records every Zoom/Meet/Teams call, generates transcripts, identifies action items, and exposes them via the claude.ai Krisp MCP server.
| Tool | Purpose | Use it when |
|---|---|---|
mcp__claude_ai_Krisp__search_meetings |
Full-text search across all past meetings | "What did we discuss about X?", "find calls with [name]" |
mcp__claude_ai_Krisp__list_action_items |
Pull action items extracted from meetings | Post-call capture, weekly review |
mcp__claude_ai_Krisp__list_upcoming_meetings |
Read calendar of upcoming Krisp-tracked meetings | Pre-call brief |
mcp__claude_ai_Krisp__list_activities |
Recent Krisp activity (transcripts, notes) | Daily catch-up |
mcp__claude_ai_Krisp__get_multiple_documents |
Pull full text of multiple meeting docs at once | Quote sourcing for testimonials, content mining |
mcp__claude_ai_Krisp__get_user_preferences |
User preferences (timezone, display) | Initial workspace context |
mcp__claude_ai_Krisp__date_time |
Current date/time in workspace timezone | Date math for "last week", "yesterday" |
These are exposed via the claude.ai Krisp MCP server in Robert's workspace.
| Source | Use Krisp | Use Whisper (Mac Mini) |
|---|---|---|
| Live Zoom/Meet/Teams meeting | ✅ Already recorded | -- |
| Phone call recording | -- | ✅ Local file |
| YouTube video | -- | ✅ Download via yt-dlp first |
| Old recording predating Krisp | -- | ✅ Local file via SKILL.md Local File Transcription |
| Need word-level timestamps for video captions | -- | ✅ Whisper word_timestamps |
| Need quick search across hundreds of meetings | ✅ MCP search_meetings |
-- |
| Need Krisp-extracted action items | ✅ list_action_items |
-- |
Default: try Krisp first. Fall back to Whisper only if Krisp didn't capture the source.
Full-text search across every meeting Krisp has transcribed.
mcp__claude_ai_Krisp__search_meetings({
query: "<keywords or contact name>"
})
Returns matching meeting documents with metadata (date, attendees, snippet around the match).
Use for:
query: "Jane Smith" to find every past call with herquery: "results", "outcomes", "transformation" to find quote-rich momentsquery: "agency model" to find every time Robert pitched the model livequery: "decision" to surface all decisions across meetingsPull structured action items extracted from meetings. Krisp's NLP identifies "I'll do X by Y" type statements.
mcp__claude_ai_Krisp__list_action_items({
filter: "<optional contact name or topic>"
})
Use for:
Forward-looking view of meetings Krisp will record.
mcp__claude_ai_Krisp__list_upcoming_meetings({})
Cross-reference with snappy-calendar events. snappy-ops uses this to know which morning meetings will produce auto-transcripts.
Recent Krisp activity feed.
mcp__claude_ai_Krisp__list_activities({})
Use for:
Pull the full text of multiple meeting documents at once. Critical for batch operations like testimonial sourcing.
mcp__claude_ai_Krisp__get_multiple_documents({
document_ids: ["<id1>", "<id2>", "<id3>"]
})
Use for:
Returns current date/time in Robert's workspace timezone. Use this for any date math (e.g., "last week").
mcp__claude_ai_Krisp__date_time({})
Returns Krisp workspace preferences (timezone, display settings). Call once at session start if you need to localize anything.
mcp__claude_ai_Krisp__search_meetings({ query: "Jane Smith" })
Cross-reference with snappy-knowledge contact record. Cite meeting dates in the call brief.
mcp__claude_ai_Krisp__search_meetings({
query: "amazing OR great OR incredible OR transformed OR helped me"
})
Then for each match, pull the surrounding context with get_multiple_documents and check if the speaker is a client (cross-reference with snappy-knowledge tag=client).
See quote-mining.md for the full testimonial sourcing recipe.
mcp__claude_ai_Krisp__search_meetings({
query: "decided OR agreed OR going to OR will do <topic>"
})
Used for accountability ("we decided to...") and for client weekly updates (snappy-update).
mcp__claude_ai_Krisp__search_meetings({
query: "concern OR worried OR not sure OR expensive OR think about it"
})
Feeds snappy-sales objection-handling library.
| ❌ WRONG | ✅ CORRECT |
|---|---|
| Defaulting to Whisper for any transcript request | Try Krisp search_meetings first -- it covers every Zoom/Meet/Teams call automatically |
| Asking the user to paste a Krisp URL | Krisp search returns the full document via MCP -- no URL paste needed |
Calling search_meetings with vague queries like "stuff" |
Use specific keywords: contact name, topic, or sentiment word |
| Pulling one document at a time in a loop | Use get_multiple_documents for batch retrieval |
| Treating Krisp action items as gospel | Confirm with the user before logging them as commitments -- Krisp's NLP misses context |
| Forgetting to cite the meeting date | Always include the meeting date and timestamp when surfacing a Krisp quote |
# Krisp MCP Integration
Krisp is the primary live-meeting transcription source for Robert. Krisp records every Zoom/Meet/Teams call, generates transcripts, identifies action items, and exposes them via the `claude.ai Krisp` MCP server.
## Table of Contents
- [Available Krisp MCP Tools](#available-krisp-mcp-tools)
- [When to Use Krisp vs Whisper](#when-to-use-krisp-vs-whisper)
- [Tool Reference](#tool-reference)
- [Search Recipes](#search-recipes)
- [What AI Agents Get Wrong](#what-ai-agents-get-wrong)
---
## Available Krisp MCP Tools
| Tool | Purpose | Use it when |
|------|---------|-------------|
| `mcp__claude_ai_Krisp__search_meetings` | Full-text search across all past meetings | "What did we discuss about X?", "find calls with [name]" |
| `mcp__claude_ai_Krisp__list_action_items` | Pull action items extracted from meetings | Post-call capture, weekly review |
| `mcp__claude_ai_Krisp__list_upcoming_meetings` | Read calendar of upcoming Krisp-tracked meetings | Pre-call brief |
| `mcp__claude_ai_Krisp__list_activities` | Recent Krisp activity (transcripts, notes) | Daily catch-up |
| `mcp__claude_ai_Krisp__get_multiple_documents` | Pull full text of multiple meeting docs at once | Quote sourcing for testimonials, content mining |
| `mcp__claude_ai_Krisp__get_user_preferences` | User preferences (timezone, display) | Initial workspace context |
| `mcp__claude_ai_Krisp__date_time` | Current date/time in workspace timezone | Date math for "last week", "yesterday" |
These are exposed via the `claude.ai Krisp` MCP server in Robert's workspace.
---
## When to Use Krisp vs Whisper
| Source | Use Krisp | Use Whisper (Mac Mini) |
|--------|-----------|------------------------|
| Live Zoom/Meet/Teams meeting | ✅ Already recorded | -- |
| Phone call recording | -- | ✅ Local file |
| YouTube video | -- | ✅ Download via yt-dlp first |
| Old recording predating Krisp | -- | ✅ Local file via [SKILL.md Local File Transcription](SKILL.md#local-file-transcription) |
| Need word-level timestamps for video captions | -- | ✅ Whisper word_timestamps |
| Need quick search across hundreds of meetings | ✅ MCP `search_meetings` | -- |
| Need Krisp-extracted action items | ✅ `list_action_items` | -- |
**Default**: try Krisp first. Fall back to Whisper only if Krisp didn't capture the source.
---
## Tool Reference
### search_meetings
Full-text search across every meeting Krisp has transcribed.
```
mcp__claude_ai_Krisp__search_meetings({
query: "<keywords or contact name>"
})
```
Returns matching meeting documents with metadata (date, attendees, snippet around the match).
**Use for:**
- Pre-call brief: `query: "Jane Smith"` to find every past call with her
- Testimonial sourcing: `query: "results", "outcomes", "transformation"` to find quote-rich moments
- Content mining: `query: "agency model"` to find every time Robert pitched the model live
- Knowledge audit: `query: "decision"` to surface all decisions across meetings
### list_action_items
Pull structured action items extracted from meetings. Krisp's NLP identifies "I'll do X by Y" type statements.
```
mcp__claude_ai_Krisp__list_action_items({
filter: "<optional contact name or topic>"
})
```
**Use for:**
- Post-call capture (workflow 2 in [snappy-knowledge/workflows.md](../snappy-knowledge/workflows.md#workflow-2-post-call-capture))
- Weekly review: who owes what to whom
- snappy-ops morning briefing: action items from yesterday's calls
### list_upcoming_meetings
Forward-looking view of meetings Krisp will record.
```
mcp__claude_ai_Krisp__list_upcoming_meetings({})
```
Cross-reference with `snappy-calendar` events. snappy-ops uses this to know which morning meetings will produce auto-transcripts.
### list_activities
Recent Krisp activity feed.
```
mcp__claude_ai_Krisp__list_activities({})
```
**Use for:**
- "What was that call I had this morning?" -- surfaces the most recent transcript
- Daily catch-up after travel
### get_multiple_documents
Pull the full text of multiple meeting documents at once. Critical for batch operations like testimonial sourcing.
```
mcp__claude_ai_Krisp__get_multiple_documents({
document_ids: ["<id1>", "<id2>", "<id3>"]
})
```
**Use for:**
- Testimonial pipeline: gather all Total CRM call transcripts to find quotes
- Content mining: pull a month of community calls for blog material
- Sales review: pull the last 3 sales calls to compare what worked
### date_time
Returns current date/time in Robert's workspace timezone. Use this for any date math (e.g., "last week").
```
mcp__claude_ai_Krisp__date_time({})
```
### get_user_preferences
Returns Krisp workspace preferences (timezone, display settings). Call once at session start if you need to localize anything.
---
## Search Recipes
### Find every call with a contact
```
mcp__claude_ai_Krisp__search_meetings({ query: "Jane Smith" })
```
Cross-reference with snappy-knowledge contact record. Cite meeting dates in the call brief.
### Find quote candidates for testimonials
```
mcp__claude_ai_Krisp__search_meetings({
query: "amazing OR great OR incredible OR transformed OR helped me"
})
```
Then for each match, pull the surrounding context with `get_multiple_documents` and check if the speaker is a client (cross-reference with snappy-knowledge `tag=client`).
See [quote-mining.md](quote-mining.md) for the full testimonial sourcing recipe.
### Find decisions made on a topic
```
mcp__claude_ai_Krisp__search_meetings({
query: "decided OR agreed OR going to OR will do <topic>"
})
```
Used for accountability ("we decided to...") and for client weekly updates (snappy-update).
### Find objections raised on sales calls
```
mcp__claude_ai_Krisp__search_meetings({
query: "concern OR worried OR not sure OR expensive OR think about it"
})
```
Feeds snappy-sales objection-handling library.
---
## What AI Agents Get Wrong
| ❌ WRONG | ✅ CORRECT |
|---------|-----------|
| Defaulting to Whisper for any transcript request | Try Krisp `search_meetings` first -- it covers every Zoom/Meet/Teams call automatically |
| Asking the user to paste a Krisp URL | Krisp search returns the full document via MCP -- no URL paste needed |
| Calling `search_meetings` with vague queries like "stuff" | Use specific keywords: contact name, topic, or sentiment word |
| Pulling one document at a time in a loop | Use `get_multiple_documents` for batch retrieval |
| Treating Krisp action items as gospel | Confirm with the user before logging them as commitments -- Krisp's NLP misses context |
| Forgetting to cite the meeting date | Always include the meeting date and timestamp when surfacing a Krisp quote |
This is the canonical recipe for extracting quote candidates from meeting transcripts. It is the primary input to the snappy-testimonials skill -- when Robert says "find quotes from clients" or snappy-testimonials activates, this is the workflow.
1. Define the quote target (whose, about what, what tone)
2. Search Krisp first (search_meetings with sentiment + topic keywords)
3. Pull surrounding context (get_multiple_documents for matched IDs)
4. Cross-reference speaker as a client (snappy-knowledge tag=client filter)
5. Hand off to snappy-testimonials (with citation + permission-request draft)
Total time: ~5 minutes for a single client, ~20 minutes for a batch.
Before searching, narrow the target. Three questions:
| Question | Why it matters |
|---|---|
| Whose quote? | A specific client, the active client roster, or any past client? |
| About what? | The transformation, the experience, a specific result, the relationship? |
| What tone? | Enthusiastic ("game changer"), specific outcome ("3x revenue"), human ("Robert just gets it") |
Examples of well-formed targets:
Anti-pattern: "Find me good quotes." Too vague -- search will return noise.
Krisp covers every Zoom/Meet/Teams call. Run search_meetings with sentiment + topic keywords from the target.
mcp__claude_ai_Krisp__search_meetings({
query: "Mark Orbiter pipeline data quality"
})
Returns matching meeting documents with metadata (date, attendees, snippet around the match).
mcp__claude_ai_Krisp__search_meetings({
query: "amazing OR great OR incredible OR transformed OR helped me OR game changer"
})
Then for each match, check the speaker (next step).
mcp__claude_ai_Krisp__search_meetings({
query: "saved time OR faster OR cleaner OR better OR before this we"
})
Best for case-study style quotes that describe the change.
mcp__claude_ai_Krisp__search_meetings({
query: "the way you do OR your approach OR how you think OR Robert just"
})
Best for quotes about Robert's process / philosophy.
A snippet from search_meetings is rarely enough. Pull the full meeting docs in batch:
mcp__claude_ai_Krisp__get_multiple_documents({
document_ids: ["<id1>", "<id2>", "<id3>"]
})
For each document:
A quote is only a testimonial if the speaker is a client. Cross-reference against snappy-knowledge:
bash# Credentials load from snappy-settings/.env.cache via env("KEY")
source ~/.claude/skills/snappy-settings/scripts/load-env.sh
# Pull active clients to filter against
curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_TOKEN" | jq '.[] | {name, email, company}'
# Pull past clients too (they can still give testimonials)
curl -s "$XANO/api:PB9UH7b9/contacts?tag=past_client" \
-H "Authorization: Bearer $XANO_TOKEN" | jq '.[] | {name, email, company}'
| Match type | Action |
|---|---|
| Krisp speaker name == knowledge contact name | Use the quote |
| Krisp identified the speaker by domain (e.g. mark@orbiter.com) | Match against contact email |
| Speaker not labeled but content references the company | Assume identity based on attendees in meeting metadata |
| Speaker is Robert | Discard -- we don't quote ourselves as a testimonial |
| Speaker is unknown / not a client | Skip -- can still mine for content (snappy-content) but not testimonials |
Once a quote candidate is verified, package it for snappy-testimonials:
yamlclient_name: Mark Lastname
client_company: Orbiter
client_email: mark@orbiter.com
client_id: 123 # snappy-knowledge contact ID
quote_text: "The QA endpoints completely changed how we think about data quality. Before this we were guessing -- now we know exactly which records are stuck and why."
context_before: "We were just talking about how Mark's team had been struggling with the stuck records issue for months."
context_after: "And then he told me the LLM Biography failure was the dominant problem, which we wouldn't have known without the diagnostics."
meeting_date: 2026-03-15
meeting_timestamp: "00:23:45"
krisp_meeting_id: "<id>"
sentiment: positive
themes: ["data quality", "diagnostics", "transformation"]
permission_status: pending
snappy-testimonials then drafts the permission request, queues it through snappy-email, and tracks the response.
Reusable Krisp queries for common quote categories:
| Quote category | Query |
|---|---|
| Transformation language | "transformed OR changed OR before this we OR now we" |
| Specific outcomes | "saved OR increased OR reduced OR faster OR better" |
| Relationship | "easy to work with OR responsive OR Robert just OR you guys" |
| Methodology | "your approach OR how you think OR the way you do" |
| Speed | "so fast OR overnight OR same day OR within a week" |
| Quality | "clean OR solid OR exactly what we needed OR right the first time" |
| Trust | "can rely on OR don't worry OR knew you would OR trust" |
| Recommendation | "would recommend OR told my friend OR refer OR sent them to you" |
Run these as separate queries -- combining too many keywords dilutes the relevance ranking.
Score each candidate 1-5 on three dimensions before passing to testimonials:
| Dimension | 1 (skip) | 3 (okay) | 5 (great) |
|---|---|---|---|
| Specificity | "It was good" | "It really helped us" | "Cut our QA time from 4 hours to 20 min" |
| Authenticity | Sounds rehearsed | Sounds natural | Robert can hear the client's voice |
| Standalone value | Needs 5 lines of setup | Some setup needed | Reads great with one line of context |
Use only quotes scoring 4+ on Authenticity and 4+ on at least one of the other two.
If a quote scores 3/3/3, it's noise. Skip it. Don't fill the testimonial pipeline with mediocre material.
When returning quote candidates to Robert (or to snappy-testimonials), use this format:
markdown## Quote Candidates -- [Search Target] -- [Date]
### Candidate 1 -- Mark @ Orbiter
**Date:** 2026-03-15 (Krisp meeting `<id>`, timestamp 00:23:45)
**Score:** Specificity 5 / Authenticity 5 / Standalone 4
**Theme:** Data quality transformation
> "The QA endpoints completely changed how we think about data quality. Before this we were guessing -- now we know exactly which records are stuck and why."
**Context:** We were just talking about how Mark's team had been struggling with the stuck records issue for months. Then he told me the LLM Biography failure was the dominant problem, which we wouldn't have known without the diagnostics.
**Permission status:** Not yet requested
**Suggested next step:** Hand to `snappy-testimonials` for permission email draft
---
### Candidate 2 -- [Next client]
...
If the source isn't in Krisp (older recording, phone call, in-person meeting), search the Mac Mini transcript archive instead. See whisper-pipeline.md#full-text-search-across-json-segments-with-timestamps for the JSON-segment search recipe -- it returns timestamped quotes the same way Krisp does.
When using Whisper-sourced quotes, cite the file name and timestamp in the same format:
Source: 2026-02-14_skool-call_robert-clients.json (timestamp 00:14:22)
Then store the audio file in the contact's interaction record as a follow-up so the quote can be retrieved during the permission flow.
| Wrong | Correct |
|---|---|
Returning every Krisp search_meetings hit as a quote candidate |
Filter by speaker = client AND quality rubric ≥ 4/4 |
| Quoting Robert as a testimonial source | Discard -- we never quote ourselves |
| Stripping the surrounding context | Always capture 2-3 sentences before/after for permission emails |
| Forgetting the timestamp | Cite meeting date + timestamp every time |
Logging quote candidates in snappy-knowledge.notes |
Hand to snappy-testimonials -- it owns the testimonial pipeline |
| Drafting the permission email yourself | snappy-testimonials owns permission flow -- pass the candidate, don't reach into email |
| Searching for vague keywords like "good" or "nice" | Use the Search Query Templates |
| Treating one match as enough | Aim for 3-5 candidates per target so the testimonial pipeline has options |
| Skipping the speaker cross-reference | A quote without a verified client speaker is just text -- not a testimonial |
| Pulling documents one at a time | Use get_multiple_documents with all matched IDs in a single call |
# Quote Mining -- Find Client Testimonial Material in Transcripts
This is the canonical recipe for extracting quote candidates from meeting transcripts. It is the primary input to the `snappy-testimonials` skill -- when Robert says "find quotes from clients" or `snappy-testimonials` activates, this is the workflow.
## Table of Contents
- [The Recipe (Five Steps)](#the-recipe-five-steps)
- [Step 1: Define the Quote Target](#step-1-define-the-quote-target)
- [Step 2: Search Krisp First](#step-2-search-krisp-first)
- [Step 3: Pull Surrounding Context](#step-3-pull-surrounding-context)
- [Step 4: Cross-Reference Speaker as a Client](#step-4-cross-reference-speaker-as-a-client)
- [Step 5: Hand Off to snappy-testimonials](#step-5-hand-off-to-snappy-testimonials)
- [Search Query Templates](#search-query-templates)
- [Quote Quality Rubric](#quote-quality-rubric)
- [Output Format](#output-format)
- [Whisper Fallback](#whisper-fallback)
- [What AI Agents Get Wrong](#what-ai-agents-get-wrong)
---
## The Recipe (Five Steps)
```
1. Define the quote target (whose, about what, what tone)
2. Search Krisp first (search_meetings with sentiment + topic keywords)
3. Pull surrounding context (get_multiple_documents for matched IDs)
4. Cross-reference speaker as a client (snappy-knowledge tag=client filter)
5. Hand off to snappy-testimonials (with citation + permission-request draft)
```
Total time: ~5 minutes for a single client, ~20 minutes for a batch.
---
## Step 1: Define the Quote Target
Before searching, narrow the target. Three questions:
| Question | Why it matters |
|----------|---------------|
| **Whose quote?** | A specific client, the active client roster, or any past client? |
| **About what?** | The transformation, the experience, a specific result, the relationship? |
| **What tone?** | Enthusiastic ("game changer"), specific outcome ("3x revenue"), human ("Robert just gets it") |
**Examples of well-formed targets:**
- "Quotes from Mark at Orbiter about how the QA pipeline improved data quality"
- "Quotes from any active client about working with Robert specifically"
- "Quotes about the mastermind community from Skool members"
- "Quotes about the Snappy methodology vs traditional consulting"
**Anti-pattern:** "Find me good quotes." Too vague -- search will return noise.
---
## Step 2: Search Krisp First
Krisp covers every Zoom/Meet/Teams call. Run `search_meetings` with sentiment + topic keywords from the target.
### Single-client search
```
mcp__claude_ai_Krisp__search_meetings({
query: "Mark Orbiter pipeline data quality"
})
```
Returns matching meeting documents with metadata (date, attendees, snippet around the match).
### Multi-keyword sentiment search
```
mcp__claude_ai_Krisp__search_meetings({
query: "amazing OR great OR incredible OR transformed OR helped me OR game changer"
})
```
Then for each match, check the speaker (next step).
### Outcome-focused search
```
mcp__claude_ai_Krisp__search_meetings({
query: "saved time OR faster OR cleaner OR better OR before this we"
})
```
Best for case-study style quotes that describe the *change*.
### Methodology search
```
mcp__claude_ai_Krisp__search_meetings({
query: "the way you do OR your approach OR how you think OR Robert just"
})
```
Best for quotes about Robert's process / philosophy.
---
## Step 3: Pull Surrounding Context
A snippet from `search_meetings` is rarely enough. Pull the full meeting docs in batch:
```
mcp__claude_ai_Krisp__get_multiple_documents({
document_ids: ["<id1>", "<id2>", "<id3>"]
})
```
For each document:
1. Locate the matching passage (search by your sentiment keyword)
2. Capture 2-3 sentences before and after (so the quote stands alone in context)
3. Note the speaker (Krisp identifies most speakers; if not, make a best guess from cadence + content)
4. Note the timestamp (cite this in the testimonial)
5. Note the meeting date (cite this in the permission request)
---
## Step 4: Cross-Reference Speaker as a Client
A quote is only a testimonial if the speaker is a client. Cross-reference against `snappy-knowledge`:
```bash
# Credentials load from snappy-settings/.env.cache via env("KEY")
source ~/.claude/skills/snappy-settings/scripts/load-env.sh
# Pull active clients to filter against
curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_TOKEN" | jq '.[] | {name, email, company}'
# Pull past clients too (they can still give testimonials)
curl -s "$XANO/api:PB9UH7b9/contacts?tag=past_client" \
-H "Authorization: Bearer $XANO_TOKEN" | jq '.[] | {name, email, company}'
```
### Match the speaker to a contact
| Match type | Action |
|-----------|--------|
| Krisp speaker name == knowledge contact name | Use the quote |
| Krisp identified the speaker by domain (e.g. mark@orbiter.com) | Match against contact email |
| Speaker not labeled but content references the company | Assume identity based on attendees in meeting metadata |
| Speaker is Robert | Discard -- we don't quote ourselves as a testimonial |
| Speaker is unknown / not a client | Skip -- can still mine for content (snappy-content) but not testimonials |
---
## Step 5: Hand Off to snappy-testimonials
Once a quote candidate is verified, package it for `snappy-testimonials`:
```yaml
client_name: Mark Lastname
client_company: Orbiter
client_email: mark@orbiter.com
client_id: 123 # snappy-knowledge contact ID
quote_text: "The QA endpoints completely changed how we think about data quality. Before this we were guessing -- now we know exactly which records are stuck and why."
context_before: "We were just talking about how Mark's team had been struggling with the stuck records issue for months."
context_after: "And then he told me the LLM Biography failure was the dominant problem, which we wouldn't have known without the diagnostics."
meeting_date: 2026-03-15
meeting_timestamp: "00:23:45"
krisp_meeting_id: "<id>"
sentiment: positive
themes: ["data quality", "diagnostics", "transformation"]
permission_status: pending
```
`snappy-testimonials` then drafts the permission request, queues it through `snappy-email`, and tracks the response.
---
## Search Query Templates
Reusable Krisp queries for common quote categories:
| Quote category | Query |
|---------------|-------|
| Transformation language | `"transformed OR changed OR before this we OR now we"` |
| Specific outcomes | `"saved OR increased OR reduced OR faster OR better"` |
| Relationship | `"easy to work with OR responsive OR Robert just OR you guys"` |
| Methodology | `"your approach OR how you think OR the way you do"` |
| Speed | `"so fast OR overnight OR same day OR within a week"` |
| Quality | `"clean OR solid OR exactly what we needed OR right the first time"` |
| Trust | `"can rely on OR don't worry OR knew you would OR trust"` |
| Recommendation | `"would recommend OR told my friend OR refer OR sent them to you"` |
Run these as separate queries -- combining too many keywords dilutes the relevance ranking.
---
## Quote Quality Rubric
Score each candidate 1-5 on three dimensions before passing to testimonials:
| Dimension | 1 (skip) | 3 (okay) | 5 (great) |
|-----------|----------|----------|-----------|
| **Specificity** | "It was good" | "It really helped us" | "Cut our QA time from 4 hours to 20 min" |
| **Authenticity** | Sounds rehearsed | Sounds natural | Robert can hear the client's voice |
| **Standalone value** | Needs 5 lines of setup | Some setup needed | Reads great with one line of context |
**Use only quotes scoring 4+ on Authenticity and 4+ on at least one of the other two.**
If a quote scores 3/3/3, it's noise. Skip it. Don't fill the testimonial pipeline with mediocre material.
---
## Output Format
When returning quote candidates to Robert (or to `snappy-testimonials`), use this format:
```markdown
## Quote Candidates -- [Search Target] -- [Date]
### Candidate 1 -- Mark @ Orbiter
**Date:** 2026-03-15 (Krisp meeting `<id>`, timestamp 00:23:45)
**Score:** Specificity 5 / Authenticity 5 / Standalone 4
**Theme:** Data quality transformation
> "The QA endpoints completely changed how we think about data quality. Before this we were guessing -- now we know exactly which records are stuck and why."
**Context:** We were just talking about how Mark's team had been struggling with the stuck records issue for months. Then he told me the LLM Biography failure was the dominant problem, which we wouldn't have known without the diagnostics.
**Permission status:** Not yet requested
**Suggested next step:** Hand to `snappy-testimonials` for permission email draft
---
### Candidate 2 -- [Next client]
...
```
---
## Whisper Fallback
If the source isn't in Krisp (older recording, phone call, in-person meeting), search the Mac Mini transcript archive instead. See [whisper-pipeline.md#full-text-search-across-json-segments-with-timestamps](whisper-pipeline.md#search-stored-transcripts) for the JSON-segment search recipe -- it returns timestamped quotes the same way Krisp does.
When using Whisper-sourced quotes, cite the file name and timestamp in the same format:
```
Source: 2026-02-14_skool-call_robert-clients.json (timestamp 00:14:22)
```
Then store the audio file in the contact's interaction record as a follow-up so the quote can be retrieved during the permission flow.
---
## What AI Agents Get Wrong
| Wrong | Correct |
|-------|---------|
| Returning every Krisp `search_meetings` hit as a quote candidate | Filter by speaker = client AND quality rubric ≥ 4/4 |
| Quoting Robert as a testimonial source | Discard -- we never quote ourselves |
| Stripping the surrounding context | Always capture 2-3 sentences before/after for permission emails |
| Forgetting the timestamp | Cite meeting date + timestamp every time |
| Logging quote candidates in `snappy-knowledge.notes` | Hand to `snappy-testimonials` -- it owns the testimonial pipeline |
| Drafting the permission email yourself | `snappy-testimonials` owns permission flow -- pass the candidate, don't reach into email |
| Searching for vague keywords like "good" or "nice" | Use the [Search Query Templates](#search-query-templates) |
| Treating one match as enough | Aim for 3-5 candidates per target so the testimonial pipeline has options |
| Skipping the speaker cross-reference | A quote without a verified client speaker is just text -- not a testimonial |
| Pulling documents one at a time | Use `get_multiple_documents` with all matched IDs in a single call |
/**
* COVERAGE FOR SNAPPY-TRANSCRIPTS'S DECLARED REFUSAL CODES
* (snappy-tool-design rule 33: "refusal codes form one closed table and each
* row has coverage").
*
* Two things are graded here, and the second is the one that matters. The
* first is that the hand's table is a PROJECTION of the collection's one
* closed table in snappy-settings/refusal-codes.ts — same object, not a copy
* that can drift. The second is that every declared code is GROUNDED: the
* evidence that justified declaring it is re-checked here, because a refusal
* code with no path that emits it is a branch the reader waits for and never
* sees, and a table of those passes a lint while teaching a lie.
*
* The code list is spelled out rather than read from the contract: a test that
* iterates the thing it grades passes for an empty table.
*/
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES } from "../snappy-settings/refusal-codes.ts";
const SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "api.ts"), "utf8");
/** Every refusal code snappy-transcripts declares. */
const DECLARED = [
"missing_argument",
"unknown_verb",
] as const;
test("snappy-transcripts declares exactly these refusal codes", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals).sort(), [...DECLARED].sort());
});
test("every declared code is the SAME row as the one closed table's, never a copy", () => {
const table = HAND_CONTRACT.refusals as Record<string, unknown>;
for (const code of DECLARED) {
assert.equal(table[code], REFUSAL_CODES[code], `${code} is not the shared row`);
}
});
test("missing_argument is grounded: at least one verb has a required word", () => {
const required = Object.values(HAND_CONTRACT.verbs as Record<string, { args?: readonly string[] }>)
.flatMap((v) => (v.args ?? []).filter((a) => !a.endsWith("?")));
assert.ok(required.length > 0, "no verb has a required argument, so missing_argument can never fire");
});
test("unknown_verb is grounded: the contract closes the verb set, so a word outside it is refusable", () => {
assert.ok(Object.keys(HAND_CONTRACT.verbs).length >= 0);
assert.ok(!Object.keys(HAND_CONTRACT.verbs).includes("no-such-verb"));
});
/**
* COVERAGE FOR SNAPPY-TRANSCRIPTS'S DECLARED REFUSAL CODES
* (snappy-tool-design rule 33: "refusal codes form one closed table and each
* row has coverage").
*
* Two things are graded here, and the second is the one that matters. The
* first is that the hand's table is a PROJECTION of the collection's one
* closed table in snappy-settings/refusal-codes.ts — same object, not a copy
* that can drift. The second is that every declared code is GROUNDED: the
* evidence that justified declaring it is re-checked here, because a refusal
* code with no path that emits it is a branch the reader waits for and never
* sees, and a table of those passes a lint while teaching a lie.
*
* The code list is spelled out rather than read from the contract: a test that
* iterates the thing it grades passes for an empty table.
*/
import { strict as assert } from "node:assert";
import { test } from "node:test";
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { HAND_CONTRACT } from "./api.ts";
import { REFUSAL_CODES } from "../snappy-settings/refusal-codes.ts";
const SOURCE = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "api.ts"), "utf8");
/** Every refusal code snappy-transcripts declares. */
const DECLARED = [
"missing_argument",
"unknown_verb",
] as const;
test("snappy-transcripts declares exactly these refusal codes", () => {
assert.deepEqual(Object.keys(HAND_CONTRACT.refusals).sort(), [...DECLARED].sort());
});
test("every declared code is the SAME row as the one closed table's, never a copy", () => {
const table = HAND_CONTRACT.refusals as Record<string, unknown>;
for (const code of DECLARED) {
assert.equal(table[code], REFUSAL_CODES[code], `${code} is not the shared row`);
}
});
test("missing_argument is grounded: at least one verb has a required word", () => {
const required = Object.values(HAND_CONTRACT.verbs as Record<string, { args?: readonly string[] }>)
.flatMap((v) => (v.args ?? []).filter((a) => !a.endsWith("?")));
assert.ok(required.length > 0, "no verb has a required argument, so missing_argument can never fire");
});
test("unknown_verb is grounded: the contract closes the verb set, so a word outside it is refusable", () => {
assert.ok(Object.keys(HAND_CONTRACT.verbs).length >= 0);
assert.ok(!Object.keys(HAND_CONTRACT.verbs).includes("no-such-verb"));
});
How to review a sales call transcript and feed coaching back to snappy-sales. This is the recipe for "review yesterday's sales call" or "what went wrong on the Acme call."
Triggered when Robert says any of:
This recipe sits between snappy-transcripts (which fetches the call) and snappy-sales (which owns the objection-handling library, follow-up sequences, and coaching loop). Don't write into snappy-sales directly -- produce the review and hand it off.
Every sales call review returns the same six sections in the same order:
1. Outcome -- what actually happened (yes / no / TBD)
2. Commitment Level -- 1-5 rating of where the prospect ended up
3. Objections Raised -- every objection + how it was handled
4. Pitch Quality -- what landed, what didn't (with timestamps)
5. Missed Opportunities -- moments Robert could have closed but didn't
6. Next Action -- single concrete next step
A one-line factual statement of the result. No interpretation.
Examples:
If the outcome is TBD, name the unresolved question that's blocking commitment.
Rate where the prospect ended on a 1-5 scale. This is the single most important number for forecast accuracy.
| Score | Meaning | Indicator language |
|---|---|---|
| 1 | Cold -- not a fit, polite no | "We're not in a buying cycle", "interesting but not for us" |
| 2 | Warm interest, no urgency | "Let me think about it", "I'll loop back" |
| 3 | Engaged, considering | "I want to discuss with my partner", "send me the proposal" |
| 4 | Committed pending logistics | "I'm in -- what's the next step", "send the invoice" |
| 5 | Closed | Verbal yes, signed agreement, or payment scheduled |
Cite the timestamp of the moment that determined the score. If the prospect went from a 2 to a 4 mid-call, note both -- that's a coaching insight.
Every objection in the call gets logged here, with how it was handled. This feeds the snappy-sales objection library.
| Field | Format |
|---|---|
| Objection | Verbatim quote |
| Category | Price / Time / Trust / Fit / Authority / Urgency |
| Timestamp | When it was raised |
| Robert's response | What he said back |
| Outcome | Resolved / Deflected / Conceded / Lingering |
Example:
markdown- **Objection**: "$25k feels steep for what we're getting."
- Category: Price
- Timestamp: 00:34:21
- Robert's response: Reframed to ROI (3 closed deals would 5x the investment)
- Outcome: Resolved -- prospect said "okay that makes sense" at 00:35:08
If an objection was raised but never addressed, mark outcome: lingering and surface it in Section 5 (Missed Opportunities).
| Category | Examples | Best response (Robert's playbook) |
|---|---|---|
| Price | "Too expensive", "out of budget" | ROI reframe -- what 1 deal is worth |
| Time | "Don't have bandwidth right now" | Show that the program gives back time |
| Trust | "How do I know it'll work?" | Case studies + the no-result refund |
| Fit | "We're not in [vertical]" | Adjacent client examples |
| Authority | "I need to talk to my partner" | Offer a 15-min joint call |
| Urgency | "Maybe next quarter" | Cohort start date scarcity |
What landed, what didn't. Two sub-blocks:
Landed well:
Didn't land:
Example:
markdown**Landed well:**
- 00:18:42 -- Robert told the Mark/Orbiter story (raw JSON to clean enrichment in 2 weeks)
- Why: Specific, time-bound, named outcome, prospect leaned in and asked follow-up
**Didn't land:**
- 00:27:15 -- Robert spent 4 minutes explaining the technical stack
- Why: Prospect went silent, energy dropped, asked unrelated question right after
Moments where Robert could have closed (or advanced) and didn't. These are the highest-value coaching insights.
| Field | Format |
|---|---|
| Moment | Timestamp + what happened |
| What Robert did | The actual response |
| What he could have done | The better play |
| Why it matters | Forecast impact / cohort fit / referral potential |
Example:
markdown- **Moment**: 00:42:11 -- Prospect said "I love what you're doing with Mark"
- What Robert did: Said "thanks!" and moved on
- Could have done: "What part of Mark's story resonates? Is that the kind of result you'd want?" (deepens the buying signal)
- Why it matters: Buying signals get lost when they're not honored -- this was a 3 → 4 commitment moment
One concrete next step. Not a list of options. Not "follow up sometime." A specific action with an owner and a date.
Examples:
This action gets handed back to snappy-sales, which dispatches the actual follow-up via snappy-email / snappy-slack / snappy-calendar.
After the 6-section review is complete, package the coaching insights for snappy-sales:
yamlcall_id: <krisp_doc_id_or_whisper_filename>
prospect_name: <name>
prospect_company: <company>
date: <YYYY-MM-DD>
outcome: <closed | no_go | tbd | ghost>
commitment_level: 1-5
objections:
- text: "<verbatim>"
category: <price | time | trust | fit | authority | urgency>
handled: <resolved | deflected | conceded | lingering>
landed_well_count: <int>
didnt_land_count: <int>
missed_opportunities_count: <int>
next_action: "<one sentence>"
next_action_owner: <robert | prospect | both>
next_action_due: <YYYY-MM-DD>
coaching_themes: ["<theme1>", "<theme2>"]
snappy-sales then:
Useful Krisp queries for finding past sales calls and patterns.
mcp__claude_ai_Krisp__search_meetings({
query: "mastermind OR cohort OR program OR proposal OR pricing"
})
mcp__claude_ai_Krisp__search_meetings({
query: "expensive OR budget OR cost OR price OR investment"
})
mcp__claude_ai_Krisp__search_meetings({
query: "send me OR loop me in OR I'll get back OR talk to my partner"
})
mcp__claude_ai_Krisp__search_meetings({
query: "I'm in OR sign me up OR send the invoice OR let's do it"
})
mcp__claude_ai_Krisp__list_activities({})
# then look for prospects with single-call records and no follow-ups
| Wrong | Correct |
|---|---|
| Reviewing the call for "tone" or "vibes" | Stick to the 6 sections -- outcome, commitment, objections, pitch quality, missed opportunities, next action |
| Logging objections without categorization | Every objection gets a category from the table -- that's how the library stays searchable |
| Saying "Robert did great" | Specific moments with timestamps. "Great" isn't actionable feedback |
| Treating commitment level as a guess | Cite the moment that determined the score -- coaching needs evidence |
| Producing a list of "next actions" | One concrete action with owner and date -- sales reps need clarity, not options |
Reaching into snappy-sales to log the call |
Hand the YAML payload off -- snappy-sales owns its own pipeline schema |
| Forgetting to flag lingering objections | Lingering objections are the #1 reason deals stall -- surface them in Missed Opportunities |
| Reviewing without the transcript | Always pull the full Krisp doc or Whisper JSON first -- coaching from memory is unreliable |
| Combining multiple calls into one review | One review per call. Patterns across calls go to snappy-sales weekly review, not the per-call file |
| Skipping the buying signal moments | Buying signals (Section 5) are gold -- they're where the deal turned, won or lost |
# Sales Call Review
How to review a sales call transcript and feed coaching back to `snappy-sales`. This is the recipe for "review yesterday's sales call" or "what went wrong on the Acme call."
## Table of Contents
- [When to Use This Recipe](#when-to-use-this-recipe)
- [The Six-Section Review Format](#the-six-section-review-format)
- [Section 1: Outcome](#section-1-outcome)
- [Section 2: Commitment Level](#section-2-commitment-level)
- [Section 3: Objections Raised](#section-3-objections-raised)
- [Section 4: Pitch Quality](#section-4-pitch-quality)
- [Section 5: Missed Opportunities](#section-5-missed-opportunities)
- [Section 6: Next Action](#section-6-next-action)
- [Coaching Hand-Off](#coaching-hand-off)
- [Search Patterns for Sales Moments](#search-patterns-for-sales-moments)
- [What AI Agents Get Wrong](#what-ai-agents-get-wrong)
---
## When to Use This Recipe
Triggered when Robert says any of:
- "Review yesterday's sales call"
- "What went wrong on the [name] call?"
- "Coach me on the [name] pitch"
- "Did I handle [objection] well?"
- "Pull the last 3 sales calls and find the patterns"
This recipe sits *between* `snappy-transcripts` (which fetches the call) and `snappy-sales` (which owns the objection-handling library, follow-up sequences, and coaching loop). Don't write into `snappy-sales` directly -- produce the review and hand it off.
---
## The Six-Section Review Format
Every sales call review returns the same six sections in the same order:
```
1. Outcome -- what actually happened (yes / no / TBD)
2. Commitment Level -- 1-5 rating of where the prospect ended up
3. Objections Raised -- every objection + how it was handled
4. Pitch Quality -- what landed, what didn't (with timestamps)
5. Missed Opportunities -- moments Robert could have closed but didn't
6. Next Action -- single concrete next step
```
---
## Section 1: Outcome
A one-line factual statement of the result. No interpretation.
**Examples:**
- "Closed -- Acme signed up for the $25k mastermind cohort starting May 1."
- "No-go -- prospect cited budget concerns and asked to revisit Q3."
- "TBD -- prospect wants to bring partner to a follow-up call next week."
- "Ghost -- prospect didn't show, no response to reschedule message."
If the outcome is TBD, name the unresolved question that's blocking commitment.
---
## Section 2: Commitment Level
Rate where the prospect ended on a 1-5 scale. This is the single most important number for forecast accuracy.
| Score | Meaning | Indicator language |
|-------|---------|-------------------|
| 1 | Cold -- not a fit, polite no | "We're not in a buying cycle", "interesting but not for us" |
| 2 | Warm interest, no urgency | "Let me think about it", "I'll loop back" |
| 3 | Engaged, considering | "I want to discuss with my partner", "send me the proposal" |
| 4 | Committed pending logistics | "I'm in -- what's the next step", "send the invoice" |
| 5 | Closed | Verbal yes, signed agreement, or payment scheduled |
**Cite the timestamp of the moment that determined the score.** If the prospect went from a 2 to a 4 mid-call, note both -- that's a coaching insight.
---
## Section 3: Objections Raised
Every objection in the call gets logged here, with how it was handled. This feeds the `snappy-sales` objection library.
| Field | Format |
|-------|--------|
| Objection | Verbatim quote |
| Category | Price / Time / Trust / Fit / Authority / Urgency |
| Timestamp | When it was raised |
| Robert's response | What he said back |
| Outcome | Resolved / Deflected / Conceded / Lingering |
**Example:**
```markdown
- **Objection**: "$25k feels steep for what we're getting."
- Category: Price
- Timestamp: 00:34:21
- Robert's response: Reframed to ROI (3 closed deals would 5x the investment)
- Outcome: Resolved -- prospect said "okay that makes sense" at 00:35:08
```
If an objection was raised but never addressed, mark `outcome: lingering` and surface it in Section 5 (Missed Opportunities).
### Common Snappy objection categories
| Category | Examples | Best response (Robert's playbook) |
|----------|----------|----------------------------------|
| Price | "Too expensive", "out of budget" | ROI reframe -- what 1 deal is worth |
| Time | "Don't have bandwidth right now" | Show that the program *gives back* time |
| Trust | "How do I know it'll work?" | Case studies + the no-result refund |
| Fit | "We're not in [vertical]" | Adjacent client examples |
| Authority | "I need to talk to my partner" | Offer a 15-min joint call |
| Urgency | "Maybe next quarter" | Cohort start date scarcity |
---
## Section 4: Pitch Quality
What landed, what didn't. Two sub-blocks:
**Landed well:**
- Specific moment (with timestamp) where Robert said something that visibly moved the prospect -- energy shift, follow-up question, "tell me more about that"
- Why it landed (specific, story-based, addressed their fear, etc.)
**Didn't land:**
- Specific moment where Robert lost the room -- long monologue, jargon, talking past the prospect
- Why it missed
**Example:**
```markdown
**Landed well:**
- 00:18:42 -- Robert told the Mark/Orbiter story (raw JSON to clean enrichment in 2 weeks)
- Why: Specific, time-bound, named outcome, prospect leaned in and asked follow-up
**Didn't land:**
- 00:27:15 -- Robert spent 4 minutes explaining the technical stack
- Why: Prospect went silent, energy dropped, asked unrelated question right after
```
---
## Section 5: Missed Opportunities
Moments where Robert could have closed (or advanced) and didn't. These are the highest-value coaching insights.
| Field | Format |
|-------|--------|
| Moment | Timestamp + what happened |
| What Robert did | The actual response |
| What he could have done | The better play |
| Why it matters | Forecast impact / cohort fit / referral potential |
**Example:**
```markdown
- **Moment**: 00:42:11 -- Prospect said "I love what you're doing with Mark"
- What Robert did: Said "thanks!" and moved on
- Could have done: "What part of Mark's story resonates? Is that the kind of result you'd want?" (deepens the buying signal)
- Why it matters: Buying signals get lost when they're not honored -- this was a 3 → 4 commitment moment
```
---
## Section 6: Next Action
One concrete next step. Not a list of options. Not "follow up sometime." A specific action with an owner and a date.
**Examples:**
- "Send the proposal PDF + recording link by 2026-04-09 EOD."
- "Schedule a 15-min joint call with prospect + business partner -- propose next Tuesday morning."
- "Add prospect to the May cohort waitlist; send confirmation email tomorrow."
- "Mark as cold; remove from active pipeline; quarterly check-in cadence."
This action gets handed back to `snappy-sales`, which dispatches the actual follow-up via `snappy-email` / `snappy-slack` / `snappy-calendar`.
---
## Coaching Hand-Off
After the 6-section review is complete, package the coaching insights for `snappy-sales`:
```yaml
call_id: <krisp_doc_id_or_whisper_filename>
prospect_name: <name>
prospect_company: <company>
date: <YYYY-MM-DD>
outcome: <closed | no_go | tbd | ghost>
commitment_level: 1-5
objections:
- text: "<verbatim>"
category: <price | time | trust | fit | authority | urgency>
handled: <resolved | deflected | conceded | lingering>
landed_well_count: <int>
didnt_land_count: <int>
missed_opportunities_count: <int>
next_action: "<one sentence>"
next_action_owner: <robert | prospect | both>
next_action_due: <YYYY-MM-DD>
coaching_themes: ["<theme1>", "<theme2>"]
```
`snappy-sales` then:
1. Logs the call against the prospect record
2. Updates the sales pipeline forecast (commitment_level drives the % chance)
3. Adds any new objections to the objection library
4. Schedules the next action via the right channel skill
5. Surfaces coaching themes in the weekly sales review
---
## Search Patterns for Sales Moments
Useful Krisp queries for finding past sales calls and patterns.
### Find every sales call
```
mcp__claude_ai_Krisp__search_meetings({
query: "mastermind OR cohort OR program OR proposal OR pricing"
})
```
### Find calls where price came up
```
mcp__claude_ai_Krisp__search_meetings({
query: "expensive OR budget OR cost OR price OR investment"
})
```
### Find calls where the prospect was warm
```
mcp__claude_ai_Krisp__search_meetings({
query: "send me OR loop me in OR I'll get back OR talk to my partner"
})
```
### Find calls that closed
```
mcp__claude_ai_Krisp__search_meetings({
query: "I'm in OR sign me up OR send the invoice OR let's do it"
})
```
### Find ghosting patterns
```
mcp__claude_ai_Krisp__list_activities({})
# then look for prospects with single-call records and no follow-ups
```
---
## What AI Agents Get Wrong
| Wrong | Correct |
|-------|---------|
| Reviewing the call for "tone" or "vibes" | Stick to the 6 sections -- outcome, commitment, objections, pitch quality, missed opportunities, next action |
| Logging objections without categorization | Every objection gets a category from the table -- that's how the library stays searchable |
| Saying "Robert did great" | Specific moments with timestamps. "Great" isn't actionable feedback |
| Treating commitment level as a guess | Cite the moment that determined the score -- coaching needs evidence |
| Producing a list of "next actions" | One concrete action with owner and date -- sales reps need clarity, not options |
| Reaching into `snappy-sales` to log the call | Hand the YAML payload off -- `snappy-sales` owns its own pipeline schema |
| Forgetting to flag lingering objections | Lingering objections are the #1 reason deals stall -- surface them in Missed Opportunities |
| Reviewing without the transcript | Always pull the full Krisp doc or Whisper JSON first -- coaching from memory is unreliable |
| Combining multiple calls into one review | One review per call. Patterns across calls go to `snappy-sales` weekly review, not the per-call file |
| Skipping the buying signal moments | Buying signals (Section 5) are gold -- they're where the deal turned, won or lost |
How to turn a raw transcript (Krisp doc or Whisper output) into structured intel that downstream skills can consume. This is the canonical recipe -- every "summarize this call" request should follow it.
Every transcript summary returns the same five blocks in the same order. Downstream skills (snappy-knowledge, snappy-clients, snappy-update, snappy-sales) parse these blocks programmatically -- don't reorder, don't rename.
1. TL;DR -- one sentence, what mattered
2. Decisions -- what was agreed, who decided
3. Action Items -- who owes what to whom by when
4. Open Questions -- what's unresolved, what's at risk
5. Quotable Moments -- verbatim lines worth surfacing (testimonials, content)
A single sentence Robert can read in 3 seconds and know whether the call mattered.
Good:
Bad:
Rule of thumb: if the TL;DR doesn't contain a noun (the thing) AND a verb (what happened to it) AND a time/decision marker, rewrite it.
What was decided (not what was discussed). Decisions are sticky -- they live in the contact's interaction record forever.
| Field | Format |
|---|---|
| Decision | One sentence |
| Decided by | Who said yes (Robert / client / mutual) |
| Effective date | When the decision kicks in |
| Reverses | Does this overturn an earlier decision? Cite which one. |
Example:
markdown- **Decision**: Move the QA endpoint deployment from Wednesday to Friday.
- Decided by: Mark (client)
- Effective: 2026-04-04
- Reverses: None
If the call had no decisions, write "None this call." Don't pad.
The most important block. These are the commitments that need follow-through.
| Field | Format |
|---|---|
| Action | Verb + object ("draft the SOW", "send the invoice") |
| Owner | Robert / client name / specific person |
| Due | ISO date or "next call" |
| Source | Transcript timestamp |
| Status | open / in-progress / done / blocked |
Example:
markdown- [ ] Draft revised SOW with Phase 3 line item
- Owner: Robert
- Due: 2026-04-09
- Source: 00:34:12
- Status: open
- [ ] Send updated BigQuery credentials
- Owner: Mark (Orbiter)
- Due: 2026-04-08
- Source: 00:51:03
- Status: open
Krisp's NLP extracts action items automatically via list_action_items, but it misses context ~30% of the time:
Always verify Krisp action items against the transcript before logging them as commitments. The Krisp output is a starting point, not gospel.
Things that came up but weren't resolved. These feed snappy-clients (relationship risks) and snappy-update (what to ask the client next time).
| Field | Format |
|---|---|
| Question | What's unresolved? |
| Owner to resolve | Who should answer it |
| Risk if unanswered | What breaks if we leave this hanging |
| Source | Transcript timestamp |
Example:
markdown- **Question**: Is the LinkedIn scraping rate limit going to throttle the bulk import?
- Owner to resolve: Robert (needs to test against the new endpoint)
- Risk if unanswered: Friday deployment could miss its window
- Source: 00:42:07
Lines that are worth surfacing later -- for testimonials, content production, or future briefings.
For each quote:
markdown> "The QA endpoints completely changed how we think about data quality."
- **Speaker**: Mark (Orbiter)
- **Timestamp**: 00:23:45
- **Theme**: Data quality / transformation
- **Suggested use**: Testimonial candidate (hand to `snappy-testimonials`)
If a quote scores high on the quote-mining.md rubric, hand it directly to snappy-testimonials. Don't try to manage testimonial permission flow inline -- that's a separate skill.
If a quote is content material (not a testimonial), hand to snappy-content for repurposing.
| Call length | Summary length |
|---|---|
| <15 min | 5-block summary, 1-2 lines per block. Total ~10 lines. |
| 15-45 min | 5-block summary, 2-4 lines per block. Total ~15-25 lines. |
| 45-90 min | 5-block summary, 3-6 lines per block. Total ~25-40 lines. |
| >90 min | Same 5 blocks, but use sub-bullets liberally. Total ~50 lines max. |
Hard ceiling: never exceed 60 lines. If you can't compress the call into 60 lines of structured output, the transcript has too much noise -- strip the small talk and try again.
Every claim in the summary must be citable back to the source.
Krisp source:
Source: Krisp meeting <doc_id> (timestamp 00:23:45, recorded 2026-04-04)
Whisper source:
Source: 2026-04-04_orbiter-qa-review_robert-mark.json (timestamp 00:23:45)
For multi-quote summaries, list every cited timestamp at the end of the summary block. If a downstream consumer (like snappy-testimonials) needs the exact quote, the timestamps make retrieval one search away.
Both produce the same 5-block summary, but the input shape differs:
| Source | How to fetch | Quote retrieval |
|---|---|---|
| Krisp | mcp__claude_ai_Krisp__get_multiple_documents({ document_ids: [<id>] }) |
Returns full document with speaker labels |
| Whisper | Read JSON file from ~/transcripts/ on Mac Mini |
Use the JSON-segment search recipe (whisper-pipeline.md#full-text-search-across-json-segments-with-timestamps) |
Krisp generally identifies speakers; Whisper does not (without a separate diarization step). When summarizing a Whisper-only transcript, infer speakers from cadence and content, and flag any low-confidence attribution explicitly.
After producing the 5-block summary, package it for the right downstream skill:
| Block contents | Hand to |
|---|---|
| Action items with Robert as owner | snappy-ops (gets added to morning briefing) |
| Action items with client as owner | snappy-clients (relationship tracking) |
| Quote candidates scoring 4+ on rubric | snappy-testimonials |
| Open questions about a client | snappy-clients (next-call prep) |
| Decisions affecting a project | snappy-knowledge (logged on contact's interaction record) |
| Sales call with objections | snappy-sales (objection handling) -- see sales-review.md |
| Content moments worth repurposing | snappy-content |
The 5-block format is the currency -- every consumer reads the same structure.
| Wrong | Correct |
|---|---|
| Writing a wall-of-text summary instead of the 5 blocks | Always emit the 5 blocks in order, even if some are "None this call." |
| Re-narrating the transcript ("Mark said... then Robert said...") | Extract decisions, actions, questions, quotes -- don't replay the conversation |
| Trusting Krisp action items without verification | Cross-check against the transcript; Krisp NLP misses ~30% of context |
| Skipping timestamps | Every claim needs a transcript timestamp for retrieval |
Logging the summary directly to snappy-knowledge |
Hand off via the Hand-Off Format -- snappy-knowledge owns its own logging schema |
| Producing a 200-line summary for a 30-min call | Hard ceiling: 60 lines max. Compress harder. |
| Writing the TL;DR as a list of topics | TL;DR is a sentence with a noun, verb, and outcome -- never a list |
| Treating "Quotable Moments" as filler | If there's nothing quotable, write "None this call." Don't fabricate. |
| Forgetting to mark the speaker on a quote | Speaker attribution is required -- without it, the quote can't become a testimonial |
| Mixing decisions with discussion topics | Decisions are agreements with a yes/no outcome. If it wasn't decided, it goes in Open Questions. |
# Transcript Summarization
How to turn a raw transcript (Krisp doc or Whisper output) into structured intel that downstream skills can consume. This is the canonical recipe -- every "summarize this call" request should follow it.
## Table of Contents
- [The Five-Block Output](#the-five-block-output)
- [Block 1: One-Sentence TL;DR](#block-1-one-sentence-tldr)
- [Block 2: Decisions & Commitments](#block-2-decisions--commitments)
- [Block 3: Action Items](#block-3-action-items)
- [Block 4: Open Questions / Risks](#block-4-open-questions--risks)
- [Block 5: Quotable Moments](#block-5-quotable-moments)
- [Length Guidelines](#length-guidelines)
- [How to Cite](#how-to-cite)
- [Krisp vs Whisper Sources](#krisp-vs-whisper-sources)
- [Hand-Off Format](#hand-off-format)
- [What AI Agents Get Wrong](#what-ai-agents-get-wrong)
---
## The Five-Block Output
Every transcript summary returns the same five blocks in the same order. Downstream skills (`snappy-knowledge`, `snappy-clients`, `snappy-update`, `snappy-sales`) parse these blocks programmatically -- don't reorder, don't rename.
```
1. TL;DR -- one sentence, what mattered
2. Decisions -- what was agreed, who decided
3. Action Items -- who owes what to whom by when
4. Open Questions -- what's unresolved, what's at risk
5. Quotable Moments -- verbatim lines worth surfacing (testimonials, content)
```
---
## Block 1: One-Sentence TL;DR
A single sentence Robert can read in 3 seconds and know whether the call mattered.
**Good:**
- "Mark confirmed the QA pipeline is shipping Friday and asked for help with one BigQuery query."
- "Sales call with Acme -- they're a 90% fit but blocked on budget approval until next month."
- "James (Total CRM) signed off on the morgage deal flow redesign and wants a demo by April 14."
**Bad:**
- "We discussed several topics including project status and next steps." (says nothing)
- "Mark talked about the pipeline." (no outcome)
- "Long meeting about LinkedIn enrichment, the BigQuery integration, the avatar issue, and Mark's birthday plans." (not a sentence -- a list)
**Rule of thumb:** if the TL;DR doesn't contain a noun (the thing) AND a verb (what happened to it) AND a time/decision marker, rewrite it.
---
## Block 2: Decisions & Commitments
What was *decided* (not what was discussed). Decisions are sticky -- they live in the contact's interaction record forever.
| Field | Format |
|-------|--------|
| Decision | One sentence |
| Decided by | Who said yes (Robert / client / mutual) |
| Effective date | When the decision kicks in |
| Reverses | Does this overturn an earlier decision? Cite which one. |
**Example:**
```markdown
- **Decision**: Move the QA endpoint deployment from Wednesday to Friday.
- Decided by: Mark (client)
- Effective: 2026-04-04
- Reverses: None
```
If the call had no decisions, write "None this call." Don't pad.
---
## Block 3: Action Items
The most important block. These are the commitments that need follow-through.
| Field | Format |
|-------|--------|
| Action | Verb + object ("draft the SOW", "send the invoice") |
| Owner | Robert / client name / specific person |
| Due | ISO date or "next call" |
| Source | Transcript timestamp |
| Status | open / in-progress / done / blocked |
**Example:**
```markdown
- [ ] Draft revised SOW with Phase 3 line item
- Owner: Robert
- Due: 2026-04-09
- Source: 00:34:12
- Status: open
- [ ] Send updated BigQuery credentials
- Owner: Mark (Orbiter)
- Due: 2026-04-08
- Source: 00:51:03
- Status: open
```
### Krisp action item caveat
Krisp's NLP extracts action items automatically via `list_action_items`, but it misses context ~30% of the time:
- It picks up rhetorical "I should..." statements as commitments
- It misses commitments phrased as questions ("can you send that over?")
- It assigns Robert as owner when the speaker was actually the client
**Always verify Krisp action items against the transcript before logging them as commitments.** The Krisp output is a starting point, not gospel.
---
## Block 4: Open Questions / Risks
Things that came up but weren't resolved. These feed `snappy-clients` (relationship risks) and `snappy-update` (what to ask the client next time).
| Field | Format |
|-------|--------|
| Question | What's unresolved? |
| Owner to resolve | Who should answer it |
| Risk if unanswered | What breaks if we leave this hanging |
| Source | Transcript timestamp |
**Example:**
```markdown
- **Question**: Is the LinkedIn scraping rate limit going to throttle the bulk import?
- Owner to resolve: Robert (needs to test against the new endpoint)
- Risk if unanswered: Friday deployment could miss its window
- Source: 00:42:07
```
---
## Block 5: Quotable Moments
Lines that are worth surfacing later -- for testimonials, content production, or future briefings.
For each quote:
```markdown
> "The QA endpoints completely changed how we think about data quality."
- **Speaker**: Mark (Orbiter)
- **Timestamp**: 00:23:45
- **Theme**: Data quality / transformation
- **Suggested use**: Testimonial candidate (hand to `snappy-testimonials`)
```
If a quote scores high on the [quote-mining.md](quote-mining.md) rubric, hand it directly to `snappy-testimonials`. Don't try to manage testimonial permission flow inline -- that's a separate skill.
If a quote is content material (not a testimonial), hand to `snappy-content` for repurposing.
---
## Length Guidelines
| Call length | Summary length |
|-------------|---------------|
| <15 min | 5-block summary, 1-2 lines per block. Total ~10 lines. |
| 15-45 min | 5-block summary, 2-4 lines per block. Total ~15-25 lines. |
| 45-90 min | 5-block summary, 3-6 lines per block. Total ~25-40 lines. |
| >90 min | Same 5 blocks, but use sub-bullets liberally. Total ~50 lines max. |
**Hard ceiling:** never exceed 60 lines. If you can't compress the call into 60 lines of structured output, the transcript has too much noise -- strip the small talk and try again.
---
## How to Cite
Every claim in the summary must be citable back to the source.
**Krisp source:**
```
Source: Krisp meeting <doc_id> (timestamp 00:23:45, recorded 2026-04-04)
```
**Whisper source:**
```
Source: 2026-04-04_orbiter-qa-review_robert-mark.json (timestamp 00:23:45)
```
For multi-quote summaries, list every cited timestamp at the end of the summary block. If a downstream consumer (like `snappy-testimonials`) needs the exact quote, the timestamps make retrieval one search away.
---
## Krisp vs Whisper Sources
Both produce the same 5-block summary, but the input shape differs:
| Source | How to fetch | Quote retrieval |
|--------|-------------|----------------|
| Krisp | `mcp__claude_ai_Krisp__get_multiple_documents({ document_ids: [<id>] })` | Returns full document with speaker labels |
| Whisper | Read JSON file from `~/transcripts/` on Mac Mini | Use the JSON-segment search recipe ([whisper-pipeline.md#full-text-search-across-json-segments-with-timestamps](whisper-pipeline.md#search-stored-transcripts)) |
Krisp generally identifies speakers; Whisper does not (without a separate diarization step). When summarizing a Whisper-only transcript, infer speakers from cadence and content, and flag any low-confidence attribution explicitly.
---
## Hand-Off Format
After producing the 5-block summary, package it for the right downstream skill:
| Block contents | Hand to |
|----------------|---------|
| Action items with Robert as owner | `snappy-ops` (gets added to morning briefing) |
| Action items with client as owner | `snappy-clients` (relationship tracking) |
| Quote candidates scoring 4+ on rubric | `snappy-testimonials` |
| Open questions about a client | `snappy-clients` (next-call prep) |
| Decisions affecting a project | `snappy-knowledge` (logged on contact's interaction record) |
| Sales call with objections | `snappy-sales` (objection handling) -- see [sales-review.md](sales-review.md) |
| Content moments worth repurposing | `snappy-content` |
The 5-block format is the *currency* -- every consumer reads the same structure.
---
## What AI Agents Get Wrong
| Wrong | Correct |
|-------|---------|
| Writing a wall-of-text summary instead of the 5 blocks | Always emit the 5 blocks in order, even if some are "None this call." |
| Re-narrating the transcript ("Mark said... then Robert said...") | Extract decisions, actions, questions, quotes -- don't replay the conversation |
| Trusting Krisp action items without verification | Cross-check against the transcript; Krisp NLP misses ~30% of context |
| Skipping timestamps | Every claim needs a transcript timestamp for retrieval |
| Logging the summary directly to `snappy-knowledge` | Hand off via the [Hand-Off Format](#hand-off-format) -- `snappy-knowledge` owns its own logging schema |
| Producing a 200-line summary for a 30-min call | Hard ceiling: 60 lines max. Compress harder. |
| Writing the TL;DR as a list of topics | TL;DR is a sentence with a noun, verb, and outcome -- never a list |
| Treating "Quotable Moments" as filler | If there's nothing quotable, write "None this call." Don't fabricate. |
| Forgetting to mark the speaker on a quote | Speaker attribution is required -- without it, the quote can't become a testimonial |
| Mixing decisions with discussion topics | Decisions are agreements with a yes/no outcome. If it wasn't decided, it goes in Open Questions. |
When Krisp doesn't have the source -- phone calls, YouTube videos, pre-Krisp archives, or any local file -- fall back to Whisper running on the Mac Mini. This file is the complete reference.
| Setting | Value |
|---|---|
| Mac Mini SSH | robertboulos@Roberts-Mac-mini.local |
| Whisper venv | /Users/robertboulos/robot-rob/venv/ |
| Storage | /Users/robertboulos/transcripts/ |
| Default model | small (override per Model Selection) |
| Word timestamps | True (always -- needed for SRT + quote citation) |
| Output formats | .txt + .srt + .json (always all three) |
| Long-job log | /tmp/transcribe-job.log |
Transcribe any local video or audio file using Whisper on the Mac Mini.
bashscp /local/path/recording.mp4 robertboulos@Roberts-Mac-mini.local:/tmp/recording.mp4
bashssh robertboulos@Roberts-Mac-mini.local "cd /Users/robertboulos/robot-rob && \
source venv/bin/activate && \
python3 -c \"
import whisper, json
model = whisper.load_model('small')
result = model.transcribe('/tmp/recording.mp4', word_timestamps=True)
# Write SRT with timestamps
with open('/tmp/recording.srt', 'w') as f:
for i, seg in enumerate(result['segments'], 1):
start = seg['start']
end = seg['end']
text = seg['text'].strip()
f.write(f'{i}\n{int(start//3600):02d}:{int(start%3600//60):02d}:{start%60:06.3f} --> {int(end//3600):02d}:{int(end%3600//60):02d}:{end%60:06.3f}\n{text}\n\n')
# Write plain text
with open('/tmp/recording.txt', 'w') as f:
f.write(result['text'])
# Write JSON with segments for search
with open('/tmp/recording.json', 'w') as f:
json.dump({
'text': result['text'],
'segments': [{'start': s['start'], 'end': s['end'], 'text': s['text'].strip()} for s in result['segments']]
}, f, indent=2)
print('Done. Files: /tmp/recording.srt, /tmp/recording.txt, /tmp/recording.json')
\""
bashscp robertboulos@Roberts-Mac-mini.local:/tmp/recording.srt /local/path/
scp robertboulos@Roberts-Mac-mini.local:/tmp/recording.txt /local/path/
scp robertboulos@Roberts-Mac-mini.local:/tmp/recording.json /local/path/
Don't block the SSH session. Use nohup and check the log later.
bashssh robertboulos@Roberts-Mac-mini.local "nohup bash -c 'cd /Users/robertboulos/robot-rob && source venv/bin/activate && python3 transcribe_job.py /tmp/recording.mp4' > /tmp/transcribe-job.log 2>&1 &"
Check status:
bashssh robertboulos@Roberts-Mac-mini.local "tail -20 /tmp/transcribe-job.log"
Confirm completion:
bashssh robertboulos@Roberts-Mac-mini.local "ls -la /tmp/recording.srt /tmp/recording.txt /tmp/recording.json"
| Recording type | Model | Speed | Notes |
|---|---|---|---|
| Quick check / short clip | tiny |
fastest | Low accuracy, fine for sanity checks |
| Standard meeting (<1hr) | base |
fast | Acceptable for clean audio |
| Default for meetings | small |
balanced | Default -- good accuracy |
| Important call / noisy audio | medium |
slow | Better accuracy |
| Critical / legal / multi-accent | large |
slowest | Best accuracy |
Override the default by changing whisper.load_model('small') to the desired model name.
Move from /tmp/ to the persistent transcripts directory with the canonical filename.
YYYY-MM-DD_topic_attendees.{ext}
Examples:
2026-04-07_pipeline-review_robert-james.txt2026-04-07_pipeline-review_robert-james.srt2026-04-07_pipeline-review_robert-james.jsonLowercase, hyphen-separated, no spaces.
bashssh robertboulos@Roberts-Mac-mini.local "mkdir -p /Users/robertboulos/transcripts"
ssh robertboulos@Roberts-Mac-mini.local "mv /tmp/recording.txt /Users/robertboulos/transcripts/2026-04-07_pipeline-review_robert-james.txt"
ssh robertboulos@Roberts-Mac-mini.local "mv /tmp/recording.srt /Users/robertboulos/transcripts/2026-04-07_pipeline-review_robert-james.srt"
ssh robertboulos@Roberts-Mac-mini.local "mv /tmp/recording.json /Users/robertboulos/transcripts/2026-04-07_pipeline-review_robert-james.json"
When logging into snappy-knowledge as an interaction record:
| Field | Source |
|---|---|
| Date | Filename prefix |
| Attendees | Filename suffix or transcript header |
| Topic | Filename middle segment |
| Duration | Whisper result result['segments'][-1]['end'] |
| Source | "Zoom local", "phone", "in-person recorder", etc. |
bashssh robertboulos@Roberts-Mac-mini.local "grep -ril 'KEYWORD' /Users/robertboulos/transcripts/*.txt"
Show context around matches:
bashssh robertboulos@Roberts-Mac-mini.local "grep -in -C 3 'KEYWORD' /Users/robertboulos/transcripts/*.txt"
bashssh robertboulos@Roberts-Mac-mini.local "find /Users/robertboulos/transcripts -name '2026-04-*' -type f"
By modification date:
bashssh robertboulos@Roberts-Mac-mini.local "find /Users/robertboulos/transcripts -newermt '2026-04-01' -not -newermt '2026-04-08' -type f"
bashssh robertboulos@Roberts-Mac-mini.local "ls /Users/robertboulos/transcripts/*james* 2>/dev/null"
bashssh robertboulos@Roberts-Mac-mini.local "python3 -c \"
import json, glob, sys
query = sys.argv[1].lower()
for f in sorted(glob.glob('/Users/robertboulos/transcripts/*.json')):
with open(f) as fh:
data = json.load(fh)
matches = [s for s in data['segments'] if query in s['text'].lower()]
if matches:
print(f'\n=== {f} ===')
for m in matches:
mins = int(m['start'] // 60)
secs = int(m['start'] % 60)
print(f' [{mins:02d}:{secs:02d}] {m[\\\"text\\\"]}')
\" 'SEARCH_TERM'"
This returns timestamped quote candidates with millisecond precision. Useful for testimonial sourcing -- see quote-mining.md.
Two paths: download the full audio and transcribe with Whisper (high quality), or grab YouTube auto-captions (fast, lower quality).
bash# Download audio only via yt-dlp on Mac Mini
ssh robertboulos@Roberts-Mac-mini.local "yt-dlp -x --audio-format mp3 -o '/tmp/yt_audio.%(ext)s' 'YOUTUBE_URL'"
Then run Local File Transcription on /tmp/yt_audio.mp3.
bashssh robertboulos@Roberts-Mac-mini.local "yt-dlp --write-auto-sub --sub-lang en --skip-download -o '/tmp/yt_subs' 'YOUTUBE_URL'"
Returns a .vtt file. Convert to text by stripping the timing lines.
| Scenario | Path |
|---|---|
| Robert's own YouTube video for repurposing | High quality (Whisper on downloaded audio) |
| Quick research / "what does X say about Y" | Auto-captions (fast) |
| Multi-language or accent-heavy content | High quality (Whisper medium or large) |
| Live stream archive | High quality (Whisper) |
Same as local file transcription. If the call was recorded on a phone:
scpFor calls captured via the Mac Mini directly (e.g. iMessage audio messages), the file already lives on the Mini -- skip the transfer step.
| Wrong | Correct |
|---|---|
| Defaulting to Whisper when Krisp probably has the call | Try Krisp search_meetings first |
Forgetting word_timestamps=True |
Always set it -- needed for SRT + quote citation |
Storing only .txt |
Always emit .txt + .srt + .json |
Loading large model for a 5-min sanity check |
Use tiny or base for short/draft work |
| Running a 2-hour transcription in the foreground | Use nohup + log file for anything > 30 min |
| Spaces in filenames | Always lowercase, hyphen-separated |
Skipping the move from /tmp/ to ~/transcripts/ |
Always move + rename so the search recipes work |
Re-downloading a YouTube video already in ~/transcripts/ |
Check filename match first |
Calling the venv with python3 directly |
Always source venv/bin/activate first |
# Whisper Pipeline (Mac Mini Fallback)
When Krisp doesn't have the source -- phone calls, YouTube videos, pre-Krisp archives, or any local file -- fall back to Whisper running on the Mac Mini. This file is the complete reference.
## Table of Contents
- [Defaults](#defaults)
- [Local File Transcription](#local-file-transcription)
- [Long Recordings (>30 min)](#long-recordings-30-min)
- [Model Selection](#model-selection)
- [Storing Transcripts](#storing-transcripts)
- [Search Stored Transcripts](#search-stored-transcripts)
- [YouTube Transcript](#youtube-transcript)
- [Phone Call Recording](#phone-call-recording)
- [What AI Agents Get Wrong](#what-ai-agents-get-wrong)
---
## Defaults
| Setting | Value |
|---------|-------|
| Mac Mini SSH | `robertboulos@Roberts-Mac-mini.local` |
| Whisper venv | `/Users/robertboulos/robot-rob/venv/` |
| Storage | `/Users/robertboulos/transcripts/` |
| Default model | `small` (override per [Model Selection](#model-selection)) |
| Word timestamps | `True` (always -- needed for SRT + quote citation) |
| Output formats | `.txt` + `.srt` + `.json` (always all three) |
| Long-job log | `/tmp/transcribe-job.log` |
---
## Local File Transcription
Transcribe any local video or audio file using Whisper on the Mac Mini.
### 1. Copy file to Mac Mini (if not already there)
```bash
scp /local/path/recording.mp4 robertboulos@Roberts-Mac-mini.local:/tmp/recording.mp4
```
### 2. Run Whisper with word timestamps
```bash
ssh robertboulos@Roberts-Mac-mini.local "cd /Users/robertboulos/robot-rob && \
source venv/bin/activate && \
python3 -c \"
import whisper, json
model = whisper.load_model('small')
result = model.transcribe('/tmp/recording.mp4', word_timestamps=True)
# Write SRT with timestamps
with open('/tmp/recording.srt', 'w') as f:
for i, seg in enumerate(result['segments'], 1):
start = seg['start']
end = seg['end']
text = seg['text'].strip()
f.write(f'{i}\n{int(start//3600):02d}:{int(start%3600//60):02d}:{start%60:06.3f} --> {int(end//3600):02d}:{int(end%3600//60):02d}:{end%60:06.3f}\n{text}\n\n')
# Write plain text
with open('/tmp/recording.txt', 'w') as f:
f.write(result['text'])
# Write JSON with segments for search
with open('/tmp/recording.json', 'w') as f:
json.dump({
'text': result['text'],
'segments': [{'start': s['start'], 'end': s['end'], 'text': s['text'].strip()} for s in result['segments']]
}, f, indent=2)
print('Done. Files: /tmp/recording.srt, /tmp/recording.txt, /tmp/recording.json')
\""
```
### 3. Pull results back
```bash
scp robertboulos@Roberts-Mac-mini.local:/tmp/recording.srt /local/path/
scp robertboulos@Roberts-Mac-mini.local:/tmp/recording.txt /local/path/
scp robertboulos@Roberts-Mac-mini.local:/tmp/recording.json /local/path/
```
---
## Long Recordings (>30 min)
Don't block the SSH session. Use `nohup` and check the log later.
```bash
ssh robertboulos@Roberts-Mac-mini.local "nohup bash -c 'cd /Users/robertboulos/robot-rob && source venv/bin/activate && python3 transcribe_job.py /tmp/recording.mp4' > /tmp/transcribe-job.log 2>&1 &"
```
Check status:
```bash
ssh robertboulos@Roberts-Mac-mini.local "tail -20 /tmp/transcribe-job.log"
```
Confirm completion:
```bash
ssh robertboulos@Roberts-Mac-mini.local "ls -la /tmp/recording.srt /tmp/recording.txt /tmp/recording.json"
```
---
## Model Selection
| Recording type | Model | Speed | Notes |
|---------------|-------|-------|-------|
| Quick check / short clip | `tiny` | fastest | Low accuracy, fine for sanity checks |
| Standard meeting (<1hr) | `base` | fast | Acceptable for clean audio |
| Default for meetings | `small` | balanced | **Default** -- good accuracy |
| Important call / noisy audio | `medium` | slow | Better accuracy |
| Critical / legal / multi-accent | `large` | slowest | Best accuracy |
Override the default by changing `whisper.load_model('small')` to the desired model name.
---
## Storing Transcripts
Move from `/tmp/` to the persistent transcripts directory with the canonical filename.
### Filename convention
```
YYYY-MM-DD_topic_attendees.{ext}
```
Examples:
- `2026-04-07_pipeline-review_robert-james.txt`
- `2026-04-07_pipeline-review_robert-james.srt`
- `2026-04-07_pipeline-review_robert-james.json`
Lowercase, hyphen-separated, no spaces.
### Move command
```bash
ssh robertboulos@Roberts-Mac-mini.local "mkdir -p /Users/robertboulos/transcripts"
ssh robertboulos@Roberts-Mac-mini.local "mv /tmp/recording.txt /Users/robertboulos/transcripts/2026-04-07_pipeline-review_robert-james.txt"
ssh robertboulos@Roberts-Mac-mini.local "mv /tmp/recording.srt /Users/robertboulos/transcripts/2026-04-07_pipeline-review_robert-james.srt"
ssh robertboulos@Roberts-Mac-mini.local "mv /tmp/recording.json /Users/robertboulos/transcripts/2026-04-07_pipeline-review_robert-james.json"
```
### Metadata to capture
When logging into `snappy-knowledge` as an interaction record:
| Field | Source |
|-------|--------|
| Date | Filename prefix |
| Attendees | Filename suffix or transcript header |
| Topic | Filename middle segment |
| Duration | Whisper result `result['segments'][-1]['end']` |
| Source | "Zoom local", "phone", "in-person recorder", etc. |
---
## Search Stored Transcripts
### Keyword search (plain text)
```bash
ssh robertboulos@Roberts-Mac-mini.local "grep -ril 'KEYWORD' /Users/robertboulos/transcripts/*.txt"
```
Show context around matches:
```bash
ssh robertboulos@Roberts-Mac-mini.local "grep -in -C 3 'KEYWORD' /Users/robertboulos/transcripts/*.txt"
```
### Date range
```bash
ssh robertboulos@Roberts-Mac-mini.local "find /Users/robertboulos/transcripts -name '2026-04-*' -type f"
```
By modification date:
```bash
ssh robertboulos@Roberts-Mac-mini.local "find /Users/robertboulos/transcripts -newermt '2026-04-01' -not -newermt '2026-04-08' -type f"
```
### By attendee (filename)
```bash
ssh robertboulos@Roberts-Mac-mini.local "ls /Users/robertboulos/transcripts/*james* 2>/dev/null"
```
### Full-text search across JSON segments (with timestamps)
```bash
ssh robertboulos@Roberts-Mac-mini.local "python3 -c \"
import json, glob, sys
query = sys.argv[1].lower()
for f in sorted(glob.glob('/Users/robertboulos/transcripts/*.json')):
with open(f) as fh:
data = json.load(fh)
matches = [s for s in data['segments'] if query in s['text'].lower()]
if matches:
print(f'\n=== {f} ===')
for m in matches:
mins = int(m['start'] // 60)
secs = int(m['start'] % 60)
print(f' [{mins:02d}:{secs:02d}] {m[\\\"text\\\"]}')
\" 'SEARCH_TERM'"
```
This returns timestamped quote candidates with millisecond precision. Useful for testimonial sourcing -- see [quote-mining.md](quote-mining.md).
---
## YouTube Transcript
Two paths: download the full audio and transcribe with Whisper (high quality), or grab YouTube auto-captions (fast, lower quality).
### High quality (download + Whisper)
```bash
# Download audio only via yt-dlp on Mac Mini
ssh robertboulos@Roberts-Mac-mini.local "yt-dlp -x --audio-format mp3 -o '/tmp/yt_audio.%(ext)s' 'YOUTUBE_URL'"
```
Then run [Local File Transcription](#local-file-transcription) on `/tmp/yt_audio.mp3`.
### Fast (auto-captions)
```bash
ssh robertboulos@Roberts-Mac-mini.local "yt-dlp --write-auto-sub --sub-lang en --skip-download -o '/tmp/yt_subs' 'YOUTUBE_URL'"
```
Returns a `.vtt` file. Convert to text by stripping the timing lines.
### When to use which
| Scenario | Path |
|----------|------|
| Robert's own YouTube video for repurposing | High quality (Whisper on downloaded audio) |
| Quick research / "what does X say about Y" | Auto-captions (fast) |
| Multi-language or accent-heavy content | High quality (Whisper `medium` or `large`) |
| Live stream archive | High quality (Whisper) |
---
## Phone Call Recording
Same as local file transcription. If the call was recorded on a phone:
1. Transfer recording to Mac Mini via AirDrop or `scp`
2. Run [Local File Transcription](#local-file-transcription)
3. Store with date, caller, and topic in the filename
For calls captured via the Mac Mini directly (e.g. iMessage audio messages), the file already lives on the Mini -- skip the transfer step.
---
## What AI Agents Get Wrong
| Wrong | Correct |
|-------|---------|
| Defaulting to Whisper when Krisp probably has the call | Try Krisp `search_meetings` first |
| Forgetting `word_timestamps=True` | Always set it -- needed for SRT + quote citation |
| Storing only `.txt` | Always emit `.txt` + `.srt` + `.json` |
| Loading `large` model for a 5-min sanity check | Use `tiny` or `base` for short/draft work |
| Running a 2-hour transcription in the foreground | Use `nohup` + log file for anything > 30 min |
| Spaces in filenames | Always lowercase, hyphen-separated |
| Skipping the move from `/tmp/` to `~/transcripts/` | Always move + rename so the search recipes work |
| Re-downloading a YouTube video already in `~/transcripts/` | Check filename match first |
| Calling the venv with `python3` directly | Always `source venv/bin/activate` first |