← All skills

Research · GTM

ICP Research

Exa finds the page that actually states the ICP, Firecrawl reads it properly, then the client logo wall gets resolved to real headcounts — so company size is observed, not guessed from a price. Every field carries a verbatim quote or stays empty.

Skill name
icp-research
Triggers on
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.
Read time
23 min · 4 files · free to use and edit
Download full skill

This skill ships 4 files. The references are where the method lives — SKILL.md on its own will point at files you do not have, so take the archive rather than the markdown.

  • SKILL.md
  • references/failure-modes.md
  • references/icp-vocabulary.json
  • scripts/validate_icp.py

Prefer just the instructions? Download SKILL.md alone.

Use it in your assistant

Claude Code — drop the file in your skills folder and it loads on the next session. Use ~/.claude/skills for every project, or .claude/skills inside a repo to keep it to that project.

mkdir -p ~/.claude/skills
curl -L https://growsteady.io/skills/icp-research/archive | tar xz -C ~/.claude/skills

Claude apps (web and desktop) — Settings → Capabilities → Skills → add a skill. Extract the archive and upload the whole icp-research folder, references included (zip it if an archive is asked for).

No install— paste the file into a Claude Project's custom instructions with “Copy as prompt”. Same behaviour, scoped to that project. Note that a paste carries the instructions only: this skill's references do not come with it, so use a real install if you want the full method.

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.

ServerWhat it does hereGet a key
ExaFinds which page states the ICPhttps://dashboard.exa.ai/api-keys
FirecrawlReads that page in fullhttps://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:

claude mcp add exa --transport http https://mcp.exa.ai/mcp
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:

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

Load them into a shell with:

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.jsonwhat_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:

EvidenceWeightWhy
Testimonial with a named person and job titlehighestA real engagement someone signed their name to; the title also gives buyer seniority for free
Written case studyhighSubstantial enough that the client approved it
Bare logolowMay 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 foundContribution
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 allcap 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:

ColumnContents
<field>_source_toolfirecrawl · exa · firecrawl + exa · none
<field>_source_urlThe exact URL, not the domain
<field>_source_locationWhere 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:

recordextractedwhat the page said
Stripeprice_band = refused-not-disclosedits own price_signal field held "2.9% + $0.30" — the price was published, captured, and then contradicted
Featheryprice_band = refused-not-discloseda Free ($0) tier alongside Custom Growth/Business tiers
Cursorbuyer_headcount = 1001+"Trusted by over half of the Fortune 500" — a logo wall, on a page selling a $20/mo individual seat
Stripebuyer_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:

"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.