---
name: icp-research
description: Extract a company's ideal customer profile (ICP) from public sources with high accuracy, using a two-tool sequence — Exa to locate the page that actually states the ICP, then Firecrawl to read that page properly — followed by a two-level pass that harvests the client logo wall and resolves each named client to a real headcount, so company size is observed rather than guessed from a price. Use this whenever the task involves working out who a company sells to, who a creator's or prospect's buyers are, what a company charges, what size or type of client they take on, who they say no to, or scoring a person's fit against a company's ICP. Use it before any ICP-fit scoring, lead qualification, account tiering, competitive positioning work, or persona build, and whenever a workflow needs `what_they_sell`, `price_band`, `buyer_headcount`, `buyer_seniority`, or an anti-ICP. Use it even when the request sounds like a simple "just look at their website" or "check what X does" — a root-domain read is the single most common way this gets silently wrong. Prefer this over the Exa-only `company-research` skill when the answer must be accurate enough to spend money or make a decision on.
---

# ICP research — Exa finds the page, Firecrawl reads it

## Onboarding — start here

**What this does.** Turns a company name, a person's name, or a domain into a
structured ICP record: what they sell, to which buyers, at what size, in which
geography, at what price, and who they explicitly refuse. Output is a fixed
closed-vocabulary JSON object, so ICPs from different companies are comparable
to each other rather than 40 improvised free-text definitions.

**What it does not do, and where to go instead.**

- It does not enrich *people* (emails, phones, job history). Use Blitz or Clay.
- It does not return firmographics for a company you already understand
  (headcount, revenue, HQ) — that is a database lookup, not a website read.
  Use Blitz (scriptable) or the Clay MCP (interactive). This skill exists for
  the axes a database cannot give you: *what they actually sell* and *who they
  say no to*.
- It does not do market sizing, competitor landscapes, or news monitoring. Use
  `company-research` (Exa-only, cheaper, looser) for that.
- It does not verify that a *person* still works somewhere. That is the
  employment-verification check — company URL, LinkedIn experience, last 5 posts.

## ⚠️ Prerequisites — this skill does NOT work without both of these

**You need two MCP servers connected: Exa and Firecrawl.** They are not
optional and they are not interchangeable — the whole method is Exa finding the
page and Firecrawl reading it. With only one connected, stop and say so rather
than working around it; a Firecrawl-only run is the exact failure this skill was
built to prevent.

| Server | What it does here | Get a key |
|---|---|---|
| **Exa** | Finds *which* page states the ICP | https://dashboard.exa.ai/api-keys |
| **Firecrawl** | Reads that page in full | https://firecrawl.dev/app/api-keys |

**If they are already connected** (they are, in this repo — see
`.cursor/mcp.json`), skip to Verify.

**If you are setting this up fresh**, add both to your MCP config. In Claude
Code:

```bash
claude mcp add exa --transport http https://mcp.exa.ai/mcp
```

```bash
claude mcp add firecrawl --env FIRECRAWL_API_KEY=fc-your-key-here -- npx -y firecrawl-mcp
```

Then put the keys in `local.env` (gitignored) so scripts can read them too:

```bash
cp .env.example local.env && $EDITOR local.env
```

Load them into a shell with:

```bash
set -a && . ./local.env && set +a
```

Never hardcode a key in a script or in this skill — read it from the
environment and fail loudly if unset.

**Verify.** Run one Exa search and one Firecrawl scrape before spending any
real effort:

```
exa web_search_exa       query: "Orainti SEO consultancy who they work with"
firecrawl firecrawl_scrape   url: "https://www.orainti.com/"
```

If the Exa call returns ranked results and the Firecrawl call returns markdown,
you are ready. If either errors on auth, the key is missing or wrong — fix that
first. (Tool names appear in your client with a server prefix that varies by
setup; match on the `web_search_exa` and `firecrawl_scrape` suffixes.)

