HarborCRM Front-of-Funnel Solver
HarborCRM is a shared read-only JSON CRM/marketing workspace. Tasks ask you to reconcile,
qualify, classify, and plan handoffs, then return exactly ONE JSON object that conforms to a
provided input/payloads/answer_template.json. The template is authoritative for shape,
key names, enum values, ordering, and which fields are required. The rules below explain HOW
to fill it correctly and WHY records get included/excluded.
Golden rules
- Output JSON ONLY. No prose outside the JSON. Add no keys not in the template; include all
required keys. Match enum spellings exactly.
- Obey every ordering rule in the template / prompt (usually a sort key, ascending). Sort
deterministically; apply secondary sort keys when specified.
- Currency is integer USD. Counts are integers. Dates are
YYYY-MM-DD.
- Derive everything from the live API data, not from memory. The
/api/policies endpoint is
intentionally sparse (just enum lists and notes) — the real rules come from the record
contents and the conventions below.
- Never invent data. Empty/absent email or phone becomes
"" (empty string), and missing
account/contact IDs become null (follow the template's stated type for each field).
API access
Base URL is supplied by the runner (e.g. http://127.0.0.1:8080 or :8067). Use curl -s.
All responses are JSON. Endpoints:
GET /health — sanity check + record counts.
GET /api/events, /api/events/{event_id} — dates, lead_opportunity_amount,
followup_days_after_end, sponsor_followup_days_after_end, campaign_code, status.
GET /api/events/{event_id}/orders and /sponsor_packages — sponsor package rows with
account_id, amount, order_status (confirmed | proposal_sent | canceled),
package_level, ticket_contacts. (orders and sponsor_packages return the same rows.)
GET /api/events/{event_id}/badges — badge scans: badge_id, badge_type,
company_name, contact_name, email, phone, scan_score, session_interest.
GET /api/finance/invoices?event_id=<id> (also account_id=<id>) — invoice_id, amount,
paid_amount, deferred_amount, status (paid_deferred | open), due_date.
GET /api/crm/accounts (filters status=, owner_region=) — account_id, name,
domain, status (customer | prospect | disqualified), disqualified_reason.
GET /api/crm/contacts (filter account_id=) — contact_id, account_id, name,
email, phone, opted_out, title, source_updated_at.
GET /api/crm/opportunities (filters event_id=, account_id=) — amount, stage.
GET /api/crm/campaign_members?event_id=<id> (also account_id=) — account_id,
contact_id, status (attended_sponsor | registered_sponsor | attended), last_activity_date.
GET /api/tradeshows, /api/tradeshows/{show_id}/exhibitors,
/api/tradeshows/{show_id}/meeting_interest — exhibitor company_id, company_name,
description, booth, country, website, crm_account_id; meeting interest
interest_score, requested_demo, notes, keyed by company_name.
GET /api/import_batches, /api/import_batches/{batch_id}/raw_contacts,
/api/import_batches/{batch_id}/suppression.
The API hosts MANY events/shows/batches beyond the one named in a task. Always scope to the
exact event_id / show_id / batch_id in the prompt.
Cross-cutting normalization rules
- Email normalization: trim surrounding whitespace, lowercase the whole string. A blank
or whitespace-only email becomes
"". (Domain part is used for account matching.)
- Phone normalization: strip ALL non-digit characters; keep exactly the digits that
remain. Do NOT add or remove a country code.
"+1 415 555 0188" -> "14155550188";
"415-555-0188" -> "4155550188"; "212-555-0166" -> "2125550166"; "" -> "".
- Account existence / matching: an exhibitor row carries
crm_account_id directly. For
badges/imports, match on the email domain to a CRM account's domain (e.g.
dana.ruiz@helioware.example -> account with domain: helioware.example). Fall back to
company-name match only if no domain match exists.
- Contact existence: a contact "exists" if a CRM contact under the matched account has the
same person. Opted-out / "Former ..." contacts still exist as records but are not reusable
as the campaign contact — a new badge/import person is still
create_contact.
- Disqualified accounts: any account with
status == "disqualified" (it carries a
disqualified_reason) is excluded from qualified-lead handoff with reason
existing_disqualified.
Sponsor status classification (sponsor_handoff)
Enums: paid_deferred, open_invoice, proposal_only, not_sponsor.
For each sponsor package/order for the event:
order_status == "canceled" -> NOT an active sponsor. Treat as not_sponsor if the
template lists that account at all; otherwise drop it from sponsor_statuses entirely. Its
badge attendees are handled by the badge/lead rules (often existing_disqualified or a
plain non-sponsor), not as sponsor attendees.
order_status == "proposal_sent" AND no invoice exists -> proposal_only
(paid_amount 0, open_balance 0; invoice_id null).
order_status == "confirmed" with an invoice whose status == "paid_deferred" (paid in
full, payment deferred terms) -> paid_deferred.
order_status == "confirmed" with an invoice whose status == "open" (paid_amount <
amount) -> open_invoice; the open balance = amount - paid_amount.
package_amount / amount_usd = the package amount. For open invoices report
paid_amount and open_balance separately when the template asks.
Revenue rollups: sum package_amount per status into the by-status totals. Add a
separate open_invoice_balance = sum of open balances. "Unpaid sponsor total" = sum of
(open-invoice open balances) + (proposal_only amounts); unpaid sponsor account names are the
open_invoice and proposal_only accounts (sorted ascending). paid_deferred sponsors are fully
paid and NOT in the unpaid follow-up set.
Badge reconciliation & lead qualification (event tasks)
Classify each badge for the event:
- non-business badge (
badge_type in {student, press, ...} — anything not
attendee/sponsor, e.g. student, press, media, academic) -> classification excluded,
crm_action no_import, exclusion_reason non_business_badge.
- sponsor attendee: badge company matches an ACTIVE sponsor account (confirmed or
proposal, not canceled), or the person is a sponsor
ticket_contact -> classification
sponsor_attendee, exclusion_reason sponsor_attendee. CRM action:
- contact + campaign member already exist ->
no_action;
- account exists but contact missing ->
create_contact_campaign_member (and the campaign
member is created with target_status attended_sponsor).
- existing disqualified: badge company maps to a CRM account with
status == disqualified
-> excluded, reason existing_disqualified.
- missing contact: badge has neither a usable contact identity nor email/phone -> excluded,
reason
missing_contact.
- qualified non-sponsor lead: a business attendee whose account is not a sponsor and not
disqualified -> classification
qualified_non_sponsor_lead. CRM action depends on what
exists: no account -> create_account_contact_campaign_member; account exists, contact
missing -> create_contact_campaign_member; both exist -> add_campaign_member. New
campaign members for non-sponsor leads use target_status attended.
Campaign-member actions: existing campaign-member rows for the event keep their current
status and are no_action. New leads/sponsor-attendees needing a member row are create
with the target_status above. Use update only when an existing member's status must change.
Opportunity totals (non-sponsor leads): every qualified non-sponsor lead gets the event's
lead_opportunity_amount. lead_pipeline_total / open_opportunity_total_usd =
count_of_qualified_leads x lead_opportunity_amount. open_opportunity_count = number of
qualified non-sponsor leads.
badge_only_contacts = the people who need contact creation from badges (qualified
non-sponsor leads, plus sponsor attendees whose contact is missing). Provide normalized email
and phone (empty string when absent). Sort as the template dictates (usually company_name).
CRM action count rollups sum the per-lead actions:
accounts_create = leads needing a new account; accounts_update = qualified leads whose
account already exists; contacts_create = new contacts; campaign_members_create = new
member rows. *_update buckets are usually 0 unless an existing record must change.
Follow-up due dates and task counts
Read the event row:
lead_followup_due_date = end_date + followup_days_after_end (calendar days).
sponsor_finance_due_date / sponsor_followup_due_date = end_date +
sponsor_followup_days_after_end.
Example: end_date 2026-09-16, followup_days_after_end 7 -> 2026-09-23; sponsor 3 ->
2026-09-19. Compute by date arithmetic, do not guess.
- lead_task_count = number of qualified lead accounts to follow up (one task each).
- sponsor_finance_task_count = number of sponsors needing finance follow-up = open_invoice
- proposal_only sponsors.
sponsor_finance_accounts = those account names, sorted ascending.
Trade-show prospecting & qualification
Platform enums (ordered): AUV, ROV, Underwater Camera. Read each exhibitor's
description to decide qualification and platforms.
Qualified = the company itself MANUFACTURES / BUILDS / OEM-builds a target platform (AUV,
ROV, or underwater camera). Map description language to platforms (list in the enum order
above, including only the platforms the company actually builds):
- "builds/manufactures AUV / autonomous underwater vehicle / scout" ->
AUV
- "builds/inspection-class ROV / remotely operated vehicle" ->
ROV
- "designs/manufactures underwater camera modules / OEM camera" ->
Underwater Camera
- A company can have multiple platforms (e.g. "AUVs and ROVs" ->
["AUV","ROV"]).
Excluded near-misses (companies adjacent to the market but not platform builders). Pick the
controlled reason matching the template's allowed list:
- distributor / reseller / sales agent / "does not manufacture" ->
distributor_only
(relationship_type distributor).
- consulting / operates rented platforms / service team ->
service_only
(relationship_type service_provider).
- sensor-only vendor (e.g. dissolved-oxygen/salinity probes, no platform) ->
sensor_vendor_only
or sensor_only (use the exact enum the template lists; relationship_type sensor_vendor).
- research lab / university (no commercial build) ->
research_only (relationship_type research).
- KEY SUBTLETY: in a "sensor integration" campaign, the qualified leads are the PLATFORM
BUILDERS who would integrate the sensor, NOT the sensor vendors. The sensor vendor is an
excluded near-miss (
sensor_vendor_only/sensor_only). Analytics/software companies that
merely USE partner camera/ROV feeds and have "no hardware manufacturing" are service_only.
CRM action for exhibitors: if crm_account_id is non-null -> update_existing (it is an
existing-CRM overlap; collect its id for overlap counts). If null -> create_account.
Excluded exhibitors are no_import.
Priority tier + opportunity sizing (from meeting_interest, matched by company_name):
- Tier
A = requested_demo == true AND interest_score >= 90.
- Tier
B = requested_demo == true AND interest_score >= 80 (and not A).
- Tier
C = everything else qualified (no demo, or score < 80).
- Opportunity USD by tier when the prompt specifies (commonly A=120000, B=90000, C=50000) —
ALWAYS use the dollar amounts the current prompt states; the tier letters are stable but the
dollar mapping is task-specific.
Ranking qualified leads (when ranked output is required), apply in order:
requested_demo true before false; 2. interest_score descending;
- broader platform coverage (more platforms first); 4.
company_name ascending.
Assign 1-based contiguous rank.
Aggregates: qualified_total / qualified_lead_count = qualified count;
platform_counts / platform_coverage_counts = number of qualified companies that build each
platform (a multi-platform company counts in each of its platforms); priority_counts =
qualified companies per A/B/C; overlap count = qualified companies with a non-null
crm_account_id (list their account ids ascending); total_estimated_opportunity_usd = sum of
qualified opportunity estimates.
Raw contact-import hygiene (import_batch tasks)
Pipeline per raw row (then roll up). Process all rows; classify each into exactly one
disposition.
- Normalize email (trim+lowercase) and phone (digits-only) for every row.
- Usability / missing_contact: a row with no usable email AND no usable phone (and no
real contact identity) is unusable -> reason
missing_contact, action bucket no_import.
- Suppression: if the normalized email OR phone matches any row in the batch
/suppression list (any reason: global_opt_out, privacy_request, role_account) -> removed,
reason suppressed, action bucket suppress. Suppression matches by email/phone regardless
of the company name on the raw row.
- Deduplicate the remaining rows by identity key — primarily
email:<normalized_email>
(use phone key if no email). Within a duplicate group choose ONE winner:
- Winner = the row with the LATEST
captured_at.
- Tie-break (same
captured_at): higher source precedence wins. Observed precedence:
partner_upload / sponsor_form / exhibitor_form (curated) beat webinar_form /
badge_scan / manual_upload (self-serve). When in doubt prefer the
partner/sponsor/exhibitor-sourced row over a webinar/manual row.
- Losers -> removed, reason
duplicate, action bucket no_import. Record the
duplicate_keys entry: { key, winner_row_id, removed_row_ids[] }.
- Surviving winners become clean_contacts. For each, carry the winner row's fields
(company_name, contact_name, captured_at, source_name) and the normalized email/phone.
Decide
crm_action by matching the email domain to a CRM account:
- matched account exists ->
update_existing (set existing_account_id; set
existing_contact_id only if a matching CRM contact exists, else null).
- no matched account ->
create_account (existing_account_id = null,
existing_contact_id = null).
- (
no_import/suppress as clean_contact crm_action values are for rows you would still
list; usually removed rows are reported only under removal_summary.)
Rollups:
duplicate_removed_count = number of removed duplicate rows.
suppressed_removed_count = number of suppressed rows.
unusable_removed_count = number of missing_contact rows.
removed_rows = all removed rows {row_id, reason} (reason in {duplicate, missing_contact,
suppressed}), sorted by row_id.
import_action_totals counts EVERY raw row by final disposition:
create_account + update_existing = surviving winners; no_import = duplicates +
missing_contact; suppress = suppressed. The four buckets must sum to the total raw rows.
campaign_member_import_count = number of surviving clean_contacts (they all become campaign
members of the batch campaign).
clean_contact_id / source_row_id = the winning row's row_id.
Common output fields & conventions
- Ordering: sponsor_statuses / qualified leads / accounts by
account_name or company_name
ascending; excluded by company then contact name; badge_decisions by badge_id; clean
contacts by clean_contact_id; removed rows by row_id; duplicate keys by key; ranked
leads by rank.
- Use exact enum spellings from the template (
paid_deferred, open_invoice, proposal_only,
not_sponsor; create_account/update_existing/add_campaign_member/no_action/
no_import; sponsor_attendee/non_business_badge/existing_disqualified/missing_contact;
platform and tier enums). When two templates spell the same concept differently
(sensor_vendor_only vs sensor_only), use the one in the CURRENT template.
null vs "": account/contact/invoice IDs that don't exist are null; absent
email/phone strings are "".
Common misjudgments to avoid
- Counting canceled sponsor packages as active sponsors. Canceled = drop from sponsor revenue;
it is not paid/open/proposal.
- Treating a sensor vendor as a qualified prospect in a sensor-integration campaign. The
qualified leads BUILD the platform; the sensor maker is an excluded near-miss.
- Treating analytics/software/consulting firms that use partner hardware as qualified — they
are
service_only (no manufacturing).
- Forgetting that proposal_only sponsors are "unpaid" for finance follow-up (the full amount is
outstanding) even though there is no invoice yet.
- Adding/removing a country code during phone normalization — just keep the digits present.
- Reusing an opted-out / former CRM contact as the campaign contact instead of creating the
new badge/import person (account is update; contact is still create).
- Picking the wrong dedup winner — winner is the LATEST captured_at, then higher source
precedence; losers are
no_import duplicates, not deletions of the surviving record.
- Including disqualified, sponsor, non-business, or missing-contact records in the qualified
lead list — each has its own exclusion reason.
- Off-by-one on follow-up dates — add the day offset to
end_date exactly.
Step-by-step SOPs
A. Post-event sponsor reconciliation / CRM handoff
- GET the event; record end_date, lead_opportunity_amount, both follow-up day offsets.
- GET orders/sponsor_packages + invoices; classify each sponsor (canceled->drop;
proposal_sent->proposal_only; confirmed+paid_deferred->paid_deferred; confirmed+open->
open_invoice with balance). Build sponsor_statuses + revenue rollups.
- GET badges; classify each (non-business / sponsor attendee / existing disqualified /
missing contact / qualified non-sponsor lead) using accounts + contacts + campaign_members.
- Decide CRM actions per badge/lead (create/update/add member/no_action/no_import); build
badge_only_contacts with normalized email/phone.
- GET campaign_members; existing rows no_action, new ones create with target_status.
- Compute opportunity totals (count x lead_opportunity_amount), unpaid sponsor totals, both
follow-up due dates and task counts, exclusion counts, and CRM action-count rollups.
- Emit JSON in template order, sorted as required.
B. Trade-show prospecting / qualification
- GET exhibitors + meeting_interest for the show; GET CRM accounts.
- For each exhibitor read the description: qualified builder -> map platforms; else excluded
with controlled relationship/reason.
- CRM action: crm_account_id non-null -> update_existing (overlap); null -> create_account;
excluded -> no_import.
- Tier from demo + interest_score (A>=90 demo, B>=80 demo, else C); opportunity USD from the
prompt's tier mapping.
- Rank (demo, score desc, platform breadth, name) if required; compute aggregates
(qualified_total, platform/priority counts, overlap ids, total opportunity).
- Emit JSON in template order, sorted as required.
C. Raw contact-import hygiene
- GET raw_contacts + suppression for the batch; GET CRM accounts/contacts.
- Normalize email/phone for all rows.
- Remove unusable (missing_contact), then suppressed (email/phone match), then dedup by
email/phone key (winner = latest captured_at, tie-break source precedence).
- Surviving winners -> clean_contacts; set crm_action via domain match (update_existing with
existing_account_id, else create_account); existing_contact_id only if contact matches.
- Roll up removal counts, duplicate keys, import_action_totals (all rows summed), and
campaign_member_import_count (= survivors).
- Emit JSON in template order, sorted as required.
1---2name: harborcrm-front-of-funnel-43description: Solve HarborCRM front-of-funnel CRM/marketing tasks against the read-only HarborCRM JSON API: post-event sponsor reconciliation, finance/sponsor-status classification, trade-show prospecting & lead qualification, raw contact-import hygiene/dedup/suppression, badge reconciliation, follow-up due-date and task planning, and CRM create/update/no-action decisions. Use whenever a task references HarborCRM, an event_id, a trade-show show_id, an import batch, sponsor packages/invoices, badge scans, exhibitors, meeting interest, or asks for a JSON handoff/prospecting/import summary matching an answer_template.4---56# HarborCRM Front-of-Funnel Solver78HarborCRM is a shared read-only JSON CRM/marketing workspace. Tasks ask you to reconcile,9qualify, classify, and plan handoffs, then return exactly ONE JSON object that conforms to a10provided `input/payloads/answer_template.json`. The template is authoritative for shape,11key names, enum values, ordering, and which fields are required. The rules below explain HOW12to fill it correctly and WHY records get included/excluded.1314## Golden rules15161. Output JSON ONLY. No prose outside the JSON. Add no keys not in the template; include all17 required keys. Match enum spellings exactly.182. Obey every ordering rule in the template / prompt (usually a sort key, ascending). Sort19 deterministically; apply secondary sort keys when specified.203. Currency is integer USD. Counts are integers. Dates are `YYYY-MM-DD`.214. Derive everything from the live API data, not from memory. The `/api/policies` endpoint is22 intentionally sparse (just enum lists and notes) — the real rules come from the record23 contents and the conventions below.245. Never invent data. Empty/absent email or phone becomes `""` (empty string), and missing25 account/contact IDs become `null` (follow the template's stated type for each field).2627## API access2829Base URL is supplied by the runner (e.g. `http://127.0.0.1:8080` or `:8067`). Use `curl -s`.30All responses are JSON. Endpoints:3132- `GET /health` — sanity check + record counts.33- `GET /api/events`, `/api/events/{event_id}` — dates, `lead_opportunity_amount`,34 `followup_days_after_end`, `sponsor_followup_days_after_end`, `campaign_code`, `status`.35- `GET /api/events/{event_id}/orders` and `/sponsor_packages` — sponsor package rows with36 `account_id`, `amount`, `order_status` (confirmed | proposal_sent | canceled),37 `package_level`, `ticket_contacts`. (orders and sponsor_packages return the same rows.)38- `GET /api/events/{event_id}/badges` — badge scans: `badge_id`, `badge_type`,39 `company_name`, `contact_name`, `email`, `phone`, `scan_score`, `session_interest`.40- `GET /api/finance/invoices?event_id=<id>` (also `account_id=<id>`) — `invoice_id`, `amount`,41 `paid_amount`, `deferred_amount`, `status` (paid_deferred | open), `due_date`.42- `GET /api/crm/accounts` (filters `status=`, `owner_region=`) — `account_id`, `name`,43 `domain`, `status` (customer | prospect | disqualified), `disqualified_reason`.44- `GET /api/crm/contacts` (filter `account_id=`) — `contact_id`, `account_id`, `name`,45 `email`, `phone`, `opted_out`, `title`, `source_updated_at`.46- `GET /api/crm/opportunities` (filters `event_id=`, `account_id=`) — `amount`, `stage`.47- `GET /api/crm/campaign_members?event_id=<id>` (also `account_id=`) — `account_id`,48 `contact_id`, `status` (attended_sponsor | registered_sponsor | attended), `last_activity_date`.49- `GET /api/tradeshows`, `/api/tradeshows/{show_id}/exhibitors`,50 `/api/tradeshows/{show_id}/meeting_interest` — exhibitor `company_id`, `company_name`,51 `description`, `booth`, `country`, `website`, `crm_account_id`; meeting interest52 `interest_score`, `requested_demo`, `notes`, keyed by `company_name`.53- `GET /api/import_batches`, `/api/import_batches/{batch_id}/raw_contacts`,54 `/api/import_batches/{batch_id}/suppression`.5556The API hosts MANY events/shows/batches beyond the one named in a task. Always scope to the57exact `event_id` / `show_id` / `batch_id` in the prompt.5859## Cross-cutting normalization rules6061- **Email normalization:** trim surrounding whitespace, lowercase the whole string. A blank62 or whitespace-only email becomes `""`. (Domain part is used for account matching.)63- **Phone normalization:** strip ALL non-digit characters; keep exactly the digits that64 remain. Do NOT add or remove a country code. `"+1 415 555 0188"` -> `"14155550188"`;65 `"415-555-0188"` -> `"4155550188"`; `"212-555-0166"` -> `"2125550166"`; `""` -> `""`.66- **Account existence / matching:** an exhibitor row carries `crm_account_id` directly. For67 badges/imports, match on the **email domain** to a CRM account's `domain` (e.g.68 `dana.ruiz@helioware.example` -> account with `domain: helioware.example`). Fall back to69 company-name match only if no domain match exists.70- **Contact existence:** a contact "exists" if a CRM contact under the matched account has the71 same person. Opted-out / "Former ..." contacts still exist as records but are not reusable72 as the campaign contact — a new badge/import person is still `create_contact`.73- **Disqualified accounts:** any account with `status == "disqualified"` (it carries a74 `disqualified_reason`) is excluded from qualified-lead handoff with reason75 `existing_disqualified`.7677## Sponsor status classification (sponsor_handoff)7879Enums: `paid_deferred`, `open_invoice`, `proposal_only`, `not_sponsor`.8081For each sponsor package/order for the event:8283- `order_status == "canceled"` -> NOT an active sponsor. Treat as `not_sponsor` if the84 template lists that account at all; otherwise drop it from `sponsor_statuses` entirely. Its85 badge attendees are handled by the badge/lead rules (often `existing_disqualified` or a86 plain non-sponsor), not as sponsor attendees.87- `order_status == "proposal_sent"` AND no invoice exists -> `proposal_only`88 (paid_amount 0, open_balance 0; `invoice_id` null).89- `order_status == "confirmed"` with an invoice whose `status == "paid_deferred"` (paid in90 full, payment deferred terms) -> `paid_deferred`.91- `order_status == "confirmed"` with an invoice whose `status == "open"` (paid_amount <92 amount) -> `open_invoice`; the open balance = `amount - paid_amount`.93- `package_amount` / `amount_usd` = the package `amount`. For open invoices report94 `paid_amount` and `open_balance` separately when the template asks.9596**Revenue rollups:** sum `package_amount` per status into the by-status totals. Add a97separate `open_invoice_balance` = sum of open balances. "Unpaid sponsor total" = sum of98(open-invoice open balances) + (proposal_only amounts); unpaid sponsor account names are the99open_invoice and proposal_only accounts (sorted ascending). paid_deferred sponsors are fully100paid and NOT in the unpaid follow-up set.101102## Badge reconciliation & lead qualification (event tasks)103104Classify each badge for the event:105106- **non-business badge** (`badge_type` in {`student`, `press`, ...} — anything not107 `attendee`/`sponsor`, e.g. student, press, media, academic) -> classification `excluded`,108 crm_action `no_import`, exclusion_reason `non_business_badge`.109- **sponsor attendee**: badge company matches an ACTIVE sponsor account (confirmed or110 proposal, not canceled), or the person is a sponsor `ticket_contact` -> classification111 `sponsor_attendee`, exclusion_reason `sponsor_attendee`. CRM action:112 - contact + campaign member already exist -> `no_action`;113 - account exists but contact missing -> `create_contact_campaign_member` (and the campaign114 member is created with target_status `attended_sponsor`).115- **existing disqualified**: badge company maps to a CRM account with `status == disqualified`116 -> excluded, reason `existing_disqualified`.117- **missing contact**: badge has neither a usable contact identity nor email/phone -> excluded,118 reason `missing_contact`.119- **qualified non-sponsor lead**: a business attendee whose account is not a sponsor and not120 disqualified -> classification `qualified_non_sponsor_lead`. CRM action depends on what121 exists: no account -> `create_account_contact_campaign_member`; account exists, contact122 missing -> `create_contact_campaign_member`; both exist -> `add_campaign_member`. New123 campaign members for non-sponsor leads use target_status `attended`.124125**Campaign-member actions:** existing campaign-member rows for the event keep their current126`status` and are `no_action`. New leads/sponsor-attendees needing a member row are `create`127with the target_status above. Use `update` only when an existing member's status must change.128129**Opportunity totals (non-sponsor leads):** every qualified non-sponsor lead gets the event's130`lead_opportunity_amount`. `lead_pipeline_total` / `open_opportunity_total_usd` =131count_of_qualified_leads x lead_opportunity_amount. `open_opportunity_count` = number of132qualified non-sponsor leads.133134**badge_only_contacts** = the people who need contact creation from badges (qualified135non-sponsor leads, plus sponsor attendees whose contact is missing). Provide normalized email136and phone (empty string when absent). Sort as the template dictates (usually company_name).137138**CRM action count rollups** sum the per-lead actions:139`accounts_create` = leads needing a new account; `accounts_update` = qualified leads whose140account already exists; `contacts_create` = new contacts; `campaign_members_create` = new141member rows. `*_update` buckets are usually 0 unless an existing record must change.142143## Follow-up due dates and task counts144145Read the event row:146147- `lead_followup_due_date` = `end_date` + `followup_days_after_end` (calendar days).148- `sponsor_finance_due_date` / `sponsor_followup_due_date` = `end_date` +149 `sponsor_followup_days_after_end`.150151Example: end_date 2026-09-16, followup_days_after_end 7 -> 2026-09-23; sponsor 3 ->1522026-09-19. Compute by date arithmetic, do not guess.153154- **lead_task_count** = number of qualified lead accounts to follow up (one task each).155- **sponsor_finance_task_count** = number of sponsors needing finance follow-up = open_invoice156 + proposal_only sponsors. `sponsor_finance_accounts` = those account names, sorted ascending.157158## Trade-show prospecting & qualification159160Platform enums (ordered): `AUV`, `ROV`, `Underwater Camera`. Read each exhibitor's161`description` to decide qualification and platforms.162163**Qualified** = the company itself MANUFACTURES / BUILDS / OEM-builds a target platform (AUV,164ROV, or underwater camera). Map description language to platforms (list in the enum order165above, including only the platforms the company actually builds):166167- "builds/manufactures AUV / autonomous underwater vehicle / scout" -> `AUV`168- "builds/inspection-class ROV / remotely operated vehicle" -> `ROV`169- "designs/manufactures underwater camera modules / OEM camera" -> `Underwater Camera`170- A company can have multiple platforms (e.g. "AUVs and ROVs" -> `["AUV","ROV"]`).171172**Excluded near-misses** (companies adjacent to the market but not platform builders). Pick the173controlled reason matching the template's allowed list:174175- distributor / reseller / sales agent / "does not manufacture" -> `distributor_only`176 (relationship_type `distributor`).177- consulting / operates rented platforms / service team -> `service_only`178 (relationship_type `service_provider`).179- sensor-only vendor (e.g. dissolved-oxygen/salinity probes, no platform) -> `sensor_vendor_only`180 or `sensor_only` (use the exact enum the template lists; relationship_type `sensor_vendor`).181- research lab / university (no commercial build) -> `research_only` (relationship_type `research`).182- KEY SUBTLETY: in a "sensor integration" campaign, the qualified leads are the PLATFORM183 BUILDERS who would integrate the sensor, NOT the sensor vendors. The sensor vendor is an184 excluded near-miss (`sensor_vendor_only`/`sensor_only`). Analytics/software companies that185 merely USE partner camera/ROV feeds and have "no hardware manufacturing" are `service_only`.186187**CRM action for exhibitors:** if `crm_account_id` is non-null -> `update_existing` (it is an188existing-CRM overlap; collect its id for overlap counts). If null -> `create_account`.189Excluded exhibitors are `no_import`.190191**Priority tier + opportunity sizing** (from meeting_interest, matched by company_name):192193- Tier `A` = `requested_demo == true` AND `interest_score >= 90`.194- Tier `B` = `requested_demo == true` AND `interest_score >= 80` (and not A).195- Tier `C` = everything else qualified (no demo, or score < 80).196- Opportunity USD by tier when the prompt specifies (commonly A=120000, B=90000, C=50000) —197 ALWAYS use the dollar amounts the current prompt states; the tier letters are stable but the198 dollar mapping is task-specific.199200**Ranking qualified leads** (when ranked output is required), apply in order:2011. `requested_demo` true before false; 2. `interest_score` descending;2023. broader platform coverage (more platforms first); 4. `company_name` ascending.203Assign 1-based contiguous `rank`.204205**Aggregates:** `qualified_total` / `qualified_lead_count` = qualified count;206`platform_counts` / `platform_coverage_counts` = number of qualified companies that build each207platform (a multi-platform company counts in each of its platforms); `priority_counts` =208qualified companies per A/B/C; overlap count = qualified companies with a non-null209`crm_account_id` (list their account ids ascending); `total_estimated_opportunity_usd` = sum of210qualified opportunity estimates.211212## Raw contact-import hygiene (import_batch tasks)213214Pipeline per raw row (then roll up). Process all rows; classify each into exactly one215disposition.2162171. **Normalize** email (trim+lowercase) and phone (digits-only) for every row.2182. **Usability / missing_contact:** a row with no usable email AND no usable phone (and no219 real contact identity) is unusable -> reason `missing_contact`, action bucket `no_import`.2203. **Suppression:** if the normalized email OR phone matches any row in the batch221 `/suppression` list (any reason: global_opt_out, privacy_request, role_account) -> removed,222 reason `suppressed`, action bucket `suppress`. Suppression matches by email/phone regardless223 of the company name on the raw row.2244. **Deduplicate** the remaining rows by identity key — primarily `email:<normalized_email>`225 (use phone key if no email). Within a duplicate group choose ONE winner:226 - Winner = the row with the LATEST `captured_at`.227 - Tie-break (same `captured_at`): higher source precedence wins. Observed precedence:228 `partner_upload` / `sponsor_form` / `exhibitor_form` (curated) beat `webinar_form` /229 `badge_scan` / `manual_upload` (self-serve). When in doubt prefer the230 partner/sponsor/exhibitor-sourced row over a webinar/manual row.231 - Losers -> removed, reason `duplicate`, action bucket `no_import`. Record the232 `duplicate_keys` entry: `{ key, winner_row_id, removed_row_ids[] }`.2335. **Surviving winners become clean_contacts.** For each, carry the winner row's fields234 (company_name, contact_name, captured_at, source_name) and the normalized email/phone.235 Decide `crm_action` by matching the email domain to a CRM account:236 - matched account exists -> `update_existing` (set `existing_account_id`; set237 `existing_contact_id` only if a matching CRM contact exists, else `null`).238 - no matched account -> `create_account` (`existing_account_id` = null,239 `existing_contact_id` = null).240 - (`no_import`/`suppress` as clean_contact crm_action values are for rows you would still241 list; usually removed rows are reported only under removal_summary.)242243**Rollups:**244- `duplicate_removed_count` = number of removed duplicate rows.245- `suppressed_removed_count` = number of suppressed rows.246- `unusable_removed_count` = number of missing_contact rows.247- `removed_rows` = all removed rows `{row_id, reason}` (reason in {duplicate, missing_contact,248 suppressed}), sorted by `row_id`.249- `import_action_totals` counts EVERY raw row by final disposition:250 `create_account` + `update_existing` = surviving winners; `no_import` = duplicates +251 missing_contact; `suppress` = suppressed. The four buckets must sum to the total raw rows.252- `campaign_member_import_count` = number of surviving clean_contacts (they all become campaign253 members of the batch campaign).254- `clean_contact_id` / `source_row_id` = the winning row's `row_id`.255256## Common output fields & conventions257258- Ordering: sponsor_statuses / qualified leads / accounts by `account_name` or `company_name`259 ascending; excluded by company then contact name; badge_decisions by `badge_id`; clean260 contacts by `clean_contact_id`; removed rows by `row_id`; duplicate keys by `key`; ranked261 leads by `rank`.262- Use exact enum spellings from the template (`paid_deferred`, `open_invoice`, `proposal_only`,263 `not_sponsor`; `create_account`/`update_existing`/`add_campaign_member`/`no_action`/264 `no_import`; `sponsor_attendee`/`non_business_badge`/`existing_disqualified`/`missing_contact`;265 platform and tier enums). When two templates spell the same concept differently266 (`sensor_vendor_only` vs `sensor_only`), use the one in the CURRENT template.267- `null` vs `""`: account/contact/invoice IDs that don't exist are `null`; absent268 email/phone strings are `""`.269270## Common misjudgments to avoid271272- Counting canceled sponsor packages as active sponsors. Canceled = drop from sponsor revenue;273 it is not paid/open/proposal.274- Treating a sensor vendor as a qualified prospect in a sensor-integration campaign. The275 qualified leads BUILD the platform; the sensor maker is an excluded near-miss.276- Treating analytics/software/consulting firms that use partner hardware as qualified — they277 are `service_only` (no manufacturing).278- Forgetting that proposal_only sponsors are "unpaid" for finance follow-up (the full amount is279 outstanding) even though there is no invoice yet.280- Adding/removing a country code during phone normalization — just keep the digits present.281- Reusing an opted-out / former CRM contact as the campaign contact instead of creating the282 new badge/import person (account is update; contact is still create).283- Picking the wrong dedup winner — winner is the LATEST captured_at, then higher source284 precedence; losers are `no_import` duplicates, not deletions of the surviving record.285- Including disqualified, sponsor, non-business, or missing-contact records in the qualified286 lead list — each has its own exclusion reason.287- Off-by-one on follow-up dates — add the day offset to `end_date` exactly.288289## Step-by-step SOPs290291### A. Post-event sponsor reconciliation / CRM handoff2921. GET the event; record end_date, lead_opportunity_amount, both follow-up day offsets.2932. GET orders/sponsor_packages + invoices; classify each sponsor (canceled->drop;294 proposal_sent->proposal_only; confirmed+paid_deferred->paid_deferred; confirmed+open->295 open_invoice with balance). Build sponsor_statuses + revenue rollups.2963. GET badges; classify each (non-business / sponsor attendee / existing disqualified /297 missing contact / qualified non-sponsor lead) using accounts + contacts + campaign_members.2984. Decide CRM actions per badge/lead (create/update/add member/no_action/no_import); build299 badge_only_contacts with normalized email/phone.3005. GET campaign_members; existing rows no_action, new ones create with target_status.3016. Compute opportunity totals (count x lead_opportunity_amount), unpaid sponsor totals, both302 follow-up due dates and task counts, exclusion counts, and CRM action-count rollups.3037. Emit JSON in template order, sorted as required.304305### B. Trade-show prospecting / qualification3061. GET exhibitors + meeting_interest for the show; GET CRM accounts.3072. For each exhibitor read the description: qualified builder -> map platforms; else excluded308 with controlled relationship/reason.3093. CRM action: crm_account_id non-null -> update_existing (overlap); null -> create_account;310 excluded -> no_import.3114. Tier from demo + interest_score (A>=90 demo, B>=80 demo, else C); opportunity USD from the312 prompt's tier mapping.3135. Rank (demo, score desc, platform breadth, name) if required; compute aggregates314 (qualified_total, platform/priority counts, overlap ids, total opportunity).3156. Emit JSON in template order, sorted as required.316317### C. Raw contact-import hygiene3181. GET raw_contacts + suppression for the batch; GET CRM accounts/contacts.3192. Normalize email/phone for all rows.3203. Remove unusable (missing_contact), then suppressed (email/phone match), then dedup by321 email/phone key (winner = latest captured_at, tie-break source precedence).3224. Surviving winners -> clean_contacts; set crm_action via domain match (update_existing with323 existing_account_id, else create_account); existing_contact_id only if contact matches.3245. Roll up removal counts, duplicate keys, import_action_totals (all rows summed), and325 campaign_member_import_count (= survivors).3266. Emit JSON in template order, sorted as required.