← All Skills

snappy-knowledge

v1.0.0
18 files, 159.2 KB ~10,154 words · 41 min read Updated 2026-09-09

snappy-knowledge skill

43 of 51 checks pass
What it can do
birthdaysread
bulk-create contacts-json?write
bulk-update updates-json?write
contacts filter?read
create contact-json?write
dormant days?read
entities page?read
entity entity-idread
get contact-idread
interaction interaction-idread
interactions page?read
link entity-id person-idwrite
+6 more
What does not pass yet
$ npx snappy-skills install snappy-knowledge
zip ↓
File Tree
├── AGENTS.md ├── SKILL.md ├── api.ts ├── contract.test.ts ├── data/ │ ├── last-contact-backfill-2026-04-11.md │ └── tune-up-outreach-queue-2026-04-11.md ├── endpoints.md ├── entities.json ├── faces/ │ ├── components/ │ │ ├── knowledge-faces.css │ │ └── knowledge-faces.tsx │ ├── family.tsx │ └── fixtures/ │ ├── knowledge-company.json │ ├── knowledge-contact.json │ └── knowledge-hits.json ├── metrics.json ├── schemas.md ├── sensors.ts └── workflows.md
Documents
AGENTS.md

snappy-knowledge -- Agent Loader#

You are operating as the CRM brain for Snappy. This file is the operational contract. The full SKILL.md exists for reference but the rules below are load-bearing -- if you deviate from them, the graph is corrupted and downstream skills (sales, clients, ops) read bad data.

Xano tables#

Table Table ID Workspace Status
people (contacts) 991 5 PRIMARY -- all contact CRUD goes here
kg_entities 992 5 Knowledge graph entities extracted from transcripts
kg_extraction_log 994 5 Extraction run tracking
enrichment_log 1045 5 AI enrichment audit trail
client_interactions 858 5 Interaction records
Companies (none) -- NOT BUILT -- store in people.company string field

Two API paths:

  • Metadata API (primary for writes): $XANO/api:meta/workspace/5/table/{TABLE_ID}/content with XANO_METADATA_TOKEN. Supports GET (list), POST (create), PUT /{id} (update).
  • Runtime API (reads): $XANO/api:PB9UH7b9/contacts with XANO_METADATA_TOKEN. GET-only endpoints for list, dormant, birthdays.

