HarborCRM Skill
Environment
Base URL: http://34.46.77.124:8001. All API calls use this prefix. Never start local env/setup.sh or use localhost — environment_access.md overrides any task text that mentions local URLs.
API Reference
Events (post-event reconciliation tasks)
| Endpoint |
Returns |
GET /api/events |
All events |
GET /api/events/{event_id} |
Single event with end_date, followup_days_after_end, sponsor_followup_days_after_end, lead_opportunity_amount |
GET /api/events/{event_id}/orders |
Sponsor orders (account_id, amount, order_status, ticket_contacts) |
GET /api/events/{event_id}/badges |
Badge scans (badge_id, badge_type, company_name, contact_name, email, phone, job_title, scan_score) |
GET /api/events/{event_id}/sponsor_packages |
Same shape as orders |
Tradeshows (prospecting tasks)
| Endpoint |
Returns |
GET /api/tradeshows |
All tradeshows |
GET /api/tradeshows/{show_id}/exhibitors |
Exhibitors with company_id, company_name, description, booth, country, website, crm_account_id |
GET /api/tradeshows/{show_id}/meeting_interest |
Per-company interest_score, requested_demo, notes |
Finance
| Endpoint |
Returns |
GET /api/finance/invoices?event_id={id} |
Invoices with status (paid_deferred/open), amount, paid_amount, deferred_amount |
CRM
| Endpoint |
Returns |
GET /api/crm/accounts |
All accounts (account_id, name, domain, status, disqualified_reason) |
GET /api/crm/contacts |
All contacts (contact_id, account_id, name, email, phone, opted_out) |
GET /api/crm/opportunities |
All opportunities |
GET /api/crm/campaign_members?event_id={id} |
Existing campaign members for an event |
Import Batches
| Endpoint |
Returns |
GET /api/import_batches |
All batches (batch_id, campaign_code) |
GET /api/import_batches/{batch_id}/raw_contacts |
Raw rows with row_id, company_name, contact_name, email, phone, source_name, captured_at |
GET /api/import_batches/{batch_id}/suppression |
Suppression list (email, phone, reason) |
Policies
GET /api/policies — returns contact_hygiene, prospecting.platform_enums (always ["AUV","ROV","Underwater Camera"]), sponsor_handoff.status_enums.
Normalization Rules
- Email: lowercase, trim leading/trailing whitespace. If blank/whitespace-only after trim → treat as empty string.
- Phone: strip all non-digit characters (
+, -, ., (, ), spaces). If result is empty → empty string.
Sponsor Status Derivation
For event reconciliation tasks, classify each sponsor order:
Order is canceled (order_status == "canceled") → exclude entirely (inactive sponsor record). Do NOT include in sponsor_statuses. Include in excluded_records with reason inactive_sponsor_record.
Has invoice with status == "paid_deferred" → paid_deferred. paid_amount = invoice.paid_amount, open_balance = 0 (deferred revenue recognized, fully paid).
- Revenue total: sum
package_amount (the order/invoice amount).
Has invoice with status == "open" → open_invoice. paid_amount = invoice.paid_amount, open_balance = invoice.amount - invoice.paid_amount.
- Revenue total: sum invoice amount.
open_invoice_balance = sum of all open_balance values.
No invoice exists but order is active (order_status == "confirmed" or "proposal_sent") → proposal_only. paid_amount = 0, open_balance = 0, invoice_id = null.
- Revenue total: sum order amount.
Not a sponsor (not_sponsor) — use only when a task template explicitly requires it.
Sponsor Revenue Totals
{
"paid_deferred": <sum of amounts for paid_deferred sponsors>,
"open_invoice": <sum of amounts for open_invoice sponsors>,
"proposal_only": <sum of amounts for proposal_only sponsors>,
"open_invoice_balance": <sum of (amount - paid_amount) for open_invoice sponsors>
}
All values are integers (USD).
Sponsor Finance Follow-up
- Accounts needing follow-up: sponsors with status
open_invoice or proposal_only (not paid_deferred).
- Due date:
event.end_date + event.sponsor_followup_days_after_end days (format YYYY-MM-DD).
- Task count: number of accounts needing follow-up.
Lead Qualification (Event Tasks)
From badge scans, identify qualified non-sponsor leads:
- Exclude sponsor attendees: any badge whose
company_name matches a sponsor order's account_name (case-sensitive exact match). Include in excluded_records with reason sponsor_attendee.
- Exclude non-business badges:
badge_type is "student", "press", or similar non-business types → reason non_business_badge.
- Exclude disqualified CRM accounts: look up badge
company_name in CRM accounts. If the account has status == "disqualified" or non-null disqualified_reason → reason existing_disqualified.
- Remaining badges → qualified non-sponsor leads.
CRM Actions for Qualified Leads
- Account: if the company has a matching CRM account (matched by name or by email domain — extract domain from the badge contact's normalized email, compare to CRM account
domain field) → update_existing. Otherwise → create_account.
- Contact: if a CRM contact exists with the same normalized email →
update_existing. Otherwise → create_contact. (Also check same account_id.)
- Campaign member: all qualified leads get
add_campaign_member.
Opportunity Amount
Use event.lead_opportunity_amount for each qualified lead. lead_pipeline_total = sum of all lead opportunity amounts.
Lead Follow-up
- Due date:
event.end_date + event.followup_days_after_end days (format YYYY-MM-DD).
- Task count: number of qualified leads.
Trade Show Prospecting
Qualification
Read each exhibitor's description field. An exhibitor is qualified if it manufactures/OEM-builds at least one of the target platforms: AUV, ROV, or Underwater Camera. Use the description text to determine platform coverage:
- "builds AUVs", "manufactures ROVs", "OEM underwater cameras" → qualified
- "distributor", "reseller", "does not manufacture" → not qualified
- "sensor-only", "analytics dashboard using partner hardware", "consulting/operates rented" → not qualified
Exclusion Reasons (controlled vocabulary)
Use the exclusion_reason enum from the task's answer template. Common values:
distributor_only — reseller/distributor, doesn't manufacture platforms
service_only — consulting/services, operates but doesn't build
sensor_vendor_only / sensor_only — sensor-only vendor, no platform manufacturing
research_only — research institution
not_target_market — doesn't fit campaign criteria
Match relationship_type to exclusion_reason when both are required: distributor→distributor_only, service_provider→service_only, sensor_vendor→sensor_only, research→research_only.
Platform Enums
Always ["AUV", "ROV", "Underwater Camera"] in that order. Sort platforms lists in this enum order, not alphabetically.
Priority Tiers & Opportunity Sizing
Derived from meeting interest data (joined by company_name):
| Tier |
Condition |
Opportunity (USD) |
| A |
requested_demo == true AND interest_score >= 90 |
120000 |
| B |
requested_demo == true AND interest_score >= 80 |
90000 |
| C |
All other qualified exhibitors |
50000 |
Ranking (when task requires ranked output)
- Demo requested first (
true before false)
- Interest score descending
- Platform coverage count descending (more platforms ranks higher)
- Company name ascending (alphabetical tiebreaker)
CRM Actions (Trade Show)
- Exhibitor has
crm_account_id not null → update_existing
- Exhibitor has
crm_account_id null → create_account
- Excluded exhibitors →
no_import
CRM Account Matching
- Match exhibitor
crm_account_id to CRM account account_id directly when the field is present.
- For event tasks without explicit exhibitor CRM links: extract domain from normalized email → match against CRM
domain field. Fall back to company_name match if domain lookup fails.
Aggregate/Summary Counts
qualified_total: number of qualified exhibitors
platform_counts: count of qualified exhibitors covering each platform (a company may cover multiple platforms)
priority_counts: count of qualified exhibitors per tier
existing_crm_overlap_count: number of qualified exhibitors with non-null crm_account_id
total_estimated_opportunity_usd: sum of opportunity_estimate_usd across all qualified leads
Batch Import Cleaning
Deduplication
Group raw contacts by normalized email (lowercase, trimmed). When multiple rows share the same email:
Winner selection (source priority, highest first):
partner_upload
webinar_form
badge_scan
sponsor_form
exhibitor_form
manual_upload
If same source, pick the row with the later captured_at timestamp. The winner becomes the clean_contact. Its row_id is used as both clean_contact_id and source_row_id.
Suppression
Check each surviving clean contact (after dedup) against:
- Suppression list (
GET .../suppression): match by normalized email OR digits-only phone.
- CRM contacts with
opted_out == true: match by normalized email.
A contact is suppressed if it matches EITHER source. Suppressed contacts get crm_action: "suppress", removal reason "suppressed".
Missing Contact
A row is unusable if contact_name is blank/null OR normalized email is empty/whitespace-only. Removal reason: "missing_contact".
CRM Account/Contact Matching (Batch Import)
- Match company to CRM account: extract domain from normalized email → find CRM account with matching
domain. If email is empty, try company_name match.
- Match contact to CRM contact: normalized email match against CRM contacts. Only set
existing_contact_id if the matched contact belongs to the matched account.
- If account found →
update_existing. If not → create_account.
Duplicate Key Format
"email:<normalized_email>" — one entry per duplicate group.
Removal Summary
unusable_removed_count: rows removed for missing_contact
suppressed_removed_count: rows removed for suppressed
duplicate_removed_count: rows removed as losers in dedup (in duplicate_summary)
removed_rows: ALL removed rows (duplicates + suppressed + missing_contact), sorted by row_id ascending
Import Action Totals
Count actions across ALL raw contact rows (not just clean ones):
create_account: clean contacts with no matching CRM account
update_existing: clean contacts with matching CRM account
no_import: removed duplicates + missing_contact rows
suppress: suppressed rows
Campaign Member Count
Number of clean contacts that survived dedup, suppression, and missing-contact removal — i.e., clean_contacts.length.
Badge-Level Handling (Event Reconciliation with Badges)
When a task requires per-badge decisions:
Classification
sponsor_attendee: badge company_name matches a sponsor order's account_name
qualified_non_sponsor_lead: not a sponsor attendee, not disqualified, business badge type
excluded: non-business badge OR existing disqualified CRM account
CRM Action per Badge
- New account + new contact needed →
create_account_contact_campaign_member
- Existing account, new contact →
create_contact_campaign_member
- Existing account and contact, just add to campaign →
add_campaign_member
- Sponsor attendee with existing campaign member →
update_campaign_member or no_action
- Excluded badges →
no_import
- Sponsor attendee (contact needed but lead-excluded) →
create_contact_campaign_member (adds the contact record but they remain classified as sponsor_attendee for lead purposes)
Campaign Member Actions
For each unique (account, contact) pair relevant to the event:
| Existing campaign member? |
Classification |
Action |
target_status |
| No |
sponsor attendee |
create |
attended_sponsor |
| Yes, status=attended_sponsor |
sponsor attendee |
no_action |
attended_sponsor |
| Yes, status=registered_sponsor |
sponsor attendee |
no_action |
registered_sponsor |
| No |
qualified lead |
create |
attended |
| Excluded badge |
— |
no_import |
excluded |
Subject key format: "acct_{id}:cont_{id}" for CRM contacts, "badge:{badge_id}" for badge-only leads.
Badge-Only Contacts
Contacts from badges that have no existing CRM contact record. Include normalized email (lowercase, trimmed; empty string if none) and normalized phone (digits only; empty string if none). Include sponsor attendee badge-only contacts too. Sort by company_name ascending.
Exclusion Counts
Count excluded badges by reason: sponsor_attendee, non_business_badge, existing_disqualified, missing_contact. Integer values.
Sorting Rules (by task type)
| Context |
Sort field(s) |
Direction |
| sponsor_statuses |
account_name |
ascending |
| qualified_lead_accounts |
account_name |
ascending |
| excluded_records |
company_name, then contact_name |
ascending, ascending |
| qualified_exhibitors |
company_name (or rank when ranked) |
ascending |
| excluded_near_misses / excluded_exhibitors |
company_name |
ascending |
| badge_decisions |
badge_id |
ascending |
| badge_only_contacts |
company_name |
ascending |
| campaign_member_actions |
subject_key |
ascending |
| clean_contacts |
clean_contact_id |
ascending |
| duplicate_keys |
key |
ascending |
| removed_rows |
row_id |
ascending |
| ranked_leads |
rank |
ascending |
| platforms (within an item) |
enum order: AUV, ROV, Underwater Camera |
— |
| qualified_non_sponsor_account_names |
account_name |
ascending |
| unpaid_sponsor_account_names |
account_name |
ascending |
| existing_crm_overlap_account_ids |
account_id |
ascending |
General Rules
- Return JSON only — no explanatory prose outside the JSON object.
- Match the answer template exactly — do not add or omit top-level keys. Use the field names and enum values from the template.
- Integer amounts — all monetary values are integers (USD). No decimals or floats.
- Date format — always
YYYY-MM-DD. Calculate follow-up dates as end_date + offset_days.
- Empty strings, not null — for missing emails/phones in normalized output, use
"" not null, unless the template explicitly requires null.
- account_id / invoice_id nulls — use JSON
null for absent IDs per the template (e.g., no invoice → invoice_id: null; no CRM account → account_id: null or crm_account_id: null).
- Campaign member actions span both CRM contacts and badge-only contacts — include all relevant (account, contact) pairs for the event, not just qualified leads.
- Canceled sponsor orders are inactive — exclude from sponsor_statuses; include in excluded_records with reason
inactive_sponsor_record.
- Don't double-count — when a sponsor contact appears in both campaign_members and badges, don't create duplicate campaign member actions.
- Domain extraction — from
user@example.com extract example.com. Use this to match against CRM account domain field.
1---2name: fewshot-attempt-01-283description: HarborCRM Skill4---5# HarborCRM Skill67## Environment89Base URL: `http://34.46.77.124:8001`. All API calls use this prefix. Never start local env/setup.sh or use localhost — environment_access.md overrides any task text that mentions local URLs.1011## API Reference1213### Events (post-event reconciliation tasks)14| Endpoint | Returns |15|---|---|16| `GET /api/events` | All events |17| `GET /api/events/{event_id}` | Single event with `end_date`, `followup_days_after_end`, `sponsor_followup_days_after_end`, `lead_opportunity_amount` |18| `GET /api/events/{event_id}/orders` | Sponsor orders (account_id, amount, order_status, ticket_contacts) |19| `GET /api/events/{event_id}/badges` | Badge scans (badge_id, badge_type, company_name, contact_name, email, phone, job_title, scan_score) |20| `GET /api/events/{event_id}/sponsor_packages` | Same shape as orders |2122### Tradeshows (prospecting tasks)23| Endpoint | Returns |24|---|---|25| `GET /api/tradeshows` | All tradeshows |26| `GET /api/tradeshows/{show_id}/exhibitors` | Exhibitors with company_id, company_name, description, booth, country, website, crm_account_id |27| `GET /api/tradeshows/{show_id}/meeting_interest` | Per-company interest_score, requested_demo, notes |2829### Finance30| Endpoint | Returns |31|---|---|32| `GET /api/finance/invoices?event_id={id}` | Invoices with status (paid_deferred/open), amount, paid_amount, deferred_amount |3334### CRM35| Endpoint | Returns |36|---|---|37| `GET /api/crm/accounts` | All accounts (account_id, name, domain, status, disqualified_reason) |38| `GET /api/crm/contacts` | All contacts (contact_id, account_id, name, email, phone, opted_out) |39| `GET /api/crm/opportunities` | All opportunities |40| `GET /api/crm/campaign_members?event_id={id}` | Existing campaign members for an event |4142### Import Batches43| Endpoint | Returns |44|---|---|45| `GET /api/import_batches` | All batches (batch_id, campaign_code) |46| `GET /api/import_batches/{batch_id}/raw_contacts` | Raw rows with row_id, company_name, contact_name, email, phone, source_name, captured_at |47| `GET /api/import_batches/{batch_id}/suppression` | Suppression list (email, phone, reason) |4849### Policies50`GET /api/policies` — returns `contact_hygiene`, `prospecting.platform_enums` (always `["AUV","ROV","Underwater Camera"]`), `sponsor_handoff.status_enums`.5152---5354## Normalization Rules5556- **Email**: lowercase, trim leading/trailing whitespace. If blank/whitespace-only after trim → treat as empty string.57- **Phone**: strip all non-digit characters (`+`, `-`, `.`, `(`, `)`, spaces). If result is empty → empty string.5859---6061## Sponsor Status Derivation6263For event reconciliation tasks, classify each sponsor order:64651. **Order is canceled** (`order_status == "canceled"`) → **exclude entirely** (inactive sponsor record). Do NOT include in sponsor_statuses. Include in excluded_records with reason `inactive_sponsor_record`.66672. **Has invoice with `status == "paid_deferred"`** → `paid_deferred`. `paid_amount` = invoice.paid_amount, `open_balance` = 0 (deferred revenue recognized, fully paid).68 - Revenue total: sum `package_amount` (the order/invoice amount).69703. **Has invoice with `status == "open"`** → `open_invoice`. `paid_amount` = invoice.paid_amount, `open_balance` = invoice.amount - invoice.paid_amount.71 - Revenue total: sum invoice amount. `open_invoice_balance` = sum of all open_balance values.72734. **No invoice exists but order is active** (`order_status == "confirmed"` or `"proposal_sent"`) → `proposal_only`. `paid_amount` = 0, `open_balance` = 0, `invoice_id` = null.74 - Revenue total: sum order amount.75765. **Not a sponsor** (`not_sponsor`) — use only when a task template explicitly requires it.7778### Sponsor Revenue Totals79```json80{81 "paid_deferred": <sum of amounts for paid_deferred sponsors>,82 "open_invoice": <sum of amounts for open_invoice sponsors>,83 "proposal_only": <sum of amounts for proposal_only sponsors>,84 "open_invoice_balance": <sum of (amount - paid_amount) for open_invoice sponsors>85}86```87All values are integers (USD).8889### Sponsor Finance Follow-up90- **Accounts needing follow-up**: sponsors with status `open_invoice` or `proposal_only` (not `paid_deferred`).91- **Due date**: `event.end_date + event.sponsor_followup_days_after_end` days (format `YYYY-MM-DD`).92- **Task count**: number of accounts needing follow-up.9394---9596## Lead Qualification (Event Tasks)9798From badge scans, identify qualified non-sponsor leads:991001. **Exclude sponsor attendees**: any badge whose `company_name` matches a sponsor order's `account_name` (case-sensitive exact match). Include in excluded_records with reason `sponsor_attendee`.1012. **Exclude non-business badges**: `badge_type` is `"student"`, `"press"`, or similar non-business types → reason `non_business_badge`.1023. **Exclude disqualified CRM accounts**: look up badge `company_name` in CRM accounts. If the account has `status == "disqualified"` or non-null `disqualified_reason` → reason `existing_disqualified`.1034. **Remaining badges** → qualified non-sponsor leads.104105### CRM Actions for Qualified Leads106- **Account**: if the company has a matching CRM account (matched by name or by email domain — extract domain from the badge contact's normalized email, compare to CRM account `domain` field) → `update_existing`. Otherwise → `create_account`.107- **Contact**: if a CRM contact exists with the same normalized email → `update_existing`. Otherwise → `create_contact`. (Also check same account_id.)108- **Campaign member**: all qualified leads get `add_campaign_member`.109110### Opportunity Amount111Use `event.lead_opportunity_amount` for each qualified lead. `lead_pipeline_total` = sum of all lead opportunity amounts.112113### Lead Follow-up114- **Due date**: `event.end_date + event.followup_days_after_end` days (format `YYYY-MM-DD`).115- **Task count**: number of qualified leads.116117---118119## Trade Show Prospecting120121### Qualification122Read each exhibitor's `description` field. An exhibitor is **qualified** if it manufactures/OEM-builds at least one of the target platforms: AUV, ROV, or Underwater Camera. Use the description text to determine platform coverage:123- "builds AUVs", "manufactures ROVs", "OEM underwater cameras" → qualified124- "distributor", "reseller", "does not manufacture" → not qualified125- "sensor-only", "analytics dashboard using partner hardware", "consulting/operates rented" → not qualified126127### Exclusion Reasons (controlled vocabulary)128Use the exclusion_reason enum from the task's answer template. Common values:129- `distributor_only` — reseller/distributor, doesn't manufacture platforms130- `service_only` — consulting/services, operates but doesn't build131- `sensor_vendor_only` / `sensor_only` — sensor-only vendor, no platform manufacturing132- `research_only` — research institution133- `not_target_market` — doesn't fit campaign criteria134135Match `relationship_type` to exclusion_reason when both are required: distributor→distributor_only, service_provider→service_only, sensor_vendor→sensor_only, research→research_only.136137### Platform Enums138Always `["AUV", "ROV", "Underwater Camera"]` in that order. Sort platforms lists in this enum order, not alphabetically.139140### Priority Tiers & Opportunity Sizing141Derived from meeting interest data (joined by company_name):142143| Tier | Condition | Opportunity (USD) |144|---|---|---|145| A | `requested_demo == true` AND `interest_score >= 90` | 120000 |146| B | `requested_demo == true` AND `interest_score >= 80` | 90000 |147| C | All other qualified exhibitors | 50000 |148149### Ranking (when task requires ranked output)1501. Demo requested first (`true` before `false`)1512. Interest score descending1523. Platform coverage count descending (more platforms ranks higher)1534. Company name ascending (alphabetical tiebreaker)154155### CRM Actions (Trade Show)156- Exhibitor has `crm_account_id` not null → `update_existing`157- Exhibitor has `crm_account_id` null → `create_account`158- Excluded exhibitors → `no_import`159160### CRM Account Matching161- Match exhibitor `crm_account_id` to CRM account `account_id` directly when the field is present.162- For event tasks without explicit exhibitor CRM links: extract domain from normalized email → match against CRM `domain` field. Fall back to company_name match if domain lookup fails.163164### Aggregate/Summary Counts165- `qualified_total`: number of qualified exhibitors166- `platform_counts`: count of qualified exhibitors covering each platform (a company may cover multiple platforms)167- `priority_counts`: count of qualified exhibitors per tier168- `existing_crm_overlap_count`: number of qualified exhibitors with non-null crm_account_id169- `total_estimated_opportunity_usd`: sum of opportunity_estimate_usd across all qualified leads170171---172173## Batch Import Cleaning174175### Deduplication176Group raw contacts by **normalized email** (lowercase, trimmed). When multiple rows share the same email:177178**Winner selection** (source priority, highest first):1791. `partner_upload`1802. `webinar_form`1813. `badge_scan`1824. `sponsor_form`1835. `exhibitor_form`1846. `manual_upload`185186If same source, pick the row with the **later `captured_at`** timestamp. The winner becomes the `clean_contact`. Its `row_id` is used as both `clean_contact_id` and `source_row_id`.187188### Suppression189Check each surviving clean contact (after dedup) against:1901. **Suppression list** (`GET .../suppression`): match by normalized email OR digits-only phone.1912. **CRM contacts with `opted_out == true`**: match by normalized email.192193A contact is suppressed if it matches EITHER source. Suppressed contacts get `crm_action: "suppress"`, removal reason `"suppressed"`.194195### Missing Contact196A row is unusable if `contact_name` is blank/null OR normalized email is empty/whitespace-only. Removal reason: `"missing_contact"`.197198### CRM Account/Contact Matching (Batch Import)199- Match company to CRM account: extract domain from normalized email → find CRM account with matching `domain`. If email is empty, try company_name match.200- Match contact to CRM contact: normalized email match against CRM contacts. Only set `existing_contact_id` if the matched contact belongs to the matched account.201- If account found → `update_existing`. If not → `create_account`.202203### Duplicate Key Format204`"email:<normalized_email>"` — one entry per duplicate group.205206### Removal Summary207- `unusable_removed_count`: rows removed for `missing_contact`208- `suppressed_removed_count`: rows removed for `suppressed`209- `duplicate_removed_count`: rows removed as losers in dedup (in duplicate_summary)210- `removed_rows`: ALL removed rows (duplicates + suppressed + missing_contact), sorted by `row_id` ascending211212### Import Action Totals213Count actions across ALL raw contact rows (not just clean ones):214- `create_account`: clean contacts with no matching CRM account215- `update_existing`: clean contacts with matching CRM account216- `no_import`: removed duplicates + missing_contact rows217- `suppress`: suppressed rows218219### Campaign Member Count220Number of clean contacts that survived dedup, suppression, and missing-contact removal — i.e., `clean_contacts.length`.221222---223224## Badge-Level Handling (Event Reconciliation with Badges)225226When a task requires per-badge decisions:227228### Classification229- `sponsor_attendee`: badge company_name matches a sponsor order's account_name230- `qualified_non_sponsor_lead`: not a sponsor attendee, not disqualified, business badge type231- `excluded`: non-business badge OR existing disqualified CRM account232233### CRM Action per Badge234- New account + new contact needed → `create_account_contact_campaign_member`235- Existing account, new contact → `create_contact_campaign_member`236- Existing account and contact, just add to campaign → `add_campaign_member`237- Sponsor attendee with existing campaign member → `update_campaign_member` or `no_action`238- Excluded badges → `no_import`239- Sponsor attendee (contact needed but lead-excluded) → `create_contact_campaign_member` (adds the contact record but they remain classified as sponsor_attendee for lead purposes)240241### Campaign Member Actions242For each unique (account, contact) pair relevant to the event:243244| Existing campaign member? | Classification | Action | target_status |245|---|---|---|---|246| No | sponsor attendee | `create` | `attended_sponsor` |247| Yes, status=attended_sponsor | sponsor attendee | `no_action` | `attended_sponsor` |248| Yes, status=registered_sponsor | sponsor attendee | `no_action` | `registered_sponsor` |249| No | qualified lead | `create` | `attended` |250| Excluded badge | — | `no_import` | `excluded` |251252Subject key format: `"acct_{id}:cont_{id}"` for CRM contacts, `"badge:{badge_id}"` for badge-only leads.253254### Badge-Only Contacts255Contacts from badges that have no existing CRM contact record. Include normalized email (lowercase, trimmed; empty string if none) and normalized phone (digits only; empty string if none). Include sponsor attendee badge-only contacts too. Sort by company_name ascending.256257### Exclusion Counts258Count excluded badges by reason: `sponsor_attendee`, `non_business_badge`, `existing_disqualified`, `missing_contact`. Integer values.259260---261262## Sorting Rules (by task type)263264| Context | Sort field(s) | Direction |265|---|---|---|266| sponsor_statuses | account_name | ascending |267| qualified_lead_accounts | account_name | ascending |268| excluded_records | company_name, then contact_name | ascending, ascending |269| qualified_exhibitors | company_name (or rank when ranked) | ascending |270| excluded_near_misses / excluded_exhibitors | company_name | ascending |271| badge_decisions | badge_id | ascending |272| badge_only_contacts | company_name | ascending |273| campaign_member_actions | subject_key | ascending |274| clean_contacts | clean_contact_id | ascending |275| duplicate_keys | key | ascending |276| removed_rows | row_id | ascending |277| ranked_leads | rank | ascending |278| platforms (within an item) | enum order: AUV, ROV, Underwater Camera | — |279| qualified_non_sponsor_account_names | account_name | ascending |280| unpaid_sponsor_account_names | account_name | ascending |281| existing_crm_overlap_account_ids | account_id | ascending |282283---284285## General Rules2862871. **Return JSON only** — no explanatory prose outside the JSON object.2882. **Match the answer template exactly** — do not add or omit top-level keys. Use the field names and enum values from the template.2893. **Integer amounts** — all monetary values are integers (USD). No decimals or floats.2904. **Date format** — always `YYYY-MM-DD`. Calculate follow-up dates as `end_date + offset_days`.2915. **Empty strings, not null** — for missing emails/phones in normalized output, use `""` not `null`, unless the template explicitly requires `null`.2926. **account_id / invoice_id nulls** — use JSON `null` for absent IDs per the template (e.g., no invoice → `invoice_id: null`; no CRM account → `account_id: null` or `crm_account_id: null`).2937. **Campaign member actions span both CRM contacts and badge-only contacts** — include all relevant (account, contact) pairs for the event, not just qualified leads.2948. **Canceled sponsor orders are inactive** — exclude from sponsor_statuses; include in excluded_records with reason `inactive_sponsor_record`.2959. **Don't double-count** — when a sponsor contact appears in both campaign_members and badges, don't create duplicate campaign member actions.29610. **Domain extraction** — from `user@example.com` extract `example.com`. Use this to match against CRM account `domain` field.