Your GTM belongs in gitRegister
Cookbooks
CRM

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.

Installation
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.
Paste it into Claude Code, Cursor, Codex, Gemini CLI, Devin or Replicas.

Say 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 withFields filledcargo_enrichment_status
Priya Raman (51007)LinkedIn onlyemail priya.raman@northwind.example, linkedin_person_id, jobtitle VP RevOpssucceeded
Tomás Ortega (51022)Email onlylinkedin_profile_url, linkedin_person_id, jobtitle Head of Sales Opssucceeded
Mei Lin (51040)Email and LinkedInjobtitle left as entered; linkedin_person_id filledsucceeded
Sam Hale (51055)Email onlynothing: 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 #

InputWhy
crm_shapeLooked upDetermines objects, record IDs, destinations, and write behavior
selected_pathsLooked upSets which resources, audits, approvals, and pilots are in scope
account_provider_schemaLooked upPrevents guessed output paths and types
account_field_contractAskedControls every company mutation
account_populationLooked upSets account scope and spend
account_runAskedAuthorizes only the account pilot
contact_tool_contractsLooked upPrevents guessed inputs, output paths, and prices
contact_field_contractAskedControls every contact mutation
contact_populationLooked upSets contact scope and spend
contact_runAskedAuthorizes 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.

shellscript
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

ResourceKindPurpose
crm_accountsModelDirect CRM company extract
account_enrichmentCustom toolSelect one company enrichment route without CRM access
enrich_accountsPlayFill 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:

  1. Cargo-native Find Email
  2. Cargo-native Find LinkedIn Profile from Email
  3. 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

ResourceKindPurpose
crm_contactsModelDirect CRM contact extract
Find EmailCargo-native toolResolve an email for a known LinkedIn profile
Find LinkedIn Profile from EmailCargo-native toolResolve a LinkedIn profile for a known email
contact_linkedin_enrichmentCustom toolEnrich a known LinkedIn profile without CRM access
enrich_contactsPlayGate the three contact tools and fill approved contact blanks

Contact route behavior

Starting stateNative lookupCustom enrichmentCRM write
Email and LinkedInNoneYesYes
LinkedIn onlyFind EmailYesYes
Email onlyFind LinkedIn Profile from EmailOnly if a profile resolvesOnly if a profile resolves
NeitherNoneNoNo

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:

shellscript
cargo-ai cdk add cookbook/crm-enrichment

Shared setup:

  1. Reconcile the default CRM and LinkedIn connectors with resources already in the project.
  2. Choose the account path, contact path, or both. Audit and approve each selected path separately.
  3. Confirm the CRM extract, record ID, destinations, blank-only behavior, and provider schemas for each selected path.
  4. Typecheck, inspect the compiled graphs, and deploy the selected plays disabled.

Contact-only setup:

  1. Instantiate Cargo-native Find Email and Find LinkedIn Profile from Email.
  2. Replace both REPLACE-WITH-...-TOOL-UUID values in infra/plays/enrich-contacts.ts.
  3. Confirm the native tool contracts. The example expects email and linkedin_url outputs.

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 #

shellscript
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.

Give your agents a runtime

Bring the agents you have.Start free, deploy in one command.