HarborCRM CRM-Marketing Solver Skill
Purpose: Teach a future solver to produce correct JSON for unseen HarborCRM test tasks. The environment is a read-only JSON API at
<remote-env-url>. Always return ONE JSON object matching the task'sanswer_template.json. No prose outside JSON.
0. API Cheat Sheet
Base URL: <remote-env-url> (call with curl -s, pipe to jq or python3 -m json.tool)
| Endpoint | Used For |
|---|---|
GET /api/events |
List all events (key fields: lead_opportunity_amount, followup_days_after_end, sponsor_followup_days_after_end, end_date) |
GET /api/events/{event_id} |
Single event detail |
GET /api/events/{event_id}/orders |
Sponsor orders (= same data as sponsor_packages) |
GET /api/events/{event_id}/sponsor_packages |
Sponsor packages: account_id, amount, order_status, package_level, ticket_contacts |
GET /api/events/{event_id}/badges |
Badge scans: badge_id, badge_type, company_name, contact_name, email, phone |
GET /api/finance/invoices?event_id={event_id} |
Invoices: status (paid_deferred/open), paid_amount, amount, invoice_id |
GET /api/crm/accounts |
CRM accounts: account_id, name, domain, status (customer/prospect/disqualified), disqualified_reason |
GET /api/crm/contacts |
CRM contacts: contact_id, account_id, name, email, phone, opted_out |
GET /api/crm/opportunities |
CRM opportunities: account_id, amount, stage, event_id |
GET /api/crm/campaign_members?event_id={event_id} |
Campaign members: account_id, contact_id, status (attended_sponsor/registered_sponsor/attended/...) |
GET /api/tradeshows |
List trade shows: show_id, name, start_date, end_date |
GET /api/tradeshows/{show_id}/exhibitors |
Exhibitors: company_id, company_name, description, booth, country, website, crm_account_id |
GET /api/tradeshows/{show_id}/meeting_interest |
Meeting interest: company_name, interest_score, requested_demo, notes |
GET /api/import_batches |
List import batches: batch_id, campaign_code, received_at |
GET /api/import_batches/{batch_id}/raw_contacts |
Raw contacts: row_id, company_name, contact_name, email, phone, source_name, captured_at |
GET /api/import_batches/{batch_id}/suppression |
Suppression list: email, phone, reason |
GET /api/policies |
Policy metadata: platform enums, sponsor status enums |
The dataset is deterministic (seed 41001). Events, tradeshows, and batches you have NOT seen in training will appear in test tasks — the same conventions apply.
1. Identify the Task Type
Read the prompt and answer template. Map to a workflow:
| Signal in prompt / template | Workflow |
|---|---|
event_id, sponsor_statuses, badge_decisions or qualified_lead_accounts, excluded_records or exclusion_counts |
A — Event Reconciliation |
show_id, qualified_exhibitors or ranked_leads, excluded_near_misses or excluded_exhibitors, platforms |
B — Tradeshow Prospecting |
batch_id, raw_contacts, clean_contacts, duplicate_summary, removal_summary, import_action_totals |
C — Import Batch Cleaning |
Some event tasks (train_001) output sponsor_statuses + qualified_lead_accounts + excluded_records + follow_up + crm_action_counts.
Other event tasks (train_004) output a more granular badge_decisions + campaign_member_actions + opportunity_summary + sponsor_followup + badge_only_contacts + exclusion_counts.
Always follow the exact field names and structure in the task's answer template.
2. Normalization Rules (Cross-Cutting — All Workflows)
NAN_EMAIL(email_string)
- Strip leading/trailing whitespace.
- Lowercase the entire string.
- Result = normalized email (may be
""if input was blank/whitespace-only).
NAN_PHONE(phone_string)
- Extract ONLY digit characters
0-9; discard everything else (+,(,),-,., spaces, letters). - Concatenate the digits.
- Result = normalized phone (may be
""if no digits present).
Examples:
| Raw input | Normalized |
|---|---|
" Dana.Ruiz@HelioWare.example " |
"dana.ruiz@helioware.example" |
"KENJI.SATO@monarchfoods.example" |
"kenji.sato@monarchfoods.example" |
"+1 (415) 555-0101" |
"14155550101" |
"415-555-0188" |
"4155550188" |
"1.206.555.0177" |
"12065550177" |
"206.555.0150" |
"2065550150" |
"" or " " |
"" |
NAN_DOMAIN(normalized_email)
- Extract the portion after
@. E.g."dana.ruiz@helioware.example"→"helioware.example". - If email is empty → no domain.
3. Workflow A — Event Reconciliation
Inputs: event details, sponsor_packages, badges, invoices, CRM accounts/contacts/opportunities, campaign_members.
Checklist
A1. Fetch all event data
GET /api/events/{event_id}
GET /api/events/{event_id}/sponsor_packages (or /orders — same data)
GET /api/events/{event_id}/badges
GET /api/finance/invoices?event_id={event_id}
GET /api/crm/campaign_members?event_id={event_id}
GET /api/crm/accounts
GET /api/crm/contacts
GET /api/crm/opportunities (filter by event_id if needed)
A2. Compute due dates
lead_due_date=event.end_date+event.followup_days_after_enddays (simple calendar addition).sponsor_due_date=event.end_date+event.sponsor_followup_days_after_enddays.- Format:
YYYY-MM-DD.
Verification (train data):
| Event | end_date | lead_days | lead_due | sponsor_days | sponsor_due |
|---|---|---|---|---|---|
| neuralops_2026 | 2026-09-16 | 7 | 2026-09-23 | 3 | 2026-09-19 |
| edgeai_field_2026 | 2026-11-05 | 5 | 2026-11-10 | 2 | 2026-11-07 |
A3. Determine sponsor statuses
For each entry in sponsor_packages:
sponsor_packages order_status |
Invoice lookup (by account_id + event_id) | → sponsor_status |
|---|---|---|
confirmed |
invoice status == paid_deferred |
paid_deferred |
confirmed |
invoice status == open |
open_invoice |
proposal_sent |
no invoice | proposal_only |
canceled |
(any) | EXCLUDE from sponsor_statuses (inactive sponsor) |
Per-sponsor fields (train_001 schema — sponsor_statuses array):
| Field | Source |
|---|---|
account_id |
sponsor_packages.account_id |
account_name |
sponsor_packages.account_name |
status |
from decision table above |
package_amount |
sponsor_packages.amount (= invoice.amount if invoice exists) |
invoice_id |
invoice.invoice_id, or null if no invoice |
paid_amount |
invoice.paid_amount, or 0 if no invoice |
open_balance |
max(0, package_amount - paid_amount); 0 if no invoice |
Sort sponsor_statuses by account_name ascending.
Per-sponsor fields (train_004 schema — sponsor_statuses array):
| Field | Source |
|---|---|
account_id |
sponsor_packages.account_id |
account_name |
sponsor_packages.account_name |
sponsor_status |
from decision table above |
amount_usd |
sponsor_packages.amount (integer) |
A4. Compute sponsor revenue totals (train_001 schema)
| Revenue field | Computation |
|---|---|
paid_deferred |
Σ package_amount for all sponsors with status paid_deferred |
open_invoice |
Σ package_amount for all sponsors with status open_invoice |
proposal_only |
Σ package_amount for all sponsors with status proposal_only |
open_invoice_balance |
Σ (package_amount − paid_amount) for sponsors with status open_invoice ONLY |
A5. Identify sponsor finance follow-up targets
- Include sponsors with status
open_invoiceorproposal_only(NOTpaid_deferred— they are fully paid). sponsor_finance_accounts/unpaid_sponsor_account_names: sorted list of these account names ascending.unpaid_sponsor_total_usd(train_004): Σ of theiramount_usd.sponsor_finance_task_count/ followup: count of these sponsors.- Due date:
sponsor_due_datefrom A2.
A6. Classify every badge
For each badge in badges, determine the badge's CRM account and contact status:
Is the badge's company an ACTIVE sponsor? Check if there is a sponsor_package with
order_status∈ {confirmed,proposal_sent} whoseaccount_namematches the badge'scompany_name. → If YES: classification =sponsor_attendeeIs the badge's company a disqualified CRM account? Check CRM accounts: is there an account whose
namematches the badge'scompany_nameANDstatus==disqualified? → If YES: classification =existing_disqualifiedIs the badge type non-business? badge_type ∈ {
student,press, ...} (anything that is NOTattendeeorsponsor)? → If YES: classification =excluded/non_business_badgeIs the badge's company a canceled sponsor (but NOT disqualified in CRM)? sponsor_package exists with
order_status==canceledfor this company, and CRM account is NOT disqualified. → If YES: classification =inactive_sponsor_recordOtherwise → classification =
qualified_non_sponsor_lead
Classification priority (first match wins):
1. active_sponsor → sponsor_attendee
2. CRM disqualified → existing_disqualified
3. non-business badge type → non_business_badge
4. canceled sponsor → inactive_sponsor_record
5. none of the above → qualified_non_sponsor_lead
Note on priority: A company can be BOTH a canceled sponsor AND disqualified in CRM. The CRM-disqualified check (priority 2) fires BEFORE the canceled-sponsor check (priority 4), so the reason is
existing_disqualified. Verified: Northstar Sensors (canceled sponsor + disqualified CRM →existing_disqualified).
A7. Build excluded_records (train_001 schema)
Sources of excluded records:
- Badges: Every badge that is NOT
qualified_non_sponsor_leadbecomes an excluded record. - Active sponsors with NO badge for that company: Add the first
ticket_contactfrom the sponsor_package assponsor_attendee.- If a sponsor company already has a badge, the badge contact covers it — do NOT add additional ticket_contacts.
- If a sponsor company has a campaign member but no badge, the campaign member contact covers it — do NOT add ticket_contacts.
Building excluded record entry:
| Field | Source |
|---|---|
company_name |
Badge company_name (or CRM account name for campaign-member-based) |
contact_name |
Badge contact_name (or CRM contact name / first ticket_contact) |
reason |
From classification (sponsor_attendee / existing_disqualified / non_business_badge / inactive_sponsor_record) |
Sort by company_name ascending, then contact_name ascending.
A8. Build badge_decisions (train_004 schema)
For EACH badge (sorted by badge_id ascending):
| Field | Source |
|---|---|
badge_id |
badge.badge_id |
company_name |
badge.company_name |
contact_name |
badge.contact_name |
classification |
sponsor_attendee / qualified_non_sponsor_lead / excluded |
crm_action |
See decision table A9 |
exclusion_reason |
sponsor_attendee / non_business_badge / existing_disqualified / missing_contact / null |
A9. Determine CRM action for each badge (train_004)
First, determine CRM existence:
- Account exists: match badge's normalized email domain to CRM account
domainfield. If email empty OR domain not found → account does NOT exist. (Alternative: match by company name to CRM accountname.) - Contact exists: match badge's normalized email to CRM contact
emailfield (exact match). - Campaign member exists: check if there is an existing campaign_member for this event where account_id matches the (existing) CRM account AND contact_id matches the (existing) CRM contact.
| Account exists? | Contact exists? | Campaign member exists? | → crm_action (train_004) |
|---|---|---|---|
| No | No | No | create_account_contact_campaign_member |
| Yes | No | No | create_contact_campaign_member |
| Yes | Yes | No | add_campaign_member |
| Yes | Yes | Yes | no_action |
| —(excluded badge)— | — | — | no_import |
A10. Build campaign_member_actions (train_004)
Include ALL campaign members for the event (existing + new badge-based):
Existing campaign members (from
/api/crm/campaign_members?event_id=...):subject_key:{account_id}:{contact_id}(e.g.acct_rivet_ai:cont_sofia_meyer)action:no_actiontarget_status: keep existing status (attended_sponsor/registered_sponsor/attended)
New campaign members from badges (badges that are NOT excluded via non_business_badge):
- Qualified non-sponsor lead badges (with
crm_action=create_account_contact_campaign_memberor above):subject_key:badge:{badge_id}action:createtarget_status:attended
- Sponsor attendee badges needing new contact/campaign member (badge classified as
sponsor_attendee, CRM action =create_contact_campaign_member):subject_key:badge:{badge_id}action:createtarget_status:attended_sponsor
- Note: sponsor badges where everything already exists (
no_action) do NOT produce a new campaign member entry beyond the existing one.
- Qualified non-sponsor lead badges (with
Excluded badges (non_business_badge): no campaign member action (
no_import). NOT listed.
Sort campaign_member_actions by subject_key ascending.
A11. Build badge_only_contacts (train_004)
Badges where the contact does NOT already exist in CRM (i.e., contact needs creation), EXCLUDING non-business badges.
For each qualifying badge:
| Field | Source |
|---|---|
company_name |
badge.company_name |
contact_name |
badge.contact_name |
normalized_email |
NAN_EMAIL(badge.email) — "" if empty |
normalized_phone |
NAN_PHONE(badge.phone) — "" if empty |
Sort by company_name ascending.
A12. Build qualified_lead_accounts (train_001 schema)
For each badge classified as qualified_non_sponsor_lead:
| Field | Source / Rule |
|---|---|
account_name |
badge.company_name |
account_id |
CRM account_id if account exists (by domain match), else null |
primary_contact |
badge.contact_name |
normalized_email |
NAN_EMAIL(badge.email) |
normalized_phone |
NAN_PHONE(badge.phone) |
crm_account_action |
update_existing if CRM account exists, else create_account |
crm_contact_action |
update_existing if CRM contact email matches, else create_contact |
campaign_member_action |
add_campaign_member (always — these are new leads) |
opportunity_amount |
event.lead_opportunity_amount (same for all qualified leads) |
Sort qualified_lead_accounts by account_name ascending.
A13. Compute opportunity / pipeline totals
| Schema field | Computation |
|---|---|
lead_pipeline_total (train_001) |
event.lead_opportunity_amount × qualified_lead_count |
lead_opportunity_amount_usd (train_004) |
event.lead_opportunity_amount |
open_opportunity_total_usd (train_004) |
event.lead_opportunity_amount × qualified_lead_count |
open_opportunity_count (train_004) |
qualified_lead_count |
qualified_non_sponsor_account_names (train_004) |
sorted list of qualified lead account names ascending |
A14. Compute CRM action counts (train_001 schema)
Count across ALL qualified_lead_accounts:
| Field | Rule |
|---|---|
accounts_create |
count where crm_account_action == create_account |
accounts_update |
count where crm_account_action == update_existing |
contacts_create |
count where crm_contact_action == create_contact |
contacts_update |
count where crm_contact_action == update_existing |
campaign_members_create |
count of qualified leads (all get new campaign members) |
campaign_members_update |
0 (qualified leads don't have existing campaign members) |
A15. Compute exclusion_counts (train_004 schema)
Count badges by exclusion_reason:
| Field | Count of badges with reason... |
|---|---|
sponsor_attendee |
badges classified as sponsor_attendee |
non_business_badge |
badges with non-business type |
existing_disqualified |
badges whose company is disqualified in CRM |
missing_contact |
badges with no usable contact (empty email AND empty phone) |
A16. Build follow_up section (train_001 schema)
| Field | Value |
|---|---|
lead_due_date |
end_date + followup_days_after_end |
lead_task_count |
number of qualified_lead_accounts |
sponsor_finance_due_date |
end_date + sponsor_followup_days_after_end |
sponsor_finance_task_count |
number of sponsors needing finance follow-up (open_invoice + proposal_only) |
sponsor_finance_accounts |
sorted list of sponsor account names (open_invoice + proposal_only) ascending |
A17. Final assembly and sort verification
- Verify ALL sort orders match the template requirements.
- Verify ALL enum values match the template's allowed_values.
- Verify ALL required keys are present.
- Output ONE JSON object. No explanatory prose.
4. Workflow B — Tradeshow Prospecting
Inputs: tradeshow exhibitors, meeting_interest, CRM accounts, policies.
Checklist
B1. Fetch all data
GET /api/tradeshows/{show_id}/exhibitors
GET /api/tradeshows/{show_id}/meeting_interest
GET /api/crm/accounts
GET /api/policies
B2. Classify each exhibitor (qualification)
Read the exhibitor description field. The key question: does the company MANUFACTURE/BUILD target underwater platforms, or is it only adjacent?
PLATFORM EXTRACTION
Scan the description for platform keywords:
| Keyword pattern in description | → Platform enum |
|---|---|
AUV or AUVs (autonomous underwater vehicle) |
AUV |
ROV or ROVs (remotely operated vehicle) |
ROV |
underwater camera, camera module, camera array, camera system, camera maker, camera manufacturer |
Underwater Camera |
Platforms are listed in enum order: AUV, ROV, Underwater Camera.
QUALIFICATION DECISION TABLE
| Description indicates... | Qualified? | relationship_type | exclusion_reason |
|---|---|---|---|
| Company builds/manufactures/OEM-builds target platforms (AUV, ROV, underwater camera) | YES | — | — |
| Company is a distributor / reseller / dealer / sales agent for others' platforms | NO | distributor |
distributor_only |
| Company provides services / consulting / analytics / rentals using others' platforms | NO | service_provider |
service_only |
| Company only makes sensors (no platforms, sensor-only vendor) | NO | sensor_vendor |
sensor_vendor_only or sensor_only (per template enum) |
| Company is research-only (academic, lab, non-commercial) | NO | research |
research_only |
| Company is not in the target market at all (no relevance to underwater platforms) | NO | — | not_target_market (if in template enum) |
CAUTION: The exact exclusion_reason enum string varies by task template. Train_002 template uses
sensor_vendor_only; train_005 template usessensor_only. Always copy the enum value from the specific answer template. If the description mentions "no hardware manufacturing" →service_provider/service_only.
Description analysis examples (generalized):
| Description excerpt | Inference |
|---|---|
| "Builds compact AUVs and... ROVs with OEM payload bays" | Builds AUV + ROV → qualified, platforms [AUV, ROV] |
| "Designs rugged underwater camera modules" | Builds underwater cameras → qualified, [Underwater Camera] |
| "Distributor... does not manufacture platforms" | Distributor → excluded, distributor_only |
| "Sensor-only... probes for integration by platform partners" | Sensor vendor → excluded |
| "Consulting team that operates rented ROVs" | Service provider → excluded, service_only |
| "Analytics dashboard... no hardware manufacturing" | Service provider → excluded, service_only |
| "Regional reseller for imported... ROVs and... cameras" | Distributor → excluded, distributor_only |
B3. Determine priority tier (from meeting_interest)
Join qualified exhibitors with meeting_interest by company_name.
PRIORITY TIER DECISION TABLE
| Condition | → tier |
|---|---|
requested_demo == true AND interest_score >= 90 |
A |
requested_demo == true AND interest_score >= 80 |
B |
| All other qualified leads | C |
This rule is used by BOTH train_002 (where only the tier is output) and train_005 (where demo/score/tier are all output).
B4. Determine CRM overlap (tradeshow)
For each qualified exhibitor, check the crm_account_id field from the exhibitor data:
- If
crm_account_idis NOT null → the exhibitor already has a CRM account →crm_action=update_existing. - If
crm_account_idIS null →crm_action=create_account.
CRM CREATE-VS-UPDATE TABLE (Tradeshow)
Exhibitor crm_account_id |
→ crm_action | → crm_account_id (output) |
|---|---|---|
Non-null (e.g. "acct_reefworks") |
update_existing |
the account_id value |
null |
create_account |
null |
existing_crm_overlap_count = number of qualified exhibitors with non-null crm_account_id.
existing_crm_overlap_account_ids = sorted list of these account_ids ascending.
B5. Opportunity sizing (train_005 schema)
OPPORTUNITY SIZE BY TIER
| Priority tier | → opportunity_estimate_usd |
|---|---|
| A | 120000 |
| B | 90000 |
| C | 50000 |
B6. Rank qualified leads (train_005 schema)
Sort qualified leads by this multi-key ordering:
requested_demo==trueBEFOREfalseinterest_scoreDESCENDING (higher score first)- Broader platform coverage (more platforms = higher rank; count of platforms DESC)
company_nameASCENDING (alphabetical tiebreaker)
Assign ranks 1, 2, 3, ... (1-based, contiguous).
B7. Compute aggregate counts
AGGREGATE COUNTS TABLE
| Field | Computation |
|---|---|
qualified_total / qualified_lead_count |
number of qualified exhibitors |
excluded_near_misses_total / excluded_count |
number of excluded exhibitors |
platform_counts / platform_coverage_counts |
For each platform enum (AUV, ROV, Underwater Camera): count qualified exhibitors that have that platform. A single exhibitor with multiple platforms is counted in each. |
priority_counts |
For tiers A, B, C: count qualified exhibitors with that tier |
total_estimated_opportunity_usd |
Σ opportunity_estimate_usd across all qualified leads |
B8. Build output arrays
qualified_exhibitors (train_002 schema): sorted by company_name ascending.
Fields: company_id, company_name, platforms (enum order), priority_tier, booth, country, website.
ranked_leads (train_005 schema): sorted by rank ascending (per B6).
Fields: rank, company_id, company_name, booth, country, website, platforms (enum order), crm_account_id, crm_action, requested_demo, interest_score, priority_tier, opportunity_estimate_usd.
excluded_near_misses (train_002): sorted by company_name ascending.
Fields: company_id, company_name, exclusion_reason.
excluded_exhibitors (train_005): sorted by company_name ascending.
Fields: company_id, company_name, relationship_type, exclusion_reason, crm_action (always no_import).
B9. Top-level fields
show_id: from the prompt/task.campaign(train_002 only): from the prompt text (e.g."oem_dissolved_oxygen_sensor"). This is not in the API.
B10. Final assembly and sort verification
- Verify ALL sort orders match template requirements.
- Verify ALL enum values match template allowed_values.
- Output ONE JSON object.
5. Workflow C — Import Batch Cleaning
Inputs: raw_contacts, suppression list, CRM accounts/contacts, policies.
Checklist
C1. Fetch all data
GET /api/import_batches/{batch_id}/raw_contacts
GET /api/import_batches/{batch_id}/suppression
GET /api/crm/accounts
GET /api/crm/contacts
GET /api/policies
Also GET /api/import_batches to get campaign_code for the batch.
C2. Normalize all raw contacts
For each row in raw_contacts:
norm_email= NAN_EMAIL(row.email)norm_phone= NAN_PHONE(row.phone)
C3. Classify each row — REMOVAL DECISION TABLE
Process in this priority order (first match wins; each row gets exactly ONE outcome):
ROW PROCESSING PIPELINE
For each raw_contact row:
STEP 1 — Missing contact check
IF norm_email == "" AND norm_phone == "":
→ reason = "missing_contact", crm_action = "no_import"
→ SKIP remaining steps
STEP 2 — Suppression check
IF norm_email matches any suppression entry's email
OR norm_phone matches any suppression entry's phone:
→ reason = "suppressed", crm_action = "suppress"
→ SKIP remaining steps
STEP 3 — Duplicate check (among rows that passed steps 1-2)
Group by norm_email (only non-empty emails).
Within each group:
Winner = row with latest captured_at (ISO timestamp comparison).
Tiebreaker: highest row_id (lexicographic string comparison).
Losers:
→ reason = "duplicate", crm_action = "no_import"
STEP 4 — Survivor
Rows that pass all checks → clean contact
→ crm_action = "create_account" or "update_existing" (see C5)
Important: The duplicate check groups by normalized email. Two rows with the same raw email spelled differently (e.g.
"Dana.Ruiz@HelioWare.example "vs"dana.ruiz@helioware.example") ARE duplicates because they normalize to the same value.Rows with empty emails cannot be duplicates (no key to group by).
Duplicate winner verification (train_003):
| Duplicate key | Rows | Winner | Why |
|---|---|---|---|
email:dana.ruiz@helioware.example |
fw_001 (2026-11-10T14:02), fw_002 (2026-11-14T09:05) | fw_002 | Later captured_at |
email:evan.blake@quartzfoods.example |
fw_003 (2026-11-10T15:20), fw_008 (2026-11-10T15:20) | fw_008 | Same timestamp → higher row_id |
C4. Build duplicate_summary
| Field | Value |
|---|---|
duplicate_removed_count |
total number of loser rows across all duplicate groups |
duplicate_keys |
one entry per duplicate group |
Each duplicate_keys entry:
| Field | Value |
|---|---|
key |
"email:{normalized_email}" |
winner_row_id |
the winning row's row_id |
removed_row_ids |
list of losing row_ids |
Sort duplicate_keys by key ascending.
C5. Determine CRM action for surviving clean contacts
For each surviving row:
CRM ACCOUNT MATCHING (Import Batch)
- Extract domain from norm_email: the part after
@. - Search CRM accounts for one whose
domainfield matches this domain. - If found →
crm_action=update_existing,existing_account_id= matched account_id. - If NOT found (or email is empty) →
crm_action=create_account,existing_account_id=null.
CRM CONTACT MATCHING (Import Batch)
- Search CRM contacts for one whose
emailfield exactly matches the row's norm_email. - If found →
existing_contact_id= matched contact_id. - If NOT found →
existing_contact_id=null.
Note: The CRM contact match is by EXACT email match, not by name or domain. A contact person may exist in CRM under a different email — that does NOT count as a match. (E.g., "old.hana@riverbendchem.example" in CRM vs "hana.park@riverbendchem.example" in the raw contact → existing_contact_id = null.)
C6. Build clean_contacts array
For each surviving row (sorted by clean_contact_id ascending):
| Field | Value |
|---|---|
clean_contact_id |
winner's row_id |
source_row_id |
winner's row_id |
company_name |
winner's raw company_name (NOT normalized — taken as-is) |
contact_name |
winner's raw contact_name |
email |
norm_email (normalized), or "" |
phone |
norm_phone (normalized digits-only), or "" |
source_name |
winner's raw source_name |
captured_at |
winner's raw captured_at (ISO timestamp as-is) |
crm_action |
create_account or update_existing (from C5) |
existing_account_id |
matched CRM account_id, or null |
existing_contact_id |
matched CRM contact_id, or null |
Company name is NOT normalized. If the winning row spells it "HelioWare Mfg." but CRM has "HelioWare Manufacturing", the output keeps "HelioWare Mfg." as-is.
C7. Build removal_summary
| Field | Value |
|---|---|
unusable_removed_count |
count of rows with reason duplicate + missing_contact |
suppressed_removed_count |
count of rows with reason suppressed |
removed_rows |
list of all removed rows |
Each removed_rows entry:
| Field | Value |
|---|---|
row_id |
the row's row_id |
reason |
duplicate / missing_contact / suppressed |
Sort removed_rows by row_id ascending.
unusable_removed_count = duplicates + missing_contact (data-quality failures). suppressed_removed_count = compliance suppressions (opt-out, privacy, role account).
C8. Build import_action_totals
| Key | Count |
|---|---|
create_account |
surviving rows with crm_action = create_account |
update_existing |
surviving rows with crm_action = update_existing |
no_import |
rows removed as duplicate + missing_contact |
suppress |
rows removed as suppressed |
Sanity check: create_account + update_existing + no_import + suppress = total raw_contacts count.
C9. campaign_member_import_count
= number of surviving clean contacts (= create_account + update_existing count).
All surviving contacts become campaign members for the batch campaign.
C10. Top-level fields
| Field | Source |
|---|---|
batch_id |
from the prompt/task |
campaign_code |
from GET /api/import_batches → find matching batch → campaign_code field |
C11. Final assembly and sort verification
clean_contactssorted byclean_contact_idascendingduplicate_summary.duplicate_keyssorted bykeyascendingremoval_summary.removed_rowssorted byrow_idascending- All enum values match template
- Output ONE JSON object.
6. Sorting Rules Reference
| Array | Sort key | Direction |
|---|---|---|
sponsor_statuses |
account_name |
ascending |
qualified_lead_accounts |
account_name |
ascending |
excluded_records (train_001) |
company_name then contact_name |
ascending, ascending |
badge_decisions (train_004) |
badge_id |
ascending |
campaign_member_actions (train_004) |
subject_key |
ascending |
badge_only_contacts (train_004) |
company_name |
ascending |
qualified_exhibitors (train_002) |
company_name |
ascending |
excluded_near_misses (train_002) |
company_name |
ascending |
ranked_leads (train_005) |
rank |
ascending |
excluded_exhibitors (train_005) |
company_name |
ascending |
clean_contacts (train_003) |
clean_contact_id |
ascending |
duplicate_keys (train_003) |
key |
ascending |
removed_rows (train_003) |
row_id |
ascending |
qualified_non_sponsor_account_names |
(string sort) | ascending |
unpaid_sponsor_account_names |
(string sort) | ascending |
sponsor_finance_accounts |
(string sort) | ascending |
existing_crm_overlap_account_ids |
(string sort) | ascending |
7. Enum Reference
Sponsor status enums
paid_deferred— invoice fully paid, revenue deferredopen_invoice— invoice issued, not fully paidproposal_only— sponsor package proposal_sent, no invoicenot_sponsor— (train_004 template) account in context but not a sponsor
Badge classification enums
sponsor_attendee— company is an active sponsorqualified_non_sponsor_lead— non-sponsor, not excluded, potential leadexcluded— badge excluded (non-business, disqualified, etc.)
Exclusion reason enums (event tasks)
sponsor_attendeenon_business_badgeexisting_disqualifiedinactive_sponsor_recordmissing_contact(train_004 template)
Platform enums (tradeshow tasks)
AUVROVUnderwater Camera(Always listed in this order.)
Priority tier enums
A— demo requested AND score ≥ 90B— demo requested AND score ≥ 80C— all other qualified leads
Exclusion reason enums (tradeshow tasks)
distributor_onlyservice_onlysensor_vendor_only(train_002 template) /sensor_only(train_005 template) — check template!research_onlynot_target_market(train_002 template only)
Relationship type enums (train_005)
distributorservice_providersensor_vendorresearch
CRM action enums
Tradeshow (train_005): create_account, update_existing, no_import
Event badge (train_004): create_account_contact_campaign_member, create_contact_campaign_member, add_campaign_member, update_campaign_member, no_action, no_import
Import batch (train_003): create_account, update_existing, no_import, suppress
Campaign member (train_001): add_campaign_member
Campaign member action (train_004): create, update, no_action, no_import
Campaign member target_status (train_004)
attended_sponsor— sponsor attendee badge, new campaign memberregistered_sponsor— existing campaign member status (pre-registered sponsor)attended— qualified non-sponsor lead, new campaign memberexcluded— excluded badge (rarely used as target_status)
Removal reason enums (import batch)
duplicatemissing_contactsuppressed
Source name enums (import batch)
badge_scan, sponsor_form, partner_upload, webinar_form, exhibitor_form, manual_upload
8. Critical Pitfalls & Edge Cases
Exclusion reason enums vary by template.
sensor_vendor_only(train_002) vssensor_only(train_005). Always copy from the answer template's allowed_values.Company name is NOT normalized in import batch clean_contacts. Keep the raw spelling from the winning row.
Duplicate winner = LATEST captured_at, then HIGHEST row_id. Not first occurrence.
CRM contact match is by EXACT email, not by name. A person in CRM with a different email is NOT a match.
Suppression matches by email OR phone. A row is suppressed if EITHER field matches a suppression entry.
Missing contact = BOTH email AND phone are empty after normalization. A row with phone but no email is NOT missing_contact.
not_sponsorsponsor_status may appear for accounts with CRM opportunities for the event but no sponsor package — include in sponsor_statuses if the schema requires it.Sponsor finance follow-up excludes
paid_deferredsponsors (fully paid). Onlyopen_invoiceandproposal_onlysponsors need follow-up.open_invoice_balanceis computed ONLY foropen_invoicesponsors, aspackage_amount - paid_amount. Proposal_only sponsors haveopen_balance = 0(no invoice exists).Badge classification priority: active_sponsor > existing_disqualified > non_business_badge > inactive_sponsor_record > qualified_lead. A company that is BOTH a canceled sponsor AND CRM-disqualified gets reason
existing_disqualified.Campaign member actions include ALL existing campaign members (with
no_action), not just new ones. Existing sponsors who didn't badge-scan still appear (e.g., SignalForge/Owen Grant in train_004 was registered_sponsor with no badge).subject_keyformat: existing CRM contacts use{account_id}:{contact_id}; new badge contacts usebadge:{badge_id}.Tradeshow CRM matching uses the exhibitor's
crm_account_idfield directly (not email domain matching, since exhibitors may not have emails).Event task
lead_opportunity_amountis per-lead, and ALL qualified leads get the SAME amount (from the event record). Don't invent per-lead amounts.All monetary values are integers (USD, no decimals).
The
campaignstring in tradeshow tasks (e.g.,oem_dissolved_oxygen_sensor) comes from the PROMPT, not the API. The tradeshow endpoint has no campaign field.Active sponsor = order_status in {confirmed, proposal_sent}. Canceled = inactive = excluded from sponsor_statuses.
Unusable_removed_count = duplicates + missing_contact. Suppressed is tracked separately. The import_action_totals
no_importbucket covers both duplicates and missing_contact.Always produce a complete JSON object. Missing keys, wrong sort order, or wrong enum strings cause failures. Verify against the template before finalizing.
Integer USD only. No floats, no string-formatted numbers.