**Cost.** About **$0.01 per company**: 1–2 Exa searches plus 1–2 Firecrawl
scrapes. A Firecrawl markdown scrape is 1 credit; a `formats:["json"]`
extraction is 5. Forty companies costs well under a dollar. This is cheap
enough that there is no reason to skip the search step to save money — and
skipping it is exactly what produces wrong answers.

## How to use it

**Just ask in plain language** — the skill triggers on intent, you do not need
to name it:

- "Get me the ICP for Orainti"
- "Who does Jordan Crawford actually sell to, and at what price?"
- "Build ICP records for these 20 creators" *(paste or point at a list)*
- "Does this lead fit ColdIQ's ICP?" *(scoring a person against a company)*

**To force it explicitly:** `/icp-research <company or person>`

**What you get back:** one JSON object per company, using the closed vocabulary
in `references/icp-vocabulary.json` — `what_they_sell`, `buyer_industries`,
`buyer_seniority`, `buyer_headcount`, `buyer_geography`, `price_band`,
`anti_icp`, plus a `confidence` score and an `evidence` line naming the exact
sentence and page the ICP came from. Because every company answers the same
closed set of questions, the records are comparable to each other instead of
being 40 improvised free-text descriptions.

**Read the `confidence` field before you act on a record.** Below 0.5 means the
source was thin and the answer is a guess — that is the skill working
correctly, not failing. Re-run with a better source or mark the row unresolved
rather than spending enrichment credits on it.

**At volume,** read `references/failure-modes.md` first. Every rule in the
sequence below exists because of a specific way this went wrong in production.

**Companion skill.** `ad-library-recon` reads the ad libraries in a browser for declared targeting — use it when Steps 3 and 3b leave the ICP thin.

**Rest of the skill.** The closed vocabulary is in
`references/icp-vocabulary.json`. The observed failure modes, with real
examples, are in `references/failure-modes.md` — read that one before running
this at any volume, because every rule in the sequence below exists because of
a specific way this went wrong in production.

---

## Why the sequence is Exa first, then Firecrawl

Each tool is good at one half of the job and bad at the other.

**Exa is a semantic index.** Give it a question and it finds pages that answer
it, including pages on subdomains, deep pages the homepage never links to, and
third-party sources like partner directories that state a price the company
itself won't publish. What it returns are *highlights* — fragments, ranked by
relevance, not the full page. Good at finding. Weak at reading.

**Firecrawl is a reader.** Point it at a URL and you get the whole page,
faithfully. But it has no idea which URL matters, and the obvious guess — the
root domain — is frequently the wrong page. Companies pivot their homepage to
their newest product while the business that pays the bills lives at
`/agency` or `/services`. Good at reading. Cannot find.

Run them in the wrong order, or run either alone, and you get a confident,
well-formatted, wrong answer. The order matters more than the tools do.

---

## The sequence

### Step 0 — Confirm the company is actually this person's, before reading a page

Only when the ICP is being extracted *for a person* — a creator, a prospect, a
lead. Skip when the subject is the company itself.

**This is the most expensive error in the skill and the least detectable.** If the
person → company mapping is wrong, every step below executes perfectly and produces
a complete, internally consistent, maximally confident ICP record for the wrong
business. Quote coverage passes. Validation passes. Nothing downstream can tell.

Observed 2026-08-14: a creator was mapped to a company name before any page was
fetched. The resolver found that company's real site, read it correctly, and
emitted a `confidence 1.0` record. The person had never worked there — and his
actual employer was the single most frequent brand word in his own posts.

Three checks, cheapest first:

1. **Does the company name appear in the person's own writing?** Their posts,
   newsletter, bio. Someone who works somewhere mentions it. Match on word
   boundaries and tokenise the company name on whitespace, or short names and
   two-word names produce false positives.
2. **Read the current position from their LinkedIn experience.** A missing end date
   does not mean current — people leave old roles open for years.
