CRM enrichment
Clean up and enrich a messy CRM: fill the missing identifiers on accounts and contacts first (LinkedIn, domain, email), write them back into blank fields only, then deduplicate on those identifiers with crm-deduplication. Keeps records filled and refreshes them when they go stale.
Set up the crm-enrichment cookbook in this project.
1. If the Cargo CLI is not installed yet, follow every step in https://api.getcargo.io/INSTALL.md
2. From the Cargo project, run: cargo-ai cdk add cookbook/crm-enrichment
3. Then follow .claude/skills/crm-enrichment/SKILL.md. Stop for my approval before any paid call, and stop at "cargo-ai project plan" before deploying anything.$cargo-ai cdk add cookbook/crm-enrichmentSay this to your agent
Keep our HubSpot contacts filled: find the missing email or LinkedIn profile, then fill job title, but never overwrite a value a rep already entered.
Illustrative output #
Fictional records, to show the shape of what comes back.
| Contact (hs_object_id) | Started with | Fields filled | cargo_enrichment_status |
|---|---|---|---|
| Priya Raman (51007) | LinkedIn only | email priya.raman@northwind.example, linkedin_person_id, jobtitle VP RevOps | succeeded |
| Tomás Ortega (51022) | Email only | linkedin_profile_url, linkedin_person_id, jobtitle Head of Sales Ops | succeeded |
| Mei Lin (51040) | Email and LinkedIn | jobtitle left as entered; linkedin_person_id filled | succeeded |
| Sam Hale (51055) | Email only | nothing: no profile resolved | (not stamped) |
Every write fills blanks only, and cargo_last_enriched_at is stamped only after a successful write, so
a contact where nothing resolved, like Sam Hale, is never marked enriched.
What you will be asked #
| Input | Why | |
|---|---|---|
| crm_shape | Looked up | Determines objects, record IDs, destinations, and write behavior |
| selected_paths | Looked up | Sets which resources, audits, approvals, and pilots are in scope |
| account_provider_schema | Looked up | Prevents guessed output paths and types |
| account_field_contract | Asked | Controls every company mutation |
| account_population | Looked up | Sets account scope and spend |
| account_run | Asked | Authorizes only the account pilot |
| contact_tool_contracts | Looked up | Prevents guessed inputs, output paths, and prices |
| contact_field_contract | Asked | Controls every contact mutation |
| contact_population | Looked up | Sets contact scope and spend |
| contact_run | Asked | Authorizes only the contact pilot |
Done when #
- The approved account field contract names every destination, type, transformation, and write policy.
- The plan contains one account model, one account enrichment tool, and one disabled account play.
- The compiled graph proves one mutually exclusive company provider route and one play-owned CRM write.
- The account write matches the audited company record ID and preserves populated business values.
- The account write probe and approved pilot pass.
- The account report includes route, processed, written, skipped, failed, fill-rate, spend, and direct-link evidence.
- The approved contact field contract names every destination, type, transformation, and write policy.
- Native tool UUIDs and live schemas are verified and placeholders are gone.
- The plan contains one contact model, exactly three contact tool targets, and one disabled contact play.
- The compiled graph proves both native calls are gated and every custom enrichment call has a LinkedIn URL.
- Unresolved and identifier-free rows stop without custom enrichment, CRM write, or successful freshness.
- The contact write probe and approved pilot pass.
- The contact report includes route, processed, written, unresolved, skipped, failed, fill-rate, spend, and direct-link evidence.
What it costs #
Fetch live prices from the workspace before every preview. Cost only the selected paths. A failed CRM write can cause provider calls to be billed again on retry, so probe write capability before a batch.
Path 1: Account cost
Quote both LinkedIn company actions. Multiply the mutually exclusive LinkedIn URL and domain fallback populations by their respective live unit prices. Rows with neither identifier make no paid call.
Path 2: Contact cost
Quote both Cargo-native tools and the LinkedIn profile enrichment action. Per contact, the maximum paid chain is:
- LinkedIn only: Find Email plus Contact LinkedIn Enrichment.
- Email only: Find LinkedIn Profile from Email plus Contact LinkedIn Enrichment when resolution succeeds.
- Both identifiers: Contact LinkedIn Enrichment only.
- Neither identifier: no paid call.
Show mutually exclusive route counts and maximum spend before requesting approval for either path.
To clean up a messy CRM, fill the missing identifiers first (LinkedIn, domain, email), then
deduplicate on them. This cookbook is the first step: it fills blank identity fields on CRM
accounts and contacts and writes them back without overwriting a value a rep entered.
crm-deduplication is the second: it merges duplicates on the
identifiers this one filled.
cargo-ai cdk add cookbook/crm-enrichment # 1. fill identifiers, write back into blanks only
cargo-ai cdk add cookbook/crm-deduplication # 2. then merge duplicates on those identifiers
Why that order: deduplication matches accounts on LinkedIn company ID, LinkedIn company page and domain, and contacts on LinkedIn person ID, LinkedIn URL, email and phone. A record missing all of them cannot be matched, so deduplicating first misses exactly the records enrichment would have made matchable.
This pipeline has two independent paths. Deploy the account path, the contact path, or both. Each path has its own model, tool contract, play, field approval, cost preview, pilot, and acceptance checklist. Both paths fill approved CRM blanks and stamp freshness only after a successful write.
Path 1: Account enrichment #
The account path keeps approved company identity and firmographic fields filled. It uses LinkedIn company identity first and falls back to the company domain. A row takes only one provider route.
flowchart LR
account["CRM account"] --> identifier{"LinkedIn present?"}
identifier -->|yes| linkedin["LinkedIn company enrichment"]
identifier -->|no, domain present| domain["Domain company enrichment"]
identifier -->|no identifiers| stop["Stop without write"]
linkedin --> write["Fill approved company blanks<br/>stamp freshness"]
domain --> write
Account resources
| Resource | Kind | Purpose |
|---|---|---|
crm_accounts | Model | Direct CRM company extract |
account_enrichment | Custom tool | Select one company enrichment route without CRM access |
enrich_accounts | Play | Fill approved company blanks and stamp freshness |
The starting HubSpot mapping covers linkedin_company_id, name, domain, website,
linkedin_company_page, and numberofemployees. Every business-field write is blank-only. The
play runs directly on the CRM extract and matches the CRM record ID.
Path 2: Contact enrichment #
The contact path is one play with three gated tool resources:
- Cargo-native Find Email
- Cargo-native Find LinkedIn Profile from Email
- Custom Contact LinkedIn Enrichment
flowchart LR
contact["CRM contact"] --> linkedin{"LinkedIn present?"}
linkedin -->|yes, email blank| emailTool["Find Email<br/>Cargo-native"]
linkedin -->|yes, email present| profileTool["Contact LinkedIn Enrichment<br/>custom"]
emailTool --> profileTool
linkedin -->|no, email present| linkedinTool["Find LinkedIn Profile from Email<br/>Cargo-native"]
linkedinTool -->|profile found| profileTool
linkedinTool -->|not found| stop["Stop without write"]
linkedin -->|no identifiers| stop
profileTool --> write["Fill approved contact blanks<br/>stamp freshness"]
Contact resources
| Resource | Kind | Purpose |
|---|---|---|
crm_contacts | Model | Direct CRM contact extract |
| Find Email | Cargo-native tool | Resolve an email for a known LinkedIn profile |
| Find LinkedIn Profile from Email | Cargo-native tool | Resolve a LinkedIn profile for a known email |
contact_linkedin_enrichment | Custom tool | Enrich a known LinkedIn profile without CRM access |
enrich_contacts | Play | Gate the three contact tools and fill approved contact blanks |
Contact route behavior
| Starting state | Native lookup | Custom enrichment | CRM write |
|---|---|---|---|
| Email and LinkedIn | None | Yes | Yes |
| LinkedIn only | Find Email | Yes | Yes |
| Email only | Find LinkedIn Profile from Email | Only if a profile resolves | Only if a profile resolves |
| Neither | None | No | No |
Explicit branches compile the custom tool into three mutually exclusive call nodes. They all target
the same contact_linkedin_enrichment resource. Successful writes fill only blank email,
linkedin_person_id, linkedin_profile_url, and jobtitle values, then stamp
cargo_last_enriched_at and cargo_enrichment_status=succeeded.
This path contains no customer split, movement detection, relationship mutation, note creation, or alerting.
Install and adapt #
From a Cargo CDK project:
cargo-ai cdk add cookbook/crm-enrichment
Shared setup:
- Reconcile the default CRM and LinkedIn connectors with resources already in the project.
- Choose the account path, contact path, or both. Audit and approve each selected path separately.
- Confirm the CRM extract, record ID, destinations, blank-only behavior, and provider schemas for each selected path.
- Typecheck, inspect the compiled graphs, and deploy the selected plays disabled.
Contact-only setup:
- Instantiate Cargo-native Find Email and Find LinkedIn Profile from Email.
- Replace both
REPLACE-WITH-...-TOOL-UUIDvalues ininfra/plays/enrich-contacts.ts. - Confirm the native tool contracts. The example expects
emailandlinkedin_urloutputs.
The checked example targets HubSpot. Salesforce and Attio require adapting the connector, extracts, record ID fields, write action, and blank-only guard. Keep one CRM shape across both selected paths.
Verify #
npm run typecheck
node scripts/check-pipelines.mjs
node --import tsx crm-enrichment/evals/contract.mjs
Run a one-record write probe for each selected path before any paid batch. See the audit contract, configuration guide, runbook, and two-path acceptance checklist.