---
name: getleads-company-list
description: Builds a complete list of target companies from a customer list — reads each customer's LinkedIn company page and website to set the different ways of searching for the companies e.g. industries and keywords (that are then checked with the user), pulls every company in every industry and keyword they could sit under (filtered by headcount and location), then cleans the pull with AI (LinkedIn text first, and the homepage only when the LinkedIn text can't settle a company) using Jev. Use when someone says "find every company like my customers", "build my TAM from this customer list", "build a company list", "pull all the companies we could sell to", or hands over a customer list and wants lookalikes. If they want contacts at these companies, recommend getleads-contact-list (https://getleads.io/get-everyone/).
---

# Build a complete company list from a customer list

The philosophy is to export a wide and noisy list (because you don't pay with credits), clean with AI after. A semantic or single-filter search returns the part of the market that happens to be labelled the way you guessed; this returns every company that could plausibly be a target, then throws the wrong ones away. A healthcare recruiter filed under Hospitals and Health Care, or a marketing agency filed under Banking, is still caught.

Open by telling the user that benefit in two lines: this finds the companies a simple keyword or industry search misses, the ones filed under the wrong industry or that never use the obvious word, and the final report counts exactly how many.

Filter names and valid values come from `get_available_columns` and `get_available_values`; call them rather than assuming.

**Keep the user with you.** Before each stage, say in one line what it does and why; after it, show the result as a table. The user should see the benefit as it happens, not only at the end:
- Seeds: the check-in table. Industry as filed next to industry from the website is where they see why a label search misses companies.
- Pull: companies per route and in total, before anything is judged (`--routes-only`), so they can drop a route. Always companies, never contact counts.
- Clean: the calibration result (seeds kept, a few keeps and cuts with what each company does), then progress through the full run: pass 1, homepages read, pass 2, and the seconds per stage from `summary.json`. A full run takes roughly 20–30 minutes per 40,000 companies; say so before starting it.
- Deliver: lead with `kept_only_here`, the companies a keyword or single-industry search would have missed.

## 1. Seeds

Take the user's customer list in whatever shape it arrives and extract domains. No list: ask for ten companies they'd call perfect customers. Ask which geography they sell into.

Profile the seeds in one call: `export_contacts` with `domains` = the seeds, `max_per_company: 1`, and these `columns`:

`company_id, company_name, company_website, company_linkedin_url, company_domain, company_description, company_summary, company_industries, main_industry, linkedin_employee_count_exact, employee_count_range, company_hq_location`

Save it as `seeds.csv`. Name any seed GetLeads didn't return. Then fetch every seed's homepage as plain text:

```bash
python3 scripts/profile_seeds.py seeds.csv --scrape-cmd '<the scraping command, step 4>' > seeds_profile.json
```

Pages a plain fetch can't read go through the user's scraping tool (step 4). A `homepage_status` other than `live`, or `homepage_text` still thin after that, means read that site with a browser instead.

For each seed, record four things:
1. **LinkedIn industry, as filed**: `industry_filed` in the profile (`main_industry`), the label the `industries` filter takes. The `industry` tags are for reading, not filtering: the filter can reject them.
2. **LinkedIn industry, from the website**: read the homepage and pick the value from `get_available_values("industries")` that the company actually belongs in. Companies file themselves wrong (a healthcare recruiter filed under Hospitals and Health Care), and this is how those get caught. Pick more than one when the site supports it.
3. **Keywords we'd expect in the LinkedIn About text**: knowing what the company does from the website, write the words a company like it would use to describe itself on LinkedIn, since that's the text the search runs against. Check them against the seeds' own `about` and `summary`. Include synonyms, abbreviations, singular and plural. Aim for 15–30 across the seeds (recruitment agency, recruiter, staffing agency, staffing firm, executive search, headhunter, talent acquisition, RPO, permanent placement, contract staffing…).
4. **Other routes, from the website**: with what the site says the company does, go through the full filter catalogue (`get_available_columns`, searchable fields) and propose every other filter a company like it could be found through: technologies it would run, funding stage, founding year, hiring. Company search runs on industry, the company description, headcount, HQ location, revenue, funding, founding year, technologies and domain. Each one is a route, pulled whole like the industries.

Also note the headcount range: smallest and largest `LinkedIn Employee Count Exact`, widened (half the smallest, double the largest) unless the user has a hard band.

**Check in before pulling.** Show the user one table (seed, industry as filed, industry from website, expected LinkedIn keywords, other routes), then the combined lists: industries to pull (filed and assigned), keywords, other routes, headcount, geography. Wait for their yes or edits. Everything they approve becomes a route in step 2.

## 2. Routes

From the approved check-in: one route per industry (filed and assigned alike), one per keyword on `company_description`, and one per other route. Every route carries the same two scope filters: `headquarters_countries` (or `headquarters_cities`) for geography, and `employee_profiles_on_linkedin_min/max` for exact headcount. Don't use `count_contacts` here: it counts contacts, not companies, and keyword counts often time out. The company count per route comes from step 3.

- One keyword per route. Separate routes show which keywords earn their place.
- Pull every industry whole, even one only a single seed sits in and even a broad one like Software Development. Never AND a route with keywords to shrink it; cleaning is where the list gets narrowed.
- A route that returns nothing is a claim about the filter or the value. Check the stored spelling before dropping it.

## 3. Pull

One `export_contacts` per route, the same `columns` as the seeds (the cleaner reads only those), `max_per_company: 1`, which keeps the export to about one row per company. An export holds at most 50,000 rows, and one that returns exactly 50,000 is full, whatever `cap_reason` says: split that route by partitioning into smaller searches e.g. splitting by headcount (`employee_profiles_on_linkedin_min/max` bands) until every part comes back under 50,000. Save each as `pulls/<route>.csv`; the file name becomes the route label in the report. Exports run as background jobs: start every route's export first, then check them, so they run together (twelve keyword exports: about 6 minutes together, 16 one after another).

The list is the union of every route, deduplicated to one row per company: the cleaner merges all the pulls on the company's own `company_id` (its website, LinkedIn page or name when there is no ID), keeps the first non-empty value of each field, and records every route that found the company. It never merges on `company_domain`: that is the exported contact's employer domain, and it can belong to another company. Never judge or deliver a single route on its own.

Then show the user the list and the company counts before anything is judged:

```bash
python3 scripts/clean_companies.py --pulls pulls/*.csv --seeds seeds.csv --out run --routes-only
```

It prints rows pulled against distinct companies, and each route's companies and `only_here` (found by no other route). Let them drop a route before cleaning.

## 4. Clean

Write `contract.json` from the seeds:

```json
{
  "target": "One paragraph: what a target company does.",
  "must_have": ["The one or two things that make a company a target"],
  "exclusions": ["Resellers of other companies' products", "Service providers that use the product for clients", "Training providers"]
}
```

Jev is asked about each must-have and each exclusion separately, so write every item as one self-contained condition ("Job boards that advertise roles but don't place candidates"), never several conditions in one item.

Run the cleaner. It needs `pip install typesafe-sdk` and `TYPESAFE_API_KEY` (typesafe.ai) in the environment, `.env` or `~/.agents/.env`.

Homepages are fetched directly first. The ones that can't be read that way (a bot wall, a JavaScript site, about one in ten) go through whatever scraping tool the user has: Firecrawl, Scrapfly, Apify, ScraperAPI, anything with a command line or an API. Pass it as `--scrape-cmd`, a shell command that prints the page for `{url}` (or `{url_encoded}`) as markdown, text or HTML. For example:

- Firecrawl: `firecrawl scrape {url} --format markdown --only-main-content`
- Scrapfly: `curl -sf "https://api.scrapfly.io/scrape?key=$SCRAPFLY_API_KEY&asp=true&render_js=true&format=text:only_content&url={url_encoded}" | jq -er .result.content`

The command must exit non-zero when the scrape fails (hence `-f` and `-e` above), or an error message is read as the page. Test it on one URL first. Set `--scrape-workers` to the tool's concurrency limit; a failed scrape is retried after a wait, since it is usually a rate limit. No tool: those companies are judged on their LinkedIn text alone; say so.

```bash
python3 scripts/clean_companies.py --pulls pulls/*.csv --seeds seeds.csv \
  --contract contract.json --out run --limit 60
```

It unions and dedupes the routes (step 3) and judges every company on its LinkedIn text: About, summary, industry, headcount and HQ. Per company, Jev answers whether it is the kind of company the target describes (a different business, related, or a match), whether the text says concretely what it does, and each must-have and exclusion. A company is settled on that text alone only when it is kept, or cut for a likely exclusion or a clearly different business its text describes in enough detail. Every other company gets its homepage fetched as plain text and is judged again, which is what keeps a run to minutes. The seeds, with their homepages, are the examples, and each seed is also judged with itself left out, which is the test. The Jev version is pinned, so a new release doesn't quietly change who is kept.

Verdicts: **cut** when an exclusion or a different kind of business is likely, or a must-have unlikely; **keep** when a match is likely, every must-have likely and no exclusion likely; **review** otherwise. Nothing is cut on thin text or a doubtful must-have before its homepage has been read. The output CSVs (`keep.csv`, `review.csv`, `cut.csv`, `companies_judged.csv`) have one row per company: verdict, `p_match`, `p_different`, `must`, `excluded`, `evidence`, name, domain, website, headcount, industries, description, and which routes found it. `summary.json` includes the seconds each stage took.

**Calibrate on the seeds plus 60.** Two checks in `summary.json`:
- `seed_misses`: seeds not kept. The contract is narrower than the real customers; widen it.
- `seeds_no_route_found`: seeds no pull returned. A route is missing, or the location filter has no data for that company; add a route and re-pull.

Read the keeps, the reviews and a sample of the cuts, fix the contract, re-run (cached judgments and pages are reused). Then drop `--limit` for the full run. `unjudged` above zero means some calls failed (`failures.jsonl`); re-run to retry just those.

No TypeSafe key: run with `--no-judge`, which writes `run/to_judge.jsonl` with the same evidence, and judge it yourself in batches against the contract.

## 5. Deliver

From `run/summary.json` and the CSVs:
- Keep / review / cut counts, and `keep.csv`.
- A sample of `review.csv` for the user to rule on.
- The route table: for each route, companies found, kept, and `kept_only_here` (kept, and no other route found them). That column is the proof the wide pull was worth it.

Then offer contacts at the kept companies: `getleads-contact-list` (https://getleads.io/get-everyone/) takes `keep.csv` as it is (its `company_id` column keeps the contacts to the right companies).