3. **Check their recent posts for a move.** *"Excited to share I've joined X."* The
   profile is stale data; the posts are fresh data, and the post wins.

Watch for a **fabricated** company as well as a wrong one: `"<Person Name>
Projects"`, `"<Person Name> advisory"` are what gets invented when no real employer
was known. There is no entity whose website states an ICP, so anything extracted
will be assembled from a personal site and is not an ICP. Mark it and stop.

### Step 1 — Exa: find the ICP-bearing page

Search for the *question*, not the company. Exa ranks on semantic similarity to
your query, so a query shaped like the answer you want outperforms a name.

```
Weak:   "ColdIQ"
Strong: "ColdIQ — who they work with, their ideal client profile,
         minimum company size and pricing"
```

You are hunting for any of these, in rough order of value:

1. A stated qualification gate — "for companies above $100k/mo", "post-Series A"
2. A pricing page or a verbatim price anywhere
3. A "who we work with" / "who this is for" / FAQ section
4. An anti-ICP sentence — "if you're still validating PMF, this isn't for you"
5. Named client logos and testimonial job titles (a size signal in disguise)

**Confirm the company identity before going further.** Exa's top result is not
guaranteed to be the right entity, especially for abbreviations and short names.
Check that the industry, headcount and founding year in the result are
consistent with what you already know about the person or company. If the top
result and the second result describe different businesses, you have an
ambiguity, not an answer.

### Step 2 — Pick the page that describes the *main* offer

Many companies run more than one business: an agency plus a course, a service
plus a product, an enterprise programme plus a €99/mo self-serve tier. These
have genuinely different ICPs and different price points.

Choose the one that is the company's primary revenue line, not the one that is
easiest to scrape or happens to have a number on it. **A page having a price on
it does not make it the right page** — a self-serve tier's price is often the
only public price precisely because the real offer is sold on calls.

If two offers are close in importance, say so and produce two records rather
than averaging them into one incoherent ICP. An averaged ICP scores everyone
as a partial match and nobody as a real one.

### Step 3 — Firecrawl: read that page

Scrape the specific URL Exa surfaced, not the root domain.

For a single company where you want to see the evidence yourself, take
`formats:["markdown"]` with `onlyMainContent: true` and extract by reading.

For volume, take `formats:["json"]` with the schema from
`references/icp-vocabulary.json`. Include this instruction in `jsonOptions.prompt`
verbatim, because without it the extractor fills gaps with plausible inventions:

> Use ONLY the closed vocabularies given. If the page does not state something,
> use "unstated" or an empty array and LOWER the confidence — never guess.
> `price_signal` MUST be verbatim from the page or an empty string; if
> `price_signal` is empty, `price_band` MUST be "unstated".

That last clause is not redundant. Without it, extractors return an empty
`price_signal` and a specific `price_band` in the same object — a number with no
source, which looks like data and is not.

### Step 3b — Second level: harvest the client roster, then resolve it

This is the highest-value step in the skill and the one most often skipped,
because it requires a second pass rather than a single scrape.

A stated price is a *proxy* for how big the clients are. The client logo wall is
those clients, observed directly. Skipping the proxy is both cheaper and more
defensible — and it works on the many service businesses that publish no price
at all, which is where the single-pass version fails hardest.

**Level 1 — extract the roster with its section label.** In the same Firecrawl
call, pull every named company along with the heading of the section it sits
under. Logo alt-text carries the name reliably (`![Cabify logo]`), so a markdown
scrape is enough; you do not need image recognition.

```
client_roster: [ { name, section_heading, evidence_type } ]
evidence_type: "testimonial-with-title" | "case-study" | "logo-only"
```

The section heading is load-bearing. Marketing sites routinely place a client
logo row and a *partner* or *tech stack* logo row on the same page, in the same
visual treatment. Harvest indiscriminately and you will conclude the agency
sells to HubSpot and Clay, when those are tools it buys. Read the heading, and
when a heading is ambiguous, drop the row rather than guessing — a false client
is far more damaging than a missing one, because it silently widens the ICP.

