agents/execution-plan-creator.md
# Agent — Execution Plan Creator
Sub-agent for `cargo-gtm`. Takes a user goal and returns a step-by-step plan citing **specific provider + action slugs** with cost estimates.
## When to invoke this agent
- The user's goal touches multiple stages (sourcing → enrichment → verification → sequencing) and the right path isn't obvious.
- The user asks "what would this cost?" or wants a budget estimate before executing.
- A recipe doesn't perfectly match — you need to compose a custom chain.
For goals matching an existing recipe in `../recipes/`, **use the recipe directly** — don't invoke this agent.
## What this agent produces
A structured plan with:
1. **Goal restatement** — one sentence confirming intent.
2. **Assumptions defined operationally** — every judgment call spelled out as a testable rule (not "best contact" but "highest-ranked current employee matching RevOps/GTM-ops titles, weighted Chief > VP > Head > Director > Lead > Manager"), plus data decisions already made and the cost trade-off chosen.
3. **Stage breakdown** — each step labelled with stage (SOURCE / DEDUPE / ENRICH / SIGNAL / CONTACT / VERIFY / BACKFILL / WRITE-BACK / SEQUENCE / SYNC), with provider + action slug + cost per step — anchored in the priority stack where possible; long-tail providers only when priority can't serve the criteria.
4. **Sample step + budget reconciliation** — the plan's first executed step is always a sample: 1–3 rows when the plan is a single action, **10–20 records when any step fans out as a batch** (a 2-row sample can't produce a hit-rate, and hit-rate drives the estimate). The full-run estimate is grounded in the sample's observed per-row cost, states **how many records** the full run enrolls, and is reconciled against the actual balance (`billing subscription get`). If the estimate exceeds the balance, the plan says so up front.
5. **Approval question with 3 shaped choices** — run-until-cap / top-up-then-run / trim-scope-to-fit (with a proposed trimming heuristic). Never bare yes/no.
6. **Open questions for the user** — anything ambiguous (segment source, contact volume per company, write-back destination).
## Plan template
```
GOAL: <one sentence>
ASSUMPTIONS (operational definitions — anything the user should confirm):
- Volume: ~N records
- ICP: <one-line>
- "<judgment call>" = <testable rule>
- Dropped/fixed in the input: <rows dropped and why, domains corrected>
- Cost trade-off: <e.g. cheap email chain (0.14 cr) over premium play (1.4 cr) — why>
- Output: <model write-back / CSV / CRM push>
PLAN:
Step 0 — SAMPLE (always first)
Run steps 1–N on a slice of the exact input:
1–3 rows for a single action · 10–20 records before any batch.
Report: credits spent, per-row cost, hit-rate, output preview.
Then ask to enroll the rest — stating the record count AND the estimate.
Step 1 — SOURCE
Provider: salesNavigator.searchAccounts (priority)
Cost: 0.05 × N = X credits
Why this provider: ...
Step 2 — DEDUPE
Provider: storage query against the existing Companies model (free)
Cost: 0 — a read, not an action
Step 3 — ENRICH (firmographics)
Provider: aiArk.enrichCompany (priority)
Cost: 0.01 × N = X credits
Fallback for thin/empty rows: companyEnrich.enrichByDomain (0.25 × M),
then waterfall.enrichCompany (1 × M2 = Y credits)
... (steps continue)
TOTAL BUDGET: ~X credits for N records (catalog estimate — refine from the sample's observed per-row cost)
BALANCE CHECK: remaining credits = subscriptionAvailableCreditsCount − subscriptionCreditsUsedCount
→ covers the run? If short, say by how much BEFORE running.
APPROVE FULL RUN? (pick one)
1. Run until the cap — ~K of N rows fit the current balance; resumable, keeps successful rows.
2. Top up first, then run all N clean.
3. Trim to the best ~M rows so the budget covers everything (heuristic: <e.g. funded + RevOps ≥ 2 first>).
OPEN QUESTIONS:
- Should we cap contacts per company at K?
- Verify priority providers are connected: <providers>
```
## Provider-selection heuristics
When choosing between providers for a stage, the agent applies these rules in order:
1. **Match the priority stack first.** If salesNavigator / cargo / aiArk / waterfall / FullEnrich / apolloio / theirStack / peopleDataLabs can express the user's filter, use them. With a **LinkedIn URL** in hand, `aiArk.enrichPerson` (0.1, profile + verified email) is the cheapest enrich rung in the stack; `apolloio` (1) is the niche-coverage rung, planned on the residue, not the full list.
2. **Pick by stage-action-map.** If the priority stack misses, consult [`../references/stage-action-map.md`](../references/stage-action-map.md) for the cheapest credible alternative.
3. **Consider rate limits & coverage**. Some providers have low rate limits (~10 RPS); for large batches > 1000 records, prefer providers with higher throughput.
4. **Confirm authentication.** Run `cargo-ai connection connector list --integration-slug <slug>` to confirm the provider is authenticated before locking it into the plan. If not, surface to the user.
## Cost discipline
The plan IS the approval gate: pilot first, estimate reconciled against the balance, 3 shaped choices, and no paid fan-out until the user picks one. Full rules (receipts, 1.4×N over-provision, count-first sizing, the phone guard): [`../references/cost-discipline.md`](../references/cost-discipline.md).
## Action shape rule (critical)
Every recipe step must use the canonical action shape: `{"kind":"connector","integrationSlug":"<slug>","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.** See [`../../cargo-orchestration/references/examples/actions.md`](../../cargo-orchestration/references/examples/actions.md).
## Output retrieval
Final step of every plan ends with `cargo-ai orchestration run download-outputs --workflow-uuid <uuid> --output-node-slug <slug>` — the canonical way to retrieve action results. See [`../references/output-retrieval.md`](../references/output-retrieval.md).
agents/list-builder.md
# Agent — List Builder
Sub-agent for `cargo-gtm`. Executes **one bounded slice** of a sourcing job — a single pre-approved search or lookup against one segment of the criteria — and returns raw structured rows. Spawn several in parallel to sweep a wide criteria space (per-industry, per-geo, per-title-band) without burning the main session's context on row data.
## When to invoke this agent
- A sourcing task fans out naturally (e.g. "RevOps leaders in fintech, healthtech, and logistics" → three parallel slices).
- The pilot has already passed the cost gate and the full run was approved — fan-out is an *execution* pattern, never a way around the pilot.
- The row data would drown the main conversation; the parent only needs the merged file back.
**Never** invoke this agent to *decide* what to search — composition and budgeting belong to the parent (or to [`execution-plan-creator.md`](execution-plan-creator.md)).
## Contract with the parent
The invoking prompt MUST specify, and this agent MUST NOT exceed:
1. **The exact action** — full `{"kind":"connector","integrationSlug":"…","actionSlug":"…"}` JSON and the records/filters payload.
2. **The row cap** — the `limit` to pass and the maximum rows to return.
3. **The credit budget for this slice** — if an executed call reports more spend than budgeted, stop immediately and report; never continue.
4. **The output destination** — a file path to write raw JSON rows to (the agent's reply carries only counts + the path, never row dumps).
## Rules
- Execute ONLY the command(s) given. If the assigned action errors twice, stop and report the exact `errorMessage` — do not substitute a different provider (that's a parent decision with cost implications).
- Poll with `--wait-until-finished`; retrieve data with `run download-outputs` (never `run download`).
- Return shape: `{sliceLabel, rowsFound, creditsSpent, outputPath, errors[]}` — machine-readable, no prose narrative.
- No enrichment, no verification, no personalization — sourcing rows only. The parent chains the rest (and the QA scripts in [`../references/contact-accuracy.md`](../references/contact-accuracy.md)).
- Deduplicate rows within the slice on the natural key (company domain or LinkedIn URL) before writing.
## Cost posture
This agent is deliberately cheap to run (small model, few turns) because it makes **zero judgment calls**: every credit it spends was approved before it was spawned. If anything is ambiguous, the correct behavior is to stop and return the question — an unasked question costs one round-trip; an improvised paid call costs real credits.
guides/enriching-and-researching.md
# Enriching and researching
How to enrich companies and contacts on Cargo. Covers waterfall enrichment, fallback chains, signal extraction, and output retrieval.
## Default chain by enrichment goal
```
Goal → which provider chain?
Firmographics on a known company (industry, size, geo, revenue, …)?
├─ aiArk.enrichCompany (0.01) — domain or LinkedIn URL, cheapest in catalog
├─ Thin result: companyEnrich.enrichByDomain (0.25) — fuller field set
├─ Fallback: waterfall.enrichCompany (1) / apolloio.enrichOrganization (1)
└─ Heavy backfill: peopleDataLabs.enrichCompany (3)
Contact details on a known person (title, location, social, …)?
├─ LinkedIn URL in hand: aiArk.enrichPerson (0.1) — profile + verified email, bills 0 on no-email
├─ No URL: waterfall.enrichContact (2) — keys on email or name + company
├─ Niche coverage (investor-backed, portfolio): apolloio.enrichPerson (1)
└─ Heavy backfill: peopleDataLabs.enrichPerson (3)
Find an email?
├─ LinkedIn URL in hand: aiArk.enrichPerson (0.1) — email comes with the profile
├─ From name + company: FullEnrich.findEmail (1) ← default
├─ Cheap fallback: hunter.findEmail (0.5) / icypeas.findEmail (0.1)
└─ Last resort: peopleDataLabs.enrichPerson (3, includes email)
Verify an email?
├─ waterfall.verifyEmail (0.1) ← default (cheap, multi-source)
└─ Alt: zeroBounce.verifyEmail (0.1) / icypeas.verifyEmail (0.01)
Find a phone number?
├─ aiArk.findMobilePhone (0.5) ← first rung; mobile-only, bills 0 on a miss
├─ Landline/DID fallback: prospeo.findPhone (3)
├─ FullEnrich.findPhone (6) ← higher quality
└─ Combined: FullEnrich.findPhoneAndEmail (7) when both are needed
Resolve a LinkedIn URL from name + company?
└─ linkedin.findProfileUrl (0.25) → linkedin.enrichProfile (0.25) for validation
See `../recipes/linkedin-url-lookup.md` for the strict-validation pattern.
Funding / acquisition signals?
└─ enrichCrm.getFunding (1) — only credits-based funding action in the catalog
Tech stack / hiring intent?
├─ builtwith.getDomainSummary (0) — free, always run first on a known domain
├─ theirStack.searchTechnologies (0.5) for catalog-style lookup
├─ builtwith.enrichDomain (1) on the rows the free summary didn't settle
└─ theirStack.searchJobs (0.5) for hiring-intent
Job change detection?
└─ waterfall.detectJobChange (3) — only credits-based action of this kind in catalog
Reverse-email lookup (email → person + company)?
├─ aiArk.reverseLookup (0.05) — email *or* phone → full profile
└─ FullEnrich.reverseEmailLookup (2) — email → LinkedIn URL
```
## Waterfall enrichment pattern
When one provider misses, escalate to the next. Run each step only on the rows where the prior step came up empty.
```bash
# Step 1 — try aiArk first (0.01, cheapest company enrich in the catalog)
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"enrichCompany"}' \
--records '[{"domain":"acme.com"}, ... ]' \
--wait-until-finished > /tmp/step1.json
# Step 2 — extract rows where step 1 returned no firmographics, retry with waterfall
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"enrichCompany"}' \
--records '<rows from step 1 where firmographics empty>' \
--wait-until-finished > /tmp/step2.json
# Step 3 — last-resort backfill with peopleDataLabs (3 credits flat)
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"peopleDataLabs","actionSlug":"enrichCompany"}' \
--records '<rows still empty after step 2>' \
--wait-until-finished > /tmp/step3.json
# Step 4 — coalesce all three into a single enriched dataset
```
Same shape applies for person enrichment (`aiArk.enrichPerson` where a LinkedIn URL exists → `waterfall.enrichContact` → `apolloio.enrichPerson` → `peopleDataLabs.enrichPerson`) and for email lookup (`FullEnrich.findEmail` → `hunter.findEmail` → `peopleDataLabs.enrichPerson`).
## Coalesce pattern (multi-pass enrichment)
When enriching the same record across multiple providers, merge results column-by-column. Prefer the higher-quality source per column:
| Column | Prefer |
|---|---|
| Firmographics (industry, size, hq) | aiArk > companyEnrich > peopleDataLabs > waterfall > apolloio |
| Funding / financials | enrichCrm.getFunding (only source) |
| Technographics | builtwith.getDomainSummary (free) > theirStack > builtwith.enrichDomain > peopleDataLabs |
| Email | aiArk.enrichPerson > FullEnrich > hunter > peopleDataLabs |
| Phone | aiArk.findMobilePhone (mobile) > FullEnrich > prospeo > waterfall |
| LinkedIn URL | linkedin.findProfileUrl > FullEnrich.reverseEmailLookup |
| Job change signal | waterfall.detectJobChange (only source) |
## Output retrieval — `run download-outputs`
After a batch run, retrieve the actual enriched data with **`cargo-ai orchestration run download-outputs`**, NOT `run download` (which gives you full run records — useful for debugging but inefficient for output extraction).
```bash
cargo-ai orchestration run download-outputs \
--workflow-uuid <uuid> \
--output-node-slug <slug> \
--batch-uuid <uuid> \
--format json
```
(Don't pass `--is-finished` — the CLI help lists it but the API currently rejects it with `unrecognized_keys`; reported.)
Returns `{"url": "..."}` — a signed URL to a CSV/JSON containing only the output node's data with input/output context per record. See [`../../cargo-analytics/SKILL.md`](../../cargo-analytics/SKILL.md#downloading-run-results) for the full reference.
For ad-hoc `action execute` / `action execute-batch` runs (no saved tool), use `--wait-until-finished` and read the response directly. The response shape is documented in [`../../cargo-orchestration/references/response-shapes.md`](../../cargo-orchestration/references/response-shapes.md). Per-node output lives at `runContext.<nodeSlug>` for runs and per-record `output` fields for batches.
## Action shape rules
`kind: "connector"` action: `{"kind":"connector","integrationSlug":"<slug>","actionSlug":"<slug>"}`. **`connectorUuid` is NOT in `config`.** The platform resolves the workspace's authenticated connector from `integrationSlug`. See [`../../cargo-orchestration/references/examples/actions.md`](../../cargo-orchestration/references/examples/actions.md).
## Polling guidance
Small runs (< 50 records): use `--wait-until-finished` for ergonomics.
Large runs (>= 100 records): poll. See [`../../cargo-orchestration/references/polling.md`](../../cargo-orchestration/references/polling.md) for retry strategy and rate-limit handling.
## When enrichment misses
Two failure modes:
1. **Coverage gap** — record exists but provider doesn't have data. Walk the waterfall.
2. **Quality issue** — provider returns data but it's wrong. Compare two sources; if they disagree, flag the record for manual review rather than picking one blindly.
Common quality pitfalls:
- Email finders return catch-all emails that look valid but bounce. Always verify with `waterfall.verifyEmail`.
- LinkedIn URL resolvers return profiles for the wrong person with the same name. Use the strict-validation pattern in [`../recipes/linkedin-url-lookup.md`](../recipes/linkedin-url-lookup.md).
- Job-change signals can show stale data on small companies. Cross-check with the contact's current LinkedIn before acting on `waterfall.detectJobChange` results.
guides/finding-companies-and-contacts.md
# Finding companies and contacts
How to source accounts and people on Cargo. Covers the full sourcing decision tree, provider-by-provider strengths, and the parallel patterns that work at scale.
## Decision tree
```
Goal → which sourcing path?
Looking for COMPANIES matching ICP criteria (industry, size, geo, …)?
├─ Cheapest at scale (0.01 cred/record): aiArk.searchCompanies — also lookalikes from ≤5 seed domains
├─ LinkedIn-native filters (0.05 cred): salesNavigator.searchAccounts
├─ Need rich filters / structured query: peopleDataLabs.queryCompanies (3 cred)
├─ Tech-stack or hiring intent: theirStack.searchCompanies / searchTechnologies / searchJobs (0.5 cred)
├─ Local / SMB / storefront (Maps-style): serper.searchPlaces (1 cred)
├─ Specific domain → details: aiArk.enrichCompany (0.01 cred)
└─ Already have a domain list? skip sourcing — go straight to enrichment
Looking for PEOPLE at companies?
├─ Cheapest at scale (0.02 cred/record): salesNavigator.searchLeads
├─ Filters SN can't express (0.05 cred): aiArk.searchPeople — education, skills, tenure, past company
├─ Rich filters / large database: peopleDataLabs.searchPeople / queryPeople (3 cred)
├─ LinkedIn-anchored: linkedin.findProfileUrl + linkedin.enrichProfile (0.25 cred)
├─ "Find people I know who can intro": theSwarm.searchWarmIntrosToCompany / Person (2 cred)
└─ Visitor de-anon (identifies a COMPANY): snitcher.searchSessions (0 cred) → salesNavigator.searchLeads there
Looking for INVESTOR-BACKED companies?
└─ peopleDataLabs.queryCompanies with investor/funding filter
(then salesNavigator.searchLeads at each portfolio company)
```
## Companies-first rule
When the user asks for "contacts at companies matching X," **always** discover the company set first, then find people at each company. Reasoning:
- Broad people-search queries return noisy results when the company filter is weak.
- A two-step (companies → people) flow lets you cap the per-company contact count (e.g. 3 prospects per account) cleanly.
- Per-company contact searches parallelize naturally via `action execute-batch` — fan out one `searchLeads` per company in the source set.
## Provider strengths at a glance
| Provider | Best for | Cost (credits) |
|---|---|---|
| **salesNavigator** | At-scale lead/account search, LinkedIn-native filters | 0.02 (lead) / 0.05 (account) |
| **aiArk** | Cheapest company search + lookalike seeds; people filters on education / skills / tenure / past company | 0.01 (company) / 0.05 (person) |
| **peopleDataLabs** | Structured queries (`queryPeople` / `queryCompanies`), heavy filtering, backfill when other sources miss | 3 (flat) |
| **theirStack** | Tech-stack signals, jobs-posted signals, "everyone hiring for role X" | 0.5 |
| **builtwith** | Tech detection on a domain you already have; `getDomainSummary` is free | 0 / 1 |
| **icypeas** | Cheapest people/company find when minimal filters work | 0.02 |
| **firecrawl** | Web search + scrape when no structured provider has the data | 0.05 |
| **serper** | Google Maps-style search for local SMBs / storefronts | 1 |
| **theSwarm** | Warm-intro paths to a target account or contact | 2 |
| **snitcher** | Anonymous website visitor identification | 0 (free credits-tier) |
For full provider details, see the per-provider playbooks under `../provider-playbooks/`.
## Cheapest path patterns
### Pattern A — TAM list at scale (>500 companies)
```bash
# Source — cheapest large-scale account search
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"salesNavigator","actionSlug":"searchAccounts"}' \
--records '[{"filters":{"industry":["fintech"],"countries":["US"],"sizeMin":50,"sizeMax":500}}]' \
--wait-until-finished
```
If salesNavigator filters don't cover the criteria you need, fall back to peopleDataLabs. Use `searchCompanies` (3) when criteria fit cargo's `{conjonction, groups, conditions}` filter shape; drop to `queryCompanies` (3) when you need a PDL **SQL** query (required for array-membership like investor name).
### Pattern B — Contact discovery at known companies
```bash
# Fan out one searchLeads per company
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"salesNavigator","actionSlug":"searchLeads"}' \
--records "$(jq -c '[.companies[] | {filters:{accountId:.linkedinId,titles:["CTO","VP Engineering"]}}]' /tmp/companies.json)" \
--wait-until-finished
```
Cap titles tightly — broad title filters dilute results.
### Pattern C — Domain → company detail
When you already have a domain list and need firmographics:
```bash
# Cheapest company enrich in the catalog — domain or LinkedIn URL, no match step
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"enrichCompany"}' \
--records '[{"domain":"acme.com"},{"domain":"globex.com"}]' \
--wait-until-finished
# Rows that came back thin — fuller field set at 0.25
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"companyEnrich","actionSlug":"enrichByDomain"}' \
--records '<rows from the previous step with empty firmographics>' \
--wait-until-finished
```
### Pattern D — Tech-stack-driven sourcing
```bash
# Find companies running a specific stack
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theirStack","actionSlug":"searchCompanies"}' \
--data '{"technologies":["snowflake","dbt"],"locations":["United States"]}' \
--wait-until-finished
# Or "everyone hiring for role X" (intent signal)
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theirStack","actionSlug":"searchJobs"}' \
--data '{"job_titles":["Head of RevOps"],"posted_at_max_age_days":30}' \
--wait-until-finished
```
### Pattern E — Investor portfolio sourcing
```bash
# Step 1 — query companies by investor (peopleDataLabs is the reliable source)
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"peopleDataLabs","actionSlug":"queryCompanies"}' \
--data '{"query":"SELECT * FROM company WHERE summary.investors LIKE %Sequoia%"}' \
--wait-until-finished > /tmp/portfolio.json
# Step 2 — fan out searchLeads per portfolio company (Pattern B above)
```
## Parallel execution
For any source list with >10 items, use `action execute-batch` with `--records`. The platform fans out automatically and respects rate limits per provider. For very large runs (>500), pass via `batch create --workflow-uuid` with a saved tool — see [`../../cargo-orchestration/references/polling.md`](../../cargo-orchestration/references/polling.md) for polling strategies.
## When the cheapest source returns garbage
Two failure modes for sourcing:
1. **Filter mismatch** — provider doesn't expose the filter you need (e.g. salesNavigator can't filter by funding round). Move to a richer provider (`peopleDataLabs.queryCompanies`).
2. **Coverage gap** — provider doesn't have data for the niche (e.g. local SMBs aren't well-covered by salesNavigator). Move to a niche provider (`serper.searchPlaces` for SMBs, `theirStack.searchCompanies` for tech-driven).
If the user's request can't be served at all, file a `workspaceManagement report` describing the gap.
guides/writing-outreach.md
# Writing outreach
How to use Cargo's LLM providers and AI agent surface to score, qualify, and personalize outreach. Covers provider routing, prompt patterns, and integration with sequencers.
> **Gate — run before any prompt on this page.** [`../references/acceptable-use.md`](../references/acceptable-use.md) §3: *basis* (which permission covers this audience), *suppression* (unsubscribe / DNC / hard-bounce filtered out first), *relevance* (why this message, for this recipient). All three are free; any failure is stop-and-ask. Nothing here sends — the output is variables for the user's own sequencer, under its limits and identities. Drafted copy carries an honest sender and subject, a working opt-out, and a postal address where required (§4).
## LLM provider routing
Cargo exposes five LLM providers as `kind: "connector"` actions with credits-based pricing. All expose a single `instruct` action that takes a prompt + model and returns text.
| Provider | Strengths | Cost (credits, cheapest model) |
|---|---|---|
| **anthropic** | High-quality reasoning, long context, structured output via JSON mode | Haiku: 0.2 / Sonnet: 0.2 / Opus: 2 |
| **openAi** | Broadest model selection (gpt-5 family, gpt-4o), native JSON-schema output | nano: 0.006 / gpt-5: 0.2 / 4o: 0.5 |
| **perplexity** | Web-grounded research with citations | Sonar: 0.3 / Sonar-pro: 1 |
| **gemini** | Cheapest large-context option | Flash: 0.01 |
| **deepSeek** | Lowest-cost reasoning when latency isn't critical | varies |
For most outreach tasks: **anthropic Haiku** (0.2) is the right default. For deep research with citations: **perplexity sonar-pro**. For batch personalization on a large list: **openAi gpt-5-nano** (0.006 — ~30× cheaper than Haiku). Costs are per 1,000-token package — full tier tables live in the [`anthropic`](../provider-playbooks/anthropic.md) / [`openAi`](../provider-playbooks/openAi.md) / [`gemini`](../provider-playbooks/gemini.md) / [`perplexity`](../provider-playbooks/perplexity.md) playbooks.
## Prompt patterns
### Lead scoring
```
You are an ICP fit scorer. Given a company profile, return a JSON object:
{
"score": <integer 0-10>,
"reasoning": "<one sentence>",
"qualified": <true|false>
}
Company profile:
- Domain: {domain}
- Industry: {industry}
- Employee count: {employee_count}
- Tech stack: {technographics}
- Recent funding: {funding}
ICP criteria: {icp_description}
```
Use anthropic Haiku with `output: {"type": "jsonSchema", "jsonSchema": {...}}` to enforce structured output.
### Personalization (one-paragraph opener)
```
Write a single short paragraph (≤ 60 words) opening a first-touch email to
{first_name}, {title} at {company}. Reference the most relevant signal from the
company profile below — if none of the signals give a reason to write to this
person specifically, output exactly: NULL. Sound like a peer, not a vendor.
No "I hope this finds you well."
Company profile: {firmographics}
Recent signals: {signals}
ICP angle: {icp_angle}
```
Run with openAi gpt-5-nano for batch jobs (cheap, fast). Inputs come from earlier enrichment passes — keep the prompt short to amortize cost.
### Qualification rubric
```
Return PASS or FAIL with a one-sentence reason.
Criteria (ALL must hold):
1. Company has 50–500 employees.
2. Company is in {target_industries}.
3. Company has at least one {target_role} on the team.
4. Company shows recent intent: hiring for {target_intent_role} OR using {target_tech} OR raised funding in last 12 months.
```
## Multi-pass pipeline (research → score → personalize)
Run as three sequential `action execute-batch` calls, piping each step's output into the next:
```bash
# Pass 1 — Research (perplexity for fresh web context)
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"perplexity","actionSlug":"instruct"}' \
--records '[{"prompt":"What is <company> known for? 2-sentence summary.","model":"sonar"}, ...]' \
--wait-until-finished > /tmp/research.json
# Pass 2 — Score (anthropic with structured output)
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"anthropic","actionSlug":"instruct"}' \
--records '<scoring inputs combining enrichment + research>' \
--wait-until-finished > /tmp/scores.json
# Pass 3 — Personalize (openAi mini for cost)
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"openAi","actionSlug":"instruct"}' \
--records '<personalization inputs for high-scored leads only>' \
--wait-until-finished > /tmp/openers.json
```
Filter between passes — only run pass 3 on leads that scored above your threshold in pass 2. Saves credits.
## Sequencer integration
Once leads are enriched, scored, and personalized, push to a sequencer:
| Provider | Action | Notes |
|---|---|---|
| **lemlist** | `upsertLead` | Maps name/email/company directly. Custom fields go in payload. Email finder + verifier built in. |
| **lgm** (LaGrowthMachine) | `createLead` | Audience-driven; create the lead and assign to an audience. |
| **instantly** | (CRUD) | Sequencing platform, use HTTP for direct API or check the `instantlyV2` integration. |
| **smartlead** | (CRUD) | Similar to instantly. |
| **outreach** / **salesloft** | (CRUD) | Enterprise sequencers. CRM-style integrations. |
| **heyReach** | (CRUD) | LinkedIn-focused outbound. |
These are mostly free CRUD operations (no credits) — push the personalized list with `action execute-batch` and the sequencer handles the campaign.
## CRM sync
If the user wants the enriched + scored data in their CRM:
| Provider | Action | Notes |
|---|---|---|
| **hubspot** | `upsertRecords` | Map cargo columns to HubSpot properties. `enrollToSequence` for sequence enrollment. |
| **salesforce** | (CRUD) | Lead / Contact / Account objects. |
| **pipedrive** | (CRUD) | Person / Organization / Deal objects. |
| **attio** | (CRUD) | Custom-object friendly. |
CRM CRUD is free (no credits). Compose ad hoc — find the action with `cargo-ai orchestration action list <keywords> --integration-slug <slug>`, then read its input schema via `cargo-ai connection integration get <slug>` and run via `orchestration action execute-batch`.
## When to use Cargo AI agents instead of raw LLM `instruct`
Cargo's `cargo-ai` skill (capability layer) lets you create persistent agents with system prompts, tools, and memory. Use those when:
- The agent needs RAG (upload a PDF for grounded answers).
- You want multi-turn chat with persistent context.
- The same prompt runs hundreds of times — define an agent once, invoke many.
For one-shot scoring or personalization across a batch, raw `instruct` is simpler and cheaper.
See [`../../cargo-ai/SKILL.md`](../../cargo-ai/SKILL.md) for the agent surface.
## Action shape rules
Same as everywhere else: `kind: "connector"` with `integrationSlug` + `actionSlug`, and **no `config`** — a top-level action carries none, and `connectorUuid` is never nested inside one.
For LLM `instruct` actions, the `model` field is in the per-record data, not in `config`:
```json
{
"kind": "connector",
"integrationSlug": "anthropic",
"actionSlug": "instruct"
}
```
Per-record:
```json
{
"prompt": "...",
"model": "claude-3-5-haiku-latest",
"maxTokens": 500
}
```
provider-playbooks/aiArk.md
---
provider: aiArk
category: enrichment
last-reviewed: 2026-07-25
---
# aiArk (AI Ark)
LinkedIn-anchored people/company data with an unusually cheap enrich-and-email combo, a personality-analysis action nothing else in the catalog has, and per-record search that bills at the bottom of the catalog. **All nine actions run on cargo's managed connection** — seven credits-based, plus two free `count*` actions that size a search before it bills — no own-key connector required (unlike `apolloio`, where only two are). Category `enrichment`, sub-category list-building. Reach for it when you hold **LinkedIn URLs** (cheapest profile+email at 0.1), need a **mobile phone** cheaply (0.5 vs the 3+ phone tier), want **lookalike-company** discovery (0.01/record), or need **personality/selling guidance** for personalization. **In the priority stack** ([`../SKILL.md`](../SKILL.md) §5) as the URL-anchored enrich rung and the cheapest per-record search — but it doesn't displace the sourcing-first spine: `salesNavigator` (0.02/lead) still leads plain at-scale people sourcing.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `enrichPerson` | **0.1** | `linkedinUrl` **or** `id` (AI-Ark person ID from a prior `searchPeople`) | Full person profile **+ verified email** in one call. Bills **0** when no email is found. |
| `reverseLookup` | **0.05** | `search` (an email address **or** a phone number) | Resolve a full person profile from an email or phone. Bills **0** on no match. |
| `analyzePersonality` | **0.05** | `linkedinUrl` | Personality insights (OCEAN, DISC) + tailored **selling and hiring guidance**. Bills **0** on no match. |
| `findMobilePhone` | **0.5** | `linkedinUrl` **or** (`domain` + `name`) | Mobile phone number. Bills **0** when nothing is found. |
| `searchPeople` | **0.05 / returned record** | contact + account filter **groups** (see below) + `limit` (default 10, max 100) | Filter-rich people search (title, seniority, department, education, skills, tenure, past company, firmographics). |
| `searchCompanies` | **0.01 / returned record** | account filter **groups** + `lookalikeDomains` (≤5 domains/LinkedIn URLs) + `limit` (default 10, max 100) | Cheapest company search in the catalog + lookalike discovery. |
| `enrichCompany` | **0.01** | `domain` **or** `linkedinUrl` | Full company profile. Cheapest company enrich in the catalog. |
| `countCompanies` | **free** | same account filter **groups** as `searchCompanies` (no `limit`) | Returns `{"count": N}` — the size of the pool a search would draw from. |
| `countPeople` | **free** | same filter **groups** as `searchPeople` (no `limit`) | Returns `{"count": N}` — pool size before paying per record. |
Two extractors (`fetchPeople`, `fetchCompanies`) also exist for syncing search results straight into a model — same filter shape, bulk export up to 10,000 rows. Use them from a CDK/model-sync context; recipes here use the actions.
## What it's for
- ✅ **URL-in-hand enrich + email** — `enrichPerson` (0.1) returns the full profile **and** a verified email from a LinkedIn URL, cheaper than `linkedin.enrichProfile` (0.25) which returns no email, and it only bills when it actually finds an email.
- ✅ **Cheap mobile phone** — `findMobilePhone` (0.5) undercuts the whole phone tier (`prospeo.findPhone` 3, `FullEnrich.findPhone` 6). Mobile-only, LinkedIn-URL or domain+name anchored, billed only on a hit.
- ✅ **Lookalike-company sourcing** — `searchCompanies` with `lookalikeDomains` at 0.01/record: seed up to 5 domains, get similar companies for less than `oceanio` / `companyEnrich` lookalikes.
- ✅ **Rich people search** — `searchPeople` filters on education, skills, tenure windows, seniority, department, and past company that `salesNavigator` can't express, at 0.05/record.
- ✅ **Reverse lookup** — `reverseLookup` (0.05) turns a stray email or phone back into a profile.
- ✅ **Personalization signal** — `analyzePersonality` (0.05) is unique: OCEAN/DISC + selling guidance to feed the WRITE step.
- ❌ **Generic at-scale sourcing** — for plain industry/size/geo lead lists, `salesNavigator.searchLeads` (0.02) is still cheaper per record.
## Patterns
### Pattern A — Enrich + get a verified email from a LinkedIn URL (ENRICH + CONTACT in one)
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"enrichPerson"}' \
--records '[
{"linkedinUrl":"https://linkedin.com/in/alicesmith"},
{"linkedinUrl":"https://linkedin.com/in/bobjones"}
]' \
--wait-until-finished
```
`linkedinUrl` **or** `id` is required (one of the two, or the call errors). Rows where AI Ark returns no email cost **0** — you're only billed on a found email.
### Pattern B — Cheapest mobile phone
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"findMobilePhone"}' \
--records '[
{"linkedinUrl":"https://linkedin.com/in/alicesmith"},
{"domain":"globex.com","name":"Bob Jones"}
]' \
--wait-until-finished
```
Provide a `linkedinUrl`, **or** both `domain` and `name` (a domain or a name alone errors). Misses cost 0.
### Pattern C — Company search with lookalike seeds
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"searchCompanies"}' \
--data '{
"lookalikeDomains": ["stripe.com", "adyen.com"],
"industry": {"industry_or": ["Financial Services"]},
"employeeSize": {"min_employee_count": 50, "max_employee_count": 1000},
"limit": 50
}' \
--wait-until-finished
```
Billed **per returned record** — `limit` is your budget cap. Size the pool with `countCompanies` / `countPeople` first: they take the same filters, cost nothing, and turn the count-first rule in [`../references/cost-discipline.md`](../references/cost-discipline.md) into a free call rather than a guess.
### Pattern D — People search (filters salesNavigator can't express)
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"searchPeople"}' \
--data '{
"jobRole": {"title_or": ["VP Engineering"], "seniority_or": ["VP"]},
"personLocation": {"location_or": ["United States"]},
"industry": {"industry_or": ["Software"]},
"employeeSize": {"min_employee_count": 200},
"limit": 25
}' \
--wait-until-finished
```
## Filter shape — read before building a search
`searchPeople` / `searchCompanies` filters are **nested groups**, not a flat map. Each group is one config key holding suffixed sub-keys:
- **Person groups** (`searchPeople` only): `peopleInfo` (`full_name_or`, `linkedin_url_or`, …), `personLocation`, `jobRole` (`title_or`, `previous_title_or`, `seniority_or`, `department_or`), `currentAndPastCompany`, `experience`, `skills`, `education`, `languageSkills`, `certification`, `profileBadge`, `personKeywords`, `personSocialMedia`, `socialMediaFollowers`.
- **Company groups** (both actions): `companyInfo` (`domain_or`, `name_or`, `linkedin_url_or`, …), `industry`, `companyLocation`, `companyKeywords`, `productAndServices`, `companyType`, `technologies`, `naics`, `employeeSize` (`min_employee_count`/`max_employee_count`), `annualRevenue`, `funding` (`funding_type`, `min_total_funding`/`max_total_funding`), `operationLanguage`, `locationCount`, `foundedYear`, `companySocialMedia`, `employeeRole`, `employeeByDepartment`, `headcountGrowth`.
Conventions inside a group:
- **`_or` includes, `_not` excludes.** e.g. `{"jobRole": {"title_or": ["CTO"], "seniority_not": ["Entry"]}}`.
- **Single value or array** — every `_or`/`_not` key accepts a string or an array of strings.
- **Enum-backed fields come from autocompletes** — `industry`, `seniority`, `department`, `funding_type`, and language values must be valid enum members; resolve them via the integration's autocompletes: `listIndustries`, `listSeniorities`, `listDepartmentsAndFunctions`, `listCompanyDepartments`, `listFundingTypes`, `listLanguages`.
- **Numeric ranges are numbers**, not strings — `min_employee_count: 50` (contrast the old proxycurl shape, which took stringified numbers).
## Common pitfalls
- **`searchPeople` company filters key on AI-Ark company IDs.** `currentAndPastCompany.current_company_id_or` wants AI Ark's own company IDs — get them from a `searchCompanies` call first, don't pass a domain there (use `companyInfo.domain_or` for domain-based company matching).
- **`enrichPerson.id` is an AI-Ark person ID**, not a generic one — it comes from a prior `searchPeople` result. With no `id`, pass `linkedinUrl`.
- **Search bills per returned record.** `limit` (default 10, max 100) is the cap; a stray high limit paginates and bills every row. The 0.01/0.05 rate is per *result*, not per call.
- **`findMobilePhone` is mobile-only** and needs a `linkedinUrl` or a full `domain` + `name` pair — it won't resolve from a name alone.
- **Rate limit: 300 calls/minute** (spread) — large batches stretch out; fine for enrich, plan for it on big searches.
- **Flat filter maps express nothing.** `{"title": "CTO"}` at the top level is ignored — it must be `{"jobRole": {"title_or": "CTO"}}`.
## Anti-patterns
- **Running `enrichPerson` and a separate email-finder.** `enrichPerson` already returns a verified email at 0.1 and bills 0 on a miss — don't chain `FullEnrich.findEmail` (1) behind it unless it came back empty.
- **Reaching for the 3–7 credit phone tier by default.** With a LinkedIn URL in hand, `findMobilePhone` (0.5) is the first stop; escalate to `prospeo`/`FullEnrich`/`waterfall` only on a miss and only when a landline/DID is acceptable.
- **Personality analysis at scale "for color".** `analyzePersonality` earns its 0.05 on qualified, about-to-be-contacted leads feeding the WRITE step — not on a raw sourced list.
## Position in the waterfall
- `enrichPerson` — **ENRICH + CONTACT (person)** for URL-in-hand rows: profile + verified email at 0.1, ahead of `linkedin.enrichProfile` (0.25, no email) and the pricier `waterfall.enrichContact` (2) / `FullEnrich.findEmail` (1) chain.
- `findMobilePhone` — **new cheapest phone rung** (0.5) ahead of `prospeo.findPhone` (3); mobile-only, so keep the higher tiers for landline/DID fallback.
- `searchCompanies` / `searchPeople` — **SOURCE** (0.01 / 0.05 per record): `searchCompanies` is the cheapest account search in the stack and the lookalike path; `searchPeople` covers the filters `salesNavigator` can't express (education, skills, tenure, past company).
- `reverseLookup` — **niche**: email/phone → profile, beside `FullEnrich.reverseEmailLookup` (2, email → LinkedIn URL).
- `analyzePersonality` — **WRITE/personalization input**, outside the credits spine's find-and-verify path.
## Recurring use
- **Scheduled search:** `searchCompanies` / `searchPeople` fit a weekly sourcing tool (persona/company searches → weekly; cadence table: [`../recipes/save-as-play.md`](../recipes/save-as-play.md)) — but they bill 0.01/0.05 **per returned record on every run**, so dedup results against the workspace model (a free `storage query execute` on `domain` / `linkedin_url`) before any paid downstream node.
- **In-play gate:** `enrichPerson` runs only where `email` is still empty; `findMobilePhone` only where the phone column is empty. Misses bill 0, but a hit on an already-filled row is pure re-spend.
- **Stable data:** profiles and emails don't decay week to week — never schedule blanket re-enrichment; `analyzePersonality` belongs in a play's WRITE step on newly qualified rows, not on a timer (see anti-patterns).
## Action shape
`{"kind":"connector","integrationSlug":"aiArk","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.** For top-level `action execute` / `execute-batch`, inputs go in `--data` (single) or `--records` (batch), **not** in the action `config`.
provider-playbooks/anthropic.md
---
provider: anthropic
category: llm
last-reviewed: 2026-08-20
---
# anthropic (Anthropic)
Claude through a single `instruct` action — the **default judgment-tier LLM of the pack**: the prompt library ([`../references/prompt-library/index.md`](../references/prompt-library/index.md)) is written against it. Billed per **1,000-token package** per model tier. Provider routing in one line: anthropic Sonnet for judgment-heavy steps (positioning, scoring against soft ICPs, salience), `openAi` nano-tier (0.006) for cheapest bulk, `gemini` Flash (0.01–0.05) for cheap high-throughput, `perplexity` when the answer must come from the live web.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `instruct` | 0.05–4 / 1,000-token package (per-model tiers below) | `model` + `prompt` (required); `advancedSettings.{systemPrompt, maxTokens, temperature, withWebSearch}` | Personalization, scoring, extraction, classification steps inside enrichment pipelines. |
### Per-model cost tiers
| Tier | Model ids | Credits / 1,000 tokens |
|---|---|---|
| Haiku | `claude-3-5-haiku-latest` | **0.05** |
| Sonnet | `claude-sonnet-5`, `claude-sonnet-4-6`, `claude-sonnet-4-5-20250929`, `claude-sonnet-4-20250514` (schema default), `claude-3-7-sonnet-latest`, `claude-3-5-sonnet-latest` (deprecated) | 0.2 |
| Opus | `claude-opus-4-8`, `claude-opus-4-7`, `claude-opus-4-6`, `claude-opus-4-1-20250805`, `claude-opus-4-20250514` | 2 |
| Fable | `claude-fable-5` | 4 |
`advancedSettings.withWebSearch: true` adds a **fixed 0.4 credits per call** on top of the token rate, at any tier. Rate limit: 4,000 calls/min per model. (`claude-3-opus-latest` is in the model enum but has no published credit rule — avoid it.)
## What it's for
- ✅ **Judgment-heavy steps** — positioning summaries, soft-criteria ICP scoring, long-document salience: Sonnet at 0.2/1k is the pack default (see [`../recipes/icp-discovery.md`](../recipes/icp-discovery.md)).
- ✅ **Extraction and classification with prompt-enforced JSON** — the whole prompt library runs through `anthropic.instruct` with `temperature: 0`.
- ✅ **Personalized outreach lines** — `temperature: 0.3`, see [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) step 5.
- ⚠️ **Bulk inside anthropic** — Haiku 3.5 is **0.05**, a quarter of Sonnet. That makes it the right rung for high-volume classification where the prompt library's Sonnet phrasing still holds. It is still 8× `openAi` nano (0.006) and 5× `gemini` Flash (0.01), so for pure-volume transforms with no judgement in them, leave the provider.
- ❌ **Web-grounded research answers** — `withWebSearch` exists (+0.4/call fixed), but `perplexity` is purpose-built for cited web answers.
## Pattern — batch personalization / extraction
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"anthropic","actionSlug":"instruct"}' \
--records '[{"model":"claude-sonnet-4-6","prompt":"<substituted prompt for row 1>","advancedSettings":{"maxTokens":4096,"temperature":0}},{"model":"claude-sonnet-4-6","prompt":"<row 2>"}, ...]' \
--wait-until-finished
```
**`model`, `prompt`, and `advancedSettings` are all *inputs*** — they go in each record (`model` and `prompt` are required), never in the action's `config`. A top-level action carries no `config` at all. Settings placed there are rejected on older backends and **silently dropped** on newer ones, which quietly bills the call at whatever the default model is. Take the prompt text from the prompt library rather than authoring from scratch.
## Input quirks
- **Temperature range is 0–1** — not 0–2 like openAi/gemini/perplexity. A pipeline-wide `temperature: 1.5` that works elsewhere is out of range here.
- **`advancedSettings.maxTokens` is required whenever `advancedSettings` is present** (default 4096). Include it any time you set `temperature` or `systemPrompt`.
- **No structured-output config.** Unlike openAi/gemini/perplexity, `instruct` has no `output.responseFormat` — JSON shape is enforced in the prompt ("emit ONLY the JSON object"), which is exactly how the prompt-library extraction prompts are written.
- Model ids must match the enum verbatim — copy them from the tier table above.
## Cost traps
- **500-row batch math** (≈1 package per short call): Haiku ≈ **25 credits**; Sonnet ≈ **100**; Opus ≈ **1,000**; Fable 5 ≈ **2,000**. Opus/Fable are 10–20× Sonnet — never use them for bulk extraction or personalization; reserve them for a handful of high-stakes judgment calls.
- **`withWebSearch` on a batch** adds 0.4 × rows of fixed cost (+200 credits on 500 rows) before any tokens — route research needs to `perplexity` or a scrape + extract instead.
- **Token-metered, not call-metered.** Stuffing a whole scraped page into every prompt multiplies packages — truncate inputs (~3,000 words max, per the prompt-library guidance).
## Position in the LLM stack
- **Default for judgment** — the "quality" rung of [`../references/stage-action-map.md`](../references/stage-action-map.md) LLM section.
- For bulk-cheap transforms, demote to `openAi` nano-tier or `gemini` Flash after validating the prompt on a Sonnet pilot (pilot gate: [`../references/cost-discipline.md`](../references/cost-discipline.md)).
## Action shape
`{"kind":"connector","integrationSlug":"anthropic","actionSlug":"instruct"}`, with `model` (required), `prompt` (required), and `advancedSettings` per record in `--records` / `--data`. **No `connectorUuid` in `config`** — and no model settings there either; inside a workflow **node** those same fields are the node's `config`. Costs above are the Cargo-credits rules; a workspace can instead attach its own Anthropic key (connector config takes a single required `apiKey`) and bill the provider directly.
## Pairs with
- [`../references/prompt-library/index.md`](../references/prompt-library/index.md) — the prompt source for every `instruct` call (extraction, qualification, scoring, personalization, research, signal analysis).
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — personalize stage; [`../recipes/icp-discovery.md`](../recipes/icp-discovery.md) — pattern analysis; [`../recipes/tech-intent.md`](../recipes/tech-intent.md) — scrape → LLM extract.
## Recurring use
No scheduled fit — `instruct` is a transform, not a data source; it belongs **inside** plays and tools, never on its own timer.
- **In-play gate:** run only where the column the prompt fills (score, personalization line, extracted JSON) is still empty — at `temperature: 0` a re-run reproduces the same output for the same token spend, so ungated re-evaluation is pure re-billing.
- **Prompt or model changes:** to redo rows after revising the prompt or tier, clear the target column deliberately for just those rows rather than dropping the gate — the 500-row batch math above compounds on every ungated pass.
provider-playbooks/apolloio.md
---
provider: apolloio
category: enrichment
last-reviewed: 2026-07-09
---
# apolloio (Apollo.io)
Apollo-anchored person and organization enrichment. Only **two of its eleven actions are credits-based** — `enrichPerson` (1, or 3 with phone reveal) and `enrichOrganization` (1); everything else (searches, contact CRUD, sequences) runs **only on your own Apollo API key** connector. That credits pair is **in the priority stack** ([`../SKILL.md`](../SKILL.md) §5) as the **niche-coverage ENRICH rung** — promoted per-batch when a pilot shows Apollo hits where `aiArk` (0.1) and `waterfall` (2) miss, its investor coverage in [`../recipes/portfolio-prospecting.md`](../recipes/portfolio-prospecting.md) being the standing example. Stack membership is not a licence to route generic enrichment here first: `aiArk` → `waterfall` still leads the default chain, and Apollo runs on the residue.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `enrichPerson` | 1 (**3** if `revealPhoneNumber: true`) | `parameters` object (`first_name, last_name, organization_name, email, domain, linkedin_url, id, email_md5, email_sha256`), `revealPersonalEmails`, `revealPhoneNumber` | Person enrichment when Apollo coverage beats the stack for the niche. |
| `enrichOrganization` | 1 | `domain` (required) | Company enrichment when the cheaper domain rungs miss and LinkedIn doesn't have it. |
## Own-API-key actions (no credits — require your Apollo account connector)
| Action | What it does |
|---|---|
| `searchPeople` | Search Apollo's people database (`filters` required: `person_titles, person_seniorities, person_locations, organization_locations, organization_num_employees_ranges, q_organization_domains_list, q_keywords, contact_email_status, prospected_by_current_team`; `shouldEnrich`, `limit` — default 10, max 100). |
| `searchOrganizations` | Search organizations (`filters` required: `q_organization_name, q_organization_keyword_tags, organization_locations, organization_not_locations, organization_num_employees_ranges, prospected_by_current_team`; `limit`). |
| `searchContacts` | Search contacts saved in your Apollo account (`filters` required). |
| `createContact` / `updateContact` / `upsertContact` | Contact CRUD in your Apollo account (`email` required for create/upsert, `id` for update; field `mappings` + `customMappings`). |
| `addContactToSequence` | Add a contact to a sequence (`sequenceId`, `contactId`, `sendEmailFromEmailAccountId` required). |
| `removeContactFromSequence` | Remove a contact from a sequence (`sequenceId`, `contactId`, `mode` required). |
| `searchEmailAccounts` | List email accounts (needed to get `sendEmailFromEmailAccountId` for sequencing). |
These consume your Apollo plan's quota, not cargo credits — recipes stay on credits-based actions, so treat this block as an **activation surface for users who already run Apollo sequences** (sequencer handoff in [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md)).
## What it's for
- ✅ **Niche-coverage person enrichment** — 1 credit when a pilot shows Apollo hits where `aiArk.enrichPerson` (0.1) / `waterfall.enrichContact` (2) miss.
- ✅ **Domain → company fallback** — `enrichOrganization` (1) after `aiArk.enrichCompany` (0.01) and `linkedin.enrichCompanyFromDomain` (0.5) come back empty.
- ✅ **Sequencer handoff** — send verified leads into Apollo sequences via the own-key actions when the user's outbound already lives there.
- ❌ **Sourcing on credits** — `searchPeople` / `searchOrganizations` are own-key only; credits-based sourcing is `salesNavigator` (0.02–0.05).
## Patterns
### Pattern A — Person enrichment (fallback rung)
```bash
# Only on rows the priority stack missed
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"apolloio","actionSlug":"enrichPerson"}' \
--records '[
{"parameters":{"first_name":"Alice","last_name":"Smith","organization_name":"Acme"}},
{"parameters":{"linkedin_url":"https://linkedin.com/in/bobjones","domain":"globex.com"}}
]' \
--wait-until-finished
```
Identifiers nest under `parameters` and are **snake_case**. Any combination works — LinkedIn URL + domain gives the best match rate. Hashed-email inputs (`email_md5`, `email_sha256`) are accepted when you only hold a hash.
### Pattern B — Domain → organization
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"apolloio","actionSlug":"enrichOrganization"}' \
--records '[{"domain":"acme.com"},{"domain":"globex.com"}]' \
--wait-until-finished
```
`domain` is the only accepted input — no name- or LinkedIn-based lookup on this action.
## Common pitfalls
- **`revealPhoneNumber: true` triples the price** (1 → 3). Gate it like any phone action — and the phone chain starts cheaper at `prospeo.findPhone` (3, [`../references/stage-action-map.md`](../references/stage-action-map.md)), so reveal only when you're enriching the person anyway. `revealPersonalEmails` does **not** change the cost.
- **Own-key actions fail without an Apollo connector.** The nine non-credits actions need a connector authenticated with your Apollo `apiKey` — they can't run on cargo's managed connection.
- **Rate limit: 400 calls/hour** (spread) — the tightest in this group; large batches stretch over hours, so prefer the stack for volume enrichment.
- **Sequencing needs three IDs** — `sequenceId`, `contactId` (create/upsert the contact first), and `sendEmailFromEmailAccountId` (from `searchEmailAccounts`).
## Anti-patterns
- **Top-level identifier fields on `enrichPerson`.** `first_name` etc. must sit inside `parameters` — top-level keys (except the two reveal flags) are ignored.
- **Reveal flags at scale "to be safe".** Found personal emails and phones still flow through VERIFY (`waterfall.verifyEmail`, 0.1) — revealing on unqualified rows is pure spend.
## Position in the waterfall
- `enrichPerson` — **ENRICH (person), fallback rung** beside the stack's `aiArk` → `waterfall` → `peopleDataLabs` chain; promote it for a batch only when the pilot shows better niche coverage.
- `enrichOrganization` — **ENRICH (company), fallback rung** after `aiArk.enrichCompany` (0.01), `companyEnrich` (0.25) and `linkedin` (0.25–0.5).
- Own-key sequence actions — post-VERIFY **activation**, outside the credits spine.
## Recurring use
- **No scheduled fit on credits** — both credits actions are per-record enrichment. The own-key `search*` actions can feed a scheduled sourcing tool on your Apollo plan's quota; dedup re-pulls there with the `prospected_by_current_team` filter.
- **In-play gate:** `enrichPerson` only where the target contact field (`email`) is still empty; `enrichOrganization` only where firmographics are empty. Never leave `revealPhoneNumber: true` on a play node — every re-evaluated row bills 3 instead of 1.
- **Stable data, tight ceiling:** enrichment output doesn't decay, and the 400 calls/hour limit means an ungated recurring batch both re-bills and stalls the queue.
## Action shape
`{"kind":"connector","integrationSlug":"apolloio","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/bouncer.md
---
provider: bouncer
category: verification
last-reviewed: 2026-07-09
---
# bouncer (Bouncer)
Dedicated email verification. **One credits-based action, 0.3 credits** — 3× the priority-stack default `waterfall.verifyEmail` (0.1) and 30× the bulk option `icypeas.verifyEmail` (0.01), making it the second-most-expensive verify tier in the catalog (only `hunter.verifyEmail` at 1 costs more). On credits it almost never earns a call; its real audience is users with an **existing Bouncer subscription** who wire their own API key into a connector and run verification on their plan instead of cargo credits.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `verifyEmail` | 0.3 | `email` (required) | Verify a single email's deliverability. |
The connector also accepts your own Bouncer API key (`apiKey`) — same action, billed to your Bouncer plan instead of credits.
## What it's for
- ✅ **Existing Bouncer subscription** — the user already pays Bouncer; create an own-key connector and the 0.3-credit price stops mattering.
- ✅ **Deliberate extra opinion on high-value contacts** — a different underlying provider when `waterfall.verifyEmail` and `zeroBounce.verifyEmail` disagree and the contact is worth a third check.
- ❌ **Default verify step** — `waterfall.verifyEmail` (0.1) is the priority default (see [`../references/alternatives.md`](../references/alternatives.md), Verify email alternatives).
- ❌ **Second opinion by default** — `zeroBounce.verifyEmail` (0.1) gives an independent verdict at a third of the price.
- ❌ **Bulk verification** — `icypeas.verifyEmail` (0.01) is 30× cheaper on large lists.
## Patterns
### Pattern A — Third opinion on a contested subset
```bash
# Only on rows where waterfall and zeroBounce disagreed, and the contact justifies 0.3
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"bouncer","actionSlug":"verifyEmail"}' \
--records '[{"email":"alice@acme.com"},{"email":"bob@globex.com"}]' \
--wait-until-finished
```
The catalog dump documents no output schema for this action — inspect the first run's output (resolve the action's output schema via `cargo-orchestration`, or read the run's `runContext`) before filtering on field names.
## Cost traps
- **0.3 per row compounds fast.** A 1,000-row verify costs 300 credits here vs 100 on `waterfall.verifyEmail` and 10 on `icypeas.verifyEmail`. Run the free pre-cull first ([`../references/contact-accuracy.md`](../references/contact-accuracy.md)) and reserve bouncer for the narrow subset that justifies it.
- **Credits by accident.** If the user mentions a Bouncer account, set up the own-key connector before batching — running their existing tool on cargo credits at 0.3/row is pure waste.
## Anti-patterns
- **bouncer as the first verify rung.** The default chain starts at `waterfall.verifyEmail` (0.1); premium-priced alternatives are swapped in deliberately, never by default (see [`../references/stage-action-map.md`](../references/stage-action-map.md), Verify email).
- **Skipping verification because a finder said "verified".** Providers grade their own homework — every found email goes through an independent verify step (see [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md)).
## Position in the waterfall
**VERIFY stage, premium rung — outside the default chain.** Default: free pre-cull → `waterfall.verifyEmail` (0.1) → `zeroBounce.verifyEmail` (0.1) second opinion → `icypeas.verifyEmail` (0.01) for bulk. `bouncer.verifyEmail` (0.3) enters only via own key or a deliberate third opinion.
## Action shape
`{"kind":"connector","integrationSlug":"bouncer","actionSlug":"verifyEmail"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — the verify step before personalization; never sequence unverified emails.
- [`../recipes/prospecting.md`](../recipes/prospecting.md) — the verify rung of the find → enrich → verify → sync spine.
## Recurring use
- **No scheduled fit — per-record verification only.** Recurring verification inside a play belongs on the default rung `waterfall.verifyEmail` (0.1) or bulk `icypeas.verifyEmail` (0.01); bouncer enters recurring flows only via an own-key connector.
- **In-play gate:** run only where the bouncer verdict column is still empty **and** `email` is non-empty — an email verifies once; re-evaluation must not re-bill settled rows.
- **Decay caveat:** deliverability does age, but scheduled re-verification of a whole list goes to the cheap rungs (or the user's Bouncer plan), never through the 0.3-credit price.
provider-playbooks/brightData.md
---
provider: brightData
category: social profile scraping (non-LinkedIn)
last-reviewed: 2026-08-20
---
# brightData (Bright Data)
Six actions, all shaped identically: hand one **profile URL**, get that account's public profile back. **0.1 fixed per call**, no per-item component, no search — you must already have the URL.
What it covers is the part of the social web the rest of the catalog doesn't: **Instagram, TikTok, Facebook, and YouTube**. The `linkedin` and `salesNavigator` providers own LinkedIn; the `x` provider owns X. Bright Data is the only way to read the other four.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `scrapeInstagramProfile` | 0.1 | `url` (required) | Follower count, posts, bio, engagement metrics for an Instagram account. |
| `scrapeTikTokProfile` | 0.1 | `url` (required) | Follower count, likes, videos, engagement metrics for a TikTok account. |
| `scrapeFacebookProfile` | 0.1 | `url` (required) | Name, followers, **contact info, business details** for a Facebook page or profile. |
| `scrapeFacebookPagePosts` | 0.1 | `url` (required) | A page's posts with content, engagement, attachments. |
| `scrapeTwitterProfile` | 0.1 | `url` (required) | X profile — **see the routing note below.** |
| `scrapeYouTubeChannel` | 0.1 | `url` (required) | Subscribers, videos, views, top videos for a channel. |
Rate limited to 100 calls per minute. Caching is supported, so a re-run over the same URLs inside the cache window doesn't re-bill — which matters more here than usual, because a follower count is exactly the field someone re-pulls out of habit.
**Route X to the `x` provider, not here.** `x.getUserProfile` is **0.02** against `scrapeTwitterProfile`'s 0.1 — the same field for a fifth of the price — and `x` also has fourteen actions this provider has no equivalent for (`getUserPosts`, `getFollowers`, `getPostLikers`, `searchPosts`, …). `scrapeTwitterProfile` is the fallback if an `x` call fails on a specific handle, not the default.
## Acceptable use — read before the first call
These are **consumer social platforms**, and [`../references/acceptable-use.md`](../references/acceptable-use.md) §2 refuses consumer and private-individual targeting outright. That rule does not soften because the data is public.
The legitimate use is **accounts that are themselves a business**: a brand's Instagram, a creator whose channel is the company, an agency's page, a marketplace seller. What you learn there is firmographic — scale, category, activity, contact route the business published for inbound.
- ✅ A brand/creator/agency account, read as a **company** record.
- ❌ A named prospect's personal Instagram or TikTok, read as a **person** record. That is personal-life data on a private individual, it is not a lawful basis for B2B outreach, and it does not become one by being enriched into a Contacts model.
- ❌ Fanning any of these across a contact segment. See Anti-patterns.
`scrapeFacebookProfile` returns "contact info" — treat an address harvested this way as **provenance-less** under §5. Business contact details a company published on its own page are usable; anything attached to a personal profile is not.
## What it's for
- ✅ **Creator- and social-commerce ICPs** — when the account *is* the business, follower count and posting cadence are the firmographics. Nothing in the LinkedIn stack sees them.
- ✅ **Qualifying a brand's actual scale** — a DTC company with 4k Instagram followers and one post a quarter is a different account than one with 400k and daily posts, and neither shows up in headcount.
- ✅ **Agency and media prospecting** — YouTube subscriber counts and TikTok engagement as the segmentation axis for accounts that sell attention.
- ❌ **B2B buyer research** — `aiArk.enrichPerson` (0.1, profile + verified email) and `linkedin.enrichProfile` (0.25) are the professional-identity rungs. A B2B buying committee does not live on Instagram.
- ❌ **Firmographics for a normal company** — `aiArk.enrichCompany` (0.01) or `companyEnrich.enrichByDomain` (0.25) return typed fields, 10x cheaper and actually structured.
- ❌ **Finding the URL** — there is no search action. Resolve the handle first (`serper.search` at 0.05, or the company's own site) or you have nothing to pass.
## Patterns
### Pattern A — Score a creator/DTC account list by real reach
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"brightData","actionSlug":"scrapeInstagramProfile","config":{}}' \
--data '{"url": "https://www.instagram.com/acmebrand/"}' \
--wait-until-finished
```
0.1 a row. Over a 200-account segment that's 20 credits — pilot 15 first, per [`../references/cost-discipline.md`](../references/cost-discipline.md) §1, because hit rate depends entirely on whether your URL column is canonical.
### Pattern B — Read a page's recent posts for a positioning line
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"brightData","actionSlug":"scrapeFacebookPagePosts","config":{}}' \
--data '{"url": "https://www.facebook.com/acmebrand"}' \
--wait-until-finished
```
One call returns the posts; the personalization step reads them. Don't pair it with a per-post enrichment fan-out — the content is already in this response.
## Common pitfalls
- **Non-canonical URLs.** A shortlink, a post URL, or a `?igsh=` tracking suffix is not a profile URL. The call bills and returns nothing useful. Normalize the column before enrolling a batch.
- **Assuming `scrapeTwitterProfile` is the X action.** It is 5x `x.getUserProfile` for less coverage.
- **Reading follower count as intent.** It's a size attribute, not a signal. A buying signal needs a change over time or an event — which means storing the number and re-reading on a cadence, and paying 0.1 each time.
- **Expecting a person.** These return accounts. The mapping from account to a named human with a role is not in the response.
## Anti-patterns
- **Fanning social scrapes across a contact segment.** 0.1 × a Contacts model is real money spent assembling personal-life profiles on private individuals — an acceptable-use refusal (§2), independent of the bill.
- **Using it as a cheaper web scraper.** For an arbitrary page, `parallel.extract` is 0.025/URL and `firecrawl.scrape` is 0.05. Bright Data's price buys platform-specific parsing; on a non-social URL you are paying 4x for nothing.
- **Six calls per account "to be thorough".** 0.6 a row for four platforms most accounts aren't active on. Pick the one platform the ICP actually lives on.
## Position in the waterfall
- **Only rung** for Instagram, TikTok, Facebook, and YouTube. There is no fallback in the catalog — if this fails, the field is unavailable.
- **Last rung for X**, behind the fourteen `x` actions at 0.02.
- **Not a rung at all for B2B person or company enrichment.** It sits beside that stack, for a different kind of account.
## Action shape
`{"kind":"connector","integrationSlug":"brightData","actionSlug":"scrapeInstagramProfile","config":{}}`. **No `connectorUuid` in `config`.** The URL goes in `--data`.
Needs a Bright Data API token on the connector (Settings → connector config; the token is at `https://brightdata.com/cp/setting/users`). It still bills cargo credits — a BYO key does not make the action free.
## Pairs with
- [`../recipes/custom-datapoints.md`](../recipes/custom-datapoints.md) — deciding whether reach metrics belong in the model at all before wiring a column that re-bills.
- [`../recipes/icp-discovery.md`](../recipes/icp-discovery.md) — when the won/lost diff points at social scale rather than headcount.
## Recurring use
- **Every scheduled run re-bills every row.** A follower count refreshed weekly on 200 accounts is 20 credits a week, ~1,000 a year, for a number that moves slowly. Monthly is almost always enough; quarterly usually is.
- **In-play gate:** filter to rows whose reach column is empty or older than the cadence, and to accounts still in an open stage. Refreshing metrics on closed-lost accounts is the most common waste here.
- **Cache window first.** Before scheduling, confirm what the connector's cache returns — a play that re-runs inside the window pays nothing new, and one just outside it pays in full.
provider-playbooks/builtwith.md
---
provider: builtwith
category: technographics
last-reviewed: 2026-08-15
---
# builtwith (BuiltWith)
A domain's technology stack. Three actions, and the important thing about them is the price spread: **`getDomainSummary` is free, `enrichDomain` is 1 credit**, and they answer overlapping questions. Reaching for the paid one first is the mistake this playbook exists to prevent.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `getDomainSummary` | **0** | `domain` (required) | The stack summary for a domain. **Free — always run this first.** |
| `enrichDomain` | 1 | `domain` (required) | Full technology detail for a domain, when the summary is not enough. |
| `findWebsitesByTechnology` | not credits-priced | `technology` (required), `otherTechnologies`, `country`, `since`, `includeMeta`, `includeHistorical`, `spend` | Reverse lookup: the sites running a given technology. |
`getDomainSummary` returning 0 is not a rounding artifact. It is free, so there is no batch size at which running it first costs anything.
## What it's for
- ✅ **Qualifying a known domain's stack** — start at `getDomainSummary` (free) and escalate to `enrichDomain` (1) only for the rows where the summary did not answer the question.
- ✅ **Reverse technology sourcing** — `findWebsitesByTechnology` with `since` and `country` narrows to recent adopters in a geography, which is a much better trigger than "uses X" on its own.
- ❌ **At-scale tech-intent sourcing** — `theirStack.searchCompanies` (0.5) combines tech filters with hiring signals and returns structured company records. builtwith answers about a domain you already have.
- ❌ **Firmographics** — this is a stack lookup. `companyEnrich.enrichByDomain` (0.25) has the size and industry fields.
## Patterns
### Pattern A — Free first, paid on the residue
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"builtwith","actionSlug":"getDomainSummary"}' \
--records '[{"domain":"acme.com"}]' \
--wait-until-finished
```
Run this across the whole list. It costs nothing, and on most qualification questions ("do they run Salesforce?") the summary settles it. Only the unresolved rows go to `enrichDomain`.
### Pattern B — Recent adopters in a geography
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"builtwith","actionSlug":"findWebsitesByTechnology"}' \
--data '{"technology":"Snowflake","country":"US","since":"2026-01-01","includeMeta":true}' \
--wait-until-finished
```
`since` is what turns a technology list into a trigger. A company that adopted the tool last quarter is in a buying cycle; one that has run it for six years is not.
## Common pitfalls
- **Paying for `enrichDomain` across a list.** The summary is free and answers most qualification questions. One credit a row over 2,000 rows is 2,000 credits for detail nobody reads.
- **Treating a stack detection as certain.** Technology detection reads public page signals: a tag left behind after a migration still detects. Where the answer decides spend, confirm with a second source before acting.
- **Using `findWebsitesByTechnology` without `since`.** Unbounded, it returns the long tail of everyone who ever installed the tag, which is a list with no intent in it.
## Anti-patterns
- **builtwith as the sourcing rung for tech intent.** `theirStack` combines stack with hiring and returns records ready to enrich; builtwith's reverse lookup is a site list you still have to resolve to companies.
- **Skipping the free action because a paid one is in the recipe.** If a step in a play calls `enrichDomain` unconditionally, the free summary in front of it is a pure saving.
## Position in the waterfall
- `getDomainSummary` — **first rung for any technographic question about a known domain**, ahead of everything on price, since it is free.
- `enrichDomain` (1) — the escalation behind this provider's own free `getDomainSummary`, and the catalog's per-domain tech-detail rung. `theirStack.searchTechnologies` (0.5/row) is the alternative when you want a catalog-style technology list rather than per-domain detection.
- `findWebsitesByTechnology` — a sourcing alternative behind `theirStack.searchCompanies` (0.5).
## Action shape
`{"kind":"connector","integrationSlug":"builtwith","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.** Per-row domains go in `--records`; the reverse-lookup filter goes in `--data`.
## Pairs with
- [`../recipes/tech-intent.md`](../recipes/tech-intent.md) — the sourcing side, where theirStack leads and builtwith confirms.
- [`../recipes/icp-discovery.md`](../recipes/icp-discovery.md) — stack as an ICP criterion.
## Recurring use
- **Stacks change slowly.** A monthly re-run is generous; weekly re-bills rows that have not moved. The free summary is the exception, since re-running it costs nothing.
- **In-play gate:** filter to rows whose stack column is empty or older than the chosen interval, so segment re-evaluation does not re-bill `enrichDomain`.
- **`findWebsitesByTechnology` on a schedule:** move `since` forward with each run so every run returns new adopters rather than re-paying for the same back catalogue.
provider-playbooks/cleon1.md
---
provider: cleon1
category: enrichment
last-reviewed: 2026-07-09
---
# cleon1 (Cleon1)
Premium phone finder — **the most expensive phone rung in the catalog at 15 credits**, the last resort after every cheaper rung (`prospeo` 3 → `FullEnrich` 6 → `waterfall` 7 → `datagma` 8) has missed ([`../references/stage-action-map.md`](../references/stage-action-map.md)). Both actions cost the same 15; the difference is the anchor: a LinkedIn URL (`findPhoneFromLinkedin`, the LinkedIn-anchored option the stage map recommends) or name + company (`findPhone`). Phone is the guarded lever in [`../references/cost-discipline.md`](../references/cost-discipline.md) — cleon1 enters a plan **only on explicit user request, on qualified high-value leads only**.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `findPhoneFromLinkedin` | 15 | `linkedinUrl` (required) | Phone from a LinkedIn URL — the strongest anchor; prefer it when you hold the URL. |
| `findPhone` | 15 | `firstName, lastName` (required), `companyName`, `companyDomain` | Phone from name, optionally refined with company info, when no LinkedIn URL exists. |
## What it's for
- ✅ **Final phone escalation** — a handful of high-value, qualified leads where the entire cheaper chain came back empty and the user explicitly asked for phones.
- ✅ **LinkedIn-anchored precision** — `findPhoneFromLinkedin` keys on the profile URL itself, so there's no name-ambiguity risk; output echoes `first_name` / `last_name` / `company_name` / `company_domain` for a sanity check, plus `direct_phone`.
- ❌ **Any default pipeline** — at 15/record, one ungated 100-row batch is 1,500 credits. The chain starts at `prospeo.findPhone` (3); see [`../references/alternatives.md`](../references/alternatives.md).
- ❌ **Email-anchored lookup** — there is no email input on either action; if all you hold is an email, `datagma.findPhone` (8) takes one ([`datagma.md`](datagma.md)).
## Patterns
### Pattern A — LinkedIn-anchored last rung (gated)
```bash
# Explicit user request + qualified leads only — 15 credits each
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"cleon1","actionSlug":"findPhoneFromLinkedin"}' \
--records '[{"linkedinUrl":"https://linkedin.com/in/alicesmith"}]' \
--wait-until-finished
```
### Pattern B — Name + company when no URL exists
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"cleon1","actionSlug":"findPhone"}' \
--records '[{"firstName":"Alice","lastName":"Smith","companyName":"Acme","companyDomain":"acme.com"}]' \
--wait-until-finished
```
Only `firstName` + `lastName` are required — but a bare name is the weakest possible anchor at the highest possible price. Always pass `companyName` and/or `companyDomain` when you have them.
## Common pitfalls
- **15 credits is the catalog's phone ceiling.** That's ~2× `datagma` (8), 5× `prospeo` (3), or 150 `waterfall.verifyEmail` calls. Never reach for cleon1 first; run it only on the residue of the full chain.
- **camelCase inputs** — `linkedinUrl`, `firstName`, `companyDomain`. Don't reuse other providers' snake_case shapes.
- **No email input.** Neither action accepts an email identifier; route email-anchored phone lookups to `datagma.findPhone` (8) instead.
- **Name-only `findPhone` on common names** — with no company anchor, a wrong-person match still bills 15. Verify the echoed `company_name` / `linkedin_url` in the output against your record.
## Anti-patterns
- **Including cleon1 in a recipe's default chain.** Every recipe gates phone lookup behind qualification and explicit request; the premium rung doubles down on that gate.
- **Batch-running it "to fill the phone column".** Pilot on ≤5 rows; if the cheaper rungs' misses were data-quality misses (bad URLs, stale companies), cleon1 will miss on the same rows — for 15 credits each.
## Position in the waterfall
- `findPhoneFromLinkedin` / `findPhone` — **CONTACT stage, terminal rung** of the phone chain: `prospeo` (3) → `FullEnrich` (6) → `waterfall` (7) → `datagma` (8) → **cleon1 (15)**. Explicit user request only, qualified leads only.
- Phones don't flow to VERIFY (that's an email stage) — but the record they attach to should already be verified before you spend 15 credits on it.
## Recurring use
No scheduled fit — per-record phone lookup only, and at 15 credits it should barely appear in recurring infrastructure at all.
- **In-play gate:** if cleon1 sits in a play, it fires only where the phone column is **still empty after the entire cheaper chain** (`prospeo` → `FullEnrich` → `waterfall` → `datagma`) has run and missed, on qualified rows only — a re-evaluation that re-fires it bills 15 per row.
- **Stable output:** a found phone doesn't decay; there is no case for re-running cleon1 on a row that already holds one.
## Action shape
`{"kind":"connector","integrationSlug":"cleon1","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/companyEnrich.md
---
provider: companyEnrich
category: enrichment
last-reviewed: 2026-07-09
---
# companyEnrich (CompanyEnrich)
Budget company enrichment. `enrichByDomain` (0.25) carries a **fuller field set than the 0.01 stack default** `aiArk.enrichCompany` ([`../references/stage-action-map.md`](../references/stage-action-map.md)), so [`../references/alternatives.md`](../references/alternatives.md) promotes it on the rows aiArk returns thin rather than running it across the whole list. `findSimilarCompanies` (1 **per company returned**) is a lookalike finder for seeding TAM expansion — the only unit-priced action here, so `limit` is the cost dial.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `enrichByDomain` | 0.25 | `domain` (required) | Cheapest domain → firmographics: industry, employees, revenue, technologies, funding, socials, NAICS codes. |
| `findSimilarCompanies` | 1 **per item** | `domain` (required), `filters` (industries, technologies, keywords, region/country/state/city, employeeCountMin/Max, revenueMin/Max, yearFoundedMin/Max), `limit` | Lookalikes of a seed company, filtered — TAM expansion from a best-customer domain. |
## What it's for
- ✅ **Depth on the rows the 0.01 rung left thin** — output covers firmographics plus `technologies`, `financial.funding` history, and a full `socials` block, so one call can serve several downstream columns that `aiArk.enrichCompany` leaves empty.
- ✅ **Lookalike seeding** — `findSimilarCompanies` from a Closed-Won domain, filtered to your ICP's size/geo, feeds [`../recipes/build-tam.md`](../recipes/build-tam.md).
- ❌ **First-stop enrichment across a whole list** — `aiArk.enrichCompany` (0.01) is 25× cheaper and answers most firmographic questions; alternatives.md promotes this action on the residue, not ahead of it.
- ❌ **Person data** — company-only provider; no contact or email actions.
## Patterns
### Pattern A — Cheap domain → firmographics
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"companyEnrich","actionSlug":"enrichByDomain"}' \
--records '[{"domain":"acme.com"},{"domain":"globex.com"}]' \
--wait-until-finished
```
`domain` is the only input — a bare domain like `company.com`, not a URL. No name- or LinkedIn-based lookup on this action.
### Pattern B — Lookalikes from a seed domain (cost-capped)
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"companyEnrich","actionSlug":"findSimilarCompanies"}' \
--data '{"domain":"acme.com","filters":{"country":"United States","employeeCountMin":50,"employeeCountMax":500,"industries":["Software"]},"limit":25}' \
--wait-until-finished
```
**Always set `limit`** — pricing is 1 credit × companies returned.
## Common pitfalls
- **`findSimilarCompanies` is per-item.** Unlike `enrichByDomain`'s fixed 0.25, an uncapped similar-companies call bills 1 credit for every company in the result. `limit: 100` = 100 credits.
- **`employees` and `revenue` come back as strings** (range buckets), not numbers — cast or map before filtering on them in storage SQL.
- **Filter values are free-text via the CLI.** The UI backs `industries` / `technologies` / `keywords` / geo filters with autocomplete lists; from the CLI you pass plain strings, so misspelled values silently narrow results to zero.
- **Rate limit 300/minute** (spread) — fine for most batches, but a five-figure TAM enrich stretches over the better part of an hour.
## Anti-patterns
- **Running it beside `aiArk.enrichCompany` "for extra coverage".** Paying 0.01 + 0.25 per row for overlapping firmographics wastes the cheaper rung's whole point. Run aiArk across the list, then this one only on the rows it left empty — per [`../references/cost-discipline.md`](../references/cost-discipline.md).
- **Using lookalikes as final TAM rows without enrichment.** Similar-company results are seeds — flow them through the normal ENRICH → dedupe path before counting them as TAM.
## Position in the waterfall
- `enrichByDomain` — **ENRICH (company), second rung**: `aiArk` 0.01 ✅ → **`companyEnrich` 0.25** → `linkedin` 0.25–0.5 → `waterfall` 1 ✅ → `peopleDataLabs` 3.
- `findSimilarCompanies` — **SOURCE-adjacent**: lookalike expansion feeding TAM builds, upstream of ENRICH.
## Recurring use
- **Scheduled lookalikes:** `findSimilarCompanies` can re-run weekly to keep a TAM growing (persona/company searches → weekly; [`../recipes/save-as-play.md`](../recipes/save-as-play.md)) — but results overlap heavily run to run and bill 1 credit **per company returned**, so keep `limit` tight and dedup against the Companies model (a free `storage query execute` on `domain`) before any downstream enrichment.
- **In-play gate:** `enrichByDomain` runs only where the row's firmographic target columns are still empty after the 0.01 rung — never beside it (see anti-patterns).
- **Stable data:** firmographics don't decay; a scheduled re-enrich of an existing TAM just re-bills unchanged data at 0.25/row.
## Action shape
`{"kind":"connector","integrationSlug":"companyEnrich","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/contactOut.md
---
provider: contactOut
category: contact
last-reviewed: 2026-07-09
---
# contactOut (ContactOut)
LinkedIn-URL-anchored contact info — emails and phones from a profile URL, plus a filter-based people/company search. **Mid-tier fallback**: reach for it when the priority stack (aiArk → FullEnrich → waterfall) misses, or for its free company-domain lookup.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `enrich` (company) | **0** | `objectType:"company"`, `companyDomain` | Free company profile from a domain (size, industry, revenue, funding, …). |
| `enrich` (contact) | 1 / 2 / 3 | `objectType:"contact"`, `linkedinUrl`, `includePhone`, `emailType` | Contact from a LinkedIn URL. 1 = profile only; 2 = + emails (`emailType` set); 3 = `includePhone: true`. |
| `search` | 1 or 3 **per item** | `objectType:"people"|"company"`, `filters`, `dataTypes`, `revealInfo` | People/company search. People: 1/item; `revealInfo: true` (emails + phones in response) = 3/item. |
Cost is driven by the **config you request**, not the data returned: asking for phone (`includePhone: true`) prices the contact enrich at 3 even before you know a phone exists.
## What it's for
- ✅ **Emails/phone when you already hold the LinkedIn URL** — `enrich` is keyed on `linkedinUrl` only; no name/domain fallback inputs.
- ✅ **Free company lookup** — `enrich` with `objectType: "company"` costs 0 credits and returns firmographics (`size`, `industry`, `revenue`, `employees`, `funding`, …).
- ✅ **Phone at the prospeo price point** — contact enrich with `includePhone` is 3, the same as `prospeo.findPhone`, and returns emails in the same call.
- ❌ **Primary people sourcing** — `search` at 1/item (3/item revealed) vs `salesNavigator.searchLeads` at 0.02. Mid-tier when other sources miss (see [`../references/stage-action-map.md`](../references/stage-action-map.md)).
- ❌ **Enrichment without a LinkedIn URL** — resolve the URL first ([`../recipes/linkedin-url-lookup.md`](../recipes/linkedin-url-lookup.md)) or use `waterfall.enrichContact` (2), which accepts name/domain/email.
## Patterns
### Pattern A — Contact enrich from a LinkedIn URL
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"contactOut","actionSlug":"enrich"}' \
--data '{
"objectType": "contact",
"linkedinUrl": "https://linkedin.com/in/alicesmith",
"includePhone": false,
"emailType": ["work"]
}' \
--wait-until-finished
```
`emailType` takes `"work"` and/or `"personal"`. Always pass `includePhone` and `emailType` explicitly — the contact branch of the schema requires them, and each one you add moves the price tier (base 1 → +emails 2 → +phone 3).
### Pattern B — Free company profile
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"contactOut","actionSlug":"enrich"}' \
--data '{"objectType":"company","companyDomain":"acme.com"}' \
--wait-until-finished
```
Zero credits. Worth a probe before paying `waterfall.enrichCompany` (1) on unmatched-by-cargo companies.
### Pattern C — People search, reveal only the keepers
```bash
# Step 1 — search WITHOUT revealing (1/item): filter and shortlist first
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"contactOut","actionSlug":"search"}' \
--data '{
"objectType": "people",
"filters": [
{"name": "job_title", "values": ["VP Sales", "Head of Sales"]},
{"name": "location", "values": ["Germany"]},
{"name": "current_titles_only", "booleanValue": true}
],
"dataTypes": ["work_email"]
}' \
--wait-until-finished
# Step 2 — enrich only the shortlist via Pattern A (or re-search with revealInfo: true)
```
The `filters` array is discriminated by `name`: list-type filters (`skills`, `education`, `location`, `company`, `domain`, `job_title`) take `values`; catalog filters (`industry`, `company_size`, `years_of_experience`, `years_in_current_role`) take `autocompleteValues`; toggles (`current_titles_only`, `current_company_only`, `include_related_job_titles`) take `booleanValue`; `name` (person name) takes `value`. `dataTypes` (`personal_email` / `work_email` / `phone`) filters to profiles that *have* that data without revealing it.
## Common pitfalls
- **`revealInfo: true` on a broad search.** Search is billed **per item returned** — 3/item revealed. Search unrevealed (1/item), shortlist, then reveal or enrich only the keepers.
- **Wrong value key for a filter.** Putting `values` where the filter wants `autocompleteValues` (or vice versa) fails validation — match the key to the filter name per the table above.
- **`includePhone: true` "just in case."** It triples the enrich price and deducts phone credits whether or not you need the number. Default `false`; escalate only for phone-first plays.
## Anti-patterns
- **contactOut for bulk sourcing.** At 1–3/item, a 1,000-row search costs 1,000–3,000 credits; `salesNavigator.searchLeads` covers the same B2B ground at 0.02. Use contactOut search only when LinkedIn-anchored and priority sources miss.
- **Skipping verification.** Returned emails still go through `waterfall.verifyEmail` (0.1) — or `zeroBounce.verifyEmail` (0.1) as a second opinion — before any send.
## Position in the waterfall
- Contact enrich — **mid rung** of the email/phone chain: after aiArk + FullEnrich, alongside `waterfall.enrichContact` (2); its `includePhone` tier (3) sits at the `prospeo.findPhone` price, below FullEnrich (6) and waterfall (7).
- `search` — coverage fallback when salesNavigator/icypeas miss the segment.
## Recurring use
No scheduled fit — per-record enrichment only; a re-run `search` re-bills every item returned (1–3/item) for a mostly unchanged result set.
- **In-play gate:** run contact `enrich` only where the target field is still empty — gate on empty `email` (1–2-tier) or empty phone (3-tier), and keep `includePhone: false` in the play config: it triples the price on every row the play re-touches.
- **The free rung is safe to repeat:** company `enrich` (0 credits) can sit ungated in a play — it never bills. Every paid action needs the empty-field gate.
- **Stability:** emails/phones behind a LinkedIn URL don't decay fast — re-enriching filled rows on a timer just re-bills unchanged data.
## Action shape
`{"kind":"connector","integrationSlug":"contactOut","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/datagma.md
---
provider: datagma
category: enrichment
last-reviewed: 2026-07-09
---
# datagma
Contact-info finder: one mid-tier email action and two premium phone actions. `findEmail` (1) is an **alt mid-tier rung** of the find-email chain ([`../references/stage-action-map.md`](../references/stage-action-map.md)) — same price as the `FullEnrich` default, so it earns a slot only as an escalation with a different underlying source. `findPhone` / `findPhoneAndEmail` (8) are the **second-most-expensive phone tier in the catalog** — last resort before `cleon1` (15), never the first stop.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `findEmail` | 1 | `firstName, lastName, companyName` (all required) | Escalation rung on the find-email chain when cheaper sources miss. |
| `findPhone` | 8 | `email`, `personLinkedInUrl` | Phone lookup from an email or LinkedIn URL. **Last-rung pricing.** |
| `findPhoneAndEmail` | 8 | `firstName, lastName, companyName` (all required) | Phone + email in one call from name + company. |
## What it's for
- ✅ **Find-email escalation** — a different index than `FullEnrich` / `hunter`, so it can hit where the standard chain (FullEnrich 1 → hunter 0.5 → peopleDataLabs 3) misses. Slot it beside the other 1-credit alternates ([`../references/alternatives.md`](../references/alternatives.md), "Alt mid-tier").
- ✅ **Phone from an identifier the cheaper rungs rejected** — `findPhone` takes `email` or `personLinkedInUrl`; useful for the handful of high-value leads left after `prospeo` → `FullEnrich` → `waterfall` all missed.
- ❌ **First-stop anything** — every datagma action has a cheaper chain-leader: email starts at `FullEnrich.findEmail` (1, best hit rate) with `hunter` (0.5) behind it; phone starts at `prospeo.findPhone` (3).
## Patterns
### Pattern A — Escalation rung of the find-email chain
```bash
# Only on rows where the earlier rungs returned nothing
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"datagma","actionSlug":"findEmail"}' \
--records '[
{"firstName":"Alice","lastName":"Smith","companyName":"Acme"},
{"firstName":"Bob","lastName":"Jones","companyName":"Globex"}
]' \
--wait-until-finished
```
All three fields are required — no domain-only or full-name variants. Every hit still flows to VERIFY: free pre-cull, then `waterfall.verifyEmail` (0.1).
### Pattern B — Last-rung phone lookup (gated)
```bash
# Qualified, high-value leads only — 8 credits each
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"datagma","actionSlug":"findPhone"}' \
--records '[{"personLinkedInUrl":"https://linkedin.com/in/alicesmith","email":"alice@acme.com"}]' \
--wait-until-finished
```
Neither field is marked required in the schema — pass at least one identifier; both together give the best match odds.
## Common pitfalls
- **camelCase inputs** — `firstName`, `lastName`, `companyName`, `personLinkedInUrl` (note the capital `In`). Don't reuse the snake_case shapes from `waterfall` / `hunter` here.
- **`findPhoneAndEmail` saves nothing.** 8 credits — more than `FullEnrich.findPhoneAndEmail` (7) and the same as `findPhone` alone, so the "free" email is only worth it if you'd otherwise pay both. If the email rung already ran, calling it re-bills work you've done.
- **`companyName`, not domain** — `findEmail` / `findPhoneAndEmail` key on the company **name**; there is no domain input, so ambiguous names ("Apex") depress the hit rate.
- **8 credits buys a lot elsewhere** — the whole cheaper phone chain (`prospeo` 3 + `FullEnrich` 6 escalation is 9 for two independent attempts) or 80 verifications. Gate per [`../references/cost-discipline.md`](../references/cost-discipline.md).
## Anti-patterns
- **Phone actions in a default pipeline.** Phone lookup is gated to qualified leads in every recipe; at 8/record an ungated batch is the fastest way to burn a budget.
- **Skipping verification because the email "came with" the phone.** `findPhoneAndEmail` output is a finder result like any other — VERIFY stage (`waterfall.verifyEmail`, 0.1) still applies.
## Position in the waterfall
- `findEmail` — **CONTACT stage, escalation rung**: after `FullEnrich` (1) and the 0.5 mid-tiers (`hunter` / `prospeo` / `findyMail` / `leadMagic`), beside the other 1-credit alternates. Demote it for the batch if it misses on the pilot's first ~10 rows.
- `findPhone` / `findPhoneAndEmail` — **CONTACT stage, last rung** of the phone chain: `prospeo` (3) → `FullEnrich` (6) → `waterfall` (7) → **datagma (8)** → `cleon1` (15, premium).
- Every found email flows to **VERIFY** (`waterfall.verifyEmail`, 0.1) before activation.
## Recurring use
No scheduled fit — per-record enrichment only; every datagma action is an escalation rung, never a monitor.
- **In-play gate:** `findEmail` runs only where `email` is still empty *and* the earlier rungs already missed; `findPhone` / `findPhoneAndEmail` (8) additionally gate on an empty phone field **and** the qualified-lead condition — the anti-pattern above ("ungated batch") applies doubly to a play that re-evaluates its segment.
- **Stability:** a filled email or phone doesn't improve on re-lookup — re-running datagma on enriched rows re-bills last-rung prices for the same data.
## Action shape
`{"kind":"connector","integrationSlug":"datagma","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/dropcontact.md
---
provider: dropcontact
category: contact (email)
last-reviewed: 2026-07-09
---
# dropcontact (Dropcontact)
**The French/EU tier of the find-email waterfall.** One credits-based action, `findEmail` (1) — same price as the priority default `FullEnrich.findEmail` (1), so it never wins on cost. It wins on **coverage**: it takes and returns French business-registry data (SIREN/SIRET, NAF codes, VAT), so swap it into the CONTACT stage when the list skews French/EU, or run it on FullEnrich misses for those geographies (see [`../references/alternatives.md`](../references/alternatives.md), Find email alternatives — "Better for French/EU data").
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `findEmail` | 1 | `first_name, last_name, full_name, email, phone, company, website, linkedin, company_linkedin, country, num_siren, siret` — all optional in the schema | Find a person's email + enrich person/company, with French registry fields. |
The connector also accepts your own Dropcontact API key (`apiKey`) — same action, billed to your Dropcontact plan instead of credits.
## What it's for
- ✅ **French/EU contact lists** — the geography where its index beats the default stack; SIREN/SIRET in, SIREN/SIRET/NAF/VAT out.
- ✅ **Escalation on FullEnrich misses for EU rows** — same 1-credit price, different underlying source.
- ❌ **Generic find-email first rung** — `FullEnrich.findEmail` (1) leads; mid-tiers (`hunter`, 0.5) come second (see [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md)).
- ❌ **Budget rung** — at 1 credit it's the top price tier; `icypeas.findEmail` (0.1) is the cheap last resort.
## Patterns
### Pattern A — Find-email rung for a French list
```bash
# Run on the FR/EU rows (or on FullEnrich misses for those rows)
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"dropcontact","actionSlug":"findEmail"}' \
--records '[
{"first_name":"Alice","last_name":"Martin","website":"acme.fr"},
{"full_name":"Bob Durand","company":"Globex","country":"France"}
]' \
--wait-until-finished
```
No field is schema-required, but a call without at least a name plus a company identifier can't resolve anything — pass `website` over `company` when you have it, and `linkedin` / `company_linkedin` / `siret` when known.
## Output fields
`email` is an **array** of `{email, qualification}` objects — not a string. Plus person fields (`first_name, last_name, full_name, civility, job, job_function, job_level, linkedin, phone, location`) and company fields including the French registry block (`company, website, company_linkedin, nb_employees, siren, siret, siret_address, siret_zip, siret_city, naf5_code, naf5_des, vat, country`).
## Common pitfalls
- **`email` is an array.** Interpolate `{{nodes.<slug>.email[0].email}}`, not `{{nodes.<slug>.email}}` — the raw field is a list of candidates with per-candidate `qualification`.
- **`qualification` is the provider grading its own homework.** Route every returned email through the free pre-cull ([`../references/contact-accuracy.md`](../references/contact-accuracy.md)) then `waterfall.verifyEmail` (0.1) regardless.
- **Rate-limited to 60 calls/minute** (spread), with up to 10 backoff retries per call — large batches drain slowly by design. Don't re-trigger a batch that looks stalled; poll it.
## Anti-patterns
- **camelCase field names.** Inputs are **snake_case** (`first_name`, `company_linkedin`) — do NOT reuse FullEnrich's `firstName`/`domainName` shape here.
- **dropcontact for non-EU lists.** Same price as the default with weaker coverage outside its home turf — that's a swap-down, not a lateral move.
## Position in the waterfall
**CONTACT stage, geography-conditional rung.** For FR/EU-heavy lists: swap in at rung 1 alongside/instead of `FullEnrich.findEmail` (1); otherwise use only on FullEnrich misses for EU rows (see [`../references/stage-action-map.md`](../references/stage-action-map.md), Find email). Every hit still flows to VERIFY: free pre-cull → `waterfall.verifyEmail` (0.1).
## Recurring use
No scheduled fit — per-record enrichment only; wire `findEmail` as the FR/EU CONTACT rung inside a play, not on a timer.
- **In-play gate:** run only where the stored email column is still empty (gate on the *written-back* field — the node output `email` is an array, per pitfalls) and, in the escalation variant, only where FullEnrich already missed.
- **Trigger shape vs the rate limit:** a play fired by segment changes trickles rows naturally under the 60 calls/minute cap (see pitfalls) — a better fit than a scheduled bulk re-pull, which drains slowly and re-bills unchanged rows.
## Action shape
`{"kind":"connector","integrationSlug":"dropcontact","actionSlug":"findEmail"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/prospecting.md`](../recipes/prospecting.md) — the CONTACT rung of the find → enrich → verify → sync spine, for French/EU segments.
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — enrich → verify before personalization.
provider-playbooks/enrichCrm.md
---
provider: enrichCrm
category: enrichment
last-reviewed: 2026-07-09
---
# enrichCrm (EnrichCRM)
Flat-rate generalist: four actions, all **1 credit fixed** — person enrichment, email finding, company enrichment, and funding data. `findEmail` sits beside the other 1-credit alternates on the find-email chain ([`../references/stage-action-map.md`](../references/stage-action-map.md), "CRM-friendly fallback"), but `getFunding` **leads** the funding signal — it is the only credits-based funding action in the catalog, which is the role it plays in [`../recipes/funding-watch.md`](../recipes/funding-watch.md). Value here is breadth at a predictable price.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `enrichPerson` | 1 | `email` **or** `fullName` + `domainName` **or** `firstName` + `lastName` + `domainName` | LinkedIn-profile-flavored person enrichment (headline, role/seniority, company history, skills). |
| `findEmail` | 1 | `firstName, lastName, fullName, company, linkedInSlug, findEmailV2Country` | Escalation rung of the find-email chain, same price as the `FullEnrich` default. |
| `enrichCompany` | 1 | `domainName` (required), booleans `filmographic, tech, financial, companyFrench` | Company enrichment with toggleable data blocks. |
| `getFunding` | 1 | `domain` (required) | Financial + funding data — the catalog's only credits-based funding action. |
## What it's for
- ✅ **Funding signal** — `getFunding` is where every funding question in this pack lands. Coverage is strong on venture-backed companies and structurally thin on bootstrapped ones; there is no cheaper rung to try first.
- ✅ **Find-email escalation** — a different underlying source at the same 1-credit price as `FullEnrich.findEmail`; slot it beside `datagma` / `enrowio` in [`../references/alternatives.md`](../references/alternatives.md).
- ✅ **Person enrichment from an email you already hold** — `enrichPerson` output is rich on profile fields (`extractedRole`, `extractedSeniority`, `headline`, `pastCompaniesDetails`, `skillsList`) useful for scoring and personalization.
- ❌ **First-stop person or company enrichment** — the priority stack (`aiArk` → `waterfall` → `peopleDataLabs`) leads both of those stages. Funding is the exception: `getFunding` is first-stop because it is only-stop.
## Patterns
### Pattern A — Funding signal (from funding-watch)
```bash
# Gate on last_funding_round_at so recently-pulled rows are skipped
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"enrichCrm","actionSlug":"getFunding"}' \
--records '[{"domain":"acme.com"},{"domain":"globex.com"}]' \
--wait-until-finished
```
### Pattern B — Find-email escalation rung
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"enrichCrm","actionSlug":"findEmail"}' \
--records '[{"firstName":"Alice","lastName":"Smith","company":"Acme","findEmailV2Country":"France"}]' \
--wait-until-finished
```
Every hit still flows to VERIFY: free pre-cull, then `waterfall.verifyEmail` (0.1).
## Common pitfalls
- **`domainName` vs `domain`** — `enrichPerson` / `enrichCompany` key on `domainName`; `getFunding` keys on `domain`. Mixing them up silently drops the identifier.
- **`linkedInSlug` is a slug, not a URL** — `findEmail` wants the profile slug (`alicesmith`), not `https://linkedin.com/in/alicesmith`. Note the capital `In`.
- **`filmographic` is the literal schema key** on `enrichCompany` — yes, spelled with an `l`; `firmographic` is not a recognized field.
- **`enrichPerson` marks nothing required** — the schema accepts any subset, but the action needs one full identifier combo (email, or full name + domain, or first + last + domain); partial combos waste the credit.
## Anti-patterns
- **Running `enrichCompany` + `getFunding` on every row.** `enrichCompany`'s `financial: true` toggle and `getFunding` overlap; if you only need funding data, one credit suffices.
- **Skipping verification on `findEmail` hits.** 1-credit finders feed the same VERIFY stage as every other rung.
## Position in the waterfall
- `findEmail` — **CONTACT stage, alt 1-credit rung** beside `FullEnrich` (1, default) after the 0.5 mid-tiers.
- `enrichPerson` / `enrichCompany` — **ENRICH, fallback rungs** behind the stack (`aiArk` → `waterfall` → `peopleDataLabs`).
- `getFunding` — **SIGNAL (funding), sole rung**: no cheaper credits-based funding action exists.
## Recurring use
The one action here with a real monitor shape is `getFunding` — funding is an event stream, not a static field.
- **Scheduled pull:** re-run `getFunding` (1) **weekly** on the watched-companies segment, per [`../recipes/funding-watch.md`](../recipes/funding-watch.md); cadence defaults in [`../recipes/save-as-play.md`](../recipes/save-as-play.md). At 1 credit a row with no since-timestamp feed, cadence is the only cost dial — daily re-bills unchanged data six days in seven. Diff each pull against the stored funding fields so only *changed* rows trigger paid downstream steps.
- **In-play gate:** the other three actions are per-record enrichment — `findEmail` only where `email` is still empty, `enrichPerson` / `enrichCompany` only where their target profile/firmographic fields are unfilled. Re-running them on a timer re-bills stable data.
## Action shape
`{"kind":"connector","integrationSlug":"enrichCrm","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/enrichley.md
---
provider: enrichley
category: verification
last-reviewed: 2026-07-09
---
# enrichley (Enrichley)
Dedicated email verification. **One credits-based action, 0.1 credits** — same price as the priority-stack default `waterfall.verifyEmail` (0.1) and as `zeroBounce.verifyEmail` (0.1), which makes it another equal-cost second-opinion candidate when a first verdict is ambiguous. Two things set it apart: the action slug is **`verify`** (not `verifyEmail`), and its output flags **secure-email-gateway domains** (`mx_secure_email_gateway`), where SMTP-based verdicts are least trustworthy.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `verify` | 0.1 | `objectType` (required, const `"email"`), `email` (required) | Verify a single email's deliverability, with MX diagnostics. |
The connector also accepts your own Enrichley API key (`apiKey`) — same action, billed to your Enrichley plan instead of credits.
## What it's for
- ✅ **Second opinion on ambiguous verdicts** — equal cost to the default; independent signal on rows waterfall flagged catch-all/risky (especially if `zeroBounce` was already spent on this list).
- ✅ **SEG detection** — `mx_secure_email_gateway: true` tells you the domain sits behind a secure email gateway; verdicts there are structurally unreliable, so route those rows by risk policy rather than re-verifying.
- ❌ **Default verify step** — `waterfall.verifyEmail` (0.1) is the priority default (see [`../references/alternatives.md`](../references/alternatives.md), Verify email alternatives).
- ❌ **Very large lists** — `icypeas.verifyEmail` (0.01) is 10× cheaper when per-row diagnostics don't matter.
## Patterns
### Pattern A — Second-opinion re-verify
```bash
# Only on rows where the first verifier returned catch-all / ambiguous
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"enrichley","actionSlug":"verify"}' \
--records '[
{"objectType":"email","email":"alice@acme.com"},
{"objectType":"email","email":"bob@globex.com"}
]' \
--wait-until-finished
```
Keep an email when either verifier passes it cleanly; drop it only when both agree it's bad. Total 0.2/re-checked row — run it on the ambiguous subset, not the whole list.
## Output fields
`email, valid, result, mx_domain, mx_provider, mx_secure_email_gateway, email_type, credits_consumed`. Filter on `valid` (boolean) / `result`; use `mx_provider` + `mx_secure_email_gateway` to explain low-confidence verdicts, `email_type` to spot role/free addresses, and `credits_consumed` when auditing spend.
## Common pitfalls
- **The action slug is `verify`, not `verifyEmail`.** Every other verify provider in the catalog uses `verifyEmail`; copy-pasting that slug here fails.
- **`objectType` is required.** The payload needs `"objectType":"email"` alongside `email` — omitting it fails schema validation.
- **Rate limits differ by billing mode:** 15 calls/second on credits, 5/second on an own-key connector (both spread). Large batches drain at that pace; poll, don't re-trigger.
## Anti-patterns
- **enrichley as the first verify rung.** Same price as the default but outside the priority stack — swap it in deliberately (second opinion, provider outage, coverage test), not by default (see [`../references/stage-action-map.md`](../references/stage-action-map.md), Verify email).
- **Skipping verification because the finder said "verified".** Providers grade their own homework — every found email gets an independent verify step (see [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md)).
- **Paid verify before the free cull.** Run the free pre-cull from [`../references/contact-accuracy.md`](../references/contact-accuracy.md) first — dropping invalid/disposable/duplicate rows is free and shrinks the paid batch.
## Position in the waterfall
**VERIFY stage, alternative rung.** Default chain: `waterfall.verifyEmail` (0.1) first; `zeroBounce.verifyEmail` / `enrichley.verify` (0.1) as equal-cost second opinions; `icypeas.verifyEmail` (0.01) when volume dominates.
## Recurring use
Verification status decays, but re-verifying a whole list on a timer re-bills every row — the recurring shape is **verify-before-send**, not verify-on-schedule.
- **In-play gate:** gate the `verify` node to rows entering a send wave whose `*_verified_at` timestamp is missing or stale (older than the send cycle) — never the full segment on each evaluation.
- **Second-opinion discipline recurs too:** in a play, keep enrichley on the ambiguous subset only (catch-all/risky from the first verifier) — 0.2/re-checked row compounds fast on repeat.
- **SEG rows don't ripen:** `mx_secure_email_gateway: true` domains stay structurally unverifiable — route them by risk policy once and exclude them from re-verification.
## Action shape
`{"kind":"connector","integrationSlug":"enrichley","actionSlug":"verify"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — the verify step before personalization; never sequence unverified emails.
- [`../recipes/prospecting.md`](../recipes/prospecting.md) — the verify rung of the find → enrich → verify → sync spine.
provider-playbooks/enrowio.md
---
provider: enrowio
category: contact (email)
last-reviewed: 2026-07-09
---
# enrowio (Enrowio)
Email finder + verifier pair. `findEmail` (1) is an **alt mid-tier finder at the top price tier** — same cost as the priority default `FullEnrich.findEmail` (1), so it earns a call only as an extra escalation rung on stack misses or via an existing Enrow subscription. `verifyEmail` (0.1) matches the priority default `waterfall.verifyEmail` (0.1), making it another equal-cost second-opinion verifier. Input quirk to remember: the finder takes a single **`fullName`** — there is no first/last split.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `findEmail` | 1 | `fullName` (required), `companyDomain`, `companyName`, `countryCode` | Extra escalation rung when the default find-email chain misses. |
| `verifyEmail` | 0.1 | `email` (required), `countryCode` | Equal-cost second opinion on ambiguous verdicts. |
The connector also accepts your own Enrow API key (`apiKey`) — same actions, billed to your Enrow plan instead of credits.
## What it's for
- ✅ **Deep escalation on misses** — after `FullEnrich.findEmail` (1) → `hunter.findEmail` (0.5) come up empty, a differently-sourced 1-credit rung for rows worth the spend.
- ✅ **Existing Enrow subscription** — own-key connector makes both actions quota-billed.
- ✅ **Second-opinion verify** — 0.1, same as the default; independent signal on catch-all/ambiguous rows.
- ❌ **Find-email first rung** — `FullEnrich.findEmail` (1) leads the chain; at equal price enrowio brings no default advantage (see [`../references/alternatives.md`](../references/alternatives.md)).
- ❌ **Budget finder** — `hunter.findEmail` (0.5) and `icypeas.findEmail` (0.1) are the cheaper tiers.
## Patterns
### Pattern A — Escalation rung of the find-email chain
```bash
# Run ONLY on rows the earlier rungs missed
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"enrowio","actionSlug":"findEmail"}' \
--records '[
{"fullName":"Alice Martin","companyDomain":"acme.com"},
{"fullName":"Bob Durand","companyName":"Globex","countryCode":"FR"}
]' \
--wait-until-finished
```
`companyDomain` beats `companyName` for accuracy and accepts multiple formats (`"apple.com"`, `"https://www.apple.com"`). `countryCode` is ISO 3166 alpha-2 and matters mainly when matching by `companyName`.
### Pattern B — Second-opinion verify
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"enrowio","actionSlug":"verifyEmail"}' \
--records '[{"email":"alice@acme.com"}]' \
--wait-until-finished
```
The catalog dump documents no output schema for either action — inspect the first run's output (resolve the output schema via `cargo-orchestration`, or read the run's `runContext`) before filtering on field names.
## Common pitfalls
- **`fullName` only.** No `firstName`/`lastName` fields — concatenate before calling (e.g. `{{nodes.<slug>.first_name}} {{nodes.<slug>.last_name}}` in an expression).
- **camelCase inputs.** `fullName`, `companyDomain`, `countryCode` — don't reuse hunter/dropcontact's snake_case shape here.
- **Rate-limited to 60 calls/minute** (spread), with up to 8 backoff retries per call — large batches drain slowly. Poll; don't re-trigger.
## Anti-patterns
- **enrowio.findEmail as an early rung.** 1 credit buys the priority default; run this only on residual misses that are individually worth it, per the pilot gate in [`../references/cost-discipline.md`](../references/cost-discipline.md).
- **Trusting the finder's own result.** Every found email goes through the free pre-cull ([`../references/contact-accuracy.md`](../references/contact-accuracy.md)) then `waterfall.verifyEmail` (0.1) — providers grade their own homework (see [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md)).
## Position in the waterfall
- `findEmail` — **late escalation rung** of the CONTACT-stage find-email chain, outside the default order (see [`../references/stage-action-map.md`](../references/stage-action-map.md), Find email — "Alt mid-tier").
- `verifyEmail` — **VERIFY stage, alternative rung** at the default 0.1 price, alongside `zeroBounce.verifyEmail` and `enrichley.verify`.
## Recurring use
No scheduled fit — both actions are per-record; their recurring shape is a gated play node, not a timer.
- **`findEmail` gate:** run only where `email` is still empty *and* the earlier chain rungs already missed — an ungated 1-credit escalation rung re-billing on every segment re-evaluation defeats its reason for existing (see the anti-pattern above).
- **`verifyEmail` gate:** verification decays, but a timed re-verify re-bills the list — gate the node to rows entering a send wave with a missing or stale `*_verified_at` timestamp (verify-before-send).
- **Rate limit:** the 60 calls/minute cap (see pitfalls) suits trickle-through segment-change triggers better than scheduled bulk re-pulls.
## Action shape
`{"kind":"connector","integrationSlug":"enrowio","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/prospecting.md`](../recipes/prospecting.md) — CONTACT and VERIFY rungs of the find → enrich → verify → sync spine.
provider-playbooks/exa.md
---
provider: exa
category: research (semantic search)
last-reviewed: 2026-08-15
---
# exa (Exa)
One action: semantic web search with a **category filter** and five search modes. What distinguishes it from the other search rungs is `category`, which restricts results to a document type (`company`, `news`, `financial report`, `research paper`, `people`, `tweet`, `personal site`) instead of hoping a keyword query lands there.
Billed **0.175 fixed + 0.025 per result**, so cost scales with `numResults` rather than with query count.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `search` | 0.175 fixed **+ 0.025 per result** | `query` (required), `searchType`, `category`, `numResults` (1–100), `includeText`, `includeDomains`, `excludeDomains`, `startPublishedDate`, `endPublishedDate`, `startCrawlDate`, `endCrawlDate` | Semantic search restricted to a document type. |
`searchType: "deep"` raises the fixed component from 0.175 to **0.3**; the per-result 0.025 is unchanged. Every other mode (`neural`, `fast`, `auto`, `instant`) bills the standard 0.175.
Worked cost: `numResults: 10` is 0.425, `numResults: 25` is 0.8, `numResults: 100` is 2.675. **Set `numResults` to what you will actually read.**
Rate limited to 10 calls per second.
## What it's for
- ✅ **Category-restricted research** — `category: "company"` returns company pages rather than blog posts about companies. `category: "financial report"` and `"news"` are the same idea for the questions where a general web search drowns in listicles.
- ✅ **Date-bounded questions** — the four date filters (`startPublishedDate` / `endPublishedDate` for publication, `startCrawlDate` / `endCrawlDate` for indexing) make "in the last quarter" a filter rather than a hope.
- ✅ **Semantic queries** — `searchType: "neural"` matches meaning rather than keywords, which is what you want for "companies that talk about the problem we solve".
- ❌ **Reading a page you already have** — `parallel.extract` (0.025/URL) or `firecrawl.scrape` (0.05). Search is the wrong instrument and 7x the price for a known URL.
- ❌ **Structured firmographics** — `companyEnrich.enrichByDomain` (0.25) returns typed fields. Search returns pages.
- ❌ **Plain keyword lookups** — `serper.search` and `firecrawl.search` are 0.05 flat for up to 100 results. If the query is a keyword and the category filter buys nothing, exa costs more for the same answer.
## Patterns
### Pattern A — Find company pages, not articles about companies
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"exa","actionSlug":"search"}' \
--data '{
"query": "B2B data enrichment platforms for revenue teams",
"category": "company",
"searchType": "neural",
"numResults": 25
}' \
--wait-until-finished
```
0.8 credits for 25 company pages. Without `category`, the same query returns comparison listicles and vendor blogs, and you pay an LLM step to sort them out.
### Pattern B — What has been said recently, bounded by date
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"exa","actionSlug":"search"}' \
--data '{
"query": "Acme product strategy and priorities",
"category": "news",
"startPublishedDate": "2026-01-01",
"numResults": 10,
"includeText": true
}' \
--wait-until-finished
```
`includeText: true` returns extracted page text with each result, which can remove a separate extraction call. Compare the two before assuming: ten results with text may beat one search plus ten `parallel.extract` calls (0.425 against 0.675).
## Common pitfalls
- **Leaving `numResults` at a large default.** Cost is per result. This is the opposite of `serper`, where raising the limit is free, and the two are easy to confuse when copying a pattern between them.
- **Reaching for `searchType: "deep"` by reflex.** It nearly doubles the fixed cost. Use `auto` unless a shallower mode has already come back thin on this specific query.
- **Skipping `category`.** It is the reason to choose exa over the 0.05 search rungs. A query without it is a more expensive `serper.search`.
- **Treating results as records.** Search output is pages. Resolve to domains and enrich before anything enters a model.
## Anti-patterns
- **Per-row exa search across a segment.** 0.425 a row at ten results is 212 credits over 500 rows, for research most of those rows will never be acted on. Search the segment definition once; do not fan out per record.
- **exa where the answer needs to be structured.** `parallel.createTask` (0.125 at `lite`) returns a schema you define. exa returns ranked pages, and turning those into fields is another paid step.
## Position in the waterfall
- **Third rung for web search**, behind `serper.search` (0.05) and `firecrawl.search` (0.05) on price. It moves to first when the question needs a **category or date restriction**, which neither of those expresses.
- Ahead of `parallel.search` (0.125 + 0.025/item) when the filter is a document type; behind it when the steering wanted is an objective in natural language.
## Action shape
`{"kind":"connector","integrationSlug":"exa","actionSlug":"search"}`. **No `connectorUuid` in `config`.** Filters go in `--data`.
## Pairs with
- [`../recipes/icp-discovery.md`](../recipes/icp-discovery.md) — `category: "company"` sourcing against a described profile.
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — date-bounded news for a personalization line that is actually recent.
## Recurring use
- **Cap `numResults` in the node.** A recurring search whose result count can vary has a bill that varies with it, and per-result pricing makes that compound quietly.
- **Date filters are what make a scheduled search legitimate.** Re-running an unbounded query weekly returns the same pages and re-bills them. Move `startPublishedDate` forward with the schedule so each run pays only for what is new.
- **In-play gate:** filter to rows whose research column is empty or whose last search predates the current window.
provider-playbooks/findyMail.md
---
provider: findyMail
category: contact (email)
last-reviewed: 2026-07-09
---
# findyMail
Mid-tier email finder with a phone lookup and a verify on the side. `findEmail` (0.5) is an alternative rung to `hunter.findEmail` in the find-email chain — sometimes finds what hunter misses ([`../references/alternatives.md`](../references/alternatives.md)) — and is the only 0.5-tier finder that also accepts a **LinkedIn URL** as input. Not the default: that's `FullEnrich.findEmail` (1) per [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md). `findPhone` (5) sits mid-chain between prospeo (3) and FullEnrich (6).
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `verifyEmail` | 0.25 | `email` | Bounce-risk check. Off-default — `waterfall.verifyEmail` (0.1) is cheaper. |
| `findEmail` | 0.5 | `name, domain, linkedinUrl` | Mid-tier email finder; the 0.5-tier option that takes a LinkedIn URL. |
| `findPhone` | 5 | `linkedinUrl` (required) | Mid-rung phone lookup between prospeo (3) and FullEnrich (6). |
## What it's for
- ✅ **Chain rung when hunter misses** — different underlying source at the same 0.5 price; swap it in for (or after) hunter on segments where hunter under-covers.
- ✅ **Email from a LinkedIn URL at the cheap tier** — when the row has a profile URL but no clean name/domain pair, `findEmail` takes `linkedinUrl` directly.
- ✅ **Rich hit payload** — output includes `job_title`, `company`, `linkedin_url`, and person/company geo fields alongside the email, useful for coalescing.
## Patterns
### Pattern A — Email finder rung
```bash
# Run on the rows the earlier rung missed
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"findyMail","actionSlug":"findEmail"}' \
--records '[
{"name":"Alice Smith","domain":"acme.com"},
{"linkedinUrl":"https://linkedin.com/in/bobjones"}
]' \
--wait-until-finished
```
`name` is a **single full-name string** — not `firstName`/`lastName`. Pass `name` + `domain`, or `linkedinUrl`, per row.
### Pattern B — Mid-rung phone lookup
```bash
# Only on qualified leads that prospeo.findPhone missed
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"findyMail","actionSlug":"findPhone"}' \
--records '[{"linkedinUrl":"https://linkedin.com/in/alicesmith"}]' \
--wait-until-finished
```
LinkedIn URL is the only accepted identifier. Output is `phone` + `line_type`.
## Common pitfalls
- **`findPhone` at 5 credits is a mid-rung, not a starting point.** The phone chain opens with `prospeo.findPhone` (3); phone lookups run on qualified leads only, after explicit user request ([`../references/cost-discipline.md`](../references/cost-discipline.md)).
- **`verifyEmail` at 0.25 is 2.5× the default.** Use `waterfall.verifyEmail` (0.1) — or `icypeas.verifyEmail` (0.01) for very large lists — unless the user's own findyMail API key makes it free to them.
- **A found email is not a safe email.** Free pre-cull with `validate-emails.ts` ([`../references/contact-accuracy.md`](../references/contact-accuracy.md)), then `waterfall.verifyEmail` (0.1) on every hit before it reaches a sequencer.
## Anti-patterns
- **`firstName`/`lastName` or `first_name`/`last_name` fields.** findyMail's finder takes `name` (one string), `domain`, `linkedinUrl` — nothing else. Wrong field names are silently ignored and the row misses.
- **Running findyMail AND hunter on the same rows by default.** They're alternates at the same tier — waterfall on misses, don't double-spend on hits ([`../references/waterfall-strategy.md`](../references/waterfall-strategy.md)).
- **Trusting the finder's hit as verified.** Verification is an independent step, always.
## Position in the waterfall
- `findEmail` — CONTACT-stage mid-tier rung, interchangeable with `hunter.findEmail` / `leadMagic.findEmail` (all 0.5); pick by segment coverage and demote whichever misses on the pilot's first ~10 rows.
- `findPhone` — **rung 2** of the phone chain (prospeo → findyMail or FullEnrich → waterfall).
- `verifyEmail` — VERIFY stage, but off-default on price; the spine verifies with `waterfall.verifyEmail` (0.1).
## Recurring use
No scheduled fit — per-record enrichment only; findyMail earns its recurring keep as a gated rung inside a play.
- **`findEmail` gate:** run only where `email` is still empty and the alternate 0.5 rung missed — alternates in a recurring play must stay waterfall-ordered, or every re-evaluation double-spends the tier (see anti-patterns).
- **`findPhone` gate:** empty phone field **and** the qualified-lead condition — at 5/record, an ungated phone node re-firing on segment changes is the play's biggest cost risk.
- **Stability:** found emails/phones don't improve on re-lookup — a filled row re-entering the segment should skip the node, which is exactly what the empty-field gates guarantee.
## Action shape
`{"kind":"connector","integrationSlug":"findyMail","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.** Note the capitalization: `findyMail` (camel-case with capital `M`).
provider-playbooks/firecrawl.md
---
provider: firecrawl
category: research (scraping)
last-reviewed: 2026-07-09
---
# firecrawl (Firecrawl)
Web scraping, crawling, and web search at **0.05 credits per item** — the default web-research provider for the personalization and niche-research stages. It fetches pages as clean markdown for downstream LLM extraction; it is **not** a bulk-enrichment provider.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `scrape` | 0.05 / item | `url` | Turn one URL into clean data (markdown, html, links, metadata). |
| `search` | 0.05 / item | `query`, `limit` (1–100) | Web search when no structured provider has the data. |
| `crawl` | 0.05 / **page** | `url`, `maxDepth`, `limit`, `includesPaths`, `excludesPaths`, `options` | Recursively gather a site's pages (docs, job boards, portfolio pages). |
All three bill **per item returned**, not per call. In credits mode the connector is rate-limited (15/min, spread), so large crawls take time as well as credits.
## What it's for
- ✅ **Personalization research** — scrape a prospect's site/blog/case-study page, then extract angles with an LLM (`anthropic.instruct`) for the first-line step of [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md).
- ✅ **Niche signals no structured provider covers** — industry job boards ([`../recipes/tech-intent.md`](../recipes/tech-intent.md)), investor portfolio pages ([`../recipes/portfolio-prospecting.md`](../recipes/portfolio-prospecting.md)), "companies that mention X on their site".
- ✅ **Cheap web search** — `search` at 0.05/result is the lowest-cost web-search rung in the sourcing map.
- ❌ **Firmographics / tech stack at scale** — `aiArk.enrichCompany` (0.01) and `builtwith.getDomainSummary` (**free**) return structured fields directly; scraping + LLM-extracting the same facts costs more end-to-end and parses worse.
- ❌ **Jobs on major boards** — `theirStack.searchJobs` (0.5) already covers LinkedIn, Indeed, etc. Crawl only the niche boards theirStack misses.
## Patterns
### Pattern A — Scrape one page → LLM extract
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"firecrawl","actionSlug":"scrape"}' \
--data '{"url":"https://acme.com/customers"}' \
--wait-until-finished
```
Feed the returned markdown to `anthropic.instruct` for structured extraction — the scrape is 0.05; the LLM call usually dominates the cost of this pair.
### Pattern B — Web search fallback
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"firecrawl","actionSlug":"search"}' \
--data '{"query":"\"built on Stripe Atlas\" fintech startup","limit":20}' \
--wait-until-finished
```
Billed per result: `limit: 20` ≈ 1 credit. Size `limit` to what you'll actually read.
### Pattern C — Bounded crawl of a niche site
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"firecrawl","actionSlug":"crawl"}' \
--data '{
"url": "https://jobs.nicheboard.io",
"maxDepth": 1,
"limit": 50,
"includesPaths": ["/jobs/*"]
}' \
--wait-until-finished
```
`maxDepth` semantics: `0` scrapes only the entered URL; `1` adds pages one level deep; `2` two levels; and so on. `options` accepts `ignoreSitemap`, `allowBackwardLinks`, `allowExternalLinks`.
## Common pitfalls
- **Unbounded crawls.** `crawl` bills 0.05 per page crawled — an uncapped crawl of a large site burns hundreds of credits. **Always set `limit`** and start with `maxDepth: 1`; widen only if the pilot shows you need more.
- **Path-filter key spelling.** The action's input schema names the filters `includesPaths` / `excludesPaths` (note the plural "includes"), while the UI labels them include/exclude paths. If a filter appears ignored, check the spelling against the schema.
- **`allowExternalLinks` on a crawl.** It lets the crawler leave the target domain — combined with a loose `limit` this is the fastest way to pay for pages you didn't want.
## Anti-patterns
- **Scraping what a structured provider sells cheaper.** Company facts (`aiArk.enrichCompany`, 0.01) and tech stack (`builtwith.getDomainSummary`, free) come back typed for less than a scrape costs. Scrape only for data no structured provider has — which is genuinely where website copy, positioning changes, and page-specific claims live.
- **Crawling per-record in a batch.** A crawl inside a 500-record workflow multiplies pages × records. Crawl once, store the result, and join it to records instead.
## Position in the waterfall
**Web-research default** (see [`../references/stage-action-map.md`](../references/stage-action-map.md), Web research): `firecrawl` first at 0.05/item, escalating to `linkup.search` (0.5) when you need structured answers, or `perplexity.instruct` (0.3) for cited synthesis. As a sourcing rung, `search` is the last-resort coverage fallback after salesNavigator / theirStack / serper.
Firecrawl's `crawl` also exists as an **extractor** — usable to sync a website into a connector-backed knowledge library (see the `cargo-content` skill) rather than as a one-off action.
## Recurring use
- **Scheduled fit: yes, for decaying pages.** A scheduled `crawl` of a niche job board (daily — hiring-intent cadence, see [`../recipes/save-as-play.md`](../recipes/save-as-play.md)) or a weekly `search` sweep works — but every run re-bills 0.05 per page/result returned, so keep `limit`/`maxDepth` as tight on run 50 as on run 1.
- **Prefer the extractor for "keep this site fresh".** A recurring site sync is what the `crawl` extractor → knowledge library path is for (see Position in the waterfall), not a cron'd one-off action.
- **In-play gate:** never crawl per-record (see Anti-patterns). For per-record `scrape`, gate on the scraped-markdown column still being empty so play re-evaluation doesn't re-fetch pages already stored.
## Action shape
`{"kind":"connector","integrationSlug":"firecrawl","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/forager.md
---
provider: forager
category: enrichment (contact info from LinkedIn URL)
last-reviewed: 2026-07-09
---
# forager (Forager)
Contact-info lookup keyed **exclusively on LinkedIn URL** — three actions, one input field each. Its niche is `findPersonalEmail` (2): personal-mailbox discovery, which the standard FIND-EMAIL chain doesn't offer at all. `findWorkEmail` (2) sits **above** the whole work-email chain (`icypeas` 0.1 → mid-tier 0.5 → `FullEnrich` 1), so it's a last-resort there; `findPhone` (5) is a documented mid-tier phone rung between `prospeo` (3) and `FullEnrich` (6) — see [`../references/alternatives.md`](../references/alternatives.md) (Find phone alternatives).
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `findPersonalEmail` | 2 | `linkedinUrl` | Personal (non-work) email. Returns `personalEmails[]` with `email`, `email_type`, `validation_status`. |
| `findWorkEmail` | 2 | `linkedinUrl` | Work email — but the standard chain is up to 20× cheaper; last resort only. |
| `findPhone` | 5 | `linkedinUrl` | Phone number. Mid-tier rung of the phone waterfall. |
## What it's for
- ✅ **Personal email for people who changed jobs** — a work email dies with the job; the personal mailbox survives. Natural follow-up to a job-change signal before re-engaging.
- ✅ **Phone waterfall, middle rung** — escalate `prospeo.findPhone` (3) misses here (5) before paying `FullEnrich.findPhone` (6) / `waterfall.findPhone` (7).
- ❌ **First stop for work email** — `FullEnrich.findEmail` (1) is the priority default and half the price; the cheap rungs (`icypeas` 0.1) are 20× cheaper.
- ❌ **Records without a LinkedIn URL** — there is no name+company input. Resolve the URL first ([`../recipes/linkedin-url-lookup.md`](../recipes/linkedin-url-lookup.md)).
## Patterns
### Pattern A — Personal email after a job-change signal
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"forager","actionSlug":"findPersonalEmail"}' \
--records '[
{"linkedinUrl":"https://linkedin.com/in/alicesmith"},
{"linkedinUrl":"https://linkedin.com/in/bobjones"}
]' \
--wait-until-finished
```
`personalEmails[]` can hold several addresses — pick by `validation_status`, then re-verify with the VERIFY chain before sending.
### Pattern B — Phone escalation rung
```bash
# Only on rows where prospeo.findPhone (3) missed
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"forager","actionSlug":"findPhone"}' \
--data '{"linkedinUrl":"https://linkedin.com/in/alicesmith"}' \
--wait-until-finished
```
## Common pitfalls
- **LinkedIn URL is the only key.** Every action takes exactly one field, `linkedinUrl`. No email/name/domain fallback — records missing the URL must go through URL lookup first.
- **`validation_status` is the provider grading its own homework.** Run found emails through the VERIFY stage (`waterfall.verifyEmail`, 0.1) regardless — see [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md).
- **Fixed cost on miss.** All three are fixed-price per execution; a `findPhone` miss still costs 5. Gate the expensive rungs on prior-rung misses only ([`../references/cost-discipline.md`](../references/cost-discipline.md)).
- **Personal email ≠ outreach consent.** Route personal mailboxes per your compliance rules; they're best for re-engagement of known contacts, not cold sends.
## Position in the waterfall
- **CONTACT stage.** Phone: `prospeo` (3) → **forager (5)** / `findyMail` (5) → `FullEnrich` (6) → `waterfall` (7). Email: standard chain first; forager only for the personal-email niche ([`../references/stage-action-map.md`](../references/stage-action-map.md)).
- Feed results into VERIFY (`waterfall.verifyEmail`, 0.1) before any send.
## Action shape
`{"kind":"connector","integrationSlug":"forager","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/re-engagement.md`](../recipes/re-engagement.md) — personal email revives contacts whose work address went stale (CONTACT step after the SIGNAL).
- [`../recipes/job-change-monitoring.md`](../recipes/job-change-monitoring.md) — the job-change signal that makes personal-email lookup worth 2 credits.
## Recurring use
No scheduled fit — per-record enrichment only, priced too high (2–5) to re-pull on a timer.
- **Natural trigger:** downstream of the job-change monitor ([`../recipes/job-change-monitoring.md`](../recipes/job-change-monitoring.md), every-2-weeks cadence per [`../recipes/save-as-play.md`](../recipes/save-as-play.md)) — run `findPersonalEmail` only on rows newly entering the "changed jobs" segment.
- **In-play gate: attempt timestamp, not empty output.** Misses bill full price (see Common pitfalls), so "run where `personal_email` is empty" re-bills the same uncoverable rows on every re-evaluation — stamp a lookup-attempted-at column and gate on it.
- **Stable data:** a found personal mailbox doesn't decay on a schedule; re-verify it before each send wave (VERIFY chain, 0.1) instead of re-finding.
provider-playbooks/FullEnrich.md
---
provider: FullEnrich
category: enrichment (premium contact lookup)
last-reviewed: 2026-04-27
---
# FullEnrich
Premium contact-detail provider. Four credits-based actions, all focused on filling email + phone + LinkedIn gaps. Higher cost than cheap email finders, but **better hit rate**, and the only provider in the priority stack that does **reverse-email lookup**.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `findEmail` | 1 | `firstName, lastName, domainName, companyName, linkedinUrl` | Default email finder in the priority stack. |
| `findPhone` | 6 | `firstName, lastName, domainName, companyName, linkedinUrl` | Premium phone lookup. Escalate from `prospeo.findPhone` (3). |
| `findPhoneAndEmail` | 7 | `firstName, lastName, domainName, companyName, linkedinUrl` | Combined call when both are needed and you'd otherwise pay 1+6=7 anyway. **No discount over running both separately.** |
| `reverseEmailLookup` | 2 | `email` | **Unique action.** Email → LinkedIn URL + company info. |
## What it's for
- ✅ **Default email finder** in the prospecting spine — better hit rate than cheap providers (`hunter`/`icypeas` at 0.5 cred), worth the 2× cost when conversion matters.
- ✅ **Reverse-email lookup** — given an email, retrieve LinkedIn + company. Critical for de-anonymizing email-only data sources.
- ✅ **Phone lookup with multi-input flexibility** — accepts any combination of name/domain/company/linkedin.
## Patterns
### Pattern A — Default email finder in the spine
```bash
# After sourcing + (optional) basic enrichment, find emails for the contacts
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"FullEnrich","actionSlug":"findEmail"}' \
--records '[
{"firstName":"Alice","lastName":"Smith","domainName":"acme.com"},
{"firstName":"Bob","lastName":"Jones","linkedinUrl":"https://linkedin.com/in/bobjones"}
]' \
--wait-until-finished
```
Pass either `domainName` (highest reliability) or `linkedinUrl`. Both is best.
### Pattern B — Reverse lookup from an email
When you have an email but no other identity (e.g., from `snitcher.searchSessions` or a webform):
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"FullEnrich","actionSlug":"reverseEmailLookup"}' \
--records '[{"email":"alice@acme.com"},{"email":"bob@globex.com"}]' \
--wait-until-finished
```
Returns LinkedIn URL + company name + (sometimes) title. Feed the LinkedIn URL into `linkedin.enrichProfile` for full validation per the `linkedin-url-lookup` recipe.
### Pattern C — Combined phone + email
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"FullEnrich","actionSlug":"findPhoneAndEmail"}' \
--records '[{"firstName":"Alice","lastName":"Smith","linkedinUrl":"…","domainName":"acme.com"}]' \
--wait-until-finished
```
Cost is 7 credits — same as running `findEmail` (1) + `findPhone` (6) separately. Only use the combined call when API simplicity matters more than the ability to skip phone lookup for low-value rows.
## Common pitfalls
- **`findPhoneAndEmail` is not a discount.** 7 credits = 1 (email) + 6 (phone). Run separately if you want to skip phone lookups for unqualified leads.
- **Multi-input matters.** Hit rate jumps significantly when you pass `linkedinUrl` AND `domainName` together vs. either alone. If you have both, use both.
- **Don't use `findEmail` for verification.** It returns a single best-guess email; some are catch-all and will bounce. Always verify with `waterfall.verifyEmail` (0.1 cred) before using in outreach.
## Anti-patterns
- **snake_case field names.** FullEnrich inputs are **camelCase**: `firstName`, `lastName`, `domainName`, `companyName`, `linkedinUrl`. Do NOT reuse waterfall's `first_name`/`domain` shape here — the exact inverse of the waterfall trap.
- **Shipping a catch-all address on one source.** If `verifyEmail` says catch-all, the address ships only when a second independent finder returned the exact same string; otherwise flag it "unverified".
- **`findPhone` in a default chain.** Phone is the ~10×-email lever — explicit user request and qualified leads only ([`../references/cost-discipline.md`](../references/cost-discipline.md) §5).
## Fallback chain
If `FullEnrich.findEmail` returns nothing for a row, escalate via:
1. `peopleDataLabs.enrichPerson` (3 cred) — heavyweight backfill.
2. Or `hunter.findEmail` (0.5 cred) — different underlying source, sometimes finds what FullEnrich misses.
3. Last resort: `icypeas.findEmail` (0.1 cred).
Don't run all four blindly — the spine is `FullEnrich` first, escalate only on misses. **Demote dynamically**: if FullEnrich misses on the pilot's first ~10 rows of a batch (some segments — e.g. non-LinkedIn-native industries — are outside its coverage), move it behind hunter for the rest of that batch.
## Recurring use
No scheduled fit — per-record enrichment only. A found email is stable data; re-running the finder on a timer just re-bills rows that won't change.
- **In-play shape:** `findEmail` as the CONTACT node of a play triggered by rows entering the segment; gate on the email column still being empty so re-evaluation never re-bills enriched rows.
- **Recurring niche:** `reverseEmailLookup` (2) on newly captured email-only rows (webforms, `snitcher.searchSessions`) — gate on the LinkedIn-URL column being empty.
- **Phone stays out of recurring chains:** `findPhone` (6) is explicit-request + qualified rows only (see Anti-patterns) — never wire it into a play's default path.
## Action shape
`{"kind":"connector","integrationSlug":"FullEnrich","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.** Note the capitalization: `FullEnrich` (camel-case starting with capital `F`).
provider-playbooks/g2.md
---
provider: g2
category: signal (software-review & category data)
last-reviewed: 2026-07-09
---
# g2 (G2)
Software-review data from G2, in two surfaces: an **action** (`enrichProduct`, 1) that pulls one product's reviews/ratings/specs, and an **extractor** (`fetchProducts`, 1) that syncs the product list of G2 categories into a model — vendor lists with each product's `users`, `industries`, and `market_segments` per G2. Use it when the software *category* is the signal ("every vendor selling X", "what do reviewers say about product Y"). Not in the priority stack — `theirStack` (0.5) stays the default for tech-stack/hiring intent; g2 answers the seller-side and review-side questions theirStack can't.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `enrichProduct` | 1 | `productUrl` (full G2 URL **or** just the product slug) | Reviews, ratings, and specs for one G2 product. |
## Extractor (syncs into a model, not an action)
| Extractor | Cost | Inputs | Use for |
|---|---|---|---|
| `fetchProducts` | 1 per fetch | `categories` (from the `listCategories` autocomplete) | Sync a G2 category's products into a model: `name`, `link`, `users`, `industries`, `market_segments`. |
Wire it with `cargo-ai storage model create … --extractor-slug fetchProducts` (see the `cargo-storage` skill). Fetch mode is non-incremental with a **30-day minimum interval** — it's a periodic snapshot, not a live feed.
## What it's for
- ✅ **Category-level vendor lists** — "every product G2 lists under Sales Intelligence" → a model of vendors to prospect into, with the buyer profile (`users`, `industries`, `market_segments`) attached.
- ✅ **Review/rating context on a known product** — `enrichProduct` for competitive or account research before outreach.
- ❌ **Cheapest G2 product pull** — `piloterr.getG2ProductInfo` (0.01) returns G2 product info for 1/100th the price; use g2 when you want its first-party connector surface or the category extractor, piloterr for high-volume product scrapes.
- ❌ **Tech-stack intent** — "who *uses* X" is `theirStack.searchCompanies` (0.5); g2's `users` field is G2's reviewer segments, not an installed-base list.
## Patterns
### Pattern A — Enrich one product
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"g2","actionSlug":"enrichProduct"}' \
--data '{"productUrl":"postman"}' \
--wait-until-finished
```
`productUrl` accepts the bare product slug as well as the full URL.
### Pattern B — Category → vendor model
1. Fetch valid category values from the `listCategories` autocomplete on `connection integration get g2` — categories are provider-defined strings, not free text.
2. Create a model backed by the `fetchProducts` extractor with those `categories` (see `cargo-storage`, `model create --extractor-slug`).
3. Downstream, treat each product row as an account seed: resolve the vendor's domain, then enrich via the normal SOURCE → ENRICH spine.
## Common pitfalls
- **`fetchProducts` is an extractor, not an action.** `action execute` won't run it; it syncs into a model on a schedule (min every 30 days).
- **Categories are an opaque enum.** Guessed category strings silently return nothing — always resolve them via the `listCategories` autocomplete first.
- **Rate limit: 10 calls/minute** (spread) — among the slowest in the catalog. Don't fan `enrichProduct` across hundreds of rows; the batch will crawl.
- **Product rows aren't companies.** The extractor emits products (`name`, `link`); you still need a domain-resolution step before company enrichment.
## Position in the waterfall
- **SIGNAL / SOURCE (category-shaped).** Seeds account lists when the filter is "sells in category X" or "reviewed on G2"; the resulting vendors flow into the standard ENRICH → CONTACT → VERIFY spine.
- For buyer-side tech intent, stay with `theirStack` (0.5, priority stack) — see [`../references/stage-action-map.md`](../references/stage-action-map.md).
## Action shape
`{"kind":"connector","integrationSlug":"g2","actionSlug":"enrichProduct"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/tech-intent.md`](../recipes/tech-intent.md) — the buyer-side counterpart; g2 covers the seller/review side of the same market.
- [`../recipes/build-tam.md`](../recipes/build-tam.md) — category vendor lists as a TAM seed.
## Recurring use
- **Scheduled fit: the extractor, not the action.** `fetchProducts` is already the recurring surface — a periodic category snapshot with a hard 30-day minimum interval, so its floor overrides the weekly company-search default in [`../recipes/save-as-play.md`](../recipes/save-as-play.md); don't also cron `enrichProduct` to fake a faster feed.
- **In-play gate for `enrichProduct`:** review data decays slowly — stamp a fetched-at column and re-enrich only rows older than the snapshot rhythm, never on empty-field alone once populated.
- **Mind the 10 calls/min limit** (see Common pitfalls): a recurring `enrichProduct` sweep over a large vendor model will crawl — keep scheduled sets to the handful of products you actually track.
provider-playbooks/gemini.md
---
provider: gemini
category: llm
last-reviewed: 2026-07-09
---
# gemini (Google Gemini)
Gemini models through a single `instruct` action — the **cheap high-throughput tier**: Flash models run at 0.01–0.05 credits per 1,000-token package with a **15,000 calls/min rate limit** (highest in the LLM catalog), plus optional Google Search grounding. Routing: `anthropic` Sonnet for judgment, `openAi` nano (0.006) for absolute-cheapest bulk, gemini Flash when you want cheap **and** fast (or Google-grounded), `perplexity` for cited web research.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `instruct` | 0.01–0.2 / 1,000-token package (per-model tiers below) | `model` + `prompt` (required); `advancedSettings.{systemPrompt, maxTokens, temperature, withWebSearch}`; `output.{responseFormat, jsonSchema}` | High-throughput LLM steps, optionally grounded in Google Search. |
### Per-model cost tiers
| Tier | Model ids | Credits / 1,000 tokens | Rate limit |
|---|---|---|---|
| Flash (older) | `gemini-2.0-flash`, `gemini-1.5-flash` | 0.01 | 15,000/min |
| Flash | `gemini-2.5-flash` (schema default) | 0.03 | 15,000/min |
| Flash (preview) | `gemini-3-flash-preview` | 0.05 | 15,000/min |
| Pro (older) | `gemini-1.5-pro` | 0.1 | 2,000/min |
| Pro | `gemini-2.5-pro` | 0.15 | 1,000/min |
| Pro (preview) | `gemini-3.1-pro-preview`, `gemini-3-pro-preview` | 0.2 | 1,000/min |
`advancedSettings.withWebSearch: true` ("With Google Search?") adds a **fixed 0.4 credits per call** per the billing rules — and the schema warns each search request incurs an additional 0.5-credit charge on Cargo credits. Treat grounded calls as materially more expensive than the token rate suggests.
## What it's for
- ✅ **Cheap, fast bulk transforms** — extraction/classification/short personalization at Flash prices; the 15,000/min ceiling means big batches don't throttle.
- ✅ **Native structured output** — `output.responseFormat`: `text` (default) | `json_object` | `json_schema` (+ sibling `jsonSchema` object), same surface as openAi.
- ✅ **Google-grounded answers mid-pipeline** — `withWebSearch` grounds responses in live Google Search when a `serper.search` + extract two-step is overkill.
- ❌ **Judgment-heavy steps** — soft scoring, positioning, salience: `anthropic` Sonnet (see per-prompt model guidance in [`../references/prompt-library/index.md`](../references/prompt-library/index.md)).
- ❌ **Absolute-cheapest bulk** — `openAi` `gpt-5-nano` (0.006) undercuts even `gemini-2.0-flash` (0.01) if throughput isn't the constraint.
## Pattern — high-throughput classification
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"gemini","actionSlug":"instruct"}' \
--records '[{"model":"gemini-2.5-flash","prompt":"<substituted classification prompt row 1>","advancedSettings":{"temperature":0},"output":{"responseFormat":"json_object"}},{"model":"gemini-2.5-flash","prompt":"<row 2>"}, ...]' \
--wait-until-finished
```
**`model`, `prompt`, `advancedSettings`, and `output` are all *inputs*** — they go in each record, never in the action's `config`, which a top-level action does not carry at all. Settings placed there are rejected on older backends and **silently dropped** on newer ones (the call still bills, at default settings).
## Input quirks
- **Temperature is 0–2, default 1** — set `temperature: 0` explicitly for extraction and scoring families.
- **Model ids are a plain enum** (no titles): copy them exactly from the tier table; `gemini-2.5-flash` is the schema default.
- The `-preview` models (3.x line) are newest but cost more than their stable siblings at the same tier — pilot before committing a batch to them.
## Cost traps
- **500-row batch math** (≈1 package per short call): `gemini-2.0-flash`/`1.5-flash` ≈ **5 credits**; `gemini-2.5-flash` ≈ **15**; `gemini-3-flash-preview` ≈ **25**; `gemini-2.5-pro` ≈ **75**; 3.x-pro-preview ≈ **100**. Keep bulk on Flash; Pro is 4–20× Flash for the same row count.
- **Grounded batches are the real trap:** `withWebSearch` adds 0.4 fixed per call (+200 credits on 500 rows) plus the per-search surcharge — a "grounded Flash" batch can cost more than an ungrounded Pro one. Ground only the rows that need fresh facts.
- **Never bulk on a judgment-tier model:** Pro-tier extraction is Flash-quality work at Pro prices — pilot on Pro/Sonnet if needed, then demote the batch to Flash.
## Position in the LLM stack
- **The throughput rung** of [`../references/stage-action-map.md`](../references/stage-action-map.md) LLM section: pick gemini Flash over `openAi` nano when rate limits (15,000/min vs 10,000/min) or Google grounding matter; otherwise nano is cheaper.
- Pilot-then-demote per [`../references/cost-discipline.md`](../references/cost-discipline.md).
## Action shape
`{"kind":"connector","integrationSlug":"gemini","actionSlug":"instruct"}`, with `model`, `prompt`, `advancedSettings`, and `output` per record in `--records` / `--data`. **No `connectorUuid` in `config`** — and no model settings there either; inside a workflow **node** those same fields are the node's `config`. Costs above are the Cargo-credits rules; a workspace can instead attach its own Gemini key (connector config takes a single required `apiKey`) and bill Google directly.
## Pairs with
- [`../references/prompt-library/index.md`](../references/prompt-library/index.md) — the library's extraction/qualification/personalization prompts port unchanged; keep `temperature: 0` for the deterministic families.
- [`../recipes/build-tam.md`](../recipes/build-tam.md) / [`../recipes/tech-intent.md`](../recipes/tech-intent.md) — high-volume classify/extract stages where Flash throughput pays off.
## Recurring use
- **The recurring shape is a play node, not a scheduled re-pull:** `instruct` as the classify/score/personalize step, gated on rows newly entering the segment or newly enriched — never re-prompt the whole model each evaluation.
- **Per-row cost compounds with cadence:** the 500-row math in Cost traps repeats every run — a daily play on `gemini-2.5-flash` is ≈15 credits/day. Keep recurring nodes on Flash; pilot on Pro, demote before scheduling.
- **`withWebSearch` is the compounding trap in a recurring node** — 0.4 fixed per call, every run. Ground only rows whose facts actually went stale.
- **Idempotence gate:** write the output to a dedicated column and run only where it's still empty (or where an input-changed timestamp is newer than the output's).
provider-playbooks/hunter.md
---
provider: hunter
category: contact (email)
last-reviewed: 2026-07-09
---
# hunter
Mid-tier email finder. Its `findEmail` (0.5) is **rung 2 of the find-email waterfall** — a different underlying source than `FullEnrich.findEmail` (1), so it often finds what FullEnrich misses. Prefer it as the escalation step on FullEnrich misses, or as the lead finder when budget is critical and a lower hit rate is acceptable ([`../references/alternatives.md`](../references/alternatives.md)). Avoid its `verifyEmail` — at 1 credit it is the most expensive verify tier in the catalog.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `findEmail` | 0.5 | `first_name, last_name, full_name, domain, company` | Rung-2 email finder in the spine ([`../references/waterfall-strategy.md`](../references/waterfall-strategy.md)). |
| `enrichPerson` | 1 | `email` | Email → person info. Cheap mid-tier alternative to heavier person enrichers. |
| `searchDomain` | 1 | `domain, type, seniorities, departments, requiredFields, limit` | List people at one domain, filtered by seniority/department. Max **10 records per call**. |
| `verifyEmail` | 1 | `email` | **Avoid.** 10× `waterfall.verifyEmail` (0.1), 100× `icypeas.verifyEmail` (0.01). |
## What it's for
- ✅ **Escalation on FullEnrich misses** — the canonical find-email chain is FullEnrich (1) → hunter (0.5) → peopleDataLabs (3) → icypeas (0.1); hunter's independent index is the whole point of running it second.
- ✅ **Budget-constrained lead finder** — half FullEnrich's price when the user accepts a lower hit rate.
- ✅ **Small per-domain people lists** — `searchDomain` filters by seniority and department when you need a handful of contacts at one known company.
## Patterns
### Pattern A — Rung 2 of the find-email chain
```bash
# Run ONLY on the rows where FullEnrich.findEmail returned nothing
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"hunter","actionSlug":"findEmail"}' \
--records '[
{"first_name":"Alice","last_name":"Smith","domain":"acme.com"},
{"full_name":"Bob Jones","company":"Globex"}
]' \
--wait-until-finished
```
`domain` beats `company` for accuracy — pass the domain whenever you have it. Output includes `email`, a confidence `score`, an `accept_all` boolean (catch-all flag), `position`, `linkedin_url`, the public-web `sources` it was extracted from, and a `verification.status` block.
### Pattern B — People at one domain
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"hunter","actionSlug":"searchDomain"}' \
--records '[{"domain":"acme.com","type":"personal","seniorities":["executive"],"departments":["sales","marketing"],"limit":10}]' \
--wait-until-finished
```
`domain` and `type` (`all`/`personal`/`generic`) are required. `limit` caps at 10 — this is a spot-check tool, not a sourcing engine; for volume sourcing use `salesNavigator.searchLeads` (0.02) or `icypeas.findPeople` (0.02).
## Common pitfalls
- **`accept_all: true` means catch-all.** The domain accepts any address, so a "found" email proves nothing. Ship it only if a second independent finder returned the exact same string; otherwise flag "unverified".
- **The embedded `verification` block is the provider grading its own homework.** Run `waterfall.verifyEmail` (0.1) on every found email regardless — verification hard rules in [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md).
- **`searchDomain` returns at most 10 records** and costs 1 credit per call — don't loop it to build a list.
## Anti-patterns
- **camelCase field names.** hunter inputs are **snake_case**: `first_name`, `last_name`, `full_name`, `domain`, `company`. Do NOT reuse FullEnrich's `firstName`/`domainName` shape here.
- **`hunter.verifyEmail` in any chain.** 1 credit buys 10 `waterfall.verifyEmail` calls or 100 `icypeas.verifyEmail` calls for the same job.
- **Paid verify before the free cull.** Run the `validate-emails.ts` script from [`../references/contact-accuracy.md`](../references/contact-accuracy.md) first — dropping invalid/disposable/duplicate rows is free and shrinks the paid batch.
## Position in the waterfall
- `findEmail` — **rung 2** of the find-email chain (after FullEnrich, before peopleDataLabs). CONTACT stage of the prospecting spine.
- Every hit still flows to the VERIFY stage: free pre-cull ([`../references/contact-accuracy.md`](../references/contact-accuracy.md)) → `waterfall.verifyEmail` (0.1).
- Demote dynamically: if hunter misses on the pilot's first ~10 rows, drop it behind peopleDataLabs for the rest of that batch.
## Recurring use
No scheduled fit — a found email is stable data; re-running `findEmail` on a timer re-bills rows that won't change.
- **In-play gate:** rung 2 stays conditional inside the play — filter to rows where the FullEnrich email column AND the hunter email column are both still empty, so each row pays the 0.5 at most once per entry into the segment.
- **Pre-send re-verify:** before each recurring send wave, re-verify stale finds with `waterfall.verifyEmail` (0.1) — never `hunter.verifyEmail` (1), per Anti-patterns — and gate on rows entering the wave, not a blanket timer over the whole list.
- **Don't cron `searchDomain`:** the 10-record cap at 1 credit/call makes a scheduled loop the "don't loop it to build a list" pitfall on a timer.
## Action shape
`{"kind":"connector","integrationSlug":"hunter","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/icypeas.md
---
provider: icypeas
category: contact + verification
last-reviewed: 2026-07-09
---
# icypeas
The **cheap tier** of the contact stack, three ways: `verifyEmail` at **0.01** is the cheapest verification in the entire catalog (10× cheaper than the `waterfall.verifyEmail` default), `findEmail` at 0.1 is the cheap last resort of the find-email chain, and `findPeople`/`findCompanies` at 0.02/record are the cheapest non-LinkedIn sourcing alternative to `salesNavigator`. Prefer it for very large lists where unit cost dominates; avoid it as the lead email finder when hit rate matters — that's `FullEnrich.findEmail` ([`../references/alternatives.md`](../references/alternatives.md)).
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `verifyEmail` | **0.01** | `email` | **Cheapest verify in the catalog.** Very large verify batches. |
| `findEmail` | 0.1 | `firstName, lastName, domainOrCompany` (all required) | Cheap last-resort rung of the find-email chain. |
| `scanDomain` | 0.1 | `domainOrCompany` | Discover **role-based** addresses on a domain (contact@, admin@, …). |
| `findPeople` | 0.02/record | `currentJobTitle, currentCompanyName, location, keyword, limit` | Cheapest non-LinkedIn people sourcing. |
| `findCompanies` | 0.02/record | `name, industry, location, keyword, headcountMin, headcountMax, limit` | Cheapest company sourcing. |
`findPeople`/`findCompanies` are package-billed per 100 records and paginated (`paginationToken` in the response meta); `limit` defaults to 100, max 10,000.
## What it's for
- ✅ **Verification at scale** — 10,000 emails = 100 credits. Use over `waterfall.verifyEmail` (0.1) when the list is large enough for the 10× saving to matter.
- ✅ **Last-resort email finding** — rung 4 of the chain (FullEnrich → hunter → peopleDataLabs → icypeas), per [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md).
- ✅ **Cheap sourcing when LinkedIn coverage is thin** — `findPeople` (0.02) matches `salesNavigator.searchLeads` pricing with a different database; useful for privacy-focused industries.
- ✅ **Role-based address discovery** — `scanDomain` is the only action in the stack that enumerates generic mailboxes on a domain.
## Patterns
### Pattern A — Bulk verify
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"icypeas","actionSlug":"verifyEmail"}' \
--records '[{"email":"alice@acme.com"},{"email":"bob@globex.com"}]' \
--wait-until-finished
```
Run the free `validate-emails.ts` cull first ([`../references/contact-accuracy.md`](../references/contact-accuracy.md)) — even at 0.01/row, paying to verify syntactically invalid or disposable addresses is waste.
### Pattern B — Last-resort email finder
```bash
# Only on rows that FullEnrich, hunter, AND peopleDataLabs all missed
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"icypeas","actionSlug":"findEmail"}' \
--records '[{"firstName":"Alice","lastName":"Smith","domainOrCompany":"acme.com"}]' \
--wait-until-finished
```
All three fields are **required**; `domainOrCompany` accepts either a domain or a company name (domain is more reliable). The output nests results under `emails[]`, each with a `certainty` grade plus MX records/provider — take the top entry, don't assume a flat `email` field.
### Pattern C — Cheap sourcing sweep
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"icypeas","actionSlug":"findPeople"}' \
--data '{"currentJobTitle":"CTO","location":"FR","limit":200}' \
--wait-until-finished
```
Filters are coarse (title, company, location, keyword) — nothing like salesNavigator's facets. Use alpha-2 country codes (`US`, `FR`) for `location`.
## Common pitfalls
- **30 requests/minute rate limit** — the slowest connector in this group (hunter and leadMagic run at 300/min). Large `findEmail`/`verifyEmail` batches take time; the platform spreads and retries automatically, so don't cancel a slow batch.
- **`emails[].certainty` is the provider grading its own homework.** A found email still goes through independent verification before use.
- **`scanDomain` returns role accounts** — contact@/admin@ addresses are REVIEW-tier for outreach (see the audit verdicts in [`../references/contact-accuracy.md`](../references/contact-accuracy.md)), not sequencer-ready contacts.
## Anti-patterns
- **Leading the find-email chain with icypeas.** 0.1 credits buys the lowest hit rate in the chain — it's the mop-up rung, not the opener.
- **snake_case field names.** icypeas inputs are **camelCase**: `firstName`, `lastName`, `domainOrCompany`.
- **Skipping verification because the finder is cheap.** Every found email — icypeas included — flows to a verify step (`waterfall.verifyEmail` 0.1, or icypeas's own 0.01 for bulk).
## Position in the waterfall
- `findEmail` — **rung 4 (last)** of the find-email chain. Often skipped: if hit rate after rung 2–3 is > 90%, the remaining misses are mostly uncoverable rows.
- `verifyEmail` — VERIFY-stage alternative to `waterfall.verifyEmail` for very large lists.
- `findPeople` / `findCompanies` — SOURCE-stage alternative when LinkedIn-anchored search isn't viable.
## Recurring use
- **Scheduled sourcing fits:** a weekly `findPeople`/`findCompanies` pull (persona/company-search cadence, see [`../recipes/save-as-play.md`](../recipes/save-as-play.md)) is the cheapest recurring source at 0.02/record — dedupe new rows against the model before any paid enrichment runs downstream.
- **Re-verify before send waves:** `verifyEmail` (0.01) is the natural pre-send gate — run it on rows entering the send segment or whose last verify is stale, never on a blanket timer over the whole list; even at 0.01, the free cull comes first (see Pattern A).
- **In-play `findEmail` gate:** the last rung stays conditional — run only where the FullEnrich, hunter, AND peopleDataLabs email columns are all still empty. Found emails are stable data; never re-find on a schedule.
- **Cadence × rate limit:** at 30 requests/minute (see Common pitfalls), large recurring batches run long — size scheduled pulls so one run finishes before the next fires.
## Action shape
`{"kind":"connector","integrationSlug":"icypeas","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/kitt.md
---
provider: kitt
category: verification (email)
last-reviewed: 2026-07-09
---
# kitt (Kitt)
Dedicated email verification — **one action, 0.05 credits**, half the price of the priority-stack default `waterfall.verifyEmail` (0.1) and 5× the price of the catalog floor `icypeas.verifyEmail` (0.01). Its output splits the verdict into `validIdentity` and `validSMTP`, so it's the budget rung when you want *why*, not just pass/fail. Listed as the cheaper verify alternative in [`../references/alternatives.md`](../references/alternatives.md) (Verify email alternatives).
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `verifyEmail` | 0.05 | `email` | Verify one email's deliverability. Output: `validity`, `validIdentity`, `validSMTP`, `mxDomain`, `reason`, `reasonCode`, `displayText`. |
## What it's for
- ✅ **Cheap verify with diagnostics** — `icypeas` (0.01) is cheaper but kitt's `validIdentity` / `validSMTP` split plus `reason`/`reasonCode` explain the verdict for triage.
- ✅ **High-throughput lists** — rate limit is 100 calls/**second** (spread), the fastest verifier in this group; large batches don't crawl.
- ✅ **Second opinion at low cost** — re-check ambiguous verdicts from `waterfall.verifyEmail` for 0.05 instead of another 0.1.
- ❌ **Default verify rung** — `waterfall.verifyEmail` (0.1, multi-source) is the priority-stack default; swap kitt in deliberately for budget or second-opinion reasons.
- ❌ **Absolute cheapest bulk pass** — `icypeas.verifyEmail` (0.01) wins when volume dominates and diagnostics don't matter.
## Patterns
### Pattern A — Batch verify before sequencing
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"kitt","actionSlug":"verifyEmail"}' \
--records '[{"email":"alice@acme.com"},{"email":"bob@globex.com"}]' \
--wait-until-finished
```
Filter on `validity`; when it's ambiguous, `validIdentity` vs `validSMTP` tells you whether the mailbox or the server is the problem, and `reason` / `reasonCode` give the provider's explanation.
### Pattern B — Single lookup
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"kitt","actionSlug":"verifyEmail"}' \
--data '{"email":"alice@acme.com"}' \
--wait-until-finished
```
## Common pitfalls
- **`validity` is a string, not a boolean.** Inspect actual values on a pilot batch before writing filters — don't assume `"valid"`/`"invalid"` is the full enum.
- **SMTP-pass ≠ mailbox-proven.** `validSMTP: true` with a weak `validIdentity` is the catch-all pattern; route those per your sequencer's risk tolerance rather than blanket-sending.
- **Verify even "verified" finds.** Email finders grade their own homework — every found email goes through a verify step regardless of the finder's flag ([`../references/waterfall-strategy.md`](../references/waterfall-strategy.md)).
## Position in the waterfall
**VERIFY stage, budget rung.** `icypeas.verifyEmail` (0.01, bulk floor) → **kitt (0.05, cheap + diagnostics)** → `waterfall.verifyEmail` (0.1, priority default) → `zeroBounce.verifyEmail` (0.1, second opinion). See [`../references/stage-action-map.md`](../references/stage-action-map.md), Verify email.
## Recurring use
Verification recurs per **send wave**, not per calendar — no scheduled fit beyond that.
- **Re-verify gate:** run `verifyEmail` (0.05) only on rows entering a send wave whose last clean verdict is stale — never on a blanket timer that re-bills the whole list.
- **In-play gate:** filter to rows where the kitt `validity` output is still empty, or gate on a verify-timestamp column older than the wave threshold.
- **Time-sensitivity:** verdicts decay slowly (mailboxes churn, not daily) — and when a pre-wave pass *is* due, the 100 calls/second rate limit keeps it fast.
## Action shape
`{"kind":"connector","integrationSlug":"kitt","actionSlug":"verifyEmail"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — the VERIFY step before personalization; never sequence unverified emails.
- [`../recipes/prospecting.md`](../recipes/prospecting.md) — the verify rung of the find → enrich → verify → sync spine.
provider-playbooks/leadMagic.md
---
provider: leadMagic
category: contact (email + mobile)
last-reviewed: 2026-07-09
---
# leadMagic
Mid-tier email finder whose hits come **pre-annotated**: `findEmail` (0.5) returns a `status` field, full MX diagnostics (provider, security gateway, records), and bonus company firmographics in the same payload. An alternative rung to `hunter.findEmail` at the same price — the default finder remains `FullEnrich.findEmail` (1) per [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md). Also carries `enrichProfile` (3): email → LinkedIn profile URL. No verify or phone actions — pair with `waterfall`/`prospeo` for those stages.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `findEmail` | 0.5 | `firstName, lastName` (required), `domain, companyName` | Mid-tier email finder; MX-annotated output. |
| `enrichProfile` | 3 | `email` (required), `isPersonal` | Email → LinkedIn `profile_url`. |
## What it's for
- ✅ **Chain rung at the 0.5 tier** — interchangeable with `hunter.findEmail` / `findyMail.findEmail`; run it on the rows the earlier rung missed ([`../references/alternatives.md`](../references/alternatives.md)).
- ✅ **MX-aware triage** — `mx_provider`, `mx_security_gateway`, and `has_mx` in the hit payload help flag risky domains before paid verification.
- ✅ **Firmographics for free on hits** — company name, industry, size, founded, location, and LinkedIn URL ride along with each found email; useful when coalescing chain results.
- ✅ **Email → LinkedIn URL** — `enrichProfile` de-anonymizes an email-only row when `FullEnrich.reverseEmailLookup` (2) missed; set `isPersonal: true` for personal addresses.
## Patterns
### Pattern A — Email finder rung
```bash
# Run on the rows the earlier rung missed
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"leadMagic","actionSlug":"findEmail"}' \
--records '[
{"firstName":"Alice","lastName":"Smith","domain":"acme.com"},
{"firstName":"Bob","lastName":"Jones","companyName":"Globex"}
]' \
--wait-until-finished
```
`firstName` and `lastName` are both **required** — full-name-only rows must be split first. Add `domain` (preferred) or `companyName`; domain-anchored lookups are more reliable.
### Pattern B — Email → LinkedIn profile
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"leadMagic","actionSlug":"enrichProfile"}' \
--records '[{"email":"alice@acme.com"},{"email":"bob.jones@gmail.com","isPersonal":true}]' \
--wait-until-finished
```
Returns `profile_url`. Validate the URL with `linkedin.enrichProfile` before trusting it, per the strict-validation pattern in [`../recipes/linkedin-url-lookup.md`](../recipes/linkedin-url-lookup.md).
## Common pitfalls
- **`status` is the provider grading its own homework.** Whatever `findEmail.status` claims, the hit still goes through `waterfall.verifyEmail` (0.1) before any sequencer — verification hard rules in [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md).
- **`mx_security_gateway: true` is a deliverability warning**, not a reason to auto-drop — route those rows to REVIEW rather than silently discarding (verdict semantics in [`../references/contact-accuracy.md`](../references/contact-accuracy.md)).
- **`enrichProfile` at 3 credits is pricier than `FullEnrich.reverseEmailLookup` (2)** for the same email → LinkedIn job. Use it as the fallback, not the opener.
## Anti-patterns
- **snake_case field names.** leadMagic inputs are **camelCase**: `firstName`, `lastName`, `domain`, `companyName`. (Its *outputs* are snake_case — don't mirror them back into inputs.)
- **Name-only records.** Without at least `domain` or `companyName`, `firstName` + `lastName` alone match too many people — expect junk hits.
- **Paid verify before the free cull.** Run `validate-emails.ts` ([`../references/contact-accuracy.md`](../references/contact-accuracy.md)) on the enriched list first; only survivors go to `waterfall.verifyEmail`.
## Position in the waterfall
- `findEmail` — CONTACT-stage mid-tier rung alongside `hunter`/`findyMail` (all 0.5), behind the `FullEnrich.findEmail` default. Demote whichever 0.5-tier finder misses on the pilot's first ~10 rows.
- Every hit flows to the VERIFY stage: free pre-cull → `waterfall.verifyEmail` (0.1). leadMagic has **no verify action of its own**.
- `enrichProfile` — LinkedIn-URL-resolution fallback after `FullEnrich.reverseEmailLookup`.
## Recurring use
No scheduled fit — **per-record enrichment only**; found emails and profile URLs are stable, so a scheduled re-pull just re-bills unchanged rows.
- **In-play gate:** as a chain rung, run `findEmail` (0.5) only where the email column is still empty *and* the earlier rung already missed; gate `enrichProfile` (3) on an empty LinkedIn-URL column.
- **Right trigger:** the recurring shape here is a play fired by rows *entering* the segment (new prospects without an email), not a cron sweep over the whole model.
## Action shape
`{"kind":"connector","integrationSlug":"leadMagic","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.** Note the capitalization: `leadMagic` (camel-case with capital `M`).
provider-playbooks/linkedin.md
---
provider: linkedin
category: sourcing + enrichment
last-reviewed: 2026-07-09
---
# linkedin
LinkedIn page-level enrichment, URL resolution, and activity signals. **Cheapest LinkedIn-anchored enrichment in the catalog** — `enrichProfile` / `enrichCompany` at 0.25, and `findProfileUrl` (0.25) is the **default for the LinkedIn-URL lookup stage** ([`../references/stage-action-map.md`](../references/stage-action-map.md)). Where `salesNavigator` searches at scale, `linkedin` resolves and deepens **one known page at a time** — plus post/job/activity extraction for signals and a set of identity-driven engagement actions.
## Credits-based actions
### Enrichment & resolution
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `enrichProfile` | 0.25 | `linkedinUrl` | Profile URL → person details. Cheapest person enrich in the catalog; also the validation step after `findProfileUrl`. |
| `findProfileUrl` | 0.25 | `fullName` (required), `companyName` | Name → LinkedIn profile URL. **Default LinkedIn resolver** — see [`../recipes/linkedin-url-lookup.md`](../recipes/linkedin-url-lookup.md). |
| `enrichProfileFromName` | 0.5 | `name`, `companyName` (both required) | Name+company → profile details in one call. |
| `enrichCompany` | 0.25 | `linkedinUrl` | Company page URL → firmographics. |
| `enrichCompanyFromDomain` | 0.5 | `domain` | Domain → LinkedIn-anchored company details. |
| `enrichJob` | 0.25 | `linkedinUrl` | Job posting URL → job details. |
| `extractCompanyEmployeesInsights` | 0.25 | `linkedinUrl`, `affiliates` | Headcount by function, location, and seniority for a company page. |
| `extractSimilarCompanies` | 0.25 | `linkedinUrl` | LinkedIn's "similar companies" recommendations — cheap lookalike seed. |
| `findCustomHeadcount` | 0.5 | `companyLinkedinUrl`, `keywords`, `includeSubsidiaries` (all required) | Count employees matching a keyword (e.g. "how many SDRs?"). |
### Posts, jobs & activity (signals)
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `searchPosts` | 0.25 | `searchKeywords, sortBy, datePosted, contentType, fromMemberUrns, fromCompanyIds, mentioningMemberUrns, mentioningCompanyIds, authorIndustry, authorKeyword` | Find posts by keyword / author / mention. |
| `searchPostComments` | 0.05/item | `urn`, `sortBy` (required) | Commenters on a post → engagement-based sourcing. |
| `searchPostReactions` | 0.05/item | `urn`, `type` (required) | Reactors on a post. |
| `extractProfilePostActivity` | 0.05/item | `linkedinProfileUrl` | Posts a person published — personalization fodder. |
| `extractProfileCommentActivity` | 0.05/item | `linkedinProfileUrl` | Posts a person commented on. |
| `extractProfileReactionActivity` | 0.05/item | `linkedinProfileUrl` | Posts a person reacted to. |
| `searchJobs` | 0.5 | `keywords, geoCodes, datePosted, experienceLevels, companyIds, titleIds, jobTypes, onsiteRemote, functions, industryCodes, sortBy, easyApply, under10Applicants` | Job-posting search — hiring-intent alternative to `theirStack.searchJobs` (0.5). |
| `extractEventAttendees` | 0.05/item | `linkedinEventUrl`, `identityIds` (required) | Attendees of a LinkedIn event → event-based sourcing. |
| `extractProfileViewers` | 0.05/item | `identityIds`, `limit` (required) | Who viewed **your** connected identity's profile recently. |
### Engagement (identity-driven — acts as a real LinkedIn user)
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `visitProfile` | 0.25 | `linkedinProfileUrl`, `identityIds` | Visit a profile (shows up in their notifications). |
| `followProfile` | 0.25 | `linkedinProfileUrl`, `unfollow`, `identityIds` | Follow / unfollow a profile. |
| `connectProfile` | 0.25 | `linkedinProfileUrl`, `message`, `identityIds` | Send a connection request. |
| `likePost` | 0.25 | `linkedinPostUrl`, `interactionType`, `identityIds` | React to a post. |
| `commentPost` | 0.25 | `linkedinPostUrl`, `comment`, `identityIds` | Comment on a post. |
| `commentPostComment` | 0.25 | `linkedinCommentUrl`, `comment`, `identityIds` | Reply to a comment. |
`identityIds` ("Linkedin Users") are the workspace's connected LinkedIn identities — discover them via the `listIdentityIds` autocomplete on `connection integration get linkedin`.
## What it's for
- ✅ **LinkedIn URL resolution** — `findProfileUrl` (0.25) then `enrichProfile` (0.25) as the mandatory validation gate ([`../recipes/linkedin-url-lookup.md`](../recipes/linkedin-url-lookup.md)).
- ✅ **Cheap page-level enrichment** — 0.25 vs `waterfall.enrichContact` (2) when LinkedIn-anchored details are sufficient and you already have the URL ([`../references/alternatives.md`](../references/alternatives.md)). Note `aiArk.enrichPerson` (0.1) is cheaper still and returns a verified email; reach here when you specifically want the LinkedIn page fields.
- ✅ **Engagement-based sourcing** — commenters/reactors on a competitor-topic post, event attendees: warm pools no search filter can express.
- ✅ **Personalization signal** — a lead's recent post/comment/reaction activity feeds openers (SIGNAL stage before outreach).
- ❌ **At-scale search** — no people/company search here; that's `salesNavigator.searchLeads` (0.02) / `searchAccounts` (0.05).
## Patterns
### Pattern A — Resolve + validate a LinkedIn URL
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"linkedin","actionSlug":"findProfileUrl"}' \
--records '[{"fullName":"Alice Smith","companyName":"Acme"},{"fullName":"Bob Jones","companyName":"Globex"}]' \
--wait-until-finished
```
Then run `enrichProfile` on each returned URL and cross-check name + company — the unvalidated hit rate is ~50%, validated ~70% ([`../recipes/linkedin-url-lookup.md`](../recipes/linkedin-url-lookup.md)). `fullName` is the only required field; always pass `companyName` when known — it improves matching.
### Pattern B — Post engagement → warm source pool
```bash
# 1. Find the post(s)
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"linkedin","actionSlug":"searchPosts"}' \
--data '{"searchKeywords":"revenue operations benchmarks","sortBy":"Latest","datePosted":"Past week"}' \
--wait-until-finished
# 2. Pull who engaged (billed per item — cap the pull)
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"linkedin","actionSlug":"searchPostReactions"}' \
--data '{"urn":"7181234567890123456","type":"ALL"}' \
--wait-until-finished
```
`searchPosts` enums: `sortBy` = `Top match`/`Latest`; `datePosted` = `Past 24 hours`/`Past week`/`Past month`/`past-year`/`past-2y`/`past-3y`/`anytime`; `fromCompanyIds`/`mentioningCompanyIds` only accept company **IDs**, `fromMemberUrns`/`mentioningMemberUrns` take member URNs.
### Pattern C — Company deep-dive
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"linkedin","actionSlug":"extractCompanyEmployeesInsights"}' \
--data '{"linkedinUrl":"https://linkedin.com/company/acme","affiliates":false}' \
--wait-until-finished
```
Chain with `extractSimilarCompanies` (0.25) for a cheap lookalike seed list, or `findCustomHeadcount` (0.5) for "how many people matching *keyword* work there".
## Common pitfalls
- **Profile URL shape is enforced.** `linkedinProfileUrl` must start with `linkedin.com/in/`, `/pub/`, `/sales/people/`, or `/sales/lead/`. Company URLs are `/company/...`, jobs `/jobs/view/...`, events `/events/...` — pass the wrong shape and the call fails.
- **Per-item billing on activity pulls.** Comments, reactions, event attendees, profile activity, and viewers bill **0.05 per returned item** — a viral post can have thousands of reactions. Size first, then pull the approved scope ([`../references/cost-discipline.md`](../references/cost-discipline.md)).
- **`searchJobs` filter IDs are LinkedIn enums.** `geoCodes`, `titleIds`, `industryCodes`, `experienceLevels`, `functions`, `jobTypes`, `onsiteRemote` come from the integration's autocompletes (`listTitles`, `listIndustries`, `listExperienceLevels`, …) — not free-text strings.
- **Rate limit: 250 calls/minute** (spread) — relevant on large validation batches.
## Anti-patterns
- **`enrichProfileFromName` instead of the two-step.** Same 0.5 total as `findProfileUrl` + `enrichProfile`, but the two-step gives you the validation gate between resolution and enrichment — the recipe's mandatory pattern.
- **Engagement actions as a bulk channel.** `connectProfile` / `commentPost` act **as a real member identity** — batch-blasting them burns the identity. Gate to qualified, personalized touches only.
- **`extractProfileViewers` on a lead's URL.** It only works on your own connected identities (`identityIds` + `limit` required) — it is not a lead-enrichment action.
## Position in the waterfall
- `findProfileUrl` + `enrichProfile` — **default for the LinkedIn-URL lookup stage**; `FullEnrich.reverseEmailLookup` (2) only when all you have is an email.
- `enrichProfile` / `enrichCompany` — **first rung of ENRICH** when the input is a LinkedIn URL; escalate to `waterfall`, then `peopleDataLabs`, for non-LinkedIn fields.
- Posts / jobs / activity extraction — **SIGNAL stage**: engagement pools and personalization inputs; `searchJobs` sits beside `theirStack.searchJobs` (both 0.5) for hiring intent.
- Engagement actions — post-VERIFY activation touches, outside the sourcing spine.
## Recurring use
Split by data half-life: **posts, jobs, and activity decay — profiles and company pages don't**.
- **Scheduled pulls:** `searchJobs` daily (hiring intent) and `searchPosts` weekly, with `datePosted` matched to the cadence (`Past 24 hours` / `Past week`) so each run bills only the fresh window — cadence defaults in [`../recipes/save-as-play.md`](../recipes/save-as-play.md). Per-item activity pulls (`extractProfilePostActivity` et al., 0.05/item) fit a pre-outreach refresh, sized first per the per-item pitfall above.
- **Don't re-pull stable pages:** `enrichProfile` / `enrichCompany` (0.25) on a timer re-bills unchanged rows; in a play, gate them on an empty enriched field.
- **In-play gate:** run `findProfileUrl` only where the LinkedIn-URL column is still empty.
## Action shape
`{"kind":"connector","integrationSlug":"linkedin","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/linkup.md
---
provider: linkup
category: search (web research)
last-reviewed: 2026-07-09
---
# linkup (Linkup)
Natural-language web research with synthesized answers. Two actions: `search` (0.5 standard / **2 deep**) returns search results for a question, and `instruct` (flat 1) returns either a **sourced answer** or a **structured object matching a JSON schema you supply**. It sits above `firecrawl.search` (0.05, raw SERP + scrape) and alongside `serper.search` (1, Google results): pick linkup when you want an *answer* — especially a schema-shaped one you can write straight into a column — rather than pages to parse. See [`../references/stage-action-map.md`](../references/stage-action-map.md), Web research.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `search` | 0.5 (`depth: "standard"`) / 2 (`depth: "deep"`) | `q`, `depth` | Web search for a natural-language question; deep = agentic multi-step search. |
| `instruct` | 1 (flat, either depth) | `q`, `depth`, `outputType` (`sourcedAnswer` \| `structured`), `structuredOutputSchema` (required when structured) | Direct answer with source links, or a custom-schema JSON object. |
## What it's for
- ✅ **Structured per-record research** — `instruct` with `outputType: "structured"` turns "what's this company's pricing model?" into a typed object per row, no parsing step.
- ✅ **Sourced answers** — `outputType: "sourcedAnswer"` returns the answer plus source links for auditable enrichment.
- ✅ **Deep questions on a budget** — `instruct` costs a flat 1 even at `depth: "deep"`, *cheaper* than `search` deep (2). If you want a deep answer rather than deep results, `instruct` wins on price.
- ❌ **Plain SERP lookups** — `firecrawl.search` (0.05) is 10× cheaper when you just need result URLs to scrape.
- ❌ **Structured-data lookups a provider already covers** — firmographics, tech stack, emails: the dedicated providers are cheaper and more reliable than web research.
## Patterns
### Pattern A — Structured extraction per record
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"linkup","actionSlug":"instruct"}' \
--data '{
"q": "What pricing model does acme.com use for its main product?",
"depth": "standard",
"outputType": "structured",
"structuredOutputSchema": {
"type": "object",
"properties": {
"pricingModel": {"type": "string", "description": "e.g. per-seat, usage-based, flat"},
"hasFreeTier": {"type": "boolean"}
}
}
}' \
--wait-until-finished
```
The schema root **must** be `type: "object"`. Keep it small — a few well-described fields extract far better than a sprawling one.
### Pattern B — Quick sourced answer
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"linkup","actionSlug":"search"}' \
--data '{"q": "Who is the current CTO of Acme Corp?", "depth": "standard"}' \
--wait-until-finished
```
Escalate to `depth: "deep"` only when standard came back empty or shallow — deep quadruples `search`'s cost (0.5 → 2).
## Common pitfalls
- **`depth` is required** on both actions — there's no implicit default in the call; pass `"standard"` explicitly and escalate deliberately.
- **`structuredOutputSchema` is required when `outputType` is `"structured"`** and its root must be an object — a bare string/array schema is rejected.
- **Deep-by-default burns credits.** Deep `search` is 4× standard. Pilot on standard; and if the question needs deep reasoning, `instruct` (flat 1) is the cheaper deep surface.
- **Questions, not keywords.** `q` is a natural-language question; keyword-stuffed queries degrade the synthesized answer.
## Position in the waterfall
**RESEARCH / fallback SOURCE.** Web research chain: `firecrawl` (0.05) for raw pages → **linkup** (0.5–2) for synthesized/structured answers → `serper` (1) for Google-shaped results. As a people/company source it's a fallback (0.5) when no structured provider has the data — see [`../references/stage-action-map.md`](../references/stage-action-map.md).
## Recurring use
Research answers are point-in-time — **recur only when the question itself moves**.
- **Scheduled fit:** narrow. A weekly `instruct` re-ask over an account segment works for genuinely time-sensitive questions (pricing changes, launches); for static facts a re-run re-bills the same answer. Cadence defaults: [`../recipes/save-as-play.md`](../recipes/save-as-play.md).
- **In-play gate:** write the structured output to a dedicated column and run only where it's still empty — or where a refreshed-at timestamp is older than the cadence — since `instruct` bills a flat 1 per row on every re-evaluation.
- **Keep `depth: "standard"` in recurring nodes** — the deep-by-default trap above compounds on a schedule.
## Action shape
`{"kind":"connector","integrationSlug":"linkup","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — structured research feeds personalization fields before the sequencer handoff.
- [`../recipes/icp-discovery.md`](../recipes/icp-discovery.md) — ad-hoc qualitative signals on Closed-Won accounts that no structured provider carries.
provider-playbooks/mixrank.md
---
provider: mixrank
category: enrichment (person + company, premium backfill)
last-reviewed: 2026-07-09
---
# mixrank (Mixrank)
Premium person/company enrichment — two actions, both **4 credits**, the most expensive general enrichers in the catalog (above `peopleDataLabs` at 3, `waterfall.enrichContact` at 2, `waterfall.enrichCompany` at 1). Its earner is identifier flexibility: `findPerson` matches from **any** of email, phone, name (+ company), or social URL — including **phone-only reverse lookup**, which the cheaper chain doesn't do. Treat it as the last backfill rung, never the first stop.
## Credits-based actions
| Action | Cost | Inputs (all optional — pass at least one) | Use for |
|---|---|---|---|
| `findPerson` | 4 | `email`, `phone`, `socialUrl`, `name` / `firstName` + `lastName`, `companyName`, `domain` | Resolve a person from whatever identifier you have — incl. phone or bare name + company. |
| `findCompany` | 4 | `name`, `url`, `linkedin` | Resolve a company from name, domain/website URL, or LinkedIn URL. |
## What it's for
- ✅ **Reverse-phone lookup** — `findPerson` with just `phone` identifies who a number belongs to; no cheaper action in the catalog takes phone as an input key.
- ✅ **Weak-identifier person backfill** — rows where `aiArk` (0.1) and `waterfall` (2) missed and all you have is a name + company or a stray social URL.
- ✅ **Company resolution from a bare name** — `findCompany` with `name` only, when there's no domain to key on (though `oceanio.enrichCompany` at 1 also takes weak identifiers — try it first).
- ❌ **Default ENRICH rung** — at 4 credits it's 2–4× the standard chain; a 1,000-row batch through mixrank is 4,000 credits.
- ❌ **Email/phone *finding*** — mixrank resolves *who someone is* from an identifier; to find missing emails/phones, use the CONTACT chains (`FullEnrich`, `prospeo`, …).
## Patterns
### Pattern A — Reverse-phone identification
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"mixrank","actionSlug":"findPerson"}' \
--data '{"phone":"+14155551234"}' \
--wait-until-finished
```
### Pattern B — Last-rung person backfill
```bash
# Only on rows the cheaper enrich rungs missed
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"mixrank","actionSlug":"findPerson"}' \
--records '[
{"firstName":"Alice","lastName":"Smith","companyName":"Acme","domain":"acme.com"},
{"socialUrl":"https://linkedin.com/in/bobjones"}
]' \
--wait-until-finished
```
Stack every identifier you have per row — more keys, better match confidence at the same 4-credit price.
## Common pitfalls
- **No required fields.** The schema marks nothing required, so an empty `--data '{}'` still executes and still bills 4 credits. Always pass at least one identifier; guard the node with a filter on identifier presence.
- **Fixed cost on miss.** 4 credits whether or not a match comes back — gate mixrank on prior-rung misses only ([`../references/cost-discipline.md`](../references/cost-discipline.md)).
- **Bare-name matching is fuzzy.** `name` or `companyName` alone can mismatch namesakes; anchor with `domain` or `socialUrl` whenever possible, and pilot 10 rows before a batch.
## Position in the waterfall
**ENRICH stage, last rung.** Person: `aiArk.enrichPerson` (0.1) → `linkedin.enrichProfile` (0.25) → `waterfall.enrichContact` (2) → `peopleDataLabs` (3) → **mixrank (4)**. Company: `waterfall.enrichCompany` / `oceanio.enrichCompany` (1) → `peopleDataLabs.enrichCompany` (3) → **mixrank (4)**. Promote it out of order only for the phone-keyed niche. See [`../references/stage-action-map.md`](../references/stage-action-map.md).
## Recurring use
No scheduled fit — **last-rung, per-record backfill only**; at 4 credits a recurring blanket pass is the fastest way to torch a budget.
- **In-play gate:** double gate — run only where the cheaper rungs' output fields are still empty *and* at least one identifier is present (the no-required-fields pitfall means an empty row bills 4 credits on every re-evaluation).
- **Time-sensitivity:** identity resolution is stable — re-running `findPerson` / `findCompany` on a matched row re-buys the same answer, and fixed-cost-on-miss means even a "retry later" pass must be deliberately scoped.
## Action shape
`{"kind":"connector","integrationSlug":"mixrank","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/prospecting.md`](../recipes/prospecting.md) — final ENRICH backfill for the rows the standard chain leaves empty.
- [`../recipes/re-engagement.md`](../recipes/re-engagement.md) — identify inbound callers or stale contacts from a phone number before re-activating.
provider-playbooks/neverBounce.md
---
provider: neverBounce
category: verification
last-reviewed: 2026-07-09
---
# neverBounce (NeverBounce)
Dedicated email verification. **One credits-based action, 0.2 credits** — double the priority-stack default `waterfall.verifyEmail` (0.1) and 20× the bulk option `icypeas.verifyEmail` (0.01). It earns a call in two cases: the user has an **existing NeverBounce subscription** (own API key connector — the 0.2 price stops mattering), or you want a typo-rescuing second opinion and the equal-cost alternatives (`zeroBounce`, `enrichley`) have already been spent on this list.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `verifyEmail` | 0.2 | `email` (required) | Verify a single email's deliverability, with a suggested typo correction. |
The connector also accepts your own NeverBounce API key (`apiKey`) — same action, billed to your NeverBounce plan instead of credits.
## What it's for
- ✅ **Existing NeverBounce subscription** — wire the own-key connector and use it as the house verifier.
- ✅ **Typo rescue** — output includes `suggested_correction`; a "bad" email is sometimes one transposed character from a deliverable one.
- ❌ **Default verify step** — `waterfall.verifyEmail` (0.1) leads (see [`../references/alternatives.md`](../references/alternatives.md), Verify email alternatives).
- ❌ **Second opinion at list scale** — `zeroBounce.verifyEmail` (0.1) is an independent provider at half the price.
- ❌ **Bulk verification** — `icypeas.verifyEmail` (0.01) is 20× cheaper.
## Patterns
### Pattern A — Re-verify the ambiguous subset
```bash
# Only on rows the first verifier flagged catch-all / ambiguous
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"neverBounce","actionSlug":"verifyEmail"}' \
--records '[{"email":"alice@acme.com"},{"email":"bob@globex.com"}]' \
--wait-until-finished
```
Keep an email when either verifier passes it cleanly; drop it only when both agree it's bad. At 0.1 + 0.2 per re-checked row, run this on the ambiguous subset only.
## Output fields
`status, result, flags[], suggested_correction, execution_time`. Filter on `result`; `flags` carries per-address diagnostic markers. The catalog dump doesn't enumerate the `result` / `flags` values — treat anything short of an unambiguous valid verdict as unproven and route it like a catch-all (send only per your sequencer's risk tolerance).
## Cost traps
- **2× the default on every row.** A 1,000-row verify costs 200 credits here vs 100 on `waterfall.verifyEmail`. Run the free pre-cull first ([`../references/contact-accuracy.md`](../references/contact-accuracy.md)) so only plausible rows reach the paid step.
- **Credits by accident.** If the user already pays NeverBounce, set up the own-key connector before batching.
## Anti-patterns
- **neverBounce as the first verify rung.** Chain order is deliberate: `waterfall.verifyEmail` (0.1) default, 0.1-tier second opinions, `icypeas` for bulk (see [`../references/stage-action-map.md`](../references/stage-action-map.md), Verify email).
- **Ignoring `suggested_correction`.** When present, re-verify the corrected address before writing the contact off — a fixed typo is the cheapest "found email" there is.
- **Trusting a finder's own "verified" flag.** Every found email goes through an independent verify step regardless (see [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md)).
## Position in the waterfall
**VERIFY stage, mid-premium rung — outside the default chain.** Default: free pre-cull → `waterfall.verifyEmail` (0.1) → `zeroBounce.verifyEmail` (0.1) second opinion → `icypeas.verifyEmail` (0.01) for bulk. `neverBounce.verifyEmail` (0.2) enters via own key or as a deliberate extra opinion.
## Recurring use
Verification recurs per **send wave**, not per calendar — at 0.2/row this is the costliest list to re-sweep on a timer.
- **Re-verify gate:** run `verifyEmail` only on rows entering a send wave whose last clean verdict is stale — gate on a verify-timestamp column, never the whole list.
- **In-play gate:** as the second-opinion rung, filter to rows the first verifier flagged catch-all/ambiguous (Pattern A) and where the neverBounce `result` column is still empty.
- **Own key for standing use:** a play that re-verifies every wave multiplies the 2×-default cost trap — wire the own-key connector before making this a recurring step.
## Action shape
`{"kind":"connector","integrationSlug":"neverBounce","actionSlug":"verifyEmail"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — the verify step before personalization; never sequence unverified emails.
- [`../recipes/prospecting.md`](../recipes/prospecting.md) — the verify rung of the find → enrich → verify → sync spine.
provider-playbooks/oceanio.md
---
provider: oceanio
category: sourcing (lookalikes)
last-reviewed: 2026-07-09
---
# oceanio (Ocean.io)
Mid-tier company/people search and enrichment — four actions, all 1 credit. Its edge is **lookalike sourcing** (`searchCompanies` with `lookalikeDomains`: "companies like these three customers") plus technographic / web-traffic / e-commerce filters, and **cross-filtered search** (people filters and company filters combined in one call). Not in the priority stack: `salesNavigator` (0.02–0.05) stays the sourcing default and `aiArk.searchCompanies` (0.01) is the cheap lookalike path; come here when the filter is technographic / web-traffic / e-commerce-shaped or needs cross-filtered people+company search, before escalating to `peopleDataLabs` (3). See [`../references/stage-action-map.md`](../references/stage-action-map.md) (mid-tier rows).
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `searchCompanies` | 1 **per returned record** | `companiesFilters`, `peopleFilters`, `limit` | Company search: lookalikes, technographics, web traffic, revenue, e-commerce flags. |
| `searchPeople` | 1 | `peopleFilters`, `companiesFilters`, `limit` | People search cross-filtered by their company's attributes. |
| `enrichCompany` | 1 | `company` object (`domain, name, linkedin, email, phone, countryCode, city, address, …` + socials) | Company enrichment from weak identifiers. |
| `enrichPerson` | 1 | `person` object (`email, linkedin, firstName, lastName, jobTitle, phone, …`) + `company` object | Person enrichment; company context improves matching. |
## What it's for
- ✅ **Lookalike TAM** — `companiesFilters.lookalikeDomains` seeds a search from best-customer domains; no priority-stack action does this.
- ✅ **Technographic + traffic filters** — `technologies`, `webTrafficVisitsFrom/To`, `ecommerce`, `mobileAppsFrom/To`, `revenues`, `companySizes` in one filter object (vs `theirStack` for job-posting-derived tech intent).
- ✅ **"People at companies like X"** — `searchPeople` accepts both filter objects: `peopleFilters` (`jobTitles`, `seniorities`, `departments`, `emailStatuses`, `keywords`, `countries`, …) AND `companiesFilters` in the same call.
- ✅ **Dedupe-aware sourcing** — `includeDomains` / `excludeDomains` (companies) and `includeIds` / `excludeIds`, `excludeJobTitles` (people) keep already-owned records out of the paid pull.
- ❌ **Plain industry/size/geo sourcing** — `salesNavigator.searchAccounts` (0.05) is 20× cheaper.
- ❌ **First-stop enrichment** — the ENRICH chain leads with `aiArk.enrichCompany` (0.01) and `linkedin` (0.25); oceanio is a same-price peer of `waterfall.enrichCompany` (1), so pick by pilot coverage.
## Patterns
### Pattern A — Lookalike company sourcing
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"oceanio","actionSlug":"searchCompanies"}' \
--data '{
"companiesFilters": {
"lookalikeDomains": ["acme.com", "globex.com", "initech.com"],
"countries": ["US"],
"companySizes": ["11-50", "51-200"],
"excludeDomains": ["bigco.com"]
},
"limit": 100
}' \
--wait-until-finished
```
Billed per returned record — set `limit` to the approved scope ([`../references/cost-discipline.md`](../references/cost-discipline.md)).
### Pattern B — People at companies matching a technographic filter
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"oceanio","actionSlug":"searchPeople"}' \
--data '{
"peopleFilters": {"jobTitles": ["VP Marketing", "CMO"], "seniorities": ["vp", "c_suite"]},
"companiesFilters": {"technologies": ["shopify"], "countries": ["US"]},
"limit": 50
}' \
--wait-until-finished
```
Enum values in both examples (`companySizes`, `seniorities`, `technologies`, …) are **illustrative** — fetch the real accepted values from the `listObjectFieldValues` autocomplete before building the filter (see pitfalls).
### Pattern C — Enrichment from weak identifiers
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"oceanio","actionSlug":"enrichPerson"}' \
--records '[
{"person":{"firstName":"Alice","lastName":"Smith","jobTitle":"CTO"},"company":{"domain":"acme.com"}},
{"person":{"linkedin":"https://linkedin.com/in/bobjones"}}
]' \
--wait-until-finished
```
`enrichCompany` mirrors this: identifiers nest under a `company` object (plus an optional `people` array for known contacts) — accepts `domain`, `name`, `linkedin`, socials, a registration number, or a postal address, which makes it useful when domain-only enrichers miss.
## Common pitfalls
- **Inputs are nested objects.** Filters go inside `peopleFilters` / `companiesFilters`; enrich identifiers inside `person` / `company`. Flat top-level fields express nothing.
- **Filter values are opaque enums.** `companySizes`, `revenues`, `seniorities`, `departments`, `emailStatuses`, `industries`, `technologies` take provider-defined string values — inspect them via the `listObjectFieldValues` autocomplete on `connection integration get oceanio` before building the filter; guessed strings silently mismatch.
- **`searchCompanies` bills per item, `searchPeople` per call** — the dump prices `searchCompanies` per returned record (`limit` = budget cap) while `searchPeople` is a fixed 1/execution.
- **Rate limit: 60 calls/minute** (spread) — the slowest in this group; batch accordingly.
## Position in the waterfall
- **SOURCE — mid-tier rung** (both searches at 1): after `aiArk` (0.01–0.05) / `salesNavigator` / `icypeas` (0.02–0.05), before `peopleDataLabs` / `waterfall.searchProspects` (3). Promote it when the filter is technographic-first or needs people+company cross-filtering.
- **ENRICH — mid-tier rung** (both enriches at 1): peer of `waterfall.enrichCompany` (1) and `apolloio.enrichOrganization` (1); pilot 10 rows to pick by coverage.
- Sourced people flow on to CONTACT (`FullEnrich.findEmail`, 1) and VERIFY (`waterfall.verifyEmail`, 0.1) as usual.
## Recurring use
Lookalike discovery compounds — **re-run `searchCompanies` weekly as the seed list grows** (cadence table: [`../recipes/save-as-play.md`](../recipes/save-as-play.md)).
- **Dedup before paid nodes:** each re-discovery returns known winners again — refresh `lookalikeDomains` with new Closed-Won domains, keep owned accounts in `excludeDomains`, and dedup hits against the Companies model before any downstream enrichment bills.
- **Per-record billing recurs too:** `searchCompanies` bills per returned record on every scheduled run — hold `limit` at the approved scope so recurring pulls bill mostly-new rows.
- **In-play gate:** `enrichCompany` / `enrichPerson` (1) run only where the target enriched field is still empty — firmographics are stable; re-enriching a filled row re-buys the same data.
## Action shape
`{"kind":"connector","integrationSlug":"oceanio","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/openAi.md
---
provider: openAi
category: llm
last-reviewed: 2026-07-09
---
# openAi (OpenAI)
GPT models through a single `instruct` action — **the cheapest bulk-LLM tier in the catalog**: the nano models run at **0.006 credits per 1,000-token package**, 33× cheaper than anthropic's cheapest tier (0.2). Default provider for pure-volume transforms (extraction, classification, short personalization) once the prompt is proven. Routing: `anthropic` Sonnet for judgment, openAi nano for cheapest bulk, `gemini` Flash for cheap high-throughput, `perplexity` for web-grounded answers.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `instruct` | 0.006–0.5 / 1,000-token package (per-model tiers below) | `model` + `prompt` (required); `advancedSettings.{systemPrompt, maxTokens, temperature, withWebSearch}`; `output.{responseFormat, jsonSchema}` | Bulk LLM steps with native structured output. |
### Per-model cost tiers
| Tier | Model ids | Credits / 1,000 tokens |
|---|---|---|
| Nano | `gpt-5-nano`, `gpt-5.5-nano`, `gpt-5.4-nano`, `gpt-5.3-nano` | 0.006 |
| 4.1 Nano | `gpt-4.1-nano` | 0.01 |
| 4o Mini | `gpt-4o-mini` | 0.02 |
| Mini | `gpt-5-mini` (schema default), `gpt-5.5-mini`, `gpt-5.4-mini`, `gpt-5.3-mini` | 0.03 |
| 4.1 Mini | `gpt-4.1-mini` | 0.05 |
| Full GPT-5 | `gpt-5`, `gpt-5.5`, `gpt-5.4`, `gpt-5.3`, `gpt-5.2`, `gpt-5.1` | 0.2 |
| GPT-4.1 | `gpt-4.1` | 0.3 |
| Legacy | `gpt-4o`, `gpt-3.5-turbo` | 0.5 |
`advancedSettings.withWebSearch: true` adds a **fixed 0.4 credits per call**. Rate limit: 10,000 calls/min per model — the highest of the four LLM providers.
## What it's for
- ✅ **Cheapest at-scale LLM step** — `gpt-5-nano` for prompt-library extraction/classification/personalization on large segments, after a pilot proves the prompt.
- ✅ **Native structured output** — `output.responseFormat: "json_schema"` + a `jsonSchema` object validates the shape at the API level; no prompt-only JSON enforcement needed.
- ✅ **Balanced default** — `gpt-5-mini` (0.03) is the schema's own recommendation for cost/performance when nano output quality wobbles.
- ❌ **Judgment-heavy steps** — soft-criteria scoring, positioning, salience: use `anthropic` Sonnet (see [`../references/prompt-library/index.md`](../references/prompt-library/index.md) per-prompt model guidance).
- ❌ **Web research** — `withWebSearch` costs +0.4 fixed per call; `perplexity` is the web-grounded provider.
## Pattern — bulk extraction with schema-validated JSON
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"openAi","actionSlug":"instruct"}' \
--records '[{"model":"gpt-5-nano","prompt":"<substituted extraction prompt row 1>","advancedSettings":{"temperature":0},"output":{"responseFormat":"json_schema","jsonSchema":{"type":"object","properties":{"industry":{"type":"string"},"confidence":{"type":"string"}}}}},{"model":"gpt-5-nano","prompt":"<row 2>"}, ...]' \
--wait-until-finished
```
**`model`, `prompt`, `advancedSettings`, and `output` are all *inputs*** — they go in each record, never in the action's `config`, which a top-level action does not carry at all. Settings placed there are rejected on older backends and **silently dropped** on newer ones, so a dropped `output` turns schema-validated JSON back into free text. `responseFormat` enum: `text` (default) | `json_object` | `json_schema` (the last requires the sibling `jsonSchema` object).
## Input quirks
- **`maxTokens` counts reasoning tokens too** on GPT-5 models ("includes both visible output tokens and reasoning tokens") — a tight `maxTokens` can truncate visible output even when the answer is short. Leave headroom.
- **Temperature is 0–2, default 1** — set `temperature: 0` explicitly for extraction/scoring; the default is creative, not deterministic.
- `json_object` mode still requires the word "JSON" discipline in your prompt; `json_schema` is the stricter, preferred mode for parse-ready output.
## Cost traps
- **500-row batch math** (≈1 package per short call): `gpt-5-nano` ≈ **3 credits**; `gpt-5-mini` ≈ **15**; `gpt-5` ≈ **100**; `gpt-4o` ≈ **250**. Never run a full-size or legacy model on a bulk transform — the nano/full spread is 33×.
- **`gpt-4o-mini` is not the cheap option anymore.** At 0.02 it costs 3.3× `gpt-5-nano` (0.006) for the same bulk role — older recipes citing gpt-4o-mini as the floor predate the GPT-5 tiers.
- **Legacy trap:** `gpt-4o` and `gpt-3.5-turbo` bill at 0.5 — more than `gpt-5` itself. Never pick them.
- **`withWebSearch` on a batch** adds 0.4 × rows fixed (+200 credits on 500 rows) before tokens.
## Position in the LLM stack
- **The bulk rung** of [`../references/stage-action-map.md`](../references/stage-action-map.md) LLM section: pilot the prompt on `anthropic` Sonnet (~10 rows), then demote the batch to `gpt-5-nano`/`gpt-5-mini` per [`../references/cost-discipline.md`](../references/cost-discipline.md).
## Recurring use
No scheduled fit — `instruct` is an offline transform; the recurring shape is **a scoring/personalization/extraction node inside a play**, never a timed re-pull.
- **In-play gate:** run only where the node's output column (score, extracted field, personalization line) is still empty — with `temperature: 0`, re-prompting an unchanged row returns the same answer and just re-bills. Trigger on newly-arrived or newly-enriched rows entering the segment.
- **Cadence compounds cost:** per-row token spend × new rows × every cycle, forever — keep the play on the nano/mini tiers per the batch math above, and never leave `withWebSearch` on in a recurring node (fixed +0.4 × rows, every run). Cadence defaults: [`../recipes/save-as-play.md`](../recipes/save-as-play.md).
## Action shape
`{"kind":"connector","integrationSlug":"openAi","actionSlug":"instruct"}`, with `model`, `prompt`, `advancedSettings`, and `output` per record in `--records` / `--data`. **No `connectorUuid` in `config`** — and no model settings there either; inside a workflow **node** those same fields are the node's `config`. Costs above are the Cargo-credits rules; a workspace can instead attach its own OpenAI key (connector config takes a single required `apiKey`) and bill the provider directly.
## Pairs with
- [`../references/prompt-library/index.md`](../references/prompt-library/index.md) — reuse the library's extraction/qualification/scoring prompts; they port to openAi unchanged (keep `temperature: 0` for deterministic families).
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — the cheap-at-scale swap for the personalization step; [`../guides/writing-outreach.md`](../guides/writing-outreach.md) — provider choice for outreach copy.
provider-playbooks/parallel.md
---
provider: parallel
category: research (search, extract, agentic task)
last-reviewed: 2026-08-15
---
# parallel (Parallel)
Web search, page extraction, and **agentic research tasks that return a schema you define**. Three actions, and the third is the one nothing else in the catalog does: `createTask` runs a multi-step research job and fills a JSON schema, at a cost you pick from a nine-rung processor ladder starting at **0.125**.
Cheapest extraction in the catalog at **0.025 per URL**, half of `firecrawl.scrape` (0.05).
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `extract` | 0.025 **per URL** | `urls` (required), `objective`, `searchQueries`, `maxCharsTotal`, `fullContent` | **Cheapest page read in the catalog.** Pull content from URLs you already have, optionally steered by an `objective`. |
| `search` | 0.125 fixed **+ 0.025 per item** | `searchQueries` (required), `objective`, `mode`, `numResults`, `maxCharsTotal`, `includeDomains`, `excludeDomains`, `afterDate` | Ranked web search with relevance scoring, steered by an objective rather than keywords alone. |
| `createTask` | 0.125 (`lite`) | `input` (required), `processor` (required), `outputSchema`, `includeDomains`, `excludeDomains`, `afterDate`, `location` | **Unique action.** An agentic research task that returns structured output against your own schema. |
`extract` bills per URL with no fixed component, so a 10-URL call is 0.25 and there is no penalty for splitting or batching.
### The `createTask` processor ladder
`processor` is **required and has no default**, so the tier is always a deliberate choice. It is the only cost decision in this provider that can run away:
| Processor | Cost | When |
|---|---|---|
| `lite` | **0.125** | The default choice. A focused question over public web with a small schema. |
| `base` | 0.25 | The same, when `lite` returns thin results on a hard target. |
| `core` | 0.625 | Multi-hop questions ("who do they compete with, and what do those competitors charge"). |
| `core2x` | 1.25 | Wider fan-out on the same shape. |
| `pro` | 2.5 | Deep research where a wrong answer is expensive. |
| `ultra` / `ultra2x` / `ultra4x` / `ultra8x` | 7.5 / 15 / 30 / **60** | Exhaustive research. **`ultra8x` costs 60 credits per record** — more than a full waterfall on a contact. Never reach for these across a list. |
**Start at `lite` and escalate the misses, exactly as with an email waterfall.** Going straight to `pro` across 200 rows is 500 credits for a question `lite` may have answered for 25.
## What it's for
- ✅ **Reading pages you already have URLs for** — `extract` at 0.025 is the cheapest rung in the catalog, and it takes an `objective` so the extraction is steered rather than a raw dump.
- ✅ **Structured research output** — `createTask` with `outputSchema` returns a JSON object rather than prose, which is what makes it usable in a pipeline instead of in a chat window. Nothing else in the catalog fills a caller-supplied schema.
- ✅ **Account research and personalization** — an objective-driven question over public web, cited and structured, at 0.125 a record.
- ❌ **Structured B2B firmographics** — `companyEnrich.enrichByDomain` (0.25) or `linkedin.enrichCompanyFromDomain` (0.5) return typed fields. Do not pay an agentic task to guess a headcount that an enrichment provider knows.
- ❌ **Finding people or emails** — that is the contact stack ([`../references/stage-action-map.md`](../references/stage-action-map.md)); a research task is the wrong instrument and the wrong price.
- ❌ **Local SMB listings** — `serper.searchPlaces` (0.05) is the index for that.
## Patterns
### Pattern A — Read the pages you already have (cheapest rung)
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"parallel","actionSlug":"extract"}' \
--data '{
"urls": ["https://acme.com", "https://acme.com/careers"],
"objective": "What the company says it does, and which teams it is growing"
}' \
--wait-until-finished
```
Two URLs, 0.05 credits. Pass `objective` even when it feels optional: it is the difference between an extraction and a page dump you then pay an LLM to read.
### Pattern B — Structured research at the cheapest tier
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"parallel","actionSlug":"createTask"}' \
--data '{
"input": "What has Acme publicly said about its priorities in the last 12 months, and who does it name as competitors?",
"processor": "lite",
"outputSchema": {
"type": "object",
"properties": {
"priorities": {"type": "array", "items": {"type": "string"}},
"competitors": {"type": "array", "items": {"type": "string"}},
"sources": {"type": "array", "items": {"type": "string"}}
}
}
}' \
--wait-until-finished
```
**Put a `sources` field in every `outputSchema`.** The task will fill it, and without it you get confident prose with nothing to check it against, which is the failure mode research output has.
### Pattern C — Objective-steered search
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"parallel","actionSlug":"search"}' \
--data '{
"searchQueries": ["Acme Series B", "Acme layoffs 2026"],
"objective": "Recent funding or headcount changes at Acme",
"numResults": 10,
"afterDate": "2026-01-01"
}' \
--wait-until-finished
```
0.125 fixed plus 0.025 per result, so `numResults: 10` is 0.375 and `numResults: 50` is 1.375. Unlike `serper`, raising the result count **does** raise the bill.
## Common pitfalls
- **Omitting `processor` and assuming a cheap default.** It is required, so the call fails rather than defaulting, which is the good outcome. The bad outcome is copying an example that pins `pro` and running it over a list.
- **Reading `search` pricing as fixed.** It is fixed **plus** per item. That is the opposite of `serper` (0.05 flat for up to 100), so a habit carried from serper of maxing the limit is expensive here.
- **Using `createTask` for a field an enrichment provider owns.** An agentic task can return a headcount. It will cost more than `companyEnrich.enrichByDomain` and be less reliable, because it is inferring what the other provider looked up.
- **`outputSchema` with no sources field.** See Pattern B. This is the single most common way research output becomes unusable.
## Anti-patterns
- **The processor ladder across a segment.** `ultra8x` is 60 credits per record. On a 500-row segment that is 30,000 credits, which is larger than most accounts' monthly allocation. If a tier above `core` is genuinely needed, the run is a per-record decision and not a batch.
- **`createTask` where `extract` would do.** If you already have the URL, extraction is 0.025 and the task is at least 0.125. Reach for the task when the question needs finding pages, not reading known ones.
- **Parallel as a firmographics provider.** See the ❌ list. This is a research instrument.
## Position in the waterfall
- `extract` — **first rung for reading a known URL**, ahead of `firecrawl.scrape` (0.05) on price alone. Prefer firecrawl when you need its crawl behavior across a site rather than a URL list.
- `createTask` — the structured-research rung, ahead of `linkup.instruct` (1) on price at `lite` (0.125) and `base` (0.25), and the only one that fills a caller-supplied schema. Prefer `linkup.instruct` when a `sourcedAnswer` in prose is genuinely all you want.
- `search` — a web-search rung alongside `firecrawl.search` (0.05) and `serper.search` (0.05). Both of those are cheaper for plain queries; parallel earns its place when the `objective` steering measurably improves what comes back.
## Action shape
`{"kind":"connector","integrationSlug":"parallel","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.** Inputs go in `--data` for a single call and in `--records` when fanning out per row.
## Pairs with
- [`../recipes/account-expansion.md`](../recipes/account-expansion.md) — research feeding an expansion angle.
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — the personalization step, where structured output beats prose.
## Recurring use
- **Signal-triggered, not timer-driven.** Research goes stale on events (funding, a launch, a leadership change), not on a calendar. Re-running `createTask` monthly over a static list re-bills settled rows for an answer that has not changed.
- **In-play gate:** filter to rows whose research column is empty, or whose triggering signal is newer than the stored research timestamp.
- **Pin the processor in the play, not in the prompt.** A recurring node that lets the tier vary per run has an unbounded bill. Set `lite` in the node and treat an escalation as a separate, human-approved run.
- **Cache `extract` output.** Page content changes slowly; re-extracting the same URL every run is the cheapest action in the catalog repeated until it is not cheap.
provider-playbooks/peopleDataLabs.md
---
provider: peopleDataLabs
category: enrichment (heavyweight backfill + structured search)
last-reviewed: 2026-04-27
---
# peopleDataLabs (People Data Labs)
Heavyweight people / company database. **Six credits-based actions, all flat 3 credits each.** Use as **backfill** when cheaper sources miss, or as the **primary** source when you need query power salesNavigator's filters can't express.
Two filter shapes — pick the right one:
- **`searchPeople` / `searchCompanies`** use cargo's standard segment-filter shape: `{filter: {conjonction, groups: [{conjonction, conditions: [{propertyName, operator, value}, ...]}]}}`. Operators: `is`, `isNot`, `contains`, `notContains`, `lowerThan`, `lowerThanOrEquals`, `greaterThan`, `greaterThanOrEquals`. Use when criteria are simple key/operator/value AND/OR combinations.
- **`queryPeople` / `queryCompanies`** take a **SQL string** — PDL's SQL API. Use when you need joins, OR-of-AND combinations beyond what the cargo filter shape supports cleanly, or when you already have a SQL query from PDL's documentation. **NOT Elasticsearch — it's PDL SQL.**
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `searchPeople` | 3 | `filter, limit, pretty, titlecase` | People search with cargo's `{conjonction, groups, conditions}` filter shape. |
| `searchCompanies` | 3 | `filter, limit, pretty, titlecase` | Company search with the same cargo filter shape. |
| `queryPeople` | 3 | `query: <SQL string>, limit, pretty, titlecase` | People search via PDL **SQL** query. Required for joins / complex bool combinations. |
| `queryCompanies` | 3 | `query: <SQL string>, limit, pretty, titlecase` | Company search via PDL **SQL** query. Best for investor / funding / complex-filter sourcing. |
| `enrichPerson` | 3 | `parameters, options` | Fill missing person fields. Default backfill when cargo + waterfall miss. |
| `enrichCompany` | 3 | `parameters, options` | Fill missing company fields. Default backfill when cargo + waterfall miss. |
## When to use peopleDataLabs (vs the alternatives)
- ✅ **Investor / funding / VC-portfolio sourcing**: `queryCompanies` SQL with a `WHERE` clause on PDL's investor / funding fields — salesNavigator can't express this.
- ✅ **Complex multi-axis filters** that salesNavigator's UI-style filters can't combine: e.g., "fintech in EMEA AND Series B+ AND > 100 engineers AND running Snowflake".
- ✅ **Heavyweight backfill**: after `aiArk.enrichPerson/Company` and `waterfall.enrich*` both return empty, peopleDataLabs is the deepest source in the catalog.
- ❌ **Cheap at-scale sourcing**: 3 cred is 60–150× more expensive than salesNavigator (0.02–0.05). Don't default here for volume work.
## Patterns
### Pattern A — Investor portfolio sourcing (queryCompanies, SQL)
`queryCompanies` accepts a SQL string (PDL's SQL API). Use it when criteria don't fit cargo's `{conjonction, groups, conditions}` shape — typically anything involving array containment (e.g., "investors includes X") or complex OR-of-AND combinations.
```bash
# "Find every company backed by Sequoia Capital, USA, 50-500 employees"
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"peopleDataLabs","actionSlug":"queryCompanies"}' \
--data '{
"query": "SELECT * FROM company WHERE summary.investors LIKE %Sequoia Capital% AND employee_count >= 50 AND employee_count <= 500 AND location.country = '\''united states'\''",
"limit": 200
}' \
--wait-until-finished
```
Common PDL SQL fields: `industry`, `employee_count`, `founded`, `total_funding_raised`, `summary.investors`, `location.country`, `location.locality`, `tags`. See PDL's SQL reference for the full schema; cargo passes the SQL through verbatim.
### Pattern B — Backfill missing person details
After cargo + waterfall both return empty for a row:
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"peopleDataLabs","actionSlug":"enrichPerson"}' \
--records '[
{"parameters":{"email":"alice@acme.com"}},
{"parameters":{"linkedin":"linkedin.com/in/alicesmith"}},
{"parameters":{"first_name":"Alice","last_name":"Smith","company":"Acme"}}
]' \
--wait-until-finished
```
`parameters` accepts any combination — `email`, `linkedin`, `phone`, `first_name + last_name + company`, `first_name + last_name + location`, etc. More identifiers = higher hit rate.
### Pattern C — Structured people search via cargo's filter shape
For criteria that fit cargo's standard filter shape (key/operator/value AND/OR), prefer `searchPeople` over `queryPeople`:
```bash
# "Find Heads of Engineering at fintechs in NYC, 50-500 employees"
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"peopleDataLabs","actionSlug":"searchPeople"}' \
--data '{
"filter": {
"conjonction": "and",
"groups": [{
"conjonction": "and",
"conditions": [
{"propertyName": "job_title", "operator": "contains", "value": "head of engineering"},
{"propertyName": "job_company_industry", "operator": "is", "value": "financial services"},
{"propertyName": "location_locality", "operator": "is", "value": "new york"},
{"propertyName": "job_company_size", "operator": "greaterThanOrEquals", "value": 50},
{"propertyName": "job_company_size", "operator": "lowerThanOrEquals", "value": 500}
]
}]
},
"limit": 100
}' \
--wait-until-finished
```
**Note the spelling**: `conjonction` (with two `o`s, no `u`) — same intentional cargo-platform-wide convention. Typo here fails silently with empty results.
If cargo's filter shape can't express the criteria (e.g., array-membership filters like `summary.investors LIKE %X%`), drop down to `queryPeople` SQL.
## Common pitfalls
- **3 credits adds up fast.** 1,000 enriches = 3,000 credits. Always run cargo + waterfall first; only escalate the ~20-30% of rows that those miss.
- **`searchPeople` vs `queryPeople`** — both cost 3 credits; pick by filter shape. `searchPeople` accepts cargo's `{conjonction, groups, conditions}` (good for simple AND/OR criteria). `queryPeople` accepts a PDL **SQL string** (good for array-membership, joins, complex bool). Default to `searchPeople`; drop down to `queryPeople` only when SQL is required.
- **`titlecase: true`** normalizes name capitalization in the response. Default is true; rarely worth disabling.
- **`pretty: true`** formats JSON for readability. Disable in production calls — adds bytes without value.
- **Multi-axis matches dilute precision.** Adding a 5th filter can reduce result quality (PDL's matching is forgiving when it has to be). Sample 10 results before fanning out.
## Anti-patterns
- **Flat records on the enrich actions.** `enrichPerson`/`enrichCompany` batch records need the `parameters` wrapper — `{"parameters":{"email":"…"}}`, NOT `{"email":"…"}`. Flat records fail validation or silently enrich nothing.
- **Elasticsearch queries in `queryX`.** The `query` field is a **PDL SQL string** (`SELECT * FROM company WHERE …`), never an Elasticsearch DSL object. This is the single most common PDL misuse.
- **`conjunction` spelling in `searchX` filters.** It's `conjonction` (two o's, no u) — the cargo-wide convention; the typo returns empty results with no error.
- **Search without a strict `limit`.** All PDL searches are billed per returned row at 3 credits each — an uncapped exploratory query is the most expensive mistake in the priority stack. Probe with `limit: 1`, then pull the approved scope ([`../references/cost-discipline.md`](../references/cost-discipline.md)).
## Action shape
`{"kind":"connector","integrationSlug":"peopleDataLabs","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
## Where peopleDataLabs sits in the spine
- Step 1 (SOURCE): only when salesNavigator's filters miss your criteria (e.g., funding-round filter).
- Steps 3–4 (ENRICH / SIGNAL): fallback after cargo + waterfall return empty.
- Step 7 (BACKFILL): canonical last-resort for missing emails / details.
Never the first stop unless the filter shape demands it.
## Recurring use
**Never on a timer.** At a flat 3 credits per row, scheduled re-enrichment is the expensive recurring anti-pattern — it re-bills mostly unchanged data every cycle.
- **In-play gate:** last rung only — `enrichPerson`/`enrichCompany` behind a gate requiring the target field still empty AND the cargo + waterfall rungs already missed (the escalation rule from Common pitfalls, enforced as a segment filter). Stamp an attempted-at column so a row PDL missed doesn't retry at 3 credits every re-evaluation.
- **Scheduled search:** avoid recurring `searchX`/`queryX` — billed 3 per returned row on every run, with no incremental mode; a list that must refresh on a schedule belongs on a cheaper sourcing rung, with PDL reserved for the residue. Cadence table for the play wrapper: [`../recipes/save-as-play.md`](../recipes/save-as-play.md).
provider-playbooks/perplexity.md
---
provider: perplexity
category: llm
last-reviewed: 2026-07-09
---
# perplexity (Perplexity)
Web-grounded LLM through a single `instruct` action — **every model has live internet access** (per the schema: "All models have access to internet"). The research rung of the LLM stack: use it when the answer must come from the current web (company facts, recent news, "what is X known for"), not for offline transforms — bulk-tier `openAi`/`gemini` models are 10–80× cheaper (gpt-5-nano 0.006 vs sonar's 0.3–1) on prompts that don't need the web.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `instruct` | 0.3–1 / 1,000-token package (per model × `searchContextSize`, below) | `model` + `prompt` (required); `advancedSettings.{searchContextSize, systemPrompt, searchRecencyFilter, searchDomainFilter, returnRelatedQuestions, returnImages, maxTokens, temperature}`; `output.{responseFormat, jsonSchema, regex}` | Web-grounded research answers per record. |
### Per-model cost tiers (credits / 1,000 tokens, by `searchContextSize`)
| Model | low | medium (default) | high | Rate limit |
|---|---|---|---|---|
| `sonar` | 0.3 | 0.4 | 0.5 | 1,000/min |
| `sonar-reasoning` | 0.4 | 0.5 | 0.6 | 1,000/min |
| `sonar-reasoning-pro` | 0.5 | 0.7 | 0.9 | 1,000/min |
| `sonar-pro` | 0.6 | 0.8 | 1 | 1,000/min |
| `sonar-deep-research` | 0.5 (flat) | 0.5 (flat) | 0.5 (flat) | **5/min** |
## What it's for
- ✅ **Per-record web research** — company summaries, recent-news lookups, fact-finding that feeds personalization (see [`../guides/writing-outreach.md`](../guides/writing-outreach.md), research step).
- ✅ **Scoped research** — `searchRecencyFilter` (`month`/`week`/`day`/`hour`) for freshness; `searchDomainFilter` to pin citations to up to 3 domains (prefix `-` to blacklist one).
- ✅ **Deep single-shot reports** — `sonar-deep-research` for a handful of high-value accounts, never batches (5/min).
- ❌ **Offline transforms** — extraction, classification, scoring, personalization on data you already hold: `sonar` at 0.3–0.5 costs 50–80× `openAi` `gpt-5-nano` (0.006).
- ❌ **Result listings** — if you want raw Google results to parse yourself, that's `serper.search` (0.05 fixed); perplexity returns synthesized answers.
## Pattern — web-grounded company research per record
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"perplexity","actionSlug":"instruct"}' \
--records '[{"model":"sonar","prompt":"What is Acme GmbH known for? 2-sentence summary.","advancedSettings":{"searchContextSize":"low","searchRecencyFilter":"month","temperature":0}},{"model":"sonar","prompt":"..."}, ...]' \
--wait-until-finished
```
**`model`, `prompt`, and `advancedSettings` are all *inputs*** — they go in each record, never in the action's `config`, which a top-level action does not carry at all. Settings placed there are rejected on older backends and **silently dropped** on newer ones — and a dropped `searchContextSize` is a real cost difference here. Pipe the answers into a cheap `anthropic`/`openAi` step for structured extraction if you need parse-ready JSON downstream.
## Input quirks
- **The schema default model is `sonar-deep-research`** — the 5/min, research-grade model. Always set `model` explicitly; an unset model turns a 500-row batch into a ~100-minute crawl at premium depth.
- **`searchContextSize` is a cost lever, not just quality** (an input, alongside `model`/`prompt`) — it moves the token rate up to ~1.8× (defaults to `medium`). Start `low` for one-fact lookups.
- **Structured output enums differ from openAi/gemini:** `output.responseFormat` is `text` (default) | `jsonSchema` (camelCase, requires sibling `jsonSchema`) | `regex` (requires sibling `regex`). There is no `json_object` mode here.
- **Temperature is 0–2 (exclusive), default 0** — already deterministic by default, unlike openAi/gemini (default 1).
- No `withWebSearch` flag — search is always on; that's the product.
## Cost traps
- **500-row batch math** (≈1 package per short call): `sonar` low ≈ **150 credits**, medium ≈ **200**; `sonar-pro` high ≈ **500**; `sonar-deep-research` ≈ **250 credits AND ~100 minutes** at 5/min. Compare: the same 500 rows on `openAi` `gpt-5-nano` ≈ 3 credits — only pay perplexity rates for rows that genuinely need the web.
- **Don't use reasoning/pro tiers for lookups.** "What does this company do?" is a `sonar`-low question; `sonar-reasoning-pro` high (0.9) triples the cost for no better citation.
- **Research the account, not the contact list.** 20 accounts × 25 contacts = research 20 times, not 500 — dedupe to one perplexity call per company, then fan the answer out to contacts.
## Position in the LLM stack
- **The web-grounded rung** of [`../references/stage-action-map.md`](../references/stage-action-map.md) LLM section; escalation path for facts: model data → `serper.search` (0.05) + cheap extract → `perplexity.instruct` when synthesis/citations are needed.
- Gate batch research spend through [`../references/cost-discipline.md`](../references/cost-discipline.md) — pilot ~10 rows first.
## Recurring use
Web-grounded answers decay — **re-research is legitimate here**, but only on rows a fresh signal touched, never the whole segment on a timer.
- **Recurring shape:** a `sonar`-low `instruct` node inside a signal-triggered play (funding, job change — see [`../recipes/funding-watch.md`](../recipes/funding-watch.md)), with `searchRecencyFilter` matched to the trigger cadence (weekly funding watch → `week`) so each answer covers only the new window. Cadence defaults: [`../recipes/save-as-play.md`](../recipes/save-as-play.md).
- **In-play gate:** gate on timestamps, not empty-only — run where the signal's detected-at is newer than the row's last-researched column; stale answers *should* refresh, but only when a signal fires.
- **Cost compounds:** 0.3–1 per row per cycle — keep the "research the account, not the contact list" dedupe from Cost traps: one call per company per signal, fanned out to contacts.
## Action shape
`{"kind":"connector","integrationSlug":"perplexity","actionSlug":"instruct"}`, with `model`, `prompt`, and `advancedSettings` per record in `--records` / `--data`. **No `connectorUuid` in `config`** — and no model settings there either; inside a workflow **node** those same fields are the node's `config`. Costs above are the Cargo-credits rules; a workspace can instead attach its own Perplexity key (connector config takes a single required `apiKey`) and bill the provider directly.
## Pairs with
- [`../references/prompt-library/index.md`](../references/prompt-library/index.md) — company-research prompts; run the research here, the structured extraction on a cheap offline model.
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) / [`../recipes/funding-watch.md`](../recipes/funding-watch.md) — fresh-facts inputs to the personalization stage.
provider-playbooks/piloterr.md
---
provider: piloterr
category: sourcing (bulk company lists + G2 product scrape)
last-reviewed: 2026-07-09
---
# piloterr (Piloterr)
Ultra-cheap sourcing surfaces, both priced at **0.01**: an **action** (`getG2ProductInfo`) that scrapes one G2 product page (reviews, ratings, pricing plans, specs), and an **extractor** (`fetchCompanies`, 0.01 **per item**) that syncs filtered company lists into a model — 10,000 companies for 100 credits, the cheapest at-scale company pull in the catalog. It complements rather than replaces `salesNavigator.searchAccounts` (0.05): salesNavigator is an on-demand action inside a workflow; piloterr's company pull is a scheduled model sync.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `getG2ProductInfo` | 0.01 | `query` (G2 product URL **or** slug, e.g. `postman`) | G2 product info — reviews, ratings, pricing plans, specs. 100× cheaper than `g2.enrichProduct` (1). |
## Extractor (syncs into a model, not an action)
| Extractor | Cost | Inputs | Use for |
|---|---|---|---|
| `fetchCompanies` | 0.01 per item | `filter` (cargo filter shape), `sort`, `limit` (default 200, max 10,000) | Bulk LinkedIn-flavored company records: `name`, `domain`, `industry`, `staff_count`/`staff_range`, `linkedin_url`, HQ fields, `founded`, `specialities_list`, … |
Wire it with `cargo-ai storage model create … --extractor-slug fetchCompanies` (see `cargo-storage`). Fetch mode is non-incremental with a **14-day minimum interval**. Synced rows unify into the account model on `domain` / `website` / `linkedin_url` / LinkedIn ids — re-fetches dedupe instead of duplicating.
## What it's for
- ✅ **Bulk TAM seeding on a budget** — a 10,000-company filtered pull costs 100 credits vs 500 with `salesNavigator.searchAccounts` (0.05/record).
- ✅ **G2 product scrapes at volume** — competitive review sweeps at 0.01/product; includes pricing plans, which `g2.enrichProduct` doesn't list in its description.
- ❌ **Interactive sourcing inside a play** — `fetchCompanies` is an extractor on a sync schedule; for search-as-a-node, use `salesNavigator` / `oceanio`.
- ❌ **Filters beyond firmographics** — the property set is LinkedIn-page-shaped (industry, staff, HQ, founded); no funding, tech-stack, or intent filters. That's `theirStack` (0.5) / `peopleDataLabs` (3).
## Patterns
### Pattern A — Cheap G2 product info
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"piloterr","actionSlug":"getG2ProductInfo"}' \
--data '{"query":"postman"}' \
--wait-until-finished
```
### Pattern B — Bulk company model via the extractor
Configure the model's extractor with the cargo filter shape (note the `conjonction` spelling):
```json
{
"filter": {
"conjonction": "and",
"groups": [{
"conjonction": "or",
"conditions": [
{"propertyName": "industry", "operator": "is", "values": ["Computer Software"]},
{"propertyName": "staff_range", "operator": "is", "values": ["51-200"]}
]
}]
},
"sort": [{"propertyName": "staff_count", "kind": "desc"}],
"limit": 5000
}
```
`industry`, `staff_range`, and `headquarter_country` values are provider enums — resolve them via the `listObjectPropertyEnum` autocomplete on `connection integration get piloterr` before building the filter.
## Common pitfalls
- **`fetchCompanies` is an extractor.** `action execute` won't run it — it lives on a model (`--extractor-slug`), syncs at most every 14 days, and bills 0.01 × rows returned. `limit` is the budget cap.
- **`conjonction`, not `conjunction`.** The filter shape is cargo's standard `{conjonction, groups, conditions}` — the misspelling-that-isn't breaks silently (see the router's [`gotchas.md`](../../cargo/references/gotchas.md)).
- **Guessed enum values match nothing.** Filter enums come from the autocomplete; free-text `industry` strings silently return zero rows.
- **Rate limit: 30 calls/minute** (spread) — fine for the extractor, slow for fanning `getG2ProductInfo` across thousands of rows in one burst.
## Position in the waterfall
**SOURCE stage, bulk/scheduled rung.** For recurring TAM refresh: **piloterr extractor (0.01/item)** → `salesNavigator.searchAccounts` (0.05, interactive) → `oceanio.searchCompanies` (1, lookalike/technographic) → `peopleDataLabs` (3, heavyweight filters). See [`../references/stage-action-map.md`](../references/stage-action-map.md).
## Recurring use
Recurring is **built in** — the `fetchCompanies` extractor *is* the scheduled pull, a model sync rather than a play node.
- **Scheduled pull:** the extractor re-fetches on its own schedule — 14-day minimum interval, non-incremental, billing 0.01 × rows returned per fetch (~100 credits per 10,000-row refresh) — and re-fetches dedupe into the account model on `domain`/`linkedin_url` instead of duplicating. The 14-day floor overrides the weekly company-search default in [`../recipes/save-as-play.md`](../recipes/save-as-play.md).
- **`getG2ProductInfo` on a schedule:** legitimate when the tracked product list changes — gate on the G2 output field being empty (or a stale scraped-at timestamp) so unchanged products aren't re-scraped, and mind the 30 calls/min limit.
- **Time-sensitivity:** firmographics move slowly — a cadence tighter than the 14-day floor buys nothing.
## Action shape
`{"kind":"connector","integrationSlug":"piloterr","actionSlug":"getG2ProductInfo"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/build-tam.md`](../recipes/build-tam.md) — the cheapest seed for a 1,000–10,000-company TAM model.
- [`../recipes/tech-intent.md`](../recipes/tech-intent.md) — G2 review data as qualitative color on intent-matched accounts.
provider-playbooks/prospeo.md
---
provider: prospeo
category: contact (email + phone)
last-reviewed: 2026-07-09
---
# prospeo
Contact-lookup specialist whose standout is **the cheapest landline/DID phone finder in the priority stack** — `findPhone` (3) sits ahead of `FullEnrich.findPhone` (6) and `waterfall.findPhone` (7), behind the mobile-only `aiArk.findMobilePhone` (0.5), which is the first rung whenever a LinkedIn URL is in hand. Its `findEmail` (0.5) is a mid-tier alternative to the `FullEnrich.findEmail` (1) default; prefer it only when budget-constrained or as a waterfall rung ([`../references/alternatives.md`](../references/alternatives.md)). Also carries cheap LinkedIn-profile and company enrichment at 0.5.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `findEmail` | 0.5 | `firstName, lastName, fullName, companyDomain` (**`companyDomain` required**) | Mid-tier email finder. |
| `enrichLinkedin` | 0.5 | `url` | Cheapest LinkedIn URL → person details in the enrich stage ([`../references/stage-action-map.md`](../references/stage-action-map.md)). |
| `enrichCompany` | 0.5 | `companyName, companyWebsite, companyLinkedinUrl` | B2B firmographics; prefer website or LinkedIn URL over name. |
| `findPhone` | 3 | `url` (LinkedIn URL, required) | **Default first stop of the phone chain.** |
## What it's for
- ✅ **Phone chain, rung 1** — prospeo (3) → FullEnrich (6) → waterfall (7); escalate only on misses ([`../references/waterfall-strategy.md`](../references/waterfall-strategy.md)).
- ✅ **Budget email finding** — 0.5 vs FullEnrich's 1, when a lower hit rate is acceptable or as a chain rung.
- ✅ **Cheap LinkedIn-URL enrichment** — `enrichLinkedin` when you already hold a validated profile URL and need title/role details.
## Patterns
### Pattern A — Phone lookup on qualified leads only
```bash
# Phone is the expensive lever — qualified rows only, never the full list
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"prospeo","actionSlug":"findPhone"}' \
--records '[
{"url":"https://linkedin.com/in/alicesmith"},
{"url":"https://linkedin.com/in/bobjones"}
]' \
--wait-until-finished
```
The only accepted identifier is a LinkedIn URL (`url`). No URL → no lookup; resolve one first via the [`../recipes/linkedin-url-lookup.md`](../recipes/linkedin-url-lookup.md) recipe.
### Pattern B — Budget email finding
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"prospeo","actionSlug":"findEmail"}' \
--records '[
{"firstName":"Alice","lastName":"Smith","companyDomain":"acme.com"},
{"fullName":"Bob Jones","companyDomain":"globex.com"}
]' \
--wait-until-finished
```
`companyDomain` is **required** — a domain, not a company name. Rows with only a company name need domain resolution first (e.g. `prospeo.enrichCompany`, or `aiArk.searchCompanies` keyed on the name).
## Common pitfalls
- **`findPhone` at 3 credits is still the ~10×-email lever.** Run it on qualified leads after explicit user request only ([`../references/cost-discipline.md`](../references/cost-discipline.md)); escalate misses to `FullEnrich.findPhone`, don't re-run.
- **`findEmail` without `companyDomain` fails** — it's the one required field. Name-only or name+company-name records don't run.
- **`enrichCompany` with name only is a weak match.** The schema's own guidance: prefer `companyWebsite` or `companyLinkedinUrl` when possible.
## Anti-patterns
- **snake_case field names.** prospeo inputs are **camelCase**: `firstName`, `lastName`, `fullName`, `companyDomain`. Do NOT reuse waterfall's `first_name`/`domain` shape here.
- **Shipping a found email unverified.** No finder's output skips verification: free pre-cull with `validate-emails.ts` ([`../references/contact-accuracy.md`](../references/contact-accuracy.md)), then `waterfall.verifyEmail` (0.1) on the survivors.
- **Using `findEmail` as the default over FullEnrich.** The spine default is `FullEnrich.findEmail` (better hit rate); prospeo is the budget alternative, not the starting point.
## Position in the waterfall
- `findPhone` — **rung 1** of the phone chain. CONTACT stage, gated to qualified leads.
- `findEmail` — mid-tier CONTACT alternative alongside `hunter`/`findyMail`/`leadMagic` (all 0.5); every hit flows to the VERIFY stage (`waterfall.verifyEmail`, 0.1).
- `enrichLinkedin` / `enrichCompany` — enrich-stage fillers when the identifier you hold matches their required input.
## Recurring use
No scheduled fit — per-record contact lookup only; recurring use means **paid nodes inside a play**, each behind an empty-field gate.
- **In-play gate:** `findEmail` only where the email column is still empty; `findPhone` only where phone is empty AND the row is qualified (the cost-discipline gate applies per-run, forever, in a play). A miss escalates to the next chain rung — stamp an attempted-at column so it never retries prospeo on the next cycle.
- **Time-sensitivity:** a found-and-verified email or phone is stable until the person moves — re-lookup belongs downstream of a job-change signal ([`../recipes/job-change-monitoring.md`](../recipes/job-change-monitoring.md)), not on a cadence. Play wrapper + cadence table: [`../recipes/save-as-play.md`](../recipes/save-as-play.md).
## Action shape
`{"kind":"connector","integrationSlug":"prospeo","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/proxycurl.md
---
provider: proxycurl
category: enrichment (LinkedIn-derived, deep filters)
last-reviewed: 2026-08-20
---
# proxycurl (ProxyCurl)
Two actions over LinkedIn-derived data: `enrich` (**1 credit fixed**) and `search` (**1 credit per item returned**). Both are expensive for what they do — `salesNavigator.searchLeads` sources at 0.02/record and `aiArk.enrichPerson` returns a profile *plus a verified email* at 0.1.
So the whole playbook is one question: **does this need a filter no cheaper rung can express?** Usually it doesn't — `aiArk.searchPeople` (0.05/record) already filters on education, degree, school, skills, tenure in role, total experience, and the employer's funding. Check there first; the list of things only proxycurl can do is short.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `enrich` | 1 fixed | `objectType` (`person` \| `company` \| `role` \| `job`) + `filters[]`, or `url` for `job` | Resolve one entity from attribute filters rather than a URL. |
| `search` | **1 / item** | `objectType` (`person` \| `company`) + `filters[]` + `limit` | Search on LinkedIn attributes the other providers don't filter on. |
`enrich` is filter-based, not URL-based — that's what separates it from `linkedin.enrichProfile` (0.25) and `aiArk.enrichPerson` (0.1), which both need the profile URL you may not have. Its `objectType: "role"` variant (`role` + `company_name`) answers "who holds this title at this company" without a URL at all.
Rate limited to 300 calls per minute.
## What only this provider filters on
`search --object-type person` takes a long filter list, and **most of it is available cheaper elsewhere**. What is genuinely unique here:
- **Free-text profile matching** — `headline`, `summary`, `current_job_description`, `past_job_description`. Nothing else in the catalog searches the prose of a profile.
- **LinkedIn-native affinities** — `linkedin_groups`, `interests`, `languages`.
- **Exact list membership** — `public_identifier_in_list` / `public_identifier_not_in_list`. The exclusion side is the one that pays for itself: it stops you buying people you already own.
- **Absolute date bounds on role start** — `current_role_before` / `current_role_after` ("started this role after 2026-05-01"), where `aiArk` expresses tenure as a *duration* (`min/max_current_job_years`). Use proxycurl when the boundary is a date, aiArk when it is a length.
- **Seat resolution without a URL** — `enrich --object-type role` (`role` + `company_name`) answers "who holds this title here" from nothing but the seat.
Everything else has a cheaper home. **Education** (`education`, `school_id_or`, `degree_or`), **skills**, **tenure length**, **total experience**, and the **employer's funding** are all `aiArk.searchPeople` filters at **0.05/record** — 20x cheaper. Title, company, seniority, geography and headcount are `salesNavigator.searchLeads` at **0.02** — 50x cheaper.
## Cost math before you run anything
`search` bills **per item returned**, and `limit` is the bill:
| `limit` | Cost |
|---|---|
| 10 | 10 |
| 25 | 25 |
| 100 | 100 |
100 records here is 100 credits; the same 100 from `salesNavigator.searchLeads` is 2. Set `limit` to what you will act on, quote it to the user before running, and treat any `search` over ~25 as needing explicit approval — [`../references/cost-discipline.md`](../references/cost-discipline.md) §1.
## Patterns
### Pattern A — Match on what a profile actually says
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"proxycurl","actionSlug":"search","config":{}}' \
--data '{
"objectType": "person",
"filters": [
{"name": "current_role_title", "value": "VP Engineering"},
{"name": "current_job_description", "value": "platform migration"},
{"name": "public_identifier_not_in_list", "values": ["janedoe", "johnsmith"]}
],
"limit": 10
}' \
--wait-until-finished
```
10 credits for 10 records. The job-description match is the part nothing else does; `public_identifier_not_in_list` keeps you from re-buying people already in the model. For an **alumni** query, go to `aiArk.searchPeople` (`school_id_or` / `degree_or`) at 0.05 instead — 20x cheaper for the same cut.
### Pattern B — New-in-role, tenure-bounded
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"proxycurl","actionSlug":"search","config":{}}' \
--data '{
"objectType": "person",
"filters": [
{"name": "current_role_title", "value": "Head of RevOps"},
{"name": "current_role_after", "value": "2026-05-01"},
{"name": "current_company_employee_count_min", "value": "200"}
],
"limit": 15
}' \
--wait-until-finished
```
`current_role_after` takes an **absolute date**, which is what makes this one worth paying for: `aiArk.searchPeople` can say "under 1 year in role" (`max_current_job_years`) but not "since the quarter started". For *monitoring* job changes on people you already track, `waterfall.detectJobChange` (3/contact) is the right instrument — see [`../recipes/job-change-monitoring.md`](../recipes/job-change-monitoring.md). This is the net-new side.
### Pattern C — Resolve a role without a URL
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"proxycurl","actionSlug":"enrich","config":{}}' \
--data '{
"objectType": "role",
"filters": [
{"name": "role", "value": "Chief Financial Officer"},
{"name": "company_name", "value": "Acme Corp"}
]
}' \
--wait-until-finished
```
1 credit, flat. Compare against `linkedin.findProfileUrl` (0.25) when you already have a person's name — that is 4x cheaper and the better first rung. `enrich --object-type role` is for when you have the *seat*, not the person.
## Common pitfalls
- **`value` where the filter wants `autocompleteValue`.** Location, country, industry, and company-type filters take `autocompleteValue`, not `value` — the schema is an `anyOf` and the wrong key is a validation failure, not a silent miss. Resolve the accepted values first:
```bash
cargo-ai connection connector autocomplete \
--connector-uuid <uuid> --slug listFilterValues \
--params '{"filterName":"current_company_industry"}' --value "software"
```
- **Leaving `limit` unset or large.** Per-item billing turns an exploratory query into a three-figure charge. Always set it.
- **Using `search` to count.** It bills for what it returns. `aiArk.countPeople` / `aiArk.countCompanies` are **free** — size the audience there first, then decide whether to pay for records.
- **Paying here for a filter aiArk has.** Education, degree, school, skills, tenure length, total experience, employer funding: all `aiArk.searchPeople` at 0.05. Check its filter list before opening this one.
- **Not deduping.** `public_identifier_not_in_list` exists so you don't pay 1 credit each for people already in the model. Pass the identifiers you hold.
## Anti-patterns
- **proxycurl as the default sourcing rung.** 1/record against `salesNavigator.searchLeads` at 0.02 and `icypeas.findPeople` at 0.02/100. A 500-lead pull is 500 credits here and 10 there.
- **Per-row `enrich` across a segment.** 1 credit a row where `aiArk.enrichPerson` is 0.1 and also returns a verified email. Only defensible where the row has no LinkedIn URL and no email — and even then, price `waterfall.enrichContact` (2, multi-source) against it.
- **Emailing straight off a `search`.** These are attribute matches, not a qualified audience. The basis/suppression/relevance checks in [`../references/acceptable-use.md`](../references/acceptable-use.md) §3 still gate the outreach step — an alumni filter is not a lawful basis.
## Position in the waterfall
- **People search:** last rung on price — behind `salesNavigator.searchLeads` (0.02), `icypeas.findPeople` (0.02/100), `aiArk.searchPeople` (0.05), `contactOut.search` (1–3), and level with `apolloio.searchPeople` (1 enriched). It moves to **first** only for profile free-text, LinkedIn groups/interests/languages, an absolute role-start date, or list-membership exclusion.
- **Person enrich:** behind `aiArk.enrichPerson` (0.1), `linkedin.enrichProfile` (0.25), `waterfall.enrichContact` (2 multi-source); ahead of `peopleDataLabs.enrichPerson` (3) and `leadMagic.enrichProfile` (3) on price. Its edge is filter-based resolution when there is no URL.
## Action shape
`{"kind":"connector","integrationSlug":"proxycurl","actionSlug":"search","config":{}}`. **No `connectorUuid` in `config`.** `objectType`, `filters`, and `limit` go in `--data`.
Needs a ProxyCurl API key on the connector. Caching is supported — a repeated identical query inside the window doesn't re-bill.
## Pairs with
- [`../recipes/source-planning.md`](../recipes/source-planning.md) — the free `aiArk.count*` probe that tells you whether a 1/record source is worth opening.
- [`../recipes/account-expansion.md`](../recipes/account-expansion.md) — `public_identifier_not_in_list` against the Contacts model is the dedupe this recipe asks for.
- [`../recipes/job-change-monitoring.md`](../recipes/job-change-monitoring.md) — the cheaper instrument when the people are already yours.
## Recurring use
- **Per-item billing on a schedule compounds.** A weekly `search` at `limit: 25` is 25 credits a week, ~1,300 a year — and mostly the same people, re-billed.
- **Make each run pay only for what is new.** Move `current_role_after` forward with the schedule, and pass `public_identifier_not_in_list` with everyone already in the model. Without both, a recurring search is a standing order for duplicates.
- **In-play gate:** cap `limit` in the node itself, never from an upstream variable that can grow.
provider-playbooks/reverseContact.md
---
provider: reverseContact
category: contact
last-reviewed: 2026-07-09
---
# reverseContact (Reverse Contact)
LinkedIn-anchored reverse lookups. **Only one of its four actions is credits-based**: `enrichCompanyFromLinkedin` (1) — a niche ENRICH rung for when your input is precisely a **LinkedIn company URL**. The other three (domain → company, LinkedIn URL → profile, email → profile) run **only on your own Reverse Contact API key** connector. Don't route generic company enrichment here: at 1 credit it matches `waterfall.enrichCompany` (1, priority) while `linkedin.enrichCompany` (0.25) covers most LinkedIn-anchored needs at a quarter of the price (see [`../references/stage-action-map.md`](../references/stage-action-map.md), Enrich company — "Niche: LinkedIn URL → company").
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `enrichCompanyFromLinkedin` | 1 | `linkedinUrl` (required) | LinkedIn company URL → company record, when the priority stack missed. |
## Own-API-key actions (no credits — require your Reverse Contact account connector)
| Action | What it does |
|---|---|
| `enrichCompanyFromDomain` | `domain` (required) → company record. |
| `enrichProfileFromLinkedin` | `linkedinUrl` (required) → person profile. |
| `enrichProfileFromEmail` | `email` (required; `firstName`, `lastName`, `companyDomain`, `companyName` optional hints) → person profile. Its signature **reverse-email lookup**. |
These consume your Reverse Contact plan's quota, not cargo credits — treat them as a surface for users who already subscribe, especially `enrichProfileFromEmail` when you hold an email and need the person behind it.
## What it's for
- ✅ **LinkedIn company URL → firmographics, as a fallback** — when you sourced company LinkedIn URLs (e.g. from `salesNavigator`) and `waterfall.enrichCompany` / `linkedin.enrichCompany` missed.
- ✅ **Reverse-email person lookup on an existing subscription** — own-key `enrichProfileFromEmail` for inbound/signup emails.
- ❌ **Default company enrich** — the chain is `aiArk.enrichCompany` (0.01) → `companyEnrich.enrichByDomain` (0.25) → `waterfall.enrichCompany` (1) → `peopleDataLabs.enrichCompany` (3) (see [`../references/alternatives.md`](../references/alternatives.md)).
- ❌ **LinkedIn-anchored enrich on a budget** — `linkedin.enrichCompany` (0.25) / `linkedin.enrichCompanyFromDomain` (0.5) first.
- ❌ **Credits-based person enrichment** — its profile actions aren't credits-compatible; use `waterfall.enrichContact` / `FullEnrich` instead.
## Patterns
### Pattern A — LinkedIn company URL → company (fallback rung)
```bash
# Only on rows the priority enrich chain missed, where a LinkedIn company URL exists
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"reverseContact","actionSlug":"enrichCompanyFromLinkedin"}' \
--records '[{"linkedinUrl":"https://www.linkedin.com/company/acme"}]' \
--wait-until-finished
```
The catalog dump documents no output schema for these actions — inspect the first run's output (resolve the output schema via `cargo-orchestration`, or read the run's `runContext`) before filtering on field names.
## Cost traps
- **1 credit for a niche lookup.** If the row also has a domain, the cheaper domain-first chain (`aiArk.enrichCompany` 0.01, `companyEnrich.enrichByDomain` 0.25, `linkedin.enrichCompanyFromDomain` 0.5) should already have run — this action is for URL-only rows.
- **Own-key actions on the wrong assumption.** `enrichProfileFromEmail` and friends fail without a Reverse Contact API key connector; there is no credits fallback for them.
## Anti-patterns
- **Using it to "find" a company LinkedIn URL.** It consumes a LinkedIn URL, it doesn't discover one — URL discovery is a sourcing problem (`salesNavigator`, or [`../recipes/linkedin-url-lookup.md`](../recipes/linkedin-url-lookup.md) for people).
- **Skipping verification on any downstream email.** Emails surfaced via profile enrichment go through the free pre-cull ([`../references/contact-accuracy.md`](../references/contact-accuracy.md)) then `waterfall.verifyEmail` (0.1), like every other source (see [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md)).
## Position in the waterfall
**ENRICH stage, niche fallback rung.** Default company chain: `aiArk.enrichCompany` (0.01) → `companyEnrich.enrichByDomain` (0.25) → `waterfall.enrichCompany` (1) → `peopleDataLabs.enrichCompany` (3). `reverseContact.enrichCompanyFromLinkedin` (1) slots in only when the input is a LinkedIn company URL the stack couldn't resolve.
## Recurring use
No scheduled fit — a niche per-record fallback rung, never a re-pull.
- **In-play gate:** `enrichCompanyFromLinkedin` runs only where the target firmographic fields are still empty, a LinkedIn company URL exists, and the cheaper chain already missed — firmographics are stable, so re-running unchanged rows re-bills 1 credit for identical data. Stamp an attempted-at column so misses don't retry each cycle.
- **Own-key actions in plays:** `enrichProfileFromEmail` and friends draw down the workspace's Reverse Contact plan quota on every cycle — apply the same empty-field gating even though no cargo credits move. Play wrapper + cadence defaults: [`../recipes/save-as-play.md`](../recipes/save-as-play.md).
## Action shape
`{"kind":"connector","integrationSlug":"reverseContact","actionSlug":"enrichCompanyFromLinkedin"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/build-tam.md`](../recipes/build-tam.md) — ENRICH stage backfill when TAM rows carry LinkedIn company URLs.
- [`../recipes/prospecting.md`](../recipes/prospecting.md) — the enrich rung of the find → enrich → verify → sync spine.
provider-playbooks/rocketreach.md
---
provider: rocketreach
category: enrichment
last-reviewed: 2026-07-09
---
# rocketreach (RocketReach)
Single-action person lookup: `lookupPerson` (1 credit) resolves a person **and their company** from flexible identifiers — name + employer, LinkedIn URL, email, or a US healthcare **NPI number** — returning contact channels (`emails`, `phones`, `recommended_email`, `current_work_email`), profile data, and `job_history` in one call. It's a 1-credit ENRICH fallback beside the priority stack's `apolloio.enrichPerson` (1) — and outside the stack, so reach for it only when Apollo has also missed; `aiArk` → `waterfall` still leads the default chain ([`../references/alternatives.md`](../references/alternatives.md)). The NPI input is its genuinely distinctive angle: healthcare-provider lookups the generalist stack doesn't key on.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `lookupPerson` | 1 | `name, currrentEmployer, title, linkedinUrl, email, npiNumber, lookupType` (enum: `standard, premium, premium (feeds disabled), bulk, phone, enrich`) | Person + company lookup from any identifier mix; healthcare lookups via NPI. |
## What it's for
- ✅ **Fallback person enrichment** — 1 credit when a pilot shows RocketReach hits where `aiArk.enrichPerson` (0.1) / `waterfall.enrichContact` (2) miss for the niche.
- ✅ **Healthcare-provider lookup** — `npiNumber` input plus `npi_data` in the output; no other catalog action takes an NPI.
- ✅ **One-call person + company context** — output carries `current_employer`, `current_employer_domain`, `current_employer_linkedin_url`, and `job_history`, so a hit can also seed company enrichment and job-change checks.
- ❌ **Sourcing or search** — lookup only; there is no people-search action here. Credits-based sourcing stays on `salesNavigator`.
- ❌ **First-stop email finding** — the find-email chain has cheaper dedicated rungs starting at `icypeas` (0.1); see [`../references/stage-action-map.md`](../references/stage-action-map.md).
## Patterns
### Pattern A — Fallback lookup from name + employer
```bash
# Only on rows the priority stack missed
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"rocketreach","actionSlug":"lookupPerson"}' \
--records '[
{"name":"Alice Smith","currrentEmployer":"Acme","title":"CTO"},
{"linkedinUrl":"https://linkedin.com/in/bobjones"}
]' \
--wait-until-finished
```
### Pattern B — Healthcare lookup by NPI
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"rocketreach","actionSlug":"lookupPerson"}' \
--data '{"npiNumber":1234567890}' \
--wait-until-finished
```
`npiNumber` is a **number**, not a string.
## Common pitfalls
- **`currrentEmployer` has three r's.** That misspelling is the literal schema key — a correctly-spelled `currentEmployer` is silently ignored and the lookup runs on name alone.
- **No field is required** — the schema accepts any subset, but an identifier-free call can't match; pass at least a LinkedIn URL, an email, an NPI, or name + employer.
- **`lookupType` doesn't change the billed cost** — the credits schedule is a fixed 1 regardless of the enum value. Treat the phone-bearing output as gated anyway: the phone-cost guard in [`../references/cost-discipline.md`](../references/cost-discipline.md) is about intent, not just price.
- **Rate limit 250/minute** (spread) — comfortable for fallback residues, slow for full-list enrichment (another reason the stack leads).
## Anti-patterns
- **Using `lookupPerson` as a bulk email finder.** Found emails still flow to VERIFY (`waterfall.verifyEmail`, 0.1), and the dedicated chain is cheaper and purpose-built; use RocketReach for the person/company bundle or the NPI niche.
- **Trusting `recommended_email` without verification.** It's a finder recommendation, not a verified address.
## Position in the waterfall
- `lookupPerson` — **ENRICH (person), 1-credit fallback rung** beside `apolloio.enrichPerson` (1), behind the stack's `aiArk` (0.1) → `waterfall` (2) → `peopleDataLabs` (3) chain; promote it per-batch only when a pilot shows better niche coverage (healthcare especially).
- Emails it surfaces flow to **VERIFY** before activation.
## Recurring use
No scheduled fit — `lookupPerson` is a per-record fallback lookup; there is nothing to poll.
- **In-play gate:** run only where the priority stack already missed and the target contact columns (email, phone) are still empty; stamp an attempted-at column so misses don't retry at 1 credit every re-evaluation. The 250/min rate limit suits this residue-sized gating, not full-list sweeps.
- **Time-sensitivity:** the person + company bundle is stable between job changes — a legitimate re-lookup is signal-triggered ([`../recipes/job-change-monitoring.md`](../recipes/job-change-monitoring.md)), and the `job_history` it returns can feed that very check. Play wrapper + cadence table: [`../recipes/save-as-play.md`](../recipes/save-as-play.md).
## Action shape
`{"kind":"connector","integrationSlug":"rocketreach","actionSlug":"lookupPerson"}`. **No `connectorUuid` in `config`.**
provider-playbooks/salesNavigator.md
---
provider: salesNavigator
category: enrichment (sourcing-leaning)
last-reviewed: 2026-04-27
---
# salesNavigator (Sales Navigator)
LinkedIn-anchored search for accounts and leads. **Cheapest sourcing in the cargo catalog** — `searchLeads` at 0.02 credits/record and `searchAccounts` at 0.05 credits/record. Default for any at-scale list-building.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `searchLeads` | 0.02 | `keywords, company, role, personal, recentUpdates, identityIds, limit` | At-scale lead search by company / title / keywords. **Cheapest at-scale people sourcing in catalog.** |
| `searchAccounts` | 0.05 | `companyHeadcounts, headquarterLocationIds, industryCodes, numOfFollowers, …` | At-scale account search by industry / size / geo. **Cheapest at-scale company sourcing in catalog.** |
| `extractLeadSearch` | 0.02 | `url, identityIds, limit` | Extract leads from a saved Sales Navigator search URL. |
| `extractAccountSearch` | 0.05 | `url, identityIds, limit` | Extract accounts from a saved Sales Navigator search URL. |
| `findCompanyInsights` | 0.25 | `companyId` | Pull insights about a known LinkedIn company. |
| `findCompanyMetrics` | 0.25 | `companyId, parameters` | Pull metrics about a known LinkedIn company. |
| `findEmployeesCount` | 0.25 | `companyId` | Get employee count snapshot. |
| `findEmployeesDistribution` | 0.25 | `companyId` | Get employee role/department distribution. |
| `searchLeadsLegacy` | **6** | (deprecated) | **Avoid.** 300× more expensive than `searchLeads`. Only use if `searchLeads` is missing a filter you need (rarely). |
## What it's for
- **Default sourcing path** for anything LinkedIn-shaped (industry, headcount, role, geo, posted updates).
- **Cheap volume**: build a 5,000-company TAM for ~250 credits.
- **LinkedIn IDs**: returned account/lead IDs slot directly into other LinkedIn-aware actions (`linkedin.enrichCompany`, `theSwarm.searchWarmIntros…`, downstream LinkedIn-anchored find/enrich).
## Common pitfalls
- **Don't use `searchLeadsLegacy`** unless `searchLeads` literally cannot express your filter. The cost difference is enormous.
- **`identityIds` filter** scopes the search to specific LinkedIn member identities. Useful for "find leads currently or recently at company X" — combine with `company` filter.
- **`recentUpdates: true`** narrows to leads who posted recently, useful for warm-outreach signal but reduces volume.
- **Pagination**: results are paginated. `limit` caps a single call; for large pulls, iterate with the cursor returned in the response.
## Anti-patterns
- **String filter values where LinkedIn codes are required.** `industryCodes`, `headquarterLocationIds`, `companyHeadcounts`, and `role.function`/`role.seniority` take LinkedIn's **internal enums/IDs** (`[43]`, `["B","C","D"]`, `[103644278]`), not names like `"fintech"` or `"50-200"`. Passing strings fails or silently mismatches — inspect the autocomplete schema via `connection integration get salesNavigator` first.
- **Pulling the full volume to "see what's there."** Search is billed per **returned** record. Size the pool with `limit: 1` (the response's total match count is free beyond that one row), decide the filter, then pull exactly the approved scope — see [`../references/cost-discipline.md`](../references/cost-discipline.md).
- **`searchLeadsLegacy` as a shortcut** — 300× the cost of `searchLeads` for marginal filter gains.
## Position in the waterfall
**First rung for all sourcing** — nothing in the catalog beats 0.02–0.05/record. Demote for a batch only when the pilot shows its LinkedIn-shaped coverage misses your segment (local SMBs → `serper.searchPlaces`; tech-stack-first → `theirStack`; funding/investor filters → `peopleDataLabs.queryCompanies`).
## Sample payloads
### Account search — 100 fintech companies in US, 50–500 headcount
```json
{
"kind": "connector",
"integrationSlug": "salesNavigator",
"actionSlug": "searchAccounts"
}
```
Per-record `--data`:
```json
{
"companyHeadcounts": ["B", "C", "D"],
"industryCodes": [43],
"headquarterLocationIds": [103644278],
"limit": 100
}
```
(Headcount enums and industry/location IDs are LinkedIn's internal codes — use `connection integration get salesNavigator` to inspect the autocomplete schema.)
### Lead search — CTOs at a known account
```json
{
"company": ["acme-inc"],
"role": {"function": [13], "seniority": [5, 7]},
"limit": 5
}
```
### Extract from a saved search URL
```json
{
"url": "https://www.linkedin.com/sales/search/people?savedSearchId=…",
"limit": 1000
}
```
## Fallback chain
If `salesNavigator.searchAccounts` doesn't have your filter (e.g., you need to filter by investor or funding round → not in salesNavigator), escalate to `peopleDataLabs.queryCompanies` (3 credits, PDL **SQL** query). Never escalate to `searchLeadsLegacy`.
For people search niches salesNavigator misses:
- **Local SMBs**: `serper.searchPlaces` (Google Maps).
- **Tech-stack-driven**: `theirStack.searchCompanies`.
- **Very specific role + industry combos with low LinkedIn coverage**: `peopleDataLabs.searchPeople`.
## Recurring use
- **Scheduled pull:** the weekly saved persona search is the canonical recurring source — re-run `searchLeads` / `extractLeadSearch` (same saved URL) on the weekly persona-search default; new matches accumulate slowly, so tighter cadences mostly return rows you already have. Cadence table: [`../recipes/save-as-play.md`](../recipes/save-as-play.md).
- **Dedup gate:** search bills per **returned** record with no memory of prior pulls — dedup returned lead/account IDs against the model so only net-new rows flow to paid downstream nodes (find-email, enrich, verify).
- **Stable snapshots:** `findEmployeesCount` / `findCompanyInsights` (0.25) drift over quarters, not weeks — refresh them on tracked accounts rarely and deliberately, never inside the weekly search play.
provider-playbooks/serper.md
---
provider: serper
category: research (search)
last-reviewed: 2026-07-09
---
# serper (Serper)
Google search results and Google Places, **0.05 credits fixed per query** (up to 100 records per call). Two jobs: `searchPlaces` is the **default sourcing action for local SMBs / storefronts** — the segment the LinkedIn-shaped priority stack skips — and `search` is Google-results research for personalization and fact-finding.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `searchPlaces` | 0.05 (fixed) | `query`, `country`, `locale`, `limit` (default 10, max 100) | Google Maps-style local business results. Default for SMB / storefront / service-area sourcing. |
| `search` | 0.05 (fixed) | `query`, `country`, `locale`, `limit` (default 10, max 100) | Google search results for research and lookups. |
Billing is **fixed per query, not per record** — a `limit: 100` call costs the same 0.05 as a `limit: 10` call, so raise `limit` and lower the query count, never the reverse.
## What it's for
- ✅ **Local / SMB TAM** — "dentists in Austin", "HVAC contractors in Lyon": `searchPlaces` is the sourcing rung the priority stack lacks (see [`../recipes/build-tam.md`](../recipes/build-tam.md), local-SMB variant, and [`../guides/finding-companies-and-contacts.md`](../guides/finding-companies-and-contacts.md)).
- ✅ **Google lookups mid-pipeline** — recent news, a company's public footprint, resolving an official website before enrichment.
- ✅ **Geo-targeted results** — `country` (autocomplete-backed country list) plus `locale` (Google interface-language codes like `en`, `fr`, `de`) localize results properly.
- ❌ **Standard B2B sourcing** — `salesNavigator.searchLeads` (0.02) / `searchAccounts` (0.05) return structured, LinkedIn-anchored records; Google results need parsing.
- ❌ **Reading a page you already know** — that's `firecrawl.scrape` (0.05/item); serper returns result listings, not page content.
## Patterns
### Pattern A — Local-SMB sourcing
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"serper","actionSlug":"searchPlaces"}' \
--data '{"query":"dentists in Austin, TX","country":"us","limit":100}' \
--wait-until-finished
```
One query = 0.05 credits for up to 100 places. To build a bigger TAM, fan out **queries** (by city, neighborhood, or category) rather than paging one query — each geo variant is its own 0.05 call.
### Pattern B — Research lookup for personalization
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"serper","actionSlug":"search"}' \
--data '{"query":"Acme GmbH funding announcement","country":"de","locale":"de","limit":10}' \
--wait-until-finished
```
Pipe the results into an LLM step (`anthropic.instruct`) to extract the fact you need; serper hands back result listings, not answers.
## Common pitfalls
- **Paying per record in your head.** Cost is per **query**. Ten queries at `limit: 10` cost 10× more than one query at `limit: 100` for the same volume — always max out `limit` before adding queries.
- **Skipping `country`/`locale` for local sourcing.** Without them Google decides the geography for you; SMB lists come back skewed. Set `country` (from the country autocomplete) and put the city in the query.
- **Treating place results as enriched records.** `searchPlaces` output is a raw local listing — dedupe it against the Companies model and enrich (website → `aiArk.enrichCompany`) before it enters a model.
## Anti-patterns
- **serper for LinkedIn-shaped B2B lists.** If the segment is companies/people that salesNavigator covers, serper adds a parsing step and loses structure — it earns its place only where Google is the best index (local SMBs, public-web facts).
- **Fanning out searches without a cap.** Fixed-per-query pricing is cheap until an agent loops one query per record over a 5,000-row segment (250 credits of searches). Batch the distinct queries first; run each once.
## Position in the waterfall
- `searchPlaces` — **first (and effectively only) rung for local-SMB sourcing** (see [`../references/stage-action-map.md`](../references/stage-action-map.md), Sourcing — Local SMBs), with `firecrawl.search` as the web-search fallback.
- `search` — a web-research rung alongside `firecrawl.search`; pick serper when you specifically want Google's ranking/geo behavior, firecrawl when you want to continue into scraping.
## Action shape
`{"kind":"connector","integrationSlug":"serper","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/build-tam.md`](../recipes/build-tam.md) — the local-SMB sourcing variant.
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — quick public-web facts feeding the personalization step.
## Recurring use
- **Signal-triggered, not timer-driven.** Google results are live, so re-running `search` on a row that just fired a signal (funding, job change, site visit) is legitimate re-research; a blanket scheduled re-search of a whole list is the fan-out anti-pattern above wired to a cron.
- **In-play gate:** filter to rows where the research output column is empty or the triggering signal is newer than the last search, so segment re-evaluation never re-bills settled rows.
- **`searchPlaces` re-pulls:** local TAM churns slowly — if scheduled at all, re-run the same fixed query set (0.05 per query either way) and dedupe places against the Companies model before any paid enrichment runs downstream.
provider-playbooks/sillage.md
---
provider: sillage
category: signal (inbound detections)
last-reviewed: 2026-08-15
---
# sillage (Sillage)
Signal detections pushed into a Cargo model, read back with one action. **`searchLeads` costs 0**, which makes this the only free signal rung in the catalog.
The shape is different from every other provider here and that is the thing to understand before using it: sillage does not go and look something up. It **receives** detections into a model you nominate, and `searchLeads` reads what has already landed.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `searchLeads` | **0** | `modelUuid` (required), `companyDomains`, `companyLinkedinHandles`, `companyLinkedinUrls`, `limit` | Read detections already delivered into the nominated model, optionally filtered to accounts you care about. |
`modelUuid` is required and there is no default. If the detections are not being delivered into a model yet, this action has nothing to return and the answer is a setup step, not a retry.
## What it's for
- ✅ **Reading detections against a named account list** — pass `companyDomains` to filter the feed to the accounts in play rather than reading everything that arrived.
- ✅ **A free first check before any paid signal action.** It costs nothing, so on any signal question it runs first by definition.
- ❌ **Going out and finding a signal** — nothing here searches the world. For that, `theirStack.searchJobs` (0.5) is hiring intent, `enrichCrm.getFunding` (1) is funding, `waterfall.detectJobChange` (3) is people moving.
- ❌ **Sourcing** — this reads a feed of accounts already detected, not a market.
## Patterns
### Pattern A — Detections for the accounts in play
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"sillage","actionSlug":"searchLeads"}' \
--data '{
"modelUuid": "<the model receiving detections>",
"companyDomains": ["acme.com", "globex.com"],
"limit": 100
}' \
--wait-until-finished
```
Free, so the only reason to keep `limit` sensible is the size of what comes back into context.
### Pattern B — The free rung in front of a paid one
Run `searchLeads` across the segment first. Rows that already carry a detection do not need a paid signal lookup at all, and the ones that do not are the only rows that should reach `theirStack` or the cargo signal actions.
## Common pitfalls
- **Calling it without `modelUuid`.** Required, no default. Resolve it with `cargo-ai storage model list` ([`../../cargo-storage/SKILL.md`](../../cargo-storage/SKILL.md)) rather than guessing.
- **Expecting it to find something.** An empty result means nothing has been delivered for those accounts, not that no signal exists in the world. Those are different answers and only the second justifies a paid lookup.
- **Reading the feed unfiltered.** Without `companyDomains` this returns everything that arrived, most of which is not in the segment being worked.
## Anti-patterns
- **Skipping it because it is free.** A free action in front of a paid one is a pure saving, and this is the only one in the catalog. A signal recipe that does not check it first is leaving credits on the table.
- **Treating a detection as a lawful basis.** It is a relevance input like any other signal. [`../references/acceptable-use.md`](../references/acceptable-use.md) is unchanged by how the signal arrived.
## Position in the waterfall
**First rung on any signal question, unconditionally**, because it is free. Everything paid (`theirStack.searchJobs` 0.5, `enrichCrm.getFunding` 1, `waterfall.detectJobChange` 3) runs on the residue.
## Action shape
`{"kind":"connector","integrationSlug":"sillage","actionSlug":"searchLeads"}`. **No `connectorUuid` in `config`.** Filters go in `--data`.
## Pairs with
- [`../recipes/account-expansion.md`](../recipes/account-expansion.md) — detections against existing accounts.
- [`../recipes/re-engagement.md`](../recipes/re-engagement.md) — a detection on a dormant account is the cheapest reason to revisit it.
## Recurring use
- **The one provider where a frequent schedule costs nothing.** Re-reading the feed hourly is free, so cadence is a question of how fresh the downstream action needs to be rather than of budget.
- **In-play gate still applies downstream.** The read is free; whatever a detection triggers is not. Gate the paid follow-up on detections newer than the last run, or a nightly read re-fires the same paid action on the same rows.
provider-playbooks/snitcher.md
---
provider: snitcher
category: enrichment
last-reviewed: 2026-07-09
---
# snitcher (Snitcher)
Website-visitor identification (catalog subcategory: `websiteVisit`) — a **signal source**, not an enrichment rung. It de-anonymizes companies visiting your site via a tracking script and a Snitcher account, then exposes them to cargo two ways: one free credits-based action (`searchSessions`, 0) and two **extractors** that auto-sync visitor data into workspace models. It's the only visitor-identification surface in the catalog ([`../references/alternatives.md`](../references/alternatives.md): "Always for visitor ID — free credits-tier"). Requires your own Snitcher `apiKey` connector and their tracking setup on your site.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `searchSessions` | 0 | `workspaceUuid, organisationUuid` (required), `dateFrom, dateTo, url, referrer, limit` | Ad-hoc: pull one identified company's sessions (pages, referrers, dates) — free. |
## Extractors (auto-fetch feeds, not actions)
| Extractor | Cost | Feeds | Use for |
|---|---|---|---|
| `fetchOrganisations` | **3 per item** | Account-unified model (dedupes on domain/website) | The identified visiting companies themselves — name, website, size, industry, first/last seen. |
| `fetchSessions` | 0 per item | Account-event model (keyed to domain) | Session activity per visitor — started/ended, referrer, device, page views. |
Both run incrementally with `autoFetch` at a minimum 30-minute interval once configured.
## What it's for
- ✅ **Visitor-intent signal** — companies browsing your pricing page are the warmest cold segment there is; feed the identified-visitor segment into [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) or [`../recipes/re-engagement.md`](../recipes/re-engagement.md).
- ✅ **Free session context** — `searchSessions` and the `fetchSessions` extractor cost nothing, so page-level context (which URLs, how often, from where) is free personalization fuel.
- ❌ **Company enrichment** — visitor records carry basic firmographics only; run identified domains through the normal ENRICH chain (`aiArk.enrichCompany` 0.01, then `companyEnrich` 0.25) for real coverage.
- ❌ **Workspaces without a Snitcher account** — there's no cargo-managed data here; no tracking script, no signal.
## Patterns
### Pattern A — Sessions for one identified organisation
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"snitcher","actionSlug":"searchSessions"}' \
--data '{"workspaceUuid":"<snitcher-workspace-uuid>","organisationUuid":"<snitcher-organisation-uuid>","dateFrom":"2026-06-01","dateTo":"2026-07-09","limit":50}' \
--wait-until-finished
```
Both UUIDs are **Snitcher's** identifiers (workspace = the tracked site; organisation = the identified visitor company from `fetchOrganisations` data) — not cargo workspace or record UUIDs.
### Pattern B — Visitor signal → activation
1. Extractors sync visiting companies (account model) + sessions (events) into storage.
2. Segment on the signal (e.g. `last_seen` this week + high-value pages in `views`).
3. Enrich + find contacts through the normal chain, verify, activate — the standard signal-to-outreach path.
## Common pitfalls
- **`fetchOrganisations` is 3 credits per identified company, recurring.** With `autoFetch` polling every ≥30 minutes, a high-traffic site quietly compounds spend — the "free provider" reputation only covers sessions. Size the tracked site's traffic before enabling it.
- **`workspaceUuid` naming collision** — the required config field is Snitcher's workspace, resolved from their account (the UI backs it with a workspace picker). Passing a cargo workspace UUID returns nothing.
- **`searchSessions` needs an `organisationUuid`** — it's a per-company drill-down, not a firehose; the firehose is the `fetchSessions` extractor.
- **No caching** — the connector is not caching-compatible; repeated identical calls re-hit the API (rate limit is a generous 6000/min).
## Anti-patterns
- **Treating identified visitors as leads.** A visit identifies a **company**, not a person — contacts still come from the CONTACT stage (sourcing + find-email + verify) on the visiting account.
- **Enabling `fetchOrganisations` "just to see"** on a high-traffic site. Pilot expectations against traffic volume first; every identified company bills 3, every sync.
## Position in the waterfall
- **SIGNAL stage** — visitor identification sits beside job-change, funding, and tech-intent as a trigger source ([`../references/stage-action-map.md`](../references/stage-action-map.md)); identified accounts then enter the normal ENRICH → CONTACT → VERIFY → activation spine.
## Recurring use
- **The extractors are the recurring surface** — `fetchOrganisations` / `fetchSessions` already sync incrementally via `autoFetch` (≥30 min); no cron, and never wrap `searchSessions` in a scheduled tool to simulate a feed. The recurring cost trap is the `fetchOrganisations` pitfall above — re-read it before enabling on a high-traffic site.
- **In-play gate:** trigger plays off the synced segment (e.g. `last_seen` this week), gating paid downstream enrichment on the account's enrichment fields being empty — a returning visitor re-enters the segment but must not re-bill the ENRICH chain.
- **Decay:** visit intent fades in days; a play on the fresh-visit segment beats any scheduled sweep over historical visitors.
## Action shape
`{"kind":"connector","integrationSlug":"snitcher","actionSlug":"searchSessions"}`. **No `connectorUuid` in `config`.**
provider-playbooks/societeInfo.md
---
provider: societeInfo
category: enrichment
last-reviewed: 2026-07-09
---
# societeInfo (Societe Info)
French-market company and contact data, anchored on the official registry: registration numbers, NAF activity codes, juridical forms, conventions collectives, filed financials (`minSales` / `minProfits`). Two actions, both premium at 4 credits — `search` bills **4 per item returned**, `enrich` a fixed 4. Reach for it only when the target is a **French entity** and the generalist stack (`aiArk` → `companyEnrich` → `waterfall`, 0.01–1 for companies) lacks the registry depth you need; for everything else it's 8–16× the going rate ([`../references/stage-action-map.md`](../references/stage-action-map.md)).
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `search` | 4 **per item** | `objectType: "company"` + `searchFields` (name/value pairs: `query, where, nafLevel, juridicalFormLevel, conventionCollectiveCode, minSales/maxSales, minProfits/maxProfits, minStaff/maxStaff, minCreationDate, webTechnos, sort, page` …) + toggles (`withSite, withPhone, withLinkedin, withEstablishments` …); **or** `objectType: "contact"` + `registrationNumber` (required) + `searchFields` (`contactLevelCode, contactDomainCode, contactRoleQuery, contactMax`) | Registry-filtered French company sourcing, or contacts at one registered company. |
| `enrich` | 4 | `objectType: "company"` or `"contact"` + `enrichFields` (name/value pairs: `name, domainName, email, firstName/lastName/fullName, linkedinUrl, registrationNumber, street/postalCode/city, minMatchScore` …; contact mode adds `withEmail, withLinkedin` toggles) | Resolve one French company/contact to its registry record from whatever identifier you hold. |
## What it's for
- ✅ **French TAM with registry filters** — company `search` by NAF code, juridical form, filed revenue/profit/staff ranges: criteria no generalist provider filters on.
- ✅ **Registry-grade company resolution** — `enrich` from a domain, name + address, or LinkedIn URL to the official record (registration number and legal identity).
- ✅ **Contacts at a registered company** — contact `search` keyed on the `registrationNumber` you got from a company search/enrich, filtered by role/level.
- ❌ **Non-French targets** — it's a France-scoped registry source; the stack covers everything else far cheaper.
- ❌ **Cheap firmographics on French companies** — if you don't need registry fields, `aiArk.enrichCompany` (0.01) or `companyEnrich.enrichByDomain` (0.25) suffices.
## Patterns
### Pattern A — Registry-filtered company search (cost-capped)
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"societeInfo","actionSlug":"search"}' \
--data '{"objectType":"company","limit":10,"withSite":true,"searchFields":[{"name":"query","value":"logiciel"},{"name":"where","value":"Paris"},{"name":"minStaff","value":50}]}' \
--wait-until-finished
```
**Set `limit`** — at 4 credits per item returned, an uncapped search is the cost trap here (25 results = 100 credits).
### Pattern B — Resolve a domain to the registry record
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"societeInfo","actionSlug":"enrich"}' \
--data '{"objectType":"company","enrichFields":[{"name":"domainName","value":"acme.fr"},{"name":"minMatchScore","value":0.8}]}' \
--wait-until-finished
```
## Common pitfalls
- **`search` is unit-priced, `enrich` is fixed** — same 4-credit sticker, very different bills. Cap search results; a "quick look" that returns a page of companies costs a page × 4.
- **Name/value pair arrays, not flat keys.** Filters go in `searchFields` / `enrichFields` as `[{"name":"...","value":...}]` objects — a flat `{"query":"..."}` config is silently ignored.
- **Contact mode requires `registrationNumber`** — the French company registration id, not a domain or name. Get it from a company `search`/`enrich` first; that's a 2-step, 8+-credit sequence.
- **`objectType` switches the whole schema.** Company and contact modes accept different fields; mixing them (e.g. `contactRoleQuery` in company mode) does nothing.
## Anti-patterns
- **Using it as a generic company enricher.** 4 credits buys 16 `companyEnrich.enrichByDomain` calls; societeInfo earns its price only on registry-specific French needs.
- **Piloting on `search` with no `limit`.** Pilot the filters with `limit: 3` before scaling, per [`../references/cost-discipline.md`](../references/cost-discipline.md).
## Position in the waterfall
- `enrich` — **ENRICH (company/contact), French specialist rung**: outside the default chain; promote per-batch for French entities needing registry fields.
- `search` — **SOURCE, French specialist**: registry-filtered sourcing feeding the normal ENRICH → VERIFY path (found contacts' emails still verify via `waterfall.verifyEmail`, 0.1).
## Recurring use
- **No schedule fit** — registry identity (registration number, NAF code, juridical form) is near-immutable; a scheduled re-`search` or re-`enrich` just re-bills 4 credits per unchanged row.
- **Recurring role = in-play enrich node:** run `enrich` only on net-new French rows entering a play, gated on an empty `registrationNumber` column so segment re-evaluation never re-bills resolved rows.
- **Filed financials move yearly at most** — if `minSales`/`minProfits`-derived fields need refreshing, do it as a rare, explicitly-approved capped batch, not a cadence.
## Action shape
`{"kind":"connector","integrationSlug":"societeInfo","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/theirStack.md
---
provider: theirStack
category: enrichment (tech-stack + hiring-intent signals)
last-reviewed: 2026-04-27
---
# theirStack (Their Stack)
Tech-stack and jobs-posted intent signals. **Three credits-based actions, all 0.5 credits each**, covering the "find companies by what they use or what they're hiring for" pattern. Cargo's primary intent-signal provider for technographics-driven outreach.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `searchTechnologies` | 0.5 | `fields, limit` | Find what technologies a set of companies / domains uses. |
| `searchJobs` | 0.5 | `fields, companyFields, limit` | Find currently-posted jobs matching role / location / company filters. **Hiring-intent signal.** |
| `searchCompanies` | 0.5 | `fields, jobFields, techFields, limit` | Find companies by tech stack and/or job-posting filters combined. |
## What it's for
- ✅ **"Everyone hiring for role X"** — `searchJobs` with title and posting-window filters → list of companies actively recruiting that role.
- ✅ **"Companies running tech stack Y"** — `searchTechnologies` with stack filter → list of companies using a specific framework, infra, or SaaS.
- ✅ **Combined intent + tech-stack** — `searchCompanies` with both `jobFields` and `techFields` → companies running stack Y AND hiring for role X.
- ❌ **Generic firmographic search** — for "fintech in US, 50-500 headcount" without intent signals, salesNavigator (0.05) is 10× cheaper. Use theirStack only when the intent signal is the primary filter.
## Patterns
### Pattern A — Hiring-intent sourcing
```bash
# "Find every company hiring a Head of RevOps in the last 30 days"
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theirStack","actionSlug":"searchJobs"}' \
--data '{
"fields": {
"job_titles": ["Head of RevOps", "VP RevOps", "Director of RevOps"],
"posted_at_max_age_days": 30,
"locations": ["United States"]
},
"companyFields": {
"employeeCounts": ["50-200", "200-500"]
},
"limit": 200
}' \
--wait-until-finished
```
Result includes both job postings and the companies that posted them. Dedup on company to get the unique account list.
### Pattern B — Tech-stack-driven sourcing
```bash
# "Find every company using Snowflake AND dbt"
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theirStack","actionSlug":"searchCompanies"}' \
--data '{
"techFields": {
"technologies": ["snowflake", "dbt"]
},
"fields": {
"industries": ["software", "saas"],
"headcountMin": 100
},
"limit": 500
}' \
--wait-until-finished
```
### Pattern C — Combined "hiring AND running stack"
```bash
# "Find every B2B SaaS hiring a data engineer AND already using Snowflake"
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theirStack","actionSlug":"searchCompanies"}' \
--data '{
"techFields": {"technologies": ["snowflake"]},
"jobFields": {"job_titles": ["Data Engineer"], "posted_at_max_age_days": 60},
"fields": {"industries": ["software"]},
"limit": 200
}' \
--wait-until-finished
```
This is the unique strength of theirStack — combined tech-stack AND hiring-intent in one call.
## Common pitfalls
- **`searchTechnologies` returns technology metadata, not company lists.** Use it to discover canonical technology slugs, then plug those into `searchCompanies.techFields.technologies`.
- **Don't over-filter.** Combining 5+ filters can collapse the result set to zero. Start broad (1–2 filters), inspect counts, then narrow.
- **Posting-window matters.** `posted_at_max_age_days` defaults loose; for "currently hiring" intent, use 30 or 60 days.
## Anti-patterns
- **Free-text technology names.** `techFields.technologies` wants theirStack's canonical slugs — discover them via `searchTechnologies` first, then plug the slugs into `searchCompanies`. Guessing (`"Snowflake"` vs `"snowflake"` vs `"snowflake-db"`) silently narrows results.
- **theirStack for plain firmographics.** No intent signal in the filter → salesNavigator is 10× cheaper. theirStack earns its cost only when the job-posting or tech-stack signal IS the filter.
- **Skipping the count check.** Start with 1–2 filters and a small `limit`, read the result count, then narrow — over-filtered queries return zero and the credits for the probe calls add up.
## Position in the waterfall
**Primary intent-signal source** (rung 1 for hiring/tech-stack-driven sourcing); falls to `builtwith` for per-domain tech detail, and to `peopleDataLabs` when the filter needs fields theirStack lacks (funding, investors).
## Action shape
`{"kind":"connector","integrationSlug":"theirStack","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
## When to combine with the enrichment chain
After sourcing with theirStack, **enrich per company** rather than running theirStack on every record. The pattern:
1. `theirStack.searchCompanies` → 500 companies matching intent (250 credits).
2. Dedupe against the Companies model with a `storage query execute` on `domain` (free).
3. `aiArk.enrichCompany` → firmographics on the new ones (5 credits).
4. `builtwith.getDomainSummary` → stack detail (free), escalating to `enrichDomain` (1) only on ambiguous rows.
Total: ~255 credits for 500 fully-enriched companies with intent signal, plus 1 per row that needs the paid stack detail. Cheaper than running peopleDataLabs (3 credits/record × 3 actions = 4,500 credits).
## Recurring use
Hiring intent decays in days — theirStack is **built for the monitor shape, not the one-off pull**.
- **Scheduled pull:** re-run `searchJobs` / `searchCompanies` on the daily hiring-intent default, with `posted_at_max_age_days` matched to the cadence (daily → 1–2 days) so each run bills only postings that appeared since the last one, never the same window twice. Cadence table: [`../recipes/save-as-play.md`](../recipes/save-as-play.md).
- **In-play gate:** dedup discovered companies against the Companies model with a free `storage query execute` on `domain` (step 2 of the pattern above) before any paid enrichment — a company re-posting the same role must not re-enter the enrich chain.
- **Postings expire; stacks don't.** `searchTechnologies`-derived tech-stack fields are slow-moving — re-pull them on demand for a batch, not on the daily intent cadence.
provider-playbooks/theSwarm.md
---
provider: theSwarm
category: network (warm-intro mapping)
last-reviewed: 2026-07-09
---
# theSwarm (The Swarm)
Warm-intro path discovery — **two actions, both 2 credits**, scoring the relationships between *your* company and a target company or person. Unique in the catalog: no priority-stack action maps who-knows-whom ([`../references/alternatives.md`](../references/alternatives.md) lists it as the only warm-intro source). Per the provider, results improve over time as your team maps its network (e.g. via The Swarm's Chrome extension) — the output is only as good as the network your account has indexed.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `searchWarmIntrosToCompany` | 2 | `matchingCompanyDomain`*, `targetCompanyDomain`*, `jobFunctions`, `seniorities` | Warm-intro paths into a target account, filtered to employees with the desired function/seniority. |
| `searchWarmIntrosToPerson` | 2 | `matchingCompanyDomain`*, `targetLinkedinUrl`* | Warm-intro paths to one specific person. |
\* required. `matchingCompanyDomain` is **your own company's domain**; the target fields are the prospect side.
## What it's for
- ✅ **Route-in on high-value accounts** — before cold outreach on a strategic target, check for an intro path; a warm intro beats any sequence.
- ✅ **Champion-led plays** — find who at your company knows the buying committee, filtered by `jobFunctions` / `seniorities` so paths land on the right people.
- ✅ **Person-level check before a big ask** — `searchWarmIntrosToPerson` with the prospect's LinkedIn URL for exec-level outreach.
- ❌ **Pure prospecting** — theSwarm doesn't find new prospects; it maps relationships to ones you already have. Source with `salesNavigator` first.
- ❌ **Whole-TAM sweeps** — 2 credits/lookup across 5,000 accounts is 10,000 credits, mostly returning "no path". Reserve it for scored/tiered accounts.
## Patterns
### Pattern A — Warm paths into a target account
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theSwarm","actionSlug":"searchWarmIntrosToCompany"}' \
--data '{
"matchingCompanyDomain": "yourco.com",
"targetCompanyDomain": "acme.com",
"jobFunctions": ["engineering"],
"seniorities": ["vp", "c_suite"]
}' \
--wait-until-finished
```
`jobFunctions` / `seniorities` values shown are **illustrative** — fetch the accepted values from the `listJobFunctions` and `listSeniorities` autocompletes on `connection integration get theSwarm` first. Both accept a single string or an array.
### Pattern B — Warm path to one person
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theSwarm","actionSlug":"searchWarmIntrosToPerson"}' \
--data '{
"matchingCompanyDomain": "yourco.com",
"targetLinkedinUrl": "https://linkedin.com/in/janedoe"
}' \
--wait-until-finished
```
## Common pitfalls
- **Swapping the domains.** `matchingCompanyDomain` is *your* domain, `targetCompanyDomain` the prospect's. Reversed, the call runs and bills 2 credits — for paths into your own company.
- **Guessed enum values.** `jobFunctions` / `seniorities` are provider-defined strings; resolve them via the autocompletes or the filter silently narrows to nothing.
- **Fixed cost, path or no path.** 2 credits per lookup regardless of result — gate it on account tier, not on the whole list ([`../references/cost-discipline.md`](../references/cost-discipline.md)).
- **Network coverage drives hit rate.** A thin mapped network returns thin results; that's account state, not an API failure — don't burn retries on it.
## Position in the waterfall
**NETWORK / route-in — after SOURCE + SCORE, before outreach.** On tier-1 accounts, run theSwarm between qualification and sequencing: warm path found → intro motion; no path → the normal CONTACT → VERIFY → sequence spine. See [`../references/stage-action-map.md`](../references/stage-action-map.md), Warm intros.
## Action shape
`{"kind":"connector","integrationSlug":"theSwarm","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/account-expansion.md`](../recipes/account-expansion.md) — multi-threading a customer account through colleagues who already know the new buyers.
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — branch send-ready records: warm path → intro request, otherwise → sequencer.
## Recurring use
- **Re-checks can flip "no path" to "path"** — results improve as the team maps its network (see intro), so periodically re-running `searchWarmIntrosToCompany` on strategic accounts that previously returned no path is justified; keep it slow (monthly-ish) and only after the mapped network has actually grown.
- **In-play gate:** 2 credits path-or-no-path — gate on account tier AND an empty warm-path result column; once a path is found, that row never re-bills, and re-checks target only the tier-1 no-path subset.
- **Never a whole-TAM cron** — the whole-TAM-sweep warning above compounds on a schedule (10,000 credits per sweep, mostly "no path", every interval).
provider-playbooks/waterfall.md
---
provider: waterfall
category: enrichment (multi-source contact + signal)
last-reviewed: 2026-04-27
---
# waterfall (Waterfall.io)
Multi-source enrichment with built-in fallback across multiple underlying providers. **Swiss-army-knife of the priority stack** — one provider covering contact enrichment, company enrichment, email verification, phone lookup, prospect search, and the **only credits-based job-change detection action in the catalog**.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `verifyEmail` | **0.1** | `email` | Email verification. **Cheapest tier in the priority stack.** |
| `enrichCompany` | 1 | `linkedin, domain, name` | Fallback for unmatched cargo companies; also useful when LinkedIn is the only known identifier. |
| `enrichContact` | 2 | `linkedin, full_name, first_name, last_name, domain, email, include_extra_fields` | Multi-source contact enrichment. |
| `detectJobChange` | 3 | `professional_email, personal_email, company_domain, company_linkedin, contact_linkedin` | **Unique action.** Returns `MOVED / LEFT / NO_CHANGE / UNKNOWN` plus updated person info. |
| `searchProspects` | 3 | `domain, company_name, linkedin, title_filter, location_country, …` | People search; alternative to salesNavigator when LinkedIn-anchored search isn't enough. |
| `findPhone` | 7 | `linkedin, full_name, first_name, last_name, domain, email, include_extra_fields` | Phone number lookup. Premium pricing — escalate from `prospeo.findPhone` (3) only when needed. |
## What it's for
- ✅ **Email verification at the cheapest tier** (0.1) — default for any verify step in the spine.
- ✅ **Job change signal** — `detectJobChange` is the only credits-based action of its kind in the entire 136-integration catalog. Cargo-unique strength.
- ✅ **Fallback contact / company enrichment** — when aiArk + FullEnrich miss, waterfall is the next stop before the heavyweight peopleDataLabs.
- ✅ **Multi-identifier enrichment** — accepts LinkedIn URL, domain, name, or email. Useful when the input is weakly identified.
## Patterns
### Pattern A — Email verification at scale
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"verifyEmail"}' \
--records '[{"email":"alice@acme.com"},{"email":"bob@globex.com"}, ...]' \
--wait-until-finished
```
At 0.1 cred/email, 1,000 emails = 100 credits. Default verify step in any prospecting pipeline.
### Pattern B — Job change detection (signal segment)
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"detectJobChange"}' \
--records '[
{"professional_email":"alice@acme.com","contact_linkedin":"https://linkedin.com/in/alicesmith"},
{"professional_email":"bob@globex.com","contact_linkedin":"https://linkedin.com/in/bobjones"},
...
]' \
--wait-until-finished
```
Pass any combination of identifiers; multi-identifier inputs improve coverage. Result statuses:
- `MOVED` — person changed company; new role + company returned.
- `LEFT` — person left and current state unknown.
- `NO_CHANGE` — same role / company.
- `UNKNOWN` — no signal available.
Filter to `MOVED` for outbound timing. See [`../recipes/job-change-monitoring.md`](../recipes/job-change-monitoring.md) for the full pattern including segment write-back.
### Pattern C — Fallback contact enrichment
```bash
# Only run on rows where aiArk.enrichPerson returned no data
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"enrichContact"}' \
--records '[
{"linkedin":"https://linkedin.com/in/alice","include_extra_fields":true},
{"first_name":"Bob","last_name":"Jones","domain":"globex.com"}
]' \
--wait-until-finished
```
`include_extra_fields: true` increases response richness but doesn't change cost.
## Common pitfalls
- **Don't use `findPhone` first.** At 7 credits, it's the most expensive phone action in the priority stack. Try `prospeo.findPhone` (3) first; escalate to waterfall only when prospeo misses.
- **`detectJobChange` requires at least one identifier**. Best coverage: LinkedIn URL + company domain. Email-only inputs often return UNKNOWN.
- **`searchProspects` is 3 credits/record** — comparable to peopleDataLabs but with less rich filtering. Default to salesNavigator.searchLeads (0.02) unless you need waterfall's specific filter combinations.
## Anti-patterns
- **camelCase field names.** waterfall inputs are **snake_case**: `first_name`, `last_name`, `full_name`, `company_domain`, `professional_email`, `contact_linkedin`. Do NOT reuse FullEnrich's `firstName`/`lastName`/`domainName` shape here — the call fails or silently ignores the field.
- **Trusting a finder's own "verified" flag.** `verifyEmail` exists precisely because providers grade their own homework — run it on every found email regardless of what the finder claimed (see [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md), verification hard rules).
- **`detectJobChange` on a fresh cadence.** At 3 credits/record, re-running the same segment weekly re-bills rows whose status can't have changed — every 2 weeks is the right default (see [`../recipes/save-as-play.md`](../recipes/save-as-play.md) cadence table).
## Position in the waterfall
- `verifyEmail` — **always the last step** of any email chain; never skipped.
- `enrichContact` / `enrichCompany` — **second rung**, after aiArk, before peopleDataLabs.
- `findPhone` — **last rung** of the phone chain (after prospeo, FullEnrich). Demote any rung that misses on the pilot's first ~10 rows for the rest of the batch.
## Recurring use
- **`detectJobChange` is the canonical recurring signal** — save it as a play over the tracked-contact segment at the every-2-weeks default; the fresh-cadence anti-pattern above explains why tighter is pure re-billing at 3 credits/record. Cadence table: [`../recipes/save-as-play.md`](../recipes/save-as-play.md); full pattern: [`../recipes/job-change-monitoring.md`](../recipes/job-change-monitoring.md).
- **`verifyEmail` recurs as verify-before-send** — a node at the top of each send-wave play, gated to rows entering the wave with a missing or stale verdict; never a standing timer over the whole model.
- **In-play gate for enrichment:** `enrichContact` / `enrichCompany` (1–2 credits) gate on the target enrichment column being empty — segment re-evaluation must not re-bill filled rows; the underlying person/company data is stable enough that a blanket refresh buys nothing.
## Action shape
`{"kind":"connector","integrationSlug":"waterfall","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
provider-playbooks/x.md
---
provider: x
category: signal (public social)
last-reviewed: 2026-08-15
---
# x (X / Twitter)
Fourteen actions over public X data: profiles, posts, replies, followers, and search. **Every one costs 0.02**, which makes this the cheapest signal surface in the catalog and the easiest place to run up a bill without noticing, because the temptation is to pull all fourteen.
## Before anything here touches a person
X data is public, which is not the same as usable. [`../references/acceptable-use.md`](../references/acceptable-use.md) binds every action below:
- **A public post is not a lawful basis for outreach.** It can inform *relevance* (why this message, for this person, now), never *basis*. The basis test is unchanged: customers, opted-in contacts, event attendees, or a documented legitimate-interest case.
- **Follower and liker lists are not audiences.** `getFollowers`, `getPostLikers` and `getRetweeters` return people who engaged with content, not people who asked to hear from you. Building a send list from them is the undifferentiated fan-out this skill refuses.
- **Company handles are the safe default.** Reading `@acme`'s posts to understand a company is research. Reading an individual's likes to profile them is not something this skill does.
## Credits-based actions
All fourteen cost 0.02. Four of them are engagement lists and carry a warning rather than a use.
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `getUserPosts` | 0.02 | `handle`, `limit` | **The one worth reaching for.** Recent posts from a company handle: launches, funding, hiring. |
| `getUserProfile` | 0.02 | `handle` | Bio, follower count, links. Confirms a handle is who you think it is. |
| `searchPosts` | 0.02 | `query`, `limit`, `type` | Who is discussing a problem in public. |
| `getPostDetails` | 0.02 | `tweetId` | One post, when a signal pointed at it. |
| `getUserReplies` | 0.02 | `handle`, `limit` | What a company answers in public. |
| `getPostComments` | 0.02 | `tweetId`, `limit` | Reaction to an announcement. |
| `getQuoteTweets` | 0.02 | `tweetId`, `limit` | The same, one step out. |
| `getUserMedia` | 0.02 | `handle`, `limit` | Images and video from a handle. |
| `getFollowing` | 0.02 | `handle`, `limit` | Who a handle follows. |
| `searchPeople` | 0.02 | `query`, `limit` | X accounts by query. **Not a contact source.** |
| `getFollowers` | 0.02 | `handle`, `limit` | **See the anti-patterns.** |
| `getUserLikes` | 0.02 | `handle`, `limit` | **See the anti-patterns.** |
| `getRetweeters` | 0.02 | `tweetId`, `limit` | **See the anti-patterns.** |
| `getPostLikers` | 0.02 | `tweetId`, `limit` | **See the anti-patterns.** |
Everything except `getUserProfile` and `getPostDetails` also takes a required `limit`.
## What it's for
- ✅ **Company announcement monitoring** — `getUserPosts` on a company handle is a launch, funding or hiring signal at 0.02, dated and public.
- ✅ **A personalization line that is actually recent** — one `getUserPosts` call beats an LLM inventing what a company cares about.
- ✅ **Topic listening** — `searchPosts` for a problem statement, to find companies discussing it in public.
- ❌ **Finding contacts** — `searchPeople` returns X accounts, not B2B records. The contact stack is in [`../references/stage-action-map.md`](../references/stage-action-map.md).
- ❌ **Firmographics** — a bio is not a headcount.
- ❌ **Audience building** — see the gate above.
## Patterns
### Pattern A — Company signal at 0.02
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"x","actionSlug":"getUserPosts"}' \
--records '[{"handle":"acme","limit":20}]' \
--wait-until-finished
```
One call per account. Keep `limit` low: twenty recent posts is more than enough to spot an announcement, and nobody reads two hundred.
### Pattern B — Who is talking about the problem
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"x","actionSlug":"searchPosts"}' \
--data '{"query":"\"data enrichment\" pricing frustrating","type":"Latest","limit":50}' \
--wait-until-finished
```
Resolve the resulting accounts to companies and enrich from there. The search output is a starting point for research, not a list.
## Common pitfalls
- **Pulling all fourteen actions per account.** At 0.02 each that is 0.28 a row before any enrichment, and twelve of the fourteen answer questions nobody asked.
- **Large `limit` values by default.** `limit` is required on most actions precisely so the caller decides. A forgotten large default is where the cheap provider stops being cheap.
- **Reading engagement as intent.** Someone liking a competitor's post is not in-market. Treat it as a research prompt, not a score input.
- **Handles that are not the company.** Squatted and parody handles resolve fine and return confident nonsense. Confirm the handle from the company's own site before trusting a signal from it.
## Anti-patterns
- **Follower harvesting.** `getFollowers` with a large `limit` across competitor accounts is list-building from people who never opted in. Refused by [`../references/acceptable-use.md`](../references/acceptable-use.md), and the cheap per-call price is exactly what makes it tempting.
- **`getUserLikes` on an individual.** Profiling a person's likes is not GTM research and is not a use this skill supports.
- **Per-row social pulls across a whole segment.** 0.02 a row looks free until it is five actions across 5,000 rows (500 credits) for signals that move a handful of them.
## Position in the waterfall
- **A signal rung, not a data rung.** It sits alongside `theirStack.searchJobs` (0.5, hiring intent) and `linkedin.extractProfilePostActivity` (0.05/item, LinkedIn posts) for the narrow question of what a company or person said publicly.
- Never in front of the sourcing or contact stacks. It informs a message; it does not build a list.
## Action shape
`{"kind":"connector","integrationSlug":"x","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.** Per-row handles go in `--records`, search filters in `--data`.
## Pairs with
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — the personalization step, where a dated public post is the strongest relevance input available.
- [`../recipes/account-expansion.md`](../recipes/account-expansion.md) — announcement monitoring on existing accounts.
## Recurring use
- **Cap `limit` in the node, not in the prompt.** A recurring pull whose limit can drift has a bill that drifts with it.
- **Signal-triggered beats scheduled.** Re-pulling every account's posts nightly re-bills accounts that posted nothing. Gate on rows whose last-checked timestamp is older than the interval, and prefer a smaller set checked often over a large set checked rarely.
- **Never schedule a follower or liker pull.** There is no version of that on a cron that stays inside the acceptable-use gate.
provider-playbooks/zeroBounce.md
---
provider: zeroBounce
category: verification
last-reviewed: 2026-07-09
---
# zeroBounce (ZeroBounce)
Dedicated email verification. **One credits-based action, 0.1 credits** — same price as the priority-stack default `waterfall.verifyEmail`, but a **different underlying provider**, which makes it the standard second opinion when waterfall's verdict is ambiguous.
## Credits-based actions
| Action | Cost | Inputs | Use for |
|---|---|---|---|
| `verifyEmail` | 0.1 | `email` | Verify a single email's deliverability status, with rich diagnostics. |
## What it's for
- ✅ **Second opinion on ambiguous verdicts** — re-check emails that `waterfall.verifyEmail` flagged as catch-all or risky before discarding them. Different underlying provider = independent signal at the same 0.1 price.
- ✅ **Typo rescue** — the output includes `did_you_mean`; a "bad" email is sometimes one transposed character away from a deliverable one.
- ✅ **Diagnostic depth** — output carries `sub_status`, `free_email`, `catchall_domain`, `mx_found`, `mx_record`, `smtp_provider`, and `domain_age_days`, useful when you need to *explain* a verdict, not just filter on it.
- ❌ **Default verify step** — `waterfall.verifyEmail` (0.1) is the priority-stack default; use zeroBounce as the alternative, not the first rung (see [`../references/alternatives.md`](../references/alternatives.md), Verify email alternatives).
- ❌ **Very large lists** — `icypeas.verifyEmail` (0.01) is 10× cheaper for bulk verification where per-row diagnostics don't matter.
## Patterns
### Pattern A — Second-opinion re-verify
```bash
# Only on rows where the first verifier returned catch-all / ambiguous
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"zeroBounce","actionSlug":"verifyEmail"}' \
--records '[{"email":"alice@acme.com"},{"email":"bob@globex.com"}]' \
--wait-until-finished
```
Keep an email when either verifier passes it cleanly; drop it only when both agree it's bad. This roughly doubles cost per re-checked row (0.1 + 0.1), so run it on the ambiguous subset, not the whole list.
### Pattern B — Single lookup with diagnostics
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"zeroBounce","actionSlug":"verifyEmail"}' \
--data '{"email":"alice@acme.com"}' \
--wait-until-finished
```
Read `status` + `sub_status` for the verdict, `catchall_domain` / `free_email` for risk context, and `did_you_mean` for a suggested correction when the address looks mistyped.
## Output fields
`address, status, sub_status, free_email, catchall_domain, did_you_mean, account, domain, domain_age_days, smtp_provider, mx_found, mx_record, firstname, lastname, gender, country, region, city, zipcode, processed_at`. Filter on `status`; use the rest for triage and reporting.
## Common pitfalls
- **Double-verifying everything by default.** Running zeroBounce on rows waterfall already passed cleanly doubles verify spend for near-zero information gain. Reserve it for the ambiguous subset.
- **Ignoring `did_you_mean`.** When present, re-verify the suggested address before writing the contact off — a corrected typo is the cheapest "found email" there is.
- **Treating catch-all as valid.** `catchall_domain: true` means the domain accepts everything; the mailbox itself is unproven. Route catch-alls per your sequencer's risk tolerance, don't blanket-send.
## Anti-patterns
- **zeroBounce as the first verify rung.** Same price as the priority default but outside the priority stack — swap it in deliberately (second opinion, provider outage, coverage test), not by default.
- **Skipping verification because the finder said "verified".** Providers grade their own homework — every found email goes through a verify step regardless of the finder's flag (see [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md)).
## Position in the waterfall
**VERIFY stage, alternative rung.** Default chain: `waterfall.verifyEmail` (0.1) first; `zeroBounce.verifyEmail` (0.1) as the equivalent-cost second opinion; `icypeas.verifyEmail` (0.01) when volume dominates and diagnostics don't matter (see [`../references/stage-action-map.md`](../references/stage-action-map.md), Verify email).
## Action shape
`{"kind":"connector","integrationSlug":"zeroBounce","actionSlug":"verifyEmail"}`. **No `connectorUuid` in `config`.**
## Pairs with
- [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md) — the verify step before personalization; never sequence unverified emails.
- [`../recipes/prospecting.md`](../recipes/prospecting.md) — the verify rung of the find → enrich → verify → sync spine.
## Recurring use
- **Re-verify before the send wave, not on a timer** — deliverability decays as people change jobs, so the recurring shape is a verify node inside each send play, gated to rows whose last verdict is missing or stale; a blanket cron over the whole model re-bills 0.1/row on addresses nobody is about to email.
- **In-play gate:** filter on empty/stale `status` — plus the first verifier's ambiguous verdicts, per Pattern A — so play re-evaluation never re-bills freshly-verified rows.
- **Second-opinion discipline holds on a schedule too:** recurring double-verification of rows waterfall already passed cleanly is the "double-verifying everything" pitfall above, compounding every cycle.
recipes/account-expansion.md
# Recipe — Find expansion contacts inside customer accounts
Use this recipe when the user wants to multi-thread existing customers — find additional buyers, champions, or budget-holders within accounts they already sell to. The output is a per-customer list of net-new contacts (not already in the workspace's Contacts model) at target personas, ready for hand-off to outreach.
**Trigger phrases:**
- *"Find me other buyers at our existing customer accounts."*
- *"Who should we be talking to for upsell at our customers?"*
- *"Multi-thread the champion accounts — who else matters?"*
- *"Find net-new contacts at customer X."*
## Why this recipe exists
Expansion revenue typically beats new-logo revenue on CAC by 3–5×. The blocker is rarely *which accounts* (the CRM already knows the customer list) — it's *which net-new contacts* at those accounts to engage. This recipe mechanizes the discovery: pull customer accounts, search for additional personas, deduplicate against contacts already in the CRM, enrich, ready for outreach.
The cargo-unique piece is the dedup against the workspace's Contacts model — sourcing tools (salesNavigator, peopleDataLabs) don't know who you already have in HubSpot/Salesforce.
## Recipe
### Step 1 — Pull the customer-account list
```bash
cargo-ai storage model list # find Companies + Contacts model UUIDs
COMPANIES_MODEL=...
CONTACTS_MODEL=...
cargo-ai segmentation segment fetch \
--model-uuid "$COMPANIES_MODEL" \
--filter '{"conjonction":"and","groups":[{"conjonction":"and","conditions":[
{"kind":"string","columnSlug":"lifecycle_stage","operator":"is","values":["customer"]},
{"kind":"string","columnSlug":"subscription_status","operator":"is","values":["active"]}
]}]}' > /tmp/customers.json
```
Adjust filters to scope: top-tier customers only, customers in renewal window (next 90d), customers with NRR > 100%, etc.
### Step 2 — Search for additional personas at each customer
Choose precision (salesNavigator) or scale (peopleDataLabs). For expansion, **precision usually wins** — you only need 2–4 net-new contacts per account, and signal quality matters more than volume:
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"salesNavigator","actionSlug":"searchLeads"}' \
--records "$(jq -c '[.records[] | {
company_domain: .domain,
title_keywords: ["VP Engineering","Director of Data","Head of Analytics","CTO"],
function: ["Engineering","Data","Product"]
}]' /tmp/customers.json)" \
--wait-until-finished > /tmp/expansion-candidates.json
```
Customize `title_keywords` and `function` to match the expansion motion — different from the original champion persona. If the original buyer was VP Sales, expansion personas might be VP Marketing, Head of Customer Success, Head of Engineering, etc.
For scale (e.g. when expanding to 1,000+ customer accounts at once), swap to `peopleDataLabs.searchLeads` — cheaper per record, broader coverage, lower signal-to-noise.
### Step 3 — Pull existing contacts at the same accounts
```bash
cargo-ai segmentation segment fetch \
--model-uuid "$CONTACTS_MODEL" \
--filter "$(jq -c '{
conjonction: "and",
groups: [{
conjonction: "and",
conditions: [{
kind: "string",
columnSlug: "company_domain",
operator: "in",
values: [.records[].domain]
}]
}]
}' /tmp/customers.json)" > /tmp/existing-contacts.json
```
### Step 4 — Deduplicate: keep only net-new candidates
```bash
# Build a set of known contact identifiers (LinkedIn URL + email)
jq -r '.records[] | (.linkedin_url // "") + "|" + (.email // "")' /tmp/existing-contacts.json | sort -u > /tmp/known.txt
# Filter expansion candidates against the known set
jq -c '[.results[] | . as $c
| (($c.linkedin_url // "") + "|" + ($c.email // "")) as $id
| select($id | IN($ARGS.positional[]) | not)
| $c
]' --args $(cat /tmp/known.txt) /tmp/expansion-candidates.json > /tmp/net-new.json
```
LinkedIn URL is the most reliable dedup key — emails can vary (work vs. personal), names collide.
### Step 5 — Enrich the net-new contacts
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"enrichProspectDetails"}' \
--records "$(jq -c '[.[] | {
first_name, last_name,
company_domain,
contact_linkedin: .linkedin_url
}]' /tmp/net-new.json)" \
--wait-until-finished > /tmp/enriched.json
```
For mobile direct dials and top-tier accuracy (worth it on a small high-value expansion list), swap in `FullEnrich.enrichPerson`.
### Step 6 — Tag with expansion signal, hand off to outreach
```bash
jq -c '[.results[] | . + {
signal_summary: ("Expansion — your colleague <existing champion at " + .company_name + "> is already a customer; reaching out to introduce the same value to your function.")
}]' /tmp/enriched.json > /tmp/expansion-ready.json
```
The expansion signal in the personalization prompt produces qualitatively different cold copy than a cold-prospect signal — name-drop the existing user, tie value to the recipient's function. Pass to [`outreach-activation.md`](outreach-activation.md) from Step 5 onwards (skip its enrichment step — already done).
## Recurring expansion (cron / play)
For continuous multi-threading:
1. Trigger: monthly cron.
2. Source: customer-accounts segment.
3. Nodes: searchLeads → fetch existing Contacts → dedup → enrich → write to "Expansion candidates" segment.
4. Downstream: a separate play takes new members of "Expansion candidates" and triggers `outreach-activation`.
For play setup, see [`../../cargo-orchestration/references/examples/plays.md`](../../cargo-orchestration/references/examples/plays.md).
## Credit budget
For 50 top-tier customer accounts, expanded monthly:
| Step | Per record | 50 accounts (3 candidates each = 150) |
|---|---|---|
| `salesNavigator.searchLeads` | 2 | 100 (per account) |
| `waterfall.enrichProspectDetails` | 1 | 150 (per net-new) |
| **Total monthly** | — | **250** |
Expansion typically runs on smaller targeted lists, so per-record costs (even high-precision providers like FullEnrich at 5 credits/record) stay affordable. The dedup step is free — it's a workspace storage query.
## Action shape
Every action follows: `{"kind":"connector","integrationSlug":"<slug>","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`** — see [`../../cargo-orchestration/references/examples/actions.md`](../../cargo-orchestration/references/examples/actions.md).
## Output retrieval
For batch runs, use `cargo-ai orchestration run download-outputs --workflow-uuid <uuid> --output-node-slug <slug>`. See [`../references/output-retrieval.md`](../references/output-retrieval.md).
## Related
- [`prospecting.md`](prospecting.md) — broader: net-new prospects across the TAM, not constrained to existing customers.
- [`outreach-activation.md`](outreach-activation.md) — downstream: turns the expansion segment into send-ready outreach. The `signal_summary` tag from Step 6 feeds directly into its personalization prompt.
recipes/ads-audience-activation.md
# Recipe — Paid ads audience activation
Use this recipe when the user wants to turn a Cargo segment into a **paid-media targeting audience**: Google Ads Customer Match or LinkedIn Matched Audiences. Typical asks: "upload this list to Google Ads", "retarget our Closed-Lost accounts on LinkedIn", "build a lookalike seed from our best customers", "suppress current customers from our ad spend", "why is our match rate so low".
This is the activation channel that sits beside outbound. Same audience, different destination: [`outreach-activation.md`](outreach-activation.md) hands a segment to a sequencer; this one hands it to an ad platform.
## What Cargo supports
Two connectors, both **own-key** (no Cargo credits — you pay the ad platform, not us):
| Integration | Actions |
|---|---|
| `googleAds` | `createAudience`, `addContactToAudience`, `removeContactFromAudience`, `createReport` |
| `linkedinMatchedAudience` | `createAudience`, `addCompanyToAudience`, `addContactToAudience`, `removeEmailFromAudience`, `createReport` |
LinkedIn is the only one of the two that takes **company** rows (`addCompanyToAudience`) — that is the ABM path, and it needs no personal data at all. Google Customer Match is person-only.
> **No Meta/Facebook connector exists in the catalog.** If the user asks for Meta Custom Audiences, say so plainly and offer the two above, or an export ([`../../cargo-analytics/SKILL.md`](../../cargo-analytics/SKILL.md)) they upload manually. Do not improvise an HTTP node against the Marketing API.
## Before you start — consent and suppression
Ad platforms treat uploaded lists as customer data. Both Google and LinkedIn require that you have a lawful basis and a direct relationship with the people you upload.
1. **Ask which basis applies** — customers, opted-in contacts, or event attendees. Purchased or scraped lists are a policy violation on both platforms; if that is what the segment is, stop and say so.
2. **Exclude opt-outs.** Filter the segment on the workspace's unsubscribe/do-not-contact column before uploading. If no such column exists, flag it — this is a real gap, not a detail.
3. **Coverage, not completeness.** Match rates of 30–70% are normal. Never chase 100% by adding more enrichment rungs; see step 5.
## Step 1 — Pick the audience
Start from a segment, not a raw model — the audience needs to be reproducible.
```bash
cargo-ai segmentation segment list # existing audiences
cargo-ai segmentation segment get <segment-uuid> # recordsCount = the real size
```
Sizing gates worth stating up front: **Google Customer Match needs ~1,000 matched members** before a list will serve, **LinkedIn needs ~300**. A 400-row segment is fine for LinkedIn and useless for Google. Say this before enriching anything.
See [`../../cargo-segmentation/SKILL.md`](../../cargo-segmentation/SKILL.md) for building the segment and for `--tracking-column-slugs` if the audience should stay in sync.
## Step 2 — Decide the identifier, then enrich only for it
Match rate is a function of which identifiers you send. Send what the platform actually keys on:
| Destination | Best identifier | Second | Do not bother |
|---|---|---|---|
| Google Customer Match | **Personal email** | Phone (E.164), then first/last + country + postal code | Work email alone often matches poorly |
| LinkedIn — contacts | **Any email on file** | — | Phone (unsupported) |
| LinkedIn — companies | **Company name + domain** | LinkedIn company page URL | Any personal data |
Two consequences that save real money:
- **The ABM path needs no contact enrichment at all.** If the user wants account targeting on LinkedIn, `addCompanyToAudience` takes `companyName` (+ optional `companyWebsiteDomain`, `companyPageUrl`, `industries`, `city`, `state`, `country`) — a company segment is already sufficient. Do not run a contact waterfall for it.
- **Personal email is a different lookup from work email.** The standard find-email chain returns *work* addresses — including `aiArk.enrichPerson` (0.1 from a LinkedIn URL, and the right pick when a work address is enough, as it is for LinkedIn Matched Audiences). The personal mailbox needs [`forager.findPersonalEmail`](../provider-playbooks/forager.md) (2, LinkedIn URL in), which is the only action in the catalog that offers it. Reach for it only when the destination is Google Customer Match and the probe in step 5 shows work addresses matching poorly — 2 credits/row is a real budget line at audience scale.
Where enrichment *is* needed, follow the normal chain in [`../guides/enriching-and-researching.md`](../guides/enriching-and-researching.md) and the cost gates in [`../references/cost-discipline.md`](../references/cost-discipline.md) — pilot 1–3 rows, present the approval message, then run.
Do **not** verify emails for an ads upload. `waterfall.verifyEmail` protects sender reputation; ad platforms do not bounce, so verification here is spend with no return.
## Step 3 — Hashing
**Do not hash anything yourself.** Both connectors take plaintext identifiers and hash them in transit as each platform requires. A pre-hashed value gets hashed again and matches nothing — a silent, total failure that looks like a bad audience.
If the user arrives with an already-hashed list from elsewhere, that list cannot be used through these connectors; you need the plaintext source.
## Step 4 — Create the audience, then fill it
Create once, then batch the members in. Both `createAudience` calls return the id that every subsequent call needs.
**Google Ads:**
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"googleAds","actionSlug":"createAudience"}' \
--data '{
"customerId": "123-456-7890",
"name": "Closed-Won lookalike seed 2026-Q3",
"membershipLifeSpan": 540
}' --wait-until-finished
```
**Every field below goes in `--data` / `--records`, never in the action's `config`** — a top-level action carries no `config` at all. Older backends reject inputs placed there; newer ones silently drop them and run the action with nothing, which here means an audience created with no name or an upload that adds no members. (Inside a workflow **node** these same fields are the node's `config`.)
`membershipLifeSpan` is in days; `540` is the maximum and the right default for a seed list. Use a short span (30–90) only for a genuinely time-boxed retargeting pool.
Then fan the segment across `addContactToAudience`, which needs `customerId` + `userListId` plus at least one identifier (`email`, `phoneNumber`, `mobileId`, or the address triple `firstName`/`lastName`/`countryCode`/`postalCode`):
```bash
# Pull the audience from the segment, then shape one record per member.
cargo-ai segmentation segment fetch --model-uuid <uuid> \
--filter '<segment filter json>' --fetching-limit 20 > /tmp/audience.json
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"googleAds","actionSlug":"addContactToAudience"}' \
--records "$(jq -c '[.records[] | {
customerId: "123-456-7890",
userListId: "987654321",
email: .personal_email
}]' /tmp/audience.json)" \
--wait-until-finished
```
`action execute-batch` takes `--records` only — there is no `--model-uuid`/`--filter` form, and `{{record.…}}` expressions resolve inside a **node graph**, not in a top-level action. Fetch the rows first (as above), or build the graph as a play/tool when this should run on a schedule.
**LinkedIn Matched Audiences** — same shape, different keys. `createAudience` takes `account`, `name`, and `type`; members take `accountUrn` + `audienceId`:
```bash
# Contacts (email-keyed)
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"linkedinMatchedAudience","actionSlug":"addContactToAudience"}' \
--records "$(jq -c '[.records[] | {
accountUrn: "urn:li:sponsoredAccount:123456789",
audienceId: "<id>",
email: .email,
firstname: .first_name,
lastname: .last_name,
companyName: .company_name
}]' /tmp/audience.json)" \
--wait-until-finished
# Companies (ABM — no personal data): fetch the account segment the same way
# (cargo-ai segmentation segment fetch --model-uuid <companies-model> … > /tmp/accounts.json)
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"linkedinMatchedAudience","actionSlug":"addCompanyToAudience"}' \
--records "$(jq -c '[.records[] | {
accountUrn: "urn:li:sponsoredAccount:123456789",
audienceId: "<id>",
companyName: .name,
companyWebsiteDomain: .domain
}]' /tmp/accounts.json)" \
--wait-until-finished
```
**Batch discipline applies here even though the actions are free.** Enroll 10–20 records first, confirm they land, then ask the user to approve the full enrollment — quoting the record count. A wrong `customerId`/`accountUrn` writes to the wrong ad account, and the fix is a manual cleanup in someone's ads console. See [`../../cargo-orchestration/SKILL.md`](../../cargo-orchestration/SKILL.md) → "Create a batch".
## Step 5 — Report the match rate, and interpret it
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"googleAds","actionSlug":"createReport"}' \
--data '{"customerId": "123-456-7890", "userListId": "987654321"}' \
--wait-until-finished
```
`linkedinMatchedAudience.createReport` takes `accountUrn` + `audienceId` and returns the same shape of answer.
Both platforms take **6–48 hours** to populate the match figure. A report run immediately after upload showing zero is expected, not a failure — tell the user that rather than re-uploading.
Reading the result:
| Symptom | Cause | Fix |
|---|---|---|
| Match rate under ~20% | Work emails against a consumer-keyed platform (Google) | Add a personal-email rung; do not add more work-email providers |
| Match rate 0% after 48h | Pre-hashed input, or wrong `customerId`/`accountUrn` | Re-upload plaintext to the verified account id |
| Audience won't serve | Below the platform minimum (~1,000 Google / ~300 LinkedIn) | Widen the segment; more enrichment on the same rows will not help |
| Rate dropped over time | `membershipLifeSpan` expiring members | Re-run the upload on a schedule |
Close with the standard receipt: rows attempted, rows accepted, matched members, match rate, credits spent on any enrichment (the upload itself is free), and what the number means for whether the campaign can run.
## Step 6 — Make it recurring
An audience uploaded once decays. When the segment is a live signal (new Closed-Won, new job changes, fresh intent), convert the chain into a scheduled play so members flow in as they qualify: [`save-as-play.md`](save-as-play.md).
Two rules for the recurring version:
- **Gate on segment membership changes, not a full re-upload.** Drive the play from the segment's change feed (`added` records) so each row uploads once instead of re-billing any enrichment step on every run.
- **Wire the removal path too.** `removeContactFromAudience` / `removeEmailFromAudience` on the `removed` kind is what keeps churned customers and opt-outs from being advertised to — the part everyone forgets, and the one with compliance consequences.
Watch the pipeline with an alert on the upload workflow's error rate: [`../../cargo-observability/SKILL.md`](../../cargo-observability/SKILL.md).
## Related
- [`outreach-activation.md`](outreach-activation.md) — same segment, outbound sequencer instead of ad platform.
- [`icp-discovery.md`](icp-discovery.md) — find the Closed-Won signals worth seeding a lookalike from.
- [`account-expansion.md`](account-expansion.md) — the contact-level counterpart of ABM company targeting.
recipes/build-tam.md
# Recipe — Build a TAM list
**Use when**: the user wants a Total Addressable Market list of companies (and optionally contacts at those companies) matching ICP criteria.
**Trigger phrases**:
- *"Build me a TAM of fintech companies in the US, 50–500 employees."*
- *"Source 1,000 SaaS companies hiring data engineers."*
- *"Find every Series A-B startup running Snowflake."*
- *"Give me all the e-commerce brands in the EU under 100 people."*
## Sourcing decision tree
The right step-1 provider depends on which filter is primary:
| Primary filter | Provider | Cost (credits) | Notes |
|---|---|---|---|
| Industry / size / geo | `salesNavigator.searchAccounts` | 0.05 | LinkedIn-anchored. Default at-scale. |
| Industry / size / geo, budget-first | `aiArk.searchCompanies` | 0.01 | **Cheapest per record in the catalog** (5× under salesNavigator). Billed per *returned* row, `limit` max 100 — paginate for large pulls. |
| "Companies like these customers" | `aiArk.searchCompanies` (with `lookalikeDomains`) | 0.01 | Up to 5 seed domains / LinkedIn URLs. Cheaper than `oceanio` / `companyEnrich` lookalikes. |
| Funding stage / investor / round size | `peopleDataLabs.queryCompanies` | 3 | PDL **SQL** string. Required for array-membership filters like `summary.investors LIKE %X%`. |
| Tech stack | `theirStack.searchCompanies` (with techFields) | 0.5 | Tech-stack-driven sourcing. |
| Hiring for role X | `theirStack.searchJobs` | 0.5 | Hiring-intent signal. |
| Local SMBs / storefronts | `serper.searchPlaces` | 1 | Google Maps-style. |
| Already have a domain list | (skip sourcing) | — | Go straight to step 2 (dedupe + enrich). |
For combined filters (e.g. fintech in US AND running Snowflake AND hiring data engineers), do parallel queries and intersect client-side.
## Volume / cost guidance
| Target volume | Recommended sourcing path | Estimated credits (sourcing only) |
|---|---|---|
| 100 companies | salesNavigator.searchAccounts | ~5 |
| 500 companies | salesNavigator.searchAccounts | ~25 |
| 1,000 companies | salesNavigator.searchAccounts | ~50 |
| 5,000 companies | salesNavigator.searchAccounts (paginate) | ~250 |
| 5,000 companies, budget-first | aiArk.searchCompanies (paginate, 100/call) | ~50 |
| 10,000 companies | peopleDataLabs.queryCompanies (high-quality, structured) | ~30,000 (3/company) |
The [sample → approval → full-run gate](../references/cost-discipline.md) applies at every volume: **10–20 rows first** (1–3 only proves the filter is syntactically right, not that the list is any good), receipt, then approval stating how many companies the full pull enrolls and what they cost, reconciled against the balance. For 5,000+ companies, widen the sample to **50 rows** — data-quality problems invisible at 3 rows show up at 50, and the 50-row cost is still noise next to the full pull. Size the pool free first: search actions bill on *returned* rows, so a `limit: 1` probe reads the provider's total match count for the price of one row.
## Inputs you need
- ICP criteria (industry, headcount range, geo, revenue band, funding stage, tech-stack signals — one or more).
- Target volume (10? 500? 5000? — drives provider choice).
- Whether contacts are required, and if so, role filter.
- Where the result lives (write to a Companies model? Export to CSV? Push to a CRM?).
If anything is missing, ask the user **once** before sourcing.
## Recipe
### Step 1 — Source companies
Cheapest at scale (≥ 100 companies): `aiArk.searchCompanies` (0.01 cred/company, `limit` max 100 per call) when price leads, or `salesNavigator.searchAccounts` (0.05 cred/company) when you want LinkedIn-native filters and larger pages. Both bill per *returned* row — size the pool with a `limit: 1` probe first. The salesNavigator form:
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"salesNavigator","actionSlug":"searchAccounts"}' \
--data '{
"filters": {
"industries": ["Financial Services"],
"countries": ["US"],
"headcountMin": 50,
"headcountMax": 500
},
"limit": 500
}' \
--wait-until-finished > /tmp/companies.json
```
Filter mismatch? Fall back to peopleDataLabs. Pick the right action by filter shape:
- **`searchCompanies`** (cargo's `{conjonction, groups, conditions}` filter shape) for simple AND/OR criteria:
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"peopleDataLabs","actionSlug":"searchCompanies"}' \
--data '{
"filter": {
"conjonction": "and",
"groups": [{
"conjonction": "and",
"conditions": [
{"propertyName": "industry", "operator": "is", "value": "financial services"},
{"propertyName": "employee_count", "operator": "greaterThanOrEquals", "value": 50},
{"propertyName": "employee_count", "operator": "lowerThanOrEquals", "value": 500},
{"propertyName": "location.country", "operator": "is", "value": "united states"}
]
}]
},
"limit": 500
}' \
--wait-until-finished > /tmp/companies.json
```
- **`queryCompanies`** (PDL **SQL string**) when criteria require array-membership, joins, or complex bool combinations:
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"peopleDataLabs","actionSlug":"queryCompanies"}' \
--data '{
"query": "SELECT * FROM company WHERE industry = '\''financial services'\'' AND employee_count >= 50 AND employee_count <= 500 AND location.country = '\''united states'\''",
"limit": 500
}' \
--wait-until-finished > /tmp/companies.json
```
### Step 2 — Dedupe against the workspace (free)
Sourcing returns companies you may already hold. Filter them out **before** any
paid enrichment — this is a storage read, not a paid action:
```bash
cargo-ai storage query execute "SELECT domain FROM default.companies" > /tmp/known.json
# keep only the domains the workspace doesn't already have
jq -c --slurpfile known /tmp/known.json \
'[.companies[]
| {domain: .website, linkedinId: .linkedinId}
| select(.domain as $d | ($known[0].rows // [] | map(.domain)) | index($d) | not)]' \
/tmp/companies.json > /tmp/new-companies.json
```
`domain` is the join key for every enrichment below — no provider-side id is
needed for those. `linkedinId` is carried through only because the optional
contact step needs a Sales Navigator `accountId`, and **no enrichment action
returns one**; it comes from `salesNavigator.searchAccounts` at step 1. Sourcing
that had no LinkedIn anchor (the `peopleDataLabs` path) has no `linkedinId` to
carry, so step 4 falls back to a per-domain title search.
### Step 3 — Enrich firmographics + signals
```bash
# Firmographics — cheapest company enrich in the catalog
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"enrichCompany"}' \
--records "$(jq -c '[.[] | {domain}]' /tmp/new-companies.json)" \
--wait-until-finished > /tmp/firmo.json
# Funding signals (only worth running if funding is part of ICP)
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"enrichCrm","actionSlug":"getFunding"}' \
--records "$(jq -c '[.[] | {domain}]' /tmp/new-companies.json)" \
--wait-until-finished > /tmp/funding.json
# Tech-stack (only worth running if technographics are part of ICP)
# getDomainSummary is FREE — run it across the whole list first
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"builtwith","actionSlug":"getDomainSummary"}' \
--records "$(jq -c '[.[] | {domain}]' /tmp/new-companies.json)" \
--wait-until-finished > /tmp/tech.json
```
Rows where `aiArk.enrichCompany` came back thin escalate one rung at a time —
`companyEnrich.enrichByDomain` (0.25), then `waterfall.enrichCompany` (1):
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"companyEnrich","actionSlug":"enrichByDomain"}' \
--records '<rows from /tmp/firmo.json with empty firmographics>' \
--wait-until-finished > /tmp/firmo-fallback.json
```
### Step 4 — (Optional) Find contacts at each company
Only run if the user asked for contacts. Cap at 3-5 per company.
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"salesNavigator","actionSlug":"searchLeads"}' \
--records "$(jq -c '[.[] | select(.linkedinId) | {filters:{accountId: .linkedinId, titles:[\"CTO\",\"VP Engineering\"]}, limit: 5}]' /tmp/new-companies.json)" \
--wait-until-finished > /tmp/contacts.json
```
### Step 5 — (Optional) Find emails for the contacts
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"FullEnrich","actionSlug":"findEmail"}' \
--records "$(jq -c '[.contacts[] | {firstName:.firstName, lastName:.lastName, companyDomain:.companyDomain}]' /tmp/contacts.json)" \
--wait-until-finished > /tmp/emails.json
```
### Step 6 — Verify emails
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"verifyEmail"}' \
--records "$(jq -c '[.results[] | {email: .email}]' /tmp/emails.json)" \
--wait-until-finished > /tmp/verified.json
```
### Step 7 — Write to model / export / push to CRM
If a Companies model exists in the workspace, write back via `cargo-ai storage column create` patterns (see [`../../cargo-storage/SKILL.md`](../../cargo-storage/SKILL.md)).
For a CSV export, point the user at `cargo-ai segmentation segment download` (see [`../../cargo-analytics/references/examples/exports.md`](../../cargo-analytics/references/examples/exports.md)).
For CRM push, compose ad hoc with `hubspot.upsertRecords` / `salesforce.upsert` — discover the action via `cargo-ai orchestration action list upsert --integration-slug hubspot`, then read its input schema with `cargo-ai connection integration get hubspot` (or `salesforce`) and run via `orchestration action execute-batch`.
## Credit budget (rough)
For a 500-company TAM with contacts:
| Step | Per record | Records | Subtotal |
|---|---|---|---|
| 1. Source (salesNavigator.searchAccounts) | 0.05 | 500 | 25 |
| 2. Dedupe against the Companies model | 0 | 500 | 0 |
| 3. aiArk.enrichCompany | 0.01 | 500 | 5 |
| 3. enrichCrm.getFunding (optional) | 1 | 500 | 500 |
| 3. builtwith.getDomainSummary (optional) | 0 | 500 | 0 |
| 4. searchLeads (3 contacts each) | 0.02 × 3 | 500 | 30 |
| 5. FullEnrich.findEmail | 1 | 1500 | 1500 |
| 6. waterfall.verifyEmail | 0.1 | 1500 | 150 |
**Total: ~2,210 credits for 500 companies + 1,500 contacts** (~1.5 credits per fully-enriched contact).
Cut steps the user doesn't need (skip step 3 funding/tech if not part of ICP, skip steps 4-6 if no contacts needed) to bring the cost down.
## When to deviate
- User wants local SMBs / storefronts → use `serper.searchPlaces` for sourcing instead of salesNavigator.
- User wants "everyone hiring for X role" → use `theirStack.searchJobs` then dedup to companies.
- User wants investor-backed companies → start with `peopleDataLabs.queryCompanies` (PDL SQL) filtering on `summary.investors LIKE %X%`. See [`portfolio-prospecting.md`](portfolio-prospecting.md) for the full pattern.
For these patterns, see [`tech-intent.md`](tech-intent.md) and [`portfolio-prospecting.md`](portfolio-prospecting.md).
recipes/clay-to-cargo.md
# Recipe — Migrate a Clay table to Cargo
Use this recipe when the user is moving off Clay specifically: a table (or a set of them) whose enrichment columns have to keep working, at a cost they can compare, with the result reviewable instead of clicked.
**Trigger phrases:**
- *"Migrate my Clay table to Cargo."*
- *"What's the Cargo equivalent of this Clay column?"*
- *"My Clay bill is getting out of hand."*
- *"Can we rebuild this table as code?"*
**Principle:** migrate the **configuration**, not the output. A Clay CSV export tells you a column was filled. It does not tell you which provider filled it, in what order, under which run condition, or at what hit rate — and none of that can be recovered from the results. Every hour spent getting the config out pays for itself twice: once in mapping accuracy, once in the parity check that decides whether the user switches.
This recipe is the Clay-specific expansion of [`import-gtm-data.md`](import-gtm-data.md), which is the general case for every other source tool. Read that one first when the source is not Clay: it deliberately carries no per-tool action mappings, and [§ Why Clay gets its own recipe](#why-clay-gets-its-own-recipe) says why this is the exception.
## Step 1 — Get the table configuration out
In descending order of what survives. **Stop at the first one that works** and record which path was used, because it decides how much of step 2 is inference.
| Path | What you get | Cost |
|---|---|---|
| **A. Column schema as JSON** — [ClayMate Lite](https://github.com/GTM-Base/claymate-lite) (MIT Chrome extension) exports Clay column structures as portable JSON | Column names, types, provider settings, formulas. The real input | free |
| **B. The user walks the table** — screenshot or read out each column's settings panel | Same as A, slower, and lossy on long tables | free |
| **C. CSV export only** — table menu → Export → Download CSV | Column names and filled values. **Not** which provider ran or in what order | free |
*Third-party code: ClayMate Lite runs on the user's logged-in Clay session. Tell them it is third-party and MIT, and let them review it before they load it. Never install it for them.*
**If you are on path C, say so out loud and say what it costs**: the mapping in step 2 becomes an educated guess from column names, waterfalls collapse into a single rung, and run conditions are invisible. A migration built on C that is later judged against Clay's real behaviour will look broken when it is only under-informed. Ask for A before accepting C.
Whichever path, read two things off the table before mapping anything:
- **The column list**, which is the thing being migrated. Each enrichment column is one provider call per row.
- **The fill rate per column.** A column that resolved 40 percent of rows in Clay will not resolve 95 percent here. This number is the denominator of the parity check in step 5, and quoting it early is how you avoid being graded against a rate nobody ever hit.
## Step 2 — Map the columns
Clay names columns after the vendor's product and renames them without notice, so **match on what a column does, not on its label**. The families below cover the great majority of production tables; anything outside them is step 3.
Costs are credits/record and are the pack's own priority stack. Confirm each against [`../references/stage-action-map.md`](../references/stage-action-map.md) and the provider's playbook before running: the § 11 gate applies here exactly as anywhere else.
### Sourcing columns (Find People / Find Companies)
| What the Clay column does | Cargo action | Cost |
|---|---|---|
| Find people by title, company, seniority | `salesNavigator.searchLeads` | 0.02 |
| Find people by education, skills, tenure | `aiArk.searchPeople` | 0.05 |
| Find companies (default) | `aiArk.searchCompanies` | 0.01 |
| Find companies, LinkedIn-anchored | `salesNavigator.searchAccounts` | 0.05 |
| Find companies by funding or investor | `peopleDataLabs.queryCompanies` (SQL variant) | 3 |
| Find lookalikes from seed domains | `aiArk.searchCompanies` with `lookalikeDomains` (≤5 seeds) | 0.01 |
### Contact-data columns (the ones that dominate the bill)
| What the Clay column does | Cargo action | Cost |
|---|---|---|
| Find work email, **LinkedIn URL in hand** | `aiArk.enrichPerson` | 0.1 |
| Find work email, name + domain | `FullEnrich.findEmail` | 1 |
| Find work email, budget rung | `hunter.findEmail` | 0.5 |
| Find work email, bulk last resort | `icypeas.findEmail` | 0.1 |
| Validate / verify email | `waterfall.verifyEmail` | 0.1 |
| Find mobile phone | `aiArk.findMobilePhone` | 0.5 |
| Enrich person from LinkedIn URL | `aiArk.enrichPerson` | 0.1 |
| Enrich person from name + company | `waterfall.enrichContact` | 2 |
| Email → LinkedIn (reverse lookup) | `FullEnrich.reverseEmailLookup` | 2 |
**`aiArk.enrichPerson` is the single highest-leverage substitution in most Clay migrations.** It returns the profile *and* a verified email for 0.1 and bills 0 when it finds none, so a Clay table that runs an email waterfall over rows that already carry LinkedIn URLs is usually paying several times over for what one 0.1 call does. Run it first, then run the finders above only on the residue.
### Company-data columns
| What the Clay column does | Cargo action | Cost |
|---|---|---|
| Enrich company firmographics | `aiArk.enrichCompany` | 0.01 |
| Enrich company, fuller field set | `companyEnrich.enrichByDomain` | 0.25 |
| Tech stack / technographics | `builtwith.getDomainSummary` → `enrichDomain` on the residue | 0 → 1 |
| Funding and acquisitions | `enrichCrm.getFunding` | 1 |
| Hiring signals | `theirStack.searchJobs` | 0.5 |
Every company action above keys on the **domain**, so a Clay company column maps
one to one with no id-resolution step in between — nothing appears in the Cargo
version that wasn't in the source. `builtwith.getDomainSummary` is the exception
worth calling out in the other direction: it is free, so the technographic column
gets cheaper on migration rather than more expensive.
### Everything else
| What the Clay column does | Where it goes |
|---|---|
| AI / Claygent prompt column | Check [`../references/prompt-library/index.md`](../references/prompt-library/index.md) for a proven equivalent **before** porting the prompt text |
| Write to CRM | A sync step, not an enrichment: [`outreach-activation.md`](outreach-activation.md) |
| HTTP / API column | The generic HTTP patterns in [`../../cargo-orchestration/SKILL.md`](../../cargo-orchestration/SKILL.md), with the user's own key |
| Formula column | A derived column on the model, or a transform in the node graph. No provider call, no cost |
| Lookup to another Clay table | A relationship between two Cargo models: [`../../cargo-storage/SKILL.md`](../../cargo-storage/SKILL.md) |
**Do not promise parity you have not checked.** If a Clay column used a provider with no Cargo equivalent, say so plainly and name what would replace it. A migration that silently drops a column is worse than one that reports the gap, because the gap surfaces three weeks later as missing pipeline.
## Step 3 — The four Clay concepts that do not map one to one
This is where real migrations break, and every one of them is invisible in a CSV export.
1. **Waterfalls.** A Clay email waterfall is one column hiding an ordered provider list. In Cargo it becomes explicit rungs, cheapest first, each escalating only the misses ([`../references/waterfall-strategy.md`](../references/waterfall-strategy.md)). That is the point rather than a workaround: the user can see which rung paid and drop the ones that never hit. Ask which providers the waterfall contained. If the answer is unavailable (path C), start from the pack's stack and say the order is Cargo's rather than theirs.
2. **Run conditions.** Clay columns run conditionally per row. Cargo's equivalent is a filtered segment feeding the batch, or a conditional node in the graph. A migration that ignores run conditions runs every action on every row and bills accordingly, which is the most common way a "cheaper" migration comes back more expensive.
3. **Auto-update / continuous runs.** A Clay table that re-runs on new rows becomes a play with a schedule ([`save-as-play.md`](save-as-play.md)), and the cadence is a cost decision, not a default. Re-billing gates are in each provider playbook's **Recurring use** section.
4. **Row limits and partial runs.** A Clay table that only ever ran on the first 500 of 5,000 rows has a fill rate that describes 500 rows. Check before quoting it as the baseline.
## Step 4 — Load the rows
The data import itself is the general case: follow [`import-gtm-data.md`](import-gtm-data.md) steps 2 to 4 (map columns to a model, add `source_tool_id`, load, then QA the **stored rows** rather than the export).
Two Clay-specific notes:
- Use the Clay row id as `source_tool_id`. It makes the migration idempotent and lets the parity check in step 5 join Cargo output to Clay output row by row instead of by fuzzy match.
- **Import the enriched values Clay already produced.** They come along free and every one of them is a row you never pay to enrich again. A migration that re-enriches everything on day one has spent the budget before proving anything.
## Step 5 — Parity check against Clay ground truth
This is the step that decides whether the user switches, and it is the reason step 1 mattered. It is pilot-gated: ~10 to 20 rows, never the full table.
**Choose the rows deliberately.** Take rows whose Clay outputs are known, and include the hard ones: at least a few that Clay *failed* to fill. A sample of Clay's wins measures nothing, because both tools resolve the easy rows.
Run the mapped chain on that sample, then report three numbers per column:
| Measure | What it answers |
|---|---|
| **Coverage** | Of N rows, how many did each tool fill? Compare against the fill rate from step 1, not against 100 percent |
| **Agreement** | On rows both filled, do the values match? Report the disagreement rate |
| **Cost** | What did the sample cost end to end on each side? |
Three rules for reading the result honestly:
- **On an email disagreement, the verified value wins, not the source.** Run `waterfall.verifyEmail` (0.1) on both sides before calling either one wrong. Clay being different is not Clay being right.
- **Never compare a Clay credit to a Cargo credit.** They are different units and the comparison is meaningless. Compare what one sample of rows cost end to end on each side, which is a measurement rather than an argument.
- **Coverage below the step-1 fill rate is a real miss** and needs a rung added or a provider swapped ([`../references/alternatives.md`](../references/alternatives.md)) before this goes any further. Coverage above it is not a win to celebrate loudly: check the disagreement rate first, because a finder that fills more rows and agrees less is guessing.
Present the table. **The user decides whether parity is good enough to switch**, not the agent.
## Step 6 — Save it as a play, then as code
Once parity passes, the chain becomes a play ([`save-as-play.md`](save-as-play.md)) so it runs on a schedule rather than by hand.
Then offer the part Clay has no answer to at all:
```bash
cargo-ai cdk init
cargo-ai cdk plan # a diffed resource tree; runs with no Cargo credentials at all
```
A Clay table is a spreadsheet: no diff, no review, no rollback, and the person who built it is the only one who knows why a column is there. Declared in `cargo-cdk`, the same table is a file that goes through a pull request. `plan` needs no credentials, so the user can see exactly what they would deploy before committing to anything; `deploy` is the only credential-gated step in the sequence. Full flow in [`../../cargo-cdk/SKILL.md`](../../cargo-cdk/SKILL.md).
Say this out loud when the parity table lands. It is the argument the cost comparison cannot make, and it is the one that does not erode when a provider changes its price.
## Credit budget
Loading is ~free. Spend concentrates in three places, and all of it goes through the [`cost-discipline`](../references/cost-discipline.md) gate: state the row count, the per-row cost, and the total before running anything.
| Where | Typical |
|---|---|
| The parity pilot | 10–20 rows × the mapped chain cost |
| Dedupe for rows with no natural key | free — a storage query against the existing Contacts / Companies models on `email` / `domain` / `linkedin_url` |
| Re-verification of the imported VERIFY bucket | `waterfall.verifyEmail`, 0.1/record |
The full-table run is a separate approval with its own three numbers. Getting the pilot approved is not getting the run approved.
## Why Clay gets its own recipe
[`import-gtm-data.md`](import-gtm-data.md) closes by saying it deliberately carries no per-tool extraction scripts or action-name mappings, because source tools change their internals without notice and CSV export is universal. That reasoning holds and this recipe is the argued exception to it, for two reasons.
Clay is not one source tool among many: it is the incumbent this product is most often replacing, and "what is the equivalent of this Clay column" is a question asked often enough to be worth maintaining an answer to. And the universal advice actively costs accuracy here, because Clay's per-row cost, its waterfalls and its run conditions are exactly the things a CSV export destroys.
The maintenance answer is the same as everywhere else in this pack: **every action slug and price above is a claim that has to agree with the provider playbooks**, and when one changes upstream this file is wrong and has to be corrected. Nothing here overrides a playbook. What is deliberately not claimed is completeness of Clay's own surface: Clay adds columns continuously, this map covers the families that appear in production tables, and an unmapped column is reported to the user as unmapped rather than guessed at.
recipes/custom-datapoints.md
# Recipe — Custom datapoints and live signals
Use this recipe when the user asks **which fields they should be collecting**, not how to fill a field they have already named. It designs an account schema — a shortlist of custom attributes and live signals specific to what they sell — then wires the survivors into columns, scoring, segments, and a refresh cadence.
**Trigger phrases:**
- *"What custom data points should we be collecting on our accounts?"*
- *"What buying signals should we watch for?"*
- *"Our scoring is industry + headcount and every competitor has the same list."*
- *"Design the enrichment schema for our ICP."*
- *"Given ten companies that all look the same, what would tell us which one to work first?"*
Adjacent recipes answer different questions: [`icp-discovery.md`](icp-discovery.md) derives fit from the user's own Won/Lost history (needs closed deals); [`source-planning.md`](source-planning.md) sources **one** field the user already named. This one runs when there are no closed deals to mine and no field named yet — the blank-page case.
## The failure this prevents
Any competent model will produce ten plausible attributes for any domain in one pass. The standard output of that exercise is a document where four attributes have no obtainable source, three cost more per account than the account is worth, and two were already columns in the workspace. It reads well and changes nothing.
The work is not generating candidates. It is **killing the ones that cannot be filled** — and the only honest way to do that is to name the action slug, the cost per account, and a probed hit rate before the list reaches the user.
## Step 0 — Whose accounts are we describing?
The domain in the ask is almost always the **seller**: the company whose GTM team wants better segmentation. The attributes you design describe **their target accounts**, not the seller.
Confirm in one line before researching anything — "so: attributes about the companies *you sell to*, designed from what *you* sell?" Read it backwards and you produce a competitor teardown instead of an enrichment schema, having spent the research budget on the wrong company.
If the user is a Cargo-side operator designing this *for* a prospect, the same rule holds one level out: research the prospect, design attributes about the prospect's customers.
## Step 1 — Read what the workspace already knows (free)
Three lookups, no credits. Any of them can end the recipe early.
```bash
cargo-ai context runtime browse # ICP / persona / proof docs already written?
cargo-ai storage model list # which model holds accounts
cargo-ai storage model get-ddl <companies-model-uuid> # which columns already exist, and their types
cargo-ai segmentation segment list # what the team already slices on
```
If the context repo already carries an ICP doc, this recipe **extends** it rather than restating it — and the output goes back to the same place (Step 8). If a proposed attribute is already a populated column, it is not a proposal, it is a reporting gap; say so and move on.
## Step 2 — Research the seller from public sources, not from memory
Model recall about a company is stale, thin for anything under ~1,000 employees, and confidently wrong about pricing and positioning. Fetch the pages.
```bash
# Crawl the seller's own site — homepage, product, pricing, docs, customers, careers
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"firecrawl","actionSlug":"crawl"}' \
--data '{"url":"https://<seller-domain>","limit":25}' --wait-until-finished
# Sourced answers for anything the site does not state (funding, category, named competitors)
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"linkup","actionSlug":"instruct"}' \
--data '{"q":"Who does <seller> sell to, and who do they compete with?","depth":"standard","outputType":"sourcedAnswer"}' \
--wait-until-finished
```
`instruct` takes `q` (a natural-language question) and a required `depth` — there is no implicit default, and `prompt` is not a field it accepts. See [`../provider-playbooks/linkup.md`](../provider-playbooks/linkup.md).
Read for the four things that actually generate attribute candidates — the homepage generates none of them:
| Page | What it tells you | Attribute it generates |
|---|---|---|
| **Pricing** | What a deal is worth, and what it scales on (seats, volume, entities, stores) | The unit the product is priced in is almost always the highest-value attribute — it *is* deal size |
| **Customer stories** | Who actually wins, in their own words | The shared conditions across logos; the "before" state each story describes is the pain to detect |
| **Docs / integrations** | Technical prerequisites | Compatibility attributes — what must already be in the stack for the product to install at all |
| **Careers** | What the seller is building next | Where the ICP is about to move; a role they are hiring for is a segment they intend to serve |
Cost: ~1–3 credits total. This is the cheapest step and it determines the quality of everything after it.
## Step 3 — Draft candidates against the discriminator test
For each candidate, ask the only question that matters: *given ten companies that already pass the standard firmographic filter, does this field tell me which one to work first?* If two companies with different values get the same treatment, cut it.
Split the candidates cleanly — the two feed different machinery downstream:
| | Attribute (state) | Signal (change) |
|---|---|---|
| Answers | What is true about this account? | Why is this account more relevant now than six months ago? |
| Example | 400 engineers; 12 countries; uses Salesforce | Engineering headcount +30% in 6 months; entered 3 new markets; migrating off HubSpot |
| Feeds | Scoring, tiering, territory design | Timing, triggers, sequence entry |
| Goes stale | Slowly — refresh on a cadence | Fast — has an expiry date |
"Uses AWS" is an attribute. "Started migrating to AWS" is a signal. A static fact dressed as a signal is the most common defect in this exercise, and it survives review because it reads urgent.
Two standing rules on the attribute side:
- **A generic firmographic earns its place only when it is unusually load-bearing** for this seller (founded-year for a remote-payroll product; entity count for a finance-automation product). Otherwise it is already in the CRM and gives no edge.
- **Aggregate, never dossier.** "Ratio of senior to junior engineers" as a company-level count is fine. The same question answered by profiling named individuals is not — see [`../references/acceptable-use.md`](../references/acceptable-use.md).
## Step 4 — The feasibility gate
Every surviving candidate gets an action slug and a price, or it gets cut. "Likely source: job postings" is not a source; `theirStack.searchJobs` at 0.5 credits per posting returned is.
A price here means **credits per account**, which for a row-billed action is not the same as the action's unit cost — see the third mechanic below the table.
| What you want to know | Cheapest catalog path | Credits/account | Coverage to expect |
|---|---|---|---|
| Function headcount, seniority mix, SDR:AE ratio, eng-as-share-of-total | `salesNavigator.findEmployeesDistribution` (0.25 **+ 0.05 ID**), or `linkedin.findCustomHeadcount` (0.5) when the role you care about isn't one of the buckets — it counts by keyword | 0.25–0.5 **+ ID** | High where a LinkedIn company page exists |
| Headcount growth or decline | `salesNavigator.findCompanyMetrics` (0.25 **+ 0.05 ID**), or `companyEnrich.getWorkforce` (0.25 — historical headcount **by department**) | 0.25 | Medium–High |
| Revenue band, NAICS / industry codes | `companyEnrich.enrichByDomain` (0.25) — same call also returns employees, funding and socials, so it can fill several rows at once | 0.25 | Medium — banded, not exact; private companies are estimates |
| Tech stack | `builtwith.getDomainSummary` (**0**) first, then `builtwith.enrichDomain` (1, flat) or `theirStack.searchTechnologies` (0.5/row) | 0 to start; 1 flat, or **0.5 × rows** — cap with `limit` | Medium — detection favors client-side and vendor-declared tech; back-office tools are near-invisible |
| Hiring intent — which roles, how many, how recent | `theirStack.searchJobs` (0.5) or `linkedin.searchJobs` (0.5) | **0.5 × postings returned** — cap with `limit` | Medium–High |
| Funding, M&A | `enrichCrm.getFunding` | 1 | High for VC-backed, structurally absent for bootstrapped |
| Positioning or website change | `firecrawl.scrape` (0.05) → `anthropic.instruct` haiku (0.2), diffed against the stored copy | ~0.25 | Medium — needs a stored baseline, or there is nothing to diff |
| Stated challenges, competitive landscape | `firecrawl.scrape` (0.05) → `anthropic.instruct` (0.2), or `linkup.instruct` (1, sourced) | 0.25–1 | Medium |
| **Anything stated on one specific page** — trust center, store locator, supported currencies, integration directory, entity list | `firecrawl.scrape` (0.05) → `anthropic.instruct` haiku (0.2) | ~0.25 | Entirely determined by whether that page exists — probe it |
| A question no structured provider carries | `linkup.instruct` (1, sourced) or `perplexity.instruct` (0.3–1) | 0.3–1 | Always answers; whether it answers *correctly* is what the probe measures |
| Companies like these customers (lookalikes) | `aiArk.searchCompanies` with `lookalikeDomains` (0.01/row, ≤5 seeds), or `linkedin.extractSimilarCompanies` (0.25 flat). `companyEnrich.findSimilarCompanies` also exists but bills **1 per company returned** — `limit: 100` is 100 credits | 0.01 × rows, or 0.25 | Medium — seed quality decides everything; probe with 5 seeds before scaling |
| Who works at one domain you already hold | `icypeas.scanDomain` (0.1, role addresses only) or `hunter.searchDomain` (1, named people, **max 10/call**) | 0.1–1 | Medium — this is a per-account lookup, not a list builder; looping `searchDomain` is the documented pitfall |
| How this person is likely to buy (personality, selling notes) | `aiArk.analyzePersonality` (0.05) | 0.05 | Catalog-unique. An input to *how you write*, never a stored fact about the person |
| Review/category presence | `piloterr.getG2ProductInfo` (0.01), `g2.enrichProduct` (1) | 0.01–1 | Low–Medium, category-dependent |
| Employee ratings / employer reputation (Glassdoor-style) | **none** | — | No credits-based action in the catalog returns this. It is a research note, not a datapoint — say so rather than substituting a scrape that reads like the real thing |
The `firecrawl.scrape` → `anthropic.instruct` row is the workhorse of this recipe: it is how a company-specific attribute nobody sells — *does this company publish a SOC 2 badge? how many store locations does the locator list? which currencies does checkout accept?* — becomes a real column for about a quarter of a credit. Use [`../references/prompt-library/data-extraction.md`](../references/prompt-library/data-extraction.md) → `custom-attribute-extraction` for the extract step rather than writing the prompt fresh.
Two cost mechanics change the arithmetic. The first is an **ID prerequisite** — a per-account entry fee before the attribute's own price, and the most common reason a shortlist under-quotes:
- Every `salesNavigator.find*` action (`findEmployeesDistribution`, `findCompanyMetrics`, `findEmployeesCount`, `findCompanyInsights`) keys on a LinkedIn **`companyId`**, not a domain. Accounts sourced through `salesNavigator.searchAccounts` already carry it; a list that arrived from a CRM export or a domain column does not, and resolving it costs 0.05/account through `searchAccounts`. It amortizes across every `find*` attribute on the same account — but it is easy to miss, because these actions look self-contained.
- **Search-shaped actions bill per returned row; the `find*` actions do not.** `theirStack.searchJobs`, `salesNavigator.searchAccounts` / `searchLeads` / `extract*` charge for every record they return, so keep `limit` strict and size the pool with `limit: 1` first ([`../references/cost-discipline.md`](../references/cost-discipline.md) §4). The `salesNavigator.find*` calls above are flat per account regardless of what comes back — don't budget them per row.
That second mechanic is the one that breaks estimates quietly, because a row-billed action *looks* like a flat per-account price in a table. A hiring-velocity field over an account with 8 open postings costs 4 credits, not 0.5 — and the accounts with the most postings are exactly the high-growth ones the field exists to find, so the overrun concentrates on the rows you care about. `cost-discipline.md` uses this precise case as its worked example of an estimate missing by 2×. **Price a row-billed attribute as `unit cost × rows you will actually accept`, and set `limit` to that number** so the cap is enforced rather than hoped for. Step 5 is where you measure the multiplier.
Doing that arithmetic can also flip which provider is cheaper. Tech stack is the clearest case: `theirStack.searchTechnologies` at 0.5/row undercuts `builtwith.enrichDomain` at a flat 1 only while an account returns one technology. At three rows it is 1.5 — the flat action wins, and it wins by more on exactly the dense stacks worth scoring. Compare per-account totals, never unit prices.
Anything left with no row here is a research note, not a datapoint. Say that plainly in the deliverable — "valuable, no obtainable source" is a legitimate and useful line item, and it is the honest version of what the exercise usually pads with.
## Step 5 — Probe before you promise
Take 5–10 **representative** accounts (not the ten biggest — they are the best-covered and will flatter every candidate) and run each surviving candidate against them. Full mechanics in [`source-planning.md`](source-planning.md) §3.
Record only: hit rate, **cost per hit** (cost per row ÷ hit rate), correctness on 3 spot-checks, freshness — plus, for any row-billed action, **rows returned per account**. That last number is the multiplier the shortlist needs; without it a per-row price gets copied into the table as if it were per-account, which is the single most common way this arithmetic goes wrong. Take the *median and the max* across the probe, not the mean: the max is what a runaway account costs, and it is what `limit` has to cap. Kill any candidate whose cost-per-hit exceeds what the decision it drives is worth. A 0.25-credit attribute at 20% coverage costs 1.25 per answer and leaves 80% of the list `Unknown` — which is a worse input to a score than not having the column, because a null reads as a low value in every naive scoring model.
Budget for this step: roughly 30–80 credits for a 6–10 candidate shortlist at 10 accounts each — the Step 6 schema alone probes at ~46. Row-billed candidates dominate that range and are the reason it is a range at all, so probe them with `limit` already set to the cap you intend to ship; a probe run uncapped measures a cost you are not going to pay and hides the one you are.
## Step 6 — Present the shortlist, then wait
The deliverable is a costed table, not an essay. Present it and stay in AWAIT_APPROVAL — the fan-out behind it is the expensive part.
> **Schema for `<seller>` — 6 attributes, 3 signals survived of 17 candidates**
>
> | # | Field | Type | Source | Cost/acct | Probe hit | Refresh | Decision it changes |
> |---|---|---|---|---:|---:|---|---|
> | — | *LinkedIn `companyId`* | *prereq* | `salesNavigator.searchAccounts` — needed once for #1–#2 | 0.05 | 9/10 | Once | — |
> | 1 | `eng_headcount_est` | number | `salesNavigator.findEmployeesDistribution` | 0.25 | 9/10 | Monthly | Tier + seat-count estimate |
> | 2 | `has_platform_team` | boolean | same call, title parse | 0.00 | 9/10 | Monthly | Routes to the technical persona |
> | 3 | `soc2_status` | enum | `firecrawl.scrape` /security + extract | 0.25 | 6/10 | Quarterly | Kills or unlocks enterprise motion |
> | 4 | `ai_tool_state` | enum | `builtwith.getDomainSummary` (0) → `enrichDomain` on the residue | 1.00 | 4/10 | Quarterly | Displacement vs greenfield play |
> | 5 | `eng_hiring_velocity` | number | `theirStack.searchJobs`, `limit: 3` | 1.50 | 7/10 | Weekly | Timing — expansion in progress |
> | 6 | `workforce_trend_12mo` | enum | `companyEnrich.getWorkforce` | 0.25 | 6/10 | Monthly | Growing vs contracting account |
>
> #5 is **row-billed**: 0.5 per posting returned, and the probe found a median of 3 relevant postings per account (max 9). `limit: 3` is what turns that into the fixed 1.50 above — uncapped, the top decile of accounts would cost 4.50 each.
>
> **Cut, with reason:** exact competitor spend (no source at any price) · contract renewal date (not public) · "is growing" (not discriminating) · industry (already a column).
>
> **Full-list arithmetic:** 3.30 credits/account (1 prereq + 6 attributes) × 4,100 accounts = **~13,500 credits**. Balance is 12,000 — the full fan-out does not fit, which is the point of quoting it before running it.
> **Cheaper cut (recommended):** drop #4 and #6, the two quarterly/monthly enrichment rows → **~8,400 credits** (2.05/account), keeps 80% of the scoring signal. Neither carries an ID fee, so the saving here is exactly their own price — 1.25/account, no more.
> **Narrower cut:** run all 6 on the 900 accounts already in the Tier-1 segment → **~3,000 credits**.
Three shaped options, a default, the reconciled balance — the standard approval shape from [`../references/cost-discipline.md`](../references/cost-discipline.md).
Two things in that table are worth copying into your own version, because both are places the arithmetic silently stops reconciling:
- **Cutting an attribute can cut its ID fee too.** Dropping #4 and #6 saves exactly their own price, because neither needs an id. Dropping #1 *and* #2 would save more than theirs, because it also retires the 0.05 `companyId` prereq — while dropping only one of them retires nothing, since the other still needs it. Recompute the prereqs per option instead of subtracting attribute prices from the total.
- **A row-billed field needs its multiplier and its cap shown**, not just a number. `1.50` in a costed table is only true because `limit: 3` makes it true; without the cap, the same row is an open-ended price that lands hardest on the fastest-growing accounts.
That arithmetic is the whole reason the feasibility gate exists. Designing ten attributes is free. Filling ten attributes across a TAM is a four-figure credit line, and the user should see it before agreeing rather than after.
## Step 7 — Operationalize: columns, batch, score
An attribute that lives in a chat reply is a research note. Give it a column.
```bash
# One column per approved attribute
cargo-ai storage column create --model-uuid <companies-model-uuid> \
--column '{"slug":"eng_headcount_est","type":"number","label":"Est. Engineering Headcount","kind":"custom"}'
# Inferred attributes get a companion confidence column — never store an inference bare
cargo-ai storage column create --model-uuid <companies-model-uuid> \
--column '{"slug":"eng_headcount_est_confidence","type":"string","label":"Est. Eng Headcount — Confidence","kind":"custom"}'
```
Add the confidence companion only for attributes that are **inferred or estimated**, not for ones read directly off a page — one extra column per uncertain field, not a doubling of the schema.
Then fill them: pilot on 10–20 accounts, present the receipt, get approval, fan out. Standard batch mechanics in [`../guides/enriching-and-researching.md`](../guides/enriching-and-researching.md); retrieve with `run download-outputs`.
Scoring is where the schema earns out. Assign points per attribute band, and be explicit that `Unknown` scores **neutral, never zero** — a missing value is absence of evidence, and scoring it as a negative systematically buries every account with thin public presence, which correlates with size, not with fit.
## Step 8 — Refresh, or it dies
Stale custom data is worse than none: it looks authoritative and is wrong. Set the cadence from how fast the underlying thing can actually change.
| Attribute family | Cadence | Mechanism |
|---|---|---|
| Job postings, hiring volume | Weekly | Scheduled play |
| Executive / leadership changes | Weekly | Scheduled play |
| Headcount, function distribution | Monthly | Scheduled play |
| Tech stack, certifications, entity counts | Quarterly | Scheduled play |
| Funding, M&A, market entry | Event-driven | `enrichCrm.getFunding` on a weekly sweep, diffed against the stored round date |
Turn each cadence into a play — [`save-as-play.md`](save-as-play.md). Before deploying, read the **Recurring use** section of the playbook for every paid node: a play re-bills its full node graph on every scheduled run.
Scope each play to the fields whose cadence is actually due. In the Step 6 schema the monthly-cadence fields are #1, #2, and #6 — 0.50 credits/account, so **~2,050 credits every month** across 4,100 accounts, not once.
Putting all six on one monthly play instead costs ~13,300/month and is wrong in both directions: #5 is a weekly field that a monthly sweep lets go stale, while #3 and #4 are quarterly ones it re-bills three times more often than they can change. One play per cadence, not one play per schema. The ID prereq is the one thing no refresh re-pays — once `companyId` is a column, every later run skips it, which is why the refresh rate (3.25/account) is below the first-fill rate (3.30).
**Do the annual arithmetic on the fastest field before you schedule it.** #5 at 1.50/account weekly across 4,100 accounts is ~6,200 credits *per week* — more than the entire first fill of every other field combined, and about 320,000 a year. Weekly cadence belongs on the segment where timing actually changes a decision (the 900 Tier-1 accounts, ~1,350/week), not on the full list. The cheapest version of a fast signal is a narrow audience, not a lower price.
**In Cargo, a live signal is a tracked column diff.** That is the mechanical link between Step 3's signal list and something that fires. Create the segment with the signal-bearing attributes as tracking columns, then read its delta feed:
```bash
cargo-ai segmentation segment create --name "ICP — signal watch" \
--model-uuid <companies-model-uuid> \
--filter '{"conjonction":"and","groups":[{"conjonction":"and","conditions":[
{"kind":"string","columnSlug":"icp_tier","operator":"is","values":["tier-1","tier-2"]}
]}]}' \
--tracking-column-slugs "eng_headcount_est,ai_tool_state,soc2_status"
cargo-ai segmentation change list --segment-uuid <segment-uuid>
cargo-ai segmentation change fetch --uuid <change-uuid> --kinds updated
```
Three flag traps in those four lines, each of which fails quietly or with a bare 400:
- **Conditions live inside `groups`, never beside them.** `{"conjonction":"and","groups":[]}` is the match-everything filter, so a top-level `conditions` array is ignored and the segment silently becomes the whole model — with the tracking and refresh spend that implies.
- **The spelling is `conjonction`.** Misspelled, it matches nothing without erroring.
- **`change fetch` takes `--uuid`** — the *change* UUID from `change list`, not the segment UUID — plus a required `--kinds`. See [`../../cargo-segmentation/SKILL.md`](../../cargo-segmentation/SKILL.md).
And `updatedRecordsCount` stays `0` unless `--tracking-column-slugs` was set **at creation** — the single most common way this whole motion silently produces nothing.
For volume-level watching ("tell me when more than 5 accounts pick up the signal this week"), add a model-scoped alert with a filter — [`../../cargo-observability/SKILL.md`](../../cargo-observability/SKILL.md). Preview it before creating it.
## Evidence rules
These are not stylistic. Each one prevents a specific wrong answer that scores well.
**Technology usage is not a boolean.** One engineer's profile, one job posting, and a company-wide mandate are different facts. Store the state, not `true`:
`company_standard` · `approved_tool` · `team_usage` · `individual_usage` · `pilot_or_evaluation` · `historical` · `none_found` · `unknown`
A single job posting establishes `individual_usage` at best — and a tool listed under "nice to have" establishes nothing at all. Use [`../references/prompt-library/data-extraction.md`](../references/prompt-library/data-extraction.md) → `technology-adoption-state` to classify the evidence rather than eyeballing it.
**Confidence is a band, and `Unknown` is a valid answer.** Confirmed (explicit, current, authoritative source) · Inferred (several consistent indirect sources, no contradiction) · Estimated (calculated from partial public data — numeric fields only) · Unknown (insufficient or contradictory). The first three are all storable, tagged with the band that produced them; anything that reaches none of them returns `Unknown` rather than a value — an unsupported assertion in a scoring column is a decision made on noise, and it is invisible once the value is written. `Estimated` means arithmetic on figures you actually have, not a plausible-sounding number.
**Store the evidence with the value** for anything above trivial cost. A quoted phrase and its URL in the companion column is what makes the value auditable six months later, and it is the difference between a rep trusting the field and ignoring it.
**Known false positives to name in the deliverable:** duplicated or stale job postings inflating hiring counts; leadership changes that carry no budget; funding earmarked for something unrelated; technology mentions that are historical; repository activity from a two-person open-source side project.
## First-party datapoints: separate list, separate model
Product usage, activation, billing history, and renewal risk are **unavailable for net-new accounts by definition** — a prospect has never logged in. Keep them out of the net-new schema entirely, and list them separately if they come up: they are excellent inputs to an *expansion* or *health* score over accounts that already exist ([`account-expansion.md`](account-expansion.md)), and mixing them into a prospecting score silently ranks customers above prospects.
**Website-visitor identification is not in that group.** `snitcher` de-anonymizes the companies browsing the seller's site, and most of them are cold — [`../provider-playbooks/snitcher.md`](../provider-playbooks/snitcher.md) calls identified visitors "the warmest cold segment there is" and files them in the SIGNAL stage beside job-change and funding. It belongs in a net-new schema. What it *isn't* is a sourceable attribute: you cannot fill it across a 4,100-account TAM, because it only exists for accounts that already visited. So it fails Step 4's gate on a different axis than the fields above — not "no source at any price" but **arrival-driven**, and only if the seller runs Snitcher's tracking script on their own site.
Treat it accordingly: a `last_seen` / `pages_viewed` column populated by the extractors, scored as a timing signal on the accounts that have it, and neutral (never negative) on the ones that don't — the same `Unknown`-scores-neutral rule as everywhere else, and the reason it can coexist with a sourced schema instead of skewing it. Watch the cost shape too: `searchSessions` is free, but the `fetchOrganisations` extractor bills **3 credits per identified company on every sync**, which is the most expensive line in this recipe if it is switched on for a high-traffic site without sizing the traffic first.
## Deliverable
What the user gets at the end: the costed shortlist table from Step 6, the cut list with reasons, the refresh cadence per field, and — once approved and filled — the columns, the segment, and the play. Write the schema itself back to the context repo so the next person inherits the reasoning instead of re-deriving it:
```bash
cargo-ai context runtime write --path icp/account-attributes.md \
--content '<the schema, its sources, probed hit rates, and the cut list>' \
--commit-message "Add custom account attribute schema"
```
The cut list is the most valuable half of that document. "We tested competitor-spend detection and there is no source at any price" saves the next person the same two days.
## Related
- [`source-planning.md`](source-planning.md) — sourcing **one** named field; Step 5 here is its probe loop.
- [`icp-discovery.md`](icp-discovery.md) — the same goal from the other end: derive fit from Closed-Won vs Closed-Lost instead of from public research. Run both when there are closed deals; their answers should agree.
- [`tech-intent.md`](tech-intent.md), [`funding-watch.md`](funding-watch.md) — two signals already built end-to-end; use them as the implementation template.
- [`save-as-play.md`](save-as-play.md) — turning the refresh cadence into something that runs.
- [`../references/prompt-library/index.md`](../references/prompt-library/index.md) — `custom-attribute-extraction`, `technology-adoption-state`, `icp-fit-score`, `signal-triage`.
- [`../references/cost-discipline.md`](../references/cost-discipline.md) — the approval shape Step 6 uses.
recipes/funding-watch.md
# Recipe — Track recently-funded companies for outbound timing
Use this recipe when the user wants to identify or monitor companies that recently raised funding. Funding events are one of the strongest outbound-timing signals — a fresh round means budget, hiring, and a willingness to evaluate new tools.
**Trigger phrases:**
- *"Find every fintech that raised in the last 90 days."*
- *"Which of our target accounts just got funded?"*
- *"Alert me when a company in my segment raises Series B or later."*
- *"Build a 'recently funded' segment for outbound."*
## Recipe
### Pattern A — Surface recent fundraises across a target segment
```bash
# 1. Pull the target accounts
cargo-ai storage model list # find the Companies model UUID
MODEL_UUID=...
cargo-ai segmentation segment fetch \
--model-uuid "$MODEL_UUID" \
--filter '{"conjonction":"and","groups":[{"conjonction":"and","conditions":[
{"kind":"string","columnSlug":"icp_tier","operator":"is","values":["tier-1","tier-2"]}
]}]}' > /tmp/targets.json
# 2. Pull funding + acquisition data — keyed on domain, no match step
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"enrichCrm","actionSlug":"getFunding"}' \
--records "$(jq -c '[.records[] | {domain}]' /tmp/targets.json)" \
--wait-until-finished > /tmp/funding.json
# 3. Filter to recent rounds (last 90 days)
# Same field Pattern B diffs on — confirm it once with get-output-schema.
jq -c --arg cutoff "$(date -v-90d -u +%Y-%m-%d 2>/dev/null || date -d '90 days ago' -u +%Y-%m-%d)" \
'[.results[] | select((.lastFundingDate // "") > $cutoff)]' \
/tmp/funding.json > /tmp/recent-funded.json
```
### Pattern B — Detect a *new* event on a known company (diff, not feed)
There is no since-timestamp event feed in the catalog. A "new round" is detected
by **diffing a fresh pull against what the Companies model already stores**, which
is why `last_funding_round_at` has to be a column before the watch is worth running:
This pattern stands alone — it does not depend on Pattern A's files.
```bash
# 1. The domains to watch, and the dates the workspace already holds for them.
# Both sides of the diff come from the same model, so they always align.
cargo-ai storage query execute \
"SELECT domain, last_funding_round_at FROM default.companies WHERE icp_tier IN ('tier-1','tier-2')" \
> /tmp/known-funding.json
# 2. Re-pull funding for exactly those domains
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"enrichCrm","actionSlug":"getFunding"}' \
--records "$(jq -c '[.rows[] | {domain}]' /tmp/known-funding.json)" \
--wait-until-finished > /tmp/fresh.json
# 3. Keep only rows whose latest round post-dates the stored value.
# Confirm the output field name first (free, runs nothing):
# cargo-ai orchestration action get-output-schema \
# --action '{"kind":"connector","integrationSlug":"enrichCrm","actionSlug":"getFunding","config":{}}'
jq -c --slurpfile known /tmp/known-funding.json '
($known[0].rows
| map({key: .domain, value: (.last_funding_round_at // "")})
| from_entries) as $stored
| [ .results[] | select((.lastFundingDate // "") > ($stored[.domain] // "")) ]
' /tmp/fresh.json > /tmp/new-rounds.json
```
A row with no stored date compares against `""` and always passes, which is the
right behaviour on first run and the reason step 4 must write `last_funding_round_at`
back — otherwise every run is a first run.
The diff is free; the re-pull is not. That is the cost shape the cadence below is
sized against.
### Pattern C — Recurring funding watch (play)
For continuous monitoring (e.g. weekly scan of target accounts):
1. Trigger: weekly cron.
2. Source: a saved segment of target accounts.
3. Action: `enrichCrm.getFunding`, gated to rows whose `last_funding_round_at` is older than the refresh window.
4. Output: write rows whose latest round post-dates the stored value to a "Recently Funded" signal segment.
5. Optional: post Slack notification per new funding event.
To make this recurring, follow [`save-as-play.md`](save-as-play.md) — it walks the tool-vs-play choice, the cadence defaults per signal, and the recurring-cost approval. Play mechanics: [`../../cargo-orchestration/references/examples/plays.md`](../../cargo-orchestration/references/examples/plays.md).
## Credit budget
| Pattern | Cost per record |
|---|---|
| `enrichCrm.getFunding` | 1 |
500 target accounts × 1 = 500 credits per scan. A **daily** cron over 30 days is 15,000 credits, and it is almost always wrong: rounds are announced on a scale of months, so the pull re-bills unchanged data 29 days out of 30.
Because there is no since-timestamp feed, cadence is the only cost dial. Default to **weekly**, and gate the node on `last_funding_round_at` so an account that raised recently is skipped until the window reopens.
## Surfacing the signal
The output of this recipe is a list of company records with funding events. Offer the user 2–3 of the moves below, **grounded in the rows just produced** (counts, balance, per-unit cost, a default pick — the next-step shape in [`../SKILL.md`](../SKILL.md) §4), never as a generic menu:
- **Outbound timing**: hand the list to a sequencer (lemlist / lgm / instantly) for a fresh-funding-triggered campaign — discover the launch action via `cargo-ai connection integration get lemlist` and run via `orchestration action execute-batch`.
- **CRM enrichment**: write a `last_funding_round_at` column on the Companies model, push to HubSpot via `hubspot.upsertRecords` (compose ad hoc — see [`build-tam.md`](build-tam.md) for the CRM-push pattern).
- **Sales notification**: post to Slack when a tier-1 account hits a funding milestone. Use `slack` connector or `http.call` for webhook patterns.
## Action shape
`{"kind":"connector","integrationSlug":"enrichCrm","actionSlug":"getFunding"}`. **No `connectorUuid` in `config`** — the single workspace connector resolves automatically.
## Output retrieval
For batch runs, use `cargo-ai orchestration run download-outputs --workflow-uuid <uuid> --output-node-slug <slug>`.
## Alternative provider
`getFunding` is the only credits-based funding action in the catalog. Where it misses, `companyEnrich.enrichByDomain` (0.25) carries a coarser funding block, and `peopleDataLabs.queryCompanies` (3, PDL SQL) can filter on investor and round fields directly — see [`portfolio-prospecting.md`](portfolio-prospecting.md).
## When stuck — file a workspace report
If a target company has known recent funding but `enrichCrm.getFunding` returns empty: file a `cargo-ai workspaceManagement report create` with the domain so the coverage gap is on record.
recipes/icp-discovery.md
# Recipe — Surface ICP signals from Closed-Won vs Closed-Lost
Use this recipe when the user wants to **discover their real ICP from conversion data**, not from gut feel. The recipe pulls Closed-Won and Closed-Lost segments via `storage query execute`, enriches both with the same firmographic and tech signals, and surfaces the features that differ most between them. Output: a ranked list of "high-fit signals" the user can use to filter prospecting.
**Trigger phrases:**
- *"What does our ideal customer look like?"*
- *"Find the patterns in our Closed-Won deals."*
- *"Why do we win against some prospects and lose against others?"*
- *"What ICP signals should we be filtering on?"*
## Why this is its own skill
Most prospecting skills are forward-looking ("find me X" / "enrich Y"). ICP discovery is **backward-looking** — analyze what worked, then turn the patterns into filters. It exercises:
- Storage (`cargo-ai storage query execute`) to pull Won/Lost segments.
- Enrichment (`aiArk.enrichCompany`, `builtwith.getDomainSummary`, `enrichCrm.getFunding`) to fill comparison signals.
- LLM analysis (`anthropic.instruct`) to surface non-obvious patterns.
## Recipe
### Step 1 — Identify the deal model and pull segments
```bash
# Find the model holding deals (usually a Deals or Opportunities model in the workspace)
cargo-ai storage model list
cargo-ai storage dataset list
# Optional — fetch the DDL for column types and SQL dialect
cargo-ai storage model get-ddl <deals-model-uuid>
```
Pull both segments via `storage query execute` (tables are referenced as `<datasetSlug>.<modelSlug>` and rewritten to the underlying storage table under the hood):
```bash
# Closed-Won deals + their associated companies
cargo-ai storage query execute "
SELECT d.uuid as deal_uuid, c.uuid as company_uuid, c.domain, c.name
FROM default.deals d
JOIN default.companies c ON d.company_uuid = c.uuid
WHERE d.stage = 'closed-won'
AND d.closed_at >= CURRENT_DATE - INTERVAL '12 months'
" > /tmp/won.json
# Closed-Lost deals + their associated companies
cargo-ai storage query execute "
SELECT d.uuid as deal_uuid, c.uuid as company_uuid, c.domain, c.name
FROM default.deals d
JOIN default.companies c ON d.company_uuid = c.uuid
WHERE d.stage = 'closed-lost'
AND d.closed_at >= CURRENT_DATE - INTERVAL '12 months'
" > /tmp/lost.json
```
(Swap `default` for the user's dataset slug if it differs, and adjust the stage filter to match the user's pipeline stage labels.)
### Step 2 — Enrich both segments with the SAME signals
Every action below keys on `domain`, so there is no match step — the domain from
step 1 is the join key throughout. Run the same set on both segments so the diff
is apples-to-apples:
```bash
for src in won lost; do
for pair in "aiArk:enrichCompany" "builtwith:getDomainSummary" "enrichCrm:getFunding"; do
slug="${pair%%:*}"; action="${pair##*:}"
cargo-ai orchestration action execute-batch \
--action "$(jq -nc --arg i "$slug" --arg a "$action" \
'{kind:"connector",integrationSlug:$i,actionSlug:$a}')" \
--records "$(jq -c '[.rows[] | {domain}]' /tmp/$src.json)" \
--wait-until-finished > /tmp/$src-$action.json
done
done
```
`builtwith.getDomainSummary` is free, so the technographic axis costs nothing to
add. `enrichCrm.getFunding` at 1/record is the expensive one — drop it when
funding stage isn't plausibly an ICP signal for this business.
### Step 3 — Diff feature distributions
For each feature (industry, size band, tech, funding stage, …), compute the % of Won vs % of Lost showing that feature, then sort by absolute difference. Largest deltas = strongest ICP signals.
This is best done in Python or via `anthropic.instruct` with structured output:
```bash
# Concatenate enrichment results into a structured comparison input
jq -s '{
won: [.[0].results[], .[1].results[], .[2].results[]] | group_by(.domain) | map(reduce .[] as $r ({}; . * $r)),
lost: [.[3].results[], .[4].results[], .[5].results[]] | group_by(.domain) | map(reduce .[] as $r ({}; . * $r))
}' \
/tmp/won-enrichCompany.json \
/tmp/won-getDomainSummary.json \
/tmp/won-getFunding.json \
/tmp/lost-enrichCompany.json \
/tmp/lost-getDomainSummary.json \
/tmp/lost-getFunding.json > /tmp/comparison.json
# Use anthropic to surface differentiating signals
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"anthropic","actionSlug":"instruct"}' \
--data '{
"model": "claude-sonnet-4-6",
"prompt": "Two arrays: Closed-Won companies and Closed-Lost companies. Compare feature distributions and surface the top 10 signals that differentiate Won from Lost. Return JSON: [{signal, won_rate, lost_rate, difference_pct, why_it_matters}]. Data: <paste /tmp/comparison.json>",
"output": {"type": "jsonSchema", "jsonSchema": {"type": "array", "items": {"type": "object"}}}
}' \
--wait-until-finished
```
For deal sets > 100 records, do the diff in Python directly — LLM is more reliable for pattern *interpretation* on small samples than for *aggregation* on large ones.
### Step 4 — Validate signals (optional)
For each surfaced signal, validate by running it as a filter against the Won segment and checking hit rate:
```bash
# Example: signal is "uses Snowflake" → confirm it's a catalog-recognized technology
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theirStack","actionSlug":"searchTechnologies"}' \
--data '{"fields":{"keywords":"snowflake"},"limit":1}' \
--wait-until-finished
```
If the technology is in theirStack's catalog and the Won-rate is significantly higher than Lost-rate, lock the signal in for prospecting.
### Step 5 — Encode signals as ICP filters
Take the top 5–10 signals and translate them into filter syntax for prospecting providers:
- Industry / size / geo → `salesNavigator.searchAccounts` filters.
- Tech stack → `theirStack.searchCompanies.techFields.technologies`.
- Funding range → `peopleDataLabs.queryCompanies` ES query.
Hand the encoded filters to `cargo-tam-build` to build the next prospecting list.
## Credit budget
| Step | Cost per Won/Lost record | Records (assume 100 each) | Subtotal |
|---|---|---|---|
| aiArk.enrichCompany | 0.01 | 200 | 2 |
| builtwith.getDomainSummary | 0 | 200 | 0 |
| enrichCrm.getFunding | 1 | 200 | 200 |
| anthropic.instruct (Sonnet, one call) | ~2 | 1 | 2 |
| **Total** | | | **~204 credits for full Won/Lost analysis on 200 deals** (~4 without the funding axis) |
The recipe is one-shot — run it once when the user wants to refine ICP, then use the output to drive prospecting going forward. Re-run quarterly to capture pipeline drift.
## Required inputs
Before executing, the agent needs:
1. The Deals / Opportunities model UUID (from `cargo-ai storage model list`).
2. The closed-won / closed-lost stage labels in the user's pipeline (often `closed-won` / `closed-lost` but may be `won` / `lost` / `unqualified`, etc.).
3. The lookback window (default: 12 months).
If any are missing, ask **once** before running — don't guess on the SQL.
## Action shape
`{"kind":"connector","integrationSlug":"<slug>","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
## Output retrieval
For batch enrichments, use `cargo-ai orchestration run download-outputs --workflow-uuid <uuid> --output-node-slug <slug>`.
## Output deliverable
The recipe's final output is a markdown table the agent presents to the user:
```
Top differentiating ICP signals (Won vs Lost):
| # | Signal | Won rate | Lost rate | Δ | Notes |
|---|--------|---------:|----------:|--:|-------|
| 1 | Headcount 50-200 | 78% | 22% | +56pp | Smaller mid-market converts better |
| 2 | Uses Snowflake | 64% | 18% | +46pp | Data-mature stack signal |
| 3 | Series B+ | 71% | 35% | +36pp | Funded pipeline = budget |
| ... | | | | | |
```
Plus a follow-up suggestion: "Want me to run `/cargo-tam-build` with these signals as the filter?"
## When stuck — file a workspace report
If `storage query execute` fails or the deal model schema is unfamiliar, file via `cargo-ai workspaceManagement report create`. See [`../../cargo-workspace-management/SKILL.md`](../../cargo-workspace-management/SKILL.md).
recipes/import-gtm-data.md
# Recipe — Import existing GTM data into Cargo
Use this recipe when the user arrives with GTM data built elsewhere — spreadsheet exports, CRM dumps, lists from another data tool — and wants it living in Cargo: rows in models, recurring enrichments as plays, prompts in reusable form.
**Trigger phrases:**
- *"I have all my lists in another tool — move them into Cargo."*
- *"Import this CSV of accounts/contacts."*
- *"Recreate my enrichment table here."*
- *"We're consolidating our GTM stack onto Cargo."*
**Principle:** import the *data* first (cheap, lossless), then rebuild the *logic* selectively (each recurring enrichment becomes a play only if it's still worth paying for). Never re-run paid enrichment on rows that already carry the values.
## Step 1 — Export from the source
Every GTM tool exports CSV; prefer it over API scraping. Get one CSV per logical entity (companies, contacts, deals). Ask the user to include **all** columns — enriched values (emails, titles, firmographics) come along free and mean less re-enrichment spend later.
**If the source tool can also export its table or workflow *schema*, get that too, and get it first.** The CSV is the output; the schema is the configuration, and only the schema says which provider filled a column, in what order, and under which run condition. None of that is recoverable from the results, so a rebuild in step 5 that starts from the CSV alone is inference rather than migration. For Clay tables the schema export exists and the Clay-specific path is its own recipe: [`clay-to-cargo.md`](clay-to-cargo.md).
## Step 2 — Map columns to a Cargo model
```bash
cargo-ai storage model list # existing Companies / Contacts models
cargo-ai storage model get-ddl <model-uuid> # column slugs + types
```
Diff the CSV header against the model's columns. Create what's missing (see [`../../cargo-storage/SKILL.md`](../../cargo-storage/SKILL.md) for types):
```bash
cargo-ai storage column create \
--model-uuid <uuid> \
--column '{"slug":"source_tool_id","type":"string","label":"Source Tool ID","kind":"custom"}'
```
**Always add a `source_tool_id` column** holding the source's record ID — it makes the import idempotent, dedupable, and diffable against later re-exports. Keep a scratch mapping table (CSV column → model column) in the conversation; you'll cite it in the receipt.
## Step 3 — Load the rows
Upload the CSV and drive a batch from it (full flow: `cargo-orchestration` → [`references/examples/tools.md`](../../cargo-orchestration/references/examples/tools.md)):
```bash
cargo-ai workspaceManagement file upload --file ./contacts-export.csv
# → returns s3-filename; use it as the batch input for the write-back workflow
```
Dedupe before writing: match on `source_tool_id` first, then email, then company domain — all three are free storage reads against the existing models. Rows with none of the three have no natural key; resolve them by enriching an identifier first (`aiArk.enrichCompany` at 0.01 from a domain, `aiArk.enrichPerson` at 0.1 from a LinkedIn URL), and that spend goes through the pilot gate.
## Step 4 — QA what actually landed (free)
Imported ≠ trustworthy: exports carry stale roles and unverified emails. Audit **the stored rows, not the source export** ([`../references/contact-accuracy.md`](../references/contact-accuracy.md)) — dedupe and column mapping in step 3 changed the set, so auditing `contacts-export.csv` grades rows that never landed and misses the transforms. Pull the loaded rows back out first:
```bash
# Export the just-loaded rows from the model (source_tool_id marks this import)
cargo-ai storage query download \
--query "SELECT * FROM <datasetSlug>.<modelSlug> WHERE source_tool_id IS NOT NULL"
# → returns a signed URL; save the file as ./loaded.csv
node <skill-dir>/scripts/validate-emails.ts --input ./loaded.csv --output ./culled.csv
node <skill-dir>/scripts/contact-accuracy-audit.ts --input ./culled.csv --output ./audited.csv
```
Report the SEND/VERIFY/REVIEW/REMOVE counts — then **write the verdicts back to the stored records** so downstream segments can act on them: create an `audit_action` column (same `--column` shape as step 2) and batch-upsert it from `./audited.csv` keyed on `source_tool_id`. Activation segments filter on `audit_action = "SEND"`; only the VERIFY bucket needs paid re-verification (`waterfall.verifyEmail`, 0.1/record — pilot-gate it); REMOVE rows stay stored but excluded from segments (bulk-delete only after the user reviews them).
## Step 5 — Rebuild recurring logic as plays (selective)
For each recurring enrichment/workflow in the source tool, decide with the user: **retire, keep manual, or rebuild**. For rebuilds:
1. Identify what each source column *did* (find email, enrich firmographics, score, personalize), from the schema you pulled in step 1 rather than by eyeballing the UI. For Clay, [`clay-to-cargo.md`](clay-to-cargo.md) carries the extraction paths and the column-family → action map; for everything else, work from the column's behaviour rather than its label.
2. Map it to the cheapest Cargo action for that stage — [`../references/stage-action-map.md`](../references/stage-action-map.md), then the provider's playbook (§11 gate applies).
3. LLM prompt columns: check [`../references/prompt-library/index.md`](../references/prompt-library/index.md) for a proven equivalent before porting the prompt text.
4. Compose the chain per the recipe spine and save it as a play — [`save-as-play.md`](save-as-play.md).
## Step 6 — Parity check (pilot-gated)
Before trusting a rebuilt play, run it on ~10 rows whose source-tool outputs are known and compare: coverage (found vs missing), agreement (same email/domain), and cost per row. Present the comparison table; the user decides whether parity is good enough to switch the play on. Disagreements on emails: the verified value wins, not the source.
## Credit budget
Import itself is ~free (storage writes). Spend concentrates in: dedupe matching for keyless rows (0.5/record), re-verification of the VERIFY bucket (0.1/record), and the parity pilot (~10 × play cost). Everything paid goes through the [`cost-discipline`](../references/cost-discipline.md) gate — state the three numbers before running.
## What this recipe deliberately doesn't do
No per-tool extraction scripts or action-name mappings — source tools change their internals without notice, and CSV export is universal. If a source tool's export is too limited, its API (with the user's own key) via the generic HTTP patterns in `cargo-orchestration` is the fallback.
**Clay is the one argued exception**, in [`clay-to-cargo.md`](clay-to-cargo.md): it is the incumbent this product most often replaces, "what is the equivalent of this Clay column" is asked often enough to be worth maintaining an answer to, and the universal advice actively costs accuracy there because a CSV export destroys exactly the per-row cost, waterfall order and run conditions the rebuild depends on. That recipe's action slugs and prices carry the same rule as everywhere else: they must agree with the provider playbooks, and the playbook wins.
recipes/job-change-monitoring.md
# Recipe — Detect job changes in a contact segment
Use this recipe when the user wants to detect job changes among a list of contacts. **The only provider in cargo's 136-integration catalog with a credits-based job-change action is `waterfall.detectJobChange`** — this recipe exists to make that capability discoverable and reusable.
**Trigger phrases:**
- *"Has anyone in our customer list changed jobs?"*
- *"Show me which contacts in the New Inbound segment have moved companies."*
- *"Track job changes for our top accounts."*
- *"Find all our champions who left their company."*
## Why this recipe exists
Job change is one of the highest-intent signals in B2B GTM:
- **MOVED contacts at target accounts** → re-engage at the new company; the relationship is warm.
- **MOVED contacts at customer accounts** → renewal / churn risk; the original champion is gone.
- **MOVED prospects in old segments** → trigger fresh outreach; previous reasons not to buy may no longer apply.
`waterfall.detectJobChange` returns one of: `MOVED`, `LEFT`, `NO_CHANGE`, `UNKNOWN` — plus updated person info when `MOVED`.
## Recipe
### Step 1 — Pull the contact segment
```bash
cargo-ai storage model list # find the Contacts model UUID
MODEL_UUID=...
cargo-ai segmentation segment fetch \
--model-uuid "$MODEL_UUID" \
--filter '{"conjonction":"and","groups":[{"conjonction":"and","conditions":[
{"kind":"string","columnSlug":"lifecycle_stage","operator":"is","values":["customer","champion"]}
]}]}' > /tmp/contacts.json
```
Adjust the filter to match the segment the user wants to monitor.
### Step 2 — Detect job changes
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"detectJobChange"}' \
--records "$(jq -c '[.records[] | {
professional_email: .email,
contact_linkedin: .linkedin_url,
company_domain: .company_domain
}]' /tmp/contacts.json)" \
--wait-until-finished > /tmp/job-changes.json
```
**Identifier strategy:** pass as many identifiers as you have. Best coverage: `contact_linkedin` + `company_domain`. Email-only inputs often return `UNKNOWN`.
### Step 3 — Filter to MOVED contacts
```bash
jq -c '[.results[] | select(.status == "MOVED")]' /tmp/job-changes.json > /tmp/moved.json
```
The `MOVED` rows include the **new** company and (sometimes) the new title. Use these to:
- Update the contact's `current_company` column in the cargo Contacts model.
- Surface as a "Job Changes — Last 30 Days" segment for outbound timing.
- Write a Slack notification per MOVED row.
### Step 4 — (Optional) Write back to the model
If a `current_company` or `last_job_change_at` column exists on the Contacts model:
```bash
# Use cargo-ai storage / segment patterns to upsert.
# See ../../cargo-storage/SKILL.md.
```
For pushing the MOVED set to a CRM (HubSpot custom property, Salesforce field), compose ad hoc with `hubspot.upsertRecords` / `salesforce.upsert` — discover the action via `cargo-ai connection integration get hubspot` and run it via `orchestration action execute-batch`.
## Recurring monitoring (cron / play)
For continuous monitoring (e.g. weekly job-change scan), build a play:
1. Trigger: weekly cron.
2. Source: a saved segment of contacts to monitor.
3. Action node: `waterfall.detectJobChange`.
4. Output: write `MOVED` rows to a "Job Changes — Recent" segment.
To make this recurring, follow [`save-as-play.md`](save-as-play.md) — it walks the tool-vs-play choice, the cadence defaults per signal, and the recurring-cost approval. Play mechanics: [`../../cargo-orchestration/references/examples/plays.md`](../../cargo-orchestration/references/examples/plays.md).
## Credit budget
`waterfall.detectJobChange` is 3 credits per record. Run sparingly:
| Volume | Cost |
|---|---|
| 100 contacts | 300 credits |
| 500 contacts | 1,500 credits |
| 1,000 contacts | 3,000 credits |
For weekly monitoring on a 1,000-contact segment: ~12,000 credits/month. Filter aggressively before running — only monitor segments where job changes are actionable (champions, customers, high-priority prospects).
## Action shape
`{"kind":"connector","integrationSlug":"waterfall","actionSlug":"detectJobChange"}`. **No `connectorUuid` in `config`.**
Per-record inputs (any combination):
- `professional_email` — work email.
- `personal_email` — alternative.
- `company_domain` — improves matching accuracy.
- `company_linkedin` — LinkedIn company URL.
- `contact_linkedin` — **highest-coverage identifier when combined with `company_domain`**.
Pass as many as you have. More identifiers = better coverage.
## Output retrieval
For batch runs, use `cargo-ai orchestration run download-outputs --workflow-uuid <uuid> --output-node-slug <slug>` to retrieve results. See [`../references/output-retrieval.md`](../references/output-retrieval.md).
## Cargo-unique strength
No other provider in the cargo catalog has a credits-based job-change action. `waterfall.detectJobChange` is unique. This recipe is one of the differentiators when comparing cargo's outcome catalog to peer GTM platforms.
recipes/linkedin-url-lookup.md
# Recipe — LinkedIn URL lookup with strict identity validation
**Use when**: the user has a name and company (or email) and needs the correct LinkedIn profile URL.
**Trigger phrases**: "Find the LinkedIn for John Smith at Acme.", "Get LinkedIn URLs for all the contacts in this list."
## Why this recipe is its own thing
LinkedIn URL resolution is the single most error-prone enrichment task. Common ways naive flows fail:
- Same first+last name, different person, wrong company → false positive.
- Person changed jobs and the resolver returns the old company.
- Resolver returns a partial / contractor profile instead of the FTE.
- Provider has stale data and returns a profile that no longer exists.
The fix is **strict cross-validation**: never trust the first hit. Always verify identity by enriching the candidate URL and checking the company match.
## Recipe
### Step 1 — Resolve a candidate URL
Use `linkedin.findProfileUrl` (0.25 cred) — cheapest credible source.
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"linkedin","actionSlug":"findProfileUrl"}' \
--records '[
{"fullName":"John Smith","companyName":"Acme"},
...
]' \
--wait-until-finished > /tmp/candidates.json
```
### Step 2 — Validate by enriching the candidate profile
Run `linkedin.enrichProfile` (0.25 cred) on the candidate URL. Compare the returned company against the input company.
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"linkedin","actionSlug":"enrichProfile"}' \
--records "$(jq -c '[.results[] | {linkedinUrl: .url}]' /tmp/candidates.json)" \
--wait-until-finished > /tmp/enriched.json
```
### Step 3 — Apply the validation gate
A candidate is **valid** only if **all** of these hold:
1. The enriched profile's `currentCompany.name` or `currentCompany.domain` matches the input company (case-insensitive, allow common variations like "Inc", "GmbH", "Ltd" stripped).
2. The enriched profile's name matches the input first+last name (case-insensitive; allow accents normalized).
3. The profile's `currentRole.startDate` is more recent than 1990 (sanity check that the profile is real and active).
If any check fails, **reject the candidate** rather than guess.
### Step 4 — Fallback for rejected candidates
For rejected candidates, escalate via reverse-email lookup (only useful if you have an email):
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"FullEnrich","actionSlug":"reverseEmailLookup"}' \
--records '[{"email":"john.smith@acme.com"}, ...]' \
--wait-until-finished > /tmp/reverse.json
```
`reverseEmailLookup` returns LinkedIn URL alongside a company match — apply the same validation gate from step 3.
If that also fails: mark the row as "unresolved" and surface to the user. Do **not** return a low-confidence URL.
### Step 5 — Output the validated set
Only the rows that passed the validation gate get written back. Mark unresolved rows explicitly so the user can decide whether to research manually.
## Credit budget
| Per validated contact | Cost |
|---|---|
| `linkedin.findProfileUrl` | 0.25 |
| `linkedin.enrichProfile` (validation) | 0.25 |
| `FullEnrich.reverseEmailLookup` (fallback, ~30% of cases) | 2 × 0.3 = 0.6 |
| **Effective: ~1.1 cred per resolved contact** (with ~80% resolution rate) |
## Common pitfalls
- **Don't skip step 3.** A first-pass `findProfileUrl` hit rate is ~70%; an unvalidated rate is ~50% (false positives bring it down). Validation gate is mandatory.
- **Don't normalize the company name aggressively.** "Acme Corp" matching "Acme Inc" is fine; "Acme Software" matching "Acme Pharmaceuticals" is not — keep the suffix awareness loose, the noun-phrase strict.
- **Don't accept candidates with `currentCompany == null`.** That usually means the person is between jobs; the LinkedIn profile may be stale or the resolver's match was wrong.
## Action shape rules
`{"kind":"connector","integrationSlug":"linkedin","actionSlug":"findProfileUrl"}`. **No `connectorUuid` in config.** Per-record data: `fullName` (required), `companyName` (optional — improves matching). If the source data has separate first/last columns, concatenate them into `fullName` first (see [`../provider-playbooks/linkedin.md`](../provider-playbooks/linkedin.md)).
recipes/lost-deal-revival.md
# Recipe — Revive Closed-Lost deals when the original blocker is gone
Use this recipe when the user wants to systematically revisit **Closed-Lost CRM deals** and only re-engage the ones where the *original lost-reason* is no longer relevant. Tighter scope than [`re-engagement.md`](re-engagement.md): input is explicitly Closed-Lost deals from the CRM (HubSpot, Salesforce, etc.), and the scan branches on `lost_reason`.
**Trigger phrases:**
- *"Revisit Closed-Lost deals where the champion left."*
- *"Find lost deals worth reopening — anyone who lost on budget but just got funded?"*
- *"Replay our Closed-Lost pipeline against current signals."*
- *"Which lost deals are revivable this quarter?"*
## Why this recipe exists
Most Closed-Lost deals stay lost. But specific lost-reason categories have specific revival triggers:
| `lost_reason` | Revival trigger |
|---|---|
| `champion_left` / `no_decision_maker` | Original contact moved to a new company (`waterfall.detectJobChange`) — warm intro at the new account. |
| `price` / `budget` / `no_budget` | Company raised a fresh round (`enrichCrm.getFunding`, compared against the deal's close date). |
| `wrong_time` / `timing` | A re-org or new exec hire (`salesNavigator.searchLeads` with `seniority: ["VP+", "C-Level"]` filter, joined date < 90d). |
| `feature_gap` / `missing_feature` | Manual — replay based on your product release date vs. deal close date. |
| `competitor_won` | Annual revisit at renewal time — check competitor satisfaction signals if available. |
The recipe runs each branch only against deals with the matching reason, keeping credit spend bounded.
## Recipe
### Step 1 — Pull Closed-Lost deals from the CRM
```bash
cargo-ai storage model list # find the Deals / Opportunities model UUID
DEALS_MODEL=...
cargo-ai segmentation segment fetch \
--model-uuid "$DEALS_MODEL" \
--filter '{"conjonction":"and","groups":[{"conjonction":"and","conditions":[
{"kind":"string","columnSlug":"stage","operator":"is","values":["Closed Lost"]},
{"kind":"date","columnSlug":"closed_at","operator":"olderThan","values":["90d"]}
]}]}' > /tmp/lost-deals.json
```
The 90-day floor avoids re-touching deals while they're still mentally fresh with the buyer. Adjust to match the workspace's cooling convention.
### Step 2 — Branch by `lost_reason`
```bash
jq -c '[.records[] | select(.lost_reason == "champion_left" or .lost_reason == "no_decision_maker")]' /tmp/lost-deals.json > /tmp/lost-champion.json
jq -c '[.records[] | select(.lost_reason == "price" or .lost_reason == "budget" or .lost_reason == "no_budget")]' /tmp/lost-deals.json > /tmp/lost-budget.json
jq -c '[.records[] | select(.lost_reason == "wrong_time" or .lost_reason == "timing")]' /tmp/lost-deals.json > /tmp/lost-timing.json
```
### Step 3a — Champion-left branch: detect job changes
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"detectJobChange"}' \
--records "$(jq -c '[.[] | {
professional_email: .primary_contact_email,
contact_linkedin: .primary_contact_linkedin,
company_domain: .account_domain
}]' /tmp/lost-champion.json)" \
--wait-until-finished > /tmp/champion-changes.json
# Keep MOVED rows — the contact is at a new (target) company
jq -c '[.results[] | select(.status == "MOVED")]' /tmp/champion-changes.json > /tmp/revive-champion.json
```
### Step 3b — Budget branch: detect fresh funding
There is no since-timestamp event feed in the catalog, so "fresh round" is a
**diff**: pull current funding data, then keep the accounts whose latest round
post-dates the deal's close date.
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"enrichCrm","actionSlug":"getFunding"}' \
--records "$(jq -c '[.[] | {domain: .account_domain}]' /tmp/lost-budget.json)" \
--wait-until-finished > /tmp/budget-funding.json
# Keep deals where a funding round closed AFTER the original deal lost, and
# carry deal_id through — step 4 merges on it and getFunding does not return it.
jq -c --slurpfile lost /tmp/lost-budget.json '
($lost[0] | map({key: .account_domain, value: .}) | from_entries) as $deal
| [ .results[]
| . as $f
| $deal[$f.domain]
| select($f.lastFundingDate > (.closed_at // "9999"))
| {deal_id, account_domain: $f.domain, lastFundingDate: $f.lastFundingDate} ]
' /tmp/budget-funding.json > /tmp/revive-budget.json
```
### Step 3c — Timing branch: detect new exec hires at the account
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"salesNavigator","actionSlug":"searchLeads"}' \
--records "$(jq -c '[.[] | {
company_domain: .account_domain,
seniority: ["VP+", "C-Level"],
function: ["Sales","Revenue Operations","Engineering"],
joined_within_days: 90
}]' /tmp/lost-timing.json)" \
--wait-until-finished > /tmp/timing-execs.json
# Keep accounts with at least one fresh exec hire
jq -c '[.results[] | select((.leads // []) | length > 0)]' /tmp/timing-execs.json > /tmp/revive-timing.json
```
Adjust the function list to match where your buyer typically sits.
### Step 4 — Merge into a single revive segment
```bash
# `inputs` is a generator, not an array — slurp it before indexing.
jq -c -n '[inputs] as $in
| ([$in[0][] | {deal_id: .deal_id, account_domain: .company_domain, revival: "champion_changed", details: .new_company}] +
[$in[1][] | {deal_id: .deal_id, account_domain: .account_domain, revival: "fresh_funding", details: {lastFundingDate: .lastFundingDate}}] +
[$in[2][] | {deal_id: .deal_id, account_domain: .company_domain, revival: "new_exec", details: .leads[0]}])
' /tmp/revive-champion.json /tmp/revive-budget.json /tmp/revive-timing.json > /tmp/lost-revival.json
```
### Step 5 — Hand off to outreach activation
Pass `/tmp/lost-revival.json` to [`outreach-activation.md`](outreach-activation.md). The `revival` field becomes the `signal_summary` input to the personalization prompt — *"They lost on budget, but just raised a $40M Series B"* writes much better cold-email copy than a generic signal.
## Recurring scan (cron / play)
For ongoing revival:
1. Trigger: monthly cron (lost deals don't churn signals fast enough for weekly).
2. Source: Closed-Lost deals segment with `closed_at older than 90d`.
3. Nodes: branch by `lost_reason` → run matching detector → union → write to "Lost — revival candidates" segment.
For play setup, see [`../../cargo-orchestration/references/examples/plays.md`](../../cargo-orchestration/references/examples/plays.md).
## Credit budget
For a 300-deal Closed-Lost cohort, scanned monthly:
| Branch | Per record | Records (assumed 1/3 each) | Subtotal |
|---|---|---|---|
| `waterfall.detectJobChange` | 3 | 100 | 300 |
| `enrichCrm.getFunding` | 1 | 100 | 100 |
| `salesNavigator.searchLeads` | 2 | 100 | 200 |
| **Total monthly** | — | 300 | **600** |
Much cheaper than the broader [`re-engagement.md`](re-engagement.md) scan because each branch only runs on the relevant subset.
## Action shape
Every action follows: `{"kind":"connector","integrationSlug":"<slug>","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`** — see [`../../cargo-orchestration/references/examples/actions.md`](../../cargo-orchestration/references/examples/actions.md).
## Output retrieval
For batch runs, use `cargo-ai orchestration run download-outputs --workflow-uuid <uuid> --output-node-slug <slug>`. See [`../references/output-retrieval.md`](../references/output-retrieval.md).
## Related
- [`re-engagement.md`](re-engagement.md) — broader: any stale contact, not specifically Closed-Lost deals.
- [`icp-discovery.md`](icp-discovery.md) — upstream: surfaces *why* deals are being lost (Closed-Won vs Closed-Lost diff), which informs the lost-reason categorization here.
- [`outreach-activation.md`](outreach-activation.md) — downstream: turns the revival segment into send-ready outreach.
recipes/outreach-activation.md
# Recipe — Activate a signal segment as personalized outreach
Use this recipe when the user has a signal-driven segment ready (recent fundraise, job change, tech intent, ICP-fit accounts, etc.) and wants to turn it into **send-ready outreach** — enriched contacts, LLM-personalized variables, handed off to their sequencer or CRM. Bridges the [`../guides/writing-outreach.md`](../guides/writing-outreach.md) guide to actual execution.
**Trigger phrases:**
- *"Take this segment and write outreach for it."*
- *"Personalize a first-touch email for every contact in the recently-funded segment."*
- *"Build a sequence-ready list from job changes this week."*
- *"Generate first lines for the tech-intent companies."*
## Before you start — basis, suppression, relevance
Blocking, and all three are free. Full spec: [`../references/acceptable-use.md`](../references/acceptable-use.md).
1. **Ask which basis applies** — existing customers, opted-in contacts, event attendees, or a documented legitimate-interest case for this B2B role. A work email in the record is not itself a basis. Purchased lists and data taken from a platform in breach of its terms are a stop.
2. **Subtract suppression before you enrich.** Filter the segment on the workspace's unsubscribe / do-not-contact / hard-bounce columns *first* — it protects the people who opted out and it stops you paying to enrich rows you can't use. No such column? Flag it as a real gap and offer to add one; don't proceed silently.
3. **Name the per-recipient reason.** The signal that built the segment is usually it. If the honest answer is "they matched an industry filter", the list isn't ready — tighten it before spending.
This recipe ends at send-ready variables. The user's sequencer sends, under its own limits, domains, and identities; the copy it sends needs an honest sender and subject, a working opt-out, and a postal address where required.
## Why this recipe exists
Signal recipes (`funding-watch`, `job-change-monitoring`, `tech-intent`, `portfolio-prospecting`) all produce a segment. They stop at *"here's a list with a signal."* The next step — enrich, personalize, hand off — has the same shape regardless of the signal. This recipe captures that shape once.
The handoff target is the workspace's sequencer of choice (Outreach, Salesloft, Apollo, HubSpot Sequences, Salesforce Cadences). The recipe stops at "send-ready variables" and points at `cargo-ai connection integration get <slug>` for the final push. Cargo's own mailboxes are a fourth option for that final push — see [`../../cargo-mailbox-management/SKILL.md`](../../cargo-mailbox-management/SKILL.md); the gates in [`../references/acceptable-use.md`](../references/acceptable-use.md) are identical either way, and a Cargo-owned mailbox adds its own volume ceiling (the warm-up ramp) on top of them.
## Recipe
### Step 1 — Pull the signal segment
```bash
cargo-ai storage model list # find Companies / Contacts model UUID
MODEL_UUID=...
cargo-ai segmentation segment list # find the signal segment, e.g. "Recently Funded — last 30d"
cargo-ai segmentation segment fetch \
--model-uuid "$MODEL_UUID" \
--filter '{"conjonction":"and","groups":[{"conjonction":"and","conditions":[
{"kind":"string","columnSlug":"signal","operator":"is","values":["funding","job_change"]}
]}]}' > /tmp/signal-segment.json
```
### Step 2 — Resolve the right contacts
If the segment is company-level (e.g. recently funded), pull target personas at each account:
```bash
# Use salesNavigator for precision, peopleDataLabs for scale.
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"salesNavigator","actionSlug":"searchLeads"}' \
--records "$(jq -c '[.records[] | {
company_domain: .domain,
title_keywords: ["VP", "Director", "Head"],
function: ["Sales", "Revenue Operations"]
}]' /tmp/signal-segment.json)" \
--wait-until-finished > /tmp/contacts.json
```
If the segment is already contact-level (e.g. job-change MOVED rows), skip this step — use those rows directly.
### Step 3 — Enrich each contact (email + LinkedIn + firmographics)
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"enrichProspectDetails"}' \
--records "$(jq -c '[.results[] | {
first_name, last_name,
company_domain: .company_domain,
contact_linkedin: .linkedin_url
}]' /tmp/contacts.json)" \
--wait-until-finished > /tmp/enriched.json
```
Waterfall returns the best-coverage `email`, `phone`, and a normalized contact profile. For premium contact data (mobile direct dials, top-tier accuracy), swap in `FullEnrich.enrichPerson`.
### Step 4 — Verify emails before personalizing
Cheap insurance against bounces and sender-reputation damage. Free cull → paid verify on the survivors only → merge statuses back → audit **all** rows → keep SEND:
```bash
# 4a. FREE pre-cull (QA scripts: ../references/contact-accuracy.md; Node >= 22.18;
# execute-batch output is accepted directly)
node <skill-dir>/scripts/validate-emails.ts --input /tmp/enriched.json --json > /tmp/culled.json
# 4b. Paid verification — build the batch from the CULLED rows, never the
# original list (that's where the credit saving happens)
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"verifyEmail"}' \
--records "$(jq -c '[.[] | select(.recommendation != "skip") | {email}]' /tmp/culled.json)" \
--wait-until-finished > /tmp/verified.json
# 4c. Merge statuses back onto ALL culled rows — do NOT pre-filter to "valid":
# the audit needs the catch-all/unknown/invalid rows to issue
# VERIFY/REVIEW/REMOVE verdicts (and the receipt needs their counts).
# Join on the LOWERCASED email — verify results may re-case the address,
# and a missed join leaves emailStatus empty (row degrades to VERIFY).
# Read .email_status (waterfall.verifyEmail output schema) — not .status.
jq -c --slurpfile ver /tmp/verified.json '
($ver[0].results | map({key: (.email | ascii_downcase), value: .email_status}) | from_entries) as $st
| map(. + {emailStatus: ($st[(.email // "" | ascii_downcase)] // "")})
' /tmp/culled.json > /tmp/merged.json
# 4d. Audit, then hand ONLY the SEND rows to the next steps — this file is
# what steps 5 and 6 read
node <skill-dir>/scripts/contact-accuracy-audit.ts --input /tmp/merged.json --json > /tmp/audited.json
jq '[.[] | select(.audit_action == "SEND")]' /tmp/audited.json > /tmp/deliverable.json
```
### Step 5 — Generate a personalized first line per contact
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"anthropic","actionSlug":"instruct"}' \
--records "$(jq -c '[.[] | {
model: "claude-3-5-haiku-latest",
advancedSettings: {temperature: 0.3, maxTokens: 1024},
prompt: ("You are writing the opening line of a first-touch email. The recipient is " + .first_name + " " + .last_name + ", " + .title + " at " + .company_name + ". Signal triggering this outreach: " + .signal_summary + ". Write ONE sentence that references the signal naturally and ties it to a relevant business outcome. No greeting. No follow-up. ≤30 words.")
}]' /tmp/deliverable.json)" \
--wait-until-finished > /tmp/personalized.json
```
`model` is a **required input**, so it belongs in every record alongside `prompt` — the action carries no `config` at all. Put it in `config` and newer backends drop it silently, billing the call at the default model.
More proven prompts (subject lines, follow-ups, job-change angles): [`../references/prompt-library/index.md`](../references/prompt-library/index.md).
For higher quality at higher cost, swap `claude-3-5-haiku-latest` for `claude-sonnet-4-6`. For ~30× cheaper at scale: `openAi` with `gpt-5-nano` (0.006 credits/1k tokens vs Haiku's 0.2) — see [`../provider-playbooks/openAi.md`](../provider-playbooks/openAi.md) for the full tier table.
### Step 6 — Hand off to the sequencer
Compose the send-ready payload — one row per contact with email, signal, and the personalized first line:
```bash
# deliverable.json is the audited SEND array; personalized.json is batch output
# ({results: [...]}) in the same order — zip them by index
jq -n --slurpfile d /tmp/deliverable.json --slurpfile p /tmp/personalized.json '
[range(0; ($d[0] | length)) as $i
| {email: $d[0][$i].email, first_line: $p[0].results[$i].text, signal: $d[0][$i].signal_summary}]
' > /tmp/send-ready.json
```
Then push to the user's sequencer. Discover the action via:
```bash
cargo-ai connection integration get outreach # Outreach.io
cargo-ai connection integration get salesloft # Salesloft
cargo-ai connection integration get hubspot # HubSpot Sequences
cargo-ai connection integration get salesforce # Salesforce Cadences
```
Then execute the discovered action with `orchestration action execute-batch`, passing the per-contact payload. **Do not invent `actionSlug` values** — list them from the integration first.
## Recurring activation (cron / play)
For ongoing signal-driven outreach:
1. Trigger: weekly cron on the signal segment.
2. Workflow nodes: signal-segment → enrich → verify → personalize → sequencer push.
3. Source: the saved signal segment (e.g. "Recently Funded — last 30d").
4. Output: send-ready payload + sequence-add action.
For play setup, see [`../../cargo-orchestration/references/examples/plays.md`](../../cargo-orchestration/references/examples/plays.md).
## Credit budget
For a 500-contact signal segment (waterfall + verify + Haiku personalization):
| Step | Per record | 500 contacts |
|---|---|---|
| `waterfall.enrichProspectDetails` | 1 | 500 |
| `waterfall.verifyEmail` | 0.5 | 250 |
| `anthropic.instruct` (Haiku) | 0.2 | 100 |
| **Total** | **1.7** | **850** |
Cut personalization ~30× by switching to `openAi.instruct` with `gpt-5-nano` (0.006/1k tokens).
## Action shape
Every action follows: `{"kind":"connector","integrationSlug":"<slug>","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`** — see [`../../cargo-orchestration/references/examples/actions.md`](../../cargo-orchestration/references/examples/actions.md). Cross-node interpolation in node graphs: `{{nodes.<slug>.<field>}}`.
## Output retrieval
For batch runs, use `cargo-ai orchestration run download-outputs --workflow-uuid <uuid> --output-node-slug <slug>`. See [`../references/output-retrieval.md`](../references/output-retrieval.md).
## Related
- [`writing-outreach.md`](../guides/writing-outreach.md) — provider routing, prompt patterns, model selection.
- Upstream signal recipes that produce input segments for this recipe: [`funding-watch.md`](funding-watch.md), [`job-change-monitoring.md`](job-change-monitoring.md), [`tech-intent.md`](tech-intent.md), [`portfolio-prospecting.md`](portfolio-prospecting.md).
recipes/portfolio-prospecting.md
# Recipe — Investor portfolio → contacts → outbound
Use this recipe when the user wants to **prospect into the portfolio of a specific investor or accelerator**. Common pattern: a partner / accelerator program is a known proxy for ICP fit, so all their portfolio companies are pre-qualified.
**Trigger phrases:**
- *"Find every company backed by Sequoia and reach out to their CTOs."*
- *"Prospect into the YC W26 batch."*
- *"Build a list of CFO contacts at all Insight Partners portfolio companies."*
- *"Show me every founder funded by First Round Capital."*
## Why this is its own skill
Portfolio prospecting has a specific shape that doesn't fit the generic prospecting pipeline:
- The sourcing filter (investor name, fund, batch) isn't expressible in salesNavigator's UI-style filters.
- `peopleDataLabs.queryCompanies` is the right tool — its **SQL** API can filter on investor / funding fields that no other priority-stack provider exposes.
- Once portfolio companies are sourced, you typically want a tight per-company contact cap (1–3 prospects per portfolio company) — different from generic at-scale lead search.
## Recipe
### Step 1 — Source portfolio companies via investor filter (PDL SQL)
`queryCompanies` accepts a SQL string — array-membership filters like investor name require SQL (cargo's `{conjonction, groups, conditions}` filter shape can't express `summary.investors LIKE %X%`).
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"peopleDataLabs","actionSlug":"queryCompanies"}' \
--data '{
"query": "SELECT * FROM company WHERE summary.investors LIKE %Sequoia Capital%",
"limit": 200
}' \
--wait-until-finished > /tmp/portfolio.json
```
For accelerator batches (e.g. YC W26), the investor field still works — accelerators are stored in `summary.investors` alongside VCs. Common SQL fields useful here:
| PDL SQL field | Use for |
|---|---|
| `summary.investors` | Investor / accelerator filter (use `LIKE %Name%`) |
| `latest_funding_stage` | Stage filter (e.g. `'series_b'`, `'seed'`) |
| `total_funding_raised` | Total funding raised (range query) |
| `industry` | Industry filter |
| `employee_count` | Headcount range |
| `location.country` / `location.locality` | Geography |
| `tags` | Topic tags |
See PDL's SQL reference for the full schema.
### Step 2 — Dedupe the portfolio against the workspace (free)
```bash
cargo-ai storage query execute "SELECT domain FROM default.companies" > /tmp/known.json
jq -c --slurpfile known /tmp/known.json \
'[.results[] | {domain: .website} | select(.domain as $d
| ($known[0].rows // [] | map(.domain)) | index($d) | not)]' \
/tmp/portfolio.json > /tmp/new-portfolio.json
```
### Step 3 — Enrich firmographics on the portfolio
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"enrichCompany"}' \
--records "$(cat /tmp/new-portfolio.json)" \
--wait-until-finished > /tmp/firmo.json
```
### Step 4 — Find contacts at each portfolio company
Cap tightly — for portfolio prospecting, 1–3 contacts per company is usually right. Targets are typically founder / CEO / role-of-interest.
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"salesNavigator","actionSlug":"searchLeads"}' \
--records "$(jq -c '[.results[] | {
keywords: "Founder OR CEO OR CTO",
company: {linkedinIds: [.linkedinId]},
limit: 3
}]' /tmp/portfolio.json)" \
--wait-until-finished > /tmp/contacts.json
```
### Step 5 — Find emails
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"FullEnrich","actionSlug":"findEmail"}' \
--records "$(jq -c '[.contacts[] | {firstName, lastName, domainName: .companyDomain, linkedinUrl: .linkedinUrl}]' /tmp/contacts.json)" \
--wait-until-finished > /tmp/emails.json
```
### Step 6 — Verify emails
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"verifyEmail"}' \
--records "$(jq -c '[.results[] | select(.email) | {email}]' /tmp/emails.json)" \
--wait-until-finished > /tmp/verified.json
```
### Step 7 — Personalize outbound (optional)
Use `linkedin.extractProfilePostActivity` (0.05/item) to pull recent LinkedIn posts, then `anthropic.instruct` for a personalized opener referencing the investor + recent portfolio activity. See [`../guides/writing-outreach.md`](../guides/writing-outreach.md) for prompt patterns.
## Credit budget
For 200 portfolio companies × 3 contacts each = 600 prospects:
| Step | Cost per record | Records | Subtotal |
|---|---|---|---|
| 1. queryCompanies (single call returning 200) | — | 1 call | 3 |
| 2. Dedupe against the Companies model | 0 | 200 | 0 |
| 3. aiArk.enrichCompany | 0.01 | 200 | 2 |
| 4. searchLeads (3 contacts each) | 0.02 | 600 | 12 |
| 5. FullEnrich.findEmail | 1 | 600 | 600 |
| 6. waterfall.verifyEmail | 0.1 | 600 | 60 |
| **Total** | | | **~677 credits for 600 verified contacts at 200 portfolio companies** |
## Discovery sequence
```bash
# Confirm priority connectors
for slug in peopleDataLabs salesNavigator FullEnrich waterfall cargo; do
cargo-ai connection connector list --integration-slug "$slug" \
| jq -e '.connectors | length > 0' > /dev/null \
&& echo "✓ $slug" || echo "✗ $slug"
done
```
## Action shape
`{"kind":"connector","integrationSlug":"<slug>","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
## Output retrieval
After each batch step, use `cargo-ai orchestration run download-outputs --workflow-uuid <uuid> --output-node-slug <slug>` for output data.
## When the investor isn't in peopleDataLabs
If `peopleDataLabs.queryCompanies` doesn't recognize the investor name (e.g. very small fund, regional accelerator), fall back to:
- `apolloio.enrichOrganization` / `enrichPerson` (1) — the priority stack's niche-coverage rung, and the standing example of it: Apollo's investor coverage is often stronger here than the generalist chain.
- `firecrawl.scrape` on the investor's portfolio page (if public) → LLM extract via `anthropic.instruct`.
- File a `cargo-ai workspaceManagement report create` if neither works — surfaces the gap to the cargo team.
## When stuck — file a workspace report
See [`../../cargo-workspace-management/SKILL.md`](../../cargo-workspace-management/SKILL.md) (Reports section).
recipes/prospecting.md
# Recipe — Prospecting (find → enrich → verify → sync)
**Use when**: the user states an end-to-end sourcing goal — find people matching a description, enrich them, verify emails, and prepare them for outreach. Cargo's flagship pipeline.
**Trigger phrases**:
- *"Find me 5 fintech CTOs in NYC and verify their emails."*
- *"Build me a list of seed-stage SaaS founders in the US."*
- *"Source 200 RevOps leaders at companies hiring data engineers."*
- *"Enrich these 100 domains and find a contact at each."*
For sourcing-only / TAM list builds, see [`build-tam.md`](build-tam.md). For investor-portfolio outbound, see [`portfolio-prospecting.md`](portfolio-prospecting.md). For the writing-outreach phase that follows this recipe, see [`../guides/writing-outreach.md`](../guides/writing-outreach.md).
## Pipeline spine
```
1. SOURCE → salesNavigator.searchLeads / searchAccounts (0.02–0.05/record)
2. DEDUPE → match against the workspace's own Contacts / Companies models
on linkedin_url / domain (storage SQL or a segment filter) (free)
3. ENRICH → LinkedIn URL in hand? aiArk.enrichPerson (0.1) FIRST — profile + verified email
aiArk.enrichCompany (0.01) for firmographics
+ waterfall.enrichContact / enrichCompany (1–2/record)
+ apolloio.enrichPerson / enrichOrganization on the residue (1/record)
4. SIGNAL → enrichCrm.getFunding (1/record)
+ theirStack.searchJobs (0.5/record)
5. CONTACT → FullEnrich.findEmail on rows step 3 left without an email
(fallback peopleDataLabs) (1–3/record)
6. VERIFY → waterfall.verifyEmail (0.1/record)
7. WRITEBACK → segment write / CRM upsert / CSV export (free)
```
Adapt by phase: drop steps that aren't relevant. Pure sourcing → step 1 only. "Enrich list I already have" → steps 2–6.
**QA gates (free, local — [`../references/contact-accuracy.md`](../references/contact-accuracy.md)):** run `scripts/validate-emails.ts` on the step-5 output *before* paying for step 6 (culls invalid/disposable/duplicate emails from the verify batch), and `scripts/contact-accuracy-audit.ts` on the merged output *before* step 7 — only `audit_action: SEND` rows go to write-back; report the audit counts in the receipt.
## Discovery sequence (run before any pipeline)
```bash
# 1. Confirm authentication
cargo-ai whoami
# 2. Confirm priority providers are connected
for slug in salesNavigator FullEnrich waterfall theirStack enrichCrm peopleDataLabs; do
cargo-ai connection connector list --integration-slug "$slug" \
| jq -e '.connectors | length > 0' > /dev/null \
&& echo "✓ $slug" \
|| echo "✗ $slug (NOT CONNECTED — recipe will fall back)"
done
# aiArk and apolloio (the other two priority providers) are deliberately not in
# this loop: their credits-based actions run on cargo's managed connection, so an
# empty `connector list` doesn't mean unavailable. apolloio's other nine actions
# (searches, contact CRUD, sequences) DO need your own Apollo API key connector.
# 3. Find the target model (Companies / Contacts) for write-back
cargo-ai storage model list
# 4. (optional) Find an existing segment to enrich, instead of fresh sourcing
cargo-ai segmentation segment list
```
---
## P1 — Mini-pipeline (10 prospects, end-to-end)
**Use when**: validating the full pipeline on a small sample, or when the user only needs ~10 prospects.
**User**: *"Find me 10 fintech CTOs in NYC, enrich, verify their emails."*
```bash
# Step 1 — SOURCE: cheapest at-scale lead search
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"salesNavigator","actionSlug":"searchLeads"}' \
--data '{
"keywords": "CTO",
"company": {"industries": [43]},
"personal": {"locations": ["New York City Metropolitan Area"]},
"limit": 10
}' \
--wait-until-finished > /tmp/p1-leads.json
# Step 2 — DEDUPE: drop leads the workspace already holds (free, no paid action)
cargo-ai storage query execute \
"SELECT linkedin_url FROM default.contacts WHERE linkedin_url IS NOT NULL" \
> /tmp/p1-known.json
jq -c --slurpfile known /tmp/p1-known.json \
'[.results[] | select(.linkedinUrl as $u
| ($known[0].rows // [] | map(.linkedin_url)) | index($u) | not)]' \
/tmp/p1-leads.json > /tmp/p1-new.json
# Step 3a — ENRICH (person): searchLeads returns a LinkedIn URL, so this is the
# cheapest rung — full profile plus a verified email, billing 0 on no-email
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"enrichPerson"}' \
--records "$(jq -c '[.[] | {linkedinUrl}]' /tmp/p1-new.json)" \
--wait-until-finished > /tmp/p1-prospect-enriched.json
# Step 3b — ENRICH (firmographics on each contact's company)
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"enrichCompany"}' \
--records "$(jq -c '[.[] | {domain: .companyDomain}]' /tmp/p1-new.json)" \
--wait-until-finished > /tmp/p1-firmo.json
# Step 5 — CONTACT: find email ONLY for the rows step 3a left without one
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"FullEnrich","actionSlug":"findEmail"}' \
--records "$(jq -c '[.results[] | select((.email // "") == "")
| {firstName, lastName, domainName: .companyDomain}]' /tmp/p1-prospect-enriched.json)" \
--wait-until-finished > /tmp/p1-emails.json
# Step 6 — VERIFY each found email
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"verifyEmail"}' \
--records "$(jq -c '[.results[] | select(.email) | {email}]' /tmp/p1-emails.json)" \
--wait-until-finished > /tmp/p1-verified.json
# Step 7 — Coalesce + summary
jq -s '[.[0].results, .[1].results, .[2].results, .[3].results]
| flatten
| group_by(.input.full_name // .input.firstName)
| map(reduce .[] as $r ({}; . * $r))' \
/tmp/p1-leads.json /tmp/p1-prospect-enriched.json /tmp/p1-emails.json /tmp/p1-verified.json
```
**Credit budget**: ~10 leads × (0.02 + 0 + 0.1 + 0.01 + 1 + 0.1) = ~12 credits. Step 5 (`FullEnrich.findEmail`, 1/record) only runs on the rows step 3a left without an email — `aiArk.enrichPerson` usually returns one, so the real figure lands under this.
---
## P2 — Full GTM run (50–500 prospects)
**Use when**: the user wants a real prospecting list with enrichment, verified emails, and segment write-back. Switches from inline-wait to async + polling.
**User**: *"Build me a list of 200 Heads of RevOps at SaaS companies hiring data engineers, get their verified emails."*
### Step 1 — Source companies via tech-intent (theirStack jobs)
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theirStack","actionSlug":"searchJobs"}' \
--data '{
"fields": {"job_titles": ["Data Engineer"], "posted_at_max_age_days": 60},
"companyFields": {"industries": ["software", "saas"], "employeeCounts": ["50-200","200-500"]},
"limit": 100
}' \
--wait-until-finished > /tmp/p2-companies.json
```
### Step 2 — Dedup + enrich firmographics on the source companies
```bash
# Dedupe is a free storage read — companies the workspace already holds don't
# need re-enriching
cargo-ai storage query execute \
"SELECT domain FROM default.companies" > /tmp/p2-known.json
jq -c --slurpfile known /tmp/p2-known.json \
'[.results[].company | select(.domain as $d
| ($known[0].rows // [] | map(.domain)) | index($d) | not)]' \
/tmp/p2-companies.json > /tmp/p2-new-companies.json
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"enrichCompany"}' \
--records "$(jq -c '[.[] | {domain}]' /tmp/p2-new-companies.json)" \
--wait-until-finished > /tmp/p2-firmo.json
```
### Step 3 — Find Heads of RevOps at each company (fan-out searchLeads per company)
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"salesNavigator","actionSlug":"searchLeads"}' \
--records "$(jq -c '[.results[].company | {keywords: "Head of RevOps", company: {linkedinIds: [.linkedinId]}, limit: 3}]' /tmp/p2-companies.json)" \
--wait-until-finished > /tmp/p2-leads.json
```
### Step 4 — Enrich each lead from its LinkedIn URL
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"enrichPerson"}' \
--records "$(jq -c '[.results[].leads[] | {linkedinUrl}]' /tmp/p2-leads.json)" \
--wait-until-finished > /tmp/p2-prospect-enriched.json
```
`enrichPerson` returns the verified email alongside the profile and bills 0 when
it finds none, so step 5 below only pays for the residue it left empty.
### Step 5 — Find emails (FullEnrich) on the residue only
```bash
# ONLY the rows step 4 left without an email — this gate is what makes the
# budget below 60 credits instead of 200.
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"FullEnrich","actionSlug":"findEmail"}' \
--records "$(jq -c '[.results[] | select((.email // \"\") == \"\")
| {firstName, lastName, domainName: .companyDomain}]' /tmp/p2-prospect-enriched.json)" \
--wait-until-finished > /tmp/p2-emails.json
```
### Step 6 — Verify emails (free cull, then waterfall)
```bash
# 6a. FREE pre-cull — stamp every row with email_risk/recommendation
# (QA scripts: ../references/contact-accuracy.md; Node >= 22.18;
# execute-batch output is accepted directly — no unwrapping needed)
node <skill-dir>/scripts/validate-emails.ts --input /tmp/p2-emails.json --json > /tmp/p2-culled.json
# 6b. Paid verification on the SURVIVORS ONLY — the cull is what saves the
# credits, so the verify batch must be built from its output, never from
# the original list
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"verifyEmail"}' \
--records "$(jq -c '[.[] | select(.recommendation != "skip") | {email}]' /tmp/p2-culled.json)" \
--wait-until-finished > /tmp/p2-verified.json
```
### Step 6.5 — Merge, then audit before handoff (free)
Join the verification statuses back onto the culled rows (the audit must see **every** row with its real status — never pre-filter to `valid` first, or the VERIFY/REMOVE verdicts and their counts are lost), then stamp each row:
```bash
# Merge: attach each row's verification status by email — join on the
# LOWERCASED address (verify results may re-case it; a missed join leaves
# emailStatus empty and the row degrades to VERIFY). Read .email_status
# (waterfall.verifyEmail output schema) — not .status.
jq -c --slurpfile ver /tmp/p2-verified.json '
($ver[0].results | map({key: (.email | ascii_downcase), value: .email_status}) | from_entries) as $st
| map(. + {emailStatus: ($st[(.email // "" | ascii_downcase)] // "")})
' /tmp/p2-culled.json > /tmp/p2-merged.json
# Audit: SEND / VERIFY / REVIEW / REMOVE per row; summary counts go in the receipt
node <skill-dir>/scripts/contact-accuracy-audit.ts --input /tmp/p2-merged.json --json > /tmp/p2-final.json
# Only SEND rows proceed to Step 7
jq '[.[] | select(.audit_action == "SEND")]' /tmp/p2-final.json > /tmp/p2-send.json
```
### Step 7 — Write back to a segment
If a Contacts model exists, upsert via `cargo-ai storage` patterns — see [`../../cargo-storage/SKILL.md`](../../cargo-storage/SKILL.md). For CRM push, defer to a future CRM-sync recipe.
**Credit budget** (200 leads, ~95 unique companies):
- theirStack searchJobs: 0.5
- dedupe against the Companies model: 0
- aiArk.enrichCompany × 95: ~1
- salesNavigator.searchLeads × 95: ~5.7 (≈ 0.02 × 3 × 95)
- aiArk.enrichPerson × 200: 20
- FullEnrich.findEmail × 60 (the rows aiArk left without an email): 60
- waterfall.verifyEmail × 200: 20
- **Total: ~107 credits for 200 fully-enriched + verified prospects** (~0.5 cred/prospect).
---
## P3 — Backfill mode (existing segment)
**Use when**: the user already has a list of contacts in a segment / model and wants to fill missing emails/phones/firmographics. No new sourcing.
**User**: *"Enrich the leads in our 'New Inbound' segment — fill missing emails."*
```bash
# 1. Discover the model + fetch the segment
cargo-ai storage model list # find the Contacts model UUID
MODEL_UUID=...
cargo-ai segmentation segment fetch \
--model-uuid "$MODEL_UUID" \
--filter '{"conjonction":"and","groups":[{"conjonction":"and","conditions":[
{"kind":"string","columnSlug":"lifecycle_stage","operator":"is","values":["new_inbound"]}
]}]}' > /tmp/p3-segment.json
# 2. Filter to rows MISSING email
jq -c '[.records[] | select(.email == null or .email == "")]' /tmp/p3-segment.json > /tmp/p3-missing-email.json
# 3. Try aiArk first on the rows that carry a LinkedIn URL (0.1, bills 0 on no-email)
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"aiArk","actionSlug":"enrichPerson"}' \
--records "$(jq -c '[.[] | select(.linkedin) | {linkedinUrl: .linkedin}]' /tmp/p3-missing-email.json)" \
--wait-until-finished > /tmp/p3-aiark-enriched.json
# 4. For rows still missing email, escalate to FullEnrich
jq -s '[.[0][], .[1].results[]] | group_by(.full_name) | map(reduce .[] as $r ({}; . * $r)) | map(select(.email == null or .email == ""))' \
/tmp/p3-missing-email.json /tmp/p3-aiark-enriched.json > /tmp/p3-still-missing.json
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"FullEnrich","actionSlug":"findEmail"}' \
--records "$(jq -c '[.[] | {firstName, lastName, domainName}]' /tmp/p3-still-missing.json)" \
--wait-until-finished > /tmp/p3-fullenrich.json
# 5. For rows still missing after FullEnrich, escalate to peopleDataLabs (heavyweight)
jq -s '[.[0][], .[1].results[]] | group_by(.firstName + .lastName) | map(reduce .[] as $r ({}; . * $r)) | map(select(.email == null or .email == ""))' \
/tmp/p3-still-missing.json /tmp/p3-fullenrich.json > /tmp/p3-final-missing.json
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"peopleDataLabs","actionSlug":"enrichPerson"}' \
--records "$(jq -c '[.[] | {parameters: {first_name: .firstName, last_name: .lastName, company: .companyName}}]' /tmp/p3-final-missing.json)" \
--wait-until-finished > /tmp/p3-pdl.json
# 6. Verify all newly-found emails
jq -s '[.[].results[] | select(.email)] | unique_by(.email)' /tmp/p3-fullenrich.json /tmp/p3-pdl.json > /tmp/p3-emails-to-verify.json
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"verifyEmail"}' \
--records "$(jq -c '[.[] | {email}]' /tmp/p3-emails-to-verify.json)" \
--wait-until-finished > /tmp/p3-verified.json
```
**Credit budget** (200 contacts missing email; assumes 60% hit on aiArk, 25% on FullEnrich, 10% on PDL, 5% unresolvable):
- aiArk.enrichPerson × 200: 20 (and 0 on the 40% that return no email)
- FullEnrich.findEmail × 80: 80
- peopleDataLabs.enrichPerson × 30: 90
- waterfall.verifyEmail × 190: 19
- **Total: ~209 credits for 190 verified emails** (~1.1 cred/email).
The waterfall pattern saves ~50% vs running peopleDataLabs on everyone (which would be 600 credits just for enrich).
---
## Output retrieval
After any batch finishes, retrieve enriched data with **`cargo-ai orchestration run download-outputs`** (not `run download`). See [`../references/output-retrieval.md`](../references/output-retrieval.md).
## Polling
Recipes use `--wait-until-finished` for runs ≤ 50 records. For larger runs, switch to async + polling per [`../../cargo-orchestration/references/polling.md`](../../cargo-orchestration/references/polling.md).
## Credits accounting
After every recipe run, surface the cost:
```bash
cargo-ai billing usage get-metrics \
--from <run-date> --to <today> \
--group-by integration_slug
```
## Alternatives
When the priority stack misses the user's criteria, see [`../references/alternatives.md`](../references/alternatives.md) for non-priority provider chains.
## Action shape rules
`{"kind":"connector","integrationSlug":"<slug>","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`** — see [`../../cargo-orchestration/references/examples/actions.md`](../../cargo-orchestration/references/examples/actions.md). Cross-node interpolation: `{{nodes.<slug>.<field>}}`.
## When stuck — file a workspace report
See [`../../cargo-workspace-management/SKILL.md`](../../cargo-workspace-management/SKILL.md) (Reports section).
recipes/re-engagement.md
# Recipe — Re-engage stale contacts when a fresh signal fires
Use this recipe when the user wants to systematically wake up cold contacts — old prospects, unresponsive leads, dormant opportunities — but only when a meaningful signal makes outreach worthwhile. The recipe polls cold contacts against the three highest-intent signal sources and re-engages only on a hit.
**Trigger phrases:**
- *"Resurrect cold leads when something changes at their company."*
- *"Re-engage contacts in the stale segment if they moved jobs or their company raised."*
- *"Build a recurring scan that wakes up old prospects on real signals."*
- *"Find old contacts worth reaching out to again."*
## Why this recipe exists
Most stale contacts will stay stale — outreach to them is wasted credits and damages sender reputation. But ~5–10% of any stale list develops a fresh trigger in any given quarter. Those are the contacts to act on. This recipe filters mechanically so the user only sees revive-worthy rows.
Three signals dominate B2B revival timing:
1. **Job change** (`waterfall.detectJobChange`) — the contact moved to a new company. Their old relationship is now warm context for a new account.
2. **Fresh funding / acquisition** (`enrichCrm.getFunding`, diffed against the stored round date) — fresh budget, new initiatives, willingness to evaluate.
3. **New tech stack or hiring pattern** (`theirStack.searchTechnologies` / `searchJobs`) — they're solving a problem your product addresses.
## Recipe
### Step 1 — Define the stale segment
A "stale contact" is one with no engagement for ≥ 180 days, not currently a customer, not currently in an active opportunity.
```bash
cargo-ai storage model list # find the Contacts model UUID
MODEL_UUID=...
cargo-ai segmentation segment fetch \
--model-uuid "$MODEL_UUID" \
--filter '{"conjonction":"and","groups":[{"conjonction":"and","conditions":[
{"kind":"date","columnSlug":"last_activity_at","operator":"olderThan","values":["180d"]},
{"kind":"string","columnSlug":"lifecycle_stage","operator":"isNot","values":["customer","opportunity"]}
]}]}' > /tmp/stale.json
```
Adjust the threshold (`180d` → `365d` for very large lists) and exclusions to match the workspace's CRM lifecycle conventions.
### Step 2 — Check for job changes
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"waterfall","actionSlug":"detectJobChange"}' \
--records "$(jq -c '[.records[] | {
professional_email: .email,
contact_linkedin: .linkedin_url,
company_domain: .company_domain
}]' /tmp/stale.json)" \
--wait-until-finished > /tmp/job-changes.json
```
`MOVED` rows are immediate revive candidates — the contact is at a new company, the old relationship is warm, and previous deal blockers (price, feature gap, internal politics) no longer apply.
### Step 3 — Check company-level events (funding / acquisition)
The catalog has no since-timestamp event feed, so a "fresh" round is the
difference between a new pull and the round date already on the record. That
date lives on the **Companies** model — `/tmp/stale.json` is a Contacts segment
and does not carry it, so read it separately or the diff compares against empty
and re-flags every account each week.
```bash
# The stored dates — a Companies column, not a Contacts one
cargo-ai storage query execute \
"SELECT domain, last_funding_round_at FROM default.companies" > /tmp/known-funding.json
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"enrichCrm","actionSlug":"getFunding"}' \
--records "$(jq -c '[.records[] | {domain: .company_domain}] | unique' /tmp/stale.json)" \
--wait-until-finished > /tmp/funding.json
# Keep rows whose latest round post-dates the stored value, then fan the company
# signal back out to the contacts at that company — step 5 unions on email.
jq -c --slurpfile known /tmp/known-funding.json --slurpfile stale /tmp/stale.json '
($known[0].rows | map({key: .domain, value: (.last_funding_round_at // "")}) | from_entries) as $stored
| [ .results[] | select((.lastFundingDate // "") > ($stored[.domain] // "")) ]
| map({key: .domain, value: .lastFundingDate}) | from_entries as $fresh
| [ $stale[0].records[]
| select($fresh[.company_domain])
| {email, signal: "company_event",
details: {domain: .company_domain, lastFundingDate: $fresh[.company_domain]}} ]
' /tmp/funding.json > /tmp/events.json
```
### Step 4 — (Optional) Check tech-stack / hiring intent
For contacts at companies where a tech signal is your strongest qualifier:
```bash
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"theirStack","actionSlug":"searchTechnologies"}' \
--records "$(jq -c '[.records[] | {company_domain: .company_domain, technologies: ["snowflake","databricks"]}]' /tmp/stale.json)" \
--wait-until-finished > /tmp/tech.json
```
Only run this step when the workspace's ICP has a strong tech-stack correlation. Otherwise skip — it adds credits with low marginal hit rate.
### Step 5 — Union into "revive candidates"
```bash
# `inputs` is a generator, not an array — slurp it before indexing.
jq -c -n '[inputs] as $in
| ([$in[0].results[] | select(.status == "MOVED") | {email, signal: "job_change", details: .new_company}] +
[$in[1][]] +
[$in[2].results[] | select(.matches // false) | {email, signal: "tech_match", details: .technologies}])
| group_by(.email) | map({email: .[0].email, signals: map(.signal), details: map(.details)})
' /tmp/job-changes.json /tmp/events.json /tmp/tech.json > /tmp/revive-candidates.json
```
Contacts with **2+ signals** are highest priority — surface them first.
### Step 6 — Hand off to outreach activation
Pass the revive segment to [`outreach-activation.md`](outreach-activation.md) — it handles enrichment, verification, LLM personalization, and sequencer push.
## Recurring scan (cron / play)
For continuous revival:
1. Trigger: weekly cron.
2. Source: the saved "Stale contacts" segment.
3. Nodes: detectJobChange + getFunding (diffed against the stored round date) + (optional) searchTechnologies → union → write to "Revive candidates" segment.
4. Downstream: a separate play watches the "Revive candidates" segment and triggers `outreach-activation` on new members.
For play setup, see [`../../cargo-orchestration/references/examples/plays.md`](../../cargo-orchestration/references/examples/plays.md).
## Credit budget
For a 1,000-contact stale segment, scanned weekly:
| Step | Per record | 1,000 contacts |
|---|---|---|
| `waterfall.detectJobChange` | 3 | 3,000 |
| `enrichCrm.getFunding` | 1 | 1,000 |
| `theirStack.searchTechnologies` (optional) | 1 | 1,000 |
| **Total weekly (without tech)** | **4** | **4,000** |
| **Total weekly (with tech)** | **5** | **5,000** |
Filter aggressively before the scan — only include contacts where revival is actually actionable (had real engagement once, valid email, ICP-fit company). Pre-filter a 10,000-contact list down to 1,000 before scanning, not after.
## Action shape
Every action follows: `{"kind":"connector","integrationSlug":"<slug>","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`** — see [`../../cargo-orchestration/references/examples/actions.md`](../../cargo-orchestration/references/examples/actions.md).
## Output retrieval
For batch runs, use `cargo-ai orchestration run download-outputs --workflow-uuid <uuid> --output-node-slug <slug>`. See [`../references/output-retrieval.md`](../references/output-retrieval.md).
## Related
- [`job-change-monitoring.md`](job-change-monitoring.md) — narrower: just job changes, applied to any segment (not specifically stale).
- [`lost-deal-revival.md`](lost-deal-revival.md) — narrower: scoped specifically to Closed-Lost CRM deals, branches on `lost_reason`.
- [`outreach-activation.md`](outreach-activation.md) — downstream: turns the revive segment into send-ready outreach.
recipes/review-and-iterate.md
# Recipe — Human review loop
Use this recipe when a run produced output that needs **human judgment** before it can be trusted or sent, and that judgment should improve the next run rather than evaporate. Typical asks: "put these in a sheet so my team can review them", "read my feedback and fix the ones I marked", "these emails are too long — make that a permanent rule", "compare the two versions", "keep iterating until they're good".
This is the counterpart to [`../../cargo-diagnostics/SKILL.md`](../../cargo-diagnostics/SKILL.md). Diagnostics answers "why did the machine do the wrong thing" from telemetry. This recipe answers "the machine did something a human doesn't like" — where there is no error to trace, only taste, accuracy, and judgment.
## When to reach for it
Judgment output — LLM-written outreach, lead scores, qualification verdicts, extracted facts — has no ground truth in the run. `status: success` says the node executed, not that the answer was right. Any time the deliverable is a *claim* rather than a *lookup*, a review pass belongs between the run and the send.
Skip it for deterministic output (an email either verified or did not). Use the QA scripts instead: [`../references/contact-accuracy.md`](../references/contact-accuracy.md).
## Step 1 — Put the output where a human can mark it up
Never paste rows into the conversation for review. Get the run's output out, then into a surface the reviewer already uses.
```bash
cargo-ai orchestration run download-outputs \
--workflow-uuid <uuid> \
--output-node-slug <slug> \
--format json
# → { "url": "…signed…" }
```
Then either hand over the file, or push the rows into a sheet the team can edit:
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"googleSheets","actionSlug":"insert"}' \
--data '{"spreadsheetId": "<id>", "worksheet": "review"}' \
--wait-until-finished
```
**Add three empty columns the reviewer fills in**, and say what each means:
| Column | Values | Meaning |
|---|---|---|
| `verdict` | `keep` / `fix` / `drop` | Is this row usable as-is? |
| `note` | free text | *Why* — the part that becomes the rule |
| `corrected` | free text | Optional: what it should have said |
`verdict` alone is nearly worthless for improving the next run. The `note` is the payload — "too long", "wrong person, this is a namesake", "the funding fact is from 2019" are each a different fix. Ask for it explicitly.
Post the sheet link where the reviewer will see it (`slack.postMessage`), rather than waiting silently.
## Step 2 — Read the marked-up rows back
```bash
# The extractor pulls the reviewed worksheet back into the workspace
cargo-ai connection integration get googleSheets # confirm `fetchWorksheet` is available on the connector
```
Then read the reviewed rows and **group by the note, not by the row**. Twenty rows marked `fix` are rarely twenty problems; they are usually two or three, repeated. Report it that way:
> 14 of 40 marked `fix`. Three causes: 9 × "too long" (median 180 words vs the 90 asked for), 3 × wrong-person (all three are common-name collisions), 2 × stale funding fact.
That grouping is the whole value of the loop. A per-row fix list produces a per-row patch; a grouped diagnosis produces a prompt change.
## Step 3 — Turn each cause into the right kind of fix
Match the fix to the cause — most reviewer complaints are **not** prompt problems:
| Cause pattern | Right fix | Wrong fix |
|---|---|---|
| Style, length, tone, structure | Amend the prompt in [`../references/prompt-library/index.md`](../references/prompt-library/index.md) and re-run only the `fix` rows | Re-running everything |
| Wrong person / wrong company | An identity-validation step, not better wording — `scripts/validate-linkedin-names.ts`, `scripts/select-current-role.ts` | Telling the LLM to "be careful" |
| Stale or invented facts | Ground the prompt in a retrieved field and require a source; drop rows where the field is empty | Raising the temperature or the model tier |
| Right answer, wrong rows in scope | Tighten the segment, not the prompt ([`../../cargo-segmentation/SKILL.md`](../../cargo-segmentation/SKILL.md)) | Post-filtering the output |
| Genuinely borderline, reviewer split | Leave it; add it to the eval set below rather than over-fitting | Writing a rule for one row |
**Re-run only the rows that were marked.** The `keep` rows are already paid for and already approved. Filter to the `fix` ids and re-run those — re-running the full set burns credits to regenerate output a human already accepted.
## Step 4 — Make the correction permanent
A fix that lives only in this session's prompt is lost by the next run. Two durable homes:
- **Prompt library** — if the correction is about how to write or judge, amend the prompt entry so every future recipe inherits it.
- **Context repo** — if the correction is about the *business* ("we don't say 'synergy'", "never claim a customer count", "Series A means <$15M for us"), it belongs in the workspace's GTM knowledge base where humans and agents both read it: [`../../cargo-context/SKILL.md`](../../cargo-context/SKILL.md).
State plainly which one you wrote to, and quote the line you added. "Made that a standing rule" without an artifact is not a standing rule.
## Step 5 — Keep the reviewed rows as an eval set
The reviewed batch is the most valuable thing this loop produces, and it is usually thrown away. Keep it:
- Store the `prompt → reviewer verdict → note` triples alongside the play (a small JSON file in the repo, or a model in the workspace).
- Before shipping a prompt change, run it against those stored rows and check that previously-`keep` rows still pass. This is what stops fix #4 from re-breaking fix #2.
- Ten to thirty rows is enough to catch regressions. Do not build a labeling program.
## Step 6 — Close the loop
Report back in this shape, then stop:
1. **What changed** — the grouped causes and the fix applied to each.
2. **What it cost** — credits for the re-run of the `fix` rows only, against the original run's cost.
3. **What is now permanent** — the prompt entry or context file, quoted.
4. **What is still open** — rows the reviewer split on, or causes you chose not to fix and why.
If the reviewer wants another pass, iterate — but cap it. Two rounds catch nearly everything; a third usually means the task is underspecified, and the right move is a conversation about the criteria, not a third re-run.
## Related
- [`../references/contact-accuracy.md`](../references/contact-accuracy.md) — deterministic QA that should run *before* a human ever sees the rows.
- [`save-as-play.md`](save-as-play.md) — once the output passes review consistently, make it scheduled.
- [`../references/cost-discipline.md`](../references/cost-discipline.md) — the pilot gate; a review loop is a pilot with a human in it.
recipes/save-as-play.md
# Recipe — Save an ad-hoc run as a durable play or tool
Convert whatever was just run ad-hoc (a search, an enrichment chain, a signal pull) into a scheduled, always-on workflow in the workspace. This is the step that turns a session's exploration into infrastructure — every saved run compounds instead of evaporating.
## When to offer this (post-run convention)
After **any successful ad-hoc run whose result would be worth having again** — a signal pull, a persona search, a monitoring query — offer once, with the right cadence pre-picked:
> "Want this to run by itself? I can save this exact `<what it did>` as a `<play/tool>` that runs `<cadence>` — new results land without you asking."
Don't offer for one-shot lookups (a single LinkedIn resolution, one email verify) — only for repeatable pulls.
## Pick the shape: tool (cron) vs play (data-driven)
| The ad-hoc run was… | Save as | Trigger |
|---|---|---|
| A search/pull against an external provider (new hires, job postings, funding events) | **Tool** | Cron trigger |
| A per-record chain over records in a model/segment (enrich, verify, score, detect job change) | **Play** | Segment `changeKinds` (+ optional `schedule` for periodic re-evaluation) |
Cadence defaults by signal type:
| Signal | Cadence | Why |
|---|---|---|
| Job offers / hiring intent | Daily (`0 9 * * *`) | Postings are time-sensitive; stale = wasted outreach window |
| New hires / champion moves / job changes | Every 2 weeks (`0 9 1,15 * *`) | Job-change data refreshes slowly; tighter cadence re-bills the same rows |
| Funding events | Weekly (`0 9 * * 1`) | Announcements cluster; a weekly digest is fresh enough to act on |
| Persona search (quickstart-style) | Weekly (`0 9 * * 1`) | New matches accumulate slowly at persona granularity |
## Cost gate — a schedule multiplies spend
A saved play spends credits **every run, forever**. Before deploying, extend the [approval gate](../references/cost-discipline.md): state *per-run cost × cadence = monthly burn* ("~1.1 credits/run, weekly → ~4.4/month") and get an explicit yes on the recurring number, not just the one-off.
Also open the [provider playbook](../provider-playbooks/) of **every paid node** and read its **Recurring use** section — it carries the provider-specific cadence default, the filter gate that keeps re-runs from re-billing already-enriched rows, and any extractor alternative that replaces the scheduled pull entirely.
## Path A — save as a tool with a cron trigger
```bash
# 1. Create the tool shell
cargo-ai orchestration tool create \
--name "Weekly RevOps-lead pull" \
--description "Saved from ad-hoc run: salesNavigator.searchLeads for <persona>, limit 25"
# → Extract tool.uuid and tool.workflowUuid
# 2. Rebuild the ad-hoc run as a node graph: start → the exact action(s) you just
# ran (same integrationSlug/actionSlug/config, connectorUuid at node top level)
# → end node exposing the output fields. Validate before deploying:
cargo-ai orchestration node validate --nodes '[...start → action → end...]'
# → { "outcome": "valid" }
# 3. Deploy the graph to the tool's workflow
cargo-ai orchestration release update-draft \
--workflow-uuid <tool.workflowUuid> \
--nodes '[...validated nodes...]'
cargo-ai orchestration release deploy-draft \
--workflow-uuid <tool.workflowUuid> \
--nodes '[...validated nodes...]' \
--form-fields 'null' \
--description "v1 — saved from ad-hoc session run"
# ⚠️ Never pass --version to release deploy-draft (shadowed by the global flag —
# prints the CLI version and exits WITHOUT deploying). Confirm with:
cargo-ai orchestration release get-deployed --workflow-uuid <tool.workflowUuid>
# → status must be "deployed"
# 4. Attach the cron trigger
cargo-ai orchestration tool update \
--uuid <tool.uuid> \
--triggers '[{"name":"Weekly","type":"cron","cron":"0 9 * * 1","data":{}}]'
# 5. Prove it works once, end to end (this run is the pilot for the schedule)
cargo-ai orchestration run create \
--workflow-uuid <tool.workflowUuid> \
--data '{}' \
--wait-until-finished
```
Node-graph syntax: see [`../../cargo-orchestration/references/examples/tools.md`](../../cargo-orchestration/references/examples/tools.md) ("Run with custom nodes") and [`../../cargo-orchestration/references/nodes.md`](../../cargo-orchestration/references/nodes.md). Check `cargo-ai orchestration template list` first — a template tagged your pattern beats authoring from zero.
## Path B — save as a play on a model segment
For chains that should re-run whenever records change (enrich every new company, detect job changes on the customer-contacts segment):
```bash
# 1. Create the play against the model it operates on
cargo-ai orchestration play create \
--name "Enrich new companies" \
--model-uuid <model-uuid> \
--change-kinds created \
--run-creation-rule once \
--description "Saved from ad-hoc enrichment chain"
# → Extract play.uuid and play.workflowUuid
# (check `play create --help` for the allowed change-kind values)
# 2–3. Same as Path A: validate the node graph, release update-draft + deploy-draft
# against <play.workflowUuid>, confirm with release get-deployed.
# 4. Optional: scope + periodic re-evaluation
cargo-ai orchestration play update <play.uuid> \
--filter '{...same shape as segmentation segment update...}' \
--limit 500 \
--schedule '{...cron re-evaluation, for signals that decay...}'
# 5. Sample the play on 10–20 records before enabling it broadly
cargo-ai orchestration batch create \
--workflow-uuid <play.workflowUuid> \
--data '{"kind":"recordIds","modelUuid":"<model-uuid>","ids":["<id-1>","…","<id-15>"]}' \
--wait-until-finished
# → report credits spent + hit-rate, then ask before enrolling the full segment:
# state how many records it covers and what they cost. A play with a schedule
# re-bills that amount on every run — the estimate is per-run, not one-off.
```
Play mechanics (batch data kinds, `playNotCompatible`, monitoring): [`../../cargo-orchestration/references/examples/plays.md`](../../cargo-orchestration/references/examples/plays.md).
## Managing the workspace as code?
If the workspace is CDK-managed (`cargo-ai cdk` — resources defined in TypeScript and deployed via plan/deploy), don't create the play imperatively: add it as a `definePlay`/`defineTool` in the CDK project instead, so it's versioned with the rest of the infra. An imperatively-created play in a CDK workspace is drift.
## Close the loop
End with the receipt discipline: what was created (name + UUID + trigger), the recurring cost line, and where results will land. Then point at monitoring: `cargo-ai orchestration run list --workflow-uuid <uuid>` or the error-rate queries in [`../../cargo-analytics/SKILL.md`](../../cargo-analytics/SKILL.md).
recipes/source-planning.md
# Recipe — Source planning (before you spend)
Use this recipe when the user asks a research or list-building question whose **answer source is not obvious**, and the wrong first guess is expensive. Typical asks: "can we even find this?", "what's the cheapest way to get X for 2,000 companies?", "which provider has the best coverage for European SMBs?", "what would this cost before I commit?", "is there a signal for Y?".
Every other recipe in this skill assumes the source is already decided. This one decides it. Run it when the question is unusual, the volume is large, or the user is cost-sensitive — and skip it when a recipe already matches (`build-tam.md`, `prospecting.md`, `tech-intent.md` each encode a settled source plan).
## The failure this prevents
The expensive mistake is not picking a slightly worse provider. It is **fanning out over the full list before learning that the field is only available for 30% of it.** You pay for 2,000 lookups, get 600 answers, and discover the coverage ceiling was a property of the data, not of your query. Source planning buys that knowledge for a few credits.
## Step 1 — Restate the question as a field on a row
Vague research questions cannot be costed. Convert to: **for each `<entity>`, what is `<field>`?**
- "Find companies that care about compliance" → *for each company, does it have a SOC 2 badge / a compliance job posting / a trust page?*
- "Who are the decision makers?" → *for each company, which people hold `<title list>`?*
- "Are they growing?" → *for each company, what is headcount now vs 12 months ago?*
If the question cannot survive this rewrite, it is a strategy question, not a data question — say so rather than shopping for a provider.
**Then name the anchor** — the identifier already on the row. Everything downstream depends on it:
| You already have | Cheapest anchors into |
|---|---|
| Domain | Company firmographics, technographics, funding |
| LinkedIn profile URL | Person profile + verified email at the bottom of the catalog |
| LinkedIn company URL | Company details, headcount, industry |
| Name + company | Nothing directly — resolution step first ([`linkedin-url-lookup.md`](linkedin-url-lookup.md)) |
| Email | Reverse lookup to person/company |
| Nothing (criteria only) | A search action, billed per returned row |
A weak anchor is the single biggest cost multiplier: name+company costs a resolution step *and* an enrich step, and compounds error at both.
## Step 2 — Enumerate candidate sources, cheapest first
Three tiers. Exhaust each before moving down.
**Tier 0 — free and already in the workspace.** Check before buying anything:
```bash
cargo-ai storage model list # is the field already a column?
cargo-ai storage query execute "SELECT count(*) FROM default.companies WHERE <field> IS NOT NULL"
cargo-ai connection connector list # is the CRM connected? it may already hold this
```
Surprisingly often the answer is a `SELECT` away, or one CRM sync. Say so and stop.
**Tier 1 — the catalog, by input type.** [`../references/stage-action-map.md`](../references/stage-action-map.md) maps input type → cheapest credits-based action per stage; [`../references/credits-cost-table.md`](../references/credits-cost-table.md) has the per-action cost for all 145. Pull 2–3 candidates, not one.
**Tier 2 — general web.** When no structured provider carries the field: `serper` (SERP), `firecrawl` (scrape a known page), `linkup` (sourced answers), or an LLM with search grounding. Cheap per call, unbounded per question, and the answer quality depends entirely on the prompt — read [`../references/prompt-library/index.md`](../references/prompt-library/index.md) before writing one.
## Step 3 — Probe coverage on 5–10 rows, per candidate
This is the step that pays for the whole recipe. Take a deliberately **representative** sample — not the first 10 rows, which are usually the largest and best-covered companies — and run each candidate against it.
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"<slug>","actionSlug":"<slug>"}' \
--data '{"domain":"example.com"}' --wait-until-finished
```
Record four numbers per candidate, and nothing else:
| Metric | How to read it |
|---|---|
| **Hit rate** | Non-empty answers ÷ rows attempted. The coverage ceiling. |
| **Cost per hit** | Cost per row ÷ hit rate — *not* cost per row. A 0.5-credit action at 20% coverage costs 2.5 credits per answer, worse than a 2-credit action at 90%. |
| **Correctness** | Spot-check 3 answers against a source you trust. Wrong beats missing in cost, because it propagates. |
| **Freshness** | How old is the value? A 2019 funding round answers the query and fails the job. |
Read the candidate's playbook (`../provider-playbooks/<slug>.md`) before the probe — it usually predicts the hit rate and names the input quirk you are about to trip over.
## Step 4 — Present the plan, then wait
Do not proceed on your own judgment. Present the costed comparison and stop:
> **Question:** for each of 2,000 companies, do they run a bug-bounty program?
> **Anchor:** domain (present on 1,940 of 2,000).
>
> | Source | Probe hit rate | Cost/row | Cost/hit | Note |
> |---|---|---|---|---|
> | `theirStack.searchTechnologies` | 3/10 | 0.5 | 1.7 | Only detects platform vendors |
> | `firecrawl` on `/security` | 7/10 | 0.5 | 0.7 | Needs an LLM extract step (+0.01) |
> | `serper` + LLM judge | 8/10 | ~0.3 | 0.4 | Noisiest; 1 of 3 spot-checks wrong |
>
> **Recommendation:** firecrawl on `/security`, ~1,360 answers for ~1,020 credits. Balance is 4,200.
> **Alternative if budget matters more than coverage:** serper + judge, ~40% cheaper, with a manual spot-check on the positives.
> **What I'd drop:** theirStack — it answers a different question than the one asked.
Three shaped options, a default, and the reconciled balance — the standard approval shape from [`../references/cost-discipline.md`](../references/cost-discipline.md). Stay in AWAIT_APPROVAL until the user picks.
## Step 5 — Record the plan so the next person doesn't re-probe
Coverage findings are durable knowledge about the market, and they are usually lost the moment the run ends. Write the result — the question, the sources tried, the hit rates, and the choice — to the workspace's context repo: [`../../cargo-context/SKILL.md`](../../cargo-context/SKILL.md).
Six weeks later, "we tested three sources for bug-bounty detection and firecrawl on /security won at 70%" is worth more than the list it produced.
## Step 6 — Hand off
With the source chosen, the job becomes an ordinary one. Route to the recipe that matches: [`build-tam.md`](build-tam.md) for company lists, [`prospecting.md`](prospecting.md) for contacts, [`tech-intent.md`](tech-intent.md) for stack and hiring signals, or [`save-as-play.md`](save-as-play.md) if the answer needs refreshing on a schedule.
Carry the probe's hit rate into the sizing: at 70% coverage, over-provision the input list rather than adding a second provider to chase the missing 30% — coverage is a property of the company, not of your effort. See [`../references/waterfall-strategy.md`](../references/waterfall-strategy.md) for when a second rung genuinely helps.
## Related
- [`../references/stage-action-map.md`](../references/stage-action-map.md) — input type → cheapest action, across the whole catalog.
- [`../references/alternatives.md`](../references/alternatives.md) — swap-ins when the priority stack can't serve.
- [`../references/credits-cost-table.md`](../references/credits-cost-table.md) — per-action costs.
- [`icp-discovery.md`](icp-discovery.md) — when the question is "which signal matters", not "where do I get this one".
recipes/tech-intent.md
# Recipe — Find companies by tech-stack or hiring intent
Use this recipe when the user wants to find or prioritize companies based on **what they use** (tech stack) or **what they're hiring for** (role intent). These are two of the strongest leading indicators in B2B GTM.
**Trigger phrases:**
- *"Find every company using Snowflake AND dbt."*
- *"Show me everyone hiring a Head of RevOps in the last 30 days."*
- *"List companies running React + AWS that just hired a data engineer."*
- *"Which of our target accounts started using Stripe in the last 6 months?"*
## Three flavors
### Flavor A — Tech-stack sourcing
"Find every company using X."
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theirStack","actionSlug":"searchCompanies"}' \
--data '{
"techFields": {"technologies": ["snowflake", "dbt"]},
"fields": {"industries": ["software"], "headcountMin": 100},
"limit": 500
}' \
--wait-until-finished
```
### Flavor B — Hiring-intent sourcing
"Find every company hiring for role X."
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theirStack","actionSlug":"searchJobs"}' \
--data '{
"fields": {
"job_titles": ["Head of RevOps", "VP RevOps"],
"posted_at_max_age_days": 30
},
"companyFields": {"employeeCounts": ["50-200","200-500"]},
"limit": 200
}' \
--wait-until-finished
```
Result includes both job postings and the companies that posted them. Dedup on company to get the unique account list.
### Flavor C — Combined "running stack AND hiring"
"Find every company running Snowflake AND hiring a data engineer in the last 60 days."
```bash
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theirStack","actionSlug":"searchCompanies"}' \
--data '{
"techFields": {"technologies": ["snowflake"]},
"jobFields": {"job_titles": ["Data Engineer"], "posted_at_max_age_days": 60},
"fields": {"industries": ["software"]},
"limit": 200
}' \
--wait-until-finished
```
This is theirStack's unique strength — combined tech-stack + hiring-intent in one call.
## Per-company tech validation
After sourcing with theirStack, validate the technographics on each company with
builtwith — **free first, paid on the residue**:
```bash
# 1. Free stack summary for every sourced domain — costs nothing
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"builtwith","actionSlug":"getDomainSummary"}' \
--records "$(jq -c '[.results[] | {domain}]' /tmp/sourced.json)" \
--wait-until-finished > /tmp/stack-summary.json
# 2. Full detail only where the free summary didn't settle the question
cargo-ai orchestration action execute-batch \
--action '{"kind":"connector","integrationSlug":"builtwith","actionSlug":"enrichDomain"}' \
--records '<rows from /tmp/stack-summary.json the summary left ambiguous>' \
--wait-until-finished
```
`enrichDomain` gives the richer view (technology categories, versions, spend
signals) than theirStack alone. Use both when high-confidence is required — but
never before the free summary has cut the list down.
## Discovering canonical technology / role slugs
theirStack's filters expect canonical slugs (e.g. `"snowflake"`, not `"Snowflake Inc."`).
```bash
# Discover canonical slugs before search
cargo-ai orchestration action execute \
--action '{"kind":"connector","integrationSlug":"theirStack","actionSlug":"searchTechnologies"}' \
--data '{"fields": {"keywords": "snowflake"}, "limit": 10}' \
--wait-until-finished
```
Use the returned `slug` values in `searchCompanies.techFields.technologies`.
## Recurring tech-intent monitoring (play)
For continuous monitoring (e.g. weekly scan for "new companies hiring my buyer"):
1. Trigger: weekly cron.
2. Action: `theirStack.searchJobs` with `posted_at_max_age_days: 7`.
3. Dedup against last week's results.
4. Output: write new companies to a "Fresh Hiring Intent" segment.
To make this recurring, follow [`save-as-play.md`](save-as-play.md) — it walks the tool-vs-play choice, the cadence defaults per signal, and the recurring-cost approval. Play mechanics: [`../../cargo-orchestration/references/examples/plays.md`](../../cargo-orchestration/references/examples/plays.md).
## Credit budget
| Action | Cost per call |
|---|---|
| `theirStack.searchTechnologies` | 0.5 |
| `theirStack.searchJobs` | 0.5 |
| `theirStack.searchCompanies` | 0.5 |
| `builtwith.getDomainSummary` | **0** per record |
| `builtwith.enrichDomain` | 1 per record |
Note: theirStack actions are **per-call**, not per-record-returned. One call returning 500 companies = 0.5 credits. builtwith is per-record.
For a 500-company tech-intent scan with validation: 0.5 (theirStack) + 0 (`getDomainSummary` × 500) + 1 per row that actually needs `enrichDomain`. If a fifth of the list escalates, that is ~100 credits, not 500 — which is the whole point of running the free summary first.
## When the intent doesn't show up in theirStack
- **Stack X isn't in theirStack's catalog**: run `builtwith.getDomainSummary` (free) directly on a known account list, and escalate the ambiguous rows to `builtwith.enrichDomain` (1) — builtwith detects from the site itself rather than from a curated catalog, so its coverage is broader for niche tools.
- **Job posting on a niche board**: theirStack covers major boards (LinkedIn, Indeed, etc.); for niche/industry boards, fall back to `firecrawl.crawl` on the board URL.
- **Self-reported intent (e.g. case studies)**: scrape with `firecrawl.scrape` + LLM extract via `anthropic.instruct`.
## Action shape
`{"kind":"connector","integrationSlug":"theirStack","actionSlug":"<slug>"}`. **No `connectorUuid` in `config`.**
## Output retrieval
For batch runs, use `cargo-ai orchestration run download-outputs --workflow-uuid <uuid> --output-node-slug <slug>`.
references/acceptable-use.md
# Acceptable use — basis, suppression, and volume gates
Canonical people-data rules for this skill. Recipes and playbooks link here instead of restating them. These are **mandatory behaviors**, the same tier as [`cost-discipline.md`](cost-discipline.md): an agent that skips the basis check or writes around a suppression list is misusing the skill.
Scope: every step that touches a person — sourcing, enrichment, verification, personalization, sequencer handoff, ads activation. Not legal advice; the user's counsel owns the final call on their jurisdiction and lawful basis.
## 1) What this skill is for
Business-to-business revenue work, on business identities, using data the workspace is licensed to receive through the providers in [`../provider-playbooks/`](../provider-playbooks/). The unit of work is a **qualified account and the person whose professional role makes them a plausible buyer** — a list that has been filtered, scored, and costed before anyone is contacted.
It is not a bulk-messaging tool. Nothing in *this* skill sends mail: the outreach recipes stop at send-ready variables and hand off to a sequencer, under that sequencer's sending limits and identities. Where that sequencer is Cargo's own — a mailbox the workspace provisioned through [`../../cargo-mailbox-management/SKILL.md`](../../cargo-mailbox-management/SKILL.md) — nothing on this page relaxes: the three checks in §3 run before the first send, the mailbox's warm-up ramp is the ceiling, and an unsubscribe writes a workspace-wide suppression that no later send may work around. Cargo owning the inbox changes who presses send, not whether the message should be sent.
## 2) Hard refusals
Do not execute these. Say which rule applies in one sentence, offer the compliant version, and move on — state it once, don't lecture.
| Request | Why it's refused |
|---|---|
| "Email everyone at every company in `<industry>`" — undifferentiated fan-out with no qualification step | Volume in place of relevance is the definition of spam; propose the scored, filtered slice instead |
| Consumer or private-individual targeting — personal life, home contact details, audiences with no business role | This skill covers B2B professional identities only |
| A list whose origin the user can't state — purchased lists, lists exported from a former employer, data taken from a platform in breach of its terms | No lawful basis, and every downstream provider ToS forbids it |
| Contacting anyone on the workspace's unsubscribe / do-not-contact / hard-bounce list | Suppression is absolute; re-contact is a violation, not an optimization |
| Evasion: rotating sending domains or identities to dodge filters, disguising the sender, misleading subject lines, fake `Re:` threads on a first touch, forged headers | Deception is prohibited independently of volume |
| Auto-dialing, SMS blasts, or a full-list phone sweep | Phone is explicit-request-only on qualified leads — see [`cost-discipline.md`](cost-discipline.md) §5 |
| Batch-blasting LinkedIn engagement actions (`connectProfile`, `commentPost`) across a raw list | They act as a real member identity — see [`../provider-playbooks/linkedin.md`](../provider-playbooks/linkedin.md) |
| Scraping a site in breach of its terms or `robots.txt` when a licensed provider action covers the same field | Use the provider action; if none exists, say so rather than routing around the block |
## 3) Before any outreach step — three checks
Run these before the personalize stage of [`../recipes/outreach-activation.md`](../recipes/outreach-activation.md), before an ads upload, and before any sequencer handoff. All three are free.
| Check | What to ask / verify | If it fails |
|---|---|---|
| **Basis** | Which basis covers this audience — existing customers, opted-in contacts, event attendees, or a documented legitimate-interest case for a B2B role? | Stop and ask. Don't assume legitimate interest because the record has a work email |
| **Suppression** | Filter the segment on the workspace's unsubscribe / DNC / hard-bounce columns *before* enriching or sending | If no such column exists, flag it as a real gap and offer to add one — don't proceed silently |
| **Relevance** | Can you name, per recipient, why this message is for them? The signal in the segment is usually the answer | If the honest answer is "they matched an industry filter", the list isn't ready — tighten it |
## 4) What every message must carry
The skill drafts copy; these are the properties that copy must have before the user's sequencer sends it.
- **Accurate identity** — real sender, real company, headers and subject line that describe the message honestly.
- **A working opt-out**, honored promptly and permanently. Under CAN-SPAM that's a mechanism valid ≥30 days and processed within 10 business days; under GDPR/ePrivacy an objection is immediate.
- **A physical postal address** where the sender's jurisdiction requires one (CAN-SPAM does).
- **Per-recipient relevance** — the personalization prompts in [`prompt-library/personalization.md`](prompt-library/personalization.md) exist for this. A prompt that produces the same sentence for every row is a signal the list is wrong, not that the prompt needs rewriting.
## 5) Data hygiene
- **Verify before you send.** `waterfall.verifyEmail` / `icypeas` aren't only a deliverability lever — mailing unverified addresses is how a list starts hitting spam traps.
- **Record provenance.** Keep which provider supplied each contact field and when. An access or erasure request can't be honored on a column with no origin.
- **Propagate erasure and opt-out.** On request, delete — and dedupe the *next* sourcing run against the suppression list, not just against the Contacts model, so a suppressed person doesn't re-enter as a "new" lead.
- **Don't hoard.** Enriched personal data the workspace isn't actively working is cost and liability at once; drop the rows that didn't qualify.
## 6) Volume and cadence
- Respect the sequencer's and mailbox's own limits — this skill never proposes raising them, and a request to work around them is an evasion refusal under §2. On a Cargo-owned mailbox that limit is the warm-up ramp (5/day rising to 40/day over 45 days, read with `mailbox get-send-allowance`): it is a ceiling, not a target, and spreading one campaign across extra mailboxes to clear the same volume is the same refusal wearing a fleet — see [`../../cargo-mailbox-management/references/warmup-and-allowance.md`](../../cargo-mailbox-management/references/warmup-and-allowance.md).
- One campaign per contact at a time; cap the touch count; **stop on reply, opt-out, or bounce**.
- Cadence on recurring plays is a spend gate *and* a contact-frequency gate — a play that re-enrolls the same segment weekly is re-contacting the same people weekly. Check the provider playbook's **Recurring use** section before scheduling.
## 7) Cross-references
- Spend gates and the approval message: [`cost-discipline.md`](cost-discipline.md)
- Ads consent and the removal path: [`../recipes/ads-audience-activation.md`](../recipes/ads-audience-activation.md)
- Sending from a Cargo-owned mailbox — warm-up ramp, suppression list, delivery events: [`../../cargo-mailbox-management/SKILL.md`](../../cargo-mailbox-management/SKILL.md)
- Personal-mailbox routing: [`../provider-playbooks/forager.md`](../provider-playbooks/forager.md)
- LinkedIn identity limits: [`../provider-playbooks/linkedin.md`](../provider-playbooks/linkedin.md)
references/alternatives.md
# Alternative provider chains
When the priority stack (salesNavigator / cargo / aiArk / waterfall / FullEnrich / apolloio / theirStack / peopleDataLabs) can't serve the user's criteria, swap in providers from the long tail.
For every alternative, see [`stage-action-map.md`](stage-action-map.md) for the cheapest credits-based action per stage across the full 136-integration catalog.
## When to swap providers
Only swap when:
1. **Filter mismatch**: priority provider doesn't expose the filter you need (e.g., salesNavigator can't filter by funding round → escalate to peopleDataLabs.queryCompanies).
2. **Coverage gap**: priority provider doesn't have data for the niche (e.g., local SMBs aren't well-covered by salesNavigator → escalate to serper.searchPlaces).
3. **Premium quality required**: cheap email/phone finders missed → FullEnrich was already the priority answer; further escalation goes to multi-source like waterfall.findPhone (7 credits).
Default rule: **don't swap to chase 2× cheaper if hit-rate drops 30%**. The total credit spend across a chain is dominated by misses (re-running across stages), not by the per-call cost.
## Sourcing alternatives
| Goal | Priority | Alternative | When to swap |
|---|---|---|---|
| At-scale lead search | salesNavigator.searchLeads (0.02) | icypeas.findPeople (0.02) | When LinkedIn coverage is thin (e.g., privacy-focused industries). |
| At-scale account search | salesNavigator.searchAccounts (0.05) **or** aiArk.searchCompanies (0.01) | oceanio.searchCompanies (1) | Both priority actions are cheap; aiArk wins on price and lookalike seeds (≤5 domains), oceanio when the filter is technographic / web-traffic shaped. |
| | | peopleDataLabs.searchCompanies (3) for cargo-filter shape, or queryCompanies (3) for SQL | When salesNavigator's filters miss (funding, investor, complex bool). |
| Tech-intent sourcing | theirStack.searchJobs / searchCompanies (0.5) | (no priority alternative — theirStack IS priority) | n/a |
| SMB / local | (none in priority — priority skips SMB) | serper.searchPlaces (0.05), firecrawl.scrape (0.05) | Always for local/storefront. |
| Visitor de-anonymization | (none — niche) | snitcher.searchSessions (0) | Always for visitor ID — free credits-tier. |
| Warm-intro sourcing | (none — niche) | theSwarm.searchWarmIntrosToCompany (2) | When the goal is intros, not pure prospecting. |
## Person enrichment alternatives
| Goal | Priority | Alternative | When to swap |
|---|---|---|---|
| Person enrichment (LinkedIn URL in hand) | aiArk.enrichPerson (0.1) | linkedin.enrichProfile (0.25) | When you only need LinkedIn-anchored details and no email. |
| | | prospeo.enrichLinkedin (0.5) | Second opinion on a URL-anchored miss. |
| Person enrichment (name + company) | waterfall.enrichContact (2) | apolloio.enrichPerson (1, **3** with phone reveal, priority) | The niche-coverage rung — promote per-batch when a pilot shows Apollo hits where aiArk/waterfall miss (investor-backed, portfolio niches). |
| | | hunter.enrichPerson (1) | Cheap mid-tier alternative. |
| Reverse email → person | (none in priority) | FullEnrich.reverseEmailLookup (2) | Always for email → LinkedIn. |
| Person backfill (heavyweight) | peopleDataLabs.enrichPerson (3) | (none cheaper for heavyweight) | n/a |
## Company enrichment alternatives
| Goal | Priority | Alternative | When to swap |
|---|---|---|---|
| Company firmographics | aiArk.enrichCompany (0.01) | companyEnrich.enrichByDomain (0.25) | Fuller field set. Promote whenever 0.01 comes back thin. |
| | | linkedin.enrichCompany (0.25) | When LinkedIn-anchored details are sufficient. |
| | | apolloio.enrichOrganization (1, priority) | The niche-coverage rung — when the cheaper rungs miss and LinkedIn doesn't have it. |
| Company technographics | builtwith.getDomainSummary (**0**) | theirStack.searchTechnologies (0.5) | When you want catalog-style "show me the tech list" rather than per-domain detection. |
| | | builtwith.enrichDomain (1) | Full stack detail on the rows the free summary left ambiguous. |
## Find email alternatives
| Goal | Priority | Alternative | When to swap |
|---|---|---|---|
| Find email (LinkedIn URL in hand) | aiArk.enrichPerson (0.1) | — | Returns a verified email with the profile and bills 0 when it finds none; run the finders below only on the residue. |
| Find email (default) | FullEnrich.findEmail (1) | hunter.findEmail (0.5) | When budget critical AND okay with lower hit rate. |
| | | icypeas.findEmail (0.1) | Cheap last-resort for very large lists. |
| | | findyMail.findEmail (0.5) | Mid-tier alternative; sometimes finds what hunter misses. |
| | | leadMagic.findEmail (0.5) | Mid-tier alternative. |
| | | dropcontact.findEmail (1) | Better for French/EU data. |
| | | datagma.findEmail (1) | Alt mid-tier. |
## Verify email alternatives
| Goal | Priority | Alternative | When to swap |
|---|---|---|---|
| Verify email | waterfall.verifyEmail (0.1) | icypeas.verifyEmail (0.01) | When verifying very large lists (10× cheaper). |
| | | zeroBounce.verifyEmail (0.1) | Equivalent cost; different underlying provider. |
| | | kitt.verifyEmail (0.05) | Cheaper alternative. |
## Find phone alternatives
| Goal | Priority | Alternative | When to swap |
|---|---|---|---|
| Find phone (mobile, URL in hand) | aiArk.findMobilePhone (0.5) | prospeo.findPhone (3) | Landline/DID fallback; escalate from aiArk on a mobile miss. |
| Find phone (no URL / mobile missed) | FullEnrich.findPhone (6) | prospeo.findPhone (3) | Cheaper first attempt; escalate to FullEnrich on a miss. |
| | | forager.findPhone (5) | Mid-tier. |
| | | findyMail.findPhone (5) | Mid-tier. |
| | | cleon1.findPhoneFromLinkedin (15) | Premium; only for high-value leads where standard sources fail. |
## LinkedIn URL alternatives
| Goal | Priority | Alternative | When to swap |
|---|---|---|---|
| Resolve LinkedIn from name+company | linkedin.findProfileUrl (0.25) | (no cheaper credible alternative) | n/a |
| Resolve LinkedIn from email | FullEnrich.reverseEmailLookup (2) | (no cheaper credible alternative) | n/a |
## When the priority stack genuinely can't serve the goal
Examples:
- "Find every TikTok creator with > 10k followers" — no priority provider has this; need apify.* or specialized scrapers.
- "Get GitHub stars over time for a list of repos" — github connector + custom enrichment.
- "Find every company that uses Stripe Atlas" — niche; might require custom scraping via firecrawl.
For these: defer to [`../SKILL.md`](../SKILL.md) and its [`../agents/execution-plan-creator.md`](../agents/execution-plan-creator.md), which builds a custom chain citing the right long-tail providers.
## Always escalate to a workspace report
If the priority stack misses AND no documented alternative covers the gap, file a `cargo-ai workspaceManagement report create` describing the missing capability. See [`../../cargo-workspace-management/SKILL.md`](../../cargo-workspace-management/SKILL.md) (Reports section).
references/contact-accuracy.md
# Contact accuracy — deterministic QA scripts
Every list that reaches a sequencer or CRM carries three failure modes that
prose diligence misses: the **wrong person** (same-name decoy behind a LinkedIn
URL), the **stale role** (contact left the company; the #1 source of bounces
and bad first lines), and the **unsafe email** (catch-all domains that accept
anything, single-source guesses, role accounts). This reference wires four
runnable TypeScript scripts into the pipeline so those checks are code, not
judgment.
**The rule: run the script — do not re-derive its logic in-context.** The
scripts are deterministic, fixture-tested in CI, and cheaper than reasoning
through 500 rows. If a script's verdict looks wrong, that's a bug report
(`workspaceManagement report create`), not a reason to hand-check rows.
## Runtime
Scripts live in [`../scripts/`](../scripts/) (this skill's directory — resolve
relative to wherever the skill loaded from). They run directly with Node ≥
22.18 (`node <script>.ts`, native type-stripping; `npx tsx <script>.ts` on
older Nodes). Zero dependencies for file mode. Every script supports:
- `--input <file.csv|file.json>` — rows from a file: a CSV from
`run download-outputs`, a JSON array, or raw `action execute-batch` output
(`{"results": [...]}` is unwrapped automatically), **or**
- `--workflow-uuid <uuid>` (+ optional `--batch-uuid`, `--output-node-slug`,
`--workspace-uuid`) — **API mode**: fetches the output rows directly via the
`@cargo-ai/api` package (`npm install -g @cargo-ai/api` if missing), reusing
the CLI's stored login (`~/.config/cargo-ai/credentials.json`) or
`CARGO_API_TOKEN`. Equivalent to `run download-outputs`, no temp file.
- `--output <file>` — write augmented rows (default: stdout). On
`validate-emails.ts` and `contact-accuracy-audit.ts`, `--json` switches the
row output from CSV to a JSON array — use it when the next step is a `jq`
filter (build the paid-verify batch from `recommendation != "skip"` rows;
hand off only `audit_action == "SEND"` rows).
- `--fixtures` — self-test against the bundled fixture file; exits non-zero on
failure (CI runs this on every push).
## The four scripts, in pipeline order
| Stage | Script | Adds columns | Run it… |
|---|---|---|---|
| Before paid verification | `validate-emails.ts` | `email_syntax_valid`, `email_risk` (ok/free/role/disposable/invalid), `recommendation`, `is_duplicate` | on every enriched list, **before** `waterfall.verifyEmail` — culling invalid/disposable/duplicate rows first is free and shrinks the paid verify batch |
| After enrichment | `select-current-role.ts` | `current_title`, `current_company`, `role_confidence` (high/medium/low), `role_reason` | whenever a provider returned an experiences array — never trust the top experience blindly |
| After enrichment | `validate-linkedin-names.ts` | `name_match` (true/false), `name_match_reason` | whenever a LinkedIn URL was looked up from a name (see [`../recipes/linkedin-url-lookup.md`](../recipes/linkedin-url-lookup.md)) — catches same-name decoys |
| Last, before handoff | `contact-accuracy-audit.ts` | `audit_action` (**SEND / VERIFY / REVIEW / REMOVE**), `audit_flags`, `audit_flag_reason` | on the final merged output, after verification — the audit consumes the columns the other three produced (it degrades gracefully if some are missing) |
**The audit must see every row.** Merge verification statuses back onto the
full row set before auditing — never pre-filter to `status == "valid"` first,
or the catch-all/unknown/invalid rows silently vanish along with their
VERIFY/REVIEW/REMOVE verdicts and the receipt counts. Filtering happens once,
after the audit: only `audit_action == "SEND"` rows proceed.
Chaining example (each script reads the previous one's output):
```bash
S=<path-to-this-skill>/scripts
node $S/validate-emails.ts --input outputs.csv --output step1.csv
# … run waterfall.verifyEmail on the survivors, merge results into step2.csv …
node $S/select-current-role.ts --input step2.csv --output step3.csv
node $S/validate-linkedin-names.ts --input step3.csv --output step4.csv
node $S/contact-accuracy-audit.ts --input step4.csv --output final.csv
```
Or audit a finished run in one step, no download:
```bash
node $S/contact-accuracy-audit.ts --workflow-uuid <uuid> --batch-uuid <uuid> --summary-json
```
## What each verdict means
- **SEND** — verified email (or catch-all corroborated by ≥ 2 providers), no
name mismatch, current role confirmed. Safe for the sequencer.
- **VERIFY** — email unproven (catch-all with a single source, or never
verified). Route these rows back through `waterfall.verifyEmail` — that
re-run is paid, so it goes through the pilot gate in
[`cost-discipline.md`](cost-discipline.md).
- **REVIEW** — human-judgment rows: likely job changer (`role_confidence:
low`), or a role account (info@/sales@). Present them to the user; don't
silently send or drop.
- **REMOVE** — wrong person, invalid/disposable email, failed verification, or
a duplicate row (the first occurrence carries the send).
Drop from the batch and report the count in the receipt.
Report the audit summary with every deliverable — the stderr table (or
`--summary-json`) gives the counts to cite in the cost receipt, e.g. "412 SEND
/ 41 VERIFY / 22 REVIEW / 25 REMOVE".
## Fixtures & CI
Each script ships a `fixtures_*.json` next to it — synthetic cases only, no
real contact data. `--fixtures` recomputes every case;
`validate-linkedin-names.ts` additionally enforces precision ≥ 0.95 / recall ≥
0.85 on the match class. CI (`skills-lint` workflow) runs all four on every
push, so a green build means the verdicts you rely on are the verdicts that
were tested.
references/cost-discipline.md
# Cost discipline — pilot gate, receipts, and spend rules
Canonical spend rules for every credits-based action in this skill. Recipes and playbooks link here instead of restating them. These are **mandatory behaviors**, not advice: an agent that skips the pilot gate or the receipt is misusing the skill.
## 1) The pilot → approval → full-run gate (blocking)
Required order for **every** paid batch (anything beyond a handful of records, or any action whose cost is unknown):
```
1. SAMPLE Run a small slice of the EXACT input data through the EXACT config.
1–3 rows to prove one action's config shape.
10–20 records before any BATCH — one row can't show a hit-rate,
and a batch's cost is (per-row cost × hit-rate) × N.
2. APPROVAL Present the approval message (format below). Wait for the user.
It must state the RECORD COUNT to be enrolled and the CREDIT
ESTIMATE for them.
3. FULL RUN Only after explicit approval, fan out across the remaining records.
```
Size the pool before you can quote either number — `segment get <uuid>` → `recordsCount`, a `storage query execute` count, or `wc -l` on the input file. All free. Approval of the sample is **not** approval of the full run; ask again, explicitly. Batch-sampling mechanics per data kind (`filter` + `limit`, `recordIds`, sliced `records`, truncated CSV) live in [`../../cargo-orchestration/SKILL.md`](../../cargo-orchestration/SKILL.md) → "Create a batch".
The approval message has four required sections. **If any section is missing, stay in AWAIT_APPROVAL — do not run paid or cost-unknown actions.**
```
ASSUMPTIONS
Define every judgment call operationally, not vaguely.
Bad: "best contact per company"
Good: "best contact = highest-ranked current employee matching RevOps/GTM-ops
titles, weighted Chief > VP > Head > Director > Lead > Manager"
Declare data decisions already made (rows dropped and why, domains fixed)
and the cost trade-off chosen (cheap chain vs premium play, and why).
SAMPLE RESULT (verbatim)
Rows run, credits spent, per-row cost, hit-rate — observed numbers,
not catalog numbers. Paste a preview of the actual output rows.
CREDITS · SCOPE · CAP
Both numbers, always, in one line the user can decide on:
- HOW MANY records the full run would enroll (the counted pool minus
the sample), and
- WHAT IT COSTS = observed per-row cost × remaining rows.
Reconcile against the ACTUAL balance (see §2) — if the estimate exceeds
the balance, say so BEFORE the user hits it mid-run.
APPROVE?
Offer 3 shaped choices, never bare yes/no:
1. Run until the budget cap is hit (state how many rows that covers).
2. Top up first, then run everything clean.
3. Trim scope to fit the budget (propose the trimming heuristic —
e.g. "keep the ~45 companies with funding data + RevOps team ≥ 2").
Option 3 is usually the operator move: reshape scope instead of asking
for more budget.
```
Check the balance before quoting an estimate:
```bash
cargo-ai billing subscription get
# remaining = subscriptionAvailableCreditsCount - subscriptionCreditsUsedCount
```
### The estimate has two terms, not one
```
credits = (provider cost per record × records) # what the cost table prices
+ (node executions per record × records / 100) # the execution charge
```
**Every node execution bills 0.01 credits — 1 per 100 — whatever the node is.** `branch`, `filter`, `switch`, `variables` and the other structural natives carry no provider price and are still not free, and errored executions bill too. The credits cost table prices *actions*; it has no row for a step, so an estimate built from it alone omits the second term entirely.
It is a rounding error on an action-heavy chain (a 2-credit `enrichContact` dwarfs the 8 steps around it) and the *whole* bill on a step-heavy, action-light one — a 12-node routing sweep over 20,000 records is 2,400 credits with no provider call in it. Two consequences for the gate above:
- **Measure it on the pilot**, where it is free to observe: the sample's execution count is `length(run.executions)` per record, or `cargo-ai billing usage get-metrics --unit orchestration.executions` over the sample window (`success` + `error` are execution counts, not credits). Never estimate it from the graph you *think* ran — loops, retries, tool internals and agent steps all multiply it.
- **Quote it in `CREDITS · SCOPE · CAP`** whenever it is more than ~10% of the total. A user approving "1,225 records ≈ 502 credits" who is then billed 640 was not given the number they approved.
The charge is attributed to no node — `executions[].creditsUsedCount` is provider cost only and reads `0` on a native that billed — so it is invisible in per-node diagnostics. Full accounting: [`../../cargo-billing/SKILL.md`](../../cargo-billing/SKILL.md) → "The execution charge".
## 2) Per-run receipt (after every paid action)
After **every** paid action or batch — pilot included — report:
1. **Credits spent + balance remaining** — "12.4 credits spent, ~31 left." Use the billing figure, not your own sum of action prices: it includes the execution charge, which your arithmetic will not.
2. **Hit-rate** — "found 34 emails of 40 contacts (85%)", per field when the action returns several ("RevOps count 67/70 · funding 31/70"). Flag rows to distrust, don't silently include them.
3. **Estimate vs actual, with the why** — only when they diverge: "cost 7.5 credits vs 3–5 estimated: theirStack billed per returned job posting, and 12 companies had >5 postings each."
Prefer the billing source of truth over your own arithmetic:
```bash
cargo-ai billing usage get-metrics --workflow-uuid <uuid>
```
A receipt is not optional bookkeeping — it is what makes the next-step suggestion and the next approval trustworthy.
**On a new account, frame the balance against the free tier.** A new workspace starts with **100 free credits, no card** — so "12.4 spent, 87.6 of your 100 free credits left" is the receipt a first-time user can actually act on, where "87.6 remaining" is a number with no scale. Two consequences for how you spend them:
- **Lead with the cheap rungs harder than usual.** 100 credits is ~5,000 sourced leads or ~50 fully enriched contacts — the same budget, two orders of magnitude apart depending on the chain. A first session that burns the tier on `findPhone` (6–7/lookup) leaves the user with nothing to try next.
- **Say what's left in the tier when proposing the next step.** "With ~88 free credits left, verifying all 400 of these runs ~40" is a decision the user can make in one word; "that'll cost about 40 credits" is not.
## 3) Over-provision 1.4×N, then filter — never chase misses
Provider coverage is a property of the target company, not something more retries can overcome. Contact search typically misses 15–20% of companies; email waterfalls miss another 5–10% of contacts.
- To deliver N complete rows, **source ~1.4×N** and let the misses fall out.
- **Drop incomplete rows instead of re-running them** through more providers — the marginal credits go to the same rows that already missed.
- Stop at ~80% of target and filter, rather than restarting the chain for the tail.
## 4) Count first, pay second
Size the pool before paying for it:
- Use free lookups (`orchestration action list <keywords>` — or `connection action search <keywords> --credits-only` to see only the paid ones — plus model SQL counts and existing segments) and the cheapest search page before any paid pull. `action list` returns each action's `credits` cost table, so the price is knowable before the call rather than after.
- **Keep `limit`/page sizes strict** — search actions are billed on *returned* rows, not on matched totals. Where a provider returns a `total_count` alongside results, a 1-row request sizes the whole TAM for the price of one row.
- Never pull a full result set "to see what's there." Decide the filter from a small page, then pull exactly the scope approved in §1.
## 5) Provider-billing rules
- **Prefer pay-on-success actions** when coverage is uncertain. If a provider bills per attempt, prove quality on the pilot before scaling.
- **Phone is the guarded lever** — the escalation tier runs 3–7 credits/record, ~10× email. `aiArk.findMobilePhone` (0.5, mobile-only, LinkedIn-URL or domain+name anchored) is the cheap first rung and bills 0 on a miss, but the rule is unchanged: never include phone lookup in a default chain; it enters a plan only on explicit user request, on qualified leads only.
- Cheap-but-low-hit-rate providers are not savings: total spend is dominated by misses, not per-call price (see [`alternatives.md`](alternatives.md)).
## 6) Context discipline
Never read a large CSV/JSON export into the conversation context — it's the most common way to blow a session. Inspect exports with `head`, `jq`, or a storage SQL query, and pass files by path. Receipts and previews (a few rows) belong in context; datasets don't.
## Where this gate is applied
- The plan agent ([`../agents/execution-plan-creator.md`](../agents/execution-plan-creator.md)) emits plans in the §1 approval format.
- Every recipe's batch step assumes the gate ran; per-recipe credit-budget tables give the *catalog* estimate, the pilot gives the *observed* one — trust the pilot.
- Waterfall chains add their own stop-early rules on top: see [`waterfall-strategy.md`](waterfall-strategy.md).
references/credits-cost-table.md
# Credits cost table
Every credits-based action Cargo can run — 176 of the 513 actions exposed by 123 of the catalog's 136 integrations, plus Cargo's own native actions — sorted by cost. The other 337 carry no *provider* price; they are not free, because every node execution bills 0.01 credits (1 per 100) regardless. See [`../../cargo-billing/SKILL.md`](../../cargo-billing/SKILL.md) → "The execution charge".
Rows whose provider is `native` are Cargo's own platform actions, run as `{"kind":"native","actionSlug":"<action>"}` with no integration; every other row runs as `{"kind":"connector","integrationSlug":"<provider>","actionSlug":"<action>"}`.
**Generated. Do not edit by hand** — this is a snapshot of the live catalog, which is where pricing actually lives. Regenerate from `action list`, which returns a `credits` array on every billed action:
```sh
cargo-ai orchestration action list --kind connector
cargo-ai orchestration action list --kind native
```
Omit `--limit` so both return the full set, then render one row per action. Each `credits` entry is one of three shapes: `fixed` bills `cost` per call; `unit` bills `cost` per `unit` consumed; `package` bills `cost` per block of `unitsCount` `unit`. For `unit` and `package`, `fixedCost` is a base charge that **adds to** the metered rate rather than replacing it — a search billed `0.175` `fixedCost` + `0.025` per item costs `0.2` for one item. Several entries mean the price depends on config, and each entry's `config.jsonSchema` const/enum is what selects it; those go in the per-config section at the end rather than the main table.
Generated: 2026-08-28
| Cost | Provider | Category | Action | Description |
|---|---|---|---|---|
| 0 | `aiArk` | enrichment | `countCompanies` | Count how many companies match company filters or lookalike domains, without retrieving them |
| 0 | `aiArk` | enrichment | `countPeople` | Count how many people match person and company filters, without retrieving them |
| 0 | `builtwith` | enrichment | `getDomainSummary` | Get summary technology-group counts for a domain (Free API) |
| 0 / item | `sillage` | sales | `searchLeads` | Search the leads Sillage collected on the monitored accounts of a listen signals model |
| 0 / item | `snitcher` | enrichment | `searchSessions` | Search and retrieve website visitor sessions with filtering options for date ranges, URLs, and referrers |
| 0–1 / person | `apolloio` | enrichment | `searchPeople` | Search Apollo's people database by person, company, technology, and hiring filters |
| 0–3 | `contactOut` | enrichment | `enrich` | Find data from an email. It returns data person / company information as the response |
| 0.006–0.5 / 1k token + base | `openAi` | freeform | `instruct` | Instruct prompt |
| 0.01 | `aiArk` | enrichment | `enrichCompany` | Retrieve firmographics for a single company from its domain or LinkedIn URL |
| 0.01 / item | `aiArk` | enrichment | `searchCompanies` | Search for companies matching company filters or lookalike domains |
| 0.01 / organization | `apolloio` | enrichment | `searchOrganizations` | Search Apollo's company database by firmographic, funding, technology, and hiring filters |
| 0.01 | `icypeas` | enrichment | `verifyEmail` | Verify a person's email status |
| 0.01 | `piloterr` | enrichment | `getG2ProductInfo` | Retrieve detailed information about a product from G2 including reviews, ratings, pricing plans, and product specificati… |
| 0.01–0.25 / 1k token + base | `gemini` | freeform | `instruct` | Instruct prompt |
| 0.02 / 100 item | `icypeas` | enrichment | `findCompanies` | Search the Icypeas lead database for companies matching the given criteria. Returns a paginated list of matching compani… |
| 0.02 / 100 item | `icypeas` | enrichment | `findPeople` | Search the Icypeas lead database for people matching the given criteria. Returns a paginated list of matching profiles. |
| 0.02 / 1k token | `native` | platform | `fileSearch` | Search files |
| 0.02 / item | `salesNavigator` | enrichment | `extractLeadSearch` | Retrieve leads from Sales Navigator |
| 0.02 / item | `salesNavigator` | enrichment | `searchLeads` | Search and retrieve contact profiles from Sales Navigator based on various filters including company, role, location, an… |
| 0.02 | `x` | enrichment | `getFollowers` | Get the followers of an X account |
| 0.02 | `x` | enrichment | `getFollowing` | Get the accounts an X account is following |
| 0.02 | `x` | enrichment | `getPostComments` | Get the replies (comments) on an X post |
| 0.02 | `x` | enrichment | `getPostDetails` | Get a single X post (tweet) with its engagement metrics |
| 0.02 | `x` | enrichment | `getPostLikers` | Get the X accounts that liked a post |
| 0.02 | `x` | enrichment | `getQuoteTweets` | Get the posts that quote-tweeted an X post |
| 0.02 | `x` | enrichment | `getRetweeters` | Get the X accounts that reposted (retweeted) a post |
| 0.02 | `x` | enrichment | `getUserLikes` | Get the posts recently liked by an X account |
| 0.02 | `x` | enrichment | `getUserMedia` | Get the recent media posts (photos/videos) of an X account |
| 0.02 | `x` | enrichment | `getUserPosts` | Get the recent posts (tweets) published by an X account |
| 0.02 | `x` | enrichment | `getUserProfile` | Get the profile of an X account (bio, followers, links, …) |
| 0.02 | `x` | enrichment | `getUserReplies` | Get the recent replies posted by an X account |
| 0.02 | `x` | enrichment | `searchPeople` | Search X accounts (people) by keyword |
| 0.02 | `x` | enrichment | `searchPosts` | Search X posts (tweets) by keyword or advanced query |
| 0.025 / url | `parallel` | enrichment | `extract` | Extract relevant content from specific web URLs using Parallel AI |
| 0.05 | `aiArk` | enrichment | `analyzePersonality` | Analyze a LinkedIn profile to get personality insights (OCEAN, DISC) and tailored selling and hiring guidance |
| 0.05 | `aiArk` | enrichment | `reverseLookup` | Find a person's full profile from an email address or a phone number |
| 0.05 / item | `aiArk` | enrichment | `searchPeople` | Search for people matching person and company filters |
| 0.05 / item | `firecrawl` | enrichment | `crawl` | Recursively search through a urls subdomains, and gather the content |
| 0.05 / item | `firecrawl` | enrichment | `scrape` | Turn any url into clean data |
| 0.05 / item | `firecrawl` | enrichment | `search` | Search the web using Firecrawl |
| 0.05 | `kitt` | sales | `verifyEmail` | Verify an email address |
| 0.05 / item | `linkedin` | enrichment | `extractCompanyViewers` | Extract the list of people who viewed a LinkedIn company page you administrate over the past year. |
| 0.05 / item | `linkedin` | enrichment | `extractEventAttendees` | Extract the attendees of a LinkedIn event. |
| 0.05 / item | `linkedin` | enrichment | `extractFollowers` | Extract the list of people who follow the connected LinkedIn profile. |
| 0.05 / item | `linkedin` | enrichment | `extractPageFollowers` | Extract the list of people who follow a LinkedIn company page you administrate, with the date each one followed. |
| 0.05 / item | `linkedin` | enrichment | `extractProfileCommentActivity` | Extract the comment activity history of a LinkedIn profile, showing posts they have commented on |
| 0.05 / item | `linkedin` | enrichment | `extractProfilePostActivity` | Extract the post activity history of a LinkedIn profile, showing content they have published |
| 0.05 / item | `linkedin` | enrichment | `extractProfileReactionActivity` | Extract the reaction activity history of a LinkedIn profile, showing posts they have liked or reacted to |
| 0.05 / item | `linkedin` | enrichment | `extractProfileViewers` | Extract the list of people who have viewed your LinkedIn profile recently. |
| 0.05 / item | `linkedin` | enrichment | `searchPostComments` | Search for post comments |
| 0.05 / item | `linkedin` | enrichment | `searchPostReactions` | Search for post reactions |
| 0.05 / item | `salesNavigator` | enrichment | `extractAccountSearch` | Retrieve accounts from Sales Navigator |
| 0.05 / item | `salesNavigator` | enrichment | `searchAccounts` | Search and retrieve company accounts from Sales Navigator based on various filters including headcount, location, indust… |
| 0.05 | `serper` | enrichment | `search` | Retrieve Google searches |
| 0.05 | `serper` | enrichment | `searchPlaces` | Retrieve Google places |
| 0.05–4 / 1k token + base | `anthropic` | freeform | `instruct` | Instruct prompt |
| 0.1 | `aiArk` | enrichment | `enrichPerson` | Enrich a person's full profile and find their verified email from a LinkedIn URL or an AI-Ark person ID |
| 0.1 | `brightData` | enrichment | `scrapeFacebookPagePosts` | Scrape Facebook page posts by URL including content, engagement metrics, and attachments |
| 0.1 | `brightData` | enrichment | `scrapeFacebookProfile` | Scrape Facebook page or profile data by URL including name, followers, contact info, and business details |
| 0.1 | `brightData` | enrichment | `scrapeInstagramProfile` | Scrape Instagram profile data by URL including follower count, posts, bio, and engagement metrics |
| 0.1 | `brightData` | enrichment | `scrapeTikTokProfile` | Scrape TikTok profile data by URL including follower count, likes, videos, and engagement metrics |
| 0.1 | `brightData` | enrichment | `scrapeTwitterProfile` | Scrape X (Twitter) profile data by URL including follower count, posts, bio, and engagement metrics |
| 0.1 | `brightData` | enrichment | `scrapeYouTubeChannel` | Scrape YouTube channel data by URL including subscriber count, videos, views, and top videos |
| 0.1 | `enrichley` | enrichment | `verify` | Verify email |
| 0.1 | `enrowio` | enrichment | `verifyEmail` | Verify a person's email |
| 0.1 | `icypeas` | enrichment | `findEmail` | Find an email address from a firstname, a lastname and a company domain name. |
| 0.1 | `icypeas` | enrichment | `scanDomain` | A special route in order to completely scan a domain. Scanning a domain allows you to discover all role-based email addr… |
| 0.1 | `native` | platform | `sendEmail` | Send an email from one of your mailboxes |
| 0.1 | `waterfall` | enrichment | `verifyEmail` | Verify a person's email |
| 0.1 | `zeroBounce` | enrichment | `verifyEmail` | Verify a person's email status. |
| 0.125–60 | `parallel` | enrichment | `createTask` | Execute a web research task using Parallel AI. Supports complex queries that require deep research, analysis, and struct… |
| 0.125 + 0.025 / item | `parallel` | enrichment | `search` | Search the web with Parallel AI and return ranked results with relevant excerpts |
| 0.025 / item + base | `exa` | enrichment | `search` | Search the web with Exa and return ranked results |
| 0.2 | `neverBounce` | enrichment | `verifyEmail` | Verify an email address |
| 0.25 | `companyEnrich` | enrichment | `enrichByDomain` | Retrieve company information by domain name |
| 0.25 | `companyEnrich` | enrichment | `getWorkforce` | Returns workforce insights including historical headcount by department. Useful for tracking department-level growth and… |
| 0.25 | `companyEnrich` | enrichment | `lookupPerson` | Looks up a person by email address. Resolves the company from the email domain first, then matches the person by email l… |
| 0.25 | `findyMail` | enrichment | `verifyEmail` | Verify email for potential bounce |
| 0.25 | `linkedin` | enrichment | `commentPost` | Comment LinkedIn posts |
| 0.25 | `linkedin` | enrichment | `commentPostComment` | Comment LinkedIn post comments |
| 0.25 | `linkedin` | enrichment | `connectProfile` | Connect to LinkedIn profiles |
| 0.25 | `linkedin` | enrichment | `enrichCompany` | Retrieve information about a company |
| 0.25 | `linkedin` | enrichment | `enrichJob` | Retrieve information about a job |
| 0.25 | `linkedin` | enrichment | `enrichPost` | Retrieve information about a LinkedIn post including content, author, engagement metrics, and media |
| 0.25 | `linkedin` | enrichment | `enrichProfile` | Retrieve information about a profile |
| 0.25 | `linkedin` | enrichment | `extractCompanyEmployeesInsights` | Extract employee insights and analytics from a LinkedIn company page, including headcount by function, location, and sen… |
| 0.25 | `linkedin` | enrichment | `extractSimilarCompanies` | Extract a list of companies similar to a given LinkedIn company page, based on LinkedIn's recommendations |
| 0.25 | `linkedin` | enrichment | `findProfileUrl` | Find a LinkedIn profile URL from a name |
| 0.25 | `linkedin` | enrichment | `followProfile` | Follow LinkedIn profiles |
| 0.25 | `linkedin` | enrichment | `likePost` | Like LinkedIn posts |
| 0.25 | `linkedin` | enrichment | `messageProfile` | Send a direct message to a LinkedIn connection |
| 0.25 | `linkedin` | enrichment | `searchPosts` | Search for posts |
| 0.25 | `linkedin` | enrichment | `visitProfile` | Visit LinkedIn profiles |
| 0.25 | `salesNavigator` | enrichment | `findCompanyInsights` | Retrieve insights about a company from Sales Navigator |
| 0.25 | `salesNavigator` | enrichment | `findCompanyMetrics` | Retrieve metrics about a company from Sales Navigator |
| 0.25 | `salesNavigator` | enrichment | `findEmployeesCount` | Retrieve employees count from Sales Navigator |
| 0.25 | `salesNavigator` | enrichment | `findEmployeesDistribution` | Retrieve employees distribution from Sales Navigator |
| 0.25 | `salesNavigator` | enrichment | `searchCompanyMetrics` | Get total result count metrics for a Sales Navigator company search URL |
| 0.25 | `salesNavigator` | enrichment | `searchPersonMetrics` | Get metrics and statistics for a Sales Navigator person search, including total results count |
| 0.3 | `bouncer` | enrichment | `verifyEmail` | Verify an email address |
| 0.3–1 / 1k token | `perplexity` | freeform | `instruct` | Instruct prompt |
| 0.5 | `aiArk` | enrichment | `findMobilePhone` | Find a person's mobile phone number from a LinkedIn URL, or from a company domain and a full name |
| 0.5 | `findyMail` | enrichment | `findEmail` | Retrieve email given a name and domain |
| 0.5 | `hunter` | enrichment | `findEmail` | Find a person's email |
| 0.5 | `leadMagic` | enrichment | `findEmail` | Find email given a name and domain |
| 0.5 | `linkedin` | enrichment | `enrichCompanyFromDomain` | Retrieve information about a company from domain |
| 0.5 | `linkedin` | enrichment | `enrichProfileFromName` | Retrieve information about a profile from name |
| 0.5 | `linkedin` | enrichment | `findCustomHeadcount` | Find the number of people in a company |
| 0.5 | `linkedin` | enrichment | `searchJobs` | Search for jobs |
| 0.5 | `native` | platform | `modelAsk` | Query your model with a question |
| 0.5 | `prospeo` | enrichment | `enrichCompany` | Enrich a company with B2B firmographics data |
| 0.5 | `prospeo` | enrichment | `enrichLinkedin` | Retrieve information about a person's Linkedin profile |
| 0.5 | `prospeo` | enrichment | `findEmail` | Find a person's email address using their name and company domain |
| 0.5 / item | `theirStack` | enrichment | `searchCompanies` | Search for companies |
| 0.5 / item | `theirStack` | enrichment | `searchJobs` | Search for jobs |
| 0.5 | `theirStack` | enrichment | `searchTechnologies` | Search for technologies |
| 0.5–2 | `linkup` | enrichment | `search` | Search for results using Linkup |
| 1 | `apolloio` | enrichment | `enrichOrganization` | Enrich an organization |
| 1 | `builtwith` | enrichment | `enrichDomain` | Look up the full technology stack and metadata for a domain |
| 1 / item | `companyEnrich` | enrichment | `findSimilarCompanies` | Find similar companies |
| 1 | `datagma` | enrichment | `findEmail` | Retrieve a person's email |
| 1 | `dropcontact` | enrichment | `findEmail` | Find a person's email using their first and last name |
| 1 | `enrichCrm` | enrichment | `enrichCompany` | Enrich company given domain |
| 1 | `enrichCrm` | enrichment | `enrichPerson` | Enrich person given email or full name + domain or first name + last name + domain |
| 1 | `enrichCrm` | enrichment | `findEmail` | Find email using first name, last name, full name, company, LinkedIn, country |
| 1 | `enrichCrm` | enrichment | `getFunding` | Get company financial and funding data given a domain |
| 1 | `enrowio` | enrichment | `findEmail` | Find a person's email |
| 1 | `FullEnrich` | enrichment | `findEmail` | Find a person's email address using their first name, last name, company name, domain name, or LinkedIn URL |
| 1 | `g2` | enrichment | `enrichProduct` | Retrieve detailed information about a product from G2 including reviews, ratings, and product specifications |
| 1 | `hunter` | enrichment | `enrichPerson` | Enrich a person's information |
| 1 | `hunter` | enrichment | `searchDomain` | Search for people in a domain |
| 1 | `hunter` | enrichment | `verifyEmail` | Verify a person's email status |
| 1 | `linkup` | enrichment | `instruct` | Get structured or sourced answers using Linkup |
| 1 | `oceanio` | enrichment | `enrichCompany` | Retrieve company data |
| 1 | `oceanio` | enrichment | `enrichPerson` | Retrieve person data |
| 1 / item | `oceanio` | enrichment | `searchCompanies` | Search for companies |
| 1 | `oceanio` | enrichment | `searchPeople` | Search for people |
| 1 | `proxycurl` | enrichment | `enrich` | Retrieve information about a person/organization |
| 1 / item | `proxycurl` | enrichment | `search` | Retrieve object records |
| 1 | `reverseContact` | enrichment | `enrichCompanyFromLinkedin` | Retrieve information about a company from Linkedin |
| 1 | `rocketreach` | enrichment | `lookupPerson` | Lookup person and company |
| 1 | `waterfall` | enrichment | `enrichCompany` | Retrieve company data |
| 1–3 / item | `contactOut` | enrichment | `search` | Search person / company data from linkedin URL |
| 1–9 | `apolloio` | enrichment | `enrichPerson` | Enrich a person |
| 2 | `datagma` | enrichment | `enrichPersonFromPersonalEmail` | Retrieve a person's profile from a personal email address (outside of the EU) |
| 2 | `forager` | enrichment | `findPersonalEmail` | Find a person's personal email |
| 2 | `forager` | enrichment | `findWorkEmail` | Find a person's work email |
| 2 | `FullEnrich` | enrichment | `reverseEmailLookup` | Find a person's LinkedIn profile and company information from their email address |
| 2 | `theSwarm` | enrichment | `searchWarmIntrosToCompany` | Search for warm intros to a company, filtering for target company employees with the desired job function and seniority. |
| 2 | `theSwarm` | enrichment | `searchWarmIntrosToPerson` | Search for warm introductions to a specific person using their LinkedIn profile. |
| 2 | `waterfall` | enrichment | `enrichContact` | Retrieve a contact |
| 3 | `leadMagic` | enrichment | `enrichProfile` | Enrich profile data |
| 3 | `peopleDataLabs` | enrichment | `enrichCompany` | Retrieve information about a company |
| 3 | `peopleDataLabs` | enrichment | `enrichPerson` | Retrieve information about a person |
| 3 / item | `peopleDataLabs` | enrichment | `queryCompanies` | Query companies |
| 3 / item | `peopleDataLabs` | enrichment | `queryPeople` | Query people |
| 3 / item | `peopleDataLabs` | enrichment | `searchCompanies` | Search for companies |
| 3 / item | `peopleDataLabs` | enrichment | `searchPeople` | Search for people |
| 3 | `prospeo` | enrichment | `findPhone` | Find a person's phone number using linkedin url |
| 3 | `waterfall` | enrichment | `detectJobChange` | Detect if a contact has changed jobs. Returns the job change status (MOVED, LEFT, NO_CHANGE, UNKNOWN) and updated person… |
| 3 / item | `waterfall` | enrichment | `searchProspects` | Search contacts and their companies |
| 4 | `mixrank` | enrichment | `findCompany` | Retrieve a person or company information |
| 4 | `mixrank` | enrichment | `findPerson` | Find a person using various identifiers like email, phone, name, or company details |
| 4 | `societeInfo` | enrichment | `enrich` | Retrieve information about a contact/company |
| 4 / item | `societeInfo` | enrichment | `search` | Search for a company or contact |
| 5 | `findyMail` | enrichment | `findPhone` | Retrieve phone number given a linkedin URL |
| 5 | `forager` | enrichment | `findPhone` | Find a person's phone number |
| 6 | `FullEnrich` | enrichment | `findPhone` | Find a person's phone number using their first name, last name, company name, domain name, or LinkedIn URL |
| 6 | `salesNavigator` | enrichment | `searchLeadsLegacy` | Retrieve leads from Sales Navigator |
| 7 | `FullEnrich` | enrichment | `findPhoneAndEmail` | Find a person's email and phone number using their first name, last name, company name, domain name, or LinkedIn URL |
| 7 | `waterfall` | enrichment | `findPhone` | Retrieve a person's phone number |
| 8 | `datagma` | enrichment | `enrichPerson` | Enrich a person from their LinkedIn profile URL or professional email |
| 8 | `datagma` | enrichment | `findPhone` | Retrieve a person's phone number |
| 8 | `datagma` | enrichment | `findPhoneAndEmail` | Retrieve both phone number and email address for a person |
| 15 | `cleon1` | enrichment | `findPhone` | Find a person's phone number using their first and last name, optionally refined with company information |
| 15 | `cleon1` | enrichment | `findPhoneFromLinkedin` | Find a person's phone number using their Linkedin URL |
## Actions whose price depends on config
These bill differently depending on how the node is configured, so the range above is not a quote. Pick the row that matches the config you are about to run.
### `apolloio.searchPeople` — varies by `shouldEnrich`
| Config | Cost |
|---|---|
| shouldEnrich=true | 1 / person |
| shouldEnrich=false | 0 / person |
### `contactOut.enrich` — varies by `objectType`, `includePhone`, `emailType`
| Config | Cost |
|---|---|
| objectType=company | 0 |
| objectType=contact, includePhone=false, emailType empty | 1 |
| objectType=contact, includePhone=false, emailType set | 2 |
| objectType=contact, includePhone=true | 3 |
### `openAi.instruct` — varies by `model`, `advancedSettings.withWebSearch`
| Config | Cost |
|---|---|
| model=gpt-5.6-sol, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=gpt-5.6-terra, advancedSettings.withWebSearch=true | 0.4 + 0.03 / 1k token |
| model=gpt-5.6-luna, advancedSettings.withWebSearch=true | 0.4 + 0.006 / 1k token |
| model=gpt-5-nano, advancedSettings.withWebSearch=true | 0.4 + 0.006 / 1k token |
| model=gpt-5-mini, advancedSettings.withWebSearch=true | 0.4 + 0.03 / 1k token |
| model=gpt-5, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=gpt-5.5, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=gpt-5.4, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=gpt-5.4-mini, advancedSettings.withWebSearch=true | 0.4 + 0.03 / 1k token |
| model=gpt-5.4-nano, advancedSettings.withWebSearch=true | 0.4 + 0.006 / 1k token |
| model=gpt-5.3, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=gpt-5.3-mini, advancedSettings.withWebSearch=true | 0.4 + 0.03 / 1k token |
| model=gpt-5.3-nano, advancedSettings.withWebSearch=true | 0.4 + 0.006 / 1k token |
| model=gpt-5.2, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=gpt-5.1, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=gpt-4.1-nano, advancedSettings.withWebSearch=true | 0.4 + 0.01 / 1k token |
| model=gpt-4.1-mini, advancedSettings.withWebSearch=true | 0.4 + 0.05 / 1k token |
| model=gpt-4.1, advancedSettings.withWebSearch=true | 0.4 + 0.3 / 1k token |
| model=gpt-4o-mini, advancedSettings.withWebSearch=true | 0.4 + 0.02 / 1k token |
| model=gpt-4o, advancedSettings.withWebSearch=true | 0.4 + 0.5 / 1k token |
| model=gpt-3.5-turbo, advancedSettings.withWebSearch=true | 0.4 + 0.5 / 1k token |
| model=gpt-5.6-sol, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=gpt-5.6-terra, advancedSettings.withWebSearch=false | 0.03 / 1k token |
| model=gpt-5.6-luna, advancedSettings.withWebSearch=false | 0.006 / 1k token |
| model=gpt-5-nano, advancedSettings.withWebSearch=false | 0.006 / 1k token |
| model=gpt-5-mini, advancedSettings.withWebSearch=false | 0.03 / 1k token |
| model=gpt-5, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=gpt-5.5, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=gpt-5.4, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=gpt-5.4-mini, advancedSettings.withWebSearch=false | 0.03 / 1k token |
| model=gpt-5.4-nano, advancedSettings.withWebSearch=false | 0.006 / 1k token |
| model=gpt-5.3, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=gpt-5.3-mini, advancedSettings.withWebSearch=false | 0.03 / 1k token |
| model=gpt-5.3-nano, advancedSettings.withWebSearch=false | 0.006 / 1k token |
| model=gpt-5.2, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=gpt-5.1, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=gpt-4.1-nano, advancedSettings.withWebSearch=false | 0.01 / 1k token |
| model=gpt-4.1-mini, advancedSettings.withWebSearch=false | 0.05 / 1k token |
| model=gpt-4.1, advancedSettings.withWebSearch=false | 0.3 / 1k token |
| model=gpt-4o-mini, advancedSettings.withWebSearch=false | 0.02 / 1k token |
| model=gpt-4o, advancedSettings.withWebSearch=false | 0.5 / 1k token |
| model=gpt-3.5-turbo, advancedSettings.withWebSearch=false | 0.5 / 1k token |
### `gemini.instruct` — varies by `model`, `advancedSettings.withWebSearch`
| Config | Cost |
|---|---|
| model=gemini-3.6-flash, advancedSettings.withWebSearch=true | 0.4 + 0.25 / 1k token |
| model=gemini-3.5-flash-lite, advancedSettings.withWebSearch=true | 0.4 + 0.08 / 1k token |
| model=gemini-3.1-pro-preview, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=gemini-3-pro-preview, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=gemini-3-flash-preview, advancedSettings.withWebSearch=true | 0.4 + 0.05 / 1k token |
| model=gemini-2.5-pro, advancedSettings.withWebSearch=true | 0.4 + 0.15 / 1k token |
| model=gemini-2.5-flash, advancedSettings.withWebSearch=true | 0.4 + 0.03 / 1k token |
| model=gemini-1.5-pro, advancedSettings.withWebSearch=true | 0.4 + 0.1 / 1k token |
| model=gemini-1.5-flash, advancedSettings.withWebSearch=true | 0.4 + 0.01 / 1k token |
| model=gemini-2.0-flash, advancedSettings.withWebSearch=true | 0.4 + 0.01 / 1k token |
| model=gemini-3.6-flash, advancedSettings.withWebSearch=false | 0.25 / 1k token |
| model=gemini-3.5-flash-lite, advancedSettings.withWebSearch=false | 0.08 / 1k token |
| model=gemini-3.1-pro-preview, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=gemini-3-pro-preview, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=gemini-3-flash-preview, advancedSettings.withWebSearch=false | 0.05 / 1k token |
| model=gemini-2.5-pro, advancedSettings.withWebSearch=false | 0.15 / 1k token |
| model=gemini-2.5-flash, advancedSettings.withWebSearch=false | 0.03 / 1k token |
| model=gemini-1.5-pro, advancedSettings.withWebSearch=false | 0.1 / 1k token |
| model=gemini-1.5-flash, advancedSettings.withWebSearch=false | 0.01 / 1k token |
| model=gemini-2.0-flash, advancedSettings.withWebSearch=false | 0.01 / 1k token |
### `anthropic.instruct` — varies by `model`, `advancedSettings.withWebSearch`
| Config | Cost |
|---|---|
| model=claude-sonnet-5, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=claude-fable-5, advancedSettings.withWebSearch=true | 0.4 + 4 / 1k token |
| model=claude-opus-4-8, advancedSettings.withWebSearch=true | 0.4 + 2 / 1k token |
| model=claude-opus-4-7, advancedSettings.withWebSearch=true | 0.4 + 2 / 1k token |
| model=claude-opus-4-6, advancedSettings.withWebSearch=true | 0.4 + 2 / 1k token |
| model=claude-opus-4-1-20250805, advancedSettings.withWebSearch=true | 0.4 + 2 / 1k token |
| model=claude-opus-4-20250514, advancedSettings.withWebSearch=true | 0.4 + 2 / 1k token |
| model=claude-sonnet-4-20250514, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=claude-sonnet-4-6, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=claude-sonnet-4-5-20250929, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=claude-3-7-sonnet-latest, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=claude-3-5-sonnet-latest, advancedSettings.withWebSearch=true | 0.4 + 0.2 / 1k token |
| model=claude-3-5-haiku-latest, advancedSettings.withWebSearch=true | 0.4 + 0.05 / 1k token |
| model=claude-sonnet-5, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=claude-fable-5, advancedSettings.withWebSearch=false | 4 / 1k token |
| model=claude-opus-4-8, advancedSettings.withWebSearch=false | 2 / 1k token |
| model=claude-opus-4-7, advancedSettings.withWebSearch=false | 2 / 1k token |
| model=claude-opus-4-6, advancedSettings.withWebSearch=false | 2 / 1k token |
| model=claude-opus-4-1-20250805, advancedSettings.withWebSearch=false | 2 / 1k token |
| model=claude-opus-4-20250514, advancedSettings.withWebSearch=false | 2 / 1k token |
| model=claude-sonnet-4-20250514, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=claude-sonnet-4-6, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=claude-sonnet-4-5-20250929, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=claude-3-7-sonnet-latest, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=claude-3-5-sonnet-latest, advancedSettings.withWebSearch=false | 0.2 / 1k token |
| model=claude-3-5-haiku-latest, advancedSettings.withWebSearch=false | 0.05 / 1k token |
### `parallel.createTask` — varies by `processor`
| Config | Cost |
|---|---|
| processor=lite | 0.125 |
| processor=base | 0.25 |
| processor=core | 0.625 |
| processor=core2x | 1.25 |
| processor=pro | 2.5 |
| processor=ultra | 7.5 |
| processor=ultra2x | 15 |
| processor=ultra4x | 30 |
| processor=ultra8x | 60 |
| any config | 0.625 |
### `exa.search` — varies by `searchType`
| Config | Cost |
|---|---|
| searchType=deep | 0.3 + 0.025 / item |
| any config | 0.175 + 0.025 / item |
### `perplexity.instruct` — varies by `model`, `searchContextSize`
| Config | Cost |
|---|---|
| model=sonar-deep-research | 0.5 / 1k token |
| model=sonar, searchContextSize=high | 0.5 / 1k token |
| model=sonar, searchContextSize=medium | 0.4 / 1k token |
| model=sonar, searchContextSize=low | 0.3 / 1k token |
| model=sonar-pro, searchContextSize=high | 1 / 1k token |
| model=sonar-pro, searchContextSize=medium | 0.8 / 1k token |
| model=sonar-pro, searchContextSize=low | 0.6 / 1k token |
| model=sonar-reasoning, searchContextSize=high | 0.6 / 1k token |
| model=sonar-reasoning, searchContextSize=medium | 0.5 / 1k token |
| model=sonar-reasoning, searchContextSize=low | 0.4 / 1k token |
| model=sonar-reasoning-pro, searchContextSize=high | 0.9 / 1k token |
| model=sonar-reasoning-pro, searchContextSize=medium | 0.7 / 1k token |
| model=sonar-reasoning-pro, searchContextSize=low | 0.5 / 1k token |
### `linkup.search` — varies by `depth`
| Config | Cost |
|---|---|
| depth=standard | 0.5 |
| depth=deep | 2 |
### `contactOut.search` — varies by `objectType`, `revealInfo`
| Config | Cost |
|---|---|
| objectType=people, revealInfo=false | 1 / item |
| objectType=people, revealInfo=true | 3 / item |
### `apolloio.enrichPerson` — varies by `revealPhoneNumber`
| Config | Cost |
|---|---|
| revealPhoneNumber=false | 1 |
| revealPhoneNumber=true | 9 |
references/output-retrieval.md
# Output retrieval — `run download-outputs` vs `run download`
How to extract action results from the platform after a run or batch finishes. **Always prefer `run download-outputs`** for the actual data; reserve `run download` for debugging.
## The two commands
| CLI command | Maps to API | Returns | Use for |
|---|---|---|---|
| `cargo-ai orchestration run download` | `POST /v1/orchestration/runs/download-runs` | Newline-delimited JSON of full run records (status, executions, `runContext.<nodeSlug>` per-node outputs, timing) | Debugging — what did each node output? Why did this run fail? |
| `cargo-ai orchestration run download-outputs` | `POST /v1/orchestration/runs/download-outputs` | `{"url": "..."}` — signed URL to a CSV (default) or JSON file with input + output node data per record | **Canonical way to get action results.** Faster, cheaper, output-focused. |
## When to use each
### Use `run download-outputs` when
- You ran an action / tool / play and want the resulting enriched records.
- You're feeding outputs into the next step of a pipeline.
- You're handing the dataset to the user as a CSV.
- You only care about one specific node's output (the terminal `output` / `end` node).
This is the default in every recipe in this skill.
### Use `run download` when
- A run failed and you need to inspect every node's `runContext` to find the breakage.
- You want timing / credit attribution per node.
- You need the full execution trace (e.g., which conditional branches fired).
## `run download-outputs` reference
```bash
cargo-ai orchestration run download-outputs \
--workflow-uuid <uuid> \
--output-node-slug <slug> \
[--format json|csv] \
[--batch-uuid <uuid>] \
[--release-uuid <uuid>] \
[--statuses pending,running,finished,failed,cancelled] \
[--parent-batch-uuid <uuid>] \
[--parent-uuid <uuid>] \
[--parent-node-uuid <uuid>] \
[--is-group-parent] \
[--record-id <id>] \
[--record-title <title>] \
[--record-title-or-id <value>] \
\
[--executions-filter <json>] \
[--created-after <iso8601>] \
[--created-before <iso8601>]
```
**Required**: `--workflow-uuid` and `--output-node-slug`.
The response is a JSON object: `{"url": "<signed-url>"}`. The signed URL is short-lived — fetch immediately:
```bash
URL=$(cargo-ai orchestration run download-outputs --workflow-uuid <uuid> --output-node-slug <slug> --format json | jq -r .url)
curl -fsSL "$URL" > /tmp/outputs.json
```
## Finding the `output-node-slug`
Two paths:
```bash
# From the deployed release of a saved workflow / tool / play:
cargo-ai orchestration release get <release-uuid> | jq '.nodes[] | {slug, name, kind}'
# → Look for the terminal node, typically slug "output" or "end"
```
For ad-hoc `action execute` calls, the slug is the action's `actionSlug` itself (the action becomes a single-node workflow internally).
For multi-step `run create --nodes` calls, the slug is whatever you assigned to the terminal node in your node graph.
## Examples
### Pull all enriched records from a finished batch
```bash
cargo-ai orchestration run download-outputs \
--workflow-uuid abc-123-… \
--output-node-slug output \
--batch-uuid def-456-… \
--format json \
```
### Pull only successful records
```bash
cargo-ai orchestration run download-outputs \
--workflow-uuid abc-123-… \
--output-node-slug output \
--statuses finished \
--format csv
```
### Pull records by external recordId
```bash
cargo-ai orchestration run download-outputs \
--workflow-uuid abc-123-… \
--output-node-slug output \
--record-id "lead-456"
```
### Filter by node-execution status (e.g., only rows where the enrich step succeeded)
```bash
cargo-ai orchestration run download-outputs \
--workflow-uuid abc-123-… \
--output-node-slug output \
--executions-filter '{"enrich":{"status":"finished"}}'
```
## Why this matters for recipes
Cargo's orchestration layer is async by default. After firing an `action execute-batch` or `batch create`, you have two options:
1. **Wait inline**: pass `--wait-until-finished` and read the response. Works for small runs (< 50 records).
2. **Poll, then download**: fire async, poll status, then `run download-outputs` once finished. Required for large runs.
Every recipe in this skill that fans out across >50 records uses path 2 with `download-outputs`. Path 1 inline reads only work because `--wait-until-finished` returns the run object directly — but it doesn't scale.
## See also
- [`../../cargo-analytics/SKILL.md`](../../cargo-analytics/SKILL.md#downloading-run-results) — full reference for `run download` and `run download-outputs`.
- [`../../cargo-orchestration/references/polling.md`](../../cargo-orchestration/references/polling.md) — polling strategies for async runs and batches.
- [`../../cargo-orchestration/references/response-shapes.md`](../../cargo-orchestration/references/response-shapes.md) — full JSON shape of run / batch responses.
references/prompt-library/company-research.md
# Prompt library — company research
Prompts that turn raw research inputs (scraped website text, news lists, headcount data) into compact, structured company understanding. Run through `anthropic.instruct` with `temperature: 0`–`0.2` (bulk tier only — some judgment-tier models reject non-default sampling parameters with a 400; see [`../../provider-playbooks/anthropic.md`](../../provider-playbooks/anthropic.md) and omit the override when in doubt). Inputs are usually large — truncate scraped text to the first ~3,000 words before substitution; the signal is almost always in the top of the page.
### company-two-liner
**Purpose:** Say what a company does in exactly 2 plain sentences from its website text. **Variables:** {{website_text}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** exactly 2 sentences, plain text — or `NULL` for empty/error pages.
```
From this website text, state what the company does in exactly 2 sentences: sentence 1 = what they sell and to whom; sentence 2 = how they differ or what they replace. Plain declarative language — strip marketing adjectives ("leading", "revolutionary", "seamless"). Use only claims present in the text; if the text never says who the customer is, write "customer unclear from site" for that part rather than guessing. If the text is empty, an error page, or a domain-parking page, output exactly: NULL. Website text: {{website_text}}
```
### business-model-classification
**Purpose:** Classify the dominant business model with a confidence level and cited evidence. **Variables:** {{website_text}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{model, confidence, evidence}`.
```
Classify this company's business model from its website text. Categories: B2B SaaS, B2C SaaS, B2B services, B2C services, marketplace, e-commerce, hardware, fintech-regulated, nonprofit, other. Pick the ONE dominant model — the one driving revenue today, not an aspirational pivot. Base the choice only on evidence in the text: pricing pages, customer language ("for teams" vs "for you"), checkout vs book-a-demo CTAs, regulatory notices. If the text supports no category, use "other" with confidence "low" — do not classify from the company name alone. Output ONLY the JSON object: {"model": "<category>", "confidence": "high|medium|low", "evidence": "<one short phrase quoted from the text>"}. Website text: {{website_text}}
```
### competitive-positioning-summary
**Purpose:** Summarize how a company positions itself from its own scraped pages — category, who it attacks, differentiators. **Variables:** {{scraped_pages}}. **Model guidance:** claude-sonnet-4-6 — reading positioning between the lines is judgment-heavy. **Output:** exactly 3 bullets (`- ` lines) — or `NULL`.
```
From these scraped pages (homepage / product / comparison pages), summarize how the company positions itself: {{scraped_pages}}
Output exactly 3 bullets: (1) the category they claim for themselves; (2) who they position against — named competitors only if the text names them, otherwise the status quo or workflow they attack; (3) the 1-2 differentiators they repeat most often. Quote or closely paraphrase the text — do not add positioning they never state, and never name a competitor the text does not name. If the pages contain no positioning language at all, output exactly: NULL. Format: three lines, each starting with "- ".
```
### news-significance-filter
**Purpose:** Filter a company's news items down to the ones that matter for outreach, with a suggested acting window. **Variables:** {{news_items}}, {{relevance_criteria}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON array (possibly empty) of `{item, category, why_significant, outreach_window_days}`.
```
Filter these news items about one company down to the ones significant for sales outreach: {{news_items}}
Significant = matches these criteria: {{relevance_criteria}} (typically funding, leadership change, expansion, layoffs, product launch, regulatory event). Not significant: awards, listicles, minor partnerships, stock-price commentary, sponsored content. Judge each item only by its given headline and summary — do not enrich from outside knowledge, and do not upgrade an item's importance beyond what its own text states. Output ONLY a JSON array, possibly empty: [{"item": "<headline>", "category": "<event type>", "why_significant": "<one clause>", "outreach_window_days": <7|30|90>}]
```
### org-maturity-estimate
**Purpose:** Estimate go-to-market maturity from headcount distribution by function — absence of roles is itself the signal. **Variables:** {{headcount_distribution}}, {{total_employees}}. **Model guidance:** claude-3-5-haiku-latest; claude-sonnet-4-6 for unusual org shapes. **Output:** JSON `{stage, signals, sales_headcount_pct}`.
```
Estimate go-to-market maturity from this headcount distribution by function: {{headcount_distribution}} (total employees: {{total_employees}}).
Stages: "founder-led" = no dedicated sales or marketing headcount; "first-team" = sales and marketing exist but are <10% of headcount, no ops roles; "scaling" = dedicated ops/enablement roles appear, sales is 10-25% of headcount; "mature" = full GTM org with visible management layers. Reason only from the functions and counts provided — never infer functions absent from the distribution; their absence is itself the signal. If the distribution is empty or totals do not parse, output stage "unknown". Output ONLY the JSON object: {"stage": "founder-led|first-team|scaling|mature|unknown", "signals": ["<observation>", ...], "sales_headcount_pct": <number|null>}
```
### target-customer-inference
**Purpose:** Infer who a company sells to from case studies, logos, pricing tiers, and industry pages on its site. **Variables:** {{website_text}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{segments, named_customers, buyer_role_guess, evidence}`.
```
Infer who this company sells to from its website text (case studies, customer logos, pricing tiers, industry pages): {{website_text}}
Output ONLY the JSON object: {"segments": ["<segment, e.g. mid-market fintech ops teams>", ...], "named_customers": ["<company named in the text>", ...], "buyer_role_guess": "<job title or null>", "evidence": "<one short phrase quoted from the text>"}
Rules: named_customers must appear verbatim in the text — never add customers you know from memory. If the text names no customers, use []. If nothing indicates a target segment, use "segments": [] rather than guessing from the industry. buyer_role_guess only if the text addresses a role directly ("built for RevOps"); otherwise null.
```
references/prompt-library/data-extraction.md
# Prompt library — data extraction
Prompts that turn messy input (scraped pages, raw name/address strings, job postings) into strict, parse-ready JSON. Every prompt carries its schema inline and instructs the model to emit ONLY the JSON object — pipe the response straight into `jq`. Run through `anthropic.instruct` with `temperature: 0` (bulk tier only — some judgment-tier models reject non-default sampling parameters with a 400; see [`../../provider-playbooks/anthropic.md`](../../provider-playbooks/anthropic.md) and omit the override when in doubt); extraction wants zero creativity.
### scraped-page-to-company-json
**Purpose:** Extract company facts from a scraped page into a fixed schema — nulls, never guesses. **Variables:** {{page_text}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON object matching the inline schema.
```
Extract company facts from this scraped page text into the exact schema below. Output ONLY the JSON object — no prose, no markdown fences.
Schema: {"company_name": string|null, "description": string|null (≤25 words), "industry": string|null, "headquarters_city": string|null, "headquarters_country": string|null, "employee_count_stated": number|null, "founded_year": number|null, "contact_email": string|null, "social_links": string[]}
Rules: every value must be supported by explicit text on the page — if the page does not state a field, output null (empty array for social_links); never fill gaps from outside knowledge. employee_count_stated only when the page states a single explicit figure; ranges and vague counts ("hundreds of employees") → null. founded_year must be a 4-digit year stated on the page. Page text: {{page_text}}
```
### person-name-normalization
**Purpose:** Split any raw name string into structured parts, handling "Last, First", particles, suffixes, and non-person strings. **Variables:** {{raw_name}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{first_name, last_name, middle, suffix, honorific, is_person}`.
```
Normalize this raw person-name string into structured parts. Output ONLY the JSON object.
Schema: {"first_name": string|null, "last_name": string|null, "middle": string|null, "suffix": string|null, "honorific": string|null, "is_person": boolean}
Rules: handle "Last, First" ordering; multi-word and particle surnames ("van der Berg", "De La Cruz") stay intact in last_name; suffixes (Jr, III, PhD, MBA, CPA) go to suffix and honorifics (Dr, Prof) to honorific — never leave either inside a name field; strip emojis, parenthesized pronouns, and credentials from name parts. Single-token names: token in first_name, last_name null. If the string is a company, team, or placeholder ("Sales Team", "info desk", "N/A"), set is_person false and every part null. Never invent a part that is not in the string. Raw name: {{raw_name}}
```
### address-geo-parsing
**Purpose:** Parse a raw address or location string into structured geography with an explicit precision level. **Variables:** {{raw_address}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{street, city, region, postal_code, country, country_code, precision}`.
```
Parse this raw address/location string into structured geography. Output ONLY the JSON object.
Schema: {"street": string|null, "city": string|null, "region": string|null, "postal_code": string|null, "country": string|null, "country_code": string|null (ISO 3166-1 alpha-2), "precision": "street|city|region|country|none"}
Rules: expand common abbreviations (NYC → New York; UK → United Kingdom); region = state/province/prefecture; keep street names in their original spelling — do not translate. Set precision to the finest level actually present in the string. Emit only parts stated or unambiguously implied ("Paris, TX" → US; a bare city name resolves its country only when there is no plausible ambiguity). Ambiguous, fictional, or empty input: all fields null, precision "none" — never pick between candidate interpretations. Raw address: {{raw_address}}
```
### employee-count-banding
**Purpose:** Convert any raw headcount expression ("~500", "5k", "200-500 employees") into a canonical band. **Variables:** {{employee_count_raw}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{band, count_parsed, source_kind}`.
```
Convert this raw employee-count value into a canonical band. Output ONLY the JSON object.
Bands: "1-10", "11-50", "51-200", "201-500", "501-1000", "1001-5000", "5001-10000", "10000+".
Schema: {"band": string|null, "count_parsed": number|null, "source_kind": "exact|range|approximate|none"}
Rules: parse formats like "1,234", "~500", "500+", "200-500 employees", "5k", "1.2k". Ranges: band by the midpoint. Open-ended values ("500+"): band by the stated floor. count_parsed = the single number you banded on. Text with no numeric employee information ("many", "growing team", empty) → band null, count_parsed null, source_kind "none". Never infer a count from company fame, revenue, or industry. Raw value: {{employee_count_raw}}
```
### industry-taxonomy-slotting
**Purpose:** Classify a company into exactly one slot of a caller-supplied fixed taxonomy — labels verbatim, no free text. **Variables:** {{company_description}}, {{taxonomy_list}}. **Model guidance:** claude-3-5-haiku-latest; claude-sonnet-4-6 for fine-grained taxonomies (>40 slots). **Output:** JSON `{industry, confidence, runner_up}`.
```
Classify this company into exactly one slot of a fixed taxonomy. Output ONLY the JSON object.
Taxonomy — choose from these values verbatim; never output a label that is not in this list: {{taxonomy_list}}
Company description: {{company_description}}
Schema: {"industry": "<taxonomy value or null>", "confidence": "high|medium|low", "runner_up": "<taxonomy value or null>"}
Rules: classify by the primary revenue activity described, not the technology used — a logistics company using AI is logistics, not AI. If the description fits two slots, pick the more specific one and put the other in runner_up. If the description is empty or fits nothing, use the taxonomy's own fallback slot ("Other" or similar) with confidence "low"; if the list has no fallback, output industry null. Do not classify from the company name alone.
```
### contact-details-extraction
**Purpose:** Pull emails, phones, and social URLs out of messy footer/contact-page/signature text — verbatim values only. **Variables:** {{page_text}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{emails, phones, linkedin_urls, other_socials, physical_address}`.
```
Extract contact details from this messy text (page footer, contact page, or email signature). Output ONLY the JSON object.
Schema: {"emails": string[], "phones": string[], "linkedin_urls": string[], "other_socials": string[], "physical_address": string|null}
Rules: emails must be syntactically valid and appear in the text — de-obfuscate only trivial patterns ("name [at] domain [dot] com"); drop placeholders and example.com addresses. Phones: keep original formatting, deduplicate. linkedin_urls: profile or company URLs only, normalized to https. physical_address: the full address string exactly as written, or null. Every value must exist in the text — output empty arrays or null for anything absent; never construct an email from a name + domain pattern. Text: {{page_text}}
```
### job-posting-fields-extraction
**Purpose:** Extract structured fields (title, seniority, location, remote policy, salary, technologies) from a job posting. **Variables:** {{job_posting_text}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON object matching the inline schema.
```
Extract structured fields from this job posting. Output ONLY the JSON object.
Schema: {"job_title": string|null, "seniority": "C-Level|VP|Director|Manager|IC"|null, "department": string|null, "location": string|null, "remote_policy": "remote|hybrid|onsite"|null, "salary_range": string|null, "technologies": string[], "posted_date": string|null (ISO 8601)}
Rules: technologies = named tools, languages, and platforms from the requirements, verbatim and deduplicated — not soft skills. salary_range only if the posting states figures; keep currency symbols as written. remote_policy only from explicit statements ("fully remote", "3 days in office") — never inferred from location alone. Any field the posting does not state = null (empty array for technologies); do not infer from the company or from title conventions. Posting text: {{job_posting_text}}
```
### custom-attribute-extraction
**Purpose:** Fill one *defined* custom attribute for one account from fetched page text — with a confidence band, a verbatim evidence quote, and `Unknown` as a first-class answer. The extract half of the `firecrawl.scrape` → `instruct` pattern in [`../../recipes/custom-datapoints.md`](../../recipes/custom-datapoints.md). **Variables:** {{attribute_name}}, {{attribute_definition}}, {{allowed_values}}, {{page_text}}. **Model guidance:** claude-3-5-haiku-latest; claude-sonnet-4-6 when the attribute needs synthesis across several pages. **Output:** JSON `{value, confidence, evidence, source_hint}`.
```
Determine ONE attribute for this company from the page text below. Do not use any knowledge of the company beyond this text.
Attribute: {{attribute_name}} — {{attribute_definition}}
Allowed values: {{allowed_values}}
Page text: {{page_text}}
Confidence bands: "confirmed" = the text states it explicitly and currently; "inferred" = several consistent indirect statements and nothing contradicting them; "estimated" = calculated or approximated from partial figures actually present in the text (use only for numeric or range-valued attributes); "unknown" = insufficient, contradictory, or only historical evidence. Those first three are all reportable — return the value with the band that describes how you got it. Return value null with confidence "unknown" whenever the evidence does not reach any of them: an unsupported value is worse than a missing one, because it will be scored as if it were real. "estimated" requires arithmetic on figures in the text, never a guess at a plausible number. Never widen the allowed value set; if the true answer is outside it, return null.
Output ONLY the JSON object: {"value": <one of the allowed values, or null>, "confidence": "confirmed|inferred|estimated|unknown", "evidence": "<verbatim phrase from the text supporting the value, or null>", "source_hint": "<which section or page the phrase came from, or null>"}
```
### technology-adoption-state
**Purpose:** Classify *how widely* a company uses a technology from mixed evidence — the guard against one job posting becoming "company-wide adoption". **Variables:** {{technology}}, {{evidence_items}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{state, strongest_evidence, evidence_count, caveat}`.
```
Classify how widely this company uses a technology, based only on the evidence listed. Technology: {{technology}}. Evidence items (each with source type and date): {{evidence_items}}
States, strongest first: "company_standard" (official docs, engineering handbook, or public standardization statement) · "approved_tool" (listed as sanctioned/available, not mandated) · "team_usage" (multiple current people on one team, or a team-scoped statement) · "individual_usage" (one person's profile, post, or repo) · "pilot_or_evaluation" (explicitly trialing or evaluating) · "historical" (all evidence predates 18 months, or describes past use) · "none_found" (evidence exists about the company but none about this technology) · "unknown" (no usable evidence).
Rules: a single job posting is at most "individual_usage" — never higher, no matter how strongly worded. A technology listed as a desired or nice-to-have skill is "none_found", not usage. A vendor's own customer page counts only if the customer is quoted. Downgrade one band when all evidence is older than 12 months. Never aggregate weak evidence into a strong state — three individual profiles are still "individual_usage" unless they name a team or a standard.
Output ONLY the JSON object: {"state": "<one state>", "strongest_evidence": "<verbatim quote or item reference>", "evidence_count": <number of items that mention the technology>, "caveat": "<the main reason this could be wrong, or null>"}
```
references/prompt-library/index.md
# Prompt library — index
Curated, parameterized prompts for the LLM steps in GTM pipelines (`anthropic.instruct` calls and agent nodes). Reuse these instead of authoring from scratch — each has a tested output contract and a hallucination guard.
**Usage:**
1. Grep this index for the task; note the prompt name and shard file.
2. Open ONLY that shard file — never load all six.
3. Substitute every `{{variable}}` (mustache, snake_case) before sending; unfilled variables silently corrupt output.
Action shape: `{"kind":"connector","integrationSlug":"anthropic","actionSlug":"instruct"}` — with `model` (required), `prompt` (required), and `advancedSettings` (e.g. `{"temperature":0.3,"maxTokens":1024}`) in each record of `--records` / in `--data`, never in the action's `config`. The substituted prompt goes in each record's `prompt` field (see [`../../recipes/outreach-activation.md`](../../recipes/outreach-activation.md) step 5). Models: `claude-3-5-haiku-latest` for cheap/bulk, `claude-sonnet-4-6` for judgment-heavy — each entry says which. **Sampling overrides apply to the bulk tier only** (extraction and classification want `temperature: 0`); some judgment-tier models reject non-default sampling parameters with a 400 — before setting `advancedSettings` on a non-bulk model, check [`../../provider-playbooks/anthropic.md`](../../provider-playbooks/anthropic.md) and omit the override when in doubt (the prompt's own output contract carries the determinism).
Shards: [company-research.md](company-research.md) · [lead-scoring.md](lead-scoring.md) · [personalization.md](personalization.md) · [qualification.md](qualification.md) · [signal-analysis.md](signal-analysis.md) · [data-extraction.md](data-extraction.md)
| Prompt | Shard | Purpose | Variables |
|---|---|---|---|
| company-two-liner | company-research.md | What the company does, in exactly 2 plain sentences | website_text |
| business-model-classification | company-research.md | B2B/B2C/marketplace/etc + confidence, from site text | website_text |
| competitive-positioning-summary | company-research.md | Category claimed, who they attack, differentiators — 3 bullets | scraped_pages |
| news-significance-filter | company-research.md | Keep only outreach-worthy news items, with acting window | news_items, relevance_criteria |
| org-maturity-estimate | company-research.md | GTM maturity stage from headcount distribution by function | headcount_distribution, total_employees |
| target-customer-inference | company-research.md | Who they sell to, from case studies/logos/pricing pages | website_text |
| icp-fit-score | lead-scoring.md | 1-10 ICP fit with explicit rubric + missing-data flags | icp_description, company_name, company_summary, industry, employee_count, country |
| tech-stack-fit-score | lead-scoring.md | Fit from detected stack; incumbents cap the score | detected_technologies, complementary_technologies, competing_technologies |
| hiring-intent-strength | lead-scoring.md | 1-10 intent from job postings, recency- and seniority-weighted | job_postings, relevant_functions |
| composite-priority-score | lead-scoring.md | Merge sub-scores into P1/P2/P3 tier with tie-breaks | icp_fit_score, signal_strength_score, engagement_score |
| disqualification-check | lead-scoring.md | Hard-disqualifier gate before paid enrichment: DISQUALIFY/PASS | disqualifiers, company_name, company_summary, industry, employee_count, country |
| persona-title-fit | lead-scoring.md | 1-10 title-vs-persona match with ambiguity flag | persona_description, title |
| cold-email-first-line | personalization.md | Signal-referencing cold-email opener (canonical, from outreach-activation) | first_name, last_name, title, company_name, signal_summary |
| job-change-follow-up-line | personalization.md | Follow-up line anchored on a new-role first-90-days priority | first_name, new_title, new_company, previous_company, relationship_context |
| funding-congrats-angle | personalization.md | Funding opener that bans the "congrats" template | company_name, round_type, round_amount, investors, stated_use_of_funds, your_value_prop |
| linkedin-connection-note | personalization.md | Connection note, ≤300 chars, no pitch | first_name, title, company_name, reason_for_connecting |
| subject-line-variants | personalization.md | 3 subject-line styles as a JSON array | first_line, signal_summary, company_name |
| reengagement-opener | personalization.md | Stale-contact opener where the fresh signal is the news | first_name, last_touch_summary, months_since_contact, fresh_signal |
| proof-point-bridge | personalization.md | One sentence tying a customer proof point to the prospect | prospect_situation, customer_name, proof_point |
| seniority-normalization | qualification.md | Any title → C-Level/VP/Director/Manager/IC/Other | title |
| buying-committee-role | qualification.md | Economic buyer/champion/user/blocker/influencer guess | title, department, product_category |
| decision-maker-likelihood | qualification.md | 0-100 can-they-approve estimate for a product + price band | title, employee_count, product_category, price_band |
| geo-territory-normalization | qualification.md | Raw location → parsed geo + one territory from a list | raw_location, territory_list |
| job-function-classification | qualification.md | Title → one of 15 fixed functions | title |
| title-red-flag-check | qualification.md | EXCLUDE students, job-seekers, agencies, joke titles | title, headline |
| job-posting-pain-hypothesis | signal-analysis.md | Job posting → business-pain hypothesis + product mapping | job_posting_text, your_product_summary |
| funding-budget-window | signal-analysis.md | Funding round → budget-timing verdict (act_now/1_3/3_9/too_late) | round_type, round_amount, announced_date, stated_use_of_funds, product_category |
| tech-change-displacement | signal-analysis.md | Stack change → open_door/fresh_incumbent/stack_shift/none | added_technologies, removed_technologies, your_product_summary, competing_technologies |
| job-change-angle | signal-analysis.md | Job change → classified re-engagement play + hook | contact_name, new_title, new_company, previous_company, prior_relationship, product_category |
| filing-priorities-extraction | signal-analysis.md | 10-K/10-Q/report text → top 5 priorities with verbatim quotes | filing_text |
| signal-triage | signal-analysis.md | All signals for one account → act_now/monitor/ignore | signals, icp_fit_score |
| scraped-page-to-company-json | data-extraction.md | Messy page → strict company JSON, nulls never guesses | page_text |
| person-name-normalization | data-extraction.md | Raw name string → structured parts, edge cases handled | raw_name |
| address-geo-parsing | data-extraction.md | Raw address → structured geo + precision level | raw_address |
| employee-count-banding | data-extraction.md | "~500"/"5k"/"200-500" → canonical headcount band | employee_count_raw |
| industry-taxonomy-slotting | data-extraction.md | Company → one verbatim slot of a supplied taxonomy | company_description, taxonomy_list |
| contact-details-extraction | data-extraction.md | Emails/phones/socials from footer or signature text, verbatim only | page_text |
| job-posting-fields-extraction | data-extraction.md | Job posting → title/seniority/location/salary/tech JSON | job_posting_text |
| custom-attribute-extraction | data-extraction.md | One defined attribute from page text + confidence band + evidence quote | attribute_name, attribute_definition, allowed_values, page_text |
| technology-adoption-state | data-extraction.md | Mixed evidence → how widely a tech is used (individual → company standard) | technology, evidence_items |
Conventions shared by every prompt: explicit output contract (parse-ready for downstream nodes), a hallucination guard ("if the text doesn't state X, output null — do not guess"), and ≤200 words. When a prompt underperforms, tune the variables before the prose — and if it's genuinely broken, file a `workspaceManagement report create` so the library gets fixed for everyone.
references/prompt-library/lead-scoring.md
# Prompt library — lead scoring
Prompts that turn enriched records into scores, tiers, and disqualifications with explicit rubrics — so two runs on the same data agree. Run through `anthropic.instruct` with `temperature: 0` (0.2 max) (bulk tier only — some judgment-tier models reject non-default sampling parameters with a 400; see [`../../provider-playbooks/anthropic.md`](../../provider-playbooks/anthropic.md) and omit the override when in doubt); scoring wants determinism. All JSON outputs are parse-ready for downstream filter/branch nodes.
### icp-fit-score
**Purpose:** Score a company 1-10 against a written ICP definition with a fixed rubric. **Variables:** {{icp_description}}, {{company_name}}, {{company_summary}}, {{industry}}, {{employee_count}}, {{country}}. **Model guidance:** claude-3-5-haiku-latest for bulk sweeps; claude-sonnet-4-6 when the ICP has many soft, judgment-heavy criteria. **Output:** JSON `{score, rationale, missing_data}`.
```
Score how well this company fits the ICP, 1-10.
ICP definition: {{icp_description}}
Company: {{company_name}} — {{company_summary}}. Industry: {{industry}}. Employees: {{employee_count}}. Country: {{country}}.
Rubric: 9-10 = matches every stated ICP criterion; 7-8 = matches all hard criteria, misses one soft criterion; 5-6 = matches most hard criteria with one clear gap; 3-4 = misses multiple hard criteria; 1-2 = wrong market entirely. Score only against criteria stated in the ICP definition — do not add criteria of your own. If a field the ICP needs is empty, do not guess its value: list it in missing_data and score conservatively. Output ONLY the JSON object: {"score": <1-10>, "rationale": "<one sentence citing the deciding criteria>", "missing_data": ["<field>", ...]}
```
### tech-stack-fit-score
**Purpose:** Score fit from detected technologies — complementary tools raise it, incumbents cap it. **Variables:** {{detected_technologies}}, {{complementary_technologies}}, {{competing_technologies}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{score, complementary_matches, competing_matches, displacement_candidate, rationale}`.
```
Score tech-stack fit 1-10 for a company whose detected stack is: {{detected_technologies}}.
Technologies that indicate fit (we integrate with or build on): {{complementary_technologies}}. Technologies that indicate an incumbent solution we would displace: {{competing_technologies}}.
Scoring: each complementary match raises the score; a competing match caps the score at 6 (displacement candidate — flag it, score 5-6 only if complementary matches also exist, otherwise 3-4). No matches either way = 3. Match only technologies literally present in the detected list — do not infer unlisted tools from company type or industry. Output ONLY the JSON object: {"score": <1-10>, "complementary_matches": [...], "competing_matches": [...], "displacement_candidate": <true|false>, "rationale": "<one sentence>"}
```
### hiring-intent-strength
**Purpose:** Score how strongly job postings signal buying intent, weighted by recency and seniority. **Variables:** {{job_postings}}, {{relevant_functions}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{score, relevant_posting_count, evidence, rationale}`.
```
Assess hiring-intent strength 1-10 from these job postings (one per line: title, posted date, location): {{job_postings}}. Functions relevant to our product: {{relevant_functions}}.
Rubric: count postings in relevant functions, weighted by recency (≤30 days old = full weight, 31-90 days = half, older = ignore) and seniority (a leadership hire in a relevant function means they are building a team: +2). 0 relevant postings = 1. 1-2 recent = 4-5. 3-5 recent = 6-7. More than 5, or any leadership hire = 8-10. Use only the postings provided — if the list is empty, output score 1 with evidence []. Output ONLY the JSON object: {"score": <1-10>, "relevant_posting_count": <n>, "evidence": ["<title (age in days)>", ...], "rationale": "<one sentence>"}
```
### composite-priority-score
**Purpose:** Merge ICP, signal, and engagement sub-scores into a P1/P2/P3 outreach tier with deterministic tie-breaks. **Variables:** {{icp_fit_score}}, {{signal_strength_score}}, {{engagement_score}}. **Model guidance:** claude-3-5-haiku-latest (pure arithmetic + two rules). **Output:** JSON `{composite, tier, tie_break_applied}`.
```
Combine three sub-scores (each 1-10, already computed — do not re-derive them) into a priority tier for outreach. ICP fit: {{icp_fit_score}}. Signal strength: {{signal_strength_score}}. Engagement history: {{engagement_score}}.
Weights: composite = 0.5 × ICP + 0.35 × signal + 0.15 × engagement. Tiers: ≥8.0 = P1, 6.0-7.9 = P2, 4.0-5.9 = P3, <4.0 = park. Tie-breaks, applied only when the composite sits within 0.2 of a tier boundary: promote one tier if signal ≥ 8 (fresh signals decay — act on them); demote one tier if ICP ≤ 4 (signal never outranks fit). If any sub-score is missing or outside 1-10, output tier "park" with composite null — do not substitute a default. Output ONLY the JSON object: {"composite": <number|null>, "tier": "P1|P2|P3|park", "tie_break_applied": "<rule applied, or none>"}
```
### disqualification-check
**Purpose:** Hard-disqualifier gate to run before spending enrichment credits on a record. **Variables:** {{disqualifiers}}, {{company_name}}, {{company_summary}}, {{industry}}, {{employee_count}}, {{country}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** one line — `DISQUALIFY: <rule> — <evidence>`, `PASS`, or `PASS (unverified: <fields>)`.
```
Check this company against hard disqualifiers before any paid enrichment. Disqualifiers: {{disqualifiers}}
Company: {{company_name}} — {{company_summary}}. Industry: {{industry}}. Employees: {{employee_count}}. Country: {{country}}.
Apply ONLY the listed disqualifiers — do not invent additional ones. A disqualifier fires only on explicit evidence in the fields above; ambiguity or a missing field is never grounds to disqualify — flag it instead. Output exactly one line, nothing else: "DISQUALIFY: <which rule> — <the evidence>" or "PASS" or "PASS (unverified: <comma-separated fields that were empty>)".
```
### persona-title-fit
**Purpose:** Score how closely a job title matches a written buyer persona. **Variables:** {{persona_description}}, {{title}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{score, ambiguous, reason}`.
```
Score 1-10 how well the job title "{{title}}" matches this buyer persona: {{persona_description}}.
Rubric: 9-10 = the persona's title or a direct synonym; 7-8 = same function, one seniority level off; 5-6 = same function at the wrong level, or an adjacent function at the right level; 3-4 = adjacent function and wrong level; 1-2 = unrelated function. Judge from the title text alone — do not assume responsibilities the title does not state. If the title is an abbreviation you cannot expand with confidence, score 5 and set ambiguous true. Empty title = score 1. Output ONLY the JSON object: {"score": <1-10>, "ambiguous": <true|false>, "reason": "<one sentence>"}
```
references/prompt-library/personalization.md
# Prompt library — personalization
Prompts for per-record LLM personalization steps (cold-email lines, connection notes, subject lines). Run through `anthropic.instruct` with the substituted prompt in each record's `prompt` field; `temperature: 0.3` is the proven default for this family (bulk tier only — some judgment-tier models reject non-default sampling parameters with a 400; see [`../../provider-playbooks/anthropic.md`](../../provider-playbooks/anthropic.md) and omit the override when in doubt). Every prompt returns plain text (or `NULL` when the inputs can't support a good line — filter those rows out before the sequencer sees them).
### cold-email-first-line
**Purpose:** Opening line of a first-touch email that references a live signal and ties it to a business outcome. **Variables:** {{first_name}}, {{last_name}}, {{title}}, {{company_name}}, {{signal_summary}}. **Model guidance:** claude-3-5-haiku-latest for bulk runs; claude-sonnet-4-6 only for small high-stakes lists. **Output:** one sentence, ≤30 words, plain text — or `NULL` if the signal is empty.
<!-- Canonical entry — ported from ../../recipes/outreach-activation.md step 5, with a hallucination guard added. -->
```
You are writing the opening line of a first-touch email. The recipient is {{first_name}} {{last_name}}, {{title}} at {{company_name}}. Signal triggering this outreach: {{signal_summary}}. Write ONE sentence that references the signal naturally and ties it to a relevant business outcome. No greeting. No follow-up. Use only facts stated in the signal — do not invent numbers, names, or dates. If the signal is empty or uninformative, output exactly: NULL. ≤30 words.
```
### job-change-follow-up-line
**Purpose:** Follow-up line to a contact who just changed jobs, anchored on a first-90-days priority. **Variables:** {{first_name}}, {{new_title}}, {{new_company}}, {{previous_company}}, {{relationship_context}}. **Model guidance:** claude-3-5-haiku-latest for bulk; claude-sonnet-4-6 for named accounts. **Output:** one sentence, ≤35 words, plain text.
```
Write ONE follow-up line to {{first_name}}, who recently became {{new_title}} at {{new_company}} after leaving {{previous_company}}. Prior relationship: {{relationship_context}}. Acknowledge the move without flattery clichés ("huge congrats", "exciting times") and connect it to a first-90-days priority a {{new_title}} typically owns. At most one question. Use only the facts given; if the prior relationship is empty, write the line without referencing any past interaction — do not invent one. ≤35 words. Output the sentence only, no greeting.
```
### funding-congrats-angle
**Purpose:** Funding-triggered opener that avoids the "congrats on the raise" template every other sender uses. **Variables:** {{company_name}}, {{round_type}}, {{round_amount}}, {{investors}}, {{stated_use_of_funds}}, {{your_value_prop}}. **Model guidance:** claude-3-5-haiku-latest; claude-sonnet-4-6 when the use-of-funds mapping needs judgment. **Output:** one sentence, ≤35 words, plain text.
```
{{company_name}} raised a {{round_type}} ({{round_amount}}; investors: {{investors}}; stated use of funds: {{stated_use_of_funds}}). Write ONE opening sentence for a first-touch email. Banned: "congrats", "congratulations", "exciting", "huge news", and any sentence whose grammatical subject is the raise itself. Lead instead with the operational consequence — what the money lets them do next quarter — and connect it to {{your_value_prop}}. Use only the facts provided; if the stated use of funds is empty, anchor on the round stage — infer nothing else. ≤35 words. Output the sentence only.
```
### linkedin-connection-note
**Purpose:** Connection-request note that fits LinkedIn's hard character limit and doesn't pitch. **Variables:** {{first_name}}, {{title}}, {{company_name}}, {{reason_for_connecting}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** plain text, ≤300 characters (hard limit).
```
Write a LinkedIn connection request note to {{first_name}}, {{title}} at {{company_name}}. Reason for connecting: {{reason_for_connecting}}. Requirements: 300 characters maximum (hard limit), no "I'd love to", no pitch, no links, one concrete reason they would plausibly accept. Sound like a person, not a sequence. Use only the facts given — do not invent shared connections, events attended, or content they posted. Output the note text only.
```
### subject-line-variants
**Purpose:** Three deliberately different subject-line styles for the same email, ready for A/B rotation. **Variables:** {{first_line}}, {{signal_summary}}, {{company_name}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON array of exactly 3 strings.
```
Given this cold-email opening line: "{{first_line}}" (signal: {{signal_summary}}, company: {{company_name}}), write 3 subject-line variants: (1) a 2-4 word internal-memo style, lowercase; (2) a specific noun phrase referencing the signal, ≤6 words; (3) a question, ≤7 words. No clickbait, no "quick question", no emoji, no recipient name. Use only facts present in the inputs — do not add claims the opening line does not make. Output ONLY a JSON array of 3 strings, e.g. ["...","...","..."].
```
### reengagement-opener
**Purpose:** First line to a stale contact where a fresh signal — not "checking in" — is the reason for writing. **Variables:** {{first_name}}, {{last_touch_summary}}, {{months_since_contact}}, {{fresh_signal}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** one sentence, ≤35 words — or `NULL` when there is no fresh signal.
```
Write ONE re-engagement opening line to {{first_name}}. Last interaction ({{months_since_contact}} months ago): {{last_touch_summary}}. What changed since: {{fresh_signal}}. The line must (a) show memory of the last interaction in a half-clause, and (b) make the fresh signal the reason for writing today — the signal is the news, not the sender. Banned: "circling back", "bubbling this up", "touching base", apologies for the silence. Use only the facts given; if the fresh signal is empty, output exactly: NULL — never send signal-less re-engagement. ≤35 words. Output the sentence only.
```
### proof-point-bridge
**Purpose:** One sentence bridging a customer proof point into the prospect's situation without "we helped X do Y" framing. **Variables:** {{prospect_situation}}, {{customer_name}}, {{proof_point}}. **Model guidance:** claude-3-5-haiku-latest; claude-sonnet-4-6 when the parallel is non-obvious. **Output:** one sentence, ≤35 words — or `NULL` if the proof point has no concrete outcome.
```
Write ONE sentence bridging a customer proof point into a first-touch email. Prospect situation: {{prospect_situation}}. Proof: {{customer_name}} — {{proof_point}}. Structure: name the parallel between the customer and the prospect first, then the outcome — never "We helped X do Y". Keep every number exactly as written in the proof point; do not round, extrapolate, or add metrics that are not there. If the proof point contains no concrete outcome, output exactly: NULL. ≤35 words. Output the sentence only.
```
references/prompt-library/qualification.md
# Prompt library — qualification
Prompts that normalize messy people-data (titles, locations) into fixed labels and qualify contacts for outreach. These are classification tasks — run through `anthropic.instruct` with `temperature: 0` (bulk tier only — some judgment-tier models reject non-default sampling parameters with a 400; see [`../../provider-playbooks/anthropic.md`](../../provider-playbooks/anthropic.md) and omit the override when in doubt) so the same title always maps to the same label. Outputs are enum-constrained for downstream branch/filter nodes.
### seniority-normalization
**Purpose:** Map any job title — any language, any convention — to one of six fixed seniority levels. **Variables:** {{title}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** exactly one label: `C-Level`, `VP`, `Director`, `Manager`, `IC`, or `Other`.
```
Map the job title "{{title}}" to exactly one seniority level: C-Level, VP, Director, Manager, IC, Other. Rules: founders, owners, partners, and presidents = C-Level. "Head of" = Director, unless the scope is clearly company-wide at a small firm (then VP). "Lead", "Principal", "Staff" = IC (technical track), unless followed by a team noun ("Lead, Sales Development" = Manager). "Deputy", "Associate", "Assistant" demote one level from the base title. Non-English titles: translate first, then map. Judge only from the title text — make no assumptions about company size unless the title states it. Empty or unmappable titles = Other. Output exactly one label, nothing else.
```
### buying-committee-role
**Purpose:** Guess a contact's buying-committee role (economic buyer / champion / user / blocker / influencer) for a given product category. **Variables:** {{title}}, {{department}}, {{product_category}}. **Model guidance:** claude-3-5-haiku-latest; claude-sonnet-4-6 for complex enterprise committees. **Output:** JSON `{role, confidence, reasoning}`.
```
Guess this person's most likely buying-committee role for a {{product_category}} purchase. Title: "{{title}}", department: {{department}}. Roles: economic_buyer (owns the budget line), champion (drives the evaluation because they feel the pain), user (hands-on with the product daily), blocker (gatekeeps — security, legal, procurement, IT admin), influencer (consulted, owns nothing). Pick ONE primary role using only title and department evidence: seniority plus how close the function sits to {{product_category}}. Do not assume an org structure the title does not imply. If title and department are both empty, or they contradict each other, output role "unknown". Output ONLY the JSON object: {"role": "economic_buyer|champion|user|blocker|influencer|unknown", "confidence": "high|medium|low", "reasoning": "<one clause>"}
```
### decision-maker-likelihood
**Purpose:** Estimate 0-100 whether a title can approve or veto a purchase, given company size and price band. **Variables:** {{title}}, {{employee_count}}, {{product_category}}, {{price_band}}. **Model guidance:** claude-sonnet-4-6 — this is a judgment call across three interacting factors; claude-3-5-haiku-latest acceptable for bulk triage. **Output:** JSON `{likelihood, confidence, reasoning}`.
```
Estimate the likelihood (0-100) that a "{{title}}" at a {{employee_count}}-person company can approve or veto a {{product_category}} purchase priced around {{price_band}}. Weigh: (a) whether the function that owns {{product_category}} typically reports through this title; (b) company size — at <100 employees, function leaders buy directly; at >1,000 the same title often sits two levels from budget; (c) the price against typical discretionary limits for that level. Use only the inputs given — if employee count or price band is empty, widen your uncertainty and cap confidence at "low" instead of assuming typical values. Output ONLY the JSON object: {"likelihood": <0-100>, "confidence": "high|medium|low", "reasoning": "<one sentence>"}
```
### geo-territory-normalization
**Purpose:** Parse a raw location string and assign it to exactly one territory from a provided list. **Variables:** {{raw_location}}, {{territory_list}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{city, country_code, territory}`.
```
Normalize the location string "{{raw_location}}" and assign it to one territory from this list: {{territory_list}}
Steps: (1) parse city / region / country from the raw string, expanding abbreviations (UK → United Kingdom, SF → San Francisco; DACH stays a region); (2) resolve the country to its ISO 3166-1 alpha-2 code; (3) match to exactly one listed territory. Match only against the listed territories — never output a territory that is not in the list. If the location is ambiguous between countries (e.g. "Cambridge" alone), empty, or fictional, set country_code null and territory "unassigned" — do not pick between candidates. Output ONLY the JSON object: {"city": <string|null>, "country_code": <string|null>, "territory": "<listed territory or unassigned>"}
```
### job-function-classification
**Purpose:** Classify a job title into one of fifteen fixed functions for routing and segmentation. **Variables:** {{title}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{function, confidence}`.
```
Classify the job title "{{title}}" into exactly one function: Sales, Marketing, Engineering, Product, Design, Data, IT, Finance, HR, Legal, Operations, Customer Success, Support, Executive (general management only — CEO, COO, GM), Other. Rules: classify by what the person does, not the first department word — "Marketing Engineer" = Engineering; ops-of-a-function titles ("Sales Operations", "Marketing Ops") = Operations. For hybrid titles ("Product & Engineering"), pick the function listed first. C-suite functional titles (CFO, CMO, CTO) map to their function, not Executive. Judge from the title text only; empty or meaningless titles = Other. Output ONLY the JSON object: {"function": "<label>", "confidence": "high|medium|low"}
```
### title-red-flag-check
**Purpose:** Catch contacts who should never enter a B2B sequence — students, job-seekers, agencies, joke titles. **Variables:** {{title}}, {{headline}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** one line — `EXCLUDE: <flag> — <evidence>` or `KEEP`.
```
Check whether this title/headline indicates a person to exclude from B2B outreach. Title: "{{title}}". Headline: "{{headline}}". Red flags: student, intern, or apprentice; retired / "former" / "ex-" with no current role; freelancer, consultant, or agency serving many clients (not an in-house buyer); actively job-seeking ("open to work", "seeking opportunities"); investor- or advisor-only portfolios; obviously fake or joke titles ("Chief Vibes Officer" with no real role attached). Flag only on explicit evidence in the given text — a short, vague, or missing headline is NOT by itself a red flag. Output exactly one line, nothing else: "EXCLUDE: <flag> — <the evidence text>" or "KEEP".
```
references/prompt-library/signal-analysis.md
# Prompt library — signal analysis
Prompts that convert a detected signal (job posting, funding round, tech-stack change, job change, public filing) into a sales hypothesis, timing window, or triage verdict. These sit between signal recipes (`funding-watch`, `job-change-monitoring`, `tech-intent`) and [`outreach-activation`](../../recipes/outreach-activation.md) — their outputs feed `signal_summary` and routing branches. Run with `temperature: 0`–`0.2` (bulk tier only — some judgment-tier models reject non-default sampling parameters with a 400; see [`../../provider-playbooks/anthropic.md`](../../provider-playbooks/anthropic.md) and omit the override when in doubt).
### job-posting-pain-hypothesis
**Purpose:** Read a job posting as a budget line against a problem — hypothesize the pain and whether it maps to your product. **Variables:** {{job_posting_text}}, {{your_product_summary}}. **Model guidance:** claude-sonnet-4-6 — hypothesis quality drives the whole outreach angle; claude-3-5-haiku-latest for high-volume pre-filtering. **Output:** JSON `{pain_hypothesis, posting_evidence, maps_to_product, outreach_angle}`.
```
From this job posting, hypothesize the business pain behind the hire and whether it maps to our product. Posting: {{job_posting_text}}. Our product: {{your_product_summary}}.
Read the responsibilities and requirements for what is breaking or scaling — a hire is a budget line against a problem. Output ONLY the JSON object: {"pain_hypothesis": "<one sentence grounded in the posting>", "posting_evidence": "<verbatim phrase from the posting>", "maps_to_product": <true|false>, "outreach_angle": "<one clause, or null>"}
Rules: posting_evidence must be quoted verbatim. If the posting is generic boilerplate with no specific responsibilities, set pain_hypothesis and outreach_angle to null and maps_to_product to false — do not manufacture pain.
```
### funding-budget-window
**Purpose:** Convert a funding round into a budget-timing verdict — when to sell into the new money. **Variables:** {{round_type}}, {{round_amount}}, {{announced_date}}, {{stated_use_of_funds}}, {{product_category}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{window, reasoning, use_of_funds_match}`.
```
Estimate the budget-timing window for selling {{product_category}} to a company that raised a {{round_type}} of {{round_amount}}, announced {{announced_date}}. Stated use of funds: {{stated_use_of_funds}}.
Frame: new-budget planning typically lands 1-3 months post-announcement; team build-out spending runs 3-9 months; consolidation pressure returns after 12. use_of_funds_match is true only if the stated use of funds literally names an area adjacent to {{product_category}} — do not stretch the mapping, and if the field is empty, set it false. If the announced date is missing or more than 18 months ago, output window "too_late". Output ONLY the JSON object: {"window": "act_now|1_3_months|3_9_months|too_late", "reasoning": "<one sentence>", "use_of_funds_match": <true|false>}
```
### tech-change-displacement
**Purpose:** Classify a detected tech-stack change as a displacement opportunity — open door, fresh incumbent, or replatform. **Variables:** {{added_technologies}}, {{removed_technologies}}, {{your_product_summary}}, {{competing_technologies}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{opportunity, trigger_technology, angle, revisit_in_months}`.
```
A company's detected tech stack changed. Added: {{added_technologies}}. Removed: {{removed_technologies}}. Our product: {{your_product_summary}}. Technologies we displace: {{competing_technologies}}.
Classify the opportunity: "open_door" = they removed a competing technology (the seat may be empty right now); "fresh_incumbent" = they just added a competing technology (bad timing — set revisit_in_months to 12); "stack_shift" = adjacent additions/removals suggesting a replatform we could ride; "none" = no relevant movement. Use only technologies literally present in the lists — never infer unlisted tooling from company type. Output ONLY the JSON object: {"opportunity": "open_door|fresh_incumbent|stack_shift|none", "trigger_technology": "<the technology, or null>", "angle": "<one sentence, or null>", "revisit_in_months": <number|null>}
```
### job-change-angle
**Purpose:** Turn a detected job change into a classified re-engagement play with a drafted hook. **Variables:** {{contact_name}}, {{new_title}}, {{new_company}}, {{previous_company}}, {{prior_relationship}}, {{product_category}}. **Model guidance:** claude-3-5-haiku-latest; claude-sonnet-4-6 for strategic accounts. **Output:** JSON `{play, hook, urgency}`.
```
{{contact_name}} moved from {{previous_company}} to become {{new_title}} at {{new_company}}. Prior relationship with us: {{prior_relationship}}. We sell {{product_category}}.
Classify the re-engagement play and draft the hook. Frame: new-in-role buyers rebuild their trusted stack in the first ~90 days; a past user or champion is warmest; a past evaluator who said no may now own a different budget; no relationship and no relevance = skip. Use only the facts given — do not assume they used our product at the previous company unless the prior relationship says so. Output ONLY the JSON object: {"play": "champion_landed|past_evaluator|cold_but_relevant|skip", "hook": "<one sentence, or null when play is skip>", "urgency": "high|medium|low"}
```
### filing-priorities-extraction
**Purpose:** Pull sales-anchorable priorities and risks from any long filing text — 10-K, 10-Q, annual report, earnings remarks. **Variables:** {{filing_text}}. **Model guidance:** claude-sonnet-4-6 — long-document salience ranking; use claude-3-5-haiku-latest only on short excerpts. **Output:** JSON `{priorities: [{theme, quote, type}], fiscal_context}` (max 5 priorities).
```
From this excerpt of a company filing or shareholder communication (10-K, 10-Q, annual report, earnings remarks), extract the stated business priorities and risks a seller could anchor outreach on: {{filing_text}}
Output ONLY the JSON object: {"priorities": [{"theme": "<short label>", "quote": "<verbatim supporting sentence from the text>", "type": "investment|efficiency|risk|growth"}], "fiscal_context": "<one clause on the period covered, or null>"}
Rules: maximum 5 priorities, ranked by prominence in the text (repetition and placement). Every quote must appear verbatim in the excerpt. Extract only what the text states — no industry-general assumptions, no outside knowledge of the company. If the excerpt contains no forward-looking priorities, output {"priorities": [], "fiscal_context": null}.
```
### signal-triage
**Purpose:** Given all signals detected for one account, decide act-now vs monitor vs ignore — with decay, compounding, and an ICP-fit gate. **Variables:** {{signals}}, {{icp_fit_score}}. **Model guidance:** claude-3-5-haiku-latest. **Output:** JSON `{verdict, primary_signal, reasoning}`.
```
Triage the signals detected for one account (one per line, each with a date): {{signals}}. Account ICP-fit score (1-10): {{icp_fit_score}}.
Rules: decay — a signal older than 90 days is context, never a trigger. Compounding — two independent fresh signals (e.g. funding + relevant hiring) outrank either alone. Fit gate — if ICP fit ≤ 4, the best verdict allowed is "monitor"; if ≤ 2, "ignore". Verdicts: act_now = at least one fresh trigger and fit ≥ 5; monitor = signals stale, weak, or fit-gated; ignore = nothing meaningful. Use only the listed signals — do not assume unlisted activity, and if the list is empty output "ignore". Output ONLY the JSON object: {"verdict": "act_now|monitor|ignore", "primary_signal": "<the signal, or null>", "reasoning": "<one sentence>"}
```
references/stage-action-map.md
# Stage → cheapest credits-based action map
Canonical reference for picking the cheapest credits-based action per GTM stage across the full 136-integration cargo catalog. Use this when the priority-stack default doesn't have what you need.
Prices are credits/record. "Priority?" marks providers in the priority stack (salesNavigator / aiArk / waterfall / FullEnrich / apolloio / theirStack / peopleDataLabs).
This map is **curated** — the cheapest few rungs per stage, with the routing judgement attached. For the complete machine-generated list of all 176 credits-based actions, including per-config pricing, see [`credits-cost-table.md`](credits-cost-table.md).
**Size before you spend.** `aiArk.countPeople` and `aiArk.countCompanies` cost **0** and return how many records a filter matches without retrieving them. Run the count, quote it, then decide whether to pay for the search.
## Sourcing — Search people
| Provider | Action | Cost | Priority? | Notes |
|---|---|---|---|---|
| aiArk | countPeople | **0** | ✅ | Not a source — counts matches for a filter. Run this first. |
| apolloio | searchPeople | 0 / **1** per person | ✅ | **0 with `shouldEnrich: false`** (identity only), 1 when it enriches. Cheapest way to test whether Apollo has the audience at all. |
| salesNavigator | searchLeads | 0.02 | ✅ | LinkedIn-anchored. Default at-scale. |
| icypeas | findPeople | 0.02 | | Cheapest non-LinkedIn source. |
| aiArk | searchPeople | 0.05 | ✅ | Rich filters (education, skills, tenure, seniority, past company). Per returned record. |
| firecrawl | search | 0.05 | | Web search; use when no structured provider has the data. |
| linkup | search | 0.5 | | Web search with structured answers. |
| contactOut | search | 1 / item (**3** with `revealInfo: true`) | | Mid-tier when other sources miss. |
| oceanio | searchPeople | 1 | | Mid-tier. |
| proxycurl | search | 1 / item | | Last resort. Unique filters: profile free-text (`headline`, `summary`, `*_job_description`), `linkedin_groups`/`interests`/`languages`, an **absolute** role-start date, and `public_identifier_not_in_list`. Education/skills/tenure/funding are `aiArk.searchPeople` at 0.05 — 20x cheaper. See [`../provider-playbooks/proxycurl.md`](../provider-playbooks/proxycurl.md). |
| peopleDataLabs | searchPeople / queryPeople | 3 | ✅ | Heavyweight. `searchPeople` uses cargo's `{conjonction, groups, conditions}` filter; `queryPeople` takes a PDL **SQL string**. |
| waterfall | searchProspects | 3 | ✅ | Multi-source; useful when LinkedIn isn't enough. |
## Sourcing — Search companies
| Provider | Action | Cost | Priority? | Notes |
|---|---|---|---|---|
| aiArk | countCompanies | **0** | ✅ | Not a source — counts matches for a filter. Run this first. |
| aiArk | searchCompanies | 0.01 | ✅ | **Cheapest in catalog.** Per returned record; supports `lookalikeDomains` (≤5 seeds). |
| apolloio | searchOrganizations | 0.01 / organization | ✅ | Ties aiArk on price. Firmographic, funding, technology and hiring filters. |
| icypeas | findCompanies | 0.02 | | Cheapest non-lookalike. |
| salesNavigator | searchAccounts | 0.05 | ✅ | LinkedIn-anchored. Default at-scale. |
| theirStack | searchCompanies | 0.5 | ✅ | Tech-stack + hiring-intent filter. |
| oceanio | searchCompanies | 1 | | Mid-tier. |
| societeInfo | search (`objectType: company`) | 4 / item | | **France only.** Registry filters nothing else has: NAF code, collective agreement, filed sales/profits, legal form. |
| peopleDataLabs | searchCompanies / queryCompanies | 3 | ✅ | `searchCompanies` uses cargo's `{conjonction, groups, conditions}` filter shape; `queryCompanies` takes a PDL **SQL string**. Investor/funding filters require the SQL variant. |
## Sourcing — Local SMBs
| Provider | Action | Cost | Notes |
|---|---|---|---|
| serper | searchPlaces | 0.05 | Google Maps-style, **fixed per query**. Default for SMB / storefront / service-area. |
| firecrawl | search | 0.05 | Web search fallback; same price, unstructured results. |
## Enrich — Person
| Provider | Action | Cost | Priority? | Notes |
|---|---|---|---|---|
| aiArk | enrichPerson | 0.1 | ✅ | **Default in priority stack when a LinkedIn URL is in hand.** LinkedIn URL → full profile **+ verified email**; bills 0 on no-email. Cheapest URL-anchored enrich that also returns an email. |
| contactOut | enrich | 0–3 | | Variable cost depending on data returned. |
| linkedin | enrichProfile | 0.25 | | LinkedIn-anchored (no email). |
| prospeo | enrichLinkedin | 0.5 | | Cheapest LinkedIn URL → details. |
| linkedin | enrichProfileFromName | 0.5 | | Name+company → LinkedIn details. |
| apolloio | enrichPerson | 1 (**9** with `revealPhoneNumber`) | ✅ | Niche-coverage rung — promote per-batch only when a pilot shows Apollo hits where aiArk/waterfall miss. The phone flag is **9x**, not a small uplift. |
| waterfall | enrichContact | 2 | ✅ | Multi-source contact enrichment. |
| peopleDataLabs | enrichPerson | 3 | ✅ | Heavyweight backfill. |
| rocketreach | lookupPerson | 1 | | Any identifier mix (name + employer, title, URL, email); NPI lookups for healthcare. |
| datagma | enrichPerson | 8 | | LinkedIn URL or work email → profile. Priced as a phone rung; use only when cheaper rungs miss. |
| mixrank | findPerson | 4 | | **Last rung.** The only one that resolves from a bare phone number. |
**Personality / selling guidance:** `aiArk.analyzePersonality` (0.05) turns a LinkedIn profile into OCEAN + DISC traits with tailored selling notes. Nothing else in the catalog does it. It is a personalization input, not an identity field — treat the output as a hypothesis about how to write, never as a fact about the person.
**From an email rather than a URL:** `aiArk.reverseLookup` (0.05) is the cheapest, then `companyEnrich.lookupPerson` (0.25, resolves the company from the domain), then `contactOut.enrich` (0–3 by config), then `datagma.enrichPersonFromPersonalEmail` (2, the only rung that takes a **personal** address — non-EU only).
## Enrich — Company
| Provider | Action | Cost | Priority? | Notes |
|---|---|---|---|---|
| aiArk | enrichCompany | 0.01 | ✅ | **Cheapest in catalog.** Firmographics from a domain or LinkedIn URL. |
| companyEnrich | enrichByDomain | 0.25 | | Fuller field set than aiArk when 0.01 comes back thin. |
| companyEnrich | getWorkforce | 0.25 | | Historical headcount **by department** — a growth signal nothing else in the catalog returns. |
| linkedin | enrichCompany | 0.25 | | LinkedIn ID-based. |
| prospeo | enrichCompany | 0.5 | | Alt mid-tier. |
| linkedin | enrichCompanyFromDomain | 0.5 | | Domain → LinkedIn-anchored details. |
| apolloio | enrichOrganization | 1 | ✅ | Apollo-anchored; the niche-coverage rung when the cheaper rungs miss. |
| oceanio | enrichCompany | 1 | | Mid-tier. |
| reverseContact | enrichCompanyFromLinkedin | 1 | | Niche: LinkedIn URL → company. |
| waterfall | enrichCompany | 1 | ✅ | Multi-source. |
| peopleDataLabs | enrichCompany | 3 | ✅ | Heavyweight backfill. |
| societeInfo | enrich | 4 | | **France only.** Resolves to the official registry record (SIREN/SIRET). The only source for French statutory data. |
| mixrank | findCompany | 4 | | **Last rung.** Resolves from name, URL, or LinkedIn when everything above missed. |
## Headcount & workforce
The most-asked company attribute, and the one with the most sources — they answer
different questions and are not interchangeable. The `salesNavigator.find*` calls
key on a LinkedIn **`companyId`**, not a domain; a list without one pays 0.05/account
through `searchAccounts` first ([`../recipes/custom-datapoints.md`](../recipes/custom-datapoints.md) prices this as an ID prerequisite).
| Provider | Action | Cost | Notes |
|---|---|---|---|
| salesNavigator | findEmployeesCount | 0.25 | Headcount snapshot. |
| salesNavigator | findEmployeesDistribution | 0.25 | Role / department split — the SDR:AE-ratio question. |
| salesNavigator | findCompanyMetrics | 0.25 | Growth and trend metrics. |
| salesNavigator | findCompanyInsights | 0.25 | Mixed company insights. |
| companyEnrich | getWorkforce | 0.25 | Historical headcount **by department** — the only source of the time series. |
| linkedin | findCustomHeadcount | 0.5 | "How many people matching *keyword* work there" — a headcount for a role the other actions don't bucket. |
| linkedin | extractCompanyEmployeesInsights | 0.25 | Aggregate employee view from the LinkedIn page. |
## Per-domain contact discovery
Distinct from **Sourcing — Search people**: these start from one domain you already
hold and return who is there, rather than searching a population by title. Cheap for
a handful of contacts at a known account; wrong for building a list.
| Provider | Action | Cost | Notes |
|---|---|---|---|
| icypeas | scanDomain | 0.1 | **Role-based** addresses only (`contact@`, `admin@`) — not named people. |
| hunter | searchDomain | 1 | Named people at one domain, filtered by seniority / department. **Max 10 per call** — never loop it to build a list. |
| societeInfo | search (`objectType: contact`) | 4 / item | **France only.** Contacts at one registered company, by registry number. |
## Find email
| Provider | Action | Cost | Priority? | Notes |
|---|---|---|---|---|
| icypeas | findEmail | 0.1 | | Cheapest. Use as cheap-fallback. |
| findyMail | findEmail | 0.5 | | Mid-tier. |
| hunter | findEmail | 0.5 | | Mid-tier; different underlying source. |
| leadMagic | findEmail | 0.5 | | Mid-tier. |
| prospeo | findEmail | 0.5 | | Mid-tier. |
| FullEnrich | findEmail | 1 | ✅ | **Default in priority stack** — best hit rate. |
| dropcontact | findEmail | 1 | | French data tier. |
| datagma | findEmail | 1 | | Alt mid-tier. |
| enrichCrm | findEmail | 1 | | CRM-friendly fallback. |
| enrowio | findEmail | 1 | | Alt mid-tier. |
> **Check step 3 before paying here.** `aiArk.enrichPerson` (0.1, Enrich — Person above) already returns a verified email from a LinkedIn URL and bills 0 when it finds none — run this stage on the residue it left empty, not on the whole list.
## Verify email
| Provider | Action | Cost | Priority? | Notes |
|---|---|---|---|---|
| icypeas | verifyEmail | 0.01 | | **Cheapest in catalog.** Use for very large verifies. |
| kitt | verifyEmail | 0.05 | | |
| enrichley | verify | 0.1 | | |
| enrowio | verifyEmail | 0.1 | | |
| waterfall | verifyEmail | 0.1 | ✅ | **Default in priority stack** — multi-source. |
| zeroBounce | verifyEmail | 0.1 | | |
| neverBounce | verifyEmail | 0.2 | | |
| findyMail | verifyEmail | 0.25 | | |
| bouncer | verifyEmail | 0.3 | | |
| hunter | verifyEmail | 1 | | Most expensive — avoid unless other tier is failing. |
## Find phone
| Provider | Action | Cost | Priority? | Notes |
|---|---|---|---|---|
| aiArk | findMobilePhone | 0.5 | ✅ | **Cheapest.** Mobile-only; needs a LinkedIn URL or domain+name. Bills 0 on miss. First stop with a URL in hand. |
| prospeo | findPhone | 3 | | Cheapest landline/DID; escalate from aiArk on a mobile miss. |
| forager | findPhone | 5 | | Mid-tier. |
| findyMail | findPhone | 5 | | Mid-tier. |
| FullEnrich | findPhone | 6 | ✅ | Better hit rate; escalate from prospeo. |
| waterfall | findPhone | 7 | ✅ | Multi-source; last-resort priority stack. |
| FullEnrich | findPhoneAndEmail | 7 | ✅ | Combined call. No discount over running both. |
| datagma | findPhone | 8 | | |
| apolloio | enrichPerson (`revealPhoneNumber: true`) | **9** | ✅ | Phone bundled into the person enrich (1 → 9). Rarely the right call: at 9 it is dearer than every dedicated phone rung except cleon1. |
| cleon1 | findPhoneFromLinkedin | 15 | | Premium; LinkedIn-anchored. |
## LinkedIn URL lookup
| Provider | Action | Cost | Notes |
|---|---|---|---|
| linkedin | findProfileUrl | 0.25 | Default. See `recipes/linkedin-url-lookup.md` for validation pattern. |
| linkedin | enrichProfile | 0.25 | Validation step after findProfileUrl. |
| FullEnrich | reverseEmailLookup | 2 | Email → LinkedIn URL. Unique action. |
## Job change signal
| Provider | Action | Cost | Notes |
|---|---|---|---|
| waterfall | detectJobChange | 3 | **Only credits-based job-change action in entire catalog.** Cargo-unique strength. |
## Funding signal
| Provider | Action | Cost | Notes |
|---|---|---|---|
| enrichCrm | getFunding | 1 | Only credits-based funding action in the catalog. |
## Tech-stack signal
| Provider | Action | Cost | Notes |
|---|---|---|---|
| builtwith | getDomainSummary | **0** | Free tier — technology-group *counts* for a domain. Enough to bucket accounts before paying for detail. |
| theirStack | searchTechnologies | 0.5 | Catalog-style lookup. |
| builtwith | enrichDomain | 1 | Full stack + metadata for one domain. |
## Hiring intent
| Provider | Action | Cost | Notes |
|---|---|---|---|
| theirStack | searchJobs | 0.5 | Default. |
| linkedin | searchJobs | 0.5 | Same price, different index — filters are LinkedIn enums from the integration's autocompletes, not free text. |
| linkedin | enrichJob | 0.25 | One posting URL → full job detail. The drill-down after either search. |
## Warm intros
| Provider | Action | Cost | Notes |
|---|---|---|---|
| theSwarm | searchWarmIntrosToCompany | 2 | Find warm-intro paths to a company. |
| theSwarm | searchWarmIntrosToPerson | 2 | Find warm-intro paths to a specific person. |
## Visitor identification
| Provider | Action | Cost | Notes |
|---|---|---|---|
| snitcher | searchSessions | 0 | Free credits-tier. De-anonymize site visitors. |
## LinkedIn audience extraction
Everything here is **0.05 per item returned** and needs a LinkedIn URL in hand. They read an audience that already engaged with something, which is a cheaper and warmer starting point than a cold title search.
| Provider | Action | Cost | Notes |
|---|---|---|---|
| linkedin | extractEventAttendees | 0.05 / item | Attendees of a LinkedIn event. |
| linkedin | searchPostComments / searchPostReactions | 0.05 / item | Who engaged with a specific post. |
| linkedin | enrichPost | 0.25 | One post URL → its content and engagement counts. Flat, not per item. |
| linkedin | extractProfilePostActivity / extractProfileCommentActivity / extractProfileReactionActivity | 0.05 / item | What one person has been posting, commenting on, reacting to. |
| linkedin | extractFollowers / extractPageFollowers | 0.05 / item | A profile's or page's followers. |
| linkedin | extractProfileViewers / extractCompanyViewers | 0.05 / item | Who viewed the profile / company page. |
| linkedin | extractCompanyEmployeesInsights | 0.25 | Aggregate view of a company's employees. |
| linkedin | extractSimilarCompanies | 0.25 | Lookalikes from LinkedIn's own graph. |
These act through a real LinkedIn identity — the engagement actions (`connectProfile`, `commentPost`, `messageProfile`, all 0.25) are rate-and-conduct sensitive and must never be batch-blasted. See [`../provider-playbooks/linkedin.md`](../provider-playbooks/linkedin.md) and [`acceptable-use.md`](acceptable-use.md) §2.
## Social profiles (non-LinkedIn)
| Provider | Action | Cost | Notes |
|---|---|---|---|
| x | `getUserProfile` / `getUserPosts` / `getFollowers` / `getPostLikers` / `searchPosts` … (14 actions) | 0.02 | Everything X. Cheapest social rung in the catalog. |
| brightData | `scrapeInstagramProfile` / `scrapeTikTokProfile` / `scrapeFacebookProfile` / `scrapeFacebookPagePosts` / `scrapeYouTubeChannel` | 0.1 | **Only** coverage for these four platforms. One profile URL in, profile out. |
| brightData | `scrapeTwitterProfile` | 0.1 | 5x `x.getUserProfile` for less. Fallback only. |
Consumer platforms: read **brand, creator and agency accounts as company records**. Building person records from a named individual's personal social profile is a consumer-targeting refusal under [`acceptable-use.md`](acceptable-use.md) §2 — see [`../provider-playbooks/brightData.md`](../provider-playbooks/brightData.md).
## Web research
| Provider | Action | Cost | Priority? | Notes |
|---|---|---|---|---|
| parallel | extract | 0.025 **per URL** | ✅ | **Cheapest page read in the catalog.** Takes an `objective` to steer extraction. |
| serper | search | 0.05 (fixed) | | Google results, **fixed per query for up to 100** — raise `limit`, never the query count. |
| firecrawl | scrape / search / crawl | 0.05 **per item** | | Reach for `crawl` when you need a whole site rather than a URL list. |
| parallel | createTask | 0.125 (`lite`) | ✅ | **Unique.** Agentic research filling a caller-supplied `outputSchema`. Ladder runs to 60 (`ultra8x`); `processor` is required, so the tier is always deliberate. |
| parallel | search | 0.125 fixed **+ 0.025/item** | | Objective-steered ranked search. |
| exa | search | 0.175 fixed **+ 0.025/item** | | The only rung with a **`category` filter** (`company`, `news`, `financial report`, …) and publication-date bounds. `searchType: "deep"` raises the fixed part to 0.3. |
| linkup | search | 0.5 standard / 2 deep | | Web search with answers. |
| linkup | instruct | 1 | | Sourced or schema-structured answers in one call. |
**Corrected 2026-08-15**: this table priced `serper.search` at **1**. It is **0.05**, verified against the live integration catalog, and the 20x error was steering agents away from the cheapest search rung. `provider-playbooks/serper.md` had it right throughout.
**Corrected 2026-08-20**, all verified against the live catalog: `serper.searchPlaces` was priced at **1**, the same 20x error as `search` above, missed in the previous pass — it is **0.05**. `apolloio.enrichPerson` with `revealPhoneNumber` was priced at **3**; it is **9**. `anthropic.instruct`'s cheapest rung was labelled 0.2 (Haiku); Haiku 3.5 is **0.05** and 0.2 is Sonnet. `prospeo.verifyEmail` was listed at 0.1 and **no longer exists** in the catalog. `aiArk.enrichCompany` (**0.01**, the cheapest company enrich there is) was missing from Enrich — Company entirely.
Picking between them: **known URL → `parallel.extract`. Plain keyword query → `serper.search`. Needs a document-type or date filter → `exa.search`. Needs structured output → `parallel.createTask` at `lite`.** Reach for `linkup.instruct` only when a prose sourced answer is genuinely what you want, since it is 8x `createTask` at `lite`.
## LLM (instruct)
| Provider | Action | Cost (cheapest model) | Notes |
|---|---|---|---|
| openAi | instruct | 0.006 (gpt-5-nano) | Cheapest at-scale. Ladder to 0.5 (gpt-4o). |
| gemini | instruct | 0.01 (1.5/2.0 Flash) | Cheap large-context. Ladder to 0.25 (3.6 Flash). |
| anthropic | instruct | 0.05 (Haiku 3.5) | **0.2 for Sonnet**, 2 for Opus, 4 for Fable 5. Default for high-quality reasoning + structured output. |
| perplexity | instruct | 0.3 (Sonar, `searchContextSize: low`) | Web-grounded research with citations. Ladder to 1 (sonar-pro, high). |
Prices are **per 1k tokens**, not per record. On openAi, gemini and anthropic, `advancedSettings.withWebSearch: true` adds a **0.4 flat charge per call** on top — cheap per token, expensive per row in a batch. Per-model pricing is in [`credits-cost-table.md`](credits-cost-table.md).
## Notes on this map
- This map is curated, not exhaustive: it carries the cheapest rungs per stage plus the routing judgement. The complete list of all **176** credits-based actions is generated from the live catalog into [`credits-cost-table.md`](credits-cost-table.md) — regenerate it from `cargo-ai orchestration action list --kind connector` and `--kind native`, which return a `credits` array on every billed action. The other 337 catalog actions (sequencer / CRM upserts, list/get/delete) carry no provider price and appear in neither — though every node execution still bills 0.01 credits.
- Costs are per-record at the cheapest config. Some actions have variable cost by config (e.g., `contactOut.enrich` returns 0/1/2/3 credits depending on data returned).
- Priority stack: see `../SKILL.md` for the canonical 8-provider priority list and `../provider-playbooks/` for per-provider deep dives.
references/waterfall-strategy.md
# Waterfall strategy — multi-provider fallback chains
A "waterfall" is a chain of provider calls where each step runs only on the rows where the prior step came up empty. It maximizes coverage while minimizing credit spend — cheap providers do the heavy lifting, premium providers fill gaps.
This doc defines the canonical waterfall chains by enrichment goal. Every recipe in this skill that talks about "fallback" or "escalation" follows one of these chains.
## The general pattern
```
1. Run cheapest credible provider on full input set.
2. Filter result: separate hits from misses.
3. Run next-tier provider only on the misses.
4. Repeat until the chain ends or hit-rate justifies stopping.
5. Coalesce: merge results column-by-column, preferring higher-quality sources.
```
Cargo doesn't have a built-in "waterfall" primitive — you implement this as N sequential `action execute-batch` calls with the records pruned between calls.
## Chain — Find email
Goal: get a verifiable email for a person given name + company (or LinkedIn).
```
1. FullEnrich.findEmail (1 cred) ← default; best hit rate
2. hunter.findEmail (0.5 cred) ← different underlying source
3. peopleDataLabs.enrichPerson (3 cred) ← heavyweight backfill (also returns email)
4. icypeas.findEmail (0.1 cred) ← cheap last resort
```
Then **always**:
```
5. waterfall.verifyEmail (0.1 cred) ← verify every found email before use
```
Don't skip step 5 — email finders return catch-all addresses that look valid but bounce.
**Verification hard rules:**
- A provider's own `verified` / `valid` flag is the provider grading its own homework. Always validate with an independent step (`waterfall.verifyEmail`) regardless of what the finder claimed.
- Ship a **catch-all** email only when a second, independent finder returned the exact same address. One source + catch-all = "unverified", flag it as such.
**Example flow (200 contacts):**
```bash
# Step 1 — try FullEnrich on all 200
... > /tmp/step1.json
# Hits: 140 found, 60 missed.
# Step 2 — hunter on the 60 missed
... > /tmp/step2.json
# Hits: 30 of 60 found.
# Step 3 — peopleDataLabs on the 30 still missed
... > /tmp/step3.json
# Hits: 18 of 30. Total: 188/200 = 94% hit rate.
# Stop the chain — running step 4 (icypeas) on 12 rows isn't worth credits
# (60% of those will probably miss too).
# Verify all 188 emails
... > /tmp/verified.json
```
## Chain — Enrich company firmographics
Goal: get firmographics (industry, size, geo, founded, …) for a company given domain.
```
1. aiArk.enrichCompany (0.01 cred) ← default; domain or LinkedIn URL
2. companyEnrich.enrichByDomain (0.25 cred) ← rows aiArk returned thin
3. waterfall.enrichCompany (1 cred) ← still empty
4. peopleDataLabs.enrichCompany (3 cred) ← last resort
```
Every rung keys on the domain, so there is no id-resolution step and each escalation runs on the residue of the one above it.
For tech-stack signals, run `builtwith.getDomainSummary` (**free**) across the whole list first, then `builtwith.enrichDomain` (1) or `theirStack.searchTechnologies` (0.5) only on the rows the free summary left ambiguous.
## Chain — Enrich person details
Goal: get title, location, role, employment for a person given name + company (or email or LinkedIn).
```
1. aiArk.enrichPerson (0.1 cred) ← default when a LinkedIn URL is in hand
2. waterfall.enrichContact (2 cred) ← no URL, or aiArk came back empty
3. apolloio.enrichPerson (1 cred) ← niche coverage on the residue
4. peopleDataLabs.enrichPerson (3 cred)
```
## Chain — LinkedIn URL resolution
Goal: get the correct LinkedIn URL for a person given name + company.
```
1. linkedin.findProfileUrl (0.25 cred)
2. linkedin.enrichProfile on candidate (0.25 cred) ← validation step (mandatory)
3. FullEnrich.reverseEmailLookup (2 cred) ← only if email available
```
See [`../recipes/linkedin-url-lookup.md`](../recipes/linkedin-url-lookup.md) for the strict-validation pattern. Don't skip step 2 — false positive rate is high without validation.
## Chain — Phone number
Goal: get a phone number for a person.
```
1. prospeo.findPhone (3 cred) ← cheapest
2. FullEnrich.findPhone (6 cred) ← better hit rate
3. waterfall.findPhone (7 cred) ← multi-source last resort
```
Phone lookup is expensive (3–7 credits/record). Run only on qualified leads, not on the full prospect list.
## Cost discipline
The mandatory spend rules (pilot → approval gate, per-run receipts, 1.4×N over-provision, count-first sizing) live in [`cost-discipline.md`](cost-discipline.md) — they apply to every chain here. Waterfall-specific rules on top:
1. **Filter aggressively between steps**: don't pass rows that already have the field populated.
2. **Stop early**: if hit rate after step 2 is > 90%, the marginal cost of step 3 may not be worth it — the remaining misses are mostly rows no provider covers (coverage is a property of the company).
3. **Demote dynamically**: if a provider misses on the first ~10 rows of a batch, move it later in the chain for the rest of that batch — its coverage doesn't match this segment.
4. **Track credit spend**: after each step, run `cargo-ai billing usage get-metrics` and fold the numbers into the run receipt.
## Coalesce — merge results from the chain
After running 2–3 steps of a chain, you have multiple files with partial results. Merge by record:
```bash
jq -s '
# Combine three step files, preferring higher-quality sources
[.[0].results, .[1].results, .[2].results]
| flatten
| group_by(.input.recordId)
| map(reduce .[] as $r ({}; . * $r))
' /tmp/step1.json /tmp/step2.json /tmp/step3.json > /tmp/coalesced.json
```
This pattern (group → reduce with object-merge) takes the latest non-null value per field. Adjust the source-priority order to match the per-column quality preferences in [`../guides/enriching-and-researching.md`](../guides/enriching-and-researching.md).
scripts/contact-accuracy-audit.ts
// Contact accuracy audit — the capstone QA pass for a prospecting run.
//
// After source → enrich → verifyEmail, stamp every output row with a verdict
// (SEND / VERIFY / REVIEW / REMOVE) plus the flags behind it, so nothing
// unverified or misattributed reaches a sequencer or CRM. It is a report, not
// a gate: the exit code is 0 regardless of verdicts (except --fixtures mode).
//
// Verdict rules (first match sets the action; ALL applicable flags are kept):
// 1. no email at all → REMOVE no-email
// 2. risk invalid/disposable or verification bad → REMOVE invalid-email /
// disposable-email /
// failed-verification
// 3. is_duplicate true (later occurrence) → REMOVE duplicate-row
// 4. name_match false (wrong person) → REMOVE name-mismatch
// 5. catch-all with < 2 corroborating providers → VERIFY catchall-single-source
// 6. verification unknown or missing → VERIFY unverified-email
// 7. role confidence low (likely job changer) → REVIEW stale-or-ambiguous-role
// 8. role-based address (info@, sales@, …) → REVIEW role-account
// 9. otherwise → SEND (catchall-corroborated /
// free-provider / partial-signals
// kept for transparency)
//
// Usage:
// node contact-accuracy-audit.ts --input rows.csv [--output audited.csv]
// node contact-accuracy-audit.ts --workflow-uuid <uuid> [--batch-uuid <uuid>]
// [--output-node-slug <slug>] [--workspace-uuid <uuid>]
// node contact-accuracy-audit.ts --input rows.csv --summary-json
// node contact-accuracy-audit.ts --fixtures
//
// Columns are auto-detected (see COLUMN_CANDIDATES); override with
// --email-column, --status-column, --corroboration-column,
// --name-match-column, --role-confidence-column, --email-risk-column.
//
// Runtime: Node >= 22.18 (`node contact-accuracy-audit.ts`, native
// type-stripping) — erasable TypeScript only, zero npm dependencies.
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import {
parseArgs,
readRows,
readJson,
toCsv,
reportFixtureRun,
fail,
type Args,
type Row,
} from "./lib/common.ts";
type Action = "SEND" | "VERIFY" | "REVIEW" | "REMOVE";
type Verification = "valid" | "catch_all" | "invalid" | "unknown" | "missing";
// ---------------------------------------------------------------------------
// Column detection
// ---------------------------------------------------------------------------
type ColumnKey =
| "email"
| "status"
| "corroboration"
| "nameMatch"
| "roleConfidence"
| "emailRisk"
| "isDuplicate";
// Candidate header names, normalized (lowercase, separators stripped) and in
// priority order. Status names follow what waterfall verifyEmail pipelines
// emit; nameMatch / roleConfidence / emailRisk are the columns produced by
// validate-linkedin-names.ts, select-current-role.ts, and validate-emails.ts.
const COLUMN_CANDIDATES: Record<ColumnKey, string[]> = {
email: ["email", "workemail", "emailaddress"],
status: [
// email_status (waterfall.verifyEmail output) normalizes to emailstatus;
// recipes merge that field onto rows as emailStatus before audit.
"emailstatus",
"verificationstatus",
"verification",
"emailverificationstatus",
// Bare "status" last — generic enough that any more specific header must win.
"status",
],
corroboration: [
"providercount",
"sourcescount",
"corroborations",
"corroborationcount",
],
nameMatch: ["namematch"],
roleConfidence: ["roleconfidence"],
emailRisk: ["emailrisk"],
isDuplicate: ["isduplicate"],
};
const OVERRIDE_FLAGS: Record<ColumnKey, string> = {
email: "email-column",
status: "status-column",
corroboration: "corroboration-column",
nameMatch: "name-match-column",
roleConfidence: "role-confidence-column",
emailRisk: "email-risk-column",
isDuplicate: "is-duplicate-column",
};
function normalizeKey(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]/g, "");
}
type Columns = Partial<Record<ColumnKey, string>>;
function detectColumns(headers: string[], args: Args): Columns {
const columns: Columns = {};
for (const key of Object.keys(COLUMN_CANDIDATES) as ColumnKey[]) {
const override = args.values.get(OVERRIDE_FLAGS[key]);
if (override) {
if (!headers.includes(override)) {
fail(`--${OVERRIDE_FLAGS[key]} "${override}" not found in input columns`);
}
columns[key] = override;
continue;
}
for (const candidate of COLUMN_CANDIDATES[key]) {
const found = headers.find((h) => normalizeKey(h) === candidate);
if (found) {
columns[key] = found;
break;
}
}
}
return columns;
}
// ---------------------------------------------------------------------------
// Signal extraction
// ---------------------------------------------------------------------------
type Signals = {
hasEmail: boolean;
verification: Verification;
corroborations: number;
nameMatch: string; // "true" | "false" | "" (not checked)
roleConfidence: string; // "high" | "medium" | "low" | ""
emailRisk: string; // "ok" | "free" | "role" | "disposable" | "invalid" | ""
/** is_duplicate from validate-emails.ts: a later occurrence of an address
* already in this list — the first occurrence carries the send. */
isDuplicate: boolean;
/** BOTH name_match and role_confidence columns exist (both upstream identity
* scripts ran) — one alone is still a partial identity check. */
identityColumnsPresent: boolean;
};
function normalizeVerification(raw: string): Verification {
const value = raw.trim().toLowerCase().replace(/[\s-]+/g, "_");
if (value === "") return "missing";
if (["valid", "deliverable", "ok", "safe"].includes(value)) return "valid";
if (["catch_all", "catchall", "accept_all", "acceptall", "risky"].includes(value)) {
return "catch_all";
}
if (["invalid", "undeliverable", "bad"].includes(value)) return "invalid";
return "unknown";
}
function extractSignals(row: Row, columns: Columns): Signals {
const value = (key: ColumnKey): string => {
const column = columns[key];
return column ? (row[column] ?? "").trim() : "";
};
const hasEmail = value("email") !== "";
const parsedCorroborations = Number.parseInt(value("corroboration"), 10);
return {
hasEmail,
verification: normalizeVerification(value("status")),
// Missing corroboration count → 1 when an email is present (the finder
// that produced it counts as one source).
corroborations: Number.isNaN(parsedCorroborations)
? hasEmail
? 1
: 0
: parsedCorroborations,
nameMatch: value("nameMatch").toLowerCase(),
roleConfidence: value("roleConfidence").toLowerCase(),
emailRisk: value("emailRisk").toLowerCase(),
isDuplicate: value("isDuplicate").trim().toLowerCase() === "true",
identityColumnsPresent:
columns.nameMatch !== undefined && columns.roleConfidence !== undefined,
};
}
// ---------------------------------------------------------------------------
// Verdict
// ---------------------------------------------------------------------------
const FLAG_REASONS: Record<string, string> = {
"no-email": "No email address on this row — nothing to send to.",
"invalid-email": "Email failed syntax validation and will bounce.",
"disposable-email": "Email uses a disposable domain — not a durable contact.",
"failed-verification": "Email verification returned invalid — this address bounces.",
"name-mismatch":
"Profile name does not match the contact — likely the wrong person, worse than no send.",
"catchall-single-source":
"Catch-all domain with a single source — needs a second independent provider before sending.",
"unverified-email": "Email was never verified — run verifyEmail before sending.",
"stale-or-ambiguous-role":
"Role confidence is low — the contact may have changed jobs.",
"role-account": "Role-based address (info@, sales@, …) — unlikely to reach a person.",
"catchall-corroborated":
"Catch-all domain corroborated by 2+ independent providers — safe to send.",
"free-provider": "Free email provider — fine for SMB outreach.",
"partial-signals":
"Audited with incomplete identity signals — run both validate-linkedin-names.ts and select-current-role.ts for full coverage.",
"duplicate-row":
"Same address appears earlier in this list — the first occurrence carries the send.",
};
type Verdict = { action: Action; flags: string[]; flagReason: string };
export function auditRow(signals: Signals): Verdict {
const s = signals;
const flags: string[] = [];
if (!s.hasEmail) flags.push("no-email");
if (s.hasEmail && s.emailRisk === "invalid") flags.push("invalid-email");
if (s.hasEmail && s.emailRisk === "disposable") flags.push("disposable-email");
if (s.hasEmail && s.verification === "invalid") flags.push("failed-verification");
if (s.isDuplicate) flags.push("duplicate-row");
if (s.nameMatch === "false") flags.push("name-mismatch");
const singleSourceCatchAll =
s.hasEmail && s.verification === "catch_all" && s.corroborations < 2;
if (singleSourceCatchAll) flags.push("catchall-single-source");
const unverified = s.verification === "unknown" || s.verification === "missing";
if (s.hasEmail && unverified) flags.push("unverified-email");
if (s.roleConfidence === "low") flags.push("stale-or-ambiguous-role");
if (s.hasEmail && s.emailRisk === "role") flags.push("role-account");
if (s.hasEmail && s.verification === "catch_all" && s.corroborations >= 2) {
flags.push("catchall-corroborated");
}
if (s.hasEmail && s.emailRisk === "free") flags.push("free-provider");
let action: Action;
let primary: string;
if (!s.hasEmail) {
action = "REMOVE";
primary = "no-email";
} else if (
s.emailRisk === "invalid" ||
s.emailRisk === "disposable" ||
s.verification === "invalid"
) {
action = "REMOVE";
primary =
s.emailRisk === "invalid"
? "invalid-email"
: s.emailRisk === "disposable"
? "disposable-email"
: "failed-verification";
} else if (s.isDuplicate) {
action = "REMOVE";
primary = "duplicate-row";
} else if (s.nameMatch === "false") {
action = "REMOVE";
primary = "name-mismatch";
} else if (singleSourceCatchAll) {
action = "VERIFY";
primary = "catchall-single-source";
} else if (unverified) {
action = "VERIFY";
primary = "unverified-email";
} else if (s.roleConfidence === "low") {
action = "REVIEW";
primary = "stale-or-ambiguous-role";
} else if (s.emailRisk === "role") {
action = "REVIEW";
primary = "role-account";
} else {
action = "SEND";
if (!s.identityColumnsPresent) flags.push("partial-signals");
primary = flags[0] ?? "";
}
return { action, flags, flagReason: primary === "" ? "" : FLAG_REASONS[primary] };
}
// ---------------------------------------------------------------------------
// Fixture mode
// ---------------------------------------------------------------------------
type FixtureCase = {
row: Row;
expected: { action: Action; flags: string[] };
note: string;
};
function runFixtures(): void {
const path = join(import.meta.dirname, "fixtures_contact_accuracy_audit.json");
const { cases } = readJson<{ cases: FixtureCase[] }>(path);
const noArgs: Args = { values: new Map(), flags: new Set() };
const failures: string[] = [];
cases.forEach((fixture, index) => {
const columns = detectColumns(Object.keys(fixture.row), noArgs);
const verdict = auditRow(extractSignals(fixture.row, columns));
const got = [...verdict.flags].sort().join(";");
const want = [...fixture.expected.flags].sort().join(";");
if (verdict.action !== fixture.expected.action || got !== want) {
failures.push(
`case ${index + 1} (${fixture.note}): expected ${fixture.expected.action} ` +
`[${want}], got ${verdict.action} [${got}]`,
);
}
});
reportFixtureRun("contact-accuracy-audit", { total: cases.length, failures });
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2), {
value: [
"input",
"workflow-uuid",
"batch-uuid",
"output-node-slug",
"workspace-uuid",
"output",
...Object.values(OVERRIDE_FLAGS),
],
boolean: ["fixtures", "summary-json", "json"],
});
if (args.flags.has("fixtures")) return runFixtures();
const rows = await readRows(args);
if (rows.length === 0) fail("no input rows to audit");
const headers = [...new Set(rows.flatMap((row) => Object.keys(row)))];
const columns = detectColumns(headers, args);
if (!columns.email) {
fail(
`could not detect an email column in [${headers.join(", ")}] — pass --email-column`,
);
}
const actionCounts: Record<Action, number> = {
SEND: 0,
VERIFY: 0,
REVIEW: 0,
REMOVE: 0,
};
const flagCounts = new Map<string, number>();
const audited: Row[] = rows.map((row) => {
const verdict = auditRow(extractSignals(row, columns));
actionCounts[verdict.action] += 1;
for (const flag of verdict.flags) {
flagCounts.set(flag, (flagCounts.get(flag) ?? 0) + 1);
}
return {
...row,
audit_action: verdict.action,
audit_flags: verdict.flags.join(";"),
audit_flag_reason: verdict.flagReason,
};
});
// Summary table (always stderr, so stdout stays machine-readable).
const topFlags = [...flagCounts.entries()].sort((a, b) => b[1] - a[1]).slice(0, 5);
const summaryLines = [`contact-accuracy-audit: ${rows.length} rows audited`];
for (const action of ["SEND", "VERIFY", "REVIEW", "REMOVE"] as Action[]) {
summaryLines.push(` ${action.padEnd(8)}${actionCounts[action]}`);
}
if (topFlags.length > 0) {
summaryLines.push("top flags:");
for (const [flag, count] of topFlags) {
summaryLines.push(` ${flag.padEnd(26)}${count}`);
}
}
process.stderr.write(summaryLines.join("\n") + "\n");
const outputPath = args.values.get("output");
// --json renders the audited rows as a JSON array instead of CSV — the shape
// jq chains want (e.g. select(.audit_action == "SEND") before handoff).
const rendered = args.flags.has("json")
? JSON.stringify(audited, null, 2) + "\n"
: toCsv(audited, [...headers, "audit_action", "audit_flags", "audit_flag_reason"]);
if (outputPath) {
writeFileSync(outputPath, rendered);
process.stderr.write(`wrote ${audited.length} audited rows to ${outputPath}\n`);
}
if (args.flags.has("summary-json")) {
const summary = {
send: actionCounts.SEND,
verify: actionCounts.VERIFY,
review: actionCounts.REVIEW,
remove: actionCounts.REMOVE,
flags: Object.fromEntries(flagCounts),
};
process.stdout.write(JSON.stringify(summary, null, 2) + "\n");
} else if (!outputPath) {
process.stdout.write(rendered);
}
}
main().catch((error) => fail(error instanceof Error ? error.message : String(error)));
scripts/fixtures_contact_accuracy_audit.json
{
"cases": [
{
"row": {
"firstName": "Ada",
"lastName": "Lovelace",
"company": "Analytical Engines",
"email": "ada@analyticalengines.com",
"emailStatus": "valid",
"providerCount": "2",
"name_match": "true",
"role_confidence": "high",
"email_risk": "ok"
},
"expected": {
"action": "SEND",
"flags": []
},
"note": "happy path: valid, corroborated, full identity signals"
},
{
"row": {
"firstName": "Grace",
"lastName": "Hopper",
"company": "Compilers Inc",
"email": "grace@compilersinc.com",
"emailStatus": "catch_all",
"providerCount": "1",
"name_match": "true",
"role_confidence": "high",
"email_risk": "ok"
},
"expected": {
"action": "VERIFY",
"flags": [
"catchall-single-source"
]
},
"note": "catch_all with a single source needs corroboration"
},
{
"row": {
"firstName": "Alan",
"lastName": "Turing",
"company": "Enigma Labs",
"email": "alan@enigmalabs.io",
"emailStatus": "catch_all",
"providerCount": "2",
"name_match": "true",
"role_confidence": "high",
"email_risk": "ok"
},
"expected": {
"action": "SEND",
"flags": [
"catchall-corroborated"
]
},
"note": "catch_all corroborated by 2 providers is sendable"
},
{
"row": {
"firstName": "Katherine",
"lastName": "Johnson",
"company": "Orbit Analytics",
"email": "katherine@orbitanalytics.com",
"emailStatus": "accept_all",
"providerCount": "1",
"name_match": "true",
"role_confidence": "high",
"email_risk": "ok"
},
"expected": {
"action": "VERIFY",
"flags": [
"catchall-single-source"
]
},
"note": "accept_all alias normalizes to catch_all"
},
{
"row": {
"firstName": "Edsger",
"lastName": "Dijkstra",
"company": "Shortest Path BV",
"email": "edsger@shortestpath.nl",
"emailStatus": "deliverable",
"providerCount": "3",
"name_match": "true",
"role_confidence": "high",
"email_risk": "ok"
},
"expected": {
"action": "SEND",
"flags": []
},
"note": "deliverable alias normalizes to valid"
},
{
"row": {
"firstName": "Barbara",
"lastName": "Liskov",
"company": "Substitution Systems",
"email": "barbara@substitutionsystems.com",
"emailStatus": "valid",
"providerCount": "2",
"name_match": "false",
"role_confidence": "high",
"email_risk": "ok"
},
"expected": {
"action": "REMOVE",
"flags": [
"name-mismatch"
]
},
"note": "name mismatch overrides a valid email — wrong person is worse than no send"
},
{
"row": {
"firstName": "Donald",
"lastName": "Knuth",
"company": "Literate Programs",
"email": "donald@literateprograms.com",
"emailStatus": "valid",
"providerCount": "2",
"name_match": "true",
"role_confidence": "low",
"email_risk": "ok"
},
"expected": {
"action": "REVIEW",
"flags": [
"stale-or-ambiguous-role"
]
},
"note": "low role confidence: likely job changer, review before sending"
},
{
"row": {
"firstName": "Info",
"lastName": "Desk",
"company": "Frontdoor Corp",
"email": "info@frontdoorcorp.com",
"emailStatus": "valid",
"providerCount": "2",
"name_match": "true",
"role_confidence": "high",
"email_risk": "role"
},
"expected": {
"action": "REVIEW",
"flags": [
"role-account"
]
},
"note": "role-based address is deliverable but rarely reaches a person"
},
{
"row": {
"firstName": "Tommy",
"lastName": "Throwaway",
"company": "Mayfly Media",
"email": "tommy@tempmailbox.net",
"emailStatus": "valid",
"providerCount": "1",
"name_match": "true",
"role_confidence": "high",
"email_risk": "disposable"
},
"expected": {
"action": "REMOVE",
"flags": [
"disposable-email"
]
},
"note": "disposable domain: remove even when verification passes"
},
{
"row": {
"firstName": "Margaret",
"lastName": "Hamilton",
"company": "Harborview Software",
"email": "margaret@harborviewsoftware.com",
"emailStatus": "",
"providerCount": "2",
"name_match": "true",
"role_confidence": "high",
"email_risk": "ok"
},
"expected": {
"action": "VERIFY",
"flags": [
"unverified-email"
]
},
"note": "verification never ran (empty status) — verify before sending"
},
{
"row": {
"firstName": "Nolan",
"lastName": "Noaddress",
"company": "Ghost Holdings",
"email": "",
"emailStatus": "",
"providerCount": "",
"name_match": "",
"role_confidence": "",
"email_risk": ""
},
"expected": {
"action": "REMOVE",
"flags": [
"no-email"
]
},
"note": "no email at all: nothing to send to"
},
{
"row": {
"firstName": "Radia",
"lastName": "Perlman",
"company": "Spanning Tree Co",
"email": "radia@spanningtree.co",
"emailStatus": "valid",
"providerCount": "2",
"email_risk": "ok"
},
"expected": {
"action": "SEND",
"flags": [
"partial-signals"
]
},
"note": "name/role columns absent entirely (upstream scripts not run) — audit on email signals alone"
},
{
"row": {
"firstName": "Sami",
"lastName": "Solo",
"company": "Solo Consulting",
"email": "sami.solo@gmail.com",
"emailStatus": "valid",
"providerCount": "2",
"name_match": "true",
"role_confidence": "medium",
"email_risk": "free"
},
"expected": {
"action": "SEND",
"flags": [
"free-provider"
]
},
"note": "free provider still sends (fine for SMB), flagged for transparency"
},
{
"row": {
"firstName": "Bea",
"lastName": "Bounce",
"company": "Hardfail Inc",
"email": "bea@hardfail.com",
"emailStatus": "invalid",
"providerCount": "2",
"name_match": "true",
"role_confidence": "high",
"email_risk": "ok"
},
"expected": {
"action": "REMOVE",
"flags": [
"failed-verification"
]
},
"note": "verification returned invalid: the address bounces"
},
{
"row": {
"firstName": "Vint",
"lastName": "Cerf",
"company": "Packet Partners",
"workEmail": "vint@packetpartners.com",
"verificationStatus": "catchall",
"corroborations": "1",
"name_match": "true",
"role_confidence": "high",
"email_risk": "ok"
},
"expected": {
"action": "VERIFY",
"flags": [
"catchall-single-source"
]
},
"note": "alternate column names (workEmail, verificationStatus, corroborations) + catchall alias"
},
{
"row": {
"firstName": "Rosa",
"lastName": "Riskova",
"company": "Maybe Mail",
"email": "rosa@maybemail.com",
"emailStatus": "risky",
"name_match": "true",
"role_confidence": "high",
"email_risk": "ok"
},
"expected": {
"action": "VERIFY",
"flags": [
"catchall-single-source"
]
},
"note": "risky alias maps to catch_all; missing corroboration count defaults to 1"
},
{
"row": {
"firstName": "Jean",
"lastName": "Bartik",
"company": "Eniac Ventures",
"email": "jean@eniacventures.com",
"emailStatus": "catch-all",
"providerCount": "3",
"name_match": "true",
"role_confidence": "low",
"email_risk": "ok"
},
"expected": {
"action": "REVIEW",
"flags": [
"stale-or-ambiguous-role",
"catchall-corroborated"
]
},
"note": "corroborated catch-all clears the email but low role confidence still demands review"
},
{
"row": {
"firstName": "Uma",
"lastName": "Unreachable",
"company": "Wrongco",
"email": "uma@wrongco.com",
"emailStatus": "undeliverable",
"providerCount": "2",
"name_match": "false",
"role_confidence": "high",
"email_risk": "ok"
},
"expected": {
"action": "REMOVE",
"flags": [
"failed-verification",
"name-mismatch"
]
},
"note": "undeliverable alias + name mismatch: primary action from verification, all flags collected"
},
{
"row": {
"firstName": "Milo",
"lastName": "Malformed",
"company": "Typo Traders",
"email": "milo@typo,traders",
"emailStatus": "valid",
"providerCount": "2",
"name_match": "true",
"role_confidence": "high",
"email_risk": "invalid"
},
"expected": {
"action": "REMOVE",
"flags": [
"invalid-email"
]
},
"note": "syntax-invalid email removes regardless of what the verifier claimed"
},
{
"row": {
"email": "nora@brightwave.example",
"status": "valid",
"providerCount": "2",
"name_match": "true",
"role_confidence": "high"
},
"expected": {
"action": "SEND",
"flags": []
},
"note": "bare \"status\" header (raw waterfall verifyEmail output) must be detected as the verification column"
},
{
"row": {
"email": "omar@northwind-labs.example",
"status": "catch_all",
"providerCount": "1",
"name_match": "true",
"role_confidence": "high"
},
"expected": {
"action": "VERIFY",
"flags": [
"catchall-single-source"
]
},
"note": "bare \"status\" carrying catch_all still triggers the corroboration rule"
},
{
"row": {
"email": "lena@acme-widgets.example",
"emailStatus": "valid",
"providerCount": "2",
"name_match": "true"
},
"expected": {
"action": "SEND",
"flags": [
"partial-signals"
]
},
"note": "only ONE identity script ran (name_match without role_confidence) — still partial-signals, one signal is not full identity coverage"
},
{
"row": {
"email": "kai@orbit-metrics.example",
"emailStatus": "valid",
"providerCount": "2",
"name_match": "true",
"role_confidence": "high",
"is_duplicate": "true"
},
"expected": {
"action": "REMOVE",
"flags": [
"duplicate-row"
]
},
"note": "is_duplicate true removes the later occurrence even when every other signal is green — the first occurrence carries the send"
}
]
}
scripts/fixtures_current_role.json
{
"cases": [
{
"name": "single-current-role",
"experiences": [
{ "title": "Head of Growth", "companyName": "Northwind Labs", "startDate": "2022-03", "current": true }
],
"expected": { "title": "Head of Growth", "company": "Northwind Labs", "confidence": "high" },
"note": "Exactly one active operating role."
},
{
"name": "ended-then-current",
"experiences": [
{ "title": "Account Executive", "companyName": "Old Harbor Software", "startDate": "2019-02", "endDate": "2022-05" },
{ "title": "Senior Account Executive", "companyName": "Northwind Labs", "startDate": "2022-06" }
],
"expected": { "title": "Senior Account Executive", "company": "Northwind Labs", "confidence": "high" },
"note": "Past endDate makes the first role inactive; no endDate means active."
},
{
"name": "string-false-current-flag",
"experiences": [
{ "title": "VP Engineering", "companyName": "Old Harbor Software", "start_date": "2018-01", "end_date": "2021-03", "jobStillWorking": "false" },
{ "title": "VP Engineering", "companyName": "Vector Systems", "start_date": "2021-04", "jobStillWorking": "true" }
],
"expected": { "title": "VP Engineering", "company": "Vector Systems", "confidence": "high" },
"note": "The classic bug: the string \"false\" is truthy — naive code picks Old Harbor."
},
{
"name": "string-true-current-flag",
"experiences": [
{ "title": "CTO", "company": "Vector Systems", "startDate": "2020-06", "isCurrent": "true" },
{ "title": "Engineering Manager", "company": "Old Harbor Software", "startDate": "2016-01", "endDate": "2020-05", "isCurrent": "false" }
],
"expected": { "title": "CTO", "company": "Vector Systems", "confidence": "high" },
"note": "String \"true\" flag counts as current; string \"false\" does not."
},
{
"name": "string-false-capitalized-no-end-date",
"experiences": [
{ "title": "Sales Director", "companyName": "Old Harbor Software", "startDate": "2019-01", "jobStillWorking": "False" },
{ "title": "Sales Director", "companyName": "BrightWave", "startDate": "2023-02", "jobStillWorking": "True" }
],
"expected": { "title": "Sales Director", "company": "BrightWave", "confidence": "high" },
"note": "\"False\" with a missing end date means left-with-unknown-end, not active."
},
{
"name": "string-zero-and-one-flags",
"experiences": [
{ "title": "Recruiter", "companyName": "Old Harbor Software", "startDate": "2019-05", "endDate": "2021-06", "isCurrent": "0" },
{ "title": "Senior Recruiter", "companyName": "BrightWave", "startDate": "2021-07", "isCurrent": "1" }
],
"expected": { "title": "Senior Recruiter", "company": "BrightWave", "confidence": "high" },
"note": "\"0\" is false and \"1\" is true — both are strings, never truthiness-test."
},
{
"name": "operator-beats-board-seat",
"experiences": [
{ "title": "Board Member", "companyName": "Harbor Ventures", "startDate": "2023-01", "current": true },
{ "title": "Chief Executive Officer", "companyName": "Northwind Labs", "startDate": "2021-05", "current": true }
],
"expected": { "title": "Chief Executive Officer", "company": "Northwind Labs", "confidence": "high" },
"note": "Board seat is excluded even though it started later; the operator role wins."
},
{
"name": "only-board-roles",
"experiences": [
{ "title": "Advisor", "companyName": "BrightWave", "startDate": "2021-03", "current": true },
{ "title": "Board Member", "companyName": "Harbor Ventures", "startDate": "2023-06", "current": true }
],
"expected": { "title": "Board Member", "company": "Harbor Ventures", "confidence": "low" },
"note": "Only non-operating roles are active: pick the most recent, confidence low."
},
{
"name": "charity-org-excluded",
"experiences": [
{ "title": "Program Lead", "companyName": "City Harvest Charity", "startDate": "2023-01", "current": true },
{ "title": "Operations Manager", "companyName": "Northwind Labs", "startDate": "2020-04", "current": true }
],
"expected": { "title": "Operations Manager", "company": "Northwind Labs", "confidence": "high" },
"note": "Charity/volunteer orgs are excluded from primary selection by company name."
},
{
"name": "multiple-active-no-flags",
"experiences": [
{ "title": "Co-Founder", "companyName": "Quantum Forge", "startDate": "2021-01" },
{ "title": "Chief Product Officer", "companyName": "Orbit Metrics", "startDate": "2023-04" }
],
"expected": { "title": "Chief Product Officer", "company": "Orbit Metrics", "confidence": "medium" },
"note": "Two concurrent operating roles, neither flagged: pick latest start, medium."
},
{
"name": "multiple-active-latest-flagged",
"experiences": [
{ "title": "Co-Founder", "companyName": "Quantum Forge", "startDate": "2021-01", "current": true },
{ "title": "Chief Executive Officer", "companyName": "Orbit Metrics", "startDate": "2023-04", "current": true }
],
"expected": { "title": "Chief Executive Officer", "company": "Orbit Metrics", "confidence": "high" },
"note": "A clear latest start that also carries a current-flag is high confidence."
},
{
"name": "no-active-roles",
"experiences": [
{ "title": "Product Manager", "companyName": "Old Harbor Software", "startDate": "2015-01", "endDate": "2019-12" },
{ "title": "Senior Product Manager", "companyName": "BrightWave", "startDate": "2020-01", "endDate": "2023-08", "jobStillWorking": false }
],
"expected": { "title": "Senior Product Manager", "company": "BrightWave", "confidence": "low" },
"note": "Everything ended: pick the most recently ended role, low — re-verify downstream."
},
{
"name": "title-repair-at",
"experiences": [
{ "title": "VP Sales at Acme Corp", "companyName": "Acme Corp", "startDate": "2022-01", "current": true }
],
"expected": { "title": "VP Sales", "company": "Acme Corp", "confidence": "high" },
"note": "Trailing \" at <company>\" segment equals the company: stripped."
},
{
"name": "title-repair-at-symbol-fuzzy",
"experiences": [
{ "title": "Head of Marketing @ BrightWave", "companyName": "BrightWave Inc", "startDate": "2021-09", "current": true }
],
"expected": { "title": "Head of Marketing", "company": "BrightWave Inc", "confidence": "high" },
"note": "\"@ BrightWave\" fuzzy-equals \"BrightWave Inc\" once the legal suffix is ignored."
},
{
"name": "title-repair-dash-legal-suffix",
"experiences": [
{ "title": "CTO - Quantum Forge Inc", "companyName": "Quantum Forge", "startDate": "2020-02", "current": true }
],
"expected": { "title": "CTO", "company": "Quantum Forge", "confidence": "high" },
"note": "\" - <company> Inc\" trailing segment stripped despite the suffix mismatch."
},
{
"name": "title-repair-pipe",
"experiences": [
{ "title": "Director of Operations | Helios Ltd", "companyName": "Helios", "startDate": "2022-07", "current": true }
],
"expected": { "title": "Director of Operations", "company": "Helios", "confidence": "high" },
"note": "Pipe separator variant, Ltd suffix ignored during the match."
},
{
"name": "title-with-at-not-stripped",
"experiences": [
{ "title": "Head of Data at Scale", "companyName": "Meridian Analytics", "startDate": "2022-05", "current": true }
],
"expected": { "title": "Head of Data at Scale", "company": "Meridian Analytics", "confidence": "high" },
"note": "\"at Scale\" is part of the real title — trailing segment does not match the company, so no repair."
},
{
"name": "company-as-object",
"experiences": [
{ "position": "Staff Engineer", "company": { "name": "Orbit Metrics" }, "start_date": "2021-11", "isCurrent": true }
],
"expected": { "title": "Staff Engineer", "company": "Orbit Metrics", "confidence": "high" },
"note": "Company arrives as an object {name}; title arrives under \"position\"."
},
{
"name": "year-only-dates",
"experiences": [
{ "title": "Analyst", "companyName": "Old Harbor Software", "startDate": "2016", "endDate": "2019" },
{ "title": "Senior Analyst", "companyName": "Northwind Labs", "startDate": "2019" }
],
"expected": { "title": "Senior Analyst", "company": "Northwind Labs", "confidence": "high" },
"note": "Bare \"YYYY\" strings parse with month defaulting to January."
},
{
"name": "year-month-object-dates",
"experiences": [
{ "title": "Data Engineer", "companyName": "Northwind Labs", "startDate": { "year": 2022, "month": 3 } },
{ "title": "Senior Data Engineer", "companyName": "Northwind Labs", "startDate": { "year": 2022, "month": 7 } }
],
"expected": { "title": "Senior Data Engineer", "company": "Northwind Labs", "confidence": "medium" },
"note": "{year, month} objects compare correctly; two concurrent roles → medium."
},
{
"name": "iso-timestamp-dates",
"experiences": [
{ "title": "Platform Engineer", "companyName": "Old Harbor Software", "startDate": "2017-02-01T00:00:00.000Z", "endDate": "2021-03-31T00:00:00.000Z" },
{ "title": "Staff Platform Engineer", "companyName": "Vector Systems", "startDate": "2021-04-15T09:30:00Z" }
],
"expected": { "title": "Staff Platform Engineer", "company": "Vector Systems", "confidence": "high" },
"note": "Full ISO timestamps reduce to (year, month) tuples."
},
{
"name": "future-end-date",
"experiences": [
{ "title": "Interim CFO", "companyName": "Helios", "startDate": "2024-01", "endDate": "2099-12-31" },
{ "title": "Finance Director", "companyName": "Old Harbor Software", "startDate": "2018-03", "endDate": "2023-12" }
],
"expected": { "title": "Interim CFO", "company": "Helios", "confidence": "high" },
"note": "An endDate in the future counts as active (fixed-term contract)."
},
{
"name": "snake-case-and-jobTitle-variants",
"experiences": [
{ "jobTitle": "Growth Lead", "organization": "BrightWave", "dateFrom": "2020-08", "dateTo": "2022-01" },
{ "jobTitle": "Head of Growth", "organization": "Orbit Metrics", "start_date": "2022-02", "jobStillWorking": true }
],
"expected": { "title": "Head of Growth", "company": "Orbit Metrics", "confidence": "high" },
"note": "jobTitle/organization/dateFrom/dateTo/start_date field variants all resolve."
},
{
"name": "tie-start-prefers-current-flag",
"experiences": [
{ "title": "Principal Consultant", "companyName": "Helios", "startDate": "2022-01" },
{ "title": "Field CTO", "companyName": "Vector Systems", "startDate": "2022-01", "current": "1" }
],
"expected": { "title": "Field CTO", "company": "Vector Systems", "confidence": "medium" },
"note": "Same startDate: the role with a true current-flag wins the tie; still medium."
},
{
"name": "tie-start-no-flags-first-listed",
"experiences": [
{ "title": "Fractional CMO", "companyName": "BrightWave", "startDate": "2023-01" },
{ "title": "Fractional CMO", "companyName": "Orbit Metrics", "startDate": "2023-01" }
],
"expected": { "title": "Fractional CMO", "company": "BrightWave", "confidence": "medium" },
"note": "Same startDate, no flags: the role listed first wins (providers list newest first)."
},
{
"name": "unparseable-experiences",
"experiences": "{{not valid json",
"expected": { "title": "", "company": "", "confidence": "low" },
"note": "A cell that fails JSON.parse yields empty title/company, low, reason unparseable-experiences."
},
{
"name": "empty-experiences",
"experiences": [],
"expected": { "title": "", "company": "", "confidence": "low" },
"note": "An empty array yields empty title/company, low."
}
]
}
scripts/fixtures_email_validation.json
{
"cases": [
{
"email": "jane.doe@acme-widgets.example",
"expected": { "valid": true, "risk": "ok" },
"note": "plain business address on a reserved .example TLD"
},
{
"email": " JOHN.SMITH@Northwind-Traders.EXAMPLE ",
"expected": { "valid": true, "risk": "ok" },
"note": "uppercase + surrounding whitespace is normalized before checks"
},
{
"email": "jane+tag@globex-corp.example",
"expected": { "valid": true, "risk": "ok" },
"note": "plus-tagged local part is legal"
},
{
"email": "cto@a.b.co",
"expected": { "valid": true, "risk": "ok" },
"note": "subdomain domain with short labels and 2-char TLD"
},
{
"email": "vp.sales@emea.initech-labs.example",
"expected": { "valid": true, "risk": "ok" },
"note": "role words inside a longer local part are NOT a role account"
},
{
"email": "user@acme.test",
"expected": { "valid": true, "risk": "ok" },
"note": "reserved .test TLD is a fine synthetic business domain"
},
{
"email": "ops@intra-net.example",
"expected": { "valid": true, "risk": "ok" },
"note": "hyphen inside a domain label is legal"
},
{
"email": "jane..doe@acme-widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "consecutive dots in local part"
},
{
"email": ".jane@acme-widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "local part starts with a dot"
},
{
"email": "jane.@acme-widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "local part ends with a dot"
},
{
"email": "janedoe.acme-widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "missing @"
},
{
"email": "jane@doe@acme-widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "two @ signs"
},
{
"email": "@acme-widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "empty local part"
},
{
"email": "jane@acme.123",
"expected": { "valid": false, "risk": "invalid" },
"note": "numeric TLD"
},
{
"email": "jane@acme.x",
"expected": { "valid": false, "risk": "invalid" },
"note": "1-char TLD"
},
{
"email": "jane@localhost",
"expected": { "valid": false, "risk": "invalid" },
"note": "domain needs at least two labels"
},
{
"email": "bob@-badstart.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "domain label starts with a hyphen"
},
{
"email": "bob@badend-.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "domain label ends with a hyphen"
},
{
"email": "jane@acme_widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "underscore is not allowed in a domain label"
},
{
"email": "jane doe@acme-widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "embedded space"
},
{
"email": "jane,doe@acme-widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "embedded comma"
},
{
"email": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa@acme-widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "local part of 65 chars exceeds the 64-char limit"
},
{
"email": "user@example.com",
"expected": { "valid": false, "risk": "invalid" },
"note": "placeholder: exact domain example.com (rule matches example.com/org/net exactly, so acme-widgets.example stays valid)"
},
{
"email": "user@example.org",
"expected": { "valid": false, "risk": "invalid" },
"note": "placeholder: exact domain example.org"
},
{
"email": "test@test.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "placeholder: contains the test@test pattern"
},
{
"email": "noemail@acme-widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "placeholder: contains noemail"
},
{
"email": "no-reply@acme-widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "placeholder: no-reply@ is caught before the role check runs"
},
{
"email": "unknown@acme-widgets.example",
"expected": { "valid": false, "risk": "invalid" },
"note": "placeholder: contains unknown@"
},
{
"email": "n/a",
"expected": { "valid": false, "risk": "invalid" },
"note": "literal placeholder n/a"
},
{
"email": "null",
"expected": { "valid": false, "risk": "invalid" },
"note": "literal placeholder null"
},
{
"email": "NONE",
"expected": { "valid": false, "risk": "invalid" },
"note": "literal placeholder none, case-insensitive"
},
{
"email": "foo@mailinator.com",
"expected": { "valid": true, "risk": "disposable" },
"note": "disposable provider"
},
{
"email": "BAR@YOPMAIL.COM",
"expected": { "valid": true, "risk": "disposable" },
"note": "disposable provider matched after lowercasing"
},
{
"email": "baz@temp-mail.org",
"expected": { "valid": true, "risk": "disposable" },
"note": "disposable provider with hyphenated domain"
},
{
"email": "qux@guerrillamail.com",
"expected": { "valid": true, "risk": "disposable" },
"note": "disposable provider"
},
{
"email": "info@acme-widgets.example",
"expected": { "valid": true, "risk": "role" },
"note": "role account: info@"
},
{
"email": "sales@globex-corp.example",
"expected": { "valid": true, "risk": "role" },
"note": "role account: sales@"
},
{
"email": "hr@initech-labs.example",
"expected": { "valid": true, "risk": "role" },
"note": "role account: hr@"
},
{
"email": "noreply@acme-widgets.example",
"expected": { "valid": true, "risk": "role" },
"note": "noreply (no hyphen) misses the no-reply@ placeholder pattern and classifies as a role account"
},
{
"email": "jane.doe@gmail.com",
"expected": { "valid": true, "risk": "free" },
"note": "consumer free-mail: gmail"
},
{
"email": "john@yahoo.co.uk",
"expected": { "valid": true, "risk": "free" },
"note": "consumer free-mail: regional yahoo"
},
{
"email": "pierre@orange.fr",
"expected": { "valid": true, "risk": "free" },
"note": "consumer free-mail: orange.fr"
},
{
"email": "li@qq.com",
"expected": { "valid": true, "risk": "free" },
"note": "consumer free-mail: qq.com"
},
{
"email": "sam@protonmail.com",
"expected": { "valid": true, "risk": "free" },
"note": "consumer free-mail: protonmail"
}
]
}
scripts/fixtures_name_validation.json
{
"cases": [
{
"sourceName": "John Carter",
"profileName": "John Carter",
"expected": true,
"note": "exact match"
},
{
"sourceName": "MARIA LOPES",
"profileName": "maria lopes",
"expected": true,
"note": "case-insensitive"
},
{
"sourceName": "José Álvarez",
"profileName": "Jose Alvarez",
"expected": true,
"note": "accents normalized (NFD strip)"
},
{
"sourceName": "Renee Dupont",
"profileName": "Renée Dupont",
"expected": true,
"note": "accents normalized, profile side"
},
{
"sourceName": "Mike Turner",
"profileName": "Michael Turner",
"expected": true,
"note": "nickname source→formal"
},
{
"sourceName": "Michael Turner",
"profileName": "Mike Turner",
"expected": true,
"note": "nickname formal→source"
},
{
"sourceName": "Bob Whitfield",
"profileName": "Robert Whitfield",
"expected": true,
"note": "nickname Bob/Robert"
},
{
"sourceName": "Elizabeth Kraus",
"profileName": "Liz Kraus",
"expected": true,
"note": "nickname Liz/Elizabeth"
},
{
"sourceName": "Beth Nolan",
"profileName": "Elizabeth Nolan",
"expected": true,
"note": "nickname Beth/Elizabeth"
},
{
"sourceName": "Rick Osborne",
"profileName": "Richard Osborne",
"expected": true,
"note": "nickname Rick/Richard"
},
{
"sourceName": "Jim Falk",
"profileName": "James Falk",
"expected": true,
"note": "nickname Jim/James"
},
{
"sourceName": "Katie Merton",
"profileName": "Katherine Merton",
"expected": true,
"note": "nickname Katie/Katherine"
},
{
"sourceName": "Tony Marek",
"profileName": "Anthony Marek",
"expected": true,
"note": "nickname Tony/Anthony"
},
{
"sourceName": "Chris Devlin",
"profileName": "Christopher Devlin",
"expected": true,
"note": "nickname Chris/Christopher"
},
{
"sourceName": "Chris Halloran",
"profileName": "Christine Halloran",
"expected": true,
"note": "nickname Chris/Christine (same nickname, second formal)"
},
{
"sourceName": "Dave Pemberton",
"profileName": "David Pemberton",
"expected": true,
"note": "nickname Dave/David"
},
{
"sourceName": "Ted Grayson",
"profileName": "Edward Grayson",
"expected": true,
"note": "nickname Ted/Edward"
},
{
"sourceName": "Alex Riven",
"profileName": "Alexandra Riven",
"expected": true,
"note": "nickname Alex/Alexandra"
},
{
"sourceName": "Chuck Ambrose",
"profileName": "Charles Ambrose",
"expected": true,
"note": "nickname Chuck/Charles"
},
{
"sourceName": "Peggy Lindqvist",
"profileName": "Margaret Lindqvist",
"expected": true,
"note": "nickname Peggy/Margaret"
},
{
"sourceName": "Jack Tremaine",
"profileName": "John Tremaine",
"expected": true,
"note": "nickname Jack/John"
},
{
"sourceName": "Sam Ellery",
"profileName": "Samantha Ellery",
"expected": true,
"note": "nickname Sam/Samantha"
},
{
"sourceName": "Steve Calloway",
"profileName": "Stephen Calloway",
"expected": true,
"note": "nickname Steve/Stephen"
},
{
"sourceName": "Zack Templeton",
"profileName": "Zachary Templeton",
"expected": true,
"note": "nickname Zack/Zachary"
},
{
"sourceName": "Meg Ostrander",
"profileName": "Margaret Ostrander",
"expected": true,
"note": "nickname Meg/Margaret"
},
{
"sourceName": "J. Rutherford",
"profileName": "James Rutherford",
"expected": true,
"note": "source first name is an initial"
},
{
"sourceName": "Nathaniel Brooks",
"profileName": "N. Brooks",
"expected": true,
"note": "profile first name is an initial"
},
{
"sourceName": "Ana García",
"profileName": "Ana García Fernández",
"expected": true,
"note": "maiden/married double surname on profile"
},
{
"sourceName": "Laura Jensen",
"profileName": "Laura Jensen Holt",
"expected": true,
"note": "married surname appended on profile"
},
{
"sourceName": "Claire Beaumont",
"profileName": "Claire Beaumont-Ridley",
"expected": true,
"note": "hyphenated profile surname contains source surname"
},
{
"sourceName": "Priya Raghavan-Iyer",
"profileName": "Priya Raghavan",
"expected": true,
"note": "hyphenated source surname, profile has one half"
},
{
"sourceName": "Bob Callahan",
"profileName": "Robert \"Bob\" Callahan",
"expected": true,
"note": "quoted nickname in profile"
},
{
"sourceName": "Kate Winslow",
"profileName": "Katherine (Kate) Winslow",
"expected": true,
"note": "parenthesized nickname in profile"
},
{
"sourceName": "Sunny Deshpande",
"profileName": "Suniti (Sunny) Deshpande",
"expected": true,
"note": "parenthesized nickname not in the nickname table"
},
{
"sourceName": "Daniel Okafor",
"profileName": "Daniel Okafor, PhD",
"expected": true,
"note": "credentials after comma stripped"
},
{
"sourceName": "Marcus Bell Jr",
"profileName": "Marcus Bell",
"expected": true,
"note": "generational suffix stripped from source"
},
{
"sourceName": "Dr Helena Voss",
"profileName": "Helena Voss",
"expected": true,
"note": "title stripped from source"
},
{
"sourceName": "Tara Whitcombe",
"profileName": "Tara Whitcombe 🚀",
"expected": true,
"note": "emoji stripped from profile"
},
{
"sourceName": "Sofia Marchetti",
"profileName": "Sofia Elena Marchetti",
"expected": true,
"note": "middle name on profile ignored"
},
{
"sourceName": "Omar Haddad",
"profileName": "Omar K. Haddad",
"expected": true,
"note": "middle initial on profile ignored"
},
{
"sourceName": "Peter James Quill",
"profileName": "Peter Quill",
"expected": true,
"note": "middle name on source ignored"
},
{
"sourceName": "田中 太郎",
"profileName": "田中 太郎",
"expected": true,
"note": "non-Latin (CJK) exact match"
},
{
"sourceName": "Иван Смирнов",
"profileName": "Иван Смирнов",
"expected": true,
"note": "non-Latin (Cyrillic) exact match"
},
{
"sourceName": "Jen Falco",
"profileName": "Jennifer Falco Marsh",
"expected": true,
"note": "nickname plus married surname combined"
},
{
"sourceName": "WILLIAM HART",
"profileName": "Will Hart, MBA",
"expected": true,
"note": "case + nickname + credential suffix combined"
},
{
"sourceName": "John Carter",
"profileName": "John Mercer",
"expected": false,
"note": "same first name, different surname — decoy"
},
{
"sourceName": "Alice Fontaine",
"profileName": "Marie Fontaine",
"expected": false,
"note": "same surname, different first name"
},
{
"sourceName": "David Kowalski",
"profileName": "",
"expected": false,
"note": "empty profile name"
},
{
"sourceName": "Sarah Linden",
"profileName": "Tobias Grunwald",
"expected": false,
"note": "completely different names"
},
{
"sourceName": "Mike Turner",
"profileName": "Nick Turner",
"expected": false,
"note": "different nicknames of different formal names"
},
{
"sourceName": "Robert Callahan",
"profileName": "Robert",
"expected": false,
"note": "single-token profile cannot confirm surname"
},
{
"sourceName": "Иван Смирнов",
"profileName": "Пётр Волков",
"expected": false,
"note": "non-Latin (Cyrillic) mismatch"
},
{
"sourceName": "李 伟",
"profileName": "王 芳",
"expected": false,
"note": "non-Latin (CJK) mismatch"
},
{
"sourceName": "Jonathan Pierce",
"profileName": "John Pierson",
"expected": false,
"note": "near-miss decoy on both name parts"
},
{
"sourceName": "Emily Watkins",
"profileName": "Emma Watkins",
"expected": false,
"note": "similar first names are not nicknames"
},
{
"sourceName": "T. Vasquez",
"profileName": "Ramon Vasquez",
"expected": false,
"note": "initial does not match first letter"
},
{
"sourceName": "Karen Doyle",
"profileName": "LinkedIn Member",
"expected": false,
"note": "scraper placeholder garbage"
},
{
"sourceName": "Hannah Brightwater",
"profileName": "H",
"expected": false,
"note": "single-letter profile name"
},
{
"sourceName": "Noah Feldstein",
"profileName": " ",
"expected": false,
"note": "whitespace-only profile name"
},
{
"sourceName": "Greg Santos",
"profileName": "Theodore \"Teddy\" Santos",
"expected": false,
"note": "quoted nickname does not rescue a first-name mismatch"
},
{
"sourceName": "Kate Holm",
"profileName": "Katie Holm",
"expected": true,
"note": "two nicknames sharing a formal name (Kate/Katie via Katherine)"
},
{
"sourceName": "Rick Sanders",
"profileName": "Dick Sanders",
"expected": true,
"note": "shared formal via Richard"
}
]
}
scripts/lib/common.ts
// Shared helpers for the cargo-gtm QA scripts.
//
// Runtime contract: Node >= 22.18 runs these files directly (`node <script>.ts`,
// native type-stripping). Use erasable TypeScript syntax only — no enums,
// namespaces, or parameter properties. Core helpers are dependency-free;
// `@cargo-ai/api` is loaded lazily and only for API mode.
import { readFileSync, existsSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { createRequire } from "node:module";
export type Row = Record<string, string>;
export function fail(message: string): never {
process.stderr.write(`Error: ${message}\n`);
process.exit(1);
}
// ---------------------------------------------------------------------------
// CLI arguments
// ---------------------------------------------------------------------------
export type ArgSpec = {
/** Flags that take a value, e.g. ["input", "output", "workflow-uuid"]. */
value?: string[];
/** Boolean flags, e.g. ["fixtures", "json"]. */
boolean?: string[];
};
export type Args = { values: Map<string, string>; flags: Set<string> };
export function parseArgs(argv: string[], spec: ArgSpec): Args {
const values = new Map<string, string>();
const flags = new Set<string>();
for (let i = 0; i < argv.length; i++) {
const raw = argv[i];
if (!raw.startsWith("--")) fail(`unexpected argument: ${raw}`);
const name = raw.slice(2);
if (spec.boolean?.includes(name)) {
flags.add(name);
} else if (spec.value?.includes(name)) {
const value = argv[++i];
if (value === undefined || value.startsWith("--")) {
fail(`flag --${name} requires a value`);
}
values.set(name, value);
} else {
fail(`unknown flag: --${name}`);
}
}
return { values, flags };
}
// ---------------------------------------------------------------------------
// CSV (RFC 4180: quoted fields, embedded commas/quotes/newlines, CRLF)
// ---------------------------------------------------------------------------
export function parseCsv(text: string): Row[] {
const rows: string[][] = [];
let field = "";
let record: string[] = [];
let inQuotes = false;
const pushField = () => {
record.push(field);
field = "";
};
const pushRecord = () => {
// Skip blank lines (a record that is a single empty field).
if (record.length > 1 || record[0] !== "") rows.push(record);
record = [];
};
for (let i = 0; i < text.length; i++) {
const ch = text[i];
if (inQuotes) {
if (ch === '"') {
if (text[i + 1] === '"') {
field += '"';
i++;
} else {
inQuotes = false;
}
} else {
field += ch;
}
} else if (ch === '"') {
inQuotes = true;
} else if (ch === ",") {
pushField();
} else if (ch === "\n" || ch === "\r") {
if (ch === "\r" && text[i + 1] === "\n") i++;
pushField();
pushRecord();
} else {
field += ch;
}
}
if (field !== "" || record.length > 0) {
pushField();
pushRecord();
}
if (rows.length === 0) return [];
const header = rows[0];
return rows.slice(1).map((cells) => {
const row: Row = {};
header.forEach((name, i) => {
row[name] = cells[i] ?? "";
});
return row;
});
}
function csvEscape(value: string): string {
return /[",\n\r]/.test(value) ? `"${value.replaceAll('"', '""')}"` : value;
}
export function toCsv(rows: Row[], headers?: string[]): string {
const cols = headers ?? [...new Set(rows.flatMap((r) => Object.keys(r)))];
const lines = [cols.map(csvEscape).join(",")];
for (const row of rows) {
lines.push(cols.map((c) => csvEscape(row[c] ?? "")).join(","));
}
return lines.join("\n") + "\n";
}
// ---------------------------------------------------------------------------
// Input loading: --input file (.csv / .json) or Cargo API mode
// ---------------------------------------------------------------------------
export function readJson<T>(path: string): T {
return JSON.parse(readFileSync(path, "utf8")) as T;
}
function rowsFromJson(data: unknown): Row[] {
// Accept `orchestration action execute-batch` output directly: rows live
// under a top-level "results" (or "records") key rather than at the root.
if (data !== null && typeof data === "object" && !Array.isArray(data)) {
const wrapped = data as { results?: unknown; records?: unknown };
if (Array.isArray(wrapped.results)) data = wrapped.results;
else if (Array.isArray(wrapped.records)) data = wrapped.records;
}
if (!Array.isArray(data)) fail("expected a JSON array of objects (or {\"results\": [...]} batch output)");
return data.map((item) => {
const row: Row = {};
for (const [key, value] of Object.entries(item as Record<string, unknown>)) {
row[key] =
value === null || value === undefined
? ""
: typeof value === "string"
? value
: JSON.stringify(value);
}
return row;
});
}
export function readInputFile(path: string): Row[] {
if (!existsSync(path)) fail(`input file not found: ${path}`);
const text = readFileSync(path, "utf8");
return path.endsWith(".json") ? rowsFromJson(JSON.parse(text)) : parseCsv(text);
}
function resolveAccessToken(): string {
const fromEnv = process.env.CARGO_API_TOKEN;
if (fromEnv) return fromEnv;
const credentialsPath = join(homedir(), ".config", "cargo-ai", "credentials.json");
if (existsSync(credentialsPath)) {
const credentials = readJson<{ accessToken?: string }>(credentialsPath);
if (credentials.accessToken) return credentials.accessToken;
}
return fail(
"no Cargo credentials found — set CARGO_API_TOKEN or run `cargo-ai login`",
);
}
async function loadCargoApi(): Promise<{ buildApi: (deps: object) => any }> {
try {
return (await import("@cargo-ai/api")) as any;
} catch {
// Not resolvable from here — try the global npm root (the CLI itself is
// installed globally, so this is the common case for a global install).
try {
// execFileSync, not execSync: the argument vector goes to npm directly
// instead of through a shell, so there is no command string to interpret.
// Windows needs the .cmd shim by name, since there is no shell to find it.
const npm = process.platform === "win32" ? "npm.cmd" : "npm";
const globalRoot = execFileSync(npm, ["root", "-g"], {
encoding: "utf8",
}).trim();
const requireFromGlobal = createRequire(join(globalRoot, "noop.js"));
const entry = requireFromGlobal.resolve("@cargo-ai/api");
return (await import(entry)) as any;
} catch {
return fail(
"API mode needs the @cargo-ai/api package — `npm install -g @cargo-ai/api`, " +
"or pass --input <file.csv|file.json> instead",
);
}
}
}
export type ApiModeOptions = {
workflowUuid: string;
batchUuid?: string;
outputNodeSlug?: string;
workspaceUuid?: string;
};
/**
* Fetch a workflow's output rows via the Cargo API — the programmatic
* equivalent of `cargo-ai orchestration run download-outputs`.
*/
export async function fetchRunOutputRows(options: ApiModeOptions): Promise<Row[]> {
const { buildApi } = await loadCargoApi();
const api = buildApi({
accessToken: resolveAccessToken(),
workspaceUuid: options.workspaceUuid ?? process.env.CARGO_WORKSPACE_UUID,
});
const { url } = await api.orchestration.run.downloadOutputs({
workflowUuid: options.workflowUuid,
batchUuid: options.batchUuid,
outputNodeSlug: options.outputNodeSlug,
format: "json",
});
const response = await fetch(url);
if (!response.ok) fail(`failed to download outputs (${response.status})`);
return rowsFromJson(await response.json());
}
/**
* Standard input resolution shared by every QA script:
* --input <file> CSV or JSON rows
* --workflow-uuid <uuid> API mode (+ optional --batch-uuid,
* --output-node-slug, --workspace-uuid)
*/
export async function readRows(args: Args): Promise<Row[]> {
const input = args.values.get("input");
if (input) return readInputFile(input);
const workflowUuid = args.values.get("workflow-uuid");
if (workflowUuid) {
return fetchRunOutputRows({
workflowUuid,
batchUuid: args.values.get("batch-uuid"),
outputNodeSlug: args.values.get("output-node-slug"),
workspaceUuid: args.values.get("workspace-uuid"),
});
}
return fail("pass --input <file.csv|file.json> or --workflow-uuid <uuid>");
}
// ---------------------------------------------------------------------------
// Fixture metrics
// ---------------------------------------------------------------------------
export type Metrics = { precision: number; recall: number; f1: number };
export function metrics(tp: number, fp: number, fn: number): Metrics {
const precision = tp + fp === 0 ? 1 : tp / (tp + fp);
const recall = tp + fn === 0 ? 1 : tp / (tp + fn);
const f1 =
precision + recall === 0 ? 0 : (2 * precision * recall) / (precision + recall);
return { precision, recall, f1 };
}
export type Thresholds = { precision?: number; recall?: number };
/**
* Print fixture metrics and exit non-zero when below thresholds. Call at the
* end of every script's --fixtures mode: CI relies on the exit code.
*/
export function reportFixtureRun(
scriptName: string,
results: { total: number; failures: string[] },
measured?: { metrics: Metrics; thresholds: Thresholds },
): void {
const lines = [`${scriptName}: ${results.total} fixture cases`];
if (measured) {
const { precision, recall, f1 } = measured.metrics;
lines.push(
`precision=${precision.toFixed(3)} recall=${recall.toFixed(3)} f1=${f1.toFixed(3)}`,
);
}
for (const failure of results.failures) lines.push(` FAIL ${failure}`);
process.stdout.write(lines.join("\n") + "\n");
let ok = results.failures.length === 0;
if (measured) {
const { precision = 0, recall = 0 } = measured.thresholds;
if (measured.metrics.precision < precision) {
process.stdout.write(`FAIL precision ${measured.metrics.precision.toFixed(3)} < required ${precision}\n`);
ok = false;
}
if (measured.metrics.recall < recall) {
process.stdout.write(`FAIL recall ${measured.metrics.recall.toFixed(3)} < required ${recall}\n`);
ok = false;
}
}
process.stdout.write(ok ? "PASS\n" : "FAIL\n");
process.exit(ok ? 0 : 1);
}
scripts/select-current-role.ts
// select-current-role.ts — deterministic current-role selection over the
// experience arrays returned by enrichment providers (LinkedIn scrapes,
// waterfall enrichment, peopleDataLabs).
//
// Picking the CURRENT role wrong is the #1 cause of emailing people who
// already left. This script selects one role per row, repairs titles that
// embed the company name ("VP Sales at Acme Corp"), and emits a confidence
// plus a machine-readable reason so downstream steps know when to re-verify.
//
// Usage:
// node select-current-role.ts --input rows.csv [--experiences-column experiences] [--output out.csv]
// node select-current-role.ts --workflow-uuid <uuid> [--batch-uuid <uuid>] [--output-node-slug <slug>]
// node select-current-role.ts --fixtures
//
// Runtime contract: Node >= 22.18 runs this file directly (native
// type-stripping) — erasable TypeScript only, node:* builtins only.
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import {
parseArgs,
readRows,
readJson,
toCsv,
reportFixtureRun,
fail,
type Row,
} from "./lib/common.ts";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type Confidence = "high" | "medium" | "low";
export type RoleSelection = {
title: string;
company: string;
confidence: Confidence;
reason: string;
};
type YearMonth = { year: number; month: number };
type ParsedExperience = {
index: number;
title: string;
company: string;
start: YearMonth | undefined;
end: YearMonth | undefined;
/** Tri-state: true / false / not present (or not boolean-ish). */
currentFlag: boolean | undefined;
};
// ---------------------------------------------------------------------------
// Tolerant field parsing
// ---------------------------------------------------------------------------
const TITLE_KEYS = ["title", "jobTitle", "position"];
const COMPANY_KEYS = ["companyName", "company", "organization"];
const START_KEYS = ["startDate", "start_date", "dateFrom"];
const END_KEYS = ["endDate", "end_date", "dateTo"];
const CURRENT_KEYS = ["current", "isCurrent", "jobStillWorking"];
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
/**
* Boolean-ish fields arrive as real booleans OR strings ("false", "False",
* "0", "", "true", "1", …). Never truthiness-test them — the string "false"
* is truthy, which is exactly the classic current-role bug.
*/
function parseBoolish(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (typeof value === "number") return value !== 0;
if (typeof value === "string") {
const s = value.trim().toLowerCase();
if (s === "" || s === "false" || s === "0" || s === "no") return false;
if (s === "true" || s === "1" || s === "yes") return true;
}
return undefined;
}
/**
* Dates arrive as "YYYY", "YYYY-MM", "YYYY-MM-DD", ISO timestamps, bare year
* numbers, or objects {year, month}. Reduce them all to a comparable
* (year, month) tuple; missing month = January.
*/
function parseYearMonth(value: unknown): YearMonth | undefined {
if (value === null || value === undefined) return undefined;
if (isObject(value)) {
const year = Number(value.year);
if (!Number.isInteger(year) || year <= 0) return undefined;
const month = Number(value.month);
return { year, month: Number.isInteger(month) && month >= 1 && month <= 12 ? month : 1 };
}
if (typeof value === "number") {
return Number.isInteger(value) && value >= 1000 && value <= 9999
? { year: value, month: 1 }
: undefined;
}
if (typeof value === "string") {
const match = value.trim().match(/^(\d{4})(?:-(\d{1,2}))?/);
if (!match) return undefined;
const month = match[2] ? Number(match[2]) : 1;
return { year: Number(match[1]), month: month >= 1 && month <= 12 ? month : 1 };
}
return undefined;
}
function compareYearMonth(a: YearMonth, b: YearMonth): number {
return a.year - b.year || a.month - b.month;
}
/** Compare optional tuples; a missing date sorts before any real date. */
function compareOptional(a: YearMonth | undefined, b: YearMonth | undefined): number {
if (a === undefined && b === undefined) return 0;
if (a === undefined) return -1;
if (b === undefined) return 1;
return compareYearMonth(a, b);
}
function extractString(exp: Record<string, unknown>, keys: string[]): string {
for (const key of keys) {
const value = exp[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return "";
}
/** Company fields are strings or objects like {"name": "Acme"}. */
function extractCompany(exp: Record<string, unknown>): string {
for (const key of COMPANY_KEYS) {
const value = exp[key];
if (typeof value === "string" && value.trim()) return value.trim();
if (isObject(value) && typeof value.name === "string" && value.name.trim()) {
return value.name.trim();
}
}
return "";
}
function extractWhen(exp: Record<string, unknown>, keys: string[]): YearMonth | undefined {
for (const key of keys) {
const parsed = parseYearMonth(exp[key]);
if (parsed) return parsed;
}
return undefined;
}
function extractCurrentFlag(exp: Record<string, unknown>): boolean | undefined {
for (const key of CURRENT_KEYS) {
const parsed = parseBoolish(exp[key]);
if (parsed !== undefined) return parsed;
}
return undefined;
}
function parseExperience(exp: Record<string, unknown>, index: number): ParsedExperience {
return {
index,
title: extractString(exp, TITLE_KEYS),
company: extractCompany(exp),
start: extractWhen(exp, START_KEYS),
end: extractWhen(exp, END_KEYS),
currentFlag: extractCurrentFlag(exp),
};
}
// ---------------------------------------------------------------------------
// Selection logic
// ---------------------------------------------------------------------------
const NON_OPERATING_TITLE =
/\b(board member|board of directors|advisor|advisory|investor|angel|venture partner|volunteer|mentor|trustee|non[- ]executive)\b/i;
const NON_OPERATING_COMPANY = /\b(charity|charitable|volunteer(?:s|ing)?)\b/i;
function isNonOperating(exp: ParsedExperience): boolean {
return NON_OPERATING_TITLE.test(exp.title) || NON_OPERATING_COMPANY.test(exp.company);
}
/**
* A role is active when its current-flag is true, its end date is missing, or
* its end date is now-or-later. An explicit false flag with a missing end
* date means "left, end date unknown" — not active.
*/
function isActive(exp: ParsedExperience, now: YearMonth): boolean {
if (exp.currentFlag === true) return true;
if (exp.end === undefined) return exp.currentFlag !== false;
return compareYearMonth(exp.end, now) >= 0;
}
/** Latest startDate; tie → prefer current-flag true, then the one listed first. */
function pickLatestByStart(candidates: ParsedExperience[]): ParsedExperience {
let best = candidates[0];
for (const exp of candidates.slice(1)) {
const cmp = compareOptional(exp.start, best.start);
if (cmp > 0) best = exp;
else if (cmp === 0 && exp.currentFlag === true && best.currentFlag !== true) best = exp;
}
return best;
}
// ---------------------------------------------------------------------------
// Title repair — scraped titles often embed the company name
// ---------------------------------------------------------------------------
const TITLE_SEPARATOR = /\s+(?:at|@|-|\|)\s+/gi;
const LEGAL_SUFFIX = /\b(?:inc|llc|ltd|corp|gmbh)$/;
function normalizeCompanyName(name: string): string {
let s = name
.toLowerCase()
.replace(/[.,'’]/g, "")
.replace(/\s+/g, " ")
.trim();
let previous;
do {
previous = s;
s = s.replace(LEGAL_SUFFIX, "").trim();
} while (s !== previous);
return s;
}
/**
* Strip a trailing "at Acme Corp" / "@ Acme" / "- Acme" / "| Acme" segment
* when it equals the company name (ignoring case and Inc/LLC/Ltd/Corp/GmbH).
* Titles whose trailing segment does NOT match the company are left alone
* ("Head of Data at Scale" at Meridian Analytics stays intact).
*/
function repairTitle(title: string, company: string): { title: string; repaired: boolean } {
const target = normalizeCompanyName(company);
if (!title || !target) return { title, repaired: false };
const matches = [...title.matchAll(TITLE_SEPARATOR)];
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const trailing = title.slice(match.index + match[0].length);
if (normalizeCompanyName(trailing) === target) {
const head = title.slice(0, match.index).trim();
if (head) return { title: head, repaired: true };
}
}
return { title, repaired: false };
}
// ---------------------------------------------------------------------------
// selectCurrentRole — the pure function under fixture test
// ---------------------------------------------------------------------------
export function selectCurrentRole(experiences: unknown): RoleSelection {
let list = experiences;
if (typeof list === "string") {
try {
list = JSON.parse(list);
} catch {
list = undefined;
}
}
if (!Array.isArray(list)) {
return { title: "", company: "", confidence: "low", reason: "unparseable-experiences" };
}
const parsed = list.filter(isObject).map(parseExperience);
if (parsed.length === 0) {
return { title: "", company: "", confidence: "low", reason: "no-experiences" };
}
const now = new Date();
const nowTuple: YearMonth = { year: now.getFullYear(), month: now.getMonth() + 1 };
const active = parsed.filter((exp) => isActive(exp, nowTuple));
const operating = active.filter((exp) => !isNonOperating(exp));
let picked: ParsedExperience;
let confidence: Confidence;
let reason: string;
if (operating.length > 0) {
picked = pickLatestByStart(operating);
const clearLatest = operating.every(
(exp) => exp === picked || compareOptional(exp.start, picked.start) < 0,
);
if (operating.length === 1) {
confidence = "high";
reason = "single-active-role";
} else if (clearLatest && picked.currentFlag === true) {
confidence = "high";
reason = "latest-active-current-flag";
} else {
confidence = "medium";
reason = "multiple-active-roles";
}
} else if (active.length > 0) {
// Only board/advisory/volunteer roles are active — usable, but weak.
picked = pickLatestByStart(active);
confidence = "low";
reason = "only-non-operating-roles";
} else {
// Everything ended: this person likely changed jobs — re-verify downstream.
picked = parsed[0];
for (const exp of parsed.slice(1)) {
if (compareOptional(exp.end ?? exp.start, picked.end ?? picked.start) > 0) picked = exp;
}
confidence = "low";
reason = "no-active-role";
}
const { title, repaired } = repairTitle(picked.title, picked.company);
if (repaired) reason += "; title-repaired";
return { title, company: picked.company, confidence, reason };
}
// ---------------------------------------------------------------------------
// Fixture mode — every case must match expected exactly
// ---------------------------------------------------------------------------
type FixtureCase = {
name: string;
experiences: unknown;
expected: { title: string; company: string; confidence: Confidence };
note?: string;
};
function runFixtures(): void {
const path = join(import.meta.dirname, "fixtures_current_role.json");
const { cases } = readJson<{ cases: FixtureCase[] }>(path);
const failures: string[] = [];
for (const testCase of cases) {
const got = selectCurrentRole(testCase.experiences);
const mismatches: string[] = [];
if (got.title !== testCase.expected.title) {
mismatches.push(`title "${got.title}" != "${testCase.expected.title}"`);
}
if (got.company !== testCase.expected.company) {
mismatches.push(`company "${got.company}" != "${testCase.expected.company}"`);
}
if (got.confidence !== testCase.expected.confidence) {
mismatches.push(`confidence "${got.confidence}" != "${testCase.expected.confidence}"`);
}
if (mismatches.length > 0) {
failures.push(`${testCase.name}: ${mismatches.join("; ")} (reason=${got.reason})`);
}
}
reportFixtureRun("select-current-role", { total: cases.length, failures });
}
// ---------------------------------------------------------------------------
// Row mode
// ---------------------------------------------------------------------------
const COLUMN_CANDIDATES = ["experiences", "experience", "workExperience", "positions", "jobs"];
function resolveExperiencesColumn(rows: Row[], override: string | undefined): string {
const keys = Object.keys(rows[0] ?? {});
if (override) {
if (keys.includes(override)) return override;
return fail(
`--experiences-column "${override}" not found; available columns: ${keys.join(", ")}`,
);
}
const byLowercase = new Map(keys.map((key) => [key.toLowerCase(), key]));
for (const candidate of COLUMN_CANDIDATES) {
const match = byLowercase.get(candidate.toLowerCase());
if (match) return match;
}
return fail(
`no experiences column found (tried ${COLUMN_CANDIDATES.join(", ")}); ` +
"pass --experiences-column <name>",
);
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2), {
value: [
"input",
"output",
"experiences-column",
"workflow-uuid",
"batch-uuid",
"output-node-slug",
"workspace-uuid",
],
boolean: ["fixtures"],
});
if (args.flags.has("fixtures")) {
runFixtures();
return;
}
const rows = await readRows(args);
if (rows.length === 0) fail("no input rows");
const column = resolveExperiencesColumn(rows, args.values.get("experiences-column"));
const enriched = rows.map((row) => {
const selection = selectCurrentRole(row[column]);
return {
...row,
current_title: selection.title,
current_company: selection.company,
role_confidence: selection.confidence,
role_reason: selection.reason,
};
});
const csv = toCsv(enriched);
const output = args.values.get("output");
if (output) {
writeFileSync(output, csv);
process.stderr.write(`wrote ${enriched.length} rows to ${output}\n`);
} else {
process.stdout.write(csv);
}
}
await main();
scripts/validate-emails.ts
// validate-emails.ts — free deterministic email pre-filter for cargo-gtm.
//
// Paid verification (waterfall verifyEmail) costs credits per address. This
// script culls obvious junk first — malformed addresses, placeholders,
// disposable domains — and flags role accounts and consumer free-mail so the
// paid step only runs on addresses worth verifying.
//
// Usage:
// node validate-emails.ts --input <rows.csv|rows.json> [--email-column email]
// [--output <file.csv>]
// node validate-emails.ts --workflow-uuid <uuid> [--batch-uuid <uuid>]
// [--output-node-slug <slug>] [--workspace-uuid <uuid>]
// node validate-emails.ts --fixtures
//
// Output: input rows + `valid`, `risk`, `reason`, `recommendation`,
// `is_duplicate` columns as CSV (stdout or --output). A one-line summary goes
// to stderr, including how many paid verifications were saved
// (invalid + disposable rows).
//
// Runtime contract: Node >= 22.18, run directly (`node validate-emails.ts`,
// native type-stripping). Erasable TypeScript only; zero npm dependencies.
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import {
parseArgs,
readRows,
toCsv,
readJson,
reportFixtureRun,
fail,
type Row,
} from "./lib/common.ts";
// ---------------------------------------------------------------------------
// Classification
// ---------------------------------------------------------------------------
export type Risk = "ok" | "free" | "role" | "disposable" | "invalid";
export type Classification = {
valid: boolean;
risk: Risk;
reason: string;
};
// Throwaway inbox providers. Addresses here bounce or rot within minutes —
// never worth a paid verification credit.
const DISPOSABLE_DOMAINS = new Set([
"mailinator.com",
"guerrillamail.com",
"10minutemail.com",
"tempmail.com",
"temp-mail.org",
"yopmail.com",
"sharklasers.com",
"throwawaymail.com",
"getnada.com",
"maildrop.cc",
"dispostable.com",
"fakeinbox.com",
"trashmail.com",
"mytemp.email",
"mohmal.com",
"emailondeck.com",
"spamgourmet.com",
"mailnesia.com",
"tempinbox.com",
"mintemail.com",
"mailcatch.com",
"inboxkitten.com",
"33mail.com",
"burnermail.io",
"anonaddy.me",
"spam4.me",
"grr.la",
"pokemail.net",
"tmpmail.org",
"moakt.com",
]);
// Role accounts (exact local-part match) — shared inboxes, poor outreach
// targets; verify only after human review.
const ROLE_LOCALS = new Set([
"info",
"sales",
"support",
"admin",
"contact",
"hello",
"team",
"office",
"hr",
"jobs",
"careers",
"marketing",
"billing",
"finance",
"legal",
"help",
"service",
"enquiries",
"inquiries",
"press",
"media",
"webmaster",
"postmaster",
"abuse",
"noc",
"security",
"no-reply",
"noreply",
"newsletter",
]);
// Consumer free-mail providers. Fine for SMB outreach, but flagged — for B2B a
// contact at the company domain is preferred.
const FREE_DOMAINS = new Set([
"gmail.com",
"googlemail.com",
"yahoo.com",
"yahoo.co.uk",
"yahoo.fr",
"yahoo.de",
"yahoo.es",
"yahoo.it",
"yahoo.ca",
"yahoo.co.jp",
"yahoo.co.in",
"yahoo.com.au",
"yahoo.com.br",
"hotmail.com",
"hotmail.co.uk",
"outlook.com",
"live.com",
"msn.com",
"aol.com",
"icloud.com",
"me.com",
"mac.com",
"protonmail.com",
"proton.me",
"gmx.com",
"gmx.de",
"gmx.net",
"mail.com",
"zoho.com",
"yandex.com",
"yandex.ru",
"web.de",
"orange.fr",
"wanadoo.fr",
"free.fr",
"t-online.de",
"comcast.net",
"verizon.net",
"att.net",
"qq.com",
"163.com",
"126.com",
]);
// Placeholder detection. Literal junk values first, then substring patterns
// CRMs commonly hold instead of a real address. Note the ordering consequence:
// "no-reply@…" is caught here as a placeholder (before the role check ever
// runs), while "noreply@…" (no hyphen) passes syntax and classifies as a role
// account. Domain placeholders are matched on the EXACT domains example.com /
// example.org / example.net — invented business domains under the reserved
// .example and .test TLDs (e.g. acme-widgets.example) stay valid.
const PLACEHOLDER_LITERALS = new Set(["n/a", "null", "none"]);
const PLACEHOLDER_SUBSTRINGS = ["test@test", "noemail", "no-reply@", "unknown@"];
const PLACEHOLDER_DOMAINS = new Set(["example.com", "example.org", "example.net"]);
const LOCAL_CHARSET = /^[a-z0-9._%+-]+$/;
const DOMAIN_LABEL = /^[a-z0-9-]+$/;
const TLD = /^[a-z]{2,}$/;
function invalid(reason: string): Classification {
return { valid: false, risk: "invalid", reason };
}
/** Normalize (trim + lowercase) an email for comparison and dedupe. */
export function normalizeEmail(email: string): string {
return email.trim().toLowerCase();
}
/**
* Pure, deterministic email classification — no network, no credits.
* `valid` means syntactically plausible (risk !== "invalid"); `risk` grades
* how worthwhile a paid verification would be.
*/
export function classifyEmail(email: string): Classification {
const normalized = normalizeEmail(email);
if (normalized === "") return invalid("empty value");
if (PLACEHOLDER_LITERALS.has(normalized)) {
return invalid(`placeholder value "${normalized}"`);
}
for (const pattern of PLACEHOLDER_SUBSTRINGS) {
if (normalized.includes(pattern)) {
return invalid(`placeholder pattern "${pattern}"`);
}
}
if (/[\s,]/.test(normalized)) return invalid("contains spaces or commas");
if (normalized.length > 254) return invalid("longer than 254 characters");
const atCount = normalized.split("@").length - 1;
if (atCount === 0) return invalid("missing @");
if (atCount > 1) return invalid("more than one @");
const [local, domain] = normalized.split("@");
if (local.length === 0) return invalid("empty local part");
if (local.length > 64) return invalid("local part longer than 64 characters");
if (!LOCAL_CHARSET.test(local)) {
return invalid("local part has characters outside [a-z0-9._%+-]");
}
if (local.startsWith(".") || local.endsWith(".")) {
return invalid("local part starts or ends with a dot");
}
if (local.includes("..")) return invalid("local part has consecutive dots");
if (domain.length === 0) return invalid("empty domain");
if (domain.length > 253) return invalid("domain longer than 253 characters");
const labels = domain.split(".");
if (labels.length < 2) return invalid("domain needs at least two labels");
for (const label of labels) {
if (label.length === 0) return invalid("domain has an empty label");
if (label.length > 63) return invalid("domain label longer than 63 characters");
if (!DOMAIN_LABEL.test(label)) {
return invalid("domain label has characters outside [a-z0-9-]");
}
if (label.startsWith("-") || label.endsWith("-")) {
return invalid("domain label starts or ends with a hyphen");
}
}
if (!TLD.test(labels[labels.length - 1])) {
return invalid("TLD must be at least 2 alphabetic characters");
}
if (PLACEHOLDER_DOMAINS.has(domain)) {
return invalid(`placeholder domain "${domain}"`);
}
if (DISPOSABLE_DOMAINS.has(domain)) {
return {
valid: true,
risk: "disposable",
reason: `disposable provider "${domain}"`,
};
}
if (ROLE_LOCALS.has(local)) {
return { valid: true, risk: "role", reason: `role account "${local}@"` };
}
if (FREE_DOMAINS.has(domain)) {
return {
valid: true,
risk: "free",
reason: `consumer free-mail "${domain}" — company domain preferred for B2B`,
};
}
return { valid: true, risk: "ok", reason: "syntax OK, business domain" };
}
const RECOMMENDATION: Record<Risk, string> = {
invalid: "skip",
disposable: "skip",
role: "review",
free: "verify",
ok: "verify",
};
// ---------------------------------------------------------------------------
// Fixture mode
// ---------------------------------------------------------------------------
type FixtureCase = {
email: string;
expected: { valid: boolean; risk: Risk };
note: string;
};
function runFixtures(): void {
const path = join(import.meta.dirname, "fixtures_email_validation.json");
const { cases } = readJson<{ cases: FixtureCase[] }>(path);
const failures: string[] = [];
for (const c of cases) {
const got = classifyEmail(c.email);
if (got.valid !== c.expected.valid || got.risk !== c.expected.risk) {
failures.push(
`${JSON.stringify(c.email)} (${c.note}): expected ` +
`valid=${c.expected.valid} risk=${c.expected.risk}, got ` +
`valid=${got.valid} risk=${got.risk} (${got.reason})`,
);
}
}
reportFixtureRun("validate-emails", { total: cases.length, failures });
}
// ---------------------------------------------------------------------------
// Row mode
// ---------------------------------------------------------------------------
const EMAIL_COLUMN_CANDIDATES = ["email", "workEmail", "emailAddress", "contactEmail"];
function resolveEmailColumn(rows: Row[], override: string | undefined): string {
const columns = Object.keys(rows[0] ?? {});
if (override) {
if (!columns.includes(override)) {
fail(
`--email-column "${override}" not found — available columns: ` +
columns.join(", "),
);
}
return override;
}
for (const candidate of EMAIL_COLUMN_CANDIDATES) {
const match = columns.find((c) => c.toLowerCase() === candidate.toLowerCase());
if (match) return match;
}
return fail(
`no email column found (tried ${EMAIL_COLUMN_CANDIDATES.join(", ")}) — ` +
`pass --email-column; available columns: ${columns.join(", ")}`,
);
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2), {
value: [
"input",
"workflow-uuid",
"batch-uuid",
"output-node-slug",
"workspace-uuid",
"email-column",
"output",
],
boolean: ["fixtures", "json"],
});
if (args.flags.has("fixtures")) {
runFixtures();
return;
}
const rows = await readRows(args);
if (rows.length === 0) fail("no input rows");
const emailColumn = resolveEmailColumn(rows, args.values.get("email-column"));
const counts: Record<Risk, number> = {
ok: 0,
free: 0,
role: 0,
disposable: 0,
invalid: 0,
};
const seen = new Set<string>();
let duplicates = 0;
const output = rows.map((row) => {
const email = row[emailColumn] ?? "";
const result = classifyEmail(email);
counts[result.risk]++;
const normalized = normalizeEmail(email);
const isDuplicate = normalized !== "" && seen.has(normalized);
if (isDuplicate) duplicates++;
if (normalized !== "") seen.add(normalized);
// Prefixed column names: they survive merges with provider outputs and
// are what contact-accuracy-audit.ts auto-detects downstream.
return {
...row,
email_syntax_valid: String(result.valid),
email_risk: result.risk,
email_risk_reason: result.reason,
// Duplicates skip regardless of risk — verifying the same address twice
// is pure credit waste; the first occurrence carries the verdict.
recommendation: isDuplicate ? "skip" : RECOMMENDATION[result.risk],
is_duplicate: String(isDuplicate),
};
});
// --json renders rows as a JSON array — the shape jq chains want (e.g.
// select(.recommendation != "skip") to build the paid-verify batch).
const rendered = args.flags.has("json")
? JSON.stringify(output, null, 2) + "\n"
: toCsv(output);
const outputPath = args.values.get("output");
if (outputPath) {
writeFileSync(outputPath, rendered);
} else {
process.stdout.write(rendered);
}
const saved = counts.invalid + counts.disposable + duplicates;
process.stderr.write(
`validate-emails: ${rows.length} rows — ok=${counts.ok} free=${counts.free} ` +
`role=${counts.role} disposable=${counts.disposable} invalid=${counts.invalid}; ` +
`duplicates=${duplicates}; paid verifications saved=${saved}\n`,
);
}
await main();
scripts/validate-linkedin-names.ts
// QA script: flag rows where the sourced person's name does not plausibly
// match the LinkedIn profile name — catching same-name decoys, wrong-profile
// matches, and scraper garbage. Complements the identity-validation gate in
// recipes/linkedin-url-lookup.md (case-insensitive, accents normalized,
// reject rather than guess).
//
// Usage:
// node validate-linkedin-names.ts --input leads.csv [--output out.csv]
// node validate-linkedin-names.ts --workflow-uuid <uuid> [--batch-uuid <uuid>]
// [--output-node-slug <slug>] [--workspace-uuid <uuid>]
// node validate-linkedin-names.ts --fixtures
//
// Columns: --name-column / --profile-name-column override auto-detection
// (source: fullName | full_name | name | firstName+lastName; profile:
// linkedinName | profileName | leadName). Output = input rows plus
// `name_match` ("true"/"false") and `name_match_reason`.
import { writeFileSync } from "node:fs";
import { join } from "node:path";
import {
fail,
metrics,
parseArgs,
readJson,
readRows,
reportFixtureRun,
toCsv,
} from "./lib/common.ts";
import type { Args, Row } from "./lib/common.ts";
// ---------------------------------------------------------------------------
// Name normalization
// ---------------------------------------------------------------------------
// Generational/credential tokens dropped from either side.
const SUFFIX_TOKENS = new Set([
"jr", "sr", "ii", "iii", "iv", "phd", "mba", "md", "dr", "esq", "cpa", "prof",
]);
// CJK, Cyrillic, and Arabic ranges — these scripts get strict handling.
const NON_LATIN =
/[\u0400-\u04ff\u0600-\u06ff\u3040-\u30ff\u3400-\u4dbf\u4e00-\u9fff\uac00-\ud7af]/;
function stripEmoji(text: string): string {
// Pictographs plus variation selector-16 and zero-width joiner.
return text.replace(/[\p{Extended_Pictographic}\ufe0f\u200d]/gu, " ");
}
/**
* Pull quoted / parenthesized nicknames out of a profile name so they can be
* matched as alternate first names: 'Robert "Bob" Smith' → text "Robert Smith",
* nicknames ["Bob"].
*/
function extractNicknames(raw: string): { text: string; nicknames: string[] } {
const nicknames: string[] = [];
const text = raw.replace(
/"([^"]*)"|“([^”]*)”|\(([^)]*)\)/g,
(_match, dquoted, curly, parens) => {
const inner = (dquoted ?? curly ?? parens ?? "").trim();
if (inner) nicknames.push(inner);
return " ";
},
);
return { text, nicknames };
}
/**
* NFD-decompose and strip combining marks (é→e), lowercase, cut credentials
* after a comma, treat hyphens as spaces, drop periods/apostrophes, collapse
* whitespace.
*/
function normalizeName(raw: string): string {
let s = raw;
const comma = s.indexOf(",");
if (comma > 0) s = s.slice(0, comma);
s = s.normalize("NFD").replace(/\p{M}+/gu, "");
s = s.toLowerCase();
s = s.replace(/[-‐‑–—]/g, " ");
s = s.replace(/['’.,]/g, "");
return s.replace(/\s+/g, " ").trim();
}
function nameTokens(normalized: string): string[] {
return normalized.split(" ").filter((t) => t !== "" && !SUFFIX_TOKENS.has(t));
}
// ---------------------------------------------------------------------------
// Nickname table (common English pairs, matched in either direction)
// ---------------------------------------------------------------------------
const NICKNAME_PAIRS: Array<[string, string]> = [
["mike", "michael"], ["bob", "robert"], ["rob", "robert"],
["bill", "william"], ["will", "william"], ["liz", "elizabeth"],
["beth", "elizabeth"], ["dick", "richard"], ["rick", "richard"],
["jim", "james"], ["kate", "katherine"], ["katie", "katherine"],
["tom", "thomas"], ["tony", "anthony"], ["chris", "christopher"],
["chris", "christine"], ["dan", "daniel"], ["dave", "david"],
["ed", "edward"], ["ted", "edward"], ["alex", "alexander"],
["alex", "alexandra"], ["andy", "andrew"], ["ben", "benjamin"],
["charlie", "charles"], ["chuck", "charles"], ["frank", "francis"],
["fred", "frederick"], ["greg", "gregory"], ["hank", "henry"],
["jack", "john"], ["jen", "jennifer"], ["jenny", "jennifer"],
["joe", "joseph"], ["josh", "joshua"], ["ken", "kenneth"],
["larry", "lawrence"], ["matt", "matthew"], ["meg", "margaret"],
["peggy", "margaret"], ["nick", "nicholas"], ["pat", "patricia"],
["pat", "patrick"], ["ron", "ronald"], ["sam", "samuel"],
["sam", "samantha"], ["steve", "steven"], ["steve", "stephen"],
["sue", "susan"], ["tim", "timothy"], ["zack", "zachary"],
];
const NICKNAMES = new Map<string, Set<string>>();
for (const [a, b] of NICKNAME_PAIRS) {
if (!NICKNAMES.has(a)) NICKNAMES.set(a, new Set());
if (!NICKNAMES.has(b)) NICKNAMES.set(b, new Set());
NICKNAMES.get(a)!.add(b);
NICKNAMES.get(b)!.add(a);
}
// ---------------------------------------------------------------------------
// Matching logic
// ---------------------------------------------------------------------------
export type NameComparison = { match: boolean; reason: string };
function firstNamesMatch(a: string, b: string): string | null {
if (a === b) return "exact";
if (NICKNAMES.get(a)?.has(b) || NICKNAMES.get(b)?.has(a)) return "nickname";
// Two nicknames of the same formal name (Kate/Katie via Katherine).
const aFormals = NICKNAMES.get(a);
const bFormals = NICKNAMES.get(b);
if (aFormals && bFormals) {
for (const formal of aFormals) {
if (bFormals.has(formal)) return "nickname-shared-formal";
}
}
if ((a.length === 1 && b.startsWith(a)) || (b.length === 1 && a.startsWith(b))) {
return "initial";
}
return null;
}
/** Pure comparison: does `profileName` plausibly belong to `sourceName`? */
export function compareNames(sourceName: string, profileName: string): NameComparison {
if (!sourceName?.trim()) return { match: false, reason: "missing-source-name" };
if (!profileName?.trim()) return { match: false, reason: "missing-profile-name" };
const extracted = extractNicknames(profileName);
const srcNorm = normalizeName(stripEmoji(sourceName));
const profNorm = normalizeName(stripEmoji(extracted.text));
if (!srcNorm) return { match: false, reason: "missing-source-name" };
if (!profNorm) return { match: false, reason: "missing-profile-name" };
// Non-Latin scripts: fuzzy rules don't apply — require full normalized
// equality (whitespace-insensitive). Partial token overlap is flagged but
// never counted as a match.
if (NON_LATIN.test(srcNorm) || NON_LATIN.test(profNorm)) {
if (
srcNorm === profNorm ||
srcNorm.replaceAll(" ", "") === profNorm.replaceAll(" ", "")
) {
return { match: true, reason: "non-latin-exact" };
}
const srcT = nameTokens(srcNorm);
const profT = nameTokens(profNorm);
const shared = srcT.some((t) => profT.includes(t));
return { match: false, reason: shared ? "non-latin-loose" : "name-mismatch" };
}
const srcT = nameTokens(srcNorm);
const profT = nameTokens(profNorm);
if (srcT.length === 0) return { match: false, reason: "missing-source-name" };
if (profT.length === 0) return { match: false, reason: "missing-profile-name" };
if (srcT.join(" ") === profT.join(" ")) return { match: true, reason: "exact" };
if (srcT.length < 2 || profT.length < 2) {
// A lone token can't confirm both first and last name — reject rather
// than guess.
return { match: false, reason: "single-token-name" };
}
// First name: exact, nickname pair, initial — against the profile's first
// token or any extracted quoted/parenthesized nickname.
const srcFirst = srcT[0];
let firstHow = firstNamesMatch(srcFirst, profT[0]);
if (!firstHow) {
for (const alt of extracted.nicknames.flatMap((n) => nameTokens(normalizeName(n)))) {
if (firstNamesMatch(srcFirst, alt)) {
firstHow = "nickname";
break;
}
}
}
// Last name: exact, or either side's extra surname tokens (maiden/married
// names, hyphenated surnames — hyphens already split) contain the other's
// surname. Middle tokens are ignored for the core comparison.
const srcLast = srcT[srcT.length - 1];
const profLast = profT[profT.length - 1];
let lastHow: string | null = null;
if (srcLast === profLast) lastHow = "exact";
else if (profT.slice(1).includes(srcLast) || srcT.slice(1).includes(profLast)) {
lastHow = "surname-variant";
}
if (firstHow && lastHow) {
return { match: true, reason: `first-${firstHow}+last-${lastHow}` };
}
if (!firstHow && !lastHow) return { match: false, reason: "name-mismatch" };
return {
match: false,
reason: firstHow ? "last-name-mismatch" : "first-name-mismatch",
};
}
// ---------------------------------------------------------------------------
// Column detection
// ---------------------------------------------------------------------------
function findKey(keys: string[], candidates: string[]): string | undefined {
const byLower = new Map(keys.map((k) => [k.toLowerCase(), k]));
for (const candidate of candidates) {
const hit = byLower.get(candidate.toLowerCase());
if (hit !== undefined) return hit;
}
return undefined;
}
function resolveSourceName(args: Args, keys: string[]): (row: Row) => string {
const override = args.values.get("name-column");
if (override) {
if (!keys.includes(override)) fail(`--name-column "${override}" not found in input columns`);
return (row) => row[override] ?? "";
}
const single = findKey(keys, ["fullName", "full_name", "name"]);
if (single) return (row) => row[single] ?? "";
const first = findKey(keys, ["firstName", "first_name"]);
const last = findKey(keys, ["lastName", "last_name"]);
if (first && last) return (row) => `${row[first] ?? ""} ${row[last] ?? ""}`.trim();
return fail(
"could not detect the source name column — looked for fullName, name, or " +
"firstName+lastName; pass --name-column <column>",
);
}
function resolveProfileName(args: Args, keys: string[]): (row: Row) => string {
const override = args.values.get("profile-name-column");
if (override) {
if (!keys.includes(override)) {
fail(`--profile-name-column "${override}" not found in input columns`);
}
return (row) => row[override] ?? "";
}
const detected = findKey(keys, [
"linkedinName", "linkedin_name",
"profileName", "profile_name",
"leadName", "lead_name",
]);
if (detected) return (row) => row[detected] ?? "";
return fail(
"could not detect the profile name column — looked for linkedinName, " +
"profileName, or leadName; pass --profile-name-column <column>",
);
}
// ---------------------------------------------------------------------------
// Fixtures mode
// ---------------------------------------------------------------------------
type FixtureCase = {
sourceName: string;
profileName: string;
expected: boolean;
note: string;
};
function runFixtures(): void {
const path = join(import.meta.dirname, "fixtures_name_validation.json");
const { cases } = readJson<{ cases: FixtureCase[] }>(path);
let tp = 0;
let fp = 0;
let fn = 0;
const mismatches: string[] = [];
for (const c of cases) {
const got = compareNames(c.sourceName, c.profileName);
if (got.match && c.expected) tp++;
else if (got.match && !c.expected) fp++;
else if (!got.match && c.expected) fn++;
if (got.match !== c.expected) {
mismatches.push(
`"${c.sourceName}" vs "${c.profileName}": expected ${c.expected}, ` +
`got ${got.match} (${got.reason}) — ${c.note}`,
);
}
}
// Mismatches are listed for triage; the precision/recall thresholds decide
// the exit code.
for (const m of mismatches) process.stdout.write(` MISMATCH ${m}\n`);
reportFixtureRun(
"validate-linkedin-names",
{ total: cases.length, failures: [] },
{ metrics: metrics(tp, fp, fn), thresholds: { precision: 0.95, recall: 0.85 } },
);
}
// ---------------------------------------------------------------------------
// Row mode
// ---------------------------------------------------------------------------
async function runOnRows(args: Args): Promise<void> {
const rows = await readRows(args);
if (rows.length === 0) fail("no input rows");
const keys = Object.keys(rows[0]);
const sourceOf = resolveSourceName(args, keys);
const profileOf = resolveProfileName(args, keys);
let matched = 0;
const augmented = rows.map((row) => {
const result = compareNames(sourceOf(row), profileOf(row));
if (result.match) matched++;
return {
...row,
name_match: String(result.match),
name_match_reason: result.reason,
};
});
const csv = toCsv(augmented);
const output = args.values.get("output");
if (output) writeFileSync(output, csv);
else process.stdout.write(csv);
process.stderr.write(
`${matched}/${rows.length} rows matched, ${rows.length - matched} flagged\n`,
);
}
// ---------------------------------------------------------------------------
// Entry point
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2), {
value: [
"input", "output", "name-column", "profile-name-column",
"workflow-uuid", "batch-uuid", "output-node-slug", "workspace-uuid",
],
boolean: ["fixtures"],
});
if (args.flags.has("fixtures")) {
runFixtures();
return;
}
await runOnRows(args);
}
await main();
skill-metadata.json
{
"$comment": "Generated by .github/scripts/skills-metadata.mjs — do not hand-edit. Regenerate with: node .github/scripts/skills-metadata.mjs --write .",
"name": "cargo-gtm",
"version": "2.1.0",
"documents": [
{
"path": "SKILL.md",
"kind": "entrypoint",
"title": "Cargo GTM — Meta Skill"
},
{
"path": "agents/execution-plan-creator.md",
"kind": "agent",
"title": "Agent — Execution Plan Creator"
},
{
"path": "agents/list-builder.md",
"kind": "agent",
"title": "Agent — List Builder"
},
{
"path": "guides/enriching-and-researching.md",
"kind": "guide",
"title": "Enriching and researching"
},
{
"path": "guides/finding-companies-and-contacts.md",
"kind": "guide",
"title": "Finding companies and contacts"
},
{
"path": "guides/writing-outreach.md",
"kind": "guide",
"title": "Writing outreach"
},
{
"path": "provider-playbooks/FullEnrich.md",
"kind": "provider-playbook",
"title": "FullEnrich",
"provider": "FullEnrich"
},
{
"path": "provider-playbooks/aiArk.md",
"kind": "provider-playbook",
"title": "aiArk (AI Ark)",
"provider": "aiArk"
},
{
"path": "provider-playbooks/anthropic.md",
"kind": "provider-playbook",
"title": "anthropic (Anthropic)",
"provider": "anthropic"
},
{
"path": "provider-playbooks/apolloio.md",
"kind": "provider-playbook",
"title": "apolloio (Apollo.io)",
"provider": "apolloio"
},
{
"path": "provider-playbooks/bouncer.md",
"kind": "provider-playbook",
"title": "bouncer (Bouncer)",
"provider": "bouncer"
},
{
"path": "provider-playbooks/brightData.md",
"kind": "provider-playbook",
"title": "brightData (Bright Data)",
"provider": "brightData"
},
{
"path": "provider-playbooks/builtwith.md",
"kind": "provider-playbook",
"title": "builtwith (BuiltWith)",
"provider": "builtwith"
},
{
"path": "provider-playbooks/cleon1.md",
"kind": "provider-playbook",
"title": "cleon1 (Cleon1)",
"provider": "cleon1"
},
{
"path": "provider-playbooks/companyEnrich.md",
"kind": "provider-playbook",
"title": "companyEnrich (CompanyEnrich)",
"provider": "companyEnrich"
},
{
"path": "provider-playbooks/contactOut.md",
"kind": "provider-playbook",
"title": "contactOut (ContactOut)",
"provider": "contactOut"
},
{
"path": "provider-playbooks/datagma.md",
"kind": "provider-playbook",
"title": "datagma",
"provider": "datagma"
},
{
"path": "provider-playbooks/dropcontact.md",
"kind": "provider-playbook",
"title": "dropcontact (Dropcontact)",
"provider": "dropcontact"
},
{
"path": "provider-playbooks/enrichCrm.md",
"kind": "provider-playbook",
"title": "enrichCrm (EnrichCRM)",
"provider": "enrichCrm"
},
{
"path": "provider-playbooks/enrichley.md",
"kind": "provider-playbook",
"title": "enrichley (Enrichley)",
"provider": "enrichley"
},
{
"path": "provider-playbooks/enrowio.md",
"kind": "provider-playbook",
"title": "enrowio (Enrowio)",
"provider": "enrowio"
},
{
"path": "provider-playbooks/exa.md",
"kind": "provider-playbook",
"title": "exa (Exa)",
"provider": "exa"
},
{
"path": "provider-playbooks/findyMail.md",
"kind": "provider-playbook",
"title": "findyMail",
"provider": "findyMail"
},
{
"path": "provider-playbooks/firecrawl.md",
"kind": "provider-playbook",
"title": "firecrawl (Firecrawl)",
"provider": "firecrawl"
},
{
"path": "provider-playbooks/forager.md",
"kind": "provider-playbook",
"title": "forager (Forager)",
"provider": "forager"
},
{
"path": "provider-playbooks/g2.md",
"kind": "provider-playbook",
"title": "g2 (G2)",
"provider": "g2"
},
{
"path": "provider-playbooks/gemini.md",
"kind": "provider-playbook",
"title": "gemini (Google Gemini)",
"provider": "gemini"
},
{
"path": "provider-playbooks/hunter.md",
"kind": "provider-playbook",
"title": "hunter",
"provider": "hunter"
},
{
"path": "provider-playbooks/icypeas.md",
"kind": "provider-playbook",
"title": "icypeas",
"provider": "icypeas"
},
{
"path": "provider-playbooks/kitt.md",
"kind": "provider-playbook",
"title": "kitt (Kitt)",
"provider": "kitt"
},
{
"path": "provider-playbooks/leadMagic.md",
"kind": "provider-playbook",
"title": "leadMagic",
"provider": "leadMagic"
},
{
"path": "provider-playbooks/linkedin.md",
"kind": "provider-playbook",
"title": "linkedin",
"provider": "linkedin"
},
{
"path": "provider-playbooks/linkup.md",
"kind": "provider-playbook",
"title": "linkup (Linkup)",
"provider": "linkup"
},
{
"path": "provider-playbooks/mixrank.md",
"kind": "provider-playbook",
"title": "mixrank (Mixrank)",
"provider": "mixrank"
},
{
"path": "provider-playbooks/neverBounce.md",
"kind": "provider-playbook",
"title": "neverBounce (NeverBounce)",
"provider": "neverBounce"
},
{
"path": "provider-playbooks/oceanio.md",
"kind": "provider-playbook",
"title": "oceanio (Ocean.io)",
"provider": "oceanio"
},
{
"path": "provider-playbooks/openAi.md",
"kind": "provider-playbook",
"title": "openAi (OpenAI)",
"provider": "openAi"
},
{
"path": "provider-playbooks/parallel.md",
"kind": "provider-playbook",
"title": "parallel (Parallel)",
"provider": "parallel"
},
{
"path": "provider-playbooks/peopleDataLabs.md",
"kind": "provider-playbook",
"title": "peopleDataLabs (People Data Labs)",
"provider": "peopleDataLabs"
},
{
"path": "provider-playbooks/perplexity.md",
"kind": "provider-playbook",
"title": "perplexity (Perplexity)",
"provider": "perplexity"
},
{
"path": "provider-playbooks/piloterr.md",
"kind": "provider-playbook",
"title": "piloterr (Piloterr)",
"provider": "piloterr"
},
{
"path": "provider-playbooks/prospeo.md",
"kind": "provider-playbook",
"title": "prospeo",
"provider": "prospeo"
},
{
"path": "provider-playbooks/proxycurl.md",
"kind": "provider-playbook",
"title": "proxycurl (ProxyCurl)",
"provider": "proxycurl"
},
{
"path": "provider-playbooks/reverseContact.md",
"kind": "provider-playbook",
"title": "reverseContact (Reverse Contact)",
"provider": "reverseContact"
},
{
"path": "provider-playbooks/rocketreach.md",
"kind": "provider-playbook",
"title": "rocketreach (RocketReach)",
"provider": "rocketreach"
},
{
"path": "provider-playbooks/salesNavigator.md",
"kind": "provider-playbook",
"title": "salesNavigator (Sales Navigator)",
"provider": "salesNavigator"
},
{
"path": "provider-playbooks/serper.md",
"kind": "provider-playbook",
"title": "serper (Serper)",
"provider": "serper"
},
{
"path": "provider-playbooks/sillage.md",
"kind": "provider-playbook",
"title": "sillage (Sillage)",
"provider": "sillage"
},
{
"path": "provider-playbooks/snitcher.md",
"kind": "provider-playbook",
"title": "snitcher (Snitcher)",
"provider": "snitcher"
},
{
"path": "provider-playbooks/societeInfo.md",
"kind": "provider-playbook",
"title": "societeInfo (Societe Info)",
"provider": "societeInfo"
},
{
"path": "provider-playbooks/theSwarm.md",
"kind": "provider-playbook",
"title": "theSwarm (The Swarm)",
"provider": "theSwarm"
},
{
"path": "provider-playbooks/theirStack.md",
"kind": "provider-playbook",
"title": "theirStack (Their Stack)",
"provider": "theirStack"
},
{
"path": "provider-playbooks/waterfall.md",
"kind": "provider-playbook",
"title": "waterfall (Waterfall.io)",
"provider": "waterfall"
},
{
"path": "provider-playbooks/x.md",
"kind": "provider-playbook",
"title": "x (X / Twitter)",
"provider": "x"
},
{
"path": "provider-playbooks/zeroBounce.md",
"kind": "provider-playbook",
"title": "zeroBounce (ZeroBounce)",
"provider": "zeroBounce"
},
{
"path": "recipes/account-expansion.md",
"kind": "recipe",
"title": "Recipe — Find expansion contacts inside customer accounts"
},
{
"path": "recipes/ads-audience-activation.md",
"kind": "recipe",
"title": "Recipe — Paid ads audience activation"
},
{
"path": "recipes/build-tam.md",
"kind": "recipe",
"title": "Recipe — Build a TAM list"
},
{
"path": "recipes/clay-to-cargo.md",
"kind": "recipe",
"title": "Recipe — Migrate a Clay table to Cargo"
},
{
"path": "recipes/custom-datapoints.md",
"kind": "recipe",
"title": "Recipe — Custom datapoints and live signals"
},
{
"path": "recipes/funding-watch.md",
"kind": "recipe",
"title": "Recipe — Track recently-funded companies for outbound timing"
},
{
"path": "recipes/icp-discovery.md",
"kind": "recipe",
"title": "Recipe — Surface ICP signals from Closed-Won vs Closed-Lost"
},
{
"path": "recipes/import-gtm-data.md",
"kind": "recipe",
"title": "Recipe — Import existing GTM data into Cargo"
},
{
"path": "recipes/job-change-monitoring.md",
"kind": "recipe",
"title": "Recipe — Detect job changes in a contact segment"
},
{
"path": "recipes/linkedin-url-lookup.md",
"kind": "recipe",
"title": "Recipe — LinkedIn URL lookup with strict identity validation"
},
{
"path": "recipes/lost-deal-revival.md",
"kind": "recipe",
"title": "Recipe — Revive Closed-Lost deals when the original blocker is gone"
},
{
"path": "recipes/outreach-activation.md",
"kind": "recipe",
"title": "Recipe — Activate a signal segment as personalized outreach"
},
{
"path": "recipes/portfolio-prospecting.md",
"kind": "recipe",
"title": "Recipe — Investor portfolio → contacts → outbound"
},
{
"path": "recipes/prospecting.md",
"kind": "recipe",
"title": "Recipe — Prospecting (find → enrich → verify → sync)"
},
{
"path": "recipes/re-engagement.md",
"kind": "recipe",
"title": "Recipe — Re-engage stale contacts when a fresh signal fires"
},
{
"path": "recipes/review-and-iterate.md",
"kind": "recipe",
"title": "Recipe — Human review loop"
},
{
"path": "recipes/save-as-play.md",
"kind": "recipe",
"title": "Recipe — Save an ad-hoc run as a durable play or tool"
},
{
"path": "recipes/source-planning.md",
"kind": "recipe",
"title": "Recipe — Source planning (before you spend)"
},
{
"path": "recipes/tech-intent.md",
"kind": "recipe",
"title": "Recipe — Find companies by tech-stack or hiring intent"
},
{
"path": "references/acceptable-use.md",
"kind": "reference",
"title": "Acceptable use — basis, suppression, and volume gates"
},
{
"path": "references/alternatives.md",
"kind": "reference",
"title": "Alternative provider chains"
},
{
"path": "references/contact-accuracy.md",
"kind": "reference",
"title": "Contact accuracy — deterministic QA scripts"
},
{
"path": "references/cost-discipline.md",
"kind": "reference",
"title": "Cost discipline — pilot gate, receipts, and spend rules"
},
{
"path": "references/credits-cost-table.md",
"kind": "reference",
"title": "Credits cost table"
},
{
"path": "references/output-retrieval.md",
"kind": "reference",
"title": "Output retrieval — `run download-outputs` vs `run download`"
},
{
"path": "references/prompt-library/company-research.md",
"kind": "prompt-library",
"title": "Prompt library — company research"
},
{
"path": "references/prompt-library/data-extraction.md",
"kind": "prompt-library",
"title": "Prompt library — data extraction"
},
{
"path": "references/prompt-library/index.md",
"kind": "prompt-library",
"title": "Prompt library — index"
},
{
"path": "references/prompt-library/lead-scoring.md",
"kind": "prompt-library",
"title": "Prompt library — lead scoring"
},
{
"path": "references/prompt-library/personalization.md",
"kind": "prompt-library",
"title": "Prompt library — personalization"
},
{
"path": "references/prompt-library/qualification.md",
"kind": "prompt-library",
"title": "Prompt library — qualification"
},
{
"path": "references/prompt-library/signal-analysis.md",
"kind": "prompt-library",
"title": "Prompt library — signal analysis"
},
{
"path": "references/stage-action-map.md",
"kind": "reference",
"title": "Stage → cheapest credits-based action map"
},
{
"path": "references/waterfall-strategy.md",
"kind": "reference",
"title": "Waterfall strategy — multi-provider fallback chains"
},
{
"path": "scripts/contact-accuracy-audit.ts",
"kind": "script"
},
{
"path": "scripts/fixtures_contact_accuracy_audit.json",
"kind": "fixture"
},
{
"path": "scripts/fixtures_current_role.json",
"kind": "fixture"
},
{
"path": "scripts/fixtures_email_validation.json",
"kind": "fixture"
},
{
"path": "scripts/fixtures_name_validation.json",
"kind": "fixture"
},
{
"path": "scripts/lib/common.ts",
"kind": "script-library"
},
{
"path": "scripts/select-current-role.ts",
"kind": "script"
},
{
"path": "scripts/validate-emails.ts",
"kind": "script"
},
{
"path": "scripts/validate-linkedin-names.ts",
"kind": "script"
}
],
"contentHash": "04a7078c6b3234ca57dca9e974205f2dcf75447f36e3733f90552d73745e1821"
}
SKILL.md
---
name: cargo-gtm
description: "Do business-to-business go-to-market work on Cargo — research accounts and buying committees, enrich and verify B2B contact records from licensed data providers, score and qualify leads, draft permission-based outreach for the user's own sequencer, sync to CRM, and monitor buying signals. Consent basis, suppression lists, and volume limits gate every step that touches a person (`references/acceptable-use.md`); bulk unsolicited messaging, purchased or scraped lists, and consumer targeting are refused. Triggers: \"build me a list of\", \"find 50 <title> at <segment>\", \"who works at\", \"find work emails for these accounts\", \"enrich this CSV\", \"verify these emails\", \"build a TAM\", \"who fits our ICP\", \"who actually buys from us\", \"what data points should we collect on accounts\", \"our outbound is reaching the wrong people\", \"score these leads\", \"write a first-touch email\", \"push these to my CRM\", \"who changed jobs\", \"who just raised funding\", \"companies using <tech>\", \"who is hiring <role>\", \"find the buying committee\", \"portfolio companies of <investor>\", \"upload this audience to Google/Meta/LinkedIn ads\". Providers: aiArk, anthropic, apolloio, bouncer, brightData, builtwith, cleon1, companyEnrich, contactOut, datagma, dropcontact, enrichCrm, enrichley, enrowio, exa, findyMail, firecrawl, forager, FullEnrich, g2, gemini, hunter, icypeas, kitt, leadMagic, linkedin, linkup, mixrank, neverBounce, oceanio, openAi, parallel, peopleDataLabs, perplexity, piloterr, prospeo, proxycurl, reverseContact, rocketreach, salesNavigator, serper, sillage, snitcher, societeInfo, theirStack, theSwarm, waterfall, x, zeroBounce. Reads phase guides, recipes, and per-provider playbooks before any paid call. Skip when: a run already happened and misbehaved — use cargo-diagnostics."
version: "2.1.0"
compatibility: Requires @cargo-ai/cli (npm). Sign in or create an account with `cargo-ai login --email` (emailed code, no browser), `--oauth`, or an API token
homepage: https://github.com/getcargohq/cargo-skills
metadata:
author: getcargo
openclaw:
requires:
bins:
- cargo-ai
install:
- kind: node
package: "@cargo-ai/cli@latest"
bins:
- cargo-ai
homepage: https://github.com/getcargohq/cargo-skills
---
# Cargo GTM — Meta Skill
Use this skill for prospecting, account research, contact enrichment, verification, lead scoring, personalization, signal monitoring, and campaign activation.
## Acceptable use — MANDATORY, before anything that touches a person
Full spec: [`references/acceptable-use.md`](references/acceptable-use.md). The short version, binding on every recipe here:
- **B2B professional identities only**, from the licensed providers in [`provider-playbooks/`](provider-playbooks/) — never consumer targeting, purchased lists, or data taken from a platform in breach of its terms.
- **Three checks before any outreach step** — *basis* (customers, opted-in contacts, event attendees, or a documented legitimate-interest case), *suppression* (filter on unsubscribe / DNC / hard-bounce **before** enriching or sending), *relevance* (name, per recipient, why this message is for them). Any check that fails is a stop-and-ask, not a warning.
- **Refuse and say why**: undifferentiated fan-out ("email everyone in `<industry>`"), contacting a suppressed record, filter evasion or disguised sender identity, auto-dialing and SMS blasts, batch-blasting LinkedIn engagement actions. Offer the compliant version once — state it, don't lecture.
- **This skill never sends.** Outreach recipes stop at send-ready variables and hand off to the user's own sequencer, under that sequencer's limits, domains, and identities. Copy it drafts must carry an honest sender and subject, a working opt-out, and a postal address where the jurisdiction requires one.
## Bootstrap
Already signed in (`cargo-ai whoami` returns a workspace)? Skip to the next section.
```bash
npm install -g @cargo-ai/cli # no global install? prefix every command with `npx @cargo-ai/cli`
cargo-ai login --email you@company.com # emailed code, no browser; creates the account on first use
# alternatives: --oauth (browser) · --token <api-token> (CI)
cargo-ai whoami # confirm the active workspace before any write
```
Every command prints JSON to stdout; failures exit non-zero with `{"errorMessage": "..."}`. Anything that creates a run or a batch is async — pass `--wait-until-finished` or poll the matching `get`. When the full skill bundle is installed, [`../cargo/references/prerequisites.md`](../cargo/references/prerequisites.md) adds the CLI version pin, token scopes, and the admin-only surface.
## 1) What this skill governs
- Route GTM decisions, safety gates, and provider/quality defaults **before** execution.
- Keep long command chains and tooling nuance in sub-docs; provider-specific implementation detail in `provider-playbooks/*.md`.
- Anchor recipes in **credits-based actions** (the high-value action calls). Free CRUD (createLead, getLead, deleteRecords) doesn't need this skill — agents can compose those ad hoc.
### Process / goal
The user is generally trying to go from "I have an ICP" to "Here's a list of prospects with verified emails and personalized signals." They may be anywhere in this process — guide them along.
**Discovery order: companies first, then people.** When the task requires finding contacts at companies matching criteria (portfolio, ICP, hiring signal), discover the company set first, then find people at each company. Don't start with broad people-search queries.
### Documentation hierarchy
- **Level 1** — `SKILL.md` (this file): decision model, guardrails, routing table, links to sub-docs.
- **Level 2** — Phase docs: [`guides/finding-companies-and-contacts.md`](guides/finding-companies-and-contacts.md), [`guides/enriching-and-researching.md`](guides/enriching-and-researching.md), [`guides/writing-outreach.md`](guides/writing-outreach.md).
- **Level 2.5** — Recipes: [`recipes/*.md`](recipes/) — step-by-step playbooks for specific scenarios.
- **Level 3** — Provider playbooks: [`provider-playbooks/<slug>.md`](provider-playbooks/) — provider-specific quirks, costs, and fallback behavior.
## 2) Read behavior — MANDATORY before any execution
**STOP. Do not call any provider, run any `cargo-ai orchestration action execute` command, or write any search query until you have opened the correct sub-doc for your task.**
These docs encode what works, what fails, and why. They contain validated parameter schemas, cheapest-provider mappings, parallel execution patterns, sample payloads, and known pitfalls. Reading the right doc for 10 seconds saves 10 failed action calls, wasted credits, and garbage output.
### Routing rules — match your task to a doc and READ IT
| When the task involves… | You MUST read this doc first | What it gives you |
|---|---|---|
| **Finding companies, finding people, building lead lists, prospecting, portfolio/VC sourcing, contact finding at known companies** | [`guides/finding-companies-and-contacts.md`](guides/finding-companies-and-contacts.md) | Provider filter schemas, cheapest-source decision tree, parallel patterns, role-based search rules, portfolio/VC shortcuts, contact-finding patterns. |
| **Enriching companies or contacts, finding emails/phones/LinkedIn, waterfall enrichment, signal lookup (job change, funding, tech stack), coalescing data** | [`guides/enriching-and-researching.md`](guides/enriching-and-researching.md) | Waterfall patterns with fallback chains, when to use aiArk vs waterfall vs FullEnrich vs peopleDataLabs, email/phone/LinkedIn fallback orders, signal segments, output retrieval via `run download-outputs`. |
| **Writing first-touch outreach, personalizing messages, lead scoring, qualification, sequence design, campaign copy** | [`guides/writing-outreach.md`](guides/writing-outreach.md) + [`references/acceptable-use.md`](references/acceptable-use.md) (§3 checks, blocking) | LLM provider routing (openAi/anthropic/perplexity/gemini), prompt templates, scoring rubrics, email length/tone rules, personalization patterns — gated on basis, suppression, and per-recipient relevance. |
| **Actually sending the drafted copy from a mailbox Cargo owns** (rather than handing off to the user's own sequencer) | [`../cargo-mailbox-management/SKILL.md`](../cargo-mailbox-management/SKILL.md) + [`references/acceptable-use.md`](references/acceptable-use.md) (§3 checks, blocking) | Provisioning and warm-up, the 5→40/day send ramp that caps volume, the `sendEmail` action (0.1 credits/send), the workspace suppression list, and replies/opens/clicks as events. |
| **Building or modifying a recurring workflow** (cron / webhook / scheduled tool / play), designing step sequences, triggers, deploy/verify cycles | [`../cargo-orchestration/SKILL.md`](../cargo-orchestration/SKILL.md) (capability) + apply-patterns from this skill's recipes + the [provider playbook](provider-playbooks/) of **every paid node** (§11, esp. its **Recurring use** section) | Schema for tool/play workflows, node graph syntax, polling strategies, output retrieval; per-provider cadence defaults and re-billing gates. |
### Recipes: step-by-step playbooks (check before executing)
Scan this list and read the recipe matching your task. **When a recipe matches: follow it step-by-step as your execution plan.**
| Recipe | Use when… |
|---|---|
| [`recipes/source-planning.md`](recipes/source-planning.md) | **Read first when the source isn't obvious.** Turn the question into a field, probe 2–3 candidate sources on 5–10 rows, present cost-per-*hit* — before any fan-out |
| [`recipes/prospecting.md`](recipes/prospecting.md) | End-to-end find → enrich → verify → sync (P1/P2/P3 variants) |
| [`recipes/build-tam.md`](recipes/build-tam.md) | Building a Total Addressable Market list at scale (100–10,000 companies) |
| [`recipes/linkedin-url-lookup.md`](recipes/linkedin-url-lookup.md) | Resolving a person's LinkedIn profile URL from name + company with strict identity validation |
| [`recipes/portfolio-prospecting.md`](recipes/portfolio-prospecting.md) | Investor / accelerator → portfolio companies → contacts |
| [`recipes/job-change-monitoring.md`](recipes/job-change-monitoring.md) | `waterfall.detectJobChange` (cargo-unique) on a contact segment |
| [`recipes/funding-watch.md`](recipes/funding-watch.md) | Tracking companies that recently raised funding |
| [`recipes/tech-intent.md`](recipes/tech-intent.md) | Finding companies by tech-stack or hiring-intent signals |
| [`recipes/icp-discovery.md`](recipes/icp-discovery.md) | Diffing Closed-Won vs Closed-Lost segments to surface ICP signals |
| [`recipes/custom-datapoints.md`](recipes/custom-datapoints.md) | Designing *which* custom attributes and live signals to collect for a seller's ICP — feasibility-gated against the catalog, then wired into columns, scoring, segments, and a refresh cadence |
| [`recipes/outreach-activation.md`](recipes/outreach-activation.md) | Turning a signal segment into send-ready outreach (enrich → verify → personalize → sequencer handoff) |
| [`recipes/ads-audience-activation.md`](recipes/ads-audience-activation.md) | Pushing a segment to paid media — Google Ads Customer Match or LinkedIn Matched Audiences — and reading the match rate |
| [`recipes/review-and-iterate.md`](recipes/review-and-iterate.md) | Judgment output a human must review — sheet handoff, grouped corrections, permanent fixes, kept as an eval set |
| [`recipes/re-engagement.md`](recipes/re-engagement.md) | Waking up stale contacts only when a fresh signal fires (job change, funding, tech intent) |
| [`recipes/lost-deal-revival.md`](recipes/lost-deal-revival.md) | Reviving Closed-Lost CRM deals by branching on `lost_reason` (champion left, budget, timing) |
| [`recipes/account-expansion.md`](recipes/account-expansion.md) | Multi-threading existing customer accounts — net-new buyers, deduped against the workspace's Contacts model |
| [`recipes/save-as-play.md`](recipes/save-as-play.md) | Converting a successful ad-hoc run into a durable scheduled play or cron tool — offer after any repeatable pull |
| [`recipes/import-gtm-data.md`](recipes/import-gtm-data.md) | Importing existing GTM data (CSV/CRM exports from any tool) into models, QA-auditing it, and selectively rebuilding recurring logic as plays with a parity check |
| [`recipes/clay-to-cargo.md`](recipes/clay-to-cargo.md) | **Clay specifically**: getting the column *configuration* out (not the CSV), the column-family → action map, the four Clay concepts that do not map one to one (waterfalls, run conditions, auto-update, partial runs), and the parity check against Clay's own output |
If none match, scan the phase docs above for the closest pattern and adapt — or invoke [`agents/execution-plan-creator.md`](agents/execution-plan-creator.md) to compose a custom chain with provider/action slugs and cost estimates. For wide sourcing sweeps that fan out (per-industry, per-geo), delegate approved slices to [`agents/list-builder.md`](agents/list-builder.md) — it executes exactly one pre-approved action per slice and returns rows to a file, keeping row data out of the main context. (On Claude Code with the plugin, both are installed as native subagents: `cargo-execution-planner` and `cargo-list-builder`.)
## 3) Cost discipline — MANDATORY gates
Full spec: [`references/cost-discipline.md`](references/cost-discipline.md). The short version every task must honor:
1. **Sample → approval → full run, in that order.** Run a slice of the exact input first — 1–3 rows to prove one action's config, **10–20 records before any batch** (one row can't show a hit-rate). Then present the 4-section approval message (Assumptions · Sample result verbatim · Credits/Scope/Cap — always stating **how many records** the full run enrolls and **what they cost**, reconciled against the actual balance · 3 shaped choices); stay in AWAIT_APPROVAL until the user picks. Never fan out on an unapproved or cost-unknown action, and never read approval of the sample as approval of the full enrollment.
2. **Receipt after every paid action**: credits spent + balance remaining + hit-rate ("found 34 emails of 40") + estimate-vs-actual with the why when they diverge. Prefer `billing usage get-metrics` over your own arithmetic.
3. **Over-provision 1.4×N, then filter** — coverage is a property of the company; drop incomplete rows instead of chasing them with more providers.
4. **Count first, pay second** — search is billed on returned rows; keep `limit` strict and size the pool with a 1-row probe before any full pull.
5. **Phone is the guarded lever** — explicit user request only, qualified leads only. Still true at the cheap end: `aiArk.findMobilePhone` (0.5, mobile-only) is the first rung and bills 0 on a miss, but the escalation behind it is 3–7 credits (~10× email), so a full-list phone sweep needs the same approval as any other paid fan-out.
## 4) After every run — receipt, then grounded next steps
End every completed run with the receipt (above), then propose **2–3 next steps maximum, computed from the data just produced — never a generic menu**. Required shape:
1. **Continuity** — builds on this session's artifacts ("67 of these 70 companies have RevOps teams — find the leads?"), not a fresh generic idea.
2. **Budget-aware** — framed against the remaining balance ("with your ~9 credits left, ~5 verified emails fits").
3. **Cost-per-unit stated** — "email waterfalls run ~1.4 credits each."
4. **A default picking heuristic** so answering takes one word ("I'd default to: has funding data + RevOps ≥ 2 + posting is recent").
5. **An escape hatch** — always end with "or something else entirely."
When a run produced a durable, repeatable result, one of the suggestions should be **making it systematic** — see [`recipes/save-as-play.md`](recipes/save-as-play.md).
When a run or batch **misbehaved** — errors, missing downstream values, cost surprises — hand off to the `cargo-diagnostics` skill (`../cargo-diagnostics/SKILL.md`): sweep the batch for root causes before re-running anything paid. Interaction defaults for plan gates, shaped choices, and presenting results live in `../cargo/references/interaction.md`.
## 5) Priority provider stack (recipes lead with these 7)
These seven credits-based providers cover the full prospecting → enrichment → verification → signal pipeline at the lowest credit cost in the catalog. Every recipe in this skill's `recipes/` leads with this stack:
| Provider | Role | Key actions (cost in credits) |
|---|---|---|
| **salesNavigator** | Sourcing | `searchLeads` (0.02), `searchAccounts` (0.05), `findCompanyInsights/Metrics/EmployeesCount/Distribution` (0.25 each) |
| **aiArk** | LinkedIn-anchored enrichment + cheapest search | `enrichCompany` (0.01 — cheapest firmographics in the catalog), `searchCompanies` (0.01/record, lookalike seeds), `searchPeople` / `reverseLookup` / `analyzePersonality` (0.05), `enrichPerson` (0.1 — profile **+ verified email**), `findMobilePhone` (0.5) |
| **waterfall** | Multi-source enrichment + signal | `enrichContact` (2), `enrichCompany` (1), `verifyEmail` (0.1), `detectJobChange` (3), `searchProspects` (3), `findPhone` (7) |
| **FullEnrich** | Premium contact lookup | `findEmail` (1), `findPhone` (6), `findPhoneAndEmail` (7), `reverseEmailLookup` (2) |
| **apolloio** | Niche-coverage enrichment | `enrichPerson` (1, **3** with `revealPhoneNumber`), `enrichOrganization` (1) — the **only two** credits-based actions; its other nine need your own Apollo API key |
| **theirStack** | Tech-stack + hiring intent | `searchTechnologies` (0.5), `searchJobs` (0.5), `searchCompanies` (0.5) |
| **peopleDataLabs** | Heavyweight backfill | `enrichPerson` (3), `enrichCompany` (3), `searchPeople` (3), `searchCompanies` (3), `queryPeople/Companies` (3) |
`aiArk` and `apolloio` sit at opposite ends of the enrich tier and are picked by **what you hold**, not by preference: `aiArk` wins whenever a **LinkedIn URL** is in hand (profile + verified email at 0.1, mobile at 0.5, both billing 0 on a miss), `apolloio` is the **1-credit niche-coverage rung** you promote per-batch when a pilot shows Apollo hits where `aiArk` (0.1) and `waterfall` (2) miss — investor-backed and portfolio niches especially. Neither displaces `salesNavigator` for plain at-scale sourcing (0.02/lead).
Three signal families sit outside the stack and are picked per task from [`references/stage-action-map.md`](references/stage-action-map.md): **firmographic depth** beyond `aiArk.enrichCompany` → `companyEnrich.enrichByDomain` (0.25); **funding / acquisitions** → `enrichCrm.getFunding` (1, the only credits-based funding action in the catalog); **tech stack on a known domain** → `builtwith.getDomainSummary` (free) before `builtwith.enrichDomain` (1).
See [`provider-playbooks/`](provider-playbooks/) for per-provider deep dives — including each provider's **Recurring use** section for when the task is a monitor, play, or scheduled pull rather than a one-off. See [`references/stage-action-map.md`](references/stage-action-map.md) for the complete cheapest-action-per-stage table across the full 136-integration catalog.
> **Already holding identifiers (not sourcing)?** The stack above leads the *sourcing-first* spine. When you already have **LinkedIn URLs**, the cheapest enrich is [`aiArk.enrichPerson`](provider-playbooks/aiArk.md) (0.1 — full profile **plus** a verified email, bills 0 when no email is found); drop to [`linkedin.enrichProfile` / `enrichCompany`](provider-playbooks/linkedin.md) (0.25) when you don't need the email, and skip `waterfall.enrichContact` entirely (it keys on email or name+company, not a URL). Need a **phone**? `aiArk.findMobilePhone` (0.5) is the first rung, not the 3–7 tier. Have a **LinkedIn event URL**? `linkedin.extractEventAttendees` sources the attendee list directly. Have **emails**? `aiArk.reverseLookup` (0.05), then `leadMagic` / `contactOut`. See `references/stage-action-map.md` for the full input-type → cheapest-action map.
## 6) Recipe spine (default chain)
```
1. SOURCE → salesNavigator.searchLeads / searchAccounts (0.02–0.05/record)
lookalike seeds, or filters SN can't express (skills,
education, tenure)? aiArk.searchCompanies / searchPeople (0.01–0.05/record)
2. DEDUPE → match against the workspace's own Companies / Contacts models
on domain / linkedin_url (storage SQL or a segment filter) (free)
3. ENRICH → LinkedIn URL in hand? aiArk.enrichPerson (0.1) FIRST — profile + verified
email in one call; linkedin.enrichProfile/enrichCompany (0.25) if no email needed
aiArk.enrichCompany (0.01) for firmographics; companyEnrich.enrichByDomain
(0.25) on the rows that come back thin
+ waterfall.enrichContact / enrichCompany (1–2/record)
+ apolloio.enrichPerson / enrichOrganization on the niche residue (1/record)
4. SIGNAL → enrichCrm.getFunding (1/record)
+ theirStack.searchJobs / builtwith.getDomainSummary (0–0.5/record)
+ waterfall.detectJobChange (3/record)
5. CONTACT → FullEnrich.findEmail — only on rows step 3 left without
an email (fallback peopleDataLabs) (1–3/record)
6. VERIFY → waterfall.verifyEmail (0.1/record)
7. BACKFILL → peopleDataLabs.enrichPerson (only if step 5 missed) (3/record)
8. QA → scripts/contact-accuracy-audit.ts (free, local)
```
Two spine notes from the 8-provider stack: step 3's `aiArk.enrichPerson` **already returns a verified email**, so step 5 runs on the residue only — don't pay `FullEnrich.findEmail` (1) behind a row that already has one. And when the goal reaches a **phone**, `aiArk.findMobilePhone` (0.5, mobile-only, bills 0 on a miss) is the first rung before `prospeo` (3) / `FullEnrich` (6) / `waterfall` (7) — the guarded-lever rule in §3 still applies to all four.
Adapt by phase: drop steps that aren't relevant to the user's goal. For pure sourcing, run step 1 only. For "enrich a list I already have," run steps 2–7.
## 7) Output retrieval — use `run download-outputs`, not `run download`
When the agent needs the actual data produced by an action (enriched fields, found emails, search results), use:
```bash
cargo-ai orchestration run download-outputs \
--workflow-uuid <uuid> \
--output-node-slug <slug> \
--format json
```
(Don't pass `--is-finished` — the CLI help still lists it but the API currently rejects it with `unrecognized_keys`; reported.)
Returns `{"url": "..."}` — a signed URL to a CSV/JSON containing only the output node's data. Faster and cheaper than `run download` (which pulls full run records). See [`references/output-retrieval.md`](references/output-retrieval.md) and [`../cargo-analytics/SKILL.md`](../cargo-analytics/SKILL.md).
## 8) Contact accuracy — run the QA scripts, don't eyeball
Four deterministic TypeScript scripts in [`scripts/`](scripts/) (Node ≥ 22.18, zero deps, fixture-tested in CI) replace in-context row checking. **Run the script — never re-derive its logic by reasoning over rows.** Full doctrine, pipeline order, and the SEND/VERIFY/REVIEW/REMOVE verdict semantics: [`references/contact-accuracy.md`](references/contact-accuracy.md).
- `scripts/validate-emails.ts` — free syntax/risk/duplicate cull **before** paid `verifyEmail`.
- `scripts/select-current-role.ts` — pick the real current role from an experiences array (catches job changers).
- `scripts/validate-linkedin-names.ts` — name↔profile match (catches same-name decoys); pairs with [`recipes/linkedin-url-lookup.md`](recipes/linkedin-url-lookup.md).
- `scripts/contact-accuracy-audit.ts` — final per-row `audit_action` stamp on the merged output; cite its summary counts in the receipt. Reads files or a finished run directly (`--workflow-uuid`, via `@cargo-ai/api`).
## 9) Action shape rules (every recipe)
Every action JSON in this skill follows the rules in [`../cargo-orchestration/references/examples/actions.md`](../cargo-orchestration/references/examples/actions.md):
- `kind: "connector"` action shape: `{"kind":"connector","integrationSlug":"<slug>","actionSlug":"<slug>"}`. **`connectorUuid` is NOT in `config`** — the platform resolves the workspace's authenticated connector from `integrationSlug` automatically.
- **A top-level action has no `config` — omit it.** Inputs go in `--data` / `--records`, and every recipe here writes the action without the key. That holds for `action execute`, `execute-batch`, and `get-output-schema` alike — the object `action list` returns pastes into all three. Inputs misplaced into `config` are not rejected, they are **dropped**, and the action runs with no input, so check this first when a call returns empty for no visible reason.
- **Don't hand-write a slug you're unsure of, and don't page the catalog looking for one.** `cargo-ai orchestration action list <keywords> [--integration-slug <slug>]` is free, searches every integration plus native actions, tools, and agents, and returns the action object ready to paste **with the action's credit costs** — a cheap sanity check on both the slug and the price before a paid call. When the question is *which paid actions exist for this?*, `cargo-ai connection action search <keywords> --credits-only` is the one that filters on it. Neither replaces the provider playbook below: the playbook is where the input quirks, hit-rates, and recurring-use traps live.
- For multi-step node graphs: `connectorUuid` lives at the top level of the node, not in `config`. Cross-node interpolation uses `{{nodes.<slug>.<field>}}`. Agent node outputs wrap under `.answer` (read as `{{nodes.<slug>.answer.<field>}}`).
## 10) When stuck — file a workspace report
If a recipe fails repeatedly and the cause isn't obvious, escalate via `cargo-ai workspaceManagement report create`. See [`../cargo-workspace-management/SKILL.md`](../cargo-workspace-management/SKILL.md) (Reports section).
## 11) Provider playbooks — read before you call (one-off or recurring)
**STOP — do not execute any paid action against a provider below, and do not wire a provider into a recurring play/tool node graph, until you have opened its playbook.** Each playbook carries the exact action slugs, config shapes, input quirks, and cost traps; reading it for five seconds is cheaper than one failed paid call, and a failed batch is 100 failed paid calls. The stakes are higher, not lower, when the provider goes into a **recurring** workflow: a bad config repeats on every scheduled run, and a wrong cadence re-bills the same rows forever — each playbook ends with a **Recurring use** section (schedule fit, cadence default, re-billing gates, extractors) for exactly this. **Every credits-based provider with callable actions now has a playbook, with one stated exception**: `openRouter`, which exposes a model lister rather than credits-based actions, so there is nothing to document. `brightData` and `proxycurl` gained playbooks rather than staying unlisted — an undocumented provider still shows up in the cost table, and leaving the acceptable-use framing implicit was the weaker option: [`provider-playbooks/brightData.md`](provider-playbooks/brightData.md) states the consumer-targeting refusal up front. Own-key integrations fall back to [`references/alternatives.md`](references/alternatives.md) and [`references/stage-action-map.md`](references/stage-action-map.md).
**Priority stack (recipes lead with these):**
- [`provider-playbooks/salesNavigator.md`](provider-playbooks/salesNavigator.md) — cheapest sourcing in the catalog (0.02–0.05/record).
- [`provider-playbooks/aiArk.md`](provider-playbooks/aiArk.md) — LinkedIn-anchored people/company data: `enrichPerson` returns profile **+ verified email** at 0.1, `findMobilePhone` (0.5) is the cheapest phone rung, `searchCompanies` (0.01/record) does lookalikes, and `analyzePersonality` (0.05) is catalog-unique. All actions run on the managed connection.
- [`provider-playbooks/waterfall.md`](provider-playbooks/waterfall.md) — swiss-army-knife: enrichment, verification, and the cargo-unique `detectJobChange` signal.
- [`provider-playbooks/FullEnrich.md`](provider-playbooks/FullEnrich.md) — premium contact lookup; `reverseEmailLookup` is unique.
- [`provider-playbooks/apolloio.md`](provider-playbooks/apolloio.md) — the 1-credit niche-coverage enrich rung (person + organization); **read it before assuming Apollo is available** — only two of its eleven actions are credits-based, the rest need your own Apollo API key.
- [`provider-playbooks/theirStack.md`](provider-playbooks/theirStack.md) — tech-stack + hiring-intent signals.
- [`provider-playbooks/peopleDataLabs.md`](provider-playbooks/peopleDataLabs.md) — heavyweight backfill at flat 3-credit tier.
**Sourcing & company-data specialists:**
- [`provider-playbooks/linkedin.md`](provider-playbooks/linkedin.md) — the native LinkedIn integration's action set (profiles, companies, posts, jobs).
- [`provider-playbooks/oceanio.md`](provider-playbooks/oceanio.md) — lookalike-company discovery from seed domains, with technographic / web-traffic filters `aiArk.searchCompanies` (0.01) can't express.
- [`provider-playbooks/datagma.md`](provider-playbooks/datagma.md) — lightweight person/company enrichment alternative.
- [`provider-playbooks/companyEnrich.md`](provider-playbooks/companyEnrich.md) — cheapest company-by-domain (0.25) + per-item-billed lookalikes.
- [`provider-playbooks/enrichCrm.md`](provider-playbooks/enrichCrm.md) — CRM-record enrichment; `getFunding` is the funding-signal fallback.
- [`provider-playbooks/societeInfo.md`](provider-playbooks/societeInfo.md) — French-registry company/contact data (SIREN/SIRET).
- [`provider-playbooks/snitcher.md`](provider-playbooks/snitcher.md) — website-visitor identification; the recurring extractor is the cost trap.
- [`provider-playbooks/piloterr.md`](provider-playbooks/piloterr.md) — ultra-cheap bulk company extractor + G2 product info.
- [`provider-playbooks/g2.md`](provider-playbooks/g2.md) — software-review & category signal data.
- [`provider-playbooks/theSwarm.md`](provider-playbooks/theSwarm.md) — warm-intro network mapping to target companies/people.
- [`provider-playbooks/mixrank.md`](provider-playbooks/mixrank.md) — premium person/company backfill (4/lookup, phone-only reverse lookup).
**Email & contact specialists** (all feed the VERIFY step — see [`references/waterfall-strategy.md`](references/waterfall-strategy.md)):
- [`provider-playbooks/hunter.md`](provider-playbooks/hunter.md) — domain-search email finding + verification.
- [`provider-playbooks/prospeo.md`](provider-playbooks/prospeo.md) — email/phone lookup, LinkedIn-URL input path.
- [`provider-playbooks/icypeas.md`](provider-playbooks/icypeas.md) — budget email find/verify.
- [`provider-playbooks/findyMail.md`](provider-playbooks/findyMail.md) — email finding alternative.
- [`provider-playbooks/leadMagic.md`](provider-playbooks/leadMagic.md) — email + mobile lookup alternative.
- [`provider-playbooks/contactOut.md`](provider-playbooks/contactOut.md) — contact info from LinkedIn profiles.
- [`provider-playbooks/zeroBounce.md`](provider-playbooks/zeroBounce.md) — email-verification second opinion to `waterfall.verifyEmail`.
- [`provider-playbooks/bouncer.md`](provider-playbooks/bouncer.md) / [`neverBounce.md`](provider-playbooks/neverBounce.md) / [`kitt.md`](provider-playbooks/kitt.md) / [`enrichley.md`](provider-playbooks/enrichley.md) — verification long tail (0.3 / 0.2 / 0.05 / 0.1; enrichley's slug is `verify`, not `verifyEmail`).
- [`provider-playbooks/dropcontact.md`](provider-playbooks/dropcontact.md) — email finding with French/EU registry depth; `email` output is an array.
- [`provider-playbooks/enrowio.md`](provider-playbooks/enrowio.md) — email find (1) + verify (0.1); takes `fullName` only.
- [`provider-playbooks/reverseContact.md`](provider-playbooks/reverseContact.md) — company-from-LinkedIn (credits); profile lookups are own-key.
- [`provider-playbooks/rocketreach.md`](provider-playbooks/rocketreach.md) — person lookup (1); healthcare/NPI niche; beware the `currrentEmployer` schema key.
- [`provider-playbooks/forager.md`](provider-playbooks/forager.md) — personal-email + phone from a LinkedIn URL.
- [`provider-playbooks/cleon1.md`](provider-playbooks/cleon1.md) — terminal phone rung (15/lookup) — explicit user request only.
**Research & scraping:**
- [`provider-playbooks/firecrawl.md`](provider-playbooks/firecrawl.md) — web scraping for research/personalization stages.
- [`provider-playbooks/serper.md`](provider-playbooks/serper.md) — Google SERP queries for research and URL discovery.
- [`provider-playbooks/linkup.md`](provider-playbooks/linkup.md) — web search (0.5 standard / 2 deep) + sourced/structured answers.
- [`provider-playbooks/parallel.md`](provider-playbooks/parallel.md) — cheapest page read in the catalog (`extract`, 0.025/URL) plus `createTask`, the only action that fills a caller-supplied output schema.
- [`provider-playbooks/exa.md`](provider-playbooks/exa.md) — semantic search with a document-type `category` filter and publication-date bounds.
- [`provider-playbooks/builtwith.md`](provider-playbooks/builtwith.md) — a domain's technology stack; `getDomainSummary` is **free** and runs in front of the paid rung.
- [`provider-playbooks/x.md`](provider-playbooks/x.md) — public X posts and profiles at 0.02 an action; a signal rung, gated by acceptable use.
- [`provider-playbooks/sillage.md`](provider-playbooks/sillage.md) — inbound signal detections read back from a model, **free**, so it runs first on any signal question.
- [`provider-playbooks/brightData.md`](provider-playbooks/brightData.md) — Instagram / TikTok / Facebook / YouTube profiles by URL, 0.1 an action; the catalog's only non-LinkedIn, non-X social coverage, and the playbook opens with the consumer-targeting refusal that gates it.
- [`provider-playbooks/proxycurl.md`](provider-playbooks/proxycurl.md) — LinkedIn profile / company lookups on your own key.
**LLM providers** (all: one `instruct` action, cost per 1,000-token package, per-model tiers — prompts come from [`references/prompt-library/index.md`](references/prompt-library/index.md)):
- [`provider-playbooks/anthropic.md`](provider-playbooks/anthropic.md) — judgment-tier default (Haiku/Sonnet 0.2, Opus 2); temperature nests under `advancedSettings` with required `maxTokens`.
- [`provider-playbooks/openAi.md`](provider-playbooks/openAi.md) — cheapest bulk tier (`gpt-5-nano` 0.006) + native JSON-schema output.
- [`provider-playbooks/gemini.md`](provider-playbooks/gemini.md) — cheap high-throughput (Flash 0.01, 15,000/min) + search grounding.
- [`provider-playbooks/perplexity.md`](provider-playbooks/perplexity.md) — web-grounded research answers; default model is the expensive `sonar-deep-research` — always set `model` explicitly.
## 12) References
- [`references/cost-discipline.md`](references/cost-discipline.md) — the mandatory spend rules: pilot → approval gate, per-run receipts, 1.4×N over-provision, count-first sizing, provider-billing rules.
- [`references/contact-accuracy.md`](references/contact-accuracy.md) — the deterministic QA scripts (email cull, current-role, name match, final audit) and the SEND/VERIFY/REVIEW/REMOVE verdicts.
- [`references/prompt-library/index.md`](references/prompt-library/index.md) — ~40 named, parameterized LLM prompts (personalization, scoring, research, qualification, signal analysis, extraction). **Before authoring any enrichment/scoring prompt from scratch, grep this index** — reuse beats reinvention, and each entry carries a tested output contract. Load only the shard you need, never all of them.
- [`references/stage-action-map.md`](references/stage-action-map.md) — cheapest credits-based action per stage across the full 136-integration catalog.
- [`references/credits-cost-table.md`](references/credits-cost-table.md) — auto-generated cost table for all 176 credits-based actions.
- [`references/waterfall-strategy.md`](references/waterfall-strategy.md) — canonical waterfall chains by enrichment goal (every recipe's "fallback" follows these).
- [`references/alternatives.md`](references/alternatives.md) — provider swap-ins from the long tail when the priority stack can't serve.
- [`references/output-retrieval.md`](references/output-retrieval.md) — `run download-outputs` patterns for fetching action data.