Enrichment

Enrichment routing

Decide, for each person in an Outreach Hub campaign's batch, which contact details the desk asks enrichment-ops for (work email, personal email, mobile phone), which it already has or skips, and which bad values it sends back to be looked up again. Use before the desk's Enrich step, in the hub or in Claude Code, once the batch is pulled and the person has chosen what to enrich. Decides only: never calls enrichment-ops or a provider, never picks a provider or its order, never spends.

DraftNot used in a campaign yetVersion 2026.09.29.1

enrichment-routing/SKILL.md12.2 KB242 lines

When asked which version of this skill is active, report metadata.version exactly.

Answer only with JSON that matches schemas/lookup-decisions.schema.json.

What this skill does

pulled batch + what we already hold -> lookup decisions (this skill) -> the confirm step quotes them -> a person approves -> the desk submits

You decide, per person and per kind of contact detail, one of four actions:

  • keep: this campaign already found it; ask nothing.
  • lookup: ask the kind's lookup door.
  • reenrich: the value we hold is wrong; reject it through the kind's re-enrich door so the service looks for a different one.
  • skip: ask nothing, and say why.

The desk's code does everything else: it reads the facts below, submits each lookup, spends, and records the answers. enrichment-ops runs its own provider waterfall and cache, so provider order, retries between providers and email verification are its job, never yours. references/enrichment-ops-api.md holds the facts about the service this skill relies on.

What you are given

A JSON object. Every fact is worked out by the desk's code; you never look anything up.

  • campaign.channels: email, call or both, from campaign_ops_campaigns.channels.
  • campaign.kinds: the kinds the person chose for this run, from work_email, personal_email and mobile_phone. You decide only these kinds, and never add one.
  • people: one entry per person in the batch:
    • contact_id: their campaign_ops_contacts.id. Echo it exactly.
    • has_company_domain: whether campaign_ops_contacts.company_domain is set.
    • fit_score: prospect-fit's 0 to 100 score, or null when nobody scored them.
    • opted_out: true when the person asked us to stop contacting them, null when unknown.
    • days_since_contacted: whole days since we last reached them on any channel, or null when never or unknown. A hard bounce is not a contact.
    • details: for each kind, what the desk already holds, or the kind is absent when it holds nothing:
      • source: campaign (this campaign's own lookup, any status), desk (a value another hub campaign found for the same LinkedIn profile) or revops (a value in RevOps' enriched_contacts). The code sends this campaign's lookup when there is one, otherwise the newest value found elsewhere.
      • status: waiting, found, none, not_searched or failed, as campaign_ops_enrichments.status records them. desk and revops values are always found.
      • value_status: an email's Valid or Unverifiable, or null (phones never carry one, and RevOps may not say).
      • age_days: whole days since that answer, or null while waiting.
      • problem: null, or what we have learned is wrong with the value: bounced (an email bounced), wrong_person (a reply or a call said it reaches someone else) or bad_number (a call found the number dead).

The values themselves (addresses, numbers, names) are never sent to you and never belong in your answer.

House numbers

RuleValue
Poor fit: never enrich a fit score under40
Contacted recently: leave out anyone reached fewer than this many days ago30
Stale: an answer older than this many days is asked for again90

references/rule-sources.md says where each rule comes from.

The rules

Decide every kind in campaign.kinds for every person. Go down the list and use the first rule that matches.

About the person (the same answer for every kind):

  1. opted_out is true: skip, opted_out. This beats everything, re-enrichment included.
  2. fit_score is under 40: skip, poor_fit. A null score is not a poor fit.
  3. days_since_contacted is under 30: skip, contacted_recently. The send leaves them out, so a detail bought now has no use.

About the channel:

  1. The kind serves no channel of the campaign: skip, no_channel. Work and personal email serve email; mobile phone serves call. Say in notes which chosen kind had no channel, so the person can fix the campaign or the choice.

Personal email, when work email is also chosen. Personal email is the fallback for the email channel: it is asked only once work email cannot carry it, one email lookup at a time. If this campaign already has a personal-email lookup (source is campaign), skip this step and use rules 5 to 13 for it as for any kind. Otherwise, decide work email first, then:

