Imported from Prism-Shadow/GDPevo (
experiments/claude_code_deepseek_v4_pro_max/reports/skills/task_group_001/self/self_attempt_03/SKILL.md). Install upstream withnpx skills add Prism-Shadow/GDPevo --skill self_attempt_03. Copyright stays with the author.
HarborCRM Task Group Skill
Environment
Use the base URL supplied by the runner or environment_access.md. Never use localhost, 127.0.0.1, or run env/setup.sh — environment_access.md overrides all local references.
The API returns JSON. All outputs must be a single JSON object with no explanatory prose outside it.
Normalization Conventions
- Email: lowercase, trim leading/trailing whitespace. Empty string when no email supplied.
- Phone: strip all non-digit characters (spaces, parens, dots, hyphens, leading
+1). Empty string when no phone supplied. - USD amounts: always integers (whole dollars, no cents).
- Dates:
YYYY-MM-DDformat. Compute follow-up dates asend_date + N days(calendar addition). - Booleans: JSON
true/false, not strings.
Sorting Rules (universal)
- Sort string lists ascending (lexicographic, case-sensitive as returned by API).
- Where a template specifies a sort key, use it. Common:
account_nameasc,company_nameasc,badge_idasc,clean_contact_idasc,row_idasc,rankasc. - For platform enums appearing in lists, sort in the enum definition order:
AUV,ROV,Underwater Camera. - For
excluded_recordswith two sort keys: primary bycompany_nameasc, secondary bycontact_nameasc.
API Endpoint Reference
Events
| Endpoint | Description |
|---|---|
GET /api/events |
List all events |
GET /api/events/{event_id} |
Event detail: name, start_date, end_date, campaign_code, status, followup_days_after_end, sponsor_followup_days_after_end, lead_opportunity_amount (integer USD) |
GET /api/events/{event_id}/orders |
Sponsor orders: account_id, account_name, amount, order_status (confirmed, proposal_sent, canceled), package_level, ticket_contacts, voucher_code |
GET /api/events/{event_id}/sponsor_packages |
Same shape as orders (identical data) |
GET /api/events/{event_id}/badges |
Badge scans: badge_id, badge_type (sponsor, attendee, student, press), company_name, contact_name, email, phone, job_title, scan_score, session_interest, source |
Finance
| Endpoint | Description |
|---|---|
GET /api/finance/invoices?event_id={event_id} |
Invoices: invoice_id, account_id, account_name, amount, paid_amount, deferred_amount, status (paid_deferred, open), due_date, invoice_date, payment_date |
CRM
| Endpoint | Description |
|---|---|
GET /api/crm/accounts |
All accounts: account_id, name, domain, industry, status (customer, prospect, disqualified), disqualified_reason (string or null), owner_region |
GET /api/crm/contacts |
All contacts: contact_id, account_id, name, email (normalized), phone (digits-only), title, opted_out (bool), source_updated_at |
GET /api/crm/opportunities |
All opportunities: opportunity_id, account_id, event_id, name, amount, stage, close_date |
GET /api/crm/campaign_members?event_id={event_id} |
Campaign members: account_id, contact_id, event_id, status (attended_sponsor, registered_sponsor, attended), last_activity_date |
Trade Shows
| Endpoint | Description |
|---|---|
GET /api/tradeshows |
List all trade shows |
GET /api/tradeshows/{show_id}/exhibitors |
Exhibitors: company_id, company_name, booth, country, website, description, crm_account_id (string or null), show_id |
GET /api/tradeshows/{show_id}/meeting_interest |
Meeting interest: company_name, interest_score (int), notes, requested_demo (bool), show_id |
Import Batches
| Endpoint | Description |
|---|---|
GET /api/import_batches |
List all batches |
GET /api/import_batches/{batch_id}/raw_contacts |
Raw rows: row_id, company_name, contact_name, email, phone, source_name, captured_at (ISO timestamp), country, city, interest |
GET /api/import_batches/{batch_id}/suppression |
Suppression list: email, phone (normalized), reason (global_opt_out, privacy_request, role_account) |
Policies
| Endpoint | Description |
|---|---|
GET /api/policies |
Business rules: contact_hygiene (normalization), prospecting.platform_enums (["AUV","ROV","Underwater Camera"]), sponsor_handoff.status_enums (["paid_deferred","open_invoice","proposal_only","not_sponsor"]) |
Task Pattern: Event Reconciliation
Applies when the task provides an event_id and asks to reconcile sponsors, badges, invoices, and CRM.
Workflow
- Fetch event detail, orders, badges, invoices, CRM accounts, CRM contacts, CRM opportunities, campaign members for the event, and policies.
- Build a sponsor lookup: keyed by
account_idfrom orders whereorder_status != "canceled". - Build a CRM account lookup: keyed by
account_id; notedisqualified_reason(null = active). - Build a CRM contact lookup: keyed by
(account_id, contact_name). - Classify each sponsor, each badge, and each campaign member.
Sponsor Status Classification
For each order where order_status != "canceled":
- Find the invoice matching
(account_id, event_id). - Derive
sponsor_status:- Invoice exists with
status = "paid_deferred"→paid_deferred - Invoice exists with
status = "open"→open_invoice - No invoice exists and
order_status = "proposal_sent"→proposal_only - No invoice exists and
order_status = "confirmed"— the invoice is pending; treat asopen_invoicewithpaid_amount = 0andopen_balance = order.amount
- Invoice exists with
- Canceled orders (
order_status = "canceled") are inactive — exclude from sponsor_statuses; report asinactive_sponsor_recordin exclusions.
Sponsor Revenue Totals
paid_deferred: sum ofamountfor all sponsors with sponsor_statuspaid_deferred.open_invoice: sum ofamountfor all sponsors with sponsor_statusopen_invoice.proposal_only: sum ofamountfor all sponsors with sponsor_statusproposal_only.open_invoice_balance: sum of(invoice.amount - invoice.paid_amount)across all invoices with status"open".
Sponsor Status Record Fields
account_id: from the order.account_name: from the order.status: one ofpaid_deferred,open_invoice,proposal_only.package_amount: orderamount(integer).invoice_id: invoiceinvoice_idif invoice exists, elsenull.paid_amount: invoicepaid_amountif invoice exists, else0.open_balance:invoice.amount - invoice.paid_amountif invoice exists and status is"open", else0.
Badge Classification
| badge_type | Company is active sponsor? | CRM account disqualified? | Classification |
|---|---|---|---|
sponsor |
yes | any | sponsor_attendee |
attendee |
no | no (or no CRM account) | qualified_non_sponsor_lead |
attendee |
yes | any | sponsor_attendee |
attendee |
no | yes | excluded / existing_disqualified |
student |
any | any | excluded / non_business_badge |
press |
any | any | excluded / non_business_badge |
A company is an "active sponsor" if it has an order with order_status != "canceled" for this event.
Exclusion Reasons
sponsor_attendee: badge belongs to a sponsor company (active order).existing_disqualified: CRM account exists anddisqualified_reasonis not null.inactive_sponsor_record: sponsor order withorder_status = "canceled".non_business_badge:badge_typeisstudentorpress.
Qualified Lead Fields
account_name: from badgecompany_name.account_id: CRMaccount_idif account exists, elsenull.primary_contact: badgecontact_name.normalized_email: normalize badgeemail.normalized_phone: normalize badgephone.crm_account_action:"update_existing"if CRM account exists and is not disqualified, else"create_account".crm_contact_action:"update_existing"if CRM contact exists matching(account_id, contact_name), else"create_contact".campaign_member_action:"add_campaign_member".opportunity_amount: the event'slead_opportunity_amount(same for all qualified leads).
Campaign Member Actions (when template includes campaign_member_actions)
subject_key: a compound key identifying the member, e.g.,"{account_id}:{contact_id}"or"{badge_id}". Use a stable, sortable identifier. If the template sorts bysubject_key, derive it so sorting is predictable.action:"create"(new member),"update"(existing member needs status change),"no_action"(existing member already correct),"no_import"(excluded).target_status:- For sponsor attendees:
"attended_sponsor"(badge scanned) or"registered_sponsor"(no badge, from order ticket_contacts). - For qualified non-sponsor leads:
"attended". - For excluded:
"excluded"or omit.
- For sponsor attendees:
For existing campaign members, compare the desired target status with the current status. If they match → no_action. If they differ → update. If no campaign member record exists for a qualified lead → create.
CRM Action Counts
Count unique accounts and contacts across all qualified leads:
accounts_create: qualified leads withcrm_account_action = "create_account".accounts_update: qualified leads withcrm_account_action = "update_existing".contacts_create: qualified leads withcrm_contact_action = "create_contact".contacts_update: qualified leads withcrm_contact_action = "update_existing".campaign_members_create: qualified leads getting new campaign member records.campaign_members_update: existing campaign members being updated.
When the template includes lead_pipeline_total: sum of opportunity_amount across all qualified leads (equals qualified_lead_count × lead_opportunity_amount when all leads use the same event amount).
Follow-Up Dates
lead_due_date:end_date + followup_days_after_end(from event).sponsor_finance_due_date:end_date + sponsor_followup_days_after_end(from event).lead_task_count: number of qualified lead accounts.sponsor_finance_task_count: number of sponsor accounts withopen_invoiceorproposal_onlystatus.sponsor_finance_accounts: account names withopen_invoiceorproposal_only, sorted ascending.
Badge-Only Contacts (when template includes badge_only_contacts)
These are qualified leads whose contact info comes only from badge scans (not already in CRM contacts). Include normalized email and phone even when empty.
Task Pattern: Trade-Show Prospecting
Applies when the task provides a show_id and asks to qualify exhibitors for a campaign.
Workflow
- Fetch trade show, exhibitors, meeting interest, CRM accounts, CRM contacts, and policies.
- Read each exhibitor's
descriptionto determine what they build/manufacture. - Classify platform coverage from the policy
platform_enums. - Classify priority tier from meeting interest data.
- Separate qualified exhibitors from excluded near-misses.
Platform Classification
Read the exhibitor description and decide if they build or OEM-manufacture platforms in the policy's platform_enums. Keywords that indicate platform building:
- AUV: "builds AUV", "autonomous underwater vehicle", "AUV scouts", "autonomous AUV"
- ROV: "builds ROV", "inspection-class ROV", "ROV with", "manufactures ROV", "pen-cleaning ROVs"
- Underwater Camera: "underwater camera", "camera modules", "camera arrays", "OEM underwater camera", "rugged underwater camera"
An exhibitor can qualify for multiple platforms. Sort platforms in the enum order from policies (AUV, ROV, Underwater Camera).
Qualification Gate
Qualified: Exhibitor description indicates they build or OEM-manufacture at least one target platform. They integrate hardware, not just resell, service, or provide software-only analytics.
Excluded (near-misses) — use these controlled reasons:
distributor_only: reseller, dealer, sales agent; "does not manufacture"service_only: consulting, rental/operation services, no manufacturingsensor_only/sensor_vendor_only: builds only sensors/probes, not the platforms that carry themresearch_only: academic/research institution, not a commercial platform buildernot_target_market: analytics software, dashboard-only, no hardware
Match the exclusion reason to the relationship_type when the template requires both.
Priority Tier Assignment
When meeting interest data exists for an exhibitor:
| Condition | Tier | Opportunity (if template requires) |
|---|---|---|
requested_demo = true AND interest_score >= 90 |
A | Follow task-specific amounts |
requested_demo = true AND interest_score >= 80 |
B | Follow task-specific amounts |
| All other qualified | C | Follow task-specific amounts |
The task prompt or template may specify different score thresholds or amounts per tier. Default tier assignment: C for any qualified exhibitor that doesn't meet A or B criteria.
Ranking (when template requires rank)
Sort qualified exhibitors by:
requested_demo = truefirst (demo requesters before non-requesters)interest_scoredescending (higher scores first; treat missing as 0)- Number of platforms descending (broader coverage ranks higher)
company_nameascending (tiebreaker)
Assign contiguous 1-based ranks.
CRM Action for Exhibitors
create_account:crm_account_idisnull(no CRM match).update_existing:crm_account_idis notnull.no_import: all excluded exhibitors.
Existing CRM Overlap
existing_crm_overlap_count: count of qualified exhibitors with non-nullcrm_account_id.existing_crm_overlap_account_ids: those CRM account IDs, sorted ascending.
Aggregate / Summary Counts
qualified_total: count of qualified exhibitors.excluded_count/excluded_near_misses_total: count of excluded exhibitors.platform_coverage_counts: for each platform enum, count how many qualified exhibitors cover that platform.priority_counts: for each tier (A, B, C), count how many qualified exhibitors have that tier.total_estimated_opportunity_usd: sum of all qualified leads' opportunity estimates.
Task Pattern: Import Batch Cleaning
Applies when the task provides a batch_id and asks to clean raw contacts for CRM import.
Workflow
- Fetch batch detail, raw contacts, suppression list, CRM accounts, CRM contacts, policies.
- Normalize all emails and phones in raw contacts.
- Remove unusable rows (missing contact name).
- Deduplicate by normalized email.
- Check suppression list.
- Match survivors against CRM.
- Classify CRM actions and compute summary counts.
Step-by-Step Rules
1. Unusable Row Removal A raw contact row is unusable if:
contact_nameis blank/whitespace-only, OR- Both
emailandphoneare blank/whitespace-only after normalization. - Reason:
"missing_contact".
2. Deduplication
- Duplicate key: normalized email (lowercase, trimmed). Two rows with the same normalized email are duplicates.
- Winner selection: the row with the most recent
captured_attimestamp. If timestamps are equal, prefer the row appearing earlier in the raw contacts list (lower array index; alternatively, lowerrow_idstring sort). - The winning row becomes the
clean_contact_id(use itsrow_id) andsource_row_id. - Removed duplicates get reason
"duplicate". - Record each duplicate group in
duplicate_summary.duplicate_keyswith:key: the normalized email.winner_row_id: the survivingrow_id.removed_row_ids: list of removedrow_ids.
3. Suppression Check For each surviving row, check the suppression list:
- Match by normalized email (case-insensitive) OR normalized phone (digits-only).
- If either matches →
crm_action = "suppress", reason"suppressed". - A row that is both duplicate-removed AND suppressed only appears once in
removed_rows. Deduplication takes precedence (the row is removed as a duplicate, not re-counted as suppressed).
4. CRM Matching For each surviving, non-suppressed row:
- Match against CRM accounts: first try exact
company_name== CRMname. If no exact match, try domain matching — extract the domain from the row's normalized email and match against CRMdomain. Use the matched CRM account'saccount_idasexisting_account_id. If neither matches,existing_account_id = null. - Match
contact_nameagainst CRM contacts for that account. If a CRM contact with the samenameexists for the matched account, setexisting_contact_id. Otherwisenull. - For trade-show exhibitors: the
crm_account_idfield on the exhibitor record is already the CRM match — use it directly rather than name-matching.
5. CRM Action Classification
create_account: no matching CRM account found, contact is not suppressed, contact is usable.update_existing: matching CRM account found, contact usable.no_import: row is unusable (missing contact), but not suppressed.suppress: contact matched the suppression list.
6. Clean Contact Record Fields
clean_contact_id: the winningrow_id(same assource_row_id).source_row_id: the winningrow_id.company_name,contact_name: from the winning row.email: normalized email (lowercase, trimmed) or"".phone: digits-only or"".source_name: from the winning row (one of:badge_scan,sponsor_form,partner_upload,webinar_form,exhibitor_form,manual_upload).captured_at: ISO timestamp from the winning row.crm_action: as classified above.existing_account_id: CRMaccount_idornull.existing_contact_id: CRMcontact_idornull.
7. Counts
import_action_totals.create_account: rows withcrm_action = "create_account".import_action_totals.update_existing: rows withcrm_action = "update_existing".import_action_totals.no_import: rows withcrm_action = "no_import".import_action_totals.suppress: rows withcrm_action = "suppress".campaign_member_import_count: number of clean contacts withcrm_actionin(create_account, update_existing)— these become campaign members for the batch'scampaign_code.
8. Removal Summary
unusable_removed_count: rows removed formissing_contact.suppressed_removed_count: rows removed forsuppressed.removed_rows: list of{row_id, reason}for all removed rows (both unusable and suppressed, but NOT duplicate-removed). Sort byrow_idascending.
Common Pitfalls
-
Canceled ≠ Proposal:
order_status = "canceled"means the sponsor is inactive — exclude entirely from sponsor_statuses.order_status = "proposal_sent"without an invoice isproposal_only— still an active sponsor record. -
Invoice amount vs paid_amount: The open balance is
amount - paid_amount, notamount - deferred_amount.deferred_amountis a revenue-recognition field, not the outstanding balance. -
Badge email/phone are raw: Always normalize badge emails (lowercase, trim) and phones (digits only) before comparing with CRM contacts (which are already normalized).
-
CRM account matching strategy varies by task type: For event reconciliation, sponsor order
account_namematches CRMnameexactly. For import batch cleaning, try exactcompany_namematch first, then fall back to domain matching (extract domain from normalized email, match against CRMdomain). For trade-show prospecting, use the exhibitor'scrm_account_idfield directly — it is pre-linked. -
Duplicate-key precedence: Dedup removal takes priority over suppression. A row that is a duplicate is removed as
duplicate, not re-reported as suppressed — even if it would also match the suppression list. -
Same-timestamp tiebreaker in dedup: When two duplicate rows share the same
captured_at, the earlier row in the list (lower array index) wins. Verify by checking row order from the API response. -
Non-sponsor but CRM-disqualified: A badge for a non-sponsor company that has a disqualified CRM account is excluded as
existing_disqualified, NOT as a qualified lead. The disqualification blocks the lead regardless of badge type. -
Sponsor badge type vs sponsor company: A badge with
badge_type = "attendee"but acompany_namematching an active sponsor order'saccount_nameis still asponsor_attendee. Classification depends on company sponsorship, not just thebadge_typefield. -
Proposal-only sponsors and badges: A company with
order_status = "proposal_sent"IS an active sponsor. Their badges aresponsor_attendee. Their contacts fromticket_contactsin the order should be considered for campaign member records even if they didn't scan a badge. -
Platform classification from descriptions: Read the full description text. A company that "builds ROVs with camera arrays" qualifies for both
ROVANDUnderwater Camera. A company that "embeds third-party probes" into cameras qualifies forUnderwater Camera(they build the camera platform) but not for AUV/ROV. A company that only makes the sensor/probe itself does NOT qualify. -
Empty email is allowed: A badge with an empty email but a valid phone is still a qualified lead (not
missing_contact).missing_contactmeans the contact name is missing or both email AND phone are blank. -
opted_out contacts: CRM contacts with
opted_out = trueare still valid CRM records for matching purposes. Opt-out status affects whether to contact them but not whether they exist in the CRM. However, suppression list entries withglobal_opt_outorprivacy_requestDO cause removal from import batches. -
Sorting is case-sensitive: API responses use exact casing. Sort lexicographically as-is; do not lowercase for sorting.
-
Single JSON output: Never include markdown fences, explanations, or prose outside the JSON object. The output must parse as a single JSON value.