sales-specialist · diff
git:20260927.31de767 to git:20260928.d21fb8d
5 added, 4 removed. Audit A to A.
---
name: sales-specialist
sheetId: "2.02"
description: "Business card pipeline — OCR extraction, schema normalization, Zynkr platform contact + company creation, company research, and personalized follow-up email drafting. Takes business-card IMAGES specifically — the OCR pass is the point. When the lead signal already arrives as text (a DM thread, an event feedback row, a website enquiry), use sales-outbound instead; for a WHOLE event survey sheet, use sales-client-sourcing."
category: sales-consultant
project: sales-specialist
platform: claude
status: Done
author: Peter Tu
input: "Business card image(s) — single or batch"
process: "OCR via Claude Vision → schema normalization → user review → Zynkr platform write (contact + company + 名片 note) → company research → follow-up email draft → Gmail save"
output: "Contact + company on the Zynkr platform (platform.zynkr.ai/contacts), a 名片 note holding the rest of the card, and a personalized follow-up email saved to Gmail Drafts"
synergy: []
type: agent
skills: ["sales-follow-up-mail"]
house-style: bound
---
# Sales Specialist
```bash
npx skills add https://github.com/peter-tu-zynkr/zynkr-skill-builder --skill sales-specialist
```
Automate the full post-trade-show workflow: extract contact info from business card images, land them on the Zynkr platform, research each company, and draft personalized follow-up emails. Use this skill after a conference, expo, or networking event where you've collected business cards and want them turned into working CRM records plus drafts ready to send before context fades.
**Not this skill when — the signal is already text.** What this skill owns is the OCR pass
from a photographed card. A DM thread, an event feedback row or a website enquiry goes to
`/sales-outbound`; a WHOLE event survey sheet goes to `/sales-client-sourcing`. Everything
downstream (CRM write, research, follow-up draft) is the same either way — only the parser
differs.
---
## Step 0 — Load Config
Read the config file at:
`./sales-specialist-config.md`
Read the contact schema at:
`./references/contact-schema.md`
Store:
- `EMAIL_SIGNATURE` — signature block from config
- `SCHEMA_FIELDS` — the 12-field list: name, title, company, email, phone, mobile, website, address, linkedin, industry, notes, card_date
- `CRM_PROJECT_ID` — Supabase project id from config (the Zynkr platform)
- `CONTACT_SQL` — the contents of `./references/contact-insert.sql` (read it now; you'll fill its placeholders per contact in Step 4)
**If `CRM_PROJECT_ID` is missing or `TBD`:**
```
⚠️ Zynkr platform project id not configured yet.
Set CRM_PROJECT_ID in sales-specialist-config.md before continuing.
```
Stop and ask the user — without it there is nowhere to write the cards.
---
## Step 1 — Card Intake
Display:
```
---------------------------------------------
📇 Business Card Pipeline
---------------------------------------------
Phase: Card Intake
---------------------------------------------
```
Ask the user:
```
Please share your business card image(s).
You can paste one image or multiple at once for batch processing.
```
Detect intake mode:
- **Single card** — 1 image → proceed to Step 2
- **Batch** — multiple images → process all in Step 2, then review all at once in Step 3
Store images as `CARD_IMAGES[]`.
---
## Step 2 — OCR & Extract
Display:
```
---------------------------------------------
Extracting contact info from card(s)...
---------------------------------------------
```
For each card image, use Claude Vision to extract all visible text and map it to the 12-field schema:
**Extraction rules:**
- Read every element on the card: name, title, company, logos, contact details, addresses, URLs, QR code domain (if visible), social handles
- Map each piece of data to the correct schema field
- If a field is not present on the card, set it to `—`
- For `industry`: infer from company name/website if not explicitly stated; use a short label (e.g., "SaaS", "製造業", "零售", "FinTech")
- For `card_date`: default to today's date unless user specifies otherwise
- If text is ambiguous (e.g., could be phone or fax), use field label context clues on the card
Store extracted data as `CONTACTS[]` — one object per card with all 12 fields.
---
## Step 3 — Schema Review
Display:
```
---------------------------------------------
Review Extracted Contact Info
---------------------------------------------
```
For each contact, display a clean table:
```
| Field | Extracted Value |
|------------|-----------------|
| name | [value] |
| title | [value] |
| company | [value] |
| email | [value] |
| phone | [value] |
| mobile | [value] |
| website | [value] |
| address | [value] |
| linkedin | [value] |
| industry | [value] |
| notes | [value] |
| card_date | [value] |
```
For batch mode, display all contacts in a numbered list (`Contact 1 of N`, etc.).
Ask:
```
Does this look correct?
- Type [OK] to proceed
- Type [EDIT field=value] to correct a field (e.g., EDIT email=david@company.com)
- Type [ADD note=...] to append to the notes field
```
Accept corrections until user types `OK`. Update `CONTACTS[]` with confirmed data.
---
## Step 3.5 — Website Lookup (auto-fill if missing)
For each contact where `website` is `—` or blank:
Run a WebSearch query: `"[company name]" official website`
Parse the top result for the company's official domain. Apply these rules:
- Accept only the company's own domain (skip directories like twincn.com, iyp.com.tw, etc.)
- If a clear official site is found, set `website` to the full URL (e.g., `https://example.com`)
- If no clear official site found, leave `website` as `—`
Display the result inline (no separate confirmation step needed):
```
🌐 Website found: [URL] (auto-filled)
```
or:
```
🌐 Website: not found — left blank
```
Update `CONTACTS[]` with the resolved website before proceeding to the platform write.
---
## Step 4 — Zynkr Platform Write
Display:
```
---------------------------------------------
Writing to the Zynkr platform...
---------------------------------------------
```
The **Zynkr platform is the only destination** for a card. Everything lands at
`https://platform.zynkr.ai/contacts`, where the funnel is actually worked — there
is no spreadsheet copy to keep in sync, and no field is dropped on the way:
| Card field | Lands in |
|---|---|
| name | `crm_contacts.last_name` (whole, as printed) |
| title | `crm_contacts.title` |
| email | `crm_contacts.email` (the dedup key) |
| mobile | `crm_contacts.phone` — the number you'd actually use |
| company | `crm_companies.name` (find-or-create) |
| website | `crm_companies.domain` (scheme/`www.` stripped by the SQL) |
| industry | `crm_companies.industry` |
| address | `crm_companies.address` |
| phone (office) | `crm_companies.phone` — belongs to the company, not the person |
| linkedin · notes · card_date | a `名片` note on the contact (`crm_activities`) |
> Earlier versions of this skill also appended a row to a `B2B Customer List`
> Google Sheet, because `crm_contacts` had no home for 7 of the 12 fields. It has
> since grown `domain` / `industry` / `address` / `phone` on the company side, so
> the platform now holds the whole card and the Sheet write has been retired. The
> old Sheet stays readable as the historical record — just don't append to it.
Run this **autonomously** for every confirmed contact (no extra approval gate —
they already signed off in Step 3). For each contact in `CONTACTS[]`:
**1. Clean the values.** Translate the `—` sentinel (and any blank) to an empty
string for every field before using it. The platform should never store a literal `—`.
**2. Use the name as printed.** The contacts page renders a contact as
`last_name + first_name`, so to show the name exactly as it appears on the card,
put the **whole** name in `{{FULL_NAME}}` (it maps to `last_name`) and leave
`first_name` empty — don't split it into 姓/名. If the card prints both a Chinese
and a romanized name (e.g. `王大明 / David Wang`), use the Chinese name if present,
otherwise the romanized one — and store it whole (`王大明`, or `David Wang`).
**3. Keep both numbers.** Pass the mobile as `{{MOBILE}}` and the office line as
`{{PHONE}}` — they go to the contact and the company respectively, so neither is
lost. If the card shows only one number, pass it as `{{MOBILE}}` and leave
`{{PHONE}}` empty; the SQL falls back so the contact always has a number.
**4. Fill and run the SQL.** Take `CONTACT_SQL` (loaded in Step 0 from
`./references/contact-insert.sql`) and substitute the placeholders, **doubling any
- single quote** (`O'Brien` → `O''Brien`). **Fill `<your-company-contact-email>` (the
- CRM owner the contact is filed under) first. If you cannot resolve it, stop and ask for
- it — never run the insert with the blank still in it.** An unfilled owner fails with
- a NOT NULL error (usually on `workspace_id`).
+ single quote** (`O'Brien` → `O''Brien`).
+
+ > **`<your-company-contact-email>` (the CRM owner the contact is filed under) is a company fact — read it from Atlas first** (`get_knowledge`, key `company.contact-email`, on the `zynkr-atlas` MCP server; the value is the `value:` line) and put the value into the SQL before running it. If Atlas cannot be reached, the key is missing, or there is no `value:` line, ask for it; never run the insert with the blank still in it.
+
+ An unfilled owner fails with a NOT NULL error (usually on `workspace_id`).
| Placeholder | Value |
|------------------|-------|
| `{{FULL_NAME}}` | the name as printed (Chinese side if bilingual) |
| `{{EMAIL}}` | `email` (empty string if the card had none) |
| `{{TITLE}}` | `title` |
| `{{MOBILE}}` | `mobile` (or the only number on the card) |
| `{{PHONE}}` | `phone` — the office line (empty if the card has one number) |
| `{{COMPANY}}` | `company` (empty string if blank) |
| `{{WEBSITE}}` | `website` as printed — pass the full URL, the SQL normalizes it |
| `{{ADDRESS}}` | `address` |
| `{{INDUSTRY}}` | `industry` (inferred in Step 2 if not printed) |
| `{{LINKEDIN}}` | `linkedin` |
| `{{NOTES}}` | `notes` |
| `{{CARD_DATE}}` | `card_date` (`YYYY-MM-DD`) |
Run it with `mcp__supabase__execute_sql`, `project_id` = `CRM_PROJECT_ID`. One
statement does all of it: find-or-create the company, backfill any company column
that is still empty (**never** overwriting a value a human curated), insert the
contact **only if the email isn't already on the platform** with the baked-in
defaults (`lifecycle_stage=lead`, `legal_basis=consent`, `lead_status=other`,
owner = Peter), and attach the 名片 note.
**5. Read the result and report.**
- One row back (a `contact_id`) → created. Display:
```
✓ [Contact Name] @ [Company] — added → https://platform.zynkr.ai/contacts/[contact_id]
```
- Zero rows back → a contact with this email already exists (de-duped). Display:
```
⏭️ [Contact Name] — already on the platform (matched by email), skipped
```
Note this also means **no note was written** for that card — the note is attached
to a newly created contact only, so a re-scan never stacks duplicate notes.
If the insert errors, surface it, keep the card in `CONTACTS[]` so it can be
retried at the end, and continue with the rest of the pipeline — the follow-up
email shouldn't be blocked by a platform hiccup. Because this is now the only
destination, an unretried failure means the card is **not recorded anywhere**: say
so explicitly in the Step 8 summary rather than reporting the contact as processed.
---
## Step 5 — Company Research
Display:
```
---------------------------------------------
Researching Company...
---------------------------------------------
[Company Name]
```
For each contact, run 3 WebSearch queries in sequence:
1. **Company overview**: `"[company name]" company overview products services`
2. **Recent news**: `"[company name]" news 2024 OR 2025`
3. **Industry context**: `[industry] Taiwan market trends 2025` (or adjust region based on company location)
Synthesize the search results into a `RESEARCH_SUMMARY` for each contact:
```
## Company: [Company Name]
### Overview
[2–3 sentences: what they do, business model, size/stage]
### Recent News
- [key item 1]
- [key item 2]
### Personalization Hook
[The single most relevant/interesting detail to reference in the follow-up email]
```
Display the research summary to the user. Ask:
```
Research looks good? [OK] or [EDIT] to add/change context.
```
---
## Step 6 — Follow-up Email Draft
Display:
```
---------------------------------------------
Drafting Follow-up Email...
---------------------------------------------
[Contact Name] @ [Company]
```
For each contact, launch the `followup-email-writer` agent using the Agent tool.
Pass the following in the prompt:
```
## Contact Info
[all 12 fields from CONTACTS[i]]
## Company Research Summary
[RESEARCH_SUMMARY for this contact]
## Meeting Context
[CONTACTS[i].notes — or "No specific notes" if empty]
## Language
[Detect from card: if Chinese characters on card → zh-TW; otherwise → en]
## Email Signature
[EMAIL_SIGNATURE from config]
```
Wait for the agent to complete. Display the drafted email.
Ask:
```
Email looks good?
- [OK] → save to Gmail Drafts
- [SEND] → send immediately
- [EDIT subject=...] / [EDIT body=...] → adjust the email
- [SKIP] → skip this contact's email
```
Accept edits until user approves.
---
## Step 7 — Save to Gmail Drafts / Send
For each approved email:
**If user chose [OK] (save as draft):**
Use `mcp__gmail__draft_email`:
- `to`: contact email from `CONTACTS[i]`
- `subject`: subject line from agent output
- `body`: email body from agent output
- `from`: `<your-google-workspace-account>`
**If user chose [SEND]:**
Use `mcp__gmail__send_email`:
- `to`: contact email from `CONTACTS[i]`
- `subject`: subject line from agent output
- `body`: email body from agent output
- `from`: `<your-google-workspace-account>`
Display confirmation per contact:
```
✓ [Contact Name] — email saved to Drafts / sent
```
---
## Step 8 — Pipeline Summary
Display final completion summary:
```
=============================================
📇 Business Card Pipeline — Complete
=============================================
Processed: [N] contact(s)
[Contact 1 Name] @ [Company]
✓ Added to the Zynkr platform (or ⏭️ already existed / ✗ FAILED — not recorded)
✓ 名片 note attached
✓ Email drafted / sent
[Contact 2 Name] @ [Company]
✓ Added to the Zynkr platform (or ⏭️ already existed / ✗ FAILED — not recorded)
✓ 名片 note attached
✓ Email saved to Drafts
...
Contacts: https://platform.zynkr.ai/contacts
=============================================
```
If any card shows `✗ FAILED`, list those cards again underneath and offer to retry
the platform write — a failed card exists nowhere but the image it came from.
---
## Progress Indicator
Show this line at the top of each phase:
```
📇 Intake → Extract → Review → Website → Platform → Research → Email → Done
```
Use `▶` for current phase, `✓` for completed, `○` for upcoming.
---
## Error Handling
- If OCR fails to extract a field: mark it `?` and flag it in the review table
- If the platform write fails: surface the error and continue the pipeline, but keep the card flagged. It is the only destination, so a failed card is recorded nowhere — always offer to retry at the end, and never report it as processed.
- If WebSearch returns no results: note "No recent news found" and proceed to email with overview only
- If Gmail draft/send fails: display the error; offer to copy email text to clipboard instead
- Never silently skip a step — always surface errors clearly
## House style
Writing style is **not owned by this file**. The house voice lives in two Google Docs under
`[@] 寫作指南` (`12DBdFz3SK22ie9im_ThFMI7IBRXsTZsV`), read at runtime:
- 《[2.0] Zynkr 通用風格指南 House Voice》 `10bOIQwRm9Pxwgct4hlwCwK_B4Pipai1HqBPZKzyRHSE` —
the universal core, plus the addendum for this surface
- 《[3.2] 禁用詞清單 Forbidden Words》 `1N5sHLP4qzmmhpCGsi6KElxi1z0MFe4QZ0Q_35T10Uyg`
Read both before producing client- or reader-facing text, and scan the draft against 《[3.2]》
before handing it over. If Drive is unreachable, say so in the output rather than proceeding
unchecked. Never re-implement either list inside this file.