HarborCRM CRM-Marketing Task Skill
A transferable playbook for solving HarborCRM post-event / prospecting / import-batch
handoff tasks. The remote dataset is deterministic and the business conventions below
are stable across tasks; only the event, tradeshow, or import batch varies.
0. Environment & ground rules
- Base URL (the only allowed API):
<remote-env-url>
- All endpoints are read-only GET. Pipe
curl -s <url> through python3 -m json.tool.
- Always read the task's own
answer_template.json first. The template is the contract:
it dictates the exact top-level keys, item keys, allowed enum values, ordering
rules, and number formatting. Two tasks that look similar can use slightly different
enum vocabularies (e.g. sensor_vendor_only vs sensor_only) — always emit the
template's allowed values, never a synonym from memory.
- Return ONE JSON object only, no prose, no extra fields.
Endpoint reference
| Endpoint |
Use |
GET /api/events |
List events (campaign_code, dates, followup day offsets, lead_opportunity_amount) |
GET /api/events/{event_id} |
Single event detail |
GET /api/events/{event_id}/orders |
Sponsor orders (= sponsor_packages) |
GET /api/events/{event_id}/sponsor_packages |
Same sponsor order data |
GET /api/events/{event_id}/badges |
Badge scans for the event |
GET /api/finance/invoices?event_id={event_id} |
Invoices for sponsors |
GET /api/crm/accounts |
All CRM accounts (account_id, domain, status, disqualified_reason) |
GET /api/crm/contacts |
All CRM contacts (contact_id, account_id, email, phone, opted_out) |
GET /api/crm/opportunities |
CRM opportunities (mostly informational; lead opp $ comes from the event) |
GET /api/crm/campaign_members?event_id={event_id} |
Existing campaign members for an event |
GET /api/tradeshows |
Tradeshow list |
GET /api/tradeshows/{show_id}/exhibitors |
Exhibitor rows (incl. crm_account_id field) |
GET /api/tradeshows/{show_id}/meeting_interest |
Per-company interest_score + requested_demo (keyed by company_name) |
GET /api/import_batches |
Batch metadata (campaign_code) |
GET /api/import_batches/{batch_id}/raw_contacts |
Rows to clean |
GET /api/import_batches/{batch_id}/suppression |
Suppression list for the batch |
GET /api/policies |
Policy enums (platform_enums, sponsor status_enums) |
Task-type detection
Map the prompt to a type by the entity it names:
- Event handoff — prompt names an
event_id and talks about sponsors/badges/invoices/reconciliation. Endpoints: events, orders, badges, invoices, crm/accounts, crm/contacts, crm/opportunities, campaign_members, policies. (train_001 and train_004 shapes.)
- Tradeshow prospecting — prompt names a
show_id / trade show and qualified leads/exhibitors. Endpoints: tradeshows, exhibitors, meeting_interest, crm/accounts, policies. (train_002 and train_005 shapes.)
- Import-batch cleaning — prompt names a
batch_id and asks to prepare contacts for CRM import. Endpoints: import_batches, raw_contacts, suppression, crm/accounts, crm/contacts, policies. (train_003 shape.)
1. Universal normalization conventions
Email normalization
lowercase + trim surrounding whitespace.
- Example:
" Dana.Ruiz@HelioWare.example " → dana.ruiz@helioware.example; "KENJI.SATO@monarchfoods.example" → kenji.sato@monarchfoods.example.
- A field that is only whitespace → empty string
"".
Phone normalization
- Strip every non-digit character. Keep all digits that remain, including a leading
country-code
1 if it was present in the source.
- Do NOT force 10 digits; do NOT strip a leading
1.
"+1 (512) 555-0121" → "15125550121"; "1.206.555.0177" → "12065550177"; "212-555-0166" → "2125550166"; "(415) 555-0188" → "4155550188".
- No phone supplied →
"" (empty string, not null).
Account matching (for badge leads & import rows)
- Match a row to an existing CRM account by email domain: take the part of the
normalized email after
@, compare to account.domain (case-insensitive).
dana.ruiz@helioware.example → domain helioware.example → account acct_helio_ware.
- If the account exists →
update_existing (account action). If not → create_account.
- If a row has no email, you cannot match by domain →
create_account (unless suppressed/missing).
Contact matching
- Match to an existing CRM contact by normalized email equality against
contact.email
(lowercased). If no exact email match → the contact is new (existing_contact_id: null,
create the contact). Do not infer a contact match from name alone when emails differ.
2. Event-handoff conventions (sponsor + badge reconciliation)
2.1 Sponsor-status classification
For each sponsor order/package at the event, classify using order_status and the
matching invoice (match invoice by account_id within the event's invoices):
| order_status |
invoice state |
sponsor_status |
invoice_id |
paid_amount |
open_balance |
confirmed |
invoice.status paid_deferred OR paid_amount >= amount |
paid_deferred |
invoice.id |
invoice.paid_amount |
0 |
confirmed |
invoice.status open (paid_amount < amount) |
open_invoice |
invoice.id |
invoice.paid_amount |
amount - paid_amount |
proposal_sent |
no invoice |
proposal_only |
null |
0 |
0 |
canceled |
(any) |
excluded entirely from sponsor_statuses |
— |
— |
— |
amount_usd / package_amount reported = the sponsor package amount.
- An account that is not a sponsor at all (no order, not canceled) would be
not_sponsor
(enum value; rarely appears in the sponsor list).
paid_deferred means paid in full (deferred revenue recognition) — it is not a
collection follow-up target.
2.2 Sponsor revenue rollup (when the schema has a totals block)
paid_deferred total = Σ package amount of all paid_deferred sponsors.
open_invoice total = Σ package amount of all open_invoice sponsors.
proposal_only total = Σ package amount of all proposal_only sponsors.
open_invoice_balance = Σ open_balance of open_invoice sponsors (i.e. Σ (amount − paid)).
- (Totals use package amounts, not paid amounts.)
2.3 Badge classification & exclusion precedence
Classify each badge for the event, in this precedence order:
- existing_disqualified — if the badge company matches a CRM account whose
status == "disqualified" (any disqualified_reason). This wins over everything.
- sponsor_attendee — else if the badge company is an active sponsor (has a
non-
canceled sponsor order at this event), OR badge_type == "sponsor".
- non_business_badge — else if
badge_type is a non-business type
(student, press, and similar). Reason non_business_badge.
- missing_contact — else if the badge has no contact name / no contactable info.
- qualified_non_sponsor_lead — otherwise (an
attendee-type badge on a valid,
non-disqualified, non-sponsor company). These go to the qualified-lead list.
Notes:
- A
canceled sponsor whose account is also disqualified shows as existing_disqualified
(rule 1 wins). A canceled sponsor whose account is not disqualified would surface as
inactive_sponsor_record (enum value) — apply this when you see a canceled order with a
non-disqualified account and an associated badge/contact.
existing_disqualified covers any badge/contact whose CRM account is disqualified,
even if it has no sponsor relationship.
2.4 Enumerating excluded sponsor attendees (event-handoff schemas that lump them)
Some schemas collect sponsor attendees into an excluded_records list. For each
active (non-canceled) sponsor account, emit exactly one sponsor_attendee record:
- Prefer a badge captured for that sponsor company (the contact on that badge).
- If the sponsor has no badge at the event, fall back to the first
ticket_contact
in the sponsor package.
- Do not enumerate companion / additional ticket contacts beyond the one
representative per account (e.g. a platinum sponsor listing two ticket contacts emits
one excluded record, the attendee of record).
Plus all non-qualifying badges (non-business, disqualified) become their own excluded
records. De-duplicate so a contact isn't listed twice.
2.5 Qualified non-sponsor leads (event handoff)
- Source:
attendee-type badges whose company is NOT an active sponsor and whose CRM
account is NOT disqualified.
- Account action:
create_account if no existing account by email domain, else update_existing.
- Contact action:
create_contact if no existing contact by email, else update_existing
(no example of update_existing in trains, but follow the email-match rule).
- Campaign-member action:
add_campaign_member (create) if the contact is not already a
campaign member for this event; if already a member, treat as update/no new create.
- Normalized email/phone come from the badge (apply §1 rules).
opportunity_amount per qualified lead = the event's lead_opportunity_amount
(a single field on the event object). Pipeline/total = (qualified lead count) ×
event.lead_opportunity_amount.
2.6 Opportunity / pipeline math (per schema wording)
- If the schema asks for a per-lead amount (e.g.
lead_opportunity_amount_usd):
report the event's lead_opportunity_amount as-is.
- If it asks for a total (e.g.
open_opportunity_total_usd, lead_pipeline_total):
(qualified non-sponsor lead count) × event.lead_opportunity_amount.
open_opportunity_count / lead task count = number of qualified non-sponsor leads.
2.7 Follow-up due dates (date arithmetic)
lead_followup_due_date = event.end_date + event.followup_days_after_end days
(plain calendar days; not business days).
sponsor_followup_due_date = event.end_date + event.sponsor_followup_days_after_end days.
- Format
YYYY-MM-DD. Validate: e.g. end 2026-09-16 + 7 = 2026-09-23; end 2026-11-05 + 2 = 2026-11-07.
2.8 Sponsor finance follow-up targets
- Targets = sponsors that are not paid in full =
open_invoice + proposal_only
(exclude paid_deferred and canceled).
unpaid_sponsor_total_usd / sponsor-finance total = Σ package amount of those targets.
sponsor_finance_task_count = number of those target accounts.
sponsor_finance_accounts = their account names, sorted ascending.
- Due date = sponsor_followup_due_date (§2.7).
2.9 CRM action counting (event handoff, count-style schemas)
For the qualified non-sponsor leads only (sponsor attendees that already exist are not
counted as creates):
accounts_create = # leads with no existing account.
accounts_update = # leads with an existing account.
contacts_create = # leads whose contact is new (no email match).
contacts_update = # leads with an existing contact (email match). (0 when none.)
campaign_members_create = # leads not already a campaign member for the event.
campaign_members_update = # leads already a member (0 when none).
lead_task_count = qualified lead count.
Do NOT count sponsor attendees as new creates when they already exist; do NOT count
excluded/disqualified badges.
2.10 Campaign-member action list (event-handoff schemas with per-subject rows)
Build a row per subject that needs action, combining:
- Existing campaign members for the event →
action: "no_action", target_status
= their existing status (attended_sponsor, registered_sponsor, attended, …).
subject_key = "{account_id}:{contact_id}".
- New badge-derived actions:
- Qualified non-sponsor lead badge →
action: "create", target_status: "attended",
subject_key: "badge:{badge_id}".
- Sponsor-attendee badge whose contact/CM is new →
action: "create",
target_status: "attended_sponsor", subject_key: "badge:{badge_id}".
- Sponsor-attendee already an existing CM → covered by the existing-CM row (no_action).
target_status enum: attended_sponsor (sponsor who attended), registered_sponsor
(sponsor registered, not attended), attended (non-sponsor lead attended), excluded.
- Sort by
subject_key ascending (acct_* keys sort before badge:* keys).
2.11 Badge-only contacts list (when schema asks for normalized badge contacts)
- Include badges that require contact creation: qualified non-sponsor leads that need
a new account+contact, and sponsor-attendee badges whose contact is new.
- Exclude badges whose contact already exists and exclude non-business/disqualified badges.
- Fields:
company_name, contact_name, normalized_email (lowercase trim; "" if none),
normalized_phone (digits only; "" if none).
- Sort by
company_name ascending.
2.12 Badge crm_action enum (combined-action schemas)
- No existing account →
create_account_contact_campaign_member.
- Existing account, new contact →
create_contact_campaign_member.
- Existing account + contact, no CM →
add_campaign_member.
- Existing account + contact + CM →
no_action.
- Excluded badge →
no_import.
- Sponsor-attendee with existing everything →
no_action (still classified sponsor_attendee).
2.13 Event-handoff sorting
sponsor_statuses → account_name ascending.
excluded_records → company_name ascending, then contact_name ascending.
qualified_lead_accounts → account_name ascending.
badge_decisions → badge_id ascending.
campaign_member_actions → subject_key ascending.
badge_only_contacts → company_name ascending.
exclusion_counts → object, fixed keys (sponsor_attendee, non_business_badge, existing_disqualified, missing_contact).
opportunity_summary.qualified_non_sponsor_account_names / sponsor_followup.unpaid_sponsor_account_names → account names ascending.
3. Tradeshow prospecting conventions
3.1 Qualification vs exclusion
An exhibitor is qualified iff it makes / manufactures / OEM-builds a target platform.
classify from description + context (policy says: "decide whether an account builds
target underwater platforms or is only adjacent to them"). Exclude exhibitors that are
merely adjacent:
| Inferred relationship_type |
Exclusion reason (use the template's enum) |
distributor / reseller / dealer ("does not manufacture", "reseller", "sales agent") |
distributor_only |
service_provider / consulting / operates rented gear / analytics-only dashboard |
service_only (template may say service_only) |
sensor_vendor / sensor-only probes ("sensor-only", "probes") |
sensor_vendor_only (train_002) or sensor_only (train_005) — match the template |
research / academic lab |
research_only |
| otherwise not a target market |
not_target_market (if the template lists it) |
Always use the exact exclusion-reason enum strings declared in that task's template.
3.2 Platform coverage inference
From the qualified exhibitor's description, detect platforms by keyword:
AUV (autonomous underwater vehicle) — words "AUV".
ROV (remotely operated vehicle) — words "ROV".
Underwater Camera — words "camera" / "underwater camera" / "camera array/module".
- Output the list sorted in the policy/enum order:
AUV, ROV, Underwater Camera.
(Use /api/policies → prospecting.platform_enums for the canonical order.)
- An exhibitor can map to multiple platforms (e.g. "ROVs with camera arrays" →
[ROV, Underwater Camera]).
3.3 CRM action (tradeshow)
- Use the exhibitor's
crm_account_id field directly (not domain matching).
- Non-null →
crm_action: "update_existing", crm_account_id: that id.
- Null →
crm_action: "create_account", crm_account_id: null.
- Excluded exhibitors →
crm_action: "no_import".
3.4 interest_score / requested_demo
- Join exhibitor to
meeting_interest by company_name (the meeting_interest rows are
keyed by company_name, not by company_id).
requested_demo (boolean) and interest_score (integer) come from that row.
- If a qualified exhibitor has no meeting_interest row, treat
requested_demo: false,
interest_score: 0 (no example of this; fall back conservatively).
3.5 Priority tier (universal prospecting convention)
Even when a terse prompt doesn't state it, assign tier from demo + score:
- A —
requested_demo == true AND interest_score >= 90.
- B —
requested_demo == true AND interest_score >= 80 (and < 90).
- C — all other qualified leads (no demo, or score < 80).
If the prompt explicitly overrides, follow the prompt.
3.6 Opportunity sizing (when an opportunity_estimate_usd field exists)
Standard fixed sizing by tier:
- A →
120000, B → 90000, C → 50000 (USD integer).
total_estimated_opportunity_usd = Σ across ranked leads.
(If the prompt states different numbers, use the prompt's.)
3.7 Ranking qualified leads
Sort key (in order):
requested_demo descending (true before false).
interest_score descending.
- Broader platform coverage descending (count of platforms, more first).
company_name ascending.
Assign rank as 1-based contiguous integers in that order.
3.8 Aggregates
qualified_lead_count / qualified_total = # qualified exhibitors.
excluded_count / excluded_near_misses_total = # excluded exhibitors.
existing_crm_overlap_count = # qualified exhibitors with non-null crm_account_id.
existing_crm_overlap_account_ids = those ids, sorted ascending.
platform_coverage_counts / platform_counts = for each platform enum, count of
qualified exhibitors that cover it (an exhibitor covering 2 platforms counts once in
each). Keys are all platform enums; value type integer.
priority_counts (if required) = count of A / B / C among qualified leads.
total_estimated_opportunity_usd = Σ opportunity_estimate (§3.6).
3.9 Tradeshow sorting
qualified_exhibitors → company_name ascending.
ranked_leads → rank ascending (per §3.7).
excluded_exhibitors / excluded_near_misses → company_name ascending.
4. Import-batch cleaning conventions
4.1 Inputs
- Raw rows:
/api/import_batches/{batch_id}/raw_contacts.
- Suppression:
/api/import_batches/{batch_id}/suppression.
campaign_code and batch_id come from /api/import_batches (match by batch_id).
- Existing accounts/contacts via
/api/crm/accounts, /api/crm/contacts.
4.2 Normalization (apply to every row)
email → lowercase + trim (§1). Whitespace-only → "".
phone → digits only (§1). Whitespace-only → "".
4.3 Removal pipeline (in order)
- missing_contact — after normalization, if
email == "" AND phone == "" → remove
with reason missing_contact. (A row with email XOR phone is usable.)
- duplicate — group remaining rows by normalized
email (only rows that have an
email are candidates; rows without email are not deduped by phone). Within a group:
- Winner = the row with the latest
captured_at.
- On an exact
captured_at tie, prefer the source earlier in this priority:
badge_scan > sponsor_form > partner_upload > webinar_form > exhibitor_form
manual_upload (equivalently, the higher row_id).
- All non-winner rows in the group → remove with reason
duplicate.
- Duplicate key reported as
"email:{normalized_email}".
- suppressed — for each remaining (winner) row, remove with reason
suppressed if
its normalized email matches any suppression email OR its normalized phone matches
any suppression phone. (Suppression entries with empty phone match by email only;
empty-email entries match by phone only.)
- Survivors =
clean_contacts.
4.4 clean_contacts fields
clean_contact_id = source_row_id = the winner's row_id.
company_name, contact_name from the winning row (as-is).
email = normalized email (or "").
phone = normalized digits-only phone (or "").
source_name = winner's source_name (enum: badge_scan, sponsor_form, partner_upload,
webinar_form, exhibitor_form, manual_upload).
captured_at = winner's ISO timestamp.
existing_account_id = matched account id by email domain (§1), else null.
existing_contact_id = matched contact id by normalized email (§1), else null.
crm_action:
update_existing — if an existing account matched (by domain).
create_account — if no existing account matched.
- (Rows that are removed never reach this list; suppressed/missing/duplicate are not
clean_contacts.)
4.5 duplicate_summary
duplicate_removed_count = total number of rows removed as duplicates.
duplicate_keys = list of { "key": "email:{normalized_email}", "winner_row_id": <id>, "removed_row_ids": [<ids>] }, sorted by key ascending.
4.6 removal_summary
unusable_removed_count = # rows removed as missing_contact ONLY. (Duplicates are NOT
"unusable" — each duplicate group has a surviving winner, so the losers are redundant, not
unusable. Duplicates are counted in duplicate_summary.duplicate_removed_count, not here.)
suppressed_removed_count = # rows removed as suppressed.
removed_rows = ALL removed rows (duplicates + missing + suppressed) as
{ "row_id": <id>, "reason": <enum> }, where reason ∈ {duplicate, missing_contact,
suppressed}, sorted by row_id ascending. (Note: removed_rows is the union;
unusable_removed_count + suppressed_removed_count will NOT sum to len(removed_rows)
because duplicate rows appear in removed_rows but in neither count.)
4.7 import_action_totals
Count of clean + removed rows by final action (integers):
create_account = # clean rows with no existing account.
update_existing = # clean rows with an existing account.
no_import = # rows removed as duplicate + missing_contact.
suppress = # rows removed as suppressed.
- Sanity:
create_account + update_existing + no_import + suppress = total raw rows.
campaign_member_import_count = # clean rows (create_account + update_existing) —
every surviving contact becomes a member of the batch's campaign.
4.8 Import-batch sorting
clean_contacts → clean_contact_id ascending.
duplicate_summary.duplicate_keys → key ascending.
removal_summary.removed_rows → row_id ascending.
5. Field-by-field quick reference (most common schemas)
Event handoff (sponsor_statuses item)
account_id, account_name, sponsor_status (paid_deferred|open_invoice|proposal_only|not_sponsor),
amount_usd/package_amount (= package amount, integer), invoice_id (or null),
paid_amount (integer), open_balance (= amount − paid; 0 for paid/proposal).
Event handoff (qualified_lead_accounts item)
account_name, account_id (null if new), primary_contact (badge contact_name),
normalized_email, normalized_phone, crm_account_action (create_account|update_existing),
crm_contact_action (create_contact|update_existing), campaign_member_action
(add_campaign_member), opportunity_amount (= event.lead_opportunity_amount).
Event handoff (excluded_records item)
company_name, contact_name, reason
(sponsor_attendee|existing_disqualified|inactive_sponsor_record|non_business_badge).
Event handoff (follow_up)
lead_due_date (end + lead days), lead_task_count (= # qualified leads),
sponsor_finance_due_date (end + sponsor days), sponsor_finance_task_count
(= # unpaid sponsors), sponsor_finance_accounts (unpaid sponsor names ascending).
Tradeshow (ranked_leads item)
rank, company_id, company_name, booth, country, website, platforms (enum
order), crm_account_id (or null), crm_action (create_account|update_existing|no_import),
requested_demo, interest_score, priority_tier (A|B|C), opportunity_estimate_usd.
Tradeshow (excluded item)
company_id, company_name, relationship_type (distributor|service_provider|sensor_vendor|research),
exclusion_reason (template enum), crm_action (no_import).
Import (clean_contacts item)
clean_contact_id, source_row_id, company_name, contact_name, email, phone,
source_name, captured_at, crm_action (create_account|update_existing|no_import|suppress),
existing_account_id (or null), existing_contact_id (or null).
6. Common pitfalls
- Don't strip a leading
1 from phones. Just remove non-digits.
- Don't dedupe import rows by phone — only by normalized email, and only when an email
exists.
paid_deferred is paid — exclude it from sponsor finance follow-up targets.
- Canceled sponsors are excluded from
sponsor_statuses entirely; if their account is
also CRM-disqualified, their badge lists as existing_disqualified.
- Exclusion-reason enums differ between templates (
sensor_vendor_only vs sensor_only,
service_only always). Read the template's allowed_values.
- Opportunity $ per lead = the event's
lead_opportunity_amount, not the CRM
opportunities amounts (those are sponsor/opportunity records, not the lead sizing).
- Tradeshow CRM match uses the exhibitor's
crm_account_id field directly; do not
re-derive by domain for tradeshow tasks.
- One excluded sponsor_attendee per active sponsor account (badge contact preferred,
else first ticket_contact); don't enumerate companion tickets.
- Date arithmetic is calendar days off
event.end_date, using the event's own
followup_days_after_end and sponsor_followup_days_after_end fields.
- meeting_interest is keyed by
company_name, not company_id — join on name.
- Always obey the template's
ordering clauses and numeric_precision (integers, USD).
1---2name: fewshot-attempt-01-23description: HarborCRM CRM-Marketing Task Skill4---5# HarborCRM CRM-Marketing Task Skill67A transferable playbook for solving HarborCRM post-event / prospecting / import-batch8handoff tasks. The remote dataset is deterministic and the business conventions below9are stable across tasks; only the event, tradeshow, or import batch varies.1011## 0. Environment & ground rules1213- Base URL (the only allowed API): `<remote-env-url>`14- All endpoints are read-only GET. Pipe `curl -s <url>` through `python3 -m json.tool`.15- Always read the task's own `answer_template.json` first. The template is the contract:16 it dictates the exact top-level keys, item keys, **allowed enum values**, **ordering17 rules**, and number formatting. Two tasks that look similar can use slightly different18 enum vocabularies (e.g. `sensor_vendor_only` vs `sensor_only`) — always emit the19 template's allowed values, never a synonym from memory.20- Return ONE JSON object only, no prose, no extra fields.2122### Endpoint reference2324| Endpoint | Use |25|---|---|26| `GET /api/events` | List events (campaign_code, dates, followup day offsets, lead_opportunity_amount) |27| `GET /api/events/{event_id}` | Single event detail |28| `GET /api/events/{event_id}/orders` | Sponsor orders (= sponsor_packages) |29| `GET /api/events/{event_id}/sponsor_packages` | Same sponsor order data |30| `GET /api/events/{event_id}/badges` | Badge scans for the event |31| `GET /api/finance/invoices?event_id={event_id}` | Invoices for sponsors |32| `GET /api/crm/accounts` | All CRM accounts (account_id, domain, status, disqualified_reason) |33| `GET /api/crm/contacts` | All CRM contacts (contact_id, account_id, email, phone, opted_out) |34| `GET /api/crm/opportunities` | CRM opportunities (mostly informational; lead opp $ comes from the event) |35| `GET /api/crm/campaign_members?event_id={event_id}` | Existing campaign members for an event |36| `GET /api/tradeshows` | Tradeshow list |37| `GET /api/tradeshows/{show_id}/exhibitors` | Exhibitor rows (incl. `crm_account_id` field) |38| `GET /api/tradeshows/{show_id}/meeting_interest` | Per-company interest_score + requested_demo (keyed by company_name) |39| `GET /api/import_batches` | Batch metadata (campaign_code) |40| `GET /api/import_batches/{batch_id}/raw_contacts` | Rows to clean |41| `GET /api/import_batches/{batch_id}/suppression` | Suppression list for the batch |42| `GET /api/policies` | Policy enums (platform_enums, sponsor status_enums) |4344### Task-type detection45Map the prompt to a type by the entity it names:46- **Event handoff** — prompt names an `event_id` and talks about sponsors/badges/invoices/reconciliation. Endpoints: events, orders, badges, invoices, crm/accounts, crm/contacts, crm/opportunities, campaign_members, policies. (train_001 and train_004 shapes.)47- **Tradeshow prospecting** — prompt names a `show_id` / trade show and qualified leads/exhibitors. Endpoints: tradeshows, exhibitors, meeting_interest, crm/accounts, policies. (train_002 and train_005 shapes.)48- **Import-batch cleaning** — prompt names a `batch_id` and asks to prepare contacts for CRM import. Endpoints: import_batches, raw_contacts, suppression, crm/accounts, crm/contacts, policies. (train_003 shape.)4950---5152## 1. Universal normalization conventions5354### Email normalization55- `lowercase` + `trim` surrounding whitespace.56- Example: `" Dana.Ruiz@HelioWare.example "` → `dana.ruiz@helioware.example`; `"KENJI.SATO@monarchfoods.example"` → `kenji.sato@monarchfoods.example`.57- A field that is only whitespace → empty string `""`.5859### Phone normalization60- **Strip every non-digit character.** Keep all digits that remain, *including a leading61 country-code `1` if it was present in the source.*62- Do NOT force 10 digits; do NOT strip a leading `1`.63- `"+1 (512) 555-0121"` → `"15125550121"`; `"1.206.555.0177"` → `"12065550177"`; `"212-555-0166"` → `"2125550166"`; `"(415) 555-0188"` → `"4155550188"`.64- No phone supplied → `""` (empty string, not null).6566### Account matching (for badge leads & import rows)67- Match a row to an existing CRM account by **email domain**: take the part of the68 normalized email after `@`, compare to `account.domain` (case-insensitive).69 - `dana.ruiz@helioware.example` → domain `helioware.example` → account `acct_helio_ware`.70- If the account exists → `update_existing` (account action). If not → `create_account`.71- If a row has no email, you cannot match by domain → `create_account` (unless suppressed/missing).7273### Contact matching74- Match to an existing CRM contact by **normalized email equality** against `contact.email`75 (lowercased). If no exact email match → the contact is new (`existing_contact_id: null`,76 create the contact). Do not infer a contact match from name alone when emails differ.7778---7980## 2. Event-handoff conventions (sponsor + badge reconciliation)8182### 2.1 Sponsor-status classification83For each sponsor order/package at the event, classify using `order_status` and the84matching invoice (match invoice by `account_id` within the event's invoices):8586| order_status | invoice state | sponsor_status | invoice_id | paid_amount | open_balance |87|---|---|---|---|---|---|88| `confirmed` | invoice.status `paid_deferred` OR paid_amount >= amount | `paid_deferred` | invoice.id | invoice.paid_amount | `0` |89| `confirmed` | invoice.status `open` (paid_amount < amount) | `open_invoice` | invoice.id | invoice.paid_amount | `amount - paid_amount` |90| `proposal_sent` | no invoice | `proposal_only` | `null` | `0` | `0` |91| `canceled` | (any) | **excluded entirely** from sponsor_statuses | — | — | — |9293- `amount_usd` / `package_amount` reported = the sponsor package `amount`.94- An account that is not a sponsor at all (no order, not canceled) would be `not_sponsor`95 (enum value; rarely appears in the sponsor list).96- `paid_deferred` means paid in full (deferred revenue recognition) — it is **not** a97 collection follow-up target.9899### 2.2 Sponsor revenue rollup (when the schema has a totals block)100- `paid_deferred` total = Σ package `amount` of all `paid_deferred` sponsors.101- `open_invoice` total = Σ package `amount` of all `open_invoice` sponsors.102- `proposal_only` total = Σ package `amount` of all `proposal_only` sponsors.103- `open_invoice_balance` = Σ `open_balance` of `open_invoice` sponsors (i.e. Σ (amount − paid)).104- (Totals use package amounts, not paid amounts.)105106### 2.3 Badge classification & exclusion precedence107Classify each badge for the event, in this precedence order:1081091. **existing_disqualified** — if the badge company matches a CRM account whose110 `status == "disqualified"` (any `disqualified_reason`). This wins over everything.1112. **sponsor_attendee** — else if the badge company is an **active** sponsor (has a112 non-`canceled` sponsor order at this event), OR `badge_type == "sponsor"`.1133. **non_business_badge** — else if `badge_type` is a non-business type114 (`student`, `press`, and similar). Reason `non_business_badge`.1154. **missing_contact** — else if the badge has no contact name / no contactable info.1165. **qualified_non_sponsor_lead** — otherwise (an `attendee`-type badge on a valid,117 non-disqualified, non-sponsor company). These go to the qualified-lead list.118119Notes:120- A `canceled` sponsor whose account is also disqualified shows as `existing_disqualified`121 (rule 1 wins). A `canceled` sponsor whose account is *not* disqualified would surface as122 `inactive_sponsor_record` (enum value) — apply this when you see a canceled order with a123 non-disqualified account and an associated badge/contact.124- `existing_disqualified` covers any badge/contact whose CRM account is disqualified,125 even if it has no sponsor relationship.126127### 2.4 Enumerating excluded sponsor attendees (event-handoff schemas that lump them)128Some schemas collect sponsor attendees into an `excluded_records` list. For each129**active** (non-canceled) sponsor account, emit **exactly one** `sponsor_attendee` record:130- Prefer a badge captured for that sponsor company (the contact on that badge).131- If the sponsor has no badge at the event, fall back to the **first** `ticket_contact`132 in the sponsor package.133- Do **not** enumerate companion / additional ticket contacts beyond the one134 representative per account (e.g. a platinum sponsor listing two ticket contacts emits135 one excluded record, the attendee of record).136137Plus all non-qualifying badges (non-business, disqualified) become their own excluded138records. De-duplicate so a contact isn't listed twice.139140### 2.5 Qualified non-sponsor leads (event handoff)141- Source: `attendee`-type badges whose company is NOT an active sponsor and whose CRM142 account is NOT disqualified.143- Account action: `create_account` if no existing account by email domain, else `update_existing`.144- Contact action: `create_contact` if no existing contact by email, else `update_existing`145 (no example of update_existing in trains, but follow the email-match rule).146- Campaign-member action: `add_campaign_member` (create) if the contact is not already a147 campaign member for this event; if already a member, treat as update/no new create.148- Normalized email/phone come from the badge (apply §1 rules).149- `opportunity_amount` per qualified lead = the event's `lead_opportunity_amount`150 (a single field on the event object). Pipeline/total = (qualified lead count) ×151 event.lead_opportunity_amount.152153### 2.6 Opportunity / pipeline math (per schema wording)154- If the schema asks for a **per-lead** amount (e.g. `lead_opportunity_amount_usd`):155 report the event's `lead_opportunity_amount` as-is.156- If it asks for a **total** (e.g. `open_opportunity_total_usd`, `lead_pipeline_total`):157 (qualified non-sponsor lead count) × event.lead_opportunity_amount.158- `open_opportunity_count` / lead task count = number of qualified non-sponsor leads.159160### 2.7 Follow-up due dates (date arithmetic)161- `lead_followup_due_date` = event.`end_date` + event.`followup_days_after_end` days162 (plain calendar days; not business days).163- `sponsor_followup_due_date` = event.`end_date` + event.`sponsor_followup_days_after_end` days.164- Format `YYYY-MM-DD`. Validate: e.g. end `2026-09-16` + 7 = `2026-09-23`; end `2026-11-05` + 2 = `2026-11-07`.165166### 2.8 Sponsor finance follow-up targets167- Targets = sponsors that are **not** paid in full = `open_invoice` + `proposal_only`168 (exclude `paid_deferred` and canceled).169- `unpaid_sponsor_total_usd` / sponsor-finance total = Σ package `amount` of those targets.170- `sponsor_finance_task_count` = number of those target accounts.171- `sponsor_finance_accounts` = their account names, sorted ascending.172- Due date = sponsor_followup_due_date (§2.7).173174### 2.9 CRM action counting (event handoff, count-style schemas)175For the qualified non-sponsor leads only (sponsor attendees that already exist are not176counted as creates):177- `accounts_create` = # leads with no existing account.178- `accounts_update` = # leads with an existing account.179- `contacts_create` = # leads whose contact is new (no email match).180- `contacts_update` = # leads with an existing contact (email match). (0 when none.)181- `campaign_members_create` = # leads not already a campaign member for the event.182- `campaign_members_update` = # leads already a member (0 when none).183- `lead_task_count` = qualified lead count.184Do NOT count sponsor attendees as new creates when they already exist; do NOT count185excluded/disqualified badges.186187### 2.10 Campaign-member action list (event-handoff schemas with per-subject rows)188Build a row per subject that needs action, combining:189- **Existing campaign members** for the event → `action: "no_action"`, `target_status`190 = their existing status (`attended_sponsor`, `registered_sponsor`, `attended`, …).191 `subject_key` = `"{account_id}:{contact_id}"`.192- **New badge-derived actions**:193 - Qualified non-sponsor lead badge → `action: "create"`, `target_status: "attended"`,194 `subject_key: "badge:{badge_id}"`.195 - Sponsor-attendee badge whose contact/CM is new → `action: "create"`,196 `target_status: "attended_sponsor"`, `subject_key: "badge:{badge_id}"`.197 - Sponsor-attendee already an existing CM → covered by the existing-CM row (no_action).198- `target_status` enum: `attended_sponsor` (sponsor who attended), `registered_sponsor`199 (sponsor registered, not attended), `attended` (non-sponsor lead attended), `excluded`.200- Sort by `subject_key` ascending (`acct_*` keys sort before `badge:*` keys).201202### 2.11 Badge-only contacts list (when schema asks for normalized badge contacts)203- Include badges that require **contact creation**: qualified non-sponsor leads that need204 a new account+contact, and sponsor-attendee badges whose contact is new.205- Exclude badges whose contact already exists and exclude non-business/disqualified badges.206- Fields: `company_name`, `contact_name`, `normalized_email` (lowercase trim; `""` if none),207 `normalized_phone` (digits only; `""` if none).208- Sort by `company_name` ascending.209210### 2.12 Badge crm_action enum (combined-action schemas)211- No existing account → `create_account_contact_campaign_member`.212- Existing account, new contact → `create_contact_campaign_member`.213- Existing account + contact, no CM → `add_campaign_member`.214- Existing account + contact + CM → `no_action`.215- Excluded badge → `no_import`.216- Sponsor-attendee with existing everything → `no_action` (still classified `sponsor_attendee`).217218### 2.13 Event-handoff sorting219- `sponsor_statuses` → `account_name` ascending.220- `excluded_records` → `company_name` ascending, then `contact_name` ascending.221- `qualified_lead_accounts` → `account_name` ascending.222- `badge_decisions` → `badge_id` ascending.223- `campaign_member_actions` → `subject_key` ascending.224- `badge_only_contacts` → `company_name` ascending.225- `exclusion_counts` → object, fixed keys (sponsor_attendee, non_business_badge, existing_disqualified, missing_contact).226- `opportunity_summary.qualified_non_sponsor_account_names` / `sponsor_followup.unpaid_sponsor_account_names` → account names ascending.227228---229230## 3. Tradeshow prospecting conventions231232### 3.1 Qualification vs exclusion233An exhibitor is **qualified** iff it *makes / manufactures / OEM-builds* a target platform.234classify from `description` + context (policy says: "decide whether an account builds235target underwater platforms or is only adjacent to them"). Exclude exhibitors that are236merely adjacent:237238| Inferred relationship_type | Exclusion reason (use the template's enum) |239|---|---|240| `distributor` / reseller / dealer ("does not manufacture", "reseller", "sales agent") | `distributor_only` |241| `service_provider` / consulting / operates rented gear / analytics-only dashboard | `service_only` (template may say `service_only`) |242| `sensor_vendor` / sensor-only probes ("sensor-only", "probes") | `sensor_vendor_only` (train_002) **or** `sensor_only` (train_005) — match the template |243| `research` / academic lab | `research_only` |244| otherwise not a target market | `not_target_market` (if the template lists it) |245246Always use the **exact** exclusion-reason enum strings declared in that task's template.247248### 3.2 Platform coverage inference249From the qualified exhibitor's `description`, detect platforms by keyword:250- `AUV` (autonomous underwater vehicle) — words "AUV".251- `ROV` (remotely operated vehicle) — words "ROV".252- `Underwater Camera` — words "camera" / "underwater camera" / "camera array/module".253- Output the list sorted in the policy/enum order: `AUV`, `ROV`, `Underwater Camera`.254 (Use `/api/policies` → `prospecting.platform_enums` for the canonical order.)255- An exhibitor can map to multiple platforms (e.g. "ROVs with camera arrays" → `[ROV, Underwater Camera]`).256257### 3.3 CRM action (tradeshow)258- Use the exhibitor's **`crm_account_id`** field directly (not domain matching).259- Non-null → `crm_action: "update_existing"`, `crm_account_id`: that id.260- Null → `crm_action: "create_account"`, `crm_account_id: null`.261- Excluded exhibitors → `crm_action: "no_import"`.262263### 3.4 interest_score / requested_demo264- Join exhibitor to `meeting_interest` by `company_name` (the meeting_interest rows are265 keyed by company_name, not by company_id).266- `requested_demo` (boolean) and `interest_score` (integer) come from that row.267- If a qualified exhibitor has no meeting_interest row, treat `requested_demo: false`,268 `interest_score: 0` (no example of this; fall back conservatively).269270### 3.5 Priority tier (universal prospecting convention)271Even when a terse prompt doesn't state it, assign tier from demo + score:272- **A** — `requested_demo == true` AND `interest_score >= 90`.273- **B** — `requested_demo == true` AND `interest_score >= 80` (and < 90).274- **C** — all other qualified leads (no demo, or score < 80).275If the prompt explicitly overrides, follow the prompt.276277### 3.6 Opportunity sizing (when an `opportunity_estimate_usd` field exists)278Standard fixed sizing by tier:279- A → `120000`, B → `90000`, C → `50000` (USD integer).280- `total_estimated_opportunity_usd` = Σ across ranked leads.281(If the prompt states different numbers, use the prompt's.)282283### 3.7 Ranking qualified leads284Sort key (in order):2851. `requested_demo` descending (`true` before `false`).2862. `interest_score` descending.2873. Broader platform coverage descending (count of platforms, more first).2884. `company_name` ascending.289Assign `rank` as 1-based contiguous integers in that order.290291### 3.8 Aggregates292- `qualified_lead_count` / `qualified_total` = # qualified exhibitors.293- `excluded_count` / `excluded_near_misses_total` = # excluded exhibitors.294- `existing_crm_overlap_count` = # qualified exhibitors with non-null `crm_account_id`.295- `existing_crm_overlap_account_ids` = those ids, sorted ascending.296- `platform_coverage_counts` / `platform_counts` = for each platform enum, count of297 qualified exhibitors that cover it (an exhibitor covering 2 platforms counts once in298 each). Keys are all platform enums; value type integer.299- `priority_counts` (if required) = count of A / B / C among qualified leads.300- `total_estimated_opportunity_usd` = Σ opportunity_estimate (§3.6).301302### 3.9 Tradeshow sorting303- `qualified_exhibitors` → `company_name` ascending.304- `ranked_leads` → `rank` ascending (per §3.7).305- `excluded_exhibitors` / `excluded_near_misses` → `company_name` ascending.306307---308309## 4. Import-batch cleaning conventions310311### 4.1 Inputs312- Raw rows: `/api/import_batches/{batch_id}/raw_contacts`.313- Suppression: `/api/import_batches/{batch_id}/suppression`.314- `campaign_code` and `batch_id` come from `/api/import_batches` (match by batch_id).315- Existing accounts/contacts via `/api/crm/accounts`, `/api/crm/contacts`.316317### 4.2 Normalization (apply to every row)318- `email` → lowercase + trim (§1). Whitespace-only → `""`.319- `phone` → digits only (§1). Whitespace-only → `""`.320321### 4.3 Removal pipeline (in order)3221. **missing_contact** — after normalization, if `email == ""` AND `phone == ""` → remove323 with reason `missing_contact`. (A row with email XOR phone is usable.)3242. **duplicate** — group remaining rows by normalized `email` (only rows that have an325 email are candidates; rows without email are not deduped by phone). Within a group:326 - Winner = the row with the **latest `captured_at`**.327 - On an exact `captured_at` tie, prefer the source earlier in this priority:328 `badge_scan` > `sponsor_form` > `partner_upload` > `webinar_form` > `exhibitor_form`329 > `manual_upload` (equivalently, the higher row_id).330 - All non-winner rows in the group → remove with reason `duplicate`.331 - Duplicate key reported as `"email:{normalized_email}"`.3323. **suppressed** — for each remaining (winner) row, remove with reason `suppressed` if333 its normalized email matches any suppression `email` **OR** its normalized phone matches334 any suppression `phone`. (Suppression entries with empty phone match by email only;335 empty-email entries match by phone only.)3364. Survivors = `clean_contacts`.337338### 4.4 clean_contacts fields339- `clean_contact_id` = `source_row_id` = the **winner's** `row_id`.340- `company_name`, `contact_name` from the winning row (as-is).341- `email` = normalized email (or `""`).342- `phone` = normalized digits-only phone (or `""`).343- `source_name` = winner's `source_name` (enum: badge_scan, sponsor_form, partner_upload,344 webinar_form, exhibitor_form, manual_upload).345- `captured_at` = winner's ISO timestamp.346- `existing_account_id` = matched account id by email domain (§1), else `null`.347- `existing_contact_id` = matched contact id by normalized email (§1), else `null`.348- `crm_action`:349 - `update_existing` — if an existing account matched (by domain).350 - `create_account` — if no existing account matched.351 - (Rows that are removed never reach this list; suppressed/missing/duplicate are not352 clean_contacts.)353354### 4.5 duplicate_summary355- `duplicate_removed_count` = total number of rows removed as duplicates.356- `duplicate_keys` = list of `{ "key": "email:{normalized_email}", "winner_row_id": <id>,357 "removed_row_ids": [<ids>] }`, sorted by `key` ascending.358359### 4.6 removal_summary360- `unusable_removed_count` = # rows removed as `missing_contact` ONLY. (Duplicates are NOT361 "unusable" — each duplicate group has a surviving winner, so the losers are redundant, not362 unusable. Duplicates are counted in `duplicate_summary.duplicate_removed_count`, not here.)363- `suppressed_removed_count` = # rows removed as `suppressed`.364- `removed_rows` = ALL removed rows (duplicates + missing + suppressed) as365 `{ "row_id": <id>, "reason": <enum> }`, where reason ∈ {`duplicate`, `missing_contact`,366 `suppressed`}, sorted by `row_id` ascending. (Note: `removed_rows` is the union;367 `unusable_removed_count` + `suppressed_removed_count` will NOT sum to `len(removed_rows)`368 because duplicate rows appear in `removed_rows` but in neither count.)369370### 4.7 import_action_totals371Count of clean + removed rows by final action (integers):372- `create_account` = # clean rows with no existing account.373- `update_existing` = # clean rows with an existing account.374- `no_import` = # rows removed as `duplicate` + `missing_contact`.375- `suppress` = # rows removed as `suppressed`.376- Sanity: `create_account + update_existing + no_import + suppress` = total raw rows.377- `campaign_member_import_count` = # clean rows (`create_account + update_existing`) —378 every surviving contact becomes a member of the batch's campaign.379380### 4.8 Import-batch sorting381- `clean_contacts` → `clean_contact_id` ascending.382- `duplicate_summary.duplicate_keys` → `key` ascending.383- `removal_summary.removed_rows` → `row_id` ascending.384385---386387## 5. Field-by-field quick reference (most common schemas)388389### Event handoff (sponsor_statuses item)390`account_id`, `account_name`, `sponsor_status` (paid_deferred|open_invoice|proposal_only|not_sponsor),391`amount_usd`/`package_amount` (= package amount, integer), `invoice_id` (or null),392`paid_amount` (integer), `open_balance` (= amount − paid; 0 for paid/proposal).393394### Event handoff (qualified_lead_accounts item)395`account_name`, `account_id` (null if new), `primary_contact` (badge contact_name),396`normalized_email`, `normalized_phone`, `crm_account_action` (create_account|update_existing),397`crm_contact_action` (create_contact|update_existing), `campaign_member_action`398(add_campaign_member), `opportunity_amount` (= event.lead_opportunity_amount).399400### Event handoff (excluded_records item)401`company_name`, `contact_name`, `reason`402(sponsor_attendee|existing_disqualified|inactive_sponsor_record|non_business_badge).403404### Event handoff (follow_up)405`lead_due_date` (end + lead days), `lead_task_count` (= # qualified leads),406`sponsor_finance_due_date` (end + sponsor days), `sponsor_finance_task_count`407(= # unpaid sponsors), `sponsor_finance_accounts` (unpaid sponsor names ascending).408409### Tradeshow (ranked_leads item)410`rank`, `company_id`, `company_name`, `booth`, `country`, `website`, `platforms` (enum411order), `crm_account_id` (or null), `crm_action` (create_account|update_existing|no_import),412`requested_demo`, `interest_score`, `priority_tier` (A|B|C), `opportunity_estimate_usd`.413414### Tradeshow (excluded item)415`company_id`, `company_name`, `relationship_type` (distributor|service_provider|sensor_vendor|research),416`exclusion_reason` (template enum), `crm_action` (`no_import`).417418### Import (clean_contacts item)419`clean_contact_id`, `source_row_id`, `company_name`, `contact_name`, `email`, `phone`,420`source_name`, `captured_at`, `crm_action` (create_account|update_existing|no_import|suppress),421`existing_account_id` (or null), `existing_contact_id` (or null).422423---424425## 6. Common pitfalls426427- **Don't strip a leading `1` from phones.** Just remove non-digits.428- **Don't dedupe import rows by phone** — only by normalized email, and only when an email429 exists.430- **`paid_deferred` is paid** — exclude it from sponsor finance follow-up targets.431- **Canceled sponsors are excluded from `sponsor_statuses`** entirely; if their account is432 also CRM-disqualified, their badge lists as `existing_disqualified`.433- **Exclusion-reason enums differ between templates** (`sensor_vendor_only` vs `sensor_only`,434 `service_only` always). Read the template's `allowed_values`.435- **Opportunity $ per lead = the event's `lead_opportunity_amount`**, not the CRM436 `opportunities` amounts (those are sponsor/opportunity records, not the lead sizing).437- **Tradeshow CRM match uses the exhibitor's `crm_account_id` field directly**; do not438 re-derive by domain for tradeshow tasks.439- **One excluded sponsor_attendee per active sponsor account** (badge contact preferred,440 else first ticket_contact); don't enumerate companion tickets.441- **Date arithmetic is calendar days** off `event.end_date`, using the event's own442 `followup_days_after_end` and `sponsor_followup_days_after_end` fields.443- **meeting_interest is keyed by `company_name`**, not company_id — join on name.444- Always obey the template's `ordering` clauses and `numeric_precision` (integers, USD).