Work email's decisionPersonal email
keep, foundskip, email_covered
any lookup, any reenrich, or skip, waitingskip, work_email_pending
keep, unverifiable, or skip, searched_none or missing_company_domainlookup, fallback

When work email is not chosen, personal email is the campaign's email lookup and follows rules 5 to 13.

About what we hold for this kind (details[kind]):

  1. status is waiting: skip, waiting. The answer is on its way.
  2. problem is set: reenrich with the problem as the reason (bounced, wrong_person or bad_number). A work email cannot be re-enriched without a company domain (the service has nothing to search on), so when has_company_domain is false it is skip, missing_company_domain.
  3. status is failed: lookup, retry_failed. Every provider failed, so nothing was learned; it is never a "none".
  4. status is not_searched: skip, missing_name. The phone search needs a name; asking again without one searches nothing.
  5. status is found or none and age_days is over 90: lookup, stale.
  6. status is found and source is campaign: keep, with unverifiable when value_status is Unverifiable, otherwise found. An unverifiable email is kept, never rejected: it may be right, and the send holds it back.
  7. status is found and source is desk or revops: lookup, known_elsewhere. The service answers from its own cache at no charge and gives this campaign its own checked answer.
  8. status is none: skip, searched_none. Asked and answered: nobody has one.
  9. Nothing held: lookup, new.

Your answer

  • decisions: one entry per person and chosen kind, each with contact_id, kind, action and reason. Nobody left out, nobody twice, no kind that was not chosen. The code refuses an answer that breaks this.
  • notes: anything a reviewer should know that the decisions do not say, or null. Plain English, sentence case, no em dashes.

Never promise or imply that a lookup has run, a value exists or a number works. Phones are never verified.

Example

Given (both channels, all three kinds chosen):

{
  "campaign": { "channels": ["email", "call"], "kinds": ["work_email", "personal_email", "mobile_phone"] },
  "people": [
    {
      "contact_id": "1c9e4b2a-7d3f-4e81-9a6b-0f2d5c8e3a11",
      "has_company_domain": true, "fit_score": 81, "opted_out": null, "days_since_contacted": null,
      "details": {
        "work_email": { "source": "revops", "status": "found", "value_status": null, "age_days": 40, "problem": null }
      }
    },
    {
      "contact_id": "2d0f5c3b-8e4a-4f92-8b7c-1a3e6d9f4b22",
      "has_company_domain": true, "fit_score": 22, "opted_out": null, "days_since_contacted": null,
      "details": {}
    },
    {
      "contact_id": "3e1a6d4c-9f5b-4a03-9c8d-2b4f7e0a5c33",
      "has_company_domain": true, "fit_score": null, "opted_out": null, "days_since_contacted": null,
      "details": {
        "work_email": { "source": "campaign", "status": "found", "value_status": "Valid", "age_days": 9, "problem": "bounced" },
        "mobile_phone": { "source": "revops", "status": "found", "value_status": null, "age_days": 331, "problem": null }
      }
    },
    {
      "contact_id": "4f2b7e5d-0a6c-4b14-8d9e-3c5a8f1b6d44",
      "has_company_domain": true, "fit_score": 64, "opted_out": null, "days_since_contacted": null,
      "details": {
        "work_email": { "source": "campaign", "status": "none", "value_status": null, "age_days": 8, "problem": null },
        "mobile_phone": { "source": "campaign", "status": "failed", "value_status": null, "age_days": 8, "problem": null }
      }
    },
    {
      "contact_id": "5a3c8f6e-1b7d-4c25-9e0f-4d6b9a2c7e55",
      "has_company_domain": true, "fit_score": 70, "opted_out": null, "days_since_contacted": null,
      "details": {
        "work_email": { "source": "campaign", "status": "found", "value_status": "Unverifiable", "age_days": 8, "problem": null },
        "mobile_phone": { "source": "desk", "status": "found", "value_status": null, "age_days": 30, "problem": "bad_number" }
      }
    },
    {
      "contact_id": "6b4d9a7f-2c8e-4d36-8f1a-5e7c0b3d8f66",
      "has_company_domain": true, "fit_score": 77, "opted_out": null, "days_since_contacted": null,
      "details": {
        "work_email": { "source": "campaign", "status": "found", "value_status": "Valid", "age_days": 8, "problem": null },
        "mobile_phone": { "source": "campaign", "status": "waiting", "value_status": null, "age_days": null, "problem": null }
      }
    }
  ]
}