Two extraction rules, both learned from a run that got them wrong:

*Separate the person from the company.* Testimonial blocks give a name and a
title, and the employer is frequently inside the title string —
`SVP of Revenue @ Plobal Apps`, `CMO & Co-Founder @ Siena AI`. The roster entry
is **Plobal Apps**, not the person. Emit `person`, `buyer_title` and `company`
as three separate fields; when the title carries no employer, `company` is null
and the row contributes buyer-seniority evidence only. A first run without this
rule returned twelve human beings as client companies.

*Read headings semantically, and force a call.* `110+ B2B teams compound their
revenue with Growth Today` is a client heading; a cautious classifier marked it
`ambiguous` and dropped a genuine roster. Meanwhile the actual partner row was
omitted from the output entirely rather than labelled `partner-or-toolstack` —
filtering by omission is not filtering, it just fails silently on the next site.
Require a label for every extracted row, and give the extractor the pattern:
a heading counting customers ("N+ teams", "trusted by", "who we've helped") is
clients; a heading naming tools, integrations, certifications or partners is
tool-stack, *even when the logos sit in identical visual treatment*.

*Sanity-check the roster before spending on it.* Sites carry joke and placeholder
testimonials — Growth Today's page contains one from "Rick Astley, Famous
Artist". Anything that fails to resolve to a real company is a filter hit, not
an enrichment failure, and should be dropped before level 2 rather than after.

**Level 2 — resolve each client to a size.** Take the deduplicated roster and
look up headcount and revenue band per company. Use a firmographic API for this
(Blitz if you need it scriptable, Clay for one-off checks); a website read is
the wrong tool for a number a database already holds. Then take the
**distribution**, not the mean — the spread between the smallest and largest
client tells you whether they serve one tier or span several, which a single
number erases.

**Weight the evidence, because the wall is biased.** Agencies display their
largest and most recognisable names, not their median client. Treat the roster
as an honest read on the *top* of their range and an optimistic one on the
middle. Correct for it by weighting:

| Evidence | Weight | Why |
|---|---|---|
| Testimonial with a named person and job title | highest | A real engagement someone signed their name to; the title also gives buyer seniority for free |
| Written case study | high | Substantial enough that the client approved it |
| Bare logo | low | May be a single pilot from three years ago, or a logo used without a live relationship |

Record `observed_client_sizes` alongside `price_band` and treat it as the
primary company-size axis. `price_band` drops to a tiebreak. That reordering is
what makes an unstated price stop being a blocking gap.

### Step 3c — Ad libraries: who they *target*, not who they *won*

The roster has an honest limit. It shows clients a company **won and is proud
of** — the largest and best-known, filtered twice by survivorship and by
marketing. It cannot tell you who they pursued and missed, and it cannot tell
you who they deliberately exclude. For a genuine anti-ICP, and for the middle of
the range the logo wall hides, the only public source of *declared* targeting is
advertising.

Ad libraries are public by law or by platform policy and are not curated by the
advertiser:

- **LinkedIn Ad Library** — the highest-value one for B2B. Shows the ads a
  company is running, and for EU-served ads, the targeting parameters including
  job function, seniority and company size. That is the ICP stated by the
  advertiser, in the advertiser's own words, with money behind it.
- **Meta Ad Library** — broad coverage, weaker B2B targeting disclosure, but
  it holds creative history going back years.
- **Google Ads Transparency Center** — shows which ads ran and where.

Treat this as complementary, not redundant. The roster is *revealed* preference
(who actually bought); ad targeting is *stated* preference (who they want). Where
the two disagree, that gap is one of the more interesting things you can learn
about a company — an agency advertising to enterprise while every logo on its
site is a 20-person startup is telling you it is trying to move upmarket and
hasn't yet.

