Imported from Prism-Shadow/GDPevo (
experiments/claude_code_opus_4_8_xhigh/reports/skills/task_group_001/self/self_attempt_02/SKILL.md). Install upstream withnpx skills add Prism-Shadow/GDPevo --skill self_attempt_02. Copyright stays with the author.
HarborCRM Front-of-Funnel Handoff Skill
Solves four recurring task families in the HarborCRM domain:
- Post-event CRM handoff / reconciliation (sponsor status + qualified non-sponsor leads).
- Trade-show prospecting (qualify exhibitors by platform fit, rank, size opportunities).
- Raw-contact import hygiene (normalize, dedupe, suppress, CRM-match).
Each test gives a prompt + an answer_template.json. The template is authoritative: emit exactly its keys, enums, ordering, and types — nothing more, nothing less. Per-task enum sets and schema shapes vary; always read the template first and copy its allowed values verbatim. Output JSON only, no prose.
0. The remote API (read-only, HTTP-only)
Base URL: supplied by runner (e.g. <remote-env-url>). Use curl or python urllib. Never WebFetch (forces HTTPS; host is HTTP-only).
Endpoints and what they are for:
GET /api/policies— controlled value enums + notes. Sparse; most rules are inferred from data + template. Always fetch it but do not expect it to give thresholds.GET /api/events/{id}—start_date,end_date,status,campaign_code,lead_opportunity_amount,followup_days_after_end,sponsor_followup_days_after_end.GET /api/events/{id}/ordersand/sponsor_packages— sponsor orders (account_id,account_name,amount,package_level,order_status∈ {confirmed, proposal_sent, canceled},ticket_contacts). Orders and sponsor_packages are usually the same rows.GET /api/events/{id}/badges— scanned attendees (badge_id,badge_type∈ {sponsor, attendee, student, press, …},company_name,contact_name,email,phone,job_title).GET /api/finance/invoices?event_id={id}—invoice_id,amount,paid_amount,deferred_amount,status∈ {paid_deferred, open, …},payment_date.GET /api/crm/accounts—account_id,name,domain,status∈ {prospect, customer, disqualified},disqualified_reason.GET /api/crm/contacts—contact_id,account_id,name,email,phone,opted_out.GET /api/crm/opportunities—account_id,amount,event_id,stage.GET /api/crm/campaign_members?event_id={id}— existing members (account_id,contact_id,status∈ {attended_sponsor, registered_sponsor, attended}).GET /api/tradeshows,/api/tradeshows/{id}/exhibitors,/api/tradeshows/{id}/meeting_interest.GET /api/import_batches,/api/import_batches/{id}/raw_contacts,/api/import_batches/{id}/suppression.
Fetch only what the task family needs (see each SOP). Match accounts to companies by email/website domain, not by display name (names vary: "HelioWare Manufacturing" vs "HelioWare Mfg.").
1. Normalization rules (used everywhere)
- Email: trim surrounding whitespace, lowercase. If the result is empty/blank → empty string
"". Domain = substring after@(for account matching). - Phone: strip everything except digits (
[^0-9]removed). Keep leading country-code digits if present in the source (e.g.+1 415 555 0188→14155550188;(415) 555-0188→4155550188). Do not invent or strip a country code that wasn't there. Empty/blank →"". - A contact is contactable iff it has a non-empty normalized email or non-empty normalized phone. Email-only and phone-only are both fine.
- Output amounts as integer USD (no decimals, no currency symbol).
2. Sponsor status classification (event reconciliation)
For every account that has a sponsor order/package for the event, reconcile order + invoice:
| order_status | invoice present? | invoice paid fully? | → sponsor_status |
|---|---|---|---|
| confirmed | yes, status=paid_deferred and paid_amount == amount |
yes | paid_deferred |
| confirmed | yes, status=open (paid_amount < amount) |
no | open_invoice |
| proposal_sent | no invoice (or no payment) | — | proposal_only |
| canceled | — | — | not a sponsor → see below |
open_invoiceopen balance =invoice.amount − invoice.paid_amount(useamount, notdeferred_amount).- Canceled orders: how to report depends on the template's
sponsor_statusenum:- If the enum includes
not_sponsor→ list the canceled/non-sponsor account withsponsor_status = not_sponsor(amount = its package/order amount, or 0 if none). - If the enum omits
not_sponsor(only paid_deferred/open_invoice/proposal_only) → exclude the canceled account fromsponsor_statusesand instead surface it in the exclusion list with reasoninactive_sponsor_record.
- If the enum includes
- Revenue-by-status totals: sum package/order
amountper status bucket.open_invoicetotal = full package amount of open-invoice sponsors;open_invoice_balance= sum of their open balances (amount − paid_amount).proposal_onlytotal = sum of proposal amounts. - Sort
sponsor_statusesbyaccount_nameascending.
Unpaid / sponsor-finance follow-up set = sponsors that are NOT paid_deferred, i.e. open_invoice + proposal_only. Their total = sum of their package amounts. (If a template clearly scopes it to "open invoices only," restrict to open_invoice. Default: include both.) paid_deferred sponsors are settled and need no finance follow-up.
3. Badge / lead classification (event reconciliation)
For each badge, classify into sponsor_attendee / qualified_non_sponsor_lead / excluded:
- sponsor_attendee — the badge's company has ANY sponsor order for the event (confirmed OR proposal_sent — proposal-stage accounts are on the sponsor track, not the lead track) or the badge_type is
sponsor. Exclusion reasonsponsor_attendee. Not a sales lead. - excluded — non_business_badge — badge_type is non-business (
student,press,guest,media, etc.). Reasonnon_business_badge. - excluded — existing_disqualified — the badge's company matches a CRM account whose
status == disqualified(has adisqualified_reason). Reasonexisting_disqualified. - excluded — missing_contact — badge has neither email nor phone after normalization (not contactable). Reason
missing_contact. - Otherwise → qualified_non_sponsor_lead (business attendee, contactable, account not disqualified, company is not a sponsor).
Precedence when several apply: sponsor_attendee → non_business_badge → existing_disqualified → missing_contact. (A disqualified-account attendee that is also a canceled sponsor's contact is genuinely ambiguous; prefer the account-level existing_disqualified if the CRM account is flagged disqualified, else inactive_sponsor_record.)
CRM action for each badge (use the template's crm_action enum — names differ per task):
- Qualified lead, company not in CRM → create account + contact + campaign member (
create_account_contact_campaign_member, or in the leaner schema:crm_account_action=create_account,crm_contact_action=create_contact,campaign_member_action=add_campaign_member). - Qualified lead, company in CRM but contact missing →
create_contact_campaign_member(accountupdate_existing, contactcreate_contact). - Qualified lead already a campaign member →
update_campaign_member/add_campaign_memberper template; sponsor attendee already a correct member →no_action. - Excluded badge →
no_import(orno_actionfor sponsor attendees already handled).
badge_decisions sort by badge_id ascending.
4. Campaign-member actions & target status (event reconciliation)
Compare each subject (sponsor ticket contacts + qualified leads) against existing campaign_members:
- Already a member with the correct status →
no_action. - New subject who should be a member →
create. - Member whose status should change →
update. - Excluded subject →
no_import.
target_status mapping:
- Sponsor account, contact attended (has a badge scan) →
attended_sponsor. - Sponsor account, registered only (no badge scan) →
registered_sponsor. - Qualified non-sponsor lead who attended →
attended. - Excluded →
excluded.
Sort by subject_key ascending (or as the template specifies).
crm_action_counts (when present) tally the implied work across qualified leads (and any member updates): accounts_create/update, contacts_create/update, campaign_members_create/update. New leads each contribute one create per dimension as applicable; an existing account contributes accounts_update.
5. Opportunity & pipeline math (event reconciliation)
- Each qualified non-sponsor lead's opportunity = the event's
lead_opportunity_amount. lead_pipeline_total/open_opportunity_total_usd= (count of qualified non-sponsor leads) ×lead_opportunity_amount.open_opportunity_count= that count.qualified_non_sponsor_account_names= distinct company names of qualified leads, sorted ascending.
6. Follow-up due dates (event reconciliation)
- Lead follow-up due date = event
end_date+followup_days_after_end(calendar days). - Sponsor finance follow-up due date = event
end_date+sponsor_followup_days_after_end. - Format
YYYY-MM-DD. Use the end_date (not start_date) as the base; single-day events have start_date==end_date. lead_task_count= number of qualified-lead handoff tasks (= qualified lead count).sponsor_finance_task_count= number of unpaid sponsor accounts (Section 2).
7. Trade-show prospecting SOP
Goal: from exhibitors, pick those that manufacture / OEM-build target underwater platforms, rank them, size opportunities, and list near-miss exclusions.
Endpoints: /api/tradeshows/{show}/exhibitors, /.../meeting_interest, /api/crm/accounts, /api/policies.
Platform enums (fixed): AUV, ROV, Underwater Camera. Always list platforms in that enum order.
Read the exhibitor description to assign platforms:
- "AUV", "autonomous underwater vehicle", "AUV scout" →
AUV. - "ROV", "remotely operated", "inspection-class ROV", "pen-cleaning ROV" →
ROV. - "underwater camera", "camera modules/arrays", "optics manufacturer", "camera maker" →
Underwater Camera. - A maker can have multiple platforms (e.g. "builds AUVs and ROVs" → [AUV, ROV]).
Qualified vs excluded (the key judgment — exclude near-misses):
Qualified = the company builds/OEM-manufactures at least one target platform. Exclude companies that only sit adjacent to the platform, with a controlled reason. Use the exact exclusion enum from the template (it differs per task):
- Distributor / reseller / dealer / sales agent ("does not manufacture", "imported brands") →
distributor_only(relationshipdistributor). - Service / consulting / operator ("operates rented ROVs", "analytics dashboard using partner ROV feeds", "no hardware manufacturing") →
service_only(relationshipservice_provider). - Sensor-only vendor ("sensor-only DO/salinity probes for integration by platform partners") →
sensor_vendor_onlyorsensor_only(copy the template's spelling) (relationshipsensor_vendor). - Research / academic lab →
research_only(relationshipresearch). - Wrong market entirely →
not_target_market(only if the template offers it). - All excluded exhibitors get
crm_action = no_importand stay visible in the exclusion list.
Critical: a company that makes the sensor but not the platform is NOT qualified (it's the sensor vendor we are selling FOR, not the OEM target). A company that merely uses or resells platforms is NOT qualified.
CRM overlap & action:
Match exhibitor to CRM account by crm_account_id (if exhibitor row provides it) or by website/email domain.
- Qualified + already in CRM →
crm_action = update_existing, setcrm_account_id. - Qualified + not in CRM →
crm_action = create_account,crm_account_id = null. existing_crm_overlap_count/existing_crm_overlap_account_idscount only qualified leads that have a CRM account; ids sorted ascending.
Priority tier & opportunity sizing:
Join exhibitor to meeting_interest by company_name to get interest_score and requested_demo.
Default tiering (used when the prompt doesn't override): based on demo request + score:
- A =
requested_demo == trueANDinterest_score >= 90. - B =
requested_demo == trueANDinterest_score >= 80(and not A). - C = everything else qualified.
If the prompt gives explicit tier thresholds or dollar values, use those verbatim. Typical opportunity sizing: A = 120000, B = 90000, C = 50000 (USD) — but always take the prompt's stated values if given.
total_estimated_opportunity_usd= sum of qualified leads' opportunity estimates.
Ranking (when the template has rank):
Order qualified leads by: (1) requested_demo true first, (2) interest_score descending, (3) broader platform coverage (more platforms first), (4) company_name ascending. Assign 1-based contiguous rank.
Counts:
platform_coverage_counts/platform_counts: count of qualified leads covering each platform (a multi-platform lead increments each of its platforms).priority_counts: count of qualified leads per tier A/B/C.qualified_total/qualified_lead_count,excluded_*_total/excluded_count.
Sort qualified_exhibitors/ranked_leads per template (company_name asc, or rank asc); excluded_* by company_name ascending.
8. Import-batch hygiene SOP
Endpoints: /api/import_batches/{batch}/raw_contacts, /.../suppression, /api/crm/accounts, /api/crm/contacts, /api/policies. Campaign code comes from the import_batch record (campaign_code).
Process raw rows in this order:
- Normalize every row (Section 1): trimmed-lowercased email, digits-only phone.
- Drop unusable rows = not contactable (no email AND no phone) → removal reason
missing_contact, actionno_import. - Dedupe remaining rows. Key = normalized email (fallback to normalized phone when email is empty). Within a duplicate group pick the winner:
- Primary: latest
captured_at(freshest record wins). - Tie-break: lowest
row_id(ascending) when timestamps are equal. Losers → removal reasonduplicate. Record{key, winner_row_id, removed_row_ids}per group; sortduplicate_keysbykeyascending.
- Primary: latest
- Suppression: a winner whose normalized email or phone matches any suppression-list entry → removal reason
suppressed, actionsuppress. (Suppression matches on email or phone; reasons like global_opt_out / privacy_request / role_account all suppress.) - CRM match the survivors by email domain → account.domain:
- Domain matches an existing account →
crm_action = update_existing; setexisting_account_id;existing_contact_id= that account's matching contact_id if the exact person already exists, elsenull. - No account match →
crm_action = create_account;existing_account_id = null,existing_contact_id = null.
- Domain matches an existing account →
Outputs:
clean_contacts= the surviving rows only (thecreate_account+update_existingset). Each item uses the winner row's values:clean_contact_id=source_row_id= winning row_id; company_name, contact_name, normalized email, normalized phone,source_name,captured_at(winner's),crm_action,existing_account_id,existing_contact_id. Sort byclean_contact_idascending.duplicate_summary:duplicate_removed_count= total duplicate losers;duplicate_keysas above.removal_summary:unusable_removed_count= missing_contact count;suppressed_removed_count;removed_rows= every removed row{row_id, reason}with reason ∈ {duplicate, missing_contact, suppressed}, sorted byrow_idascending.import_action_totals: integer counts over the deduped rows (winners + suppressed + unusable, i.e. excluding duplicate losers) forcreate_account,update_existing,no_import(missing_contact rows),suppress.campaign_member_import_count= number of surviving cleaned contacts (the clean_contacts list length).
9. Common misjudgments to avoid
- Don't add or rename keys/enums. Copy the template's exact enum spellings (e.g.
sensor_vendor_onlyvssensor_only; presence/absence ofnot_sponsor). - Canceled sponsors are not active sponsors. Either
not_sponsoror excluded (inactive_sponsor_record) depending on the enum — neverproposal_only. - Open-invoice total uses full package
amount; the balance is a separate field (amount − paid_amount). Don't put the balance in the revenue total. - Proposal-stage accounts are on the sponsor track, so their attendees are
sponsor_attendee, not qualified leads. - Match companies by domain, not display name (manufacturing/Mfg., punctuation, suffixes vary).
- Sensor-only and platform-using companies are NOT qualified prospects — they are near-miss exclusions.
- Use
end_date(not start_date) for all follow-up date arithmetic; add the event's own day-offset fields. - Lead opportunity amount comes from the event (
lead_opportunity_amount), not from CRM opportunity rows; CRMopportunitiesare usually for sponsor/closed deals, not the new leads. - Dedup winner = latest captured_at, tie-break lowest row_id. A duplicate with an identical timestamp is the signal to use the row_id tie-breaker.
- Suppression and missing-contact rows are removed, not in clean_contacts, but still counted in
import_action_totalsand listed inremoval_summary. - Contactable = email OR phone. An empty email alone does not disqualify a row that has a phone.
- Respect every
orderingrule; ties on the primary sort key fall back to the secondary key named in the template. - Output JSON only, integers for all amounts/counts, dates as
YYYY-MM-DD.