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.
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,callor both, fromcampaign_ops_campaigns.channels.campaign.kinds: the kinds the person chose for this run, fromwork_email,personal_emailandmobile_phone. You decide only these kinds, and never add one.people: one entry per person in the batch:contact_id: theircampaign_ops_contacts.id. Echo it exactly.has_company_domain: whethercampaign_ops_contacts.company_domainis 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) orrevops(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_searchedorfailed, ascampaign_ops_enrichments.statusrecords them.deskandrevopsvalues are alwaysfound.value_status: an email'sValidorUnverifiable, or null (phones never carry one, and RevOps may not say).age_days: whole days since that answer, or null whilewaiting.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) orbad_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
| Rule | Value |
|---|---|
| Poor fit: never enrich a fit score under | 40 |
| Contacted recently: leave out anyone reached fewer than this many days ago | 30 |
| Stale: an answer older than this many days is asked for again | 90 |
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):
opted_outis true:skip,opted_out. This beats everything, re-enrichment included.fit_scoreis under 40:skip,poor_fit. A null score is not a poor fit.days_since_contactedis under 30:skip,contacted_recently. The send leaves them out, so a detail bought now has no use.
About the channel:
- The kind serves no channel of the campaign:
skip,no_channel. Work and personal email serveemail; mobile phone servescall. Say innoteswhich 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 decision | Personal email |
|---|---|
keep, found | skip, email_covered |
any lookup, any reenrich, or skip, waiting | skip, work_email_pending |
keep, unverifiable, or skip, searched_none or missing_company_domain | lookup, 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]):
statusiswaiting:skip,waiting. The answer is on its way.problemis set:reenrichwith the problem as the reason (bounced,wrong_personorbad_number). A work email cannot be re-enriched without a company domain (the service has nothing to search on), so whenhas_company_domainis false it isskip,missing_company_domain.statusisfailed:lookup,retry_failed. Every provider failed, so nothing was learned; it is never a "none".statusisnot_searched:skip,missing_name. The phone search needs a name; asking again without one searches nothing.statusisfoundornoneandage_daysis over 90:lookup,stale.statusisfoundandsourceiscampaign:keep, withunverifiablewhenvalue_statusisUnverifiable, otherwisefound. An unverifiable email is kept, never rejected: it may be right, and the send holds it back.statusisfoundandsourceisdeskorrevops:lookup,known_elsewhere. The service answers from its own cache at no charge and gives this campaign its own checked answer.statusisnone:skip,searched_none. Asked and answered: nobody has one.- Nothing held:
lookup,new.
Your answer
decisions: one entry per person and chosen kind, each withcontact_id,kind,actionandreason. 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.