Practical note: these libraries are JavaScript applications behind search forms,
so a plain scrape will not reach them. Use Firecrawl's `actions` to search and
wait, or a browser session. Budget more effort per company than a page read, and
run it only for the creators whose ICP is still weak after Steps 3 and 3b — it
does not need to run on all 36.

### Step 4 — Read the two sources against each other

You now have Exa's fragments and Firecrawl's full page. Where they disagree,
the disagreement is usually the finding, not noise:

- A LinkedIn company blurb saying "agency, done-for-you" against a personal site
  saying "coaching and workshops" means the business changed. **The more
  recently updated source wins**, and the site is usually more current than the
  LinkedIn company page.
- A third-party directory stating a price the company's own site omits is
  legitimate evidence. Record it with its source.
- A second business surfaced by Exa that the site never mentions is a real
  finding about that person, and it belongs in the record.

### Step 5 — Set confidence from evidence, not from feeling

Do not ask a model how confident it is; it will say 0.5–0.6 regardless, which
is above most exclusion thresholds and therefore worse than useless. Compute it:

| Evidence found | Contribution |
|---|---|
| Verbatim qualification gate (revenue, size, stage) | +0.3 |
| Verbatim price anywhere | +0.2 |
| Explicit "who this is for" statement | +0.2 |
| Explicit anti-ICP statement | +0.15 |
| Named clients or testimonial job titles | +0.15 |
| *Penalty:* no company website exists at all | cap at 0.35 |
| *Penalty:* sources disagree and you could not resolve it | −0.2 |

Below **0.5**, mark the record unusable for scoring. Keep it in volume and
coverage statistics and publish the exclusion rate. Reporting "we could score
31 of 40" is honest; publishing 40 numbers where 9 are noise is not.

---

## Output

Return one object per company, conforming to `references/icp-vocabulary.json`,
with these fields always populated:

- `price_signal` — verbatim from the source, or `""`. Never paraphrased.
- `confidence` — computed per the table above.
- `evidence` — one line naming which sentence on which URL established the ICP.
  This is the field that makes a hand spot-check of 20 rows possible, and it is
  the first thing to write, not an afterthought.
- `source_urls` — every page read, so a stale record can be re-checked later
  rather than silently trusted.

An ICP record with no `evidence` line cannot be audited, and an unauditable
record will eventually be believed by someone who shouldn't believe it.

---

## Always emit a review CSV with per-field provenance

Every run ends with a CSV the human can check by hand. This is not a reporting
nicety — an ICP record is a claim about someone else's business, and the person
relying on it has to be able to verify any single cell in under a minute without
re-running anything.

For every extracted value that carries weight — the price above all, but also
the qualification gate and the anti-ICP — emit three companion columns:

| Column | Contents |
|---|---|
| `<field>_source_tool` | `firecrawl` · `exa` · `firecrawl + exa` · `none` |
| `<field>_source_url` | The exact URL, not the domain |
| `<field>_source_location` | Where on the page. "FAQ accordion, question 'What does it cost?'", "hero eyebrow above the H1", "warning banner near top" |

The location column is the one people skip and the one that earns its keep.
A real example: a `$3,900/month` price was extracted correctly from a company's
own homepage, and the user could not find it — because it sat inside a
**collapsed FAQ accordion**. Firecrawl reads the rendered DOM; a human scrolling
the page sees only a closed toggle. Without the location column that looks like
a fabricated number, and trust in every other row goes with it.

**Distinguish the tool honestly, because the two carry different weight.** A
figure from the company's own page is a first-party claim. A figure from a Clay
partner directory, a LinkedIn company record or a review site is a third-party
claim — real evidence, but written for a listing, and it goes stale exactly the
way a website does. Mark third-party-only values and cap their confidence below
a value confirmed on the company's own site. Two records in the first production
run rested entirely on partner-directory claims at 0.85 confidence with the
company's own domain never read at all.

