---
name: moltsets-accounts-to-contacts
description: Use this skill when the user has a list of target companies and wants to find contacts at those companies.
---

# Accounts to Contacts

Given a list of companies, return ranked contacts at each one - with title, seniority, department, and contact data included - and optionally enrich.

## Triggers

"find contacts at these companies", "I have a list of accounts, find me contacts", "turn this company list into a contact list", "prospect into these accounts", "who works at these companies". Input is a list of companies; output is a unified people list ready for enrichment or outreach.

## Chain

```
company list
  to search_people  (per company, looped)
  to deduplicate
  to linkedin_to_business_email     (optional)
  to linkedin_to_mobile_phone              (optional)
```

## Set Expectations

Before firing any calls, give the user a brief, dry heads-up. Deadpan over enthusiastic - no filler, no corporate speak.

Facts to work with:
- One search call per company, sequential - no batch search API
- N companies × ~2–4 seconds = search phase estimate
- Enrichment (if requested) adds one batch call after all searches complete
- For 10+ companies, flag that this takes a while and you'll report progress

## Risk scores - confirm before enriching

Every email MoltSets returns carries a deliverability risk score - a grade from A to F. Ask which grades to keep before running anything:

> "MoltSets grades email deliverability risk from A (best) to F (no data). Which grades should I keep? I'd recommend **A, B, C and F** - and dropping **D**, since D is a confirmed bounce, complaint or spam trap."

| Grade | Meaning | Best practice |
| --- | --- | --- |
| A | Strongest engagement signal available | Safe to send immediately |
| B | Solid | Fine in regular sends; on a new or warming domain, send a smaller batch before scaling |
| C | Deliverability can't be confirmed | Segment separately from A/B, warm at low volume, watch engagement, suppress non-responders after 1-2 attempts |
| D | Confirmed bounce, complaint or spam trap | Never send - fastest way to trip spam traps and damage sender reputation for the whole list |
| F | No data - unknown risk, not "safe" | Re-verify before sending, or treat like C |

Use whatever set the user confirms. Treat the grade as a filter, not a guarantee - it lowers risk, it doesn't remove it. Pair it with the user's own sending domain reputation and list hygiene.

## Step 0 - Input Quality Gate

**Company list** - at least 1 company with a name (domain optional but helps precision). If missing, stop:
> "Paste or upload your company list. I need at least company names - domains help precision."

### Notes
- `search_people` accepts both company and people filters: `company` (name), `company_domain` (exact), `country`, `state`, `city`, `seniority`, `department`/`functional_area`, `industry`, `linkedin_industry`, `naics_code`, `employee_range`, `revenue_range`, `query` (free-text name/role), plus `limit`/`offset`
- You're already scoping to specific companies, so industry is usually unnecessary - but you CAN add `seniority`/`department`/`country` to pull only a role slice at each account (use enum values)

## `search_people` filters and best practice

Full request parameter set:

| Purpose | Param | Notes |
|---|---|---|
| Free text | `query` | Searches `full_name`, `first_name`, `last_name`, company name, `title` (~85% filled), `headline` (~65% filled). Names and role keywords only |
| Company name | `company` | Analysed match - every word you pass must appear in the stored name |
| Company domain | `company_domain` | Exact; full URLs are normalised automatically |
| Country | `country` | ~99% filled - the most reliable filter available |
| State / region | `state` | The person's location. Full names - "Texas", not "TX" |
| City | `city` | The exact stored city, **not** its suburbs. Pair with `state`/`country` to disambiguate repeated names |
| Seniority | `seniority` | ~60% filled. Intern, Entry, Senior, Manager, Director, VP, Head, C Suite, Owner, Partner |
| Department | `department` | ~60% filled. Shares its data with `functional_area` - use one |
| Functional area | `functional_area` | Same underlying data as `department` - never send both |
| Industry (broad) | `industry` | ~60% filled. 21 broad buckets |
| Industry (niche) | `linkedin_industry` | ~50% filled. LinkedIn's own ~150 labels - "Staffing and Recruiting", "Wellness and Fitness Services" |
| Industry (code) | `naics_code` | ~50% filled. Any hierarchy level - "54" is broad, "541120" is surgical |
| Employer size | `employee_range` | Filters the person's **current employer** - no company lookup needed first |
| Employer revenue | `revenue_range` | Same - applied to the person's current employer |
| Paging | `limit` / `offset` | 10 default, 25 max (5 on the Free plan) |

**Filters are free precision.** Execution cost is driven by the free-text `query`, not by the filters attached to it.

| Query shape | Relative cost to execute |
|---|---|
| `query` alone | baseline |
| `query` + any number of filters | the same - filters add nothing |
| filters only, no `query` | **~40x cheaper** |

- **Never send a bare `query`.** Attaching `country` costs nothing and removes every other market. A query-only call is the least precise shape available and no cheaper than a filtered one.
- **Drop `query` entirely when filters carry the whole intent.** "VPs of sales at US software companies" is fully expressible as `seniority` + `department` + `industry` + `country`, and runs ~40x cheaper as a pure filter search.
- Billing doesn't change either way - tokens are charged per record returned. This is about speed and search load.
- If all you have is free text, add `country`: highest fill rate of any filter, so it discards almost no legitimate match.

**Don't over-stack sparse filters.** Every non-`query` filter is an exact match and silently discards records where that field is empty. Three ~60%-filled filters can cut a viable audience to nothing. When a search comes back thin, drop the sparsest filter first (`naics_code`, `linkedin_industry`) or shorten the NAICS code - not `country`.