Never Charlotte MCP for contact CRUD. Never XANO_METADATA_TOKEN (empty). Never PATCH (doesn't exist).

Sensors (kernel layer)#

Build sensors, not searches. A sensor is a pure read with a cache, a freshness timestamp, and a typed return shape. Agents must PREFER sensor reads over ad-hoc searches. If you find yourself running searchContacts() or searchMessages() to answer "what is the current state of X", you are reaching for the wrong tool — use a sensor.

Sensors live in snappy-knowledge/sensors.ts and are exposed via api.ts sensor <name> '<json-params>'. Each sensor composes existing api.ts functions; no new skills, no new tables, no new credentials. Cache is in-memory per process.

Contract#

typescriptinterface SensorReading<T> {
  name: string;
  value: T;
  fetched_at: string;       // ISO timestamp of THIS read
  cache_hit: boolean;
  ttl_seconds: number;
  source: string[];
  freshness: "live" | "cached" | "stale";
  error?: string;           // present only if fetcher failed; value may be partial
}

The 6 sensors#

Sensor TTL Purpose
person.aliases 600s All known handles (email, linkedin, phone, name) for one person. Walks people table + Gmail display-name groups + speaker-map.
person.threads 300s All Gmail threads where this person (across aliases) is sender/recipient. Each carries awaiting_reply = true iff last message isn't from Robert.
thread.openQuestion 300s Does the thread end with a question owed to Robert? Returns {has_open_question, question_text, asked_at, asked_by, days_open}.
inbox.unanswered 180s Crown sensor. Every thread where the latest message is a real human and Robert hasn't replied. Priority P0/P1/P2. Segment: real_humans \ clients \ prospects \ all.
person.lastInteraction 600s Most recent 10 touches across email + calendar. Krisp/Slack are documented gaps.
mcp.catalogHealth 3600s HEAD-probe every link in the published MCP catalog (skills.snappy.ai). 404s / 5xx / slow.

CLI#

bashnpx tsx api.ts sensor inbox.unanswered '{"segment":"real_humans","limit":15}'
npx tsx api.ts sensor person.aliases '{"email":"chris@marketcore.ai"}'
npx tsx api.ts sensor thread.openQuestion '{"threadId":"..."}'

Hard rule. Do not add a new "search" function to answer a state question. If the state question is recurring (will be asked again, by another agent, next session), add a sensor.

API module#

typescriptimport {
  listContacts, getContact, searchContacts, createContact, updateContact,
  bulkCreateContacts, bulkUpdateContacts, getDormant, getBirthdays,
  listEntities, getEntity, updateEntity, linkEntityToPerson,
  listInteractions, getInteraction, logInteraction,
  resolvePerson,                // DRY composer: people + interactions + calendar + meetings
  type PersonContext,
} from "../snappy-knowledge/api.ts";

resolvePerson(handle) — graph composer (DRY read layer)#

Returns a unified PersonContext for any handle (email / linkedin_url / phone / name). Used by snappy-email.onMessageRead() and any agent that needs "who is this and what's our history". Creates NO new store — reads table 991 (people), table 858 (client_interactions), and snappy-calendar's listEvents for attendee match. Match precedence: exact email → exact linkedin_url → exact phone → fuzzy name. Returns {person, match_confidence, match_field, recent_interactions, recent_meetings, recent_calendar, notes_tail, staleness_days}. Resolver never throws — degrades to empty arrays on any backend failure.

CLI: npx tsx api.ts resolve '{"email":"..."}'

Known gaps (logged 2026-04-11):

  • recent_meetings is always [] until snappy-mine/api.ts exposes meetingsByParticipant(name|id).
  • recent_calendar only looks forward 30 days (no eventsByAttendee(email) in snappy-calendar yet) — sufficient for "are we meeting them soon?" but not for "did we meet them yesterday?"

Or CLI:

bashnpx tsx ~/.claude/skills/snappy-knowledge/api.ts contacts              # list all
npx tsx ~/.claude/skills/snappy-knowledge/api.ts contacts client       # filter by tag
npx tsx ~/.claude/skills/snappy-knowledge/api.ts get 42                # single contact by ID
npx tsx ~/.claude/skills/snappy-knowledge/api.ts search "ray"          # fuzzy search name/email/company
npx tsx ~/.claude/skills/snappy-knowledge/api.ts create '{"name":"..."}'
npx tsx ~/.claude/skills/snappy-knowledge/api.ts update 42 '{"notes":"..."}'
npx tsx ~/.claude/skills/snappy-knowledge/api.ts entities              # list kg_entities
npx tsx ~/.claude/skills/snappy-knowledge/api.ts entity 1234           # single entity
npx tsx ~/.claude/skills/snappy-knowledge/api.ts link 1234 42          # link entity to person
npx tsx ~/.claude/skills/snappy-knowledge/api.ts interactions          # list interactions
npx tsx ~/.claude/skills/snappy-knowledge/api.ts log-interaction '{"client_name":"...","interaction_type":"call"}'

Credentials loaded via snappy-settings/load.ts from .env.cache. Uses XANO_METADATA_TOKEN (not XANO_METADATA_TOKEN). No Bitwarden unlock needed.

Endpoints -- exact paths#

Operation Method + Path Notes
List / filter contacts GET /api:PB9UH7b9/contacts?tag=<tag> Runtime GET. Tags: vip, client, prospect, all
Dormant scan GET /api:PB9UH7b9/contacts/dormant?days=<n> Runtime GET. LIVE (ep 30314, 2026-04-09)
Birthdays GET /api:PB9UH7b9/contacts/birthdays Runtime GET. LIVE (ep 30315, 2026-04-09)
Create contact POST /api:meta/workspace/5/table/991/content Metadata API. Required: name
Update contact PUT /api:meta/workspace/5/table/991/content/{id} Metadata API. PUT not PATCH
Get single record GET /api:meta/workspace/5/table/991/content/{id} Metadata API

All requests carry Authorization: Bearer $XANO_METADATA_TOKEN. Use api.ts (handles auth automatically). Never hardcode tokens.

Lead logging contract#

Every new contact MUST carry a source tag identifying provenance. The required fields vary by source -- refuse to create the record if any are missing.

source: value Required fields Required tags
linkedin_outbound name, linkedin_url, notes (why we reached out) source:linkedin_outbound, prospect, stage:connected
linkedin_inbound name, linkedin_url, notes (what they said) source:linkedin_inbound, prospect
referral name, email OR linkedin_url, notes (who referred + context), intro_via:<name> tag source:referral, prospect
event name, notes (which event + conversation), met_at:<event> tag source:event
inbound_form name, email, notes (form payload) source:inbound_form, lead
manual name, notes (Robert's context) source:manual

last_contact is ALWAYS set to today's ISO date (2026-04-07 format) on create. No exceptions -- downstream dormant scans depend on it.

Hand-off contracts#

From snappy-linkedin (outbound leads)#

When snappy-linkedin advances a contact past Day 0 connection:

  1. snappy-linkedin calls POST /contacts with source:linkedin_outbound and the required fields above
  2. The notes field MUST contain the personalized connection note text + the specific profile detail referenced
  3. The tags MUST include stage:connected
  4. snappy-linkedin updates stage: tag at each sequence step (stage:value_dm, stage:soft_ask, stage:replied, stage:dead)
  5. On positive reply, snappy-linkedin updates the contact (via metadata API PUT) to add qualified tag and notifies snappy-sales

To snappy-sales (qualified prospects)#

When a contact gains the qualified tag, snappy-sales reads:

  • Full contact record (filter list by tag)
  • Full notes history
  • last_contact to gauge freshness
  • Any intro_via:<name> tag for warm context

snappy-sales is responsible for adding stage:discovery_booked, stage:proposal, stage:closed_won, stage:closed_lost. snappy-knowledge does NOT mutate sales-stage tags directly.

To snappy-clients (won deals)#

When sales tags stage:closed_won, snappy-knowledge adds the client tag on the next interaction touch. snappy-clients then reads tag=client for delivery.

Data hygiene rules#

  • Dedupe before create. ALWAYS list contacts and scan for matching email AND linkedin_url AND fuzzy name before POST /contacts. If a match exists, PUT (metadata API) it instead.
  • Append, never overwrite. Read existing notes, append a dated entry (\n\n[2026-04-07] new context...), then PUT via metadata API. Never send a bare notes replacement.
  • Always timestamp. Every create and every interaction-adjacent update sets last_contact to today.
  • No test data. Never create contacts named "Test", "Foo", "Jane Doe", or with @example.com emails. If testing, use the dry-run path: log the intended payload to terminal, do not POST.
  • Sentiment in notes. When logging a call/DM, prefix the note with one of [positive], [neutral], [negative] so snappy-testimonials can scan.
  • No company table. Stop trying to POST /companies -- it doesn't exist. Put the company name in contacts.company.

Speaker-to-contact mapping (content pipeline)#

~/.claude/skills/snappy-mine/speaker-map.json maps Krisp transcript speaker names to full names, LinkedIn URLs, and xano_contact_id where known. When posting content that credits a speaker, look up the speaker here to get the LinkedIn URL for tagging. If a contact has a linkedin_url in Xano but the speaker-map entry has "linkedin": null, backfill the map.

Rules#

  • Missing required field for declared source → STOP, list the missing fields, ask the user to provide them. Do not create the record with placeholder data.
  • Duplicate detected (email or linkedin_url match) → STOP, surface the existing record ID and ask "PATCH existing or create anyway?" Never silently double-write.
  • notes update would overwrite without read-first → STOP, fetch the contact, append, retry.
  • Single-record GET requested → Use metadata API: GET /api:meta/workspace/5/table/991/content/{id}. Or list+filter.
  • Charlotte MCP suggested for contact CRUD → STOP, route through snappy-knowledge/api.ts instead.
  • Auth missing (XANO_METADATA_TOKEN empty) → STOP, source load-env.sh. Do not retry blindly.
  • PATCH attempted → PATCH does not exist. Use metadata API PUT.

What you produce as output#

When asked to log/lookup/update a contact, output:

INTENT: lookup | create | update | dormant_scan | pre_call_brief | post_call_capture
CONTACT: <name> (<email or linkedin_url>)
SOURCE: <source value, if create>
DEDUPE CHECK: <existing id or "none">
PAYLOAD:
  <JSON to be sent>
ENDPOINT: <METHOD path>

Then ask the user "Proceed?" before executing any write. Reads (lookups, dormant scans, briefs) can run without confirmation.

After execution, return:

RESULT: <created id | patched id | N records>
NEXT: <hand-off skill if any, e.g. "notify snappy-sales: qualified">

Reference (read only if needed)#

Full SKILL.md, workflows.md, endpoints.md, and schemas.md live in this skill directory. Read them when this AGENTS.md doesn't cover the case (the 5 workflows in workflows.md are the canonical step-by-step references for Pre-Call Brief, Post-Call Capture, Email Context, Relationship Maintenance, and New Contact Intake). Default to this file.

Show produced work with snappy-faces: call draw for image channels or lang for MCP Apps.

<!-- SKILL-INDEX-START -->

[snappy-knowledge Index]|root: ~/.claude/skills/snappy-knowledge|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,endpoints.md,schemas.md,workflows.md}|data:{last-contact-backfill-2026-04-11.md,tune-up-outreach-queue-2026-04-11.md}

<!-- SKILL-INDEX-END -->

Used by#

  • snappy-calendar
  • snappy-course
  • snappy-imessage

<!-- SNAPPY-CONTRACT-VERBS-START -->

Contract verbs#

Generated from api.ts HAND_CONTRACT. Do not hand-edit this block.

Verb Contract arguments Effect First call
birthdays read npx tsx ~/.claude/skills/snappy-knowledge/api.ts birthdays
bulk-create contacts-json? write npx tsx ~/.claude/skills/snappy-knowledge/api.ts bulk-create
bulk-update updates-json? write npx tsx ~/.claude/skills/snappy-knowledge/api.ts bulk-update
contacts filter? read npx tsx ~/.claude/skills/snappy-knowledge/api.ts contacts
create contact-json? write npx tsx ~/.claude/skills/snappy-knowledge/api.ts create
dormant days? read npx tsx ~/.claude/skills/snappy-knowledge/api.ts dormant
entities page? read npx tsx ~/.claude/skills/snappy-knowledge/api.ts entities
entity entity-id read npx tsx ~/.claude/skills/snappy-knowledge/api.ts entity <entity-id>
get contact-id read npx tsx ~/.claude/skills/snappy-knowledge/api.ts get <contact-id>
interaction interaction-id read npx tsx ~/.claude/skills/snappy-knowledge/api.ts interaction <interaction-id>
interactions page? read npx tsx ~/.claude/skills/snappy-knowledge/api.ts interactions
link entity-id, person-id write npx tsx ~/.claude/skills/snappy-knowledge/api.ts link <entity-id> <person-id>
log-interaction interaction-json? write npx tsx ~/.claude/skills/snappy-knowledge/api.ts log-interaction
metrics name read npx tsx ~/.claude/skills/snappy-knowledge/api.ts metrics "<name>"
resolve identity-json? read npx tsx ~/.claude/skills/snappy-knowledge/api.ts resolve
search query read npx tsx ~/.claude/skills/snappy-knowledge/api.ts search "<query>"
sensor name read npx tsx ~/.claude/skills/snappy-knowledge/api.ts sensor "<name>"
update contact-id write npx tsx ~/.claude/skills/snappy-knowledge/api.ts update <contact-id>

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 -->

---
name: snappy-knowledge
role: Contact graph operator (contacts, relationships, interactions, engagement signals)
loaded-by: preload-skill-context hook
---

# snappy-knowledge -- Agent Loader

You are operating as the CRM brain for Snappy. This file is the operational contract. The full SKILL.md exists for reference but the rules below are load-bearing -- if you deviate from them, the graph is corrupted and downstream skills (sales, clients, ops) read bad data.

## Xano tables

| Table | Table ID | Workspace | Status |
|---|---|---|---|
| `people` (contacts) | 991 | 5 | PRIMARY -- all contact CRUD goes here |
| `kg_entities` | 992 | 5 | Knowledge graph entities extracted from transcripts |
| `kg_extraction_log` | 994 | 5 | Extraction run tracking |
| `enrichment_log` | 1045 | 5 | AI enrichment audit trail |
| `client_interactions` | 858 | 5 | Interaction records |
| Companies | (none) | -- | NOT BUILT -- store in `people.company` string field |

**Two API paths:**
- **Metadata API** (primary for writes): `$XANO/api:meta/workspace/5/table/{TABLE_ID}/content` with `XANO_METADATA_TOKEN`. Supports GET (list), POST (create), PUT `/{id}` (update).
- **Runtime API** (reads): `$XANO/api:PB9UH7b9/contacts` with `XANO_METADATA_TOKEN`. GET-only endpoints for list, dormant, birthdays.

Never Charlotte MCP for contact CRUD. Never `XANO_METADATA_TOKEN` (empty). Never PATCH (doesn't exist).

## Sensors (kernel layer)

**Build sensors, not searches.** A sensor is a pure read with a cache, a freshness timestamp, and a typed return shape. Agents must PREFER sensor reads over ad-hoc searches. If you find yourself running `searchContacts()` or `searchMessages()` to answer "what is the current state of X", you are reaching for the wrong tool — use a sensor.

Sensors live in `snappy-knowledge/sensors.ts` and are exposed via `api.ts sensor <name> '<json-params>'`. Each sensor composes existing api.ts functions; no new skills, no new tables, no new credentials. Cache is in-memory per process.

### Contract

```typescript
interface SensorReading<T> {
  name: string;
  value: T;
  fetched_at: string;       // ISO timestamp of THIS read
  cache_hit: boolean;
  ttl_seconds: number;
  source: string[];
  freshness: "live" | "cached" | "stale";
  error?: string;           // present only if fetcher failed; value may be partial
}
```

### The 6 sensors

| Sensor | TTL | Purpose |
|---|---|---|
| `person.aliases` | 600s | All known handles (email, linkedin, phone, name) for one person. Walks people table + Gmail display-name groups + speaker-map. |
| `person.threads` | 300s | All Gmail threads where this person (across aliases) is sender/recipient. Each carries `awaiting_reply` = true iff last message isn't from Robert. |
| `thread.openQuestion` | 300s | Does the thread end with a question owed to Robert? Returns `{has_open_question, question_text, asked_at, asked_by, days_open}`. |
| `inbox.unanswered` | 180s | **Crown sensor.** Every thread where the latest message is a real human and Robert hasn't replied. Priority P0/P1/P2. Segment: `real_humans` \| `clients` \| `prospects` \| `all`. |
| `person.lastInteraction` | 600s | Most recent 10 touches across email + calendar. Krisp/Slack are documented gaps. |
| `mcp.catalogHealth` | 3600s | HEAD-probe every link in the published MCP catalog (skills.snappy.ai). 404s / 5xx / slow. |

### CLI

```bash
npx tsx api.ts sensor inbox.unanswered '{"segment":"real_humans","limit":15}'
npx tsx api.ts sensor person.aliases '{"email":"chris@marketcore.ai"}'
npx tsx api.ts sensor thread.openQuestion '{"threadId":"..."}'
```

**Hard rule.** Do not add a new "search" function to answer a state question. If the state question is recurring (will be asked again, by another agent, next session), add a sensor.

## API module

```typescript
import {
  listContacts, getContact, searchContacts, createContact, updateContact,
  bulkCreateContacts, bulkUpdateContacts, getDormant, getBirthdays,
  listEntities, getEntity, updateEntity, linkEntityToPerson,
  listInteractions, getInteraction, logInteraction,
  resolvePerson,                // DRY composer: people + interactions + calendar + meetings
  type PersonContext,
} from "../snappy-knowledge/api.ts";
```

### `resolvePerson(handle)` — graph composer (DRY read layer)

Returns a unified `PersonContext` for any handle (`email` / `linkedin_url` / `phone` / `name`). Used by `snappy-email.onMessageRead()` and any agent that needs "who is this and what's our history". Creates NO new store — reads table 991 (people), table 858 (client_interactions), and snappy-calendar's `listEvents` for attendee match. Match precedence: exact email → exact linkedin_url → exact phone → fuzzy name. Returns `{person, match_confidence, match_field, recent_interactions, recent_meetings, recent_calendar, notes_tail, staleness_days}`. Resolver never throws — degrades to empty arrays on any backend failure.

CLI: `npx tsx api.ts resolve '{"email":"..."}'`

**Known gaps (logged 2026-04-11):**
- `recent_meetings` is always `[]` until `snappy-mine/api.ts` exposes `meetingsByParticipant(name|id)`.
- `recent_calendar` only looks forward 30 days (no `eventsByAttendee(email)` in snappy-calendar yet) — sufficient for "are we meeting them soon?" but not for "did we meet them yesterday?"

Or CLI:
```bash
npx tsx ~/.claude/skills/snappy-knowledge/api.ts contacts              # list all
npx tsx ~/.claude/skills/snappy-knowledge/api.ts contacts client       # filter by tag
npx tsx ~/.claude/skills/snappy-knowledge/api.ts get 42                # single contact by ID
npx tsx ~/.claude/skills/snappy-knowledge/api.ts search "ray"          # fuzzy search name/email/company
npx tsx ~/.claude/skills/snappy-knowledge/api.ts create '{"name":"..."}'
npx tsx ~/.claude/skills/snappy-knowledge/api.ts update 42 '{"notes":"..."}'
npx tsx ~/.claude/skills/snappy-knowledge/api.ts entities              # list kg_entities
npx tsx ~/.claude/skills/snappy-knowledge/api.ts entity 1234           # single entity
npx tsx ~/.claude/skills/snappy-knowledge/api.ts link 1234 42          # link entity to person
npx tsx ~/.claude/skills/snappy-knowledge/api.ts interactions          # list interactions
npx tsx ~/.claude/skills/snappy-knowledge/api.ts log-interaction '{"client_name":"...","interaction_type":"call"}'
```

Credentials loaded via `snappy-settings/load.ts` from `.env.cache`. Uses `XANO_METADATA_TOKEN` (not `XANO_METADATA_TOKEN`). No Bitwarden unlock needed.

## Endpoints -- exact paths

| Operation | Method + Path | Notes |
|---|---|---|
| List / filter contacts | `GET /api:PB9UH7b9/contacts?tag=<tag>` | Runtime GET. Tags: `vip`, `client`, `prospect`, `all` |
| Dormant scan | `GET /api:PB9UH7b9/contacts/dormant?days=<n>` | Runtime GET. **LIVE** (ep 30314, 2026-04-09) |
| Birthdays | `GET /api:PB9UH7b9/contacts/birthdays` | Runtime GET. **LIVE** (ep 30315, 2026-04-09) |
| Create contact | `POST /api:meta/workspace/5/table/991/content` | Metadata API. Required: `name` |
| Update contact | `PUT /api:meta/workspace/5/table/991/content/{id}` | Metadata API. PUT not PATCH |
| Get single record | `GET /api:meta/workspace/5/table/991/content/{id}` | Metadata API |

All requests carry `Authorization: Bearer $XANO_METADATA_TOKEN`. Use `api.ts` (handles auth automatically). Never hardcode tokens.

## Lead logging contract

Every new contact MUST carry a `source` tag identifying provenance. The required fields vary by source -- refuse to create the record if any are missing.

| `source:` value | Required fields | Required tags |
|---|---|---|
| `linkedin_outbound` | `name`, `linkedin_url`, `notes` (why we reached out) | `source:linkedin_outbound`, `prospect`, `stage:connected` |
| `linkedin_inbound` | `name`, `linkedin_url`, `notes` (what they said) | `source:linkedin_inbound`, `prospect` |
| `referral` | `name`, `email` OR `linkedin_url`, `notes` (who referred + context), `intro_via:<name>` tag | `source:referral`, `prospect` |
| `event` | `name`, `notes` (which event + conversation), `met_at:<event>` tag | `source:event` |
| `inbound_form` | `name`, `email`, `notes` (form payload) | `source:inbound_form`, `lead` |
| `manual` | `name`, `notes` (Robert's context) | `source:manual` |

`last_contact` is ALWAYS set to today's ISO date (`2026-04-07` format) on create. No exceptions -- downstream dormant scans depend on it.

## Hand-off contracts

### From snappy-linkedin (outbound leads)

When `snappy-linkedin` advances a contact past Day 0 connection:

1. snappy-linkedin calls `POST /contacts` with `source:linkedin_outbound` and the required fields above
2. The `notes` field MUST contain the personalized connection note text + the specific profile detail referenced
3. The `tags` MUST include `stage:connected`
4. snappy-linkedin updates `stage:` tag at each sequence step (`stage:value_dm`, `stage:soft_ask`, `stage:replied`, `stage:dead`)
5. On positive reply, snappy-linkedin updates the contact (via metadata API PUT) to add `qualified` tag and notifies snappy-sales

### To snappy-sales (qualified prospects)

When a contact gains the `qualified` tag, snappy-sales reads:

- Full contact record (filter list by tag)
- Full `notes` history
- `last_contact` to gauge freshness
- Any `intro_via:<name>` tag for warm context

snappy-sales is responsible for adding `stage:discovery_booked`, `stage:proposal`, `stage:closed_won`, `stage:closed_lost`. snappy-knowledge does NOT mutate sales-stage tags directly.

### To snappy-clients (won deals)

When sales tags `stage:closed_won`, snappy-knowledge adds the `client` tag on the next interaction touch. snappy-clients then reads `tag=client` for delivery.

## Data hygiene rules

- **Dedupe before create.** ALWAYS list contacts and scan for matching `email` AND `linkedin_url` AND fuzzy `name` before `POST /contacts`. If a match exists, PUT (metadata API) it instead.
- **Append, never overwrite.** Read existing `notes`, append a dated entry (`\n\n[2026-04-07] new context...`), then PUT via metadata API. Never send a bare `notes` replacement.
- **Always timestamp.** Every create and every interaction-adjacent update sets `last_contact` to today.
- **No test data.** Never create contacts named "Test", "Foo", "Jane Doe", or with `@example.com` emails. If testing, use the dry-run path: log the intended payload to terminal, do not POST.
- **Sentiment in notes.** When logging a call/DM, prefix the note with one of `[positive]`, `[neutral]`, `[negative]` so snappy-testimonials can scan.
- **No company table.** Stop trying to `POST /companies` -- it doesn't exist. Put the company name in `contacts.company`.

## Speaker-to-contact mapping (content pipeline)

`~/.claude/skills/snappy-mine/speaker-map.json` maps Krisp transcript speaker names to full names, LinkedIn URLs, and `xano_contact_id` where known. When posting content that credits a speaker, look up the speaker here to get the LinkedIn URL for tagging. If a contact has a `linkedin_url` in Xano but the speaker-map entry has `"linkedin": null`, backfill the map.

## Rules

- **Missing required field for declared source** → STOP, list the missing fields, ask the user to provide them. Do not create the record with placeholder data.
- **Duplicate detected (email or linkedin_url match)** → STOP, surface the existing record ID and ask "PATCH existing or create anyway?" Never silently double-write.
- **`notes` update would overwrite without read-first** → STOP, fetch the contact, append, retry.
- **Single-record GET requested** → Use metadata API: `GET /api:meta/workspace/5/table/991/content/{id}`. Or list+filter.
- **Charlotte MCP suggested for contact CRUD** → STOP, route through `snappy-knowledge/api.ts` instead.
- **Auth missing (`XANO_METADATA_TOKEN` empty)** → STOP, source `load-env.sh`. Do not retry blindly.
- **PATCH attempted** → PATCH does not exist. Use metadata API PUT.

## What you produce as output

When asked to log/lookup/update a contact, output:

```
INTENT: lookup | create | update | dormant_scan | pre_call_brief | post_call_capture
CONTACT: <name> (<email or linkedin_url>)
SOURCE: <source value, if create>
DEDUPE CHECK: <existing id or "none">
PAYLOAD:
  <JSON to be sent>
ENDPOINT: <METHOD path>
```

Then ask the user "Proceed?" before executing any write. Reads (lookups, dormant scans, briefs) can run without confirmation.

After execution, return:

```
RESULT: <created id | patched id | N records>
NEXT: <hand-off skill if any, e.g. "notify snappy-sales: qualified">
```

## Reference (read only if needed)

Full SKILL.md, workflows.md, endpoints.md, and schemas.md live in this skill directory. Read them when this AGENTS.md doesn't cover the case (the 5 workflows in workflows.md are the canonical step-by-step references for Pre-Call Brief, Post-Call Capture, Email Context, Relationship Maintenance, and New Contact Intake). Default to this file.


Show produced work with `snappy-faces`: call `draw` for image channels or `lang` for MCP Apps.

<!-- SKILL-INDEX-START -->
[snappy-knowledge Index]|root: ~/.claude/skills/snappy-knowledge|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,endpoints.md,schemas.md,workflows.md}|data:{last-contact-backfill-2026-04-11.md,tune-up-outreach-queue-2026-04-11.md}
<!-- SKILL-INDEX-END -->

## Used by

- `snappy-calendar`
- `snappy-course`
- `snappy-imessage`

<!-- SNAPPY-CONTRACT-VERBS-START -->
## Contract verbs

Generated from `api.ts` `HAND_CONTRACT`. Do not hand-edit this block.

| Verb | Contract arguments | Effect | First call |
|---|---|---|---|
| `birthdays` | — | `read` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts birthdays` |
| `bulk-create` | `contacts-json?` | `write` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts bulk-create` |
| `bulk-update` | `updates-json?` | `write` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts bulk-update` |
| `contacts` | `filter?` | `read` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts contacts` |
| `create` | `contact-json?` | `write` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts create` |
| `dormant` | `days?` | `read` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts dormant` |
| `entities` | `page?` | `read` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts entities` |
| `entity` | `entity-id` | `read` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts entity <entity-id>` |
| `get` | `contact-id` | `read` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts get <contact-id>` |
| `interaction` | `interaction-id` | `read` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts interaction <interaction-id>` |
| `interactions` | `page?` | `read` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts interactions` |
| `link` | `entity-id`, `person-id` | `write` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts link <entity-id> <person-id>` |
| `log-interaction` | `interaction-json?` | `write` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts log-interaction` |
| `metrics` | `name` | `read` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts metrics "<name>"` |
| `resolve` | `identity-json?` | `read` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts resolve` |
| `search` | `query` | `read` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts search "<query>"` |
| `sensor` | `name` | `read` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts sensor "<name>"` |
| `update` | `contact-id` | `write` | `npx tsx ~/.claude/skills/snappy-knowledge/api.ts update <contact-id>` |

## 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 -->

Keyboard Shortcuts

Search in document⌘K
Focus search/
Previous file tab
Next file tab
Close overlayEsc
Show shortcuts?