**Never let an empty field hide which kind of empty it is.** `none` in the
source column plus `unstated` in the value means one of two very different
things: the company publishes no price, or nobody pointed a tool at the pricing
page. The first is a finding, the second is an unfinished task. Use
`refused-not-disclosed` for the former, and in the location column for the
latter write plainly that the page was not read. Three rows in the first run
read as "no price published" when the truth was that the pricing page existed
and was never opened.

Order the columns for the human, not for the database: identifier, URL,
confidence and scoreable first, then the claim, then its provenance, then the
long qualitative fields, and the full evidence sentence last.

---

## Step 3c-bis — Read every pricing tier, and never let the top tier speak for the company

Firecrawl the **whole** pricing page, not the enterprise column. Then read the
use-case, solutions and "who it's for" pages, which state the buyer more plainly
than the homepage does and are routinely skipped.

The failure this prevents, measured on two records the same day:

| record | extracted | what the page said |
|---|---|---|
| Stripe | `price_band = refused-not-disclosed` | its own `price_signal` field held **"2.9% + $0.30"** — the price was published, captured, and then contradicted |
| Feathery | `price_band = refused-not-disclosed` | a **Free ($0)** tier alongside Custom Growth/Business tiers |
| Cursor | `buyer_headcount = 1001+` | *"Trusted by over half of the Fortune 500"* — a logo wall, on a page selling a $20/mo individual seat |
| Stripe | `buyer_headcount = 1001+` | *"From startups to Fortune 500s"* — the quote contains "startups" |

One root cause in all four: **the top of a range was taken as the whole of it.** An
enterprise tier marked "Custom" means *that tier* is quoted, not that the company
refuses to disclose price. A Fortune 500 logo means large companies are *among* the
customers, not that they are the buyer profile.

Rules:

- **`refused-not-disclosed` requires that NO tier publishes a number.** One
  published tier — including a free one — disqualifies it.
- **Social proof establishes that a segment is served, never that it is the only
  one, and can never bound a range.** Testimonials and logo walls remain valid
  evidence for `buyer_seniority` and `buyer_industries` — they are observed
  customers, which is why Step 3b harvests them. The restriction applies only to
  bounded ranges.
- **A self-serve tier and an enterprise tier is `mixed`**, and it is a fact about
  the motion. Record both, not the more impressive one.
- **`buyer_headcount` is conditional on what they sell.** Required for agencies,
  consultancies, freelancers, training and recruiting, where a retainer price
  creates a real floor that is usually stated. For horizontal SaaS and API/data
  products, leave it empty unless the page states tiers explicitly — selling from
  solo user to enterprise is the business model, not a targeting decision, and the
  size signal is price band × seat model rather than a customer list. The
  informative exception is a young product capped *below* enterprise by SOC 2, data
  residency or privacy review — a ceiling imposed on the vendor, not an ICP they
  chose, and it should be recorded as such.

**Store a quote per value, not per field.** A field holding
`c-level | founder-owner | individual-contributor` against one quote cannot be
verified: the quote supports one value and is silent on the others, and a reviewer
has no way to say so. Per-value evidence also makes the range rule enforceable —
if only one value has a quote, only one value is supported.

## Step 3d — Go three levels deep: testimonials and customer stories

Steps 3b harvests the roster. This step reads what the roster *is*, and it is
where the real ICP lives. Three levels, each cheap:

**Level 1 — the page.** Extract testimonials AND customer-story / case-study
entries. Treat them as the same class of evidence: a named company attached to a
described engagement. Customer stories are usually richer than testimonials
because they state the client's situation, which is the ICP in narrative form.

**Level 2 — the client's own site.** Firecrawl each named client. You are after
two things the roster alone cannot give you: **what industry they are actually
in** and **what they sell**. A LinkedIn industry code is far too coarse here —
"Marketing & Advertising" covers a two-person ghostwriting shop and a 200-person
media buyer, and separating those is the entire job.

