snappy-testimonials skill
list status?readscore text speaker?read$ npx snappy-skills install snappy-testimonials
$ npx snappy-skills install --all
$ npx snappy-skills update
Testimonial sourcing engine. Scans Krisp transcripts (via snappy-transcripts quote-mining
recipe) and snappy-knowledge notes for genuine client praise. Scores candidates on a
4-dimension rubric (specificity, authenticity, business impact, standalone clarity), surfaces
the top 5 to Robert with full context, drafts a casual permission-request in Robert's voice,
and tracks approval state so no quote is ever re-asked.
relationship signals, recommendation signals) plus knowledge graph notes scan.
AND authenticity 4+. Auto-disqualifiers: not a client, speaker is Robert, older than 12mo,
public source, contains private info.
context before/after, why it scores high, suggested permission channel.
one quote per ask, zero corporate buzzwords. DRAFT ONLY -- never auto-sends.
testimonial_asked, testimonial_approved, testimonial_declinedinto snappy-knowledge contact notes via Xano PATCH.
preferred_channel from snappy-knowledge before picking the send channel.| File | Purpose |
|---|---|
SKILL.md |
Full workflow + rubric + output format |
quote-rubric.md |
Scoring rubric with worked examples |
permission-templates.md |
4 channel-specific permission request templates |
xano-tracking.md |
Xano PATCH calls for approval state logging |
scripts/scan-testimonials.sh |
Grep-based corpus scanner -- finds candidate quotes in Krisp transcripts |
scripts/scan-testimonials.sh scans ~/.claude/corpus/krisp/2026/**/*.md for testimonial
candidates using regex patterns aligned with SKILL.md sentiment seeds (transformation language,
specific outcomes, praise, relationship signals, recommendation signals).
bash# Scan all 2026 transcripts
./scripts/scan-testimonials.sh
# Scan a specific month
./scripts/scan-testimonials.sh 2026/04
# Filter by speaker name
./scripts/scan-testimonials.sh 2026/03 "mark"
Output is TSV: FILE | SPEAKER | QUOTE_PREVIEW | SCORE (high/medium/low).
Automatically skips Robert's own lines and summary/nuggets files.
Sort by score: ./scripts/scan-testimonials.sh | sort -t\t' -k4 -r
snappy-transcripts (Krisp meetings), snappy-knowledge (contact records),snappy-clients (active roster), snappy-content (voice rules)
snappy-content (approved quotes), snappy-website (landing page testimonials),snappy-knowledge (approval state)
typescriptimport { getTestimonials, scoreTestimonial } from "../snappy-testimonials/api.ts";
Or CLI:
bashnpx tsx ~/.claude/skills/snappy-testimonials/api.ts list
npx tsx ~/.claude/skills/snappy-testimonials/api.ts list HIGH
npx tsx ~/.claude/skills/snappy-testimonials/api.ts score "This changed everything" "Jane"
| Function | Purpose |
|---|---|
getTestimonials(status?) |
Query content engine DB for testimonials, optionally filtered by score |
scoreTestimonial(text, speaker) |
Score a testimonial candidate on 4 dimensions (specificity, authenticity, impact, clarity) |
If this loader doesn't cover your case:
bashecho "[$(date -u +%FT%TZ)] snappy-testimonials: <what was missing>" >> ~/.claude/logs/agents-md-feedback.log
<!-- SKILL-INDEX-START -->
[snappy-testimonials Index]|root: ~/.claude/skills/snappy-testimonials|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,permission-templates.md,quote-rubric.md,xano-tracking.md}
<!-- SKILL-INDEX-END -->
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 |
status? |
read |
npx tsx ~/.claude/skills/snappy-testimonials/api.ts list |
score |
text, speaker? |
read |
npx tsx ~/.claude/skills/snappy-testimonials/api.ts score "<text>" |
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 -->
---
name: snappy-testimonials
role: Scans transcripts and knowledge graph for client quotes, ranks on quality rubric, drafts permission requests, tracks approvals.
loaded-by: PreToolUse hook (auto-injected when "snappy-testimonials" is mentioned)
---
# snappy-testimonials
Testimonial sourcing engine. Scans Krisp transcripts (via `snappy-transcripts` quote-mining
recipe) and `snappy-knowledge` notes for genuine client praise. Scores candidates on a
4-dimension rubric (specificity, authenticity, business impact, standalone clarity), surfaces
the top 5 to Robert with full context, drafts a casual permission-request in Robert's voice,
and tracks approval state so no quote is ever re-asked.
## Key capabilities
- **Quote mining** -- Krisp MCP sentiment queries (transformation language, specific outcomes,
relationship signals, recommendation signals) plus knowledge graph notes scan.
- **Quality rubric** -- 4 dimensions scored 1-5 each. Threshold: 14+/20 AND specificity 4+
AND authenticity 4+. Auto-disqualifiers: not a client, speaker is Robert, older than 12mo,
public source, contains private info.
- **Candidate presentation** -- top 5 with score, theme, Krisp meeting ID + timestamp,
context before/after, why it scores high, suggested permission channel.
- **Permission drafts** -- 4 channel templates (email, WhatsApp, Slack, iMessage). Casual,
one quote per ask, zero corporate buzzwords. DRAFT ONLY -- never auto-sends.
- **Approval tracking** -- logs `testimonial_asked`, `testimonial_approved`, `testimonial_declined`
into `snappy-knowledge` contact notes via Xano PATCH.
## Rules
- Verbatim quotes only. Never paraphrase.
- Always present candidates to Robert before drafting permission asks.
- One quote per permission message. Never batch.
- Check `preferred_channel` from `snappy-knowledge` before picking the send channel.
- Never re-ask a declined quote. Read contact notes first.
- Never quote Robert himself.
## Directory contents
| File | Purpose |
|---|---|
| `SKILL.md` | Full workflow + rubric + output format |
| `quote-rubric.md` | Scoring rubric with worked examples |
| `permission-templates.md` | 4 channel-specific permission request templates |
| `xano-tracking.md` | Xano PATCH calls for approval state logging |
| `scripts/scan-testimonials.sh` | Grep-based corpus scanner -- finds candidate quotes in Krisp transcripts |
## Corpus scanner script
`scripts/scan-testimonials.sh` scans `~/.claude/corpus/krisp/2026/**/*.md` for testimonial
candidates using regex patterns aligned with SKILL.md sentiment seeds (transformation language,
specific outcomes, praise, relationship signals, recommendation signals).
```bash
# Scan all 2026 transcripts
./scripts/scan-testimonials.sh
# Scan a specific month
./scripts/scan-testimonials.sh 2026/04
# Filter by speaker name
./scripts/scan-testimonials.sh 2026/03 "mark"
```
Output is TSV: `FILE | SPEAKER | QUOTE_PREVIEW | SCORE` (high/medium/low).
Automatically skips Robert's own lines and summary/nuggets files.
Sort by score: `./scripts/scan-testimonials.sh | sort -t$'\t' -k4 -r`
## Feeds / fed by
- **Fed by**: `snappy-transcripts` (Krisp meetings), `snappy-knowledge` (contact records),
`snappy-clients` (active roster), `snappy-content` (voice rules)
- **Feeds**: `snappy-content` (approved quotes), `snappy-website` (landing page testimonials),
`snappy-knowledge` (approval state)
## API module
```typescript
import { getTestimonials, scoreTestimonial } from "../snappy-testimonials/api.ts";
```
Or CLI:
```bash
npx tsx ~/.claude/skills/snappy-testimonials/api.ts list
npx tsx ~/.claude/skills/snappy-testimonials/api.ts list HIGH
npx tsx ~/.claude/skills/snappy-testimonials/api.ts score "This changed everything" "Jane"
```
## API functions
| Function | Purpose |
|----------|---------|
| `getTestimonials(status?)` | Query content engine DB for testimonials, optionally filtered by score |
| `scoreTestimonial(text, speaker)` | Score a testimonial candidate on 4 dimensions (specificity, authenticity, impact, clarity) |
---
If this loader doesn't cover your case:
```bash
echo "[$(date -u +%FT%TZ)] snappy-testimonials: <what was missing>" >> ~/.claude/logs/agents-md-feedback.log
```
<!-- SKILL-INDEX-START -->
[snappy-testimonials Index]|root: ~/.claude/skills/snappy-testimonials|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,permission-templates.md,quote-rubric.md,xano-tracking.md}
<!-- SKILL-INDEX-END -->
## 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` | `status?` | `read` | `npx tsx ~/.claude/skills/snappy-testimonials/api.ts list` |
| `score` | `text`, `speaker?` | `read` | `npx tsx ~/.claude/skills/snappy-testimonials/api.ts score "<text>"` |
## 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 -->
Find quote-worthy moments where Snappy clients have said something genuinely positive, package the best 5 with citation + context, draft a permission-request in Robert's voice, and log the approval state so approved quotes flow into snappy-content and snappy-website and unapproved-but-already-asked quotes never get re-requested.
This is Robert's "pull me a testimonial from Total CRM client" engine. It does not paraphrase, does not invent, does not auto-send.
Auto-activates when Robert:
snappy-ops business reviewsnappy-content or snappy-website needs social proof for a landing pageDo NOT use this skill for:
snappy-content interview pipeline instead)Inputs (skills that feed this one):
snappy-transcripts -- provides meeting transcripts via Krisp MCP (mcp__claude_ai_Krisp__search_meetings, mcp__claude_ai_Krisp__get_multiple_documents); the quote-mining recipe is the canonical sourcing flowsnappy-knowledge -- provides logged client interactions, sentiment field on notes, contact records (id, name, email, preferred_channel) for cross-referencing speakerssnappy-clients -- provides the active client roster (tag=client) and past client roster (tag=past_client) for filtering speakers + relationship statussnappy-content -- provides Robert's voice rules + anti-AI checklist used when drafting the permission messageOutputs (skills that consume this one):
snappy-content -- receives approved testimonial copy (quote + attribution) for blog, email, socialsnappy-website -- receives approved testimonials for embedding on landing pages, homepage, case studiessnappy-knowledge -- receives the approval status logged back as a contact note (testimonial_asked, testimonial_approved, testimonial_declined flags) so the same quote is never re-requestedChannels (delivery for permission request):
snappy-email -- formal clients, slow-reply clients, anyone whose preferred_channel is emailsnappy-whatsapp -- warm casual clients, international, fast-replysnappy-slack -- clients with a shared Snappy channelsnappy-imessage -- close personal client relationships (Apple-only)Robert picks the channel based on how he normally talks to that client (read preferred_channel from snappy-knowledge first).
Orchestrator:
snappy-ops triggers this skill during the monthly testimonial scan (first Monday of each month, scans the active client roster for fresh quotes since last scan) and during the end-of-engagement wrap (when a client moves to past_client in snappy-clients)Run this checklist for any testimonial request:
snappy-clients lookup) OR scan all active clients (tag=client) OR scan a specific cohort (e.g., past_client wins from last quarter)snappy-transcripts quote-mining recipe; run sentiment + topic queries against the target client's meetings (last 6 months prioritized)snappy-knowledge contacts/{id} notes field for any logged client praise (Slack reply, email reply, WhatsApp reply that Robert manually logged)preferred_channel; queue the draft (do NOT auto-send)snappy-knowledge so the system never re-asks the same quote and approved quotes are flagged for use; see xano-tracking.md for the exact PATCH callsTotal time: 5-10 minutes for one client, 30-45 minutes for a roster scan.
| Robert says... | You do... |
|---|---|
| "Find me a testimonial from [client]" | Steps 1-6 for that one client |
| "Pull a quote from [client] about [topic]" | Steps 1-6, narrow Krisp query with topic keywords |
| "Scan all clients for testimonials" | Steps 1-6 looped across tag=client roster |
| "Monthly testimonial scan" | Full 8-step workflow across active clients, only quotes since last scan date |
| "Draft the permission ask" | Step 7 only (quote already approved by Robert) |
| "Send the permission ask via [channel]" | Step 7 + dispatch via channel skill (only after Robert says go) |
| "What testimonials do we have approved?" | Query snappy-knowledge for testimonial_approved=true notes, return the list |
| "Has [client] been asked about a quote yet?" | Query snappy-knowledge notes for that contact, return ask history |
Score every candidate against four dimensions, 1-5 each. Threshold to surface to Robert: total 14+/20 AND specificity 4+ AND authenticity 4+.
| Dimension | 1 (skip) | 3 (okay) | 5 (great) |
|---|---|---|---|
| Specificity | "It was good" | "It really helped" | "Cut our QA time from 4 hours to 20 minutes" |
| Authenticity | Sounds rehearsed / scripted | Sounds normal | Sounds like the client's real voice; Robert can hear them say it |
| Business impact | No outcome mentioned | Vague benefit | Named metric, named result, named change in behavior |
| Standalone clarity | Needs 5+ sentences of setup | Needs 1-2 lines | Stands on its own with attribution alone |
Filters BEFORE scoring (any one of these auto-disqualifies):
snappy-knowledge tag=client or tag=past_client)Full rubric with scored examples + edge cases in quote-rubric.md.
Four channel variants live in permission-templates.md. Each follows Robert's voice rules from snappy-content:
| Channel | When to use | Template |
|---|---|---|
email |
Default for formal clients, B2B, anyone whose preferred_channel=email |
permission-templates.md#email |
whatsapp |
Casual / international / fast-reply clients | permission-templates.md#whatsapp |
slack |
Clients with a shared Snappy Slack channel | permission-templates.md#slack |
imessage |
Close personal Apple-bubble clients | permission-templates.md#imessage |
The skill never auto-sends. It drafts → Robert approves → channel skill (snappy-email, snappy-whatsapp, etc.) actually dispatches.
When returning quote candidates to Robert, use this exact format:
markdown## Testimonial Candidates -- [Client Name or "Roster Scan"] -- [Date]
### Candidate 1 -- [Client Name] @ [Company]
**Date:** 2026-03-15 (Krisp meeting `<id>`, timestamp 00:23:45)
**Score:** Specificity 5 / Authenticity 5 / Impact 4 / Standalone 4 = 18/20
**Theme:** Data quality transformation
**Permission status:** not yet asked
> "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:** Mark was describing how his team had been struggling with the stuck records issue for months without clear visibility.
**Context after:** He then said the LLM Biography failure was the dominant problem, which they wouldn't have known without the diagnostics.
**Why this scores high:** Names a specific outcome (diagnostic visibility), uses transformation language ("before / now"), sounds like Mark's actual voice (no rehearsal feel).
**Suggested channel for permission ask:** Slack (his `preferred_channel` per knowledge graph)
---
### Candidate 2 -- [Next client]
...
Five candidates max per response. If the scan finds fewer than 5 above the rubric threshold, return what's there and tell Robert how many were rejected and why.
mcp__claude_ai_Krisp__search_meetings({ query: "<sentiment + client name>" })
mcp__claude_ai_Krisp__get_multiple_documents({ document_ids: [...] })
mcp__claude_ai_Krisp__list_activities({})
Full Krisp tool reference lives in snappy-transcripts/krisp-integration.md. The quote-mining flow lives in snappy-transcripts/quote-mining.md. This skill consumes both.
# Transformation language
search_meetings({ query: "transformed OR changed OR before this we OR now we" })
# Specific outcomes
search_meetings({ query: "saved OR increased OR reduced OR faster OR better" })
# Relationship / methodology
search_meetings({ query: "your approach OR Robert just OR easy to work with OR responsive" })
# Recommendation signals
search_meetings({ query: "would recommend OR told my friend OR refer OR sent them to you" })
bash# Auth -- credentials load from snappy-settings/.env.cache via env("KEY")
SNAPPY_SETTINGS_QUIET=1 source ~/.claude/skills/snappy-settings/scripts/load-env.sh
# Pull active clients to scan
curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN"
# Log a permission ask (PATCH contact notes -- append, never overwrite)
curl -s -X PATCH "$XANO/api:PB9UH7b9/contacts/CONTACT_ID" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_METADATA_TOKEN" \
-d '{"notes": "<existing notes>\n[testimonial_asked 2026-04-07] Quote from Krisp meeting <id> 00:23:45 about data quality. Channel: slack. Status: pending."}'
Full tracking scheme + read/write helpers in xano-tracking.md.
| Wrong | Correct |
|---|---|
| Paraphrasing the client's words to "improve" them | Verbatim only -- if it's not what they said, it's not a testimonial |
| Asking permission for quotes Robert hasn't seen yet | Always present candidates first → wait for Robert's go → then draft ask |
| Auto-sending the permission request | This skill drafts only. Channel skills (snappy-email, etc.) actually send AFTER Robert approves the draft |
| Pulling quotes from public chats / podcasts / livestreams | Out of scope -- only private 1:1 / project meetings |
| Including private info in the request (NDA, finances, third parties) | Filter the quote AND its context for confidentiality before drafting the ask |
| Surfacing 12+ month old quotes the client may not remember | Default to last 6 months; only go older if Robert explicitly asks |
| Asking for multiple quotes in one message | One quote per ask. Sending three at once kills reply rate and feels desperate |
| Skipping the speaker cross-reference | A quote without a verified client speaker is just text. Always check tag=client / tag=past_client |
| Quoting Robert | We never quote ourselves. If the speaker label is Robert, discard |
Reaching for snappy-email directly to send the request |
This skill only DRAFTS. Hand the draft to Robert. Robert says go. Then the channel skill sends |
| Re-asking a quote that's already been declined | Read the contact's testimonial_* notes before drafting any ask |
| Logging the ask as a touchpoint without the quote citation | Always include the Krisp meeting ID + timestamp in the log so the source is retrievable |
| Using corporate/buzzword voice in the permission ask | Run the ask through snappy-content anti-AI checklist: no "thrilled", "delighted", "leverage", etc. |
| Asking via a channel the client doesn't use | Read preferred_channel from snappy-knowledge first; if unset, default to email for formal, slack for tech founders |
| Treating the testimonial as final after a one-line "yes" reply | Confirm: which version of the quote, where it'll be used, attribution preference (full name vs first name vs anonymized) |
| Need to... | Read this |
|---|---|
| Score quotes against the rubric (with worked examples) | quote-rubric.md |
| Draft a permission request in any of the 4 channels | permission-templates.md |
| Log an ask / approval / decline in the knowledge graph | xano-tracking.md |
| Run the upstream quote-mining recipe | snappy-transcripts/quote-mining.md |
| Look up a contact's preferred channel | snappy-knowledge/schemas.md#channel-vocabulary |
| Apply Robert's voice rules to the permission ask | snappy-content/anti-ai-checklist.md |
snappy-transcripts -- primary upstream source. Owns Krisp MCP integration and the canonical quote-mining recipe. This skill consumes its output.snappy-knowledge -- secondary upstream source. Provides client contact records, tag=client filtering, preferred_channel lookup, and the notes field where approvals are logged.snappy-clients -- provides the active client roster and the client lifecycle context (active vs past_client). End-of-engagement wrap triggers this skill.snappy-content -- provides voice rules + anti-AI checklist used when drafting the permission ask. Also a downstream consumer of approved testimonials for blog/social/email reuse.snappy-website -- downstream consumer. Approved testimonials embed on the homepage, landing pages, and case study pages.snappy-email -- channel skill. Receives the email-format permission draft and dispatches after Robert approves.snappy-whatsapp -- channel skill. Receives the WhatsApp-format draft and dispatches.snappy-slack -- channel skill. Receives the Slack-DM-format draft and dispatches via the shared client channel or DM.snappy-imessage -- channel skill. Receives the iMessage-format draft and dispatches via Mac Mini bridge.snappy-ops -- orchestrator. Triggers the monthly testimonial scan during business review and the end-of-engagement scan when snappy-clients flips a contact to past_client.snappy-infra -- Xano API base + auth reference. All knowledge graph reads/writes route through this skill's auth pattern.Skill 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-ax |
Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools act… |
snappy-client-orbiter |
Per-client delivery context for Orbiter -- Mark's people-enrichment platform built on a SEPAR… |
snappy-corpus |
The Krisp transcript corpus + nugget mining pipeline: import calls into the corpus, find quot… |
snappy-freshbooks |
Snappy FreshBooks -- authoritative source for DRAFT invoicing, billing, time tracking, expens… |
snappy-inbox-sweep |
Deterministic sweep across every inbox Robert has to check (Slack, Gmail, LinkedIn DMs, Skool… |
snappy-pipeline |
Read-only QA agent for Orbiter enrichment pipeline data quality auditing. |
snappy-telegram |
Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to… |
snappy-voice-control |
Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Ag… |
---
name: snappy-testimonials
reports_to: growth
head: false
description: >
Snappy Testimonials -- scans client meeting transcripts (Krisp) and the knowledge graph for
positive client quotes, ranks them against a quality rubric, presents the best candidates to
Robert with full context, drafts a casual permission-request message in Robert's voice, and
tracks approvals so the same quote is never asked twice. The testimonial sourcing engine for
the entire snappy system. Triggers on: testimonial, testimonials, social proof, client quote,
client praise, find a quote, find quotes, scan transcripts for, mine quotes, find a testimonial,
client said, what did the client say, praise from client, get a testimonial, can I use this
quote, ask permission for quote, testimonial permission, testimonial request, client kind words,
good things clients said, source a testimonial, testimonial scan, monthly testimonial scan,
client compliments, kind words from, quote request, testimonial pipeline, approved testimonials.
---
# Snappy Testimonials
## Purpose
Find quote-worthy moments where Snappy clients have said something genuinely positive, package the best 5 with citation + context, draft a permission-request in Robert's voice, and log the approval state so approved quotes flow into `snappy-content` and `snappy-website` and unapproved-but-already-asked quotes never get re-requested.
This is Robert's "pull me a testimonial from Total CRM client" engine. It does not paraphrase, does not invent, does not auto-send.
## When to Use This Skill
Auto-activates when Robert:
- Says "find me a testimonial from [client]" / "pull a quote from [client]"
- Says "scan transcripts for testimonials" / "any good client quotes lately?"
- Says "what nice things has [client] said about us?"
- Says "draft a permission ask for that quote"
- Asks "do we have any testimonials we can use on the homepage?"
- The monthly testimonial scan triggers from `snappy-ops` business review
- A client signs off / wraps a project (post-engagement testimonial moment)
- `snappy-content` or `snappy-website` needs social proof for a landing page
Do NOT use this skill for:
- Public reviews / G2 / Trustpilot scraping (out of scope)
- Generating fake quotes / paraphrasing
- Auto-sending the permission request without Robert seeing the candidate first
- Sourcing content quotes for blog/social where attribution isn't needed (use `snappy-content` interview pipeline instead)
## Workflow
**Inputs (skills that feed this one):**
- `snappy-transcripts` -- provides meeting transcripts via Krisp MCP (`mcp__claude_ai_Krisp__search_meetings`, `mcp__claude_ai_Krisp__get_multiple_documents`); the [quote-mining recipe](../snappy-transcripts/quote-mining.md) is the canonical sourcing flow
- `snappy-knowledge` -- provides logged client interactions, sentiment field on `notes`, contact records (id, name, email, preferred_channel) for cross-referencing speakers
- `snappy-clients` -- provides the active client roster (`tag=client`) and past client roster (`tag=past_client`) for filtering speakers + relationship status
- `snappy-content` -- provides Robert's voice rules + anti-AI checklist used when drafting the permission message
**Outputs (skills that consume this one):**
- `snappy-content` -- receives approved testimonial copy (quote + attribution) for blog, email, social
- `snappy-website` -- receives approved testimonials for embedding on landing pages, homepage, case studies
- `snappy-knowledge` -- receives the approval status logged back as a contact note (`testimonial_asked`, `testimonial_approved`, `testimonial_declined` flags) so the same quote is never re-requested
**Channels (delivery for permission request):**
- `snappy-email` -- formal clients, slow-reply clients, anyone whose `preferred_channel` is email
- `snappy-whatsapp` -- warm casual clients, international, fast-reply
- `snappy-slack` -- clients with a shared Snappy channel
- `snappy-imessage` -- close personal client relationships (Apple-only)
Robert picks the channel based on how he normally talks to that client (read `preferred_channel` from `snappy-knowledge` first).
**Orchestrator:**
- `snappy-ops` triggers this skill during the **monthly testimonial scan** (first Monday of each month, scans the active client roster for fresh quotes since last scan) and during the **end-of-engagement wrap** (when a client moves to `past_client` in `snappy-clients`)
## Quick Start -- The 8-Step Workflow
Run this checklist for any testimonial request:
1. **Source selection** -- pick a client by name (`snappy-clients` lookup) OR scan all active clients (`tag=client`) OR scan a specific cohort (e.g., past_client wins from last quarter)
2. **Transcript scan** -- query Krisp via `snappy-transcripts` quote-mining recipe; run sentiment + topic queries against the target client's meetings (last 6 months prioritized)
3. **Knowledge graph scan** -- also query `snappy-knowledge` `contacts/{id}` notes field for any logged client praise (Slack reply, email reply, WhatsApp reply that Robert manually logged)
4. **Quote extraction** -- pull verbatim quotes only (never paraphrase); apply the [Quote Quality Rubric](#quote-quality-rubric) filters: specific, attribution-clear, context-rich, recent
5. **Quote ranking** -- score each candidate against the rubric; sort top to bottom by emotional intensity + specificity + business impact + named result
6. **Present to Robert** -- top 5 candidates only; format from [Output Format](#output-format) below; include client name, date, full quote, 1-2 sentences before/after, why it scores high
7. **Permission draft** -- for quotes Robert says yes to, generate a casual permission-request message using the [Permission Request Templates](permission-templates.md); pick the channel matching the client's `preferred_channel`; queue the draft (do NOT auto-send)
8. **Track approvals** -- log every ask (and the response when it comes) back into `snappy-knowledge` so the system never re-asks the same quote and approved quotes are flagged for use; see [xano-tracking.md](xano-tracking.md) for the exact PATCH calls
Total time: 5-10 minutes for one client, 30-45 minutes for a roster scan.
## Quick Decision Map
| Robert says... | You do... |
|----------------|-----------|
| "Find me a testimonial from [client]" | Steps 1-6 for that one client |
| "Pull a quote from [client] about [topic]" | Steps 1-6, narrow Krisp query with topic keywords |
| "Scan all clients for testimonials" | Steps 1-6 looped across `tag=client` roster |
| "Monthly testimonial scan" | Full 8-step workflow across active clients, only quotes since last scan date |
| "Draft the permission ask" | Step 7 only (quote already approved by Robert) |
| "Send the permission ask via [channel]" | Step 7 + dispatch via channel skill (only after Robert says go) |
| "What testimonials do we have approved?" | Query `snappy-knowledge` for `testimonial_approved=true` notes, return the list |
| "Has [client] been asked about a quote yet?" | Query `snappy-knowledge` notes for that contact, return ask history |
## Quote Quality Rubric
Score every candidate against four dimensions, 1-5 each. Threshold to surface to Robert: total 14+/20 AND specificity 4+ AND authenticity 4+.
| Dimension | 1 (skip) | 3 (okay) | 5 (great) |
|-----------|----------|----------|-----------|
| **Specificity** | "It was good" | "It really helped" | "Cut our QA time from 4 hours to 20 minutes" |
| **Authenticity** | Sounds rehearsed / scripted | Sounds normal | Sounds like the client's real voice; Robert can hear them say it |
| **Business impact** | No outcome mentioned | Vague benefit | Named metric, named result, named change in behavior |
| **Standalone clarity** | Needs 5+ sentences of setup | Needs 1-2 lines | Stands on its own with attribution alone |
**Filters BEFORE scoring** (any one of these auto-disqualifies):
- Speaker is not a client (cross-reference against `snappy-knowledge` `tag=client` or `tag=past_client`)
- Speaker is Robert (we never quote ourselves)
- Quote is older than 12 months unless Robert specifically asked for archives
- Source is a public chat / podcast / livestream Robert was hosting (out of scope; only private 1:1 / project meetings)
- Quote contains private information (NDA topics, financial details, third-party names, internal team conflicts)
Full rubric with scored examples + edge cases in [quote-rubric.md](quote-rubric.md).
## Permission Request Templates
Four channel variants live in [permission-templates.md](permission-templates.md). Each follows Robert's voice rules from `snappy-content`:
- Casual, no hard-sell
- Specific (reference the exact moment by date / topic)
- Single ask, single quote per message
- Frictionless yes/no -- never make the client write the testimonial themselves
- Zero corporate buzzwords (no "leverage", "thrilled", "delighted")
| Channel | When to use | Template |
|---------|-------------|----------|
| `email` | Default for formal clients, B2B, anyone whose `preferred_channel=email` | [permission-templates.md#email](permission-templates.md#email) |
| `whatsapp` | Casual / international / fast-reply clients | [permission-templates.md#whatsapp](permission-templates.md#whatsapp) |
| `slack` | Clients with a shared Snappy Slack channel | [permission-templates.md#slack](permission-templates.md#slack) |
| `imessage` | Close personal Apple-bubble clients | [permission-templates.md#imessage](permission-templates.md#imessage) |
The skill never auto-sends. It drafts → Robert approves → channel skill (`snappy-email`, `snappy-whatsapp`, etc.) actually dispatches.
## Output Format -- Candidate Presentation
When returning quote candidates to Robert, use this exact format:
```markdown
## Testimonial Candidates -- [Client Name or "Roster Scan"] -- [Date]
### Candidate 1 -- [Client Name] @ [Company]
**Date:** 2026-03-15 (Krisp meeting `<id>`, timestamp 00:23:45)
**Score:** Specificity 5 / Authenticity 5 / Impact 4 / Standalone 4 = 18/20
**Theme:** Data quality transformation
**Permission status:** not yet asked
> "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:** Mark was describing how his team had been struggling with the stuck records issue for months without clear visibility.
**Context after:** He then said the LLM Biography failure was the dominant problem, which they wouldn't have known without the diagnostics.
**Why this scores high:** Names a specific outcome (diagnostic visibility), uses transformation language ("before / now"), sounds like Mark's actual voice (no rehearsal feel).
**Suggested channel for permission ask:** Slack (his `preferred_channel` per knowledge graph)
---
### Candidate 2 -- [Next client]
...
```
Five candidates max per response. If the scan finds fewer than 5 above the rubric threshold, return what's there and tell Robert how many were rejected and why.
## Quick Reference
### Krisp MCP tools used (via snappy-transcripts)
```
mcp__claude_ai_Krisp__search_meetings({ query: "<sentiment + client name>" })
mcp__claude_ai_Krisp__get_multiple_documents({ document_ids: [...] })
mcp__claude_ai_Krisp__list_activities({})
```
Full Krisp tool reference lives in [snappy-transcripts/krisp-integration.md](../snappy-transcripts/krisp-integration.md). The quote-mining flow lives in [snappy-transcripts/quote-mining.md](../snappy-transcripts/quote-mining.md). This skill consumes both.
### Search query templates (sentiment seeds)
```
# Transformation language
search_meetings({ query: "transformed OR changed OR before this we OR now we" })
# Specific outcomes
search_meetings({ query: "saved OR increased OR reduced OR faster OR better" })
# Relationship / methodology
search_meetings({ query: "your approach OR Robert just OR easy to work with OR responsive" })
# Recommendation signals
search_meetings({ query: "would recommend OR told my friend OR refer OR sent them to you" })
```
### Xano tracking (knowledge graph approval log)
```bash
# Auth -- credentials load from snappy-settings/.env.cache via env("KEY")
SNAPPY_SETTINGS_QUIET=1 source ~/.claude/skills/snappy-settings/scripts/load-env.sh
# Pull active clients to scan
curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN"
# Log a permission ask (PATCH contact notes -- append, never overwrite)
curl -s -X PATCH "$XANO/api:PB9UH7b9/contacts/CONTACT_ID" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_METADATA_TOKEN" \
-d '{"notes": "<existing notes>\n[testimonial_asked 2026-04-07] Quote from Krisp meeting <id> 00:23:45 about data quality. Channel: slack. Status: pending."}'
```
Full tracking scheme + read/write helpers in [xano-tracking.md](xano-tracking.md).
## What AI Agents Get Wrong
| Wrong | Correct |
|-------|---------|
| Paraphrasing the client's words to "improve" them | Verbatim only -- if it's not what they said, it's not a testimonial |
| Asking permission for quotes Robert hasn't seen yet | Always present candidates first → wait for Robert's go → then draft ask |
| Auto-sending the permission request | This skill drafts only. Channel skills (`snappy-email`, etc.) actually send AFTER Robert approves the draft |
| Pulling quotes from public chats / podcasts / livestreams | Out of scope -- only private 1:1 / project meetings |
| Including private info in the request (NDA, finances, third parties) | Filter the quote AND its context for confidentiality before drafting the ask |
| Surfacing 12+ month old quotes the client may not remember | Default to last 6 months; only go older if Robert explicitly asks |
| Asking for multiple quotes in one message | One quote per ask. Sending three at once kills reply rate and feels desperate |
| Skipping the speaker cross-reference | A quote without a verified client speaker is just text. Always check `tag=client` / `tag=past_client` |
| Quoting Robert | We never quote ourselves. If the speaker label is Robert, discard |
| Reaching for `snappy-email` directly to send the request | This skill only DRAFTS. Hand the draft to Robert. Robert says go. Then the channel skill sends |
| Re-asking a quote that's already been declined | Read the contact's `testimonial_*` notes before drafting any ask |
| Logging the ask as a touchpoint without the quote citation | Always include the Krisp meeting ID + timestamp in the log so the source is retrievable |
| Using corporate/buzzword voice in the permission ask | Run the ask through `snappy-content` anti-AI checklist: no "thrilled", "delighted", "leverage", etc. |
| Asking via a channel the client doesn't use | Read `preferred_channel` from `snappy-knowledge` first; if unset, default to email for formal, slack for tech founders |
| Treating the testimonial as final after a one-line "yes" reply | Confirm: which version of the quote, where it'll be used, attribution preference (full name vs first name vs anonymized) |
## Navigation Guide
| Need to... | Read this |
|------------|-----------|
| Score quotes against the rubric (with worked examples) | [quote-rubric.md](quote-rubric.md) |
| Draft a permission request in any of the 4 channels | [permission-templates.md](permission-templates.md) |
| Log an ask / approval / decline in the knowledge graph | [xano-tracking.md](xano-tracking.md) |
| Run the upstream quote-mining recipe | [snappy-transcripts/quote-mining.md](../snappy-transcripts/quote-mining.md) |
| Look up a contact's preferred channel | [snappy-knowledge/schemas.md#channel-vocabulary](../snappy-knowledge/schemas.md#channel-vocabulary) |
| Apply Robert's voice rules to the permission ask | [snappy-content/anti-ai-checklist.md](../snappy-content/anti-ai-checklist.md) |
## Related Skills
- **`snappy-transcripts`** -- primary upstream source. Owns Krisp MCP integration and the canonical quote-mining recipe. This skill consumes its output.
- **`snappy-knowledge`** -- secondary upstream source. Provides client contact records, `tag=client` filtering, `preferred_channel` lookup, and the notes field where approvals are logged.
- **`snappy-clients`** -- provides the active client roster and the client lifecycle context (active vs past_client). End-of-engagement wrap triggers this skill.
- **`snappy-content`** -- provides voice rules + anti-AI checklist used when drafting the permission ask. Also a downstream consumer of approved testimonials for blog/social/email reuse.
- **`snappy-website`** -- downstream consumer. Approved testimonials embed on the homepage, landing pages, and case study pages.
- **`snappy-email`** -- channel skill. Receives the email-format permission draft and dispatches after Robert approves.
- **`snappy-whatsapp`** -- channel skill. Receives the WhatsApp-format draft and dispatches.
- **`snappy-slack`** -- channel skill. Receives the Slack-DM-format draft and dispatches via the shared client channel or DM.
- **`snappy-imessage`** -- channel skill. Receives the iMessage-format draft and dispatches via Mac Mini bridge.
- **`snappy-ops`** -- orchestrator. Triggers the monthly testimonial scan during business review and the end-of-engagement scan when `snappy-clients` flips a contact to `past_client`.
- **`snappy-infra`** -- Xano API base + auth reference. All knowledge graph reads/writes route through this skill's auth pattern.
---
**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-ax` | Drive any Mac app through the Accessibility tree (AXUIElement) the way the shipping tools act… |
| `snappy-client-orbiter` | Per-client delivery context for Orbiter -- Mark's people-enrichment platform built on a SEPAR… |
| `snappy-corpus` | The Krisp transcript corpus + nugget mining pipeline: import calls into the corpus, find quot… |
| `snappy-freshbooks` | Snappy FreshBooks -- authoritative source for DRAFT invoicing, billing, time tracking, expens… |
| `snappy-inbox-sweep` | Deterministic sweep across every inbox Robert has to check (Slack, Gmail, LinkedIn DMs, Skool… |
| `snappy-pipeline` | Read-only QA agent for Orbiter enrichment pipeline data quality auditing. |
| `snappy-telegram` | Telegram Bot API channel for Snappy: direct calls to api.telegram.org (no Xano middleware) to… |
| `snappy-voice-control` | Voice control on macOS, extracted from two shipping open-source agents (fazm by mediar-ai; Ag… |
#!/usr/bin/env npx tsx
/**
* snappy-testimonials/api.ts -- Testimonial scanning and scoring for all snappy-* skills.
*
* Usage:
* npx tsx api.ts list # list all testimonials from content engine DB
* npx tsx api.ts list HIGH # filter by score
* npx tsx api.ts score "This changed everything" "Jane" # score a testimonial candidate
*
* Or import as module:
* import { getTestimonials, scoreTestimonial } from "../snappy-testimonials/api.ts";
*/
import { env } from "../snappy-settings/load.ts";
import { boundRows, limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
import { realpathSync } from "fs";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
const API = "https://rb-content-engine.fly.dev/sql";
interface Testimonial {
id: number;
text: string;
speaker: string;
score: string;
status: string;
meeting_id: string | null;
created_at: string;
}
interface ScoreResult {
score: "HIGH" | "MEDIUM" | "LOW";
specificity: number;
authenticity: number;
impact: number;
clarity: number;
total: number;
reason: string;
}
async function sql(query: string): Promise<{ rows?: any[]; error?: string }> {
const res = await fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
if (!res.ok) {
const body = await res.text();
return { error: `HTTP ${res.status}: ${body}` };
}
return res.json();
}
function esc(s: string): string {
return "'" + s.replace(/\\/g, "\\\\").replace(/'/g, "''") + "'";
}
/** Queries content engine DB for testimonials, optionally filtered by score status. */
export async function getTestimonials(status?: string): Promise<Testimonial[]> {
const where = status ? `WHERE type = 'testimonial' AND topic = ${esc(status)}` : "WHERE type = 'testimonial'";
const result = await sql(`SELECT id, text, speaker, topic as score, status, meeting_id, mined_at as created_at FROM content_atoms ${where} ORDER BY mined_at DESC LIMIT 50`);
if (result.error) throw new Error(result.error);
return (result.rows ?? []) as Testimonial[];
}
/** Scores a testimonial candidate on 4 dimensions. Returns HIGH/MEDIUM/LOW. */
export function scoreTestimonial(text: string, speaker: string): ScoreResult {
let specificity = 1;
let authenticity = 1;
let impact = 1;
let clarity = 1;
// Specificity: numbers, tool names, time references, dollar amounts
if (/\d+/.test(text)) specificity += 2;
if (/\$[\d,]+|saved|reduced|increased|hours|minutes|weeks/.test(text)) specificity += 1;
if (text.length > 50) specificity += 1;
// Authenticity: conversational markers, hedging, natural speech
if (/actually|honestly|like,|you know|I mean/.test(text)) authenticity += 1;
if (!/incredible|amazing|revolutionary|transformative/.test(text)) authenticity += 1;
if (text.split(" ").length > 10 && text.split(" ").length < 60) authenticity += 2;
// Impact: business outcomes, transformation language
if (/changed|transformed|saved|replaced|built|shipped|launched/.test(text)) impact += 2;
if (/team|company|business|revenue|clients|customers/.test(text)) impact += 1;
if (/before.*after|used to|now we/.test(text)) impact += 1;
// Clarity: standalone readability
if (text.endsWith(".") || text.endsWith("!")) clarity += 1;
if (speaker && speaker !== "Unknown") clarity += 1;
if (text.split(" ").length >= 8) clarity += 1;
if (!/\b(it|that|this|those|they)\b/i.test(text.split(" ").slice(0, 3).join(" "))) clarity += 1;
specificity = Math.min(specificity, 5);
authenticity = Math.min(authenticity, 5);
impact = Math.min(impact, 5);
clarity = Math.min(clarity, 5);
const total = specificity + authenticity + impact + clarity;
let score: "HIGH" | "MEDIUM" | "LOW";
let reason: string;
if (total >= 14 && specificity >= 4 && authenticity >= 4) {
score = "HIGH";
reason = "Meets threshold (14+/20, specificity 4+, authenticity 4+)";
} else if (total >= 10) {
score = "MEDIUM";
reason = `Total ${total}/20. ${specificity < 4 ? "Needs more specifics. " : ""}${authenticity < 4 ? "Sounds too polished." : ""}`;
} else {
score = "LOW";
reason = `Total ${total}/20. Too generic or short.`;
}
return { score, specificity, authenticity, impact, clarity, total, reason };
}
// --- CLI ---
/** 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-testimonials",
description: "Snappy Testimonials -- scans client meeting transcripts (Krisp) and the knowledge graph for positive client quotes, ranks them against a quality rubric, presents the best candidates to Robert with full context, drafts a casual permission-request message in Robert's voice, and tracks approvals so the same quote is never asked twice. The testimonial sourcing engine for the entire snappy system. Triggers on: testimonial, testimonials, social proof, client quote, client praise, find a quote, find quotes, scan transcripts for, mine quotes, find a testimonial, client said, what did the client say, praise from client, get a testimonial, can I use this quote, ask permission for quote, testimonial permission, testimonial request, client kind words, good things clients said, source a testimonial, testimonial scan, monthly testimonial scan, client compliments, kind words from, quote request, testimonial pipeline, approved testimonials.",
managed: false,
requires: [] as string[],
refusals: refusalTable("missing_argument", "unknown_verb"),
verbs: {
list: {
args: ["status?"], flags: { limit: "--limit" }, effect: "read", class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
limit: limitSchema(200, "How many testimonials to return, newest first"), status: { type: "string", description: "Testimonial status to filter by" } } },
},
score: {
args: ["text","speaker?"], effect: "read", class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: { text: { type: "string", description: "The words to use, verbatim" }, speaker: { type: "string", description: "Name of the person who said it" } } },
},
},
} 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 [status] = bound.rest;
const found = await getTestimonials(status);
const testimonials = boundRows(found, bound.limit);
console.log(`${testimonials.length} of ${found.length} testimonials${status ? ` (${status})` : ""}:`);
for (const t of testimonials) {
console.log(` [${t.score || "?"}] ${t.speaker || "unknown"}: ${(t.text || "").slice(0, 100)}`);
}
break;
}
case "score": {
const [text, speaker] = args;
if (!text) { console.error("Usage: api.ts score <text> [speaker]"); process.exit(1); }
const result = scoreTestimonial(text, speaker || "Unknown");
console.log(`Score: ${result.score} (${result.total}/20)`);
console.log(` Specificity: ${result.specificity}/5`);
console.log(` Authenticity: ${result.authenticity}/5`);
console.log(` Impact: ${result.impact}/5`);
console.log(` Clarity: ${result.clarity}/5`);
console.log(` ${result.reason}`);
break;
}
default:
console.log("Usage: npx tsx api.ts [list|score] ...");
}
})();
}
#!/usr/bin/env npx tsx
/**
* snappy-testimonials/api.ts -- Testimonial scanning and scoring for all snappy-* skills.
*
* Usage:
* npx tsx api.ts list # list all testimonials from content engine DB
* npx tsx api.ts list HIGH # filter by score
* npx tsx api.ts score "This changed everything" "Jane" # score a testimonial candidate
*
* Or import as module:
* import { getTestimonials, scoreTestimonial } from "../snappy-testimonials/api.ts";
*/
import { env } from "../snappy-settings/load.ts";
import { boundRows, limitSchema, takeLimit } from "../snappy-settings/read-limit.ts";
import { realpathSync } from "fs";
import { annotationsForClass } from "../snappy-settings/tool-annotations.ts";
import { refusalTable } from "../snappy-settings/refusal-codes.ts";
const API = "https://rb-content-engine.fly.dev/sql";
interface Testimonial {
id: number;
text: string;
speaker: string;
score: string;
status: string;
meeting_id: string | null;
created_at: string;
}
interface ScoreResult {
score: "HIGH" | "MEDIUM" | "LOW";
specificity: number;
authenticity: number;
impact: number;
clarity: number;
total: number;
reason: string;
}
async function sql(query: string): Promise<{ rows?: any[]; error?: string }> {
const res = await fetch(API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
});
if (!res.ok) {
const body = await res.text();
return { error: `HTTP ${res.status}: ${body}` };
}
return res.json();
}
function esc(s: string): string {
return "'" + s.replace(/\\/g, "\\\\").replace(/'/g, "''") + "'";
}
/** Queries content engine DB for testimonials, optionally filtered by score status. */
export async function getTestimonials(status?: string): Promise<Testimonial[]> {
const where = status ? `WHERE type = 'testimonial' AND topic = ${esc(status)}` : "WHERE type = 'testimonial'";
const result = await sql(`SELECT id, text, speaker, topic as score, status, meeting_id, mined_at as created_at FROM content_atoms ${where} ORDER BY mined_at DESC LIMIT 50`);
if (result.error) throw new Error(result.error);
return (result.rows ?? []) as Testimonial[];
}
/** Scores a testimonial candidate on 4 dimensions. Returns HIGH/MEDIUM/LOW. */
export function scoreTestimonial(text: string, speaker: string): ScoreResult {
let specificity = 1;
let authenticity = 1;
let impact = 1;
let clarity = 1;
// Specificity: numbers, tool names, time references, dollar amounts
if (/\d+/.test(text)) specificity += 2;
if (/\$[\d,]+|saved|reduced|increased|hours|minutes|weeks/.test(text)) specificity += 1;
if (text.length > 50) specificity += 1;
// Authenticity: conversational markers, hedging, natural speech
if (/actually|honestly|like,|you know|I mean/.test(text)) authenticity += 1;
if (!/incredible|amazing|revolutionary|transformative/.test(text)) authenticity += 1;
if (text.split(" ").length > 10 && text.split(" ").length < 60) authenticity += 2;
// Impact: business outcomes, transformation language
if (/changed|transformed|saved|replaced|built|shipped|launched/.test(text)) impact += 2;
if (/team|company|business|revenue|clients|customers/.test(text)) impact += 1;
if (/before.*after|used to|now we/.test(text)) impact += 1;
// Clarity: standalone readability
if (text.endsWith(".") || text.endsWith("!")) clarity += 1;
if (speaker && speaker !== "Unknown") clarity += 1;
if (text.split(" ").length >= 8) clarity += 1;
if (!/\b(it|that|this|those|they)\b/i.test(text.split(" ").slice(0, 3).join(" "))) clarity += 1;
specificity = Math.min(specificity, 5);
authenticity = Math.min(authenticity, 5);
impact = Math.min(impact, 5);
clarity = Math.min(clarity, 5);
const total = specificity + authenticity + impact + clarity;
let score: "HIGH" | "MEDIUM" | "LOW";
let reason: string;
if (total >= 14 && specificity >= 4 && authenticity >= 4) {
score = "HIGH";
reason = "Meets threshold (14+/20, specificity 4+, authenticity 4+)";
} else if (total >= 10) {
score = "MEDIUM";
reason = `Total ${total}/20. ${specificity < 4 ? "Needs more specifics. " : ""}${authenticity < 4 ? "Sounds too polished." : ""}`;
} else {
score = "LOW";
reason = `Total ${total}/20. Too generic or short.`;
}
return { score, specificity, authenticity, impact, clarity, total, reason };
}
// --- CLI ---
/** 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-testimonials",
description: "Snappy Testimonials -- scans client meeting transcripts (Krisp) and the knowledge graph for positive client quotes, ranks them against a quality rubric, presents the best candidates to Robert with full context, drafts a casual permission-request message in Robert's voice, and tracks approvals so the same quote is never asked twice. The testimonial sourcing engine for the entire snappy system. Triggers on: testimonial, testimonials, social proof, client quote, client praise, find a quote, find quotes, scan transcripts for, mine quotes, find a testimonial, client said, what did the client say, praise from client, get a testimonial, can I use this quote, ask permission for quote, testimonial permission, testimonial request, client kind words, good things clients said, source a testimonial, testimonial scan, monthly testimonial scan, client compliments, kind words from, quote request, testimonial pipeline, approved testimonials.",
managed: false,
requires: [] as string[],
refusals: refusalTable("missing_argument", "unknown_verb"),
verbs: {
list: {
args: ["status?"], flags: { limit: "--limit" }, effect: "read", class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: {
limit: limitSchema(200, "How many testimonials to return, newest first"), status: { type: "string", description: "Testimonial status to filter by" } } },
},
score: {
args: ["text","speaker?"], effect: "read", class: "read", execution: "call", openWorld: true,
annotations: annotationsForClass("read", { openWorld: true }),
inputSchema: { properties: { text: { type: "string", description: "The words to use, verbatim" }, speaker: { type: "string", description: "Name of the person who said it" } } },
},
},
} 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 [status] = bound.rest;
const found = await getTestimonials(status);
const testimonials = boundRows(found, bound.limit);
console.log(`${testimonials.length} of ${found.length} testimonials${status ? ` (${status})` : ""}:`);
for (const t of testimonials) {
console.log(` [${t.score || "?"}] ${t.speaker || "unknown"}: ${(t.text || "").slice(0, 100)}`);
}
break;
}
case "score": {
const [text, speaker] = args;
if (!text) { console.error("Usage: api.ts score <text> [speaker]"); process.exit(1); }
const result = scoreTestimonial(text, speaker || "Unknown");
console.log(`Score: ${result.score} (${result.total}/20)`);
console.log(` Specificity: ${result.specificity}/5`);
console.log(` Authenticity: ${result.authenticity}/5`);
console.log(` Impact: ${result.impact}/5`);
console.log(` Clarity: ${result.clarity}/5`);
console.log(` ${result.reason}`);
break;
}
default:
console.log("Usage: npx tsx api.ts [list|score] ...");
}
})();
}
{
"providers": [
{
"name": "testimonials",
"label": "testimonial",
"description": "captured testimonials with optional score",
"fetch": "npx tsx ~/.claude/skills/snappy-testimonials/api.ts list | python3 -c \"import sys,json,re; rows=[]; \nfor line in sys.stdin:\n s=line.rstrip('\\n')\n m=re.match(r'\\s*\\[(.+?)\\]\\s+(.+?):\\s+(.*)',s)\n if m:\n score, speaker, text = m.group(1), m.group(2), m.group(3)\n rows.append({'id':speaker.replace(' ','-').lower(),'name':speaker,'description':('score=' + score + ' · ' + text[:60])})\nprint(json.dumps(rows))\"",
"fields": { "id": "id", "label": "name", "description": "description" },
"verbs": [
{ "name": "score", "label": "score testimonial text", "description": "rate via api.ts score", "fire": "echo 'paste text' | npx tsx ~/.claude/skills/snappy-testimonials/api.ts score {label}" }
]
}
]
}
{
"providers": [
{
"name": "testimonials",
"label": "testimonial",
"description": "captured testimonials with optional score",
"fetch": "npx tsx ~/.claude/skills/snappy-testimonials/api.ts list | python3 -c \"import sys,json,re; rows=[]; \nfor line in sys.stdin:\n s=line.rstrip('\\n')\n m=re.match(r'\\s*\\[(.+?)\\]\\s+(.+?):\\s+(.*)',s)\n if m:\n score, speaker, text = m.group(1), m.group(2), m.group(3)\n rows.append({'id':speaker.replace(' ','-').lower(),'name':speaker,'description':('score=' + score + ' · ' + text[:60])})\nprint(json.dumps(rows))\"",
"fields": { "id": "id", "label": "name", "description": "description" },
"verbs": [
{ "name": "score", "label": "score testimonial text", "description": "rate via api.ts score", "fire": "echo 'paste text' | npx tsx ~/.claude/skills/snappy-testimonials/api.ts score {label}" }
]
}
]
}
Channel-specific templates for asking a client if Snappy can use their quote as a testimonial. Each template follows Robert's voice rules (see snappy-content/anti-ai-checklist.md).
| Rule | Why |
|---|---|
| Casual, never corporate | Robert talks to clients like friends |
| Reference the exact moment | Specific date or context -- proves you didn't generate it |
| One quote per ask | Multiple quotes feels desperate; cuts reply rate |
| Never make the client write the testimonial | Frictionless yes/no -- they only need to confirm |
| Single ask | Don't bury the ask in pleasantries |
| No banned words | Run through snappy-content anti-AI checklist before sending |
| Sign with first name only ("Robert") | Not "Robert Boulos, Founder of Snappy" |
| No subject-line buzz ("Quick favor!") on email | Plain subject like "Something you said" |
Use when the client's preferred_channel is email, the relationship is formal/B2B, or the quote is for a public-facing case study.
Subject: Something you said
Hey [first name],
You said something on our call [day or date reference] that stuck with me:
> "[exact quote, verbatim]"
Mind if I use it as a testimonial? It would go [where -- landing page / case study / homepage]. Happy to send you the final placement before it goes live, and happy to use first name only if you'd rather.
No pressure either way.
Robert
Subject: Something you said
Hey Mark,
You said something on our call last Wednesday that stuck with me:
> "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."
Mind if I use it as a testimonial? It would go on the Orbiter case study page. Happy to send you the final placement before it goes live, and happy to use first name only if you'd rather.
No pressure either way.
Robert
dry_run: true first via snappy-email (POST /api:PB9UH7b9/emails/send), confirm the body, then resend with dry_run: falseUse when the client's preferred_channel is whatsapp, or the relationship is casual/international/fast-reply.
Hey [first name] -- quick one. You said this on our call [day reference]:
"[exact quote, verbatim]"
Cool if I use it as a testimonial? Would go [where]. First name only is fine if you prefer.
Hey Mark -- quick one. You said this on our call Wednesday:
"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."
Cool if I use it as a testimonial? Would go on the Orbiter case study. First name only is fine if you prefer.
snappy-whatsapp (POST /api:hZB4Dj0c/whatsapp-send-message)Use when the client has a shared Snappy Slack channel or when DM is the established comms pattern.
hey [first name], you said this on our call [day reference]:
> [exact quote, verbatim]
cool if I use it as a testimonial? would go [where]. first name only is fine if you prefer.
[first name] -- pulling this back up because it stuck with me:
> [exact quote]
cool if I use it as a testimonial? happy to put it on [where] with first name only if you prefer.
hey Mark, you said this on our call Wednesday:
> 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.
cool if I use it as a testimonial? would go on the Orbiter case study. first name only is fine if you prefer.
snappy-slack (POST /api:hZB4Dj0c/slack/bot-message or api:XOwEm4wm/slack/messages)> blockquote syntax (Slack renders it)@channel or @hereUse when the client's preferred_channel is imessage or the relationship is close/personal/Apple-only.
Hey [first name] -- you said this on our call [day reference]:
"[exact quote, verbatim]"
Cool if I use it as a testimonial? Would go [where]. First name only if you prefer.
Hey Mark -- you said this on our call Wednesday:
"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."
Cool if I use it as a testimonial? Would go on the Orbiter case study. First name only if you prefer.
snappy-imessage (imsg send --to +1... --text "..." over Mac Mini SSH)Swap these into any template based on the client's vibe.
| Vibe | Use |
|---|---|
| Direct | "Quick one -- " |
| Warm | "You said something I keep thinking about -- " |
| Reflective | "Going through our call notes from [date] and this jumped out -- " |
| Playful | "Caught you saying something good -- " |
| Vibe | Use |
|---|---|
| Default | "Mind if I use it as a testimonial?" |
| Soft | "Would you be okay with me using this as a testimonial?" |
| Direct | "Cool if I use this somewhere public?" |
| Specific | "Mind if I put this on the [page]?" |
| Vibe | Use |
|---|---|
| Default | "No pressure either way." |
| Warm | "Totally fine if not -- just wanted to ask." |
| Direct | "Yes, no, or edit it however you want." |
| Reciprocal | "Happy to do the same for you anytime." |
Wait 7 days. Then ONE follow-up only. Never more than two messages total.
Hey [first name] --
Bumping this back up. No rush, no pressure. If it's a no, just say no and I'll close the loop.
Robert
hey [first name] -- bumping this. no rush, just don't want it lost.
If no reply after the second message → log as testimonial_no_response in snappy-knowledge notes and stop. Do not message a third time.
| Reply type | Action |
|---|---|
| Plain yes | Confirm one detail back: "Cool -- using first name only and putting it on [page]. Sounds good?" Then log testimonial_approved=true with the quote text and approved attribution format in snappy-knowledge notes (see xano-tracking.md). Hand the approved quote to snappy-website or snappy-content. |
| Yes with edits | Use their exact edit. Confirm back: "Got it -- using this version: [their edit]." Log the EDITED version, not the original. The original is now off-limits. |
| Yes but anonymize | Confirm format: "Cool -- listing as 'Engineer at Y-Combinator-backed startup' instead of name + company?" Log the approved attribution format. Never use the named version. |
| No | "Totally cool, appreciate you saying so." Log testimonial_declined in snappy-knowledge notes with the date and the quote. Never re-ask the same quote. |
| No reply (after follow-up) | Log testimonial_no_response. Do not re-ask the same quote for at least 6 months. |
| "Can I see what you'd do with it first?" | Send a mock -- quote on the page in context. Wait for sign-off. Don't publish until they explicitly approve the mock. |
Approval logging schema lives in xano-tracking.md.
# Permission Request Templates
Channel-specific templates for asking a client if Snappy can use their quote as a testimonial. Each template follows Robert's voice rules (see [snappy-content/anti-ai-checklist.md](../snappy-content/anti-ai-checklist.md)).
## Table of Contents
- [Voice Rules (Apply To Every Channel)](#voice-rules-apply-to-every-channel)
- [Email](#email)
- [WhatsApp](#whatsapp)
- [Slack](#slack)
- [iMessage](#imessage)
- [Variation Library](#variation-library)
- [Follow-Up If No Reply](#follow-up-if-no-reply)
- [Handling Yes / No / Edit](#handling-yes--no--edit)
---
## Voice Rules (Apply To Every Channel)
| Rule | Why |
|------|-----|
| Casual, never corporate | Robert talks to clients like friends |
| Reference the exact moment | Specific date or context -- proves you didn't generate it |
| One quote per ask | Multiple quotes feels desperate; cuts reply rate |
| Never make the client write the testimonial | Frictionless yes/no -- they only need to confirm |
| Single ask | Don't bury the ask in pleasantries |
| No banned words | Run through `snappy-content` anti-AI checklist before sending |
| Sign with first name only ("Robert") | Not "Robert Boulos, Founder of Snappy" |
| No subject-line buzz ("Quick favor!") on email | Plain subject like "Something you said" |
### Banned (do not use these in any template)
- "I hope this finds you well" / "I hope you're doing well"
- "Just wanted to reach out"
- "Quick favor"
- "I'd love to" / "Would love to"
- "Truly", "really", "absolutely", "literally"
- "Game-changer", "thrilled", "delighted"
- "It would mean a lot"
- "If you have a minute"
- Em-dash overload (one is fine; three in a row reads AI)
---
## Email
Use when the client's `preferred_channel` is `email`, the relationship is formal/B2B, or the quote is for a public-facing case study.
### Template
```
Subject: Something you said
Hey [first name],
You said something on our call [day or date reference] that stuck with me:
> "[exact quote, verbatim]"
Mind if I use it as a testimonial? It would go [where -- landing page / case study / homepage]. Happy to send you the final placement before it goes live, and happy to use first name only if you'd rather.
No pressure either way.
Robert
```
### Filled example
```
Subject: Something you said
Hey Mark,
You said something on our call last Wednesday that stuck with me:
> "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."
Mind if I use it as a testimonial? It would go on the Orbiter case study page. Happy to send you the final placement before it goes live, and happy to use first name only if you'd rather.
No pressure either way.
Robert
```
### Email-specific notes
- Always send `dry_run: true` first via `snappy-email` (`POST /api:PB9UH7b9/emails/send`), confirm the body, then resend with `dry_run: false`
- Plain text only -- no HTML formatting beyond the blockquote
- BCC nothing
- Do not attach anything
---
## WhatsApp
Use when the client's `preferred_channel` is `whatsapp`, or the relationship is casual/international/fast-reply.
### Template
```
Hey [first name] -- quick one. You said this on our call [day reference]:
"[exact quote, verbatim]"
Cool if I use it as a testimonial? Would go [where]. First name only is fine if you prefer.
```
### Filled example
```
Hey Mark -- quick one. You said this on our call Wednesday:
"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."
Cool if I use it as a testimonial? Would go on the Orbiter case study. First name only is fine if you prefer.
```
### WhatsApp-specific notes
- Send via `snappy-whatsapp` (`POST /api:hZB4Dj0c/whatsapp-send-message`)
- One message, not three -- no separate "hey", "you good?", "quick question"
- Use plain quote marks, not blockquote (WhatsApp doesn't render blockquote)
- Emojis only if the existing thread already uses them (mirror the client's style)
---
## Slack
Use when the client has a shared Snappy Slack channel or when DM is the established comms pattern.
### Template (DM)
```
hey [first name], you said this on our call [day reference]:
> [exact quote, verbatim]
cool if I use it as a testimonial? would go [where]. first name only is fine if you prefer.
```
### Template (shared channel -- only if the conversation happened there originally)
```
[first name] -- pulling this back up because it stuck with me:
> [exact quote]
cool if I use it as a testimonial? happy to put it on [where] with first name only if you prefer.
```
### Filled example (DM)
```
hey Mark, you said this on our call Wednesday:
> 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.
cool if I use it as a testimonial? would go on the Orbiter case study. first name only is fine if you prefer.
```
### Slack-specific notes
- Send via `snappy-slack` (`POST /api:hZB4Dj0c/slack/bot-message` or `api:XOwEm4wm/slack/messages`)
- Lowercase "hey" (Slack convention, mirrors how Robert actually types in Slack)
- Use the `>` blockquote syntax (Slack renders it)
- Never tag with `@channel` or `@here`
- DM by default unless the original conversation was in the shared channel
---
## iMessage
Use when the client's `preferred_channel` is `imessage` or the relationship is close/personal/Apple-only.
### Template
```
Hey [first name] -- you said this on our call [day reference]:
"[exact quote, verbatim]"
Cool if I use it as a testimonial? Would go [where]. First name only if you prefer.
```
### Filled example
```
Hey Mark -- you said this on our call Wednesday:
"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."
Cool if I use it as a testimonial? Would go on the Orbiter case study. First name only if you prefer.
```
### iMessage-specific notes
- Send via `snappy-imessage` (`imsg send --to +1... --text "..."` over Mac Mini SSH)
- Use straight quotes (no smart quotes -- they get mangled in some clients)
- One message -- never split into "Hey" + "wanted to ask"
- Robert's iMessage tone is slightly more formal than Slack but warmer than email
---
## Variation Library
Swap these into any template based on the client's vibe.
### Opener variations
| Vibe | Use |
|------|-----|
| Direct | "Quick one -- " |
| Warm | "You said something I keep thinking about -- " |
| Reflective | "Going through our call notes from [date] and this jumped out -- " |
| Playful | "Caught you saying something good -- " |
### Ask variations
| Vibe | Use |
|------|-----|
| Default | "Mind if I use it as a testimonial?" |
| Soft | "Would you be okay with me using this as a testimonial?" |
| Direct | "Cool if I use this somewhere public?" |
| Specific | "Mind if I put this on the [page]?" |
### Closer variations (always offer the out)
| Vibe | Use |
|------|-----|
| Default | "No pressure either way." |
| Warm | "Totally fine if not -- just wanted to ask." |
| Direct | "Yes, no, or edit it however you want." |
| Reciprocal | "Happy to do the same for you anytime." |
---
## Follow-Up If No Reply
Wait 7 days. Then ONE follow-up only. Never more than two messages total.
### Email follow-up
```
Hey [first name] --
Bumping this back up. No rush, no pressure. If it's a no, just say no and I'll close the loop.
Robert
```
### WhatsApp / Slack / iMessage follow-up
```
hey [first name] -- bumping this. no rush, just don't want it lost.
```
If no reply after the second message → log as `testimonial_no_response` in `snappy-knowledge` notes and stop. Do not message a third time.
---
## Handling Yes / No / Edit
| Reply type | Action |
|-----------|--------|
| **Plain yes** | Confirm one detail back: "Cool -- using first name only and putting it on [page]. Sounds good?" Then log `testimonial_approved=true` with the quote text and approved attribution format in `snappy-knowledge` notes (see [xano-tracking.md](xano-tracking.md)). Hand the approved quote to `snappy-website` or `snappy-content`. |
| **Yes with edits** | Use their exact edit. Confirm back: "Got it -- using this version: [their edit]." Log the EDITED version, not the original. The original is now off-limits. |
| **Yes but anonymize** | Confirm format: "Cool -- listing as 'Engineer at Y-Combinator-backed startup' instead of name + company?" Log the approved attribution format. Never use the named version. |
| **No** | "Totally cool, appreciate you saying so." Log `testimonial_declined` in `snappy-knowledge` notes with the date and the quote. Never re-ask the same quote. |
| **No reply (after follow-up)** | Log `testimonial_no_response`. Do not re-ask the same quote for at least 6 months. |
| **"Can I see what you'd do with it first?"** | Send a mock -- quote on the page in context. Wait for sign-off. Don't publish until they explicitly approve the mock. |
Approval logging schema lives in [xano-tracking.md](xano-tracking.md).
The full scoring system used by snappy-testimonials to filter and rank candidate quotes from client transcripts. This is the canonical version of the rubric summarized in SKILL.md.
Run these checks BEFORE scoring. Any one of them auto-disqualifies the quote -- do not even score it.
| Check | Reason |
|---|---|
Speaker is not in snappy-knowledge tag=client or tag=past_client |
A testimonial requires a real client, not a prospect or one-off contact |
| Speaker is Robert | We never quote ourselves |
| Quote is older than 12 months | Client may not remember it; impact has decayed; default to last 6 months |
| Source is a public chat / podcast / livestream | Out of scope -- only private 1:1 / project meetings |
| Quote contains NDA topics | Confidentiality risk; reject and do not log |
| Quote contains client financial details (revenue, MRR, valuation, fundraise specifics) | Even with permission, this is risky to publish |
| Quote names a third party negatively | "Way better than [competitor]" -- never publish; risk to client |
| Quote contains internal team conflict references | Protects the client's relationships |
| Quote is paraphrased / summarized in the transcript metadata | We need verbatim only |
If the quote passes all of the above, proceed to scoring.
Score each on 1-5. Sum across the four = total /20.
How concrete is the language? Generic praise scores low; named outcomes score high.
| Score | Description | Example |
|---|---|---|
| 1 | Pure generic | "It was good." |
| 2 | Vague positive | "Really helpful, you guys are great." |
| 3 | Some detail | "It really helped us with the data side." |
| 4 | Named outcome | "It cut our manual review work way down." |
| 5 | Hard metric / named result | "Cut our QA review from 4 hours to 20 minutes per batch." |
Does it sound like the client actually talking, or like a marketing brochure? Robert's filter: "Can I hear them say this in their voice?"
| Score | Description | Example |
|---|---|---|
| 1 | Reads like a sales script | "Snappy is the premier AI consultancy in the market." |
| 2 | Polished but stiff | "We're very happy with the engagement." |
| 3 | Normal conversational | "Honestly, this has been a great experience." |
| 4 | Distinctive voice | "I keep telling my team -- like, this just shouldn't be possible this fast." |
| 5 | Unmistakably the client | "Robert, I don't even know how to thank you. We were dead in the water." |
Does the quote describe a real outcome -- saved time, made money, prevented a fire, changed how the team works?
| Score | Description | Example |
|---|---|---|
| 1 | No outcome | "It's nice." |
| 2 | Vague benefit | "It helps." |
| 3 | Soft outcome | "Our team feels less stressed." |
| 4 | Named change in behavior or process | "Now we know exactly which records are stuck." |
| 5 | Quantified or named hard result | "Saved us 20 hours per week and unblocked the LLM bio rebuild." |
Can the quote stand on its own with just attribution, or does it need a paragraph of setup?
| Score | Description | Test |
|---|---|---|
| 1 | Needs 5+ sentences of setup | "Yeah, that one." (only makes sense in context) |
| 2 | Needs a topic sentence | "It was the second one I mentioned." |
| 3 | Needs one line of setup | "On the data quality side, this changed everything." |
| 4 | Self-contained with light context | "The diagnostic visibility was a game-changer for our team." (paired with name + role) |
| 5 | Reads great with attribution alone | "Snappy turned our QA pipeline from a black box into a glass house." -- Mark, Orbiter |
A quote MUST score:
Otherwise it's noise. Don't fill the testimonial pipeline with mediocre material.
If a search yields fewer than 5 quotes above threshold, return what's there and tell Robert how many were rejected and why.
"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."
| Dimension | Score | Reasoning |
|---|---|---|
| Specificity | 5 | Names "QA endpoints", "stuck records", uses concrete before/after |
| Authenticity | 5 | Mark's voice -- direct, no buzzwords |
| Business Impact | 4 | Named change in process visibility (no hard number, but explicit transformation) |
| Standalone | 4 | Reads great; only needs Mark's name + Orbiter |
| Total | 18/20 | SURFACE |
"I appreciate how responsive the team has been."
| Dimension | Score | Reasoning |
|---|---|---|
| Specificity | 2 | "Responsive" is vague |
| Authenticity | 3 | Conversational but flat |
| Business Impact | 2 | No outcome stated |
| Standalone | 3 | Works alone but adds nothing memorable |
| Total | 10/20 | SKIP |
"I sent you an email at 11pm and woke up to a working prototype. I literally didn't believe it until I clicked it."
| Dimension | Score | Reasoning |
|---|---|---|
| Specificity | 5 | Concrete timing, "working prototype", "clicked it" |
| Authenticity | 5 | Distinct voice, includes the disbelief reaction |
| Business Impact | 4 | Speed-to-value implied; not a hard metric but vivid |
| Standalone | 5 | Self-contained narrative |
| Total | 19/20 | SURFACE -- top candidate |
"Working with Snappy has been a great experience overall."
| Dimension | Score | Reasoning |
|---|---|---|
| Specificity | 2 | "Great" is the AI filler word category |
| Authenticity | 2 | Sounds like a Trustpilot review |
| Business Impact | 1 | No outcome |
| Standalone | 4 | Self-contained but boring |
| Total | 9/20 | SKIP |
| Situation | How to handle |
|---|---|
| Speaker shares praise + a complaint in the same sentence | Reject the quote -- too risky to extract just the positive half |
| Quote is great but speaker is a contractor / employee of the client (not the founder/decision-maker) | Score normally; flag the attribution clarification for Robert (use first name only or "engineer at [company]") |
| Quote praises a specific Snappy team member by name | Score normally; verify the person is okay with being named in the ask |
| Two clients say nearly the same thing | Surface both; let Robert pick the strongest voice |
| Quote is in a non-English language Robert can read | Surface it with both original and a note; ask permission in the client's language |
| Speaker says "you can quote me on that" in the meeting | Still ask formally -- the in-meeting throwaway is not consent |
| Quote references a future state ("once you ship X this will be amazing") | Reject -- testimonial of a thing that hasn't happened yet |
| Quote is sarcastic | Reject -- sentiment analysis often misses sarcasm; verify the surrounding context |
Track the reject reason when reporting back to Robert. Common reasons:
| Reason | Frequency | Example |
|---|---|---|
| Below specificity threshold | most common | "It was good." |
| Below authenticity threshold | common | Reads like a press release |
| Speaker not a verified client | common | Krisp didn't label, can't confirm |
| Too old | medium | Q2 2025 quote, client may not remember |
| Contains NDA topic | rare but critical | Quote names a confidential project |
| Mentions third party negatively | rare | "Better than [competitor]" |
| Already asked + declined | tracked | Logged in snappy-knowledge notes |
Reporting format when there are no surfaced candidates:
Scanned 14 meetings across 4 active clients.
0 candidates met the threshold.
Rejected: 9 specificity, 3 authenticity, 1 NDA topic, 1 already declined.
Recommendation: hold the testimonial scan for 4 weeks and re-run after the next round of project milestones.
Default scan window: last 6 months. Quotes from the most recent month get a +1 emotional intensity bonus (the client remembers the moment vividly and the ask is natural).
| Age | Modifier | Note |
|---|---|---|
| < 30 days | +1 to Authenticity | Client remembers the conversation |
| 30-90 days | no modifier | Default window |
| 90-180 days | -1 to Standalone | Client may need a context reminder in the ask |
| 180-365 days | -2 to Standalone | Only surface if exceptionally strong |
| > 365 days | reject by default | Override only on Robert's explicit request |
The recency bonus/penalty modifies the surfaced score, not the raw rubric score. Note both in the candidate output.
# Quote Quality Rubric -- Full Reference
The full scoring system used by `snappy-testimonials` to filter and rank candidate quotes from client transcripts. This is the canonical version of the rubric summarized in [SKILL.md](SKILL.md#quote-quality-rubric).
## Table of Contents
- [Pre-Filter Disqualifiers](#pre-filter-disqualifiers)
- [The Four Dimensions](#the-four-dimensions)
- [Worked Examples](#worked-examples)
- [Edge Cases](#edge-cases)
- [Why Quotes Get Rejected](#why-quotes-get-rejected)
- [Recency Weighting](#recency-weighting)
---
## Pre-Filter Disqualifiers
Run these checks BEFORE scoring. Any one of them auto-disqualifies the quote -- do not even score it.
| Check | Reason |
|-------|--------|
| Speaker is not in `snappy-knowledge` `tag=client` or `tag=past_client` | A testimonial requires a real client, not a prospect or one-off contact |
| Speaker is Robert | We never quote ourselves |
| Quote is older than 12 months | Client may not remember it; impact has decayed; default to last 6 months |
| Source is a public chat / podcast / livestream | Out of scope -- only private 1:1 / project meetings |
| Quote contains NDA topics | Confidentiality risk; reject and do not log |
| Quote contains client financial details (revenue, MRR, valuation, fundraise specifics) | Even with permission, this is risky to publish |
| Quote names a third party negatively | "Way better than [competitor]" -- never publish; risk to client |
| Quote contains internal team conflict references | Protects the client's relationships |
| Quote is paraphrased / summarized in the transcript metadata | We need verbatim only |
If the quote passes all of the above, proceed to scoring.
---
## The Four Dimensions
Score each on 1-5. Sum across the four = total /20.
### Specificity (1-5)
How concrete is the language? Generic praise scores low; named outcomes score high.
| Score | Description | Example |
|-------|-------------|---------|
| 1 | Pure generic | "It was good." |
| 2 | Vague positive | "Really helpful, you guys are great." |
| 3 | Some detail | "It really helped us with the data side." |
| 4 | Named outcome | "It cut our manual review work way down." |
| 5 | Hard metric / named result | "Cut our QA review from 4 hours to 20 minutes per batch." |
### Authenticity (1-5)
Does it sound like the client actually talking, or like a marketing brochure? Robert's filter: "Can I hear them say this in their voice?"
| Score | Description | Example |
|-------|-------------|---------|
| 1 | Reads like a sales script | "Snappy is the premier AI consultancy in the market." |
| 2 | Polished but stiff | "We're very happy with the engagement." |
| 3 | Normal conversational | "Honestly, this has been a great experience." |
| 4 | Distinctive voice | "I keep telling my team -- like, this just shouldn't be possible this fast." |
| 5 | Unmistakably the client | "Robert, I don't even know how to thank you. We were dead in the water." |
### Business Impact (1-5)
Does the quote describe a real outcome -- saved time, made money, prevented a fire, changed how the team works?
| Score | Description | Example |
|-------|-------------|---------|
| 1 | No outcome | "It's nice." |
| 2 | Vague benefit | "It helps." |
| 3 | Soft outcome | "Our team feels less stressed." |
| 4 | Named change in behavior or process | "Now we know exactly which records are stuck." |
| 5 | Quantified or named hard result | "Saved us 20 hours per week and unblocked the LLM bio rebuild." |
### Standalone Clarity (1-5)
Can the quote stand on its own with just attribution, or does it need a paragraph of setup?
| Score | Description | Test |
|-------|-------------|------|
| 1 | Needs 5+ sentences of setup | "Yeah, that one." (only makes sense in context) |
| 2 | Needs a topic sentence | "It was the second one I mentioned." |
| 3 | Needs one line of setup | "On the data quality side, this changed everything." |
| 4 | Self-contained with light context | "The diagnostic visibility was a game-changer for our team." (paired with name + role) |
| 5 | Reads great with attribution alone | "Snappy turned our QA pipeline from a black box into a glass house." -- Mark, Orbiter |
---
## Threshold to Surface
A quote MUST score:
- **Total ≥ 14/20**
- **Specificity ≥ 4**
- **Authenticity ≥ 4**
Otherwise it's noise. Don't fill the testimonial pipeline with mediocre material.
If a search yields fewer than 5 quotes above threshold, return what's there and tell Robert how many were rejected and why.
---
## Worked Examples
### Example 1 -- Mark @ Orbiter (data quality)
> "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."
| Dimension | Score | Reasoning |
|-----------|-------|-----------|
| Specificity | 5 | Names "QA endpoints", "stuck records", uses concrete before/after |
| Authenticity | 5 | Mark's voice -- direct, no buzzwords |
| Business Impact | 4 | Named change in process visibility (no hard number, but explicit transformation) |
| Standalone | 4 | Reads great; only needs Mark's name + Orbiter |
| **Total** | **18/20** | **SURFACE** |
### Example 2 -- James @ Total CRM (relationship)
> "I appreciate how responsive the team has been."
| Dimension | Score | Reasoning |
|-----------|-------|-----------|
| Specificity | 2 | "Responsive" is vague |
| Authenticity | 3 | Conversational but flat |
| Business Impact | 2 | No outcome stated |
| Standalone | 3 | Works alone but adds nothing memorable |
| **Total** | **10/20** | **SKIP** |
### Example 3 -- Scott @ Project (speed)
> "I sent you an email at 11pm and woke up to a working prototype. I literally didn't believe it until I clicked it."
| Dimension | Score | Reasoning |
|-----------|-------|-----------|
| Specificity | 5 | Concrete timing, "working prototype", "clicked it" |
| Authenticity | 5 | Distinct voice, includes the disbelief reaction |
| Business Impact | 4 | Speed-to-value implied; not a hard metric but vivid |
| Standalone | 5 | Self-contained narrative |
| **Total** | **19/20** | **SURFACE -- top candidate** |
### Example 4 -- Generic positive
> "Working with Snappy has been a great experience overall."
| Dimension | Score | Reasoning |
|-----------|-------|-----------|
| Specificity | 2 | "Great" is the AI filler word category |
| Authenticity | 2 | Sounds like a Trustpilot review |
| Business Impact | 1 | No outcome |
| Standalone | 4 | Self-contained but boring |
| **Total** | **9/20** | **SKIP** |
---
## Edge Cases
| Situation | How to handle |
|-----------|---------------|
| Speaker shares praise + a complaint in the same sentence | Reject the quote -- too risky to extract just the positive half |
| Quote is great but speaker is a contractor / employee of the client (not the founder/decision-maker) | Score normally; flag the attribution clarification for Robert (use first name only or "engineer at [company]") |
| Quote praises a specific Snappy team member by name | Score normally; verify the person is okay with being named in the ask |
| Two clients say nearly the same thing | Surface both; let Robert pick the strongest voice |
| Quote is in a non-English language Robert can read | Surface it with both original and a note; ask permission in the client's language |
| Speaker says "you can quote me on that" in the meeting | Still ask formally -- the in-meeting throwaway is not consent |
| Quote references a future state ("once you ship X this will be amazing") | Reject -- testimonial of a thing that hasn't happened yet |
| Quote is sarcastic | Reject -- sentiment analysis often misses sarcasm; verify the surrounding context |
---
## Why Quotes Get Rejected
Track the reject reason when reporting back to Robert. Common reasons:
| Reason | Frequency | Example |
|--------|-----------|---------|
| Below specificity threshold | most common | "It was good." |
| Below authenticity threshold | common | Reads like a press release |
| Speaker not a verified client | common | Krisp didn't label, can't confirm |
| Too old | medium | Q2 2025 quote, client may not remember |
| Contains NDA topic | rare but critical | Quote names a confidential project |
| Mentions third party negatively | rare | "Better than [competitor]" |
| Already asked + declined | tracked | Logged in `snappy-knowledge` notes |
Reporting format when there are no surfaced candidates:
```
Scanned 14 meetings across 4 active clients.
0 candidates met the threshold.
Rejected: 9 specificity, 3 authenticity, 1 NDA topic, 1 already declined.
Recommendation: hold the testimonial scan for 4 weeks and re-run after the next round of project milestones.
```
---
## Recency Weighting
Default scan window: **last 6 months**. Quotes from the most recent month get a +1 emotional intensity bonus (the client remembers the moment vividly and the ask is natural).
| Age | Modifier | Note |
|-----|----------|------|
| < 30 days | +1 to Authenticity | Client remembers the conversation |
| 30-90 days | no modifier | Default window |
| 90-180 days | -1 to Standalone | Client may need a context reminder in the ask |
| 180-365 days | -2 to Standalone | Only surface if exceptionally strong |
| > 365 days | reject by default | Override only on Robert's explicit request |
The recency bonus/penalty modifies the surfaced score, not the raw rubric score. Note both in the candidate output.
/**
* COVERAGE FOR SNAPPY-TESTIMONIALS'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-testimonials declares. */
const DECLARED = [
"missing_argument",
"unknown_verb",
] as const;
test("snappy-testimonials 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-TESTIMONIALS'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-testimonials declares. */
const DECLARED = [
"missing_argument",
"unknown_verb",
] as const;
test("snappy-testimonials 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"));
});
#!/usr/bin/env bash
# scan-testimonials.sh -- Scan Krisp transcript corpus for testimonial candidates
# Part of snappy-testimonials skill
#
# Usage:
# ./scan-testimonials.sh # scan all 2026 transcripts
# ./scan-testimonials.sh 2026/04 # scan specific month
# ./scan-testimonials.sh 2026/03 "mark" # scan month, filter by speaker
#
# Output: TSV -- file | speaker | quote_preview | score_hint
set -euo pipefail
CORPUS_ROOT="$HOME/.claude/corpus/krisp"
SUBPATH="${1:-2026}"
SPEAKER_FILTER="${2:-}"
# --- Pattern sets (aligned with SKILL.md sentiment seeds) ---
# Transformation language -- before/after, change verbs
TRANSFORM_PAT='(before this|before we|before you|now we|now I|completely changed|transformed|game.?changer|night and day|turned around|changed how)'
# Specific outcomes -- metrics, speed, savings
OUTCOME_PAT='(saved|increased|reduced|faster|cut.*from.*to|went from|doubled|tripled|percent|hours to|minutes to|days to|half the time)'
# Praise / gratitude
PRAISE_PAT='(thank you|thanks so much|really appreciate|this is amazing|this is incredible|love (this|it|what)|blown away|exceeded|above and beyond|couldn.t have done|life.?saver|so helpful|huge difference|exactly what (we|I) needed)'
# Relationship / methodology signals
RELATION_PAT='(your approach|easy to work with|responsive|on top of|always available|trust you|great to work|best (we.ve|I.ve) worked|pleasure working|smooth|seamless)'
# Recommendation signals
RECOMMEND_PAT='(would recommend|told my|refer|sent them to you|telling people|spreading the word|should talk to Robert|you should hire|check out snappy)'
# --- Disqualifier: skip lines where Robert is the speaker ---
ROBERT_PAT='^\*\*Robert Boulos'
# --- Collect transcript files ---
find_transcripts() {
find "$CORPUS_ROOT/$SUBPATH" -name "*.md" \
! -name "*.summary.md" \
! -name "*.nuggets.json" \
! -name "index.json" \
-type f 2>/dev/null | sort
}
# --- Extract speaker from a transcript line ---
# Format: **Speaker Name | 00:00**
extract_speaker() {
echo "$1" | sed -n 's/^\*\*\([^|]*\) |.*/\1/p' | sed 's/[[:space:]]*$//'
}
# --- Score a match: high/medium/low based on pattern category hits ---
score_line() {
local line="$1"
local hits=0
echo "$line" | grep -qiE "$TRANSFORM_PAT" 2>/dev/null && ((hits+=2)) || true
echo "$line" | grep -qiE "$OUTCOME_PAT" 2>/dev/null && ((hits+=2)) || true
echo "$line" | grep -qiE "$PRAISE_PAT" 2>/dev/null && ((hits+=1)) || true
echo "$line" | grep -qiE "$RELATION_PAT" 2>/dev/null && ((hits+=1)) || true
echo "$line" | grep -qiE "$RECOMMEND_PAT" 2>/dev/null && ((hits+=2)) || true
if [ "$hits" -ge 3 ]; then
echo "high"
elif [ "$hits" -ge 2 ]; then
echo "medium"
else
echo "low"
fi
}
# --- Truncate quote for preview (first 120 chars) ---
preview() {
echo "$1" | head -c 120 | tr '\n' ' '
}
# --- Main scan ---
main() {
local files
files=$(find_transcripts)
if [ -z "$files" ]; then
echo "ERROR: No transcripts found at $CORPUS_ROOT/$SUBPATH" >&2
exit 1
fi
local file_count
file_count=$(echo "$files" | wc -l | tr -d ' ')
echo "# Scanning $file_count transcripts under $CORPUS_ROOT/$SUBPATH" >&2
[ -n "$SPEAKER_FILTER" ] && echo "# Speaker filter: $SPEAKER_FILTER" >&2
echo "" >&2
# Header
printf "FILE\tSPEAKER\tQUOTE_PREVIEW\tSCORE\n"
local current_speaker=""
local candidate_count=0
while IFS= read -r file; do
local relpath="${file#$CORPUS_ROOT/}"
# Read file, track current speaker, grep for pattern matches
while IFS= read -r line; do
# Update speaker if this is a speaker line
if echo "$line" | grep -qE '^\*\*[A-Z].*\|.*\*\*$'; then
current_speaker=$(extract_speaker "$line")
continue
fi
# Skip Robert's own lines
[ -z "$current_speaker" ] && continue
echo "$current_speaker" | grep -qi "robert boulos" && continue
# Apply speaker filter if set
if [ -n "$SPEAKER_FILTER" ]; then
echo "$current_speaker" | grep -qi "$SPEAKER_FILTER" || continue
fi
# Skip empty / very short lines (< 20 chars not useful)
[ "${#line}" -lt 20 ] && continue
# Test against all pattern sets
if echo "$line" | grep -qiE "$TRANSFORM_PAT|$OUTCOME_PAT|$PRAISE_PAT|$RELATION_PAT|$RECOMMEND_PAT" 2>/dev/null; then
local score
score=$(score_line "$line")
local prev
prev=$(preview "$line")
printf "%s\t%s\t%s\t%s\n" "$relpath" "$current_speaker" "$prev" "$score"
((candidate_count+=1))
fi
done < "$file"
done <<< "$files"
echo "" >&2
echo "# Found $candidate_count candidate quotes" >&2
echo "# Pipe through: sort -t$'\t' -k4 -r to sort by score" >&2
}
main
#!/usr/bin/env bash
# scan-testimonials.sh -- Scan Krisp transcript corpus for testimonial candidates
# Part of snappy-testimonials skill
#
# Usage:
# ./scan-testimonials.sh # scan all 2026 transcripts
# ./scan-testimonials.sh 2026/04 # scan specific month
# ./scan-testimonials.sh 2026/03 "mark" # scan month, filter by speaker
#
# Output: TSV -- file | speaker | quote_preview | score_hint
set -euo pipefail
CORPUS_ROOT="$HOME/.claude/corpus/krisp"
SUBPATH="${1:-2026}"
SPEAKER_FILTER="${2:-}"
# --- Pattern sets (aligned with SKILL.md sentiment seeds) ---
# Transformation language -- before/after, change verbs
TRANSFORM_PAT='(before this|before we|before you|now we|now I|completely changed|transformed|game.?changer|night and day|turned around|changed how)'
# Specific outcomes -- metrics, speed, savings
OUTCOME_PAT='(saved|increased|reduced|faster|cut.*from.*to|went from|doubled|tripled|percent|hours to|minutes to|days to|half the time)'
# Praise / gratitude
PRAISE_PAT='(thank you|thanks so much|really appreciate|this is amazing|this is incredible|love (this|it|what)|blown away|exceeded|above and beyond|couldn.t have done|life.?saver|so helpful|huge difference|exactly what (we|I) needed)'
# Relationship / methodology signals
RELATION_PAT='(your approach|easy to work with|responsive|on top of|always available|trust you|great to work|best (we.ve|I.ve) worked|pleasure working|smooth|seamless)'
# Recommendation signals
RECOMMEND_PAT='(would recommend|told my|refer|sent them to you|telling people|spreading the word|should talk to Robert|you should hire|check out snappy)'
# --- Disqualifier: skip lines where Robert is the speaker ---
ROBERT_PAT='^\*\*Robert Boulos'
# --- Collect transcript files ---
find_transcripts() {
find "$CORPUS_ROOT/$SUBPATH" -name "*.md" \
! -name "*.summary.md" \
! -name "*.nuggets.json" \
! -name "index.json" \
-type f 2>/dev/null | sort
}
# --- Extract speaker from a transcript line ---
# Format: **Speaker Name | 00:00**
extract_speaker() {
echo "$1" | sed -n 's/^\*\*\([^|]*\) |.*/\1/p' | sed 's/[[:space:]]*$//'
}
# --- Score a match: high/medium/low based on pattern category hits ---
score_line() {
local line="$1"
local hits=0
echo "$line" | grep -qiE "$TRANSFORM_PAT" 2>/dev/null && ((hits+=2)) || true
echo "$line" | grep -qiE "$OUTCOME_PAT" 2>/dev/null && ((hits+=2)) || true
echo "$line" | grep -qiE "$PRAISE_PAT" 2>/dev/null && ((hits+=1)) || true
echo "$line" | grep -qiE "$RELATION_PAT" 2>/dev/null && ((hits+=1)) || true
echo "$line" | grep -qiE "$RECOMMEND_PAT" 2>/dev/null && ((hits+=2)) || true
if [ "$hits" -ge 3 ]; then
echo "high"
elif [ "$hits" -ge 2 ]; then
echo "medium"
else
echo "low"
fi
}
# --- Truncate quote for preview (first 120 chars) ---
preview() {
echo "$1" | head -c 120 | tr '\n' ' '
}
# --- Main scan ---
main() {
local files
files=$(find_transcripts)
if [ -z "$files" ]; then
echo "ERROR: No transcripts found at $CORPUS_ROOT/$SUBPATH" >&2
exit 1
fi
local file_count
file_count=$(echo "$files" | wc -l | tr -d ' ')
echo "# Scanning $file_count transcripts under $CORPUS_ROOT/$SUBPATH" >&2
[ -n "$SPEAKER_FILTER" ] && echo "# Speaker filter: $SPEAKER_FILTER" >&2
echo "" >&2
# Header
printf "FILE\tSPEAKER\tQUOTE_PREVIEW\tSCORE\n"
local current_speaker=""
local candidate_count=0
while IFS= read -r file; do
local relpath="${file#$CORPUS_ROOT/}"
# Read file, track current speaker, grep for pattern matches
while IFS= read -r line; do
# Update speaker if this is a speaker line
if echo "$line" | grep -qE '^\*\*[A-Z].*\|.*\*\*$'; then
current_speaker=$(extract_speaker "$line")
continue
fi
# Skip Robert's own lines
[ -z "$current_speaker" ] && continue
echo "$current_speaker" | grep -qi "robert boulos" && continue
# Apply speaker filter if set
if [ -n "$SPEAKER_FILTER" ]; then
echo "$current_speaker" | grep -qi "$SPEAKER_FILTER" || continue
fi
# Skip empty / very short lines (< 20 chars not useful)
[ "${#line}" -lt 20 ] && continue
# Test against all pattern sets
if echo "$line" | grep -qiE "$TRANSFORM_PAT|$OUTCOME_PAT|$PRAISE_PAT|$RELATION_PAT|$RECOMMEND_PAT" 2>/dev/null; then
local score
score=$(score_line "$line")
local prev
prev=$(preview "$line")
printf "%s\t%s\t%s\t%s\n" "$relpath" "$current_speaker" "$prev" "$score"
((candidate_count+=1))
fi
done < "$file"
done <<< "$files"
echo "" >&2
echo "# Found $candidate_count candidate quotes" >&2
echo "# Pipe through: sort -t$'\t' -k4 -r to sort by score" >&2
}
main
How snappy-testimonials reads from and writes back to the knowledge graph (snappy-knowledge) so the same quote is never asked twice and approved testimonials are flagged for use by snappy-content and snappy-website.
Credentials load from snappy-settings/.env.cache via env("KEY") -- see snappy-settings/SKILL.md.
bashSNAPPY_SETTINGS_QUIET=1 source ~/.claude/skills/snappy-settings/scripts/load-env.sh
Until dedicated testimonials endpoints land in Xano (see Aspirational Endpoints), all approval state lives in the contact's notes field on snappy-knowledge contacts table -- appended, never overwritten.
| Field | Source of truth |
|---|---|
| Contact ID | snappy-knowledge contacts.id |
| Quote text + citation | Appended block in contacts.notes |
| Ask date | Appended in the same block |
| Channel asked through | Appended in the same block |
| Approval status | Appended (testimonial_asked, testimonial_approved, testimonial_declined, testimonial_no_response) |
| Approved attribution format | Appended (e.g. attribution: first_name_only, attribution: full_name_company) |
The convention is: prepend each block with a marker so future scans can grep for it.
[testimonial_<status> YYYY-MM-DD] quote: "<quote text>" | source: krisp <id> @ <timestamp> | channel: <slack|email|whatsapp|imessage> | attribution: <format>
Examples:
[testimonial_asked 2026-04-07] quote: "The QA endpoints completely changed how we think about data quality." | source: krisp abc123 @ 00:23:45 | channel: slack | attribution: pending
[testimonial_approved 2026-04-09] quote: "The QA endpoints completely changed how we think about data quality." | source: krisp abc123 @ 00:23:45 | channel: slack | attribution: first_name_only
[testimonial_declined 2026-03-15] quote: "I love the speed, it's like overnight." | source: krisp xyz789 @ 00:08:12 | channel: email | attribution: n/a
Schema defined in snappy-knowledge/schemas.md. Relevant fields for this skill:
| Field | Used for |
|---|---|
id |
Contact lookup |
name |
Speaker matching against transcript |
email |
Cross-check transcript domain matching |
company |
Used in attribution format |
tags |
Filter by client or past_client |
preferred_channel |
Determines which permission template to use |
notes |
Where the testimonial blocks live (append-only) |
last_contact |
Update after the ask is sent |
bashcurl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq '.[] | {id, name, email, company, preferred_channel, notes}'
bashcurl -s "$XANO/api:PB9UH7b9/contacts?tag=past_client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq '.[] | {id, name, email, company, preferred_channel, notes}'
bashcurl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq '.[] | select(.id == 123) | .notes' \
| grep -o '\[testimonial_[a-z_]* [0-9-]*\] quote: "[^"]*"' || echo "No prior testimonial activity"
bashcurl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '.[] | select(.notes | test("testimonial_declined")) | "\(.name) -- \(.notes)"'
snappy-content / snappy-website)#bashcurl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '.[] | select(.notes | test("testimonial_approved")) | "\(.name) (\(.company)) -- \(.notes)"'
CRITICAL: read existing notes first, append the new block, write the combined string back. Never overwrite.
bash# 1. Read current notes
EXISTING_NOTES=$(curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '.[] | select(.id == 123) | .notes')
# 2. Compose the new block
NEW_BLOCK='[testimonial_asked 2026-04-07] quote: "The QA endpoints completely changed how we think about data quality." | source: krisp abc123 @ 00:23:45 | channel: slack | attribution: pending'
# 3. Append + PATCH
COMBINED=$(printf "%s\n%s" "$EXISTING_NOTES" "$NEW_BLOCK")
curl -s -X PATCH "$XANO/api:PB9UH7b9/contacts/123" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_METADATA_TOKEN" \
-d "$(jq -n --arg notes "$COMBINED" '{notes: $notes, last_contact: "2026-04-07"}')"
After the client says yes, update the matching block in place.
bash# Read existing notes
EXISTING=$(curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '.[] | select(.id == 123) | .notes')
# Replace the asked block with the approved block (sed-style)
UPDATED=$(echo "$EXISTING" | python3 -c "
import sys, re
text = sys.stdin.read()
text = re.sub(
r'\[testimonial_asked 2026-04-07\](.*)attribution: pending',
r'[testimonial_approved 2026-04-09]\1attribution: first_name_only',
text
)
print(text)
")
curl -s -X PATCH "$XANO/api:PB9UH7b9/contacts/123" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_METADATA_TOKEN" \
-d "$(jq -n --arg notes "$UPDATED" '{notes: $notes}')"
bash# Same pattern as approved -- flip status to declined, leave attribution n/a
UPDATED=$(echo "$EXISTING" | sed 's/testimonial_asked/testimonial_declined/' | sed 's/attribution: pending/attribution: n\/a/')
curl -s -X PATCH "$XANO/api:PB9UH7b9/contacts/123" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_METADATA_TOKEN" \
-d "$(jq -n --arg notes "$UPDATED" '{notes: $notes}')"
bashUPDATED=$(echo "$EXISTING" | sed 's/testimonial_asked/testimonial_no_response/')
curl -s -X PATCH "$XANO/api:PB9UH7b9/contacts/123" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_METADATA_TOKEN" \
-d "$(jq -n --arg notes "$UPDATED" '{notes: $notes}')"
bashcurl -s -X POST "$XANO/api:PB9UH7b9/contacts/123/touchpoints" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_METADATA_TOKEN" \
-d '{"type": "testimonial_request", "notes": "Sent permission ask via slack for quote from krisp abc123"}'
Used by snappy-content and snappy-website when they need approved quotes for use.
bashcurl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '
.[] |
select(.notes | test("testimonial_approved")) |
{
contact: .name,
company: .company,
preferred_channel: .preferred_channel,
notes_excerpt: (.notes | capture("\\[testimonial_approved [^\\]]+\\][^\\[]*"; "g") | .[].string?)
}
'
bashcurl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '.[] | select(.name == "Mark Lastname") | .notes' \
| grep "testimonial_approved"
bashcurl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '
[.[] | .notes] | join("\n") |
{
asked: (capture("testimonial_asked"; "g") | length),
approved: (capture("testimonial_approved"; "g") | length),
declined: (capture("testimonial_declined"; "g") | length),
no_response: (capture("testimonial_no_response"; "g") | length)
}
'
When dedicated testimonial endpoints land, this skill will migrate from notes-appending to a real testimonials table. Until then, the notes pattern is the source of truth.
testimonials table:
- id (int)
- contact_id (FK -> contacts)
- quote_text (string, verbatim)
- quote_source (string, e.g., "krisp:abc123@00:23:45")
- meeting_date (date)
- ask_date (date)
- ask_channel (enum: slack|email|whatsapp|imessage)
- status (enum: asked|approved|declined|no_response)
- attribution_format (enum: full_name|first_name|first_name_company|anonymous)
- approved_for (array, e.g., ["website_homepage", "case_study_orbiter"])
- approved_at (datetime)
- final_quote_text (string, the edited version if client edited)
- created_at, updated_at
| Method | Path | Purpose |
|---|---|---|
| GET | /api:PB9UH7b9/testimonials |
List all (filter by status, contact_id) |
| GET | /api:PB9UH7b9/testimonials/approved |
Quick list of approved-for-use |
| POST | /api:PB9UH7b9/testimonials |
Create new ask record |
| PATCH | /api:PB9UH7b9/testimonials/{id} |
Update status / attribution / final text |
| GET | /api:PB9UH7b9/contacts/{id}/testimonials |
All asks for one contact |
When these land, migrate the notes-block log into rows by parsing the existing markers.
| Wrong | Correct |
|---|---|
POST /testimonials (endpoint doesn't exist yet) |
Append to contacts.notes with the block format above |
Overwriting notes on PATCH |
Always read existing notes first, append, write combined string |
Storing the quote in last_contact field |
last_contact is a date -- quote text goes in notes |
| Logging the ask without the Krisp source ID | Always include source: krisp <id> @ <timestamp> so the quote is retrievable |
| Logging the ask without the channel used | Always include channel: <name> so we know how the relationship is tracked |
Re-asking a quote that's already in testimonial_declined state |
Always read the contact's notes first; abort if the quote was previously declined |
Forgetting to flip testimonial_asked to testimonial_approved after a yes |
Always update in place; otherwise the same quote shows up as "still pending" forever |
| Storing the original quote when the client edited it | Always log the EDITED version under testimonial_approved. The original is now off-limits |
| Logging via Charlotte MCP | Charlotte MCP for contact CRUD doesn't work -- use Xano PB9UH7b9 group directly |
Skipping the touchpoints POST |
Log both the notes-block append AND the touchpoint POST so the contact's interaction history reflects the ask |
# Xano Tracking -- Testimonial Approval Log
How `snappy-testimonials` reads from and writes back to the knowledge graph (`snappy-knowledge`) so the same quote is never asked twice and approved testimonials are flagged for use by `snappy-content` and `snappy-website`.
## Table of Contents
- [Auth (Canonical)](#auth-canonical)
- [Where Approvals Live](#where-approvals-live)
- [Tag + Note Schema](#tag--note-schema)
- [Read Patterns](#read-patterns)
- [Write Patterns](#write-patterns)
- [Querying Approved Testimonials](#querying-approved-testimonials)
- [Aspirational Endpoints](#aspirational-endpoints)
- [What AI Agents Get Wrong](#what-ai-agents-get-wrong)
---
## Auth (Canonical)
Credentials load from `snappy-settings/.env.cache` via `env("KEY")` -- see [snappy-settings/SKILL.md](../snappy-settings/SKILL.md).
```bash
SNAPPY_SETTINGS_QUIET=1 source ~/.claude/skills/snappy-settings/scripts/load-env.sh
```
---
## Where Approvals Live
Until dedicated `testimonials` endpoints land in Xano (see [Aspirational Endpoints](#aspirational-endpoints)), all approval state lives in the contact's `notes` field on `snappy-knowledge` `contacts` table -- appended, never overwritten.
| Field | Source of truth |
|-------|----------------|
| Contact ID | `snappy-knowledge` `contacts.id` |
| Quote text + citation | Appended block in `contacts.notes` |
| Ask date | Appended in the same block |
| Channel asked through | Appended in the same block |
| Approval status | Appended (`testimonial_asked`, `testimonial_approved`, `testimonial_declined`, `testimonial_no_response`) |
| Approved attribution format | Appended (e.g. `attribution: first_name_only`, `attribution: full_name_company`) |
The convention is: prepend each block with a marker so future scans can grep for it.
### Block format
```
[testimonial_<status> YYYY-MM-DD] quote: "<quote text>" | source: krisp <id> @ <timestamp> | channel: <slack|email|whatsapp|imessage> | attribution: <format>
```
Examples:
```
[testimonial_asked 2026-04-07] quote: "The QA endpoints completely changed how we think about data quality." | source: krisp abc123 @ 00:23:45 | channel: slack | attribution: pending
[testimonial_approved 2026-04-09] quote: "The QA endpoints completely changed how we think about data quality." | source: krisp abc123 @ 00:23:45 | channel: slack | attribution: first_name_only
[testimonial_declined 2026-03-15] quote: "I love the speed, it's like overnight." | source: krisp xyz789 @ 00:08:12 | channel: email | attribution: n/a
```
---
## Tag + Note Schema
Schema defined in [snappy-knowledge/schemas.md](../snappy-knowledge/schemas.md). Relevant fields for this skill:
| Field | Used for |
|-------|----------|
| `id` | Contact lookup |
| `name` | Speaker matching against transcript |
| `email` | Cross-check transcript domain matching |
| `company` | Used in attribution format |
| `tags` | Filter by `client` or `past_client` |
| `preferred_channel` | Determines which permission template to use |
| `notes` | Where the testimonial blocks live (append-only) |
| `last_contact` | Update after the ask is sent |
---
## Read Patterns
### Pull all clients to scan for quotes
```bash
curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq '.[] | {id, name, email, company, preferred_channel, notes}'
```
### Pull past clients (still eligible for testimonials)
```bash
curl -s "$XANO/api:PB9UH7b9/contacts?tag=past_client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq '.[] | {id, name, email, company, preferred_channel, notes}'
```
### Check the ask history for ONE contact (before drafting any new ask)
```bash
curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq '.[] | select(.id == 123) | .notes' \
| grep -o '\[testimonial_[a-z_]* [0-9-]*\] quote: "[^"]*"' || echo "No prior testimonial activity"
```
### Check for already-asked-and-declined quotes (avoid re-asking)
```bash
curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '.[] | select(.notes | test("testimonial_declined")) | "\(.name) -- \(.notes)"'
```
### Find all approved testimonials across the roster (for `snappy-content` / `snappy-website`)
```bash
curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '.[] | select(.notes | test("testimonial_approved")) | "\(.name) (\(.company)) -- \(.notes)"'
```
---
## Write Patterns
### Log a new ask (status: pending)
CRITICAL: read existing notes first, append the new block, write the combined string back. Never overwrite.
```bash
# 1. Read current notes
EXISTING_NOTES=$(curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '.[] | select(.id == 123) | .notes')
# 2. Compose the new block
NEW_BLOCK='[testimonial_asked 2026-04-07] quote: "The QA endpoints completely changed how we think about data quality." | source: krisp abc123 @ 00:23:45 | channel: slack | attribution: pending'
# 3. Append + PATCH
COMBINED=$(printf "%s\n%s" "$EXISTING_NOTES" "$NEW_BLOCK")
curl -s -X PATCH "$XANO/api:PB9UH7b9/contacts/123" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_METADATA_TOKEN" \
-d "$(jq -n --arg notes "$COMBINED" '{notes: $notes, last_contact: "2026-04-07"}')"
```
### Update an ask to approved
After the client says yes, update the matching block in place.
```bash
# Read existing notes
EXISTING=$(curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '.[] | select(.id == 123) | .notes')
# Replace the asked block with the approved block (sed-style)
UPDATED=$(echo "$EXISTING" | python3 -c "
import sys, re
text = sys.stdin.read()
text = re.sub(
r'\[testimonial_asked 2026-04-07\](.*)attribution: pending',
r'[testimonial_approved 2026-04-09]\1attribution: first_name_only',
text
)
print(text)
")
curl -s -X PATCH "$XANO/api:PB9UH7b9/contacts/123" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_METADATA_TOKEN" \
-d "$(jq -n --arg notes "$UPDATED" '{notes: $notes}')"
```
### Update an ask to declined
```bash
# Same pattern as approved -- flip status to declined, leave attribution n/a
UPDATED=$(echo "$EXISTING" | sed 's/testimonial_asked/testimonial_declined/' | sed 's/attribution: pending/attribution: n\/a/')
curl -s -X PATCH "$XANO/api:PB9UH7b9/contacts/123" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_METADATA_TOKEN" \
-d "$(jq -n --arg notes "$UPDATED" '{notes: $notes}')"
```
### Mark as no_response after follow-up window expires
```bash
UPDATED=$(echo "$EXISTING" | sed 's/testimonial_asked/testimonial_no_response/')
curl -s -X PATCH "$XANO/api:PB9UH7b9/contacts/123" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_METADATA_TOKEN" \
-d "$(jq -n --arg notes "$UPDATED" '{notes: $notes}')"
```
### Log the touchpoint (parallel to the notes append)
```bash
curl -s -X POST "$XANO/api:PB9UH7b9/contacts/123/touchpoints" \
-H "Content-Type: application/json" -H "Authorization: Bearer $XANO_METADATA_TOKEN" \
-d '{"type": "testimonial_request", "notes": "Sent permission ask via slack for quote from krisp abc123"}'
```
---
## Querying Approved Testimonials
Used by `snappy-content` and `snappy-website` when they need approved quotes for use.
### Get every approved quote with attribution
```bash
curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '
.[] |
select(.notes | test("testimonial_approved")) |
{
contact: .name,
company: .company,
preferred_channel: .preferred_channel,
notes_excerpt: (.notes | capture("\\[testimonial_approved [^\\]]+\\][^\\[]*"; "g") | .[].string?)
}
'
```
### Get approved quotes for one specific client
```bash
curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '.[] | select(.name == "Mark Lastname") | .notes' \
| grep "testimonial_approved"
```
### Count testimonial pipeline state
```bash
curl -s "$XANO/api:PB9UH7b9/contacts?tag=client" \
-H "Authorization: Bearer $XANO_METADATA_TOKEN" \
| jq -r '
[.[] | .notes] | join("\n") |
{
asked: (capture("testimonial_asked"; "g") | length),
approved: (capture("testimonial_approved"; "g") | length),
declined: (capture("testimonial_declined"; "g") | length),
no_response: (capture("testimonial_no_response"; "g") | length)
}
'
```
---
## Aspirational Endpoints
When dedicated testimonial endpoints land, this skill will migrate from notes-appending to a real `testimonials` table. Until then, the notes pattern is the source of truth.
### Proposed schema (when endpoints exist)
```
testimonials table:
- id (int)
- contact_id (FK -> contacts)
- quote_text (string, verbatim)
- quote_source (string, e.g., "krisp:abc123@00:23:45")
- meeting_date (date)
- ask_date (date)
- ask_channel (enum: slack|email|whatsapp|imessage)
- status (enum: asked|approved|declined|no_response)
- attribution_format (enum: full_name|first_name|first_name_company|anonymous)
- approved_for (array, e.g., ["website_homepage", "case_study_orbiter"])
- approved_at (datetime)
- final_quote_text (string, the edited version if client edited)
- created_at, updated_at
```
### Proposed endpoints
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api:PB9UH7b9/testimonials` | List all (filter by status, contact_id) |
| GET | `/api:PB9UH7b9/testimonials/approved` | Quick list of approved-for-use |
| POST | `/api:PB9UH7b9/testimonials` | Create new ask record |
| PATCH | `/api:PB9UH7b9/testimonials/{id}` | Update status / attribution / final text |
| GET | `/api:PB9UH7b9/contacts/{id}/testimonials` | All asks for one contact |
When these land, migrate the notes-block log into rows by parsing the existing markers.
---
## What AI Agents Get Wrong
| Wrong | Correct |
|-------|---------|
| `POST /testimonials` (endpoint doesn't exist yet) | Append to `contacts.notes` with the block format above |
| Overwriting `notes` on PATCH | Always read existing notes first, append, write combined string |
| Storing the quote in `last_contact` field | `last_contact` is a date -- quote text goes in `notes` |
| Logging the ask without the Krisp source ID | Always include `source: krisp <id> @ <timestamp>` so the quote is retrievable |
| Logging the ask without the channel used | Always include `channel: <name>` so we know how the relationship is tracked |
| Re-asking a quote that's already in `testimonial_declined` state | Always read the contact's notes first; abort if the quote was previously declined |
| Forgetting to flip `testimonial_asked` to `testimonial_approved` after a yes | Always update in place; otherwise the same quote shows up as "still pending" forever |
| Storing the original quote when the client edited it | Always log the EDITED version under `testimonial_approved`. The original is now off-limits |
| Logging via Charlotte MCP | Charlotte MCP for contact CRUD doesn't work -- use Xano `PB9UH7b9` group directly |
| Skipping the `touchpoints` POST | Log both the notes-block append AND the touchpoint POST so the contact's interaction history reflects the ask |