**Exact strings matter.** `"C Suite"` has a space, not a hyphen. `"Marketing & Advertising"` and `"Medical & Health"` use ampersands. `"5001+"` and `"$500k - $1M"` must match character for character. `"UK"` and `"HK"` exist as dirty values - prefer `"United Kingdom"` and `"Hong Kong"`.

**Target by company size without naming a company.** `employee_range` and `revenue_range` apply to the person's current employer, so a `search_companies` pass first is usually unnecessary:

```json
{ "seniority": "VP", "employee_range": "51-200", "country": "United States" }
```

Prefer the numeric bands over the legacy `"Small"` / `"Mid-Market"` / `"Enterprise"` / `"Unknown"` values, which carry far thinner coverage.

**Size the search before you walk it.** `search_linkedin_profile` accepts `count_only: true`, which returns the match count without fetching data or charging tokens. Every search response also carries `results.total` - read it before paginating, then increment `offset` by `limit`. Results are `_score`-ranked, so the first page is the best page.

**Emails come back in the search.** Records already include `business_email` and `business_email_risk_score` - for those rows, no follow-up enrichment call is needed.

## Step 1 - Parse company list

Accept:
- Line-separated paste (name only, or name + domain)
- CSV (extract company name and domain columns)
- Typed list

Normalise into rows: `Company Name | Domain (if provided)`

Flag any rows with unresolvable names (very ambiguous, no domain). Report: "Parsed X companies."

## Step 2 - Confirm target count

Confirm with user before running:

> "I'll pull contacts at each of X companies. How many contacts per company? (Default: 3, max 25 per call)"

Default to 3 per company unless specified. Wait for confirmation.

## Step 3 - Credit check (required)

Call `MoltSets:get_billing` and `MoltSets:get_usage` with `period: billing_cycle` in parallel (free).

Estimate (conservative - actual results vary):
```
X companies × ~3 results      = ~X prospects
X prospects × linkedin_to_business_email = email cost  (if requested)
X prospects × linkedin_to_mobile_phone    = phone cost  (if requested)
Total:                         = ~XXX credits
Current balance:               = XXX credits
```

If estimated cost > balance: stop. Report shortfall, how many companies the balance covers, ask to proceed partially or top up.

## Step 4 - Probe (first company)

Before looping all companies, run `search_people` with `limit: 1` for the first company:

- `company_domain`: the company's domain if known (most precise), or `company`/`query`: the company name as free text
- Optionally narrow with `seniority`, `department`/`functional_area`, `country` to target a role slice (use enum values)
- Title also comes back as a response field (`title`, `seniority`, `department`, `country`, etc.) for ranking

Check result quality:
- Good match to proceed with full loop
- Poor match (wrong company) to adjust `company_domain`/`company`/`query` before scaling

## Step 5 - Loop all companies

For each company:
1. Run `search_people`:
   - `company_domain` (if known) or `company`/`query`: the company name
   - optionally `seniority`, `department`/`functional_area`, `country` for a role slice
   - `limit`: target per company (default 10, max 25)
2. If results < target and user wants more: paginate with `offset`
3. Tag each result with source company for traceability

Process sequentially - one search call per company, no batch search API. Do not pause per company - flag any zero-result companies in final output.

**Note:** `company_domain` is the most precise scope; `company`/`query` (company name) is a softer signal and results may include people who only mention the company in their headline.

## Step 6 - Deduplicate

Remove duplicate LinkedIn URLs across all companies. If a person appears at multiple companies (e.g. consulting multiple clients), keep the entry with the stronger company match and note the duplicate.

Report: "Found X unique contacts across Y companies."

## Step 7 - Optional enrichment

Business email is often already present on the search records - check first and only enrich the gaps. If user wants emails (for the gaps) or phones: collect the LinkedIn URLs that still need data to run enrichment in batch.

Credit confirm first: "Found X contacts. Enriching [email/phone/both] = ~XXX credits. Proceed?"

- `linkedin_to_business_email` - SINGLE `linkedin_url` per call; fire one per URL in parallel (no email batch array)
- `linkedin_to_mobile_phone` - `linkedin_urls` array (batch, max 100)

Fire the per-URL email calls in parallel; chunk the phone array at 100 if needed.

## Step 8 - Output

| Company | Name | Title | Seniority | Department | Country | LinkedIn | Business Email | Risk | Mobile |
|---|---|---|---|---|---|---|---|---|---|

Omit email/risk/mobile columns if enrichment not requested.

Show the risk score on every row that has an email, including grades outside the accepted set - flag those rather than dropping them silently, so the user can see what was filtered and why.

**Summary:**
```
Companies searched:   X
Contacts found:       X  (avg X per company)
Zero-result accounts: X  (listed below)
Business emails:      X/N  (XX%)  - if enriched
Mobile numbers:       X/N  (XX%)  - if enriched
Credits used:         ~XXX

Email risk scores:
  A: X   B: X   C: X   D: X   F: X
  kept (accepted grades):  X
  filtered (excluded):     X
```

List zero-result companies separately so user can investigate.

Offer to export as CSV-style table.

## Edge cases

- Zero results for a company to mark "No results" in summary, do not stop loop
- Company name ambiguous (e.g. "Apple" could be many things) to note in output, results may include false matches
- Large list (20+ companies) to set expectation it will take time; report progress periodically
- Credits run out mid-loop to stop, output completed rows, report exact resume point (which company index)
- Max 25 results per `search_people` call - loop with `offset` if user wants more per company