Answer:

{
  "decisions": [
    { "contact_id": "1c9e4b2a-7d3f-4e81-9a6b-0f2d5c8e3a11", "kind": "work_email", "action": "lookup", "reason": "known_elsewhere" },
    { "contact_id": "1c9e4b2a-7d3f-4e81-9a6b-0f2d5c8e3a11", "kind": "personal_email", "action": "skip", "reason": "work_email_pending" },
    { "contact_id": "1c9e4b2a-7d3f-4e81-9a6b-0f2d5c8e3a11", "kind": "mobile_phone", "action": "lookup", "reason": "new" },
    { "contact_id": "2d0f5c3b-8e4a-4f92-8b7c-1a3e6d9f4b22", "kind": "work_email", "action": "skip", "reason": "poor_fit" },
    { "contact_id": "2d0f5c3b-8e4a-4f92-8b7c-1a3e6d9f4b22", "kind": "personal_email", "action": "skip", "reason": "poor_fit" },
    { "contact_id": "2d0f5c3b-8e4a-4f92-8b7c-1a3e6d9f4b22", "kind": "mobile_phone", "action": "skip", "reason": "poor_fit" },
    { "contact_id": "3e1a6d4c-9f5b-4a03-9c8d-2b4f7e0a5c33", "kind": "work_email", "action": "reenrich", "reason": "bounced" },
    { "contact_id": "3e1a6d4c-9f5b-4a03-9c8d-2b4f7e0a5c33", "kind": "personal_email", "action": "skip", "reason": "work_email_pending" },
    { "contact_id": "3e1a6d4c-9f5b-4a03-9c8d-2b4f7e0a5c33", "kind": "mobile_phone", "action": "lookup", "reason": "stale" },
    { "contact_id": "4f2b7e5d-0a6c-4b14-8d9e-3c5a8f1b6d44", "kind": "work_email", "action": "skip", "reason": "searched_none" },
    { "contact_id": "4f2b7e5d-0a6c-4b14-8d9e-3c5a8f1b6d44", "kind": "personal_email", "action": "lookup", "reason": "fallback" },
    { "contact_id": "4f2b7e5d-0a6c-4b14-8d9e-3c5a8f1b6d44", "kind": "mobile_phone", "action": "lookup", "reason": "retry_failed" },
    { "contact_id": "5a3c8f6e-1b7d-4c25-9e0f-4d6b9a2c7e55", "kind": "work_email", "action": "keep", "reason": "unverifiable" },
    { "contact_id": "5a3c8f6e-1b7d-4c25-9e0f-4d6b9a2c7e55", "kind": "personal_email", "action": "lookup", "reason": "fallback" },
    { "contact_id": "5a3c8f6e-1b7d-4c25-9e0f-4d6b9a2c7e55", "kind": "mobile_phone", "action": "reenrich", "reason": "bad_number" },
    { "contact_id": "6b4d9a7f-2c8e-4d36-8f1a-5e7c0b3d8f66", "kind": "work_email", "action": "keep", "reason": "found" },
    { "contact_id": "6b4d9a7f-2c8e-4d36-8f1a-5e7c0b3d8f66", "kind": "personal_email", "action": "skip", "reason": "email_covered" },
    { "contact_id": "6b4d9a7f-2c8e-4d36-8f1a-5e7c0b3d8f66", "kind": "mobile_phone", "action": "skip", "reason": "waiting" }
  ],
  "notes": null
}

Before you answer

Check that every person has one decision for each chosen kind, that each reason fits its action and kind (the schema lists which), and that personal email was decided after work email.