**Level 3 — the client's size.** Resolve headcount and revenue band per client
from a firmographic source. The *distribution* across the roster is the ICP's
size axis, and it beats any inference from price.

Read the industries together, not one by one. A roster of lemlist, ColdIQ,
Scalezia and Cargo is not "B2B SaaS" — it is *GTM and sales-tooling companies*,
which is a far narrower and more useful ICP than any enum bucket. The pattern
across clients is the finding; each individual client is just a data point.

**Sanity-check affordability against size.** If a service costs $5k/mo and the
roster shows 51-200 person companies, an inferred `buyer_headcount` starting at
`2-10` is almost certainly wrong — a ten-person agency cannot fund that
retainer. When the size band and the price band disagree, trust the roster and
correct the band. A first production run put a premium content agency at `11-50`
when its actual clients were all substantially larger.

## Step 3e — Partner directories are a second first-party source

When a company is listed as a certified partner — Clay's expert directory,
HubSpot's solutions directory, a platform's agency marketplace — open that
listing. These are written by the company for a qualified audience, so they
routinely state the ICP more plainly and more commercially than the marketing
site does, including deal sizes and stage gates the site omits.

Two rules learned the hard way:

**Look the company up in the directory yourself; do not trust a cached
fragment.** A search result quoting a directory listing may be stale. In one
production run a `clay.com/experts/partner/coldiq` URL was cited as evidence,
and the live directory listed the successor company instead — the agency had
been renamed and the listing had moved with it. The cited evidence was real when
indexed and wrong when checked.

**A directory entry is first-party but not the same party.** It is written by
the company, which makes it strong evidence, but it describes the entity that
holds the partnership. After a rename, spin-out or split, that may no longer be
the company whose website you read. Confirm the entity name in the listing
matches the domain you scraped before merging the two into one record.

---

## The four rules that make output trustworthy

Measured on a 14-record production run that a human then audited: **5 of 14 rows
wrong, 36%.** The errors were not random. They clustered in two places, and both
are cheap to close. These rules are the fix, and they are not optional if
anything downstream spends money or makes a decision.

**1. Never ship a row whose page was not read.** Same run, split by method:
Exa → Firecrawl full read, 22% wrong. Exa highlights only, **60% wrong**.
An Exa highlight tells you a page exists and roughly what it says. It is a
pointer, not evidence. A Firecrawl markdown read costs 1 credit — about $0.001.
There is no budget argument for skipping it, only an attention one.

**2. No verbatim quote, no value.** Every single failure in that audit was an
*inferred enum*. Not one verbatim quote broke. So bind them together: an enum
field may only be populated when a supporting span from the source is attached.
Emit a parallel `_quotes` object keyed by field name:

```json
"buyer_headcount": ["51-200","201-500"],
"_quotes": { "buyer_headcount": "for teams of 50-500 with an existing sales function" }
```

No span means the field stays empty. This converts silent errors into visible
gaps, which is the trade you want — an empty field is recoverable, a confident
wrong band propagates into every score built on it. Three enums in that run
were quoteless, and all three were wrong.

**3. Compute the confidence, do not choose it.** Self-reported confidence had no
discriminating power (0.85 rows wrong, a 0.95 row right), and neither did a
hand-weighted evidence table. Use something mechanical instead: the share of
populated fields carrying a quote, times a flag for whether the page was
actually read. It cannot flatter itself, and it is reproducible.

**4. Two models must agree.** Re-extract each page with a second model and diff
the enums. Any disagreement flags the row for human review rather than silently
picking one. About $0.002 per row.

**Then gate it.** Hand-check a **randomly drawn** sample of 10 and proceed only
at ≤10% error. Random matters: an audit of rows where the checker already knows
the ground truth measures the easy cases and reports a floor, not a rate.
