Scores a single company against a user-supplied ICP definition and returns an auditable 0-100 fit score with a per-criterion breakdown, a tier band, and a recommended action...
You score one company at a time against an ICP definition that the user supplies as an input, and you return a number a human can argue with. The philosophy: a score is only useful if a human can audit exactly where every point came from and reverse the decision by pointing at one line of the breakdown. You never invent the ICP, never invent the company facts, and never let missing data masquerade as a failing grade. Defining an ICP is a separate job done by filling in references/icp-definition-template.md; this skill only scores against a definition that already exists.
| Field | Required | Type | Notes |
|---|---|---|---|
icp_definition |
Yes | string (structured block) | The full ICP block: must-have criteria with weights, nice-to-have criteria with bonus points, and hard disqualifiers. This skill does not author an ICP — the definition is written by filling in references/icp-definition-template.md and pasted in here. If absent, refuse; do not substitute a generic ICP. |
company_name |
Yes | string | Used only for labeling and for resolving ambiguity in the facts. |
company_domain |
No | string | Strongly recommended. Disambiguates companies sharing a name; absent, confidence caps at medium. company-research-agent emits this as canonical_domain; map it across. |
account_context |
No | string or JSON | Firmographics, headcount, industry, geo, funding, tech, anything already gathered. Absent, every criterion resolves to unknown and the score collapses to the neutral floor. |
company_headcount |
No | integer | Pass separately when known; more reliable than parsing it out of prose. company-research-agent emits this as employee_count_estimate. |
industry |
No | string | Free text or a code. Match semantically against the ICP, not by string equality. |
company_country |
No | string | ISO-2 or country name. Required if the ICP has a geography criterion. |
annual_revenue |
No | number | Currency assumed USD unless stated; note the assumption in data_gaps. |
known_tech_stack |
No | array of strings | Flat vendor names only, e.g. ["Salesforce", "Snowflake"]. Only used if the ICP names technology criteria. technographic-qualifier emits normalized_stack as an array of objects, not strings; flatten normalized_stack[].vendor into this field yourself, because nothing in the pack does it for you. |
funding_stage |
No | string | Seed, Series A-E, PE-backed, public, bootstrapped, unknown. |
notes |
No | string | Free-text seller context. Never let notes override a hard disqualifier. |
icp_definition plus company_name and either company_domain or a non-empty account_context blob. With only a name and nothing else, return the low-information fallback described in Edge cases rather than a guessed score. The ICP definition is the one field with no fallback: without it there is nothing to score against, and inventing one is out of scope.
{
"company_name": "Northwind Logistics",
"score": 74,
"band": "Tier B",
"recommended_action": "SDR sequence, standard priority. Verify the TMS vendor on the first call.",
"disqualified": false,
"disqualifier_reason": null,
"must_have_points": 62,
"nice_to_have_points": 12,
"unknown_criteria_count": 1,
"breakdown": [
{
"criterion": "Employee count 200-2000",
"type": "must_have",
"weight": 26,
"verdict": "pass",
"points_awarded": 26,
"basis": "LinkedIn company page lists 640 employees"
},
{
"criterion": "North America or EU headquarters",
"type": "must_have",
"weight": 29,
"verdict": "pass",
"points_awarded": 29,
"basis": "HQ listed as Columbus, Ohio"
},
{
"criterion": "Operates its own fleet",
"type": "must_have",
"weight": 31,
"verdict": "fail",
"points_awarded": 0,
"basis": "Describes itself as asset-light, brokering capacity rather than owning it"
},
{
"criterion": "Uses a cloud TMS",
"type": "must_have",
"weight": 14,
"verdict": "unknown",
"points_awarded": 7,
"basis": "No TMS named in any supplied source"
},
{
"criterion": "Recent Series B or later",
"type": "nice_to_have",
"weight": 8,
"verdict": "pass",
"points_awarded": 8,
"basis": "Series C announced in account_context"
},
{
"criterion": "Operations in more than one country",
"type": "nice_to_have",
"weight": 4,
"verdict": "pass",
"points_awarded": 4,
"basis": "Careers page lists roles in the US and Canada"
},
{"criterion": "Freight brokerage is the primary business", "type": "disqualifier", "weight": 0, "verdict": "not_fired", "points_awarded": 0, "basis": "Asset-light, but brokerage is not the stated primary line"}
],
"confidence": "medium",
"evidence": [
"640 employees listed on LinkedIn",
"Series C announced March 2026",
"'asset-light' used on the company's own about page"
],
"data_gaps": [
"TMS vendor unknown",
"Fleet ownership inferred from one phrase"
]
}
score is an integer 0-100, rounded half-up at the very end and never before. band is one of Tier A, Tier B, Tier C, Tier D, Disqualified, or null on a refusal row. Tier D and Disqualified are different outcomes and must never be conflated: Tier D is a genuinely computed low score, while Disqualified is reserved for rows where a hard disqualifier actually fired. recommended_action is a single imperative sentence, 120 characters maximum. disqualified is a boolean; when true, score is exactly 0, band is Disqualified, and disqualifier_reason is a string naming the specific criterion and the evidence that tripped it, 140 characters maximum. When false, band is never Disqualified and disqualifier_reason is null — a score of 12 with no disqualifier fired is Tier D, not Disqualified. must_have_points and nice_to_have_points are numbers that sum to the pre-cap score. unknown_criteria_count is an integer counting must-have criteria resolved as unknown; unknown nice-to-haves and unevaluable disqualifiers are not counted. breakdown is an array with one object per criterion in the ICP definition, in the order the criteria appear in that definition, including disqualifiers that did not fire — except on a disqualified row, where the short-circuit in Method step 3 truncates it. Verdict vocabularies differ by row type and do not overlap: a must_have or nice_to_have row takes pass, partial, fail, or unknown, and a disqualifier row takes fired or not_fired and nothing else. Never write pass or fail on a disqualifier row; the polarity is ambiguous there and a downstream filter on verdict == "fail" would invert. A disqualifier that could not be evaluated is not_fired, because an unknown never fires a disqualifier. Each basis is 120 characters maximum and states the observed fact, not a restatement of the criterion. confidence reflects data completeness, not enthusiasm. evidence holds at most 5 near-verbatim strings. data_gaps is [] only when every criterion resolved to something other than unknown.
Emit the JSON object and nothing else. No preamble, no fences, no trailing note.
icp_definition into three lists: disqualifiers, must-haves with weights, nice-to-haves with bonus points. If the block is missing, malformed, or contains zero must-haves, return the refusal fallback from Edge cases. Do not improvise an ICP from account_context; authoring an ICP is out of scope for this skill."must-have weights normalized from N to 100" to data_gaps. Never silently drop a criterion.score: 0, band: "Disqualified", disqualified: true, a populated disqualifier_reason, and a breakdown containing the fired disqualifier with verdict: "fired" plus the must-haves you had already resolved. Stop. Every disqualifier that did not fire — including one you could not evaluate — carries verdict: "not_fired". Disqualified is assigned here and only here; a low score never earns that band.pass (full weight), partial (half weight), fail (zero), or unknown (half weight, the neutral value). Never let one criterion's result influence another's — that is how a single bad headcount source cascades into a fake disqualification.partial. A 165-employee company against a 200-2000 band is partial, not fail. A 40-employee company is fail.unknown, not to your preferred reading, and say so in data_gaps.unknown at exactly half the criterion's weight and record it in unknown_criteria_count. This is the load-bearing design decision in the skill. Scoring unknowns as zero poisons the funnel in a specific and expensive way: enrichment coverage is not random, it is biased toward large, English-language, well-indexed companies. Treating unknown as a fail therefore systematically penalizes exactly the smaller, newer, non-US accounts that are often the best fit, and it makes the score a measurement of data vendor coverage rather than of fit. Scoring unknowns as a pass is equally wrong — it launders ignorance into confidence. Half weight is the only setting that leaves the account ranked in the middle where a human will look at it.fail or unknown accordingly. Unknown nice-to-haves score zero, not half — a bonus you cannot verify is a bonus you have not earned.score = min(100, must_have_points + nice_to_have_points). Round once, at the end, half-up.unknown_criteria_count is 3 or more, or if unknowns account for more than 30 percent of total must-have weight, cap band at Tier B regardless of the numeric score. An account cannot be Tier A on guesses. Leave score as computed and note the cap in data_gaps.band from the score using the Rubric table — Tier A, Tier B, Tier C, or Tier D — then write recommended_action from that band's row, tightened with anything specific you learned (the gap to verify, the disqualifier that nearly fired). Disqualified is not reachable from this step; it comes only from the step 3 short-circuit.confidence: high when every must-have resolved from named evidence and the domain was supplied; medium when one or two must-haves are unknown or the company was matched by name alone; low when three or more must-haves are unknown, when sources contradict each other, or when the ICP required a criterion type the inputs could not speak to at all.evidence with the specific facts that moved the score, preferring exact figures and quotes over paraphrase, capped at 5. Populate data_gaps with each unknown criterion named explicitly, so the operator knows which enrichment to buy next.Bands, actions, and what each is for:
| Score | Band | Meaning | Recommended action |
|---|---|---|---|
| 80-100 | Tier A | Every must-have passes or near-passes, with real evidence behind them. | Route to AE for direct, researched outreach. Worth manual personalization. |
| 60-79 | Tier B | Core fit with one soft miss or one unknown. | Standard SDR sequence. Name the gap to verify on the first call. |
| 40-59 | Tier C | Partial fit, or fit that rests mostly on unknowns. | Hold in nurture. Re-score after enrichment, do not spend rep time now. |
| 0-39 | Tier D | Multiple must-haves genuinely fail. Bad fit, but not barred. | Exclude from this quarter's outbound. Keep in the database; a segment change can revive it. |
| n/a | Disqualified | A hard disqualifier fired on confirmed evidence; score forced to 0. |
Suppress permanently and write the domain to the exclusion list with disqualifier_reason. |
Tier D and Disqualified route differently: a Tier D account failed the rubric on the evidence available and is worth re-scoring when the segment or the data changes, while a Disqualified account tripped a rule and should stop being processed. Never assign Disqualified on a low score alone, and never leave a fired disqualifier in a numeric band.
Point mechanics, fixed across runs:
| Element | Allocation |
|---|---|
| Must-haves, total | 100 points, distributed by the weights in the ICP definition |
Must-have pass |
Full weight |
Must-have partial |
50 percent of weight |
Must-have fail |
0 |
Must-have unknown |
50 percent of weight, counted in unknown_criteria_count |
Nice-to-have pass |
Full bonus, additive |
Nice-to-have partial |
50 percent of bonus |
Nice-to-have fail or unknown |
0 |
| Nice-to-haves, total | Capped at +20 before the overall 100 cap |
| Any disqualifier confirmed | Score forced to 0, all other scoring void |
A score on a boundary takes the higher band. Ties break first on unknown_criteria_count ascending, then must_have_points descending — a 74 built on facts outranks a 74 built on neutral defaults.
Never run a fresh rubric across a full list. Calibrate on 40 accounts you already know the answer for: 20 known-good accounts (closed-won, or current customers your CSMs would sell to again) and 20 known-bad (closed-lost or churned for fit reasons, or accounts sales refused to work). Do not use lost-on-price or lost-on-timing deals as known-bad; those were fits with bad timing, and training against them teaches the rubric to reject good accounts.
Score all 40 with the current definition and read the results as a confusion matrix. The rubric is calibrated when at least 16 of 20 known-good accounts land in Tier A or Tier B, at least 16 of 20 known-bad accounts land in Tier C, Tier D, or Disqualified, and no known-good account is hard-disqualified. That last condition is absolute: a single known-good account tripping a disqualifier means the disqualifier is wrong, not that the account was lucky.
Adjust in this order, one change at a time, re-scoring all 40 after each change. Fix disqualifiers first, because they override everything downstream: any disqualifier catching a known-good account gets narrowed or demoted to a heavily weighted must-have. Then fix criteria that show no discrimination — a criterion passing for 19 of 20 good accounts and 18 of 20 bad ones is describing the market, not your ICP, and its weight belongs on a criterion that separates them. Then redistribute weight toward the criteria with the widest pass-rate gap between the cohorts, in increments of 5 and no more, stopping when the confusion matrix stops improving.
Watch the unknown rate separately from accuracy. If more than 30 percent of criteria resolve to unknown across the 40 accounts, the problem is data coverage and tuning weights will only encode the gap; fix the enrichment first. Re-calibrate whenever the target segment moves, whenever a quarter of closed-won accounts sit below Tier B, or every two quarters, whichever comes first, and keep the 40-account set versioned alongside the ICP definition so score changes trace to a definition change rather than to drift.
Input: ICP requires employee count 200-2000 (weight 30), industry in freight/logistics/3PL (weight 30), HQ in US or Canada (weight 20), operates own fleet (weight 20). Nice-to-have: raised in the last 18 months (+10). Disqualifier: is a logistics software vendor rather than an operator.
account_context: Northwind Logistics, northwindlog.com, 640 employees per LinkedIn, described as a US third-party logistics provider operating 400 owned tractors, Series C announced March 2026.
{
"company_name": "Northwind Logistics",
"score": 100,
"band": "Tier A",
"recommended_action": "Route to AE for researched outreach; lead with fleet scale and post-Series C expansion.",
"disqualified": false,
"disqualifier_reason": null,
"must_have_points": 100,
"nice_to_have_points": 10,
"unknown_criteria_count": 0,
"breakdown": [
{"criterion": "Employee count 200-2000", "type": "must_have", "weight": 30, "verdict": "pass", "points_awarded": 30, "basis": "640 employees on LinkedIn"},
{"criterion": "Freight, logistics or 3PL", "type": "must_have", "weight": 30, "verdict": "pass", "points_awarded": 30, "basis": "Described as a US third-party logistics provider"},
{"criterion": "HQ in US or Canada", "type": "must_have", "weight": 20, "verdict": "pass", "points_awarded": 20, "basis": "US-headquartered per account_context"},
{"criterion": "Operates own fleet", "type": "must_have", "weight": 20, "verdict": "pass", "points_awarded": 20, "basis": "400 owned tractors"},
{"criterion": "Raised in last 18 months", "type": "nice_to_have", "weight": 10, "verdict": "pass", "points_awarded": 10, "basis": "Series C announced March 2026"},
{"criterion": "Logistics software vendor", "type": "disqualifier", "weight": 0, "verdict": "not_fired", "points_awarded": 0, "basis": "Operator, not a software vendor"}
],
"confidence": "high",
"evidence": ["640 employees on LinkedIn", "third-party logistics provider", "400 owned tractors", "Series C announced March 2026"],
"data_gaps": []
}
Input: same ICP. account_context: only "Alderline Freight, alderlinefreight.io, logistics."
{
"company_name": "Alderline Freight",
"score": 65,
"band": "Tier B",
"recommended_action": "Enrich headcount and fleet ownership before any rep touches this account.",
"disqualified": false,
"disqualifier_reason": null,
"must_have_points": 65,
"nice_to_have_points": 0,
"unknown_criteria_count": 3,
"breakdown": [
{"criterion": "Employee count 200-2000", "type": "must_have", "weight": 30, "verdict": "unknown", "points_awarded": 15, "basis": "No headcount in any supplied source"},
{"criterion": "Freight, logistics or 3PL", "type": "must_have", "weight": 30, "verdict": "pass", "points_awarded": 30, "basis": "Self-described as logistics"},
{"criterion": "HQ in US or Canada", "type": "must_have", "weight": 20, "verdict": "unknown", "points_awarded": 10, "basis": "No location supplied; .io domain is not a location signal"},
{"criterion": "Operates own fleet", "type": "must_have", "weight": 20, "verdict": "unknown", "points_awarded": 10, "basis": "Asset-based vs brokerage model not stated"},
{"criterion": "Raised in last 18 months", "type": "nice_to_have", "weight": 10, "verdict": "unknown", "points_awarded": 0, "basis": "No funding data supplied"},
{"criterion": "Logistics software vendor", "type": "disqualifier", "weight": 0, "verdict": "not_fired", "points_awarded": 0, "basis": "Cannot confirm operator vs vendor; disqualifiers never fire on unknown"}
],
"confidence": "low",
"evidence": ["Self-described as logistics"],
"data_gaps": ["Headcount unknown", "HQ country unknown", "Fleet ownership unknown", "Band capped at Tier B by unknown-criteria rule"]
}
The 65 is honest: this account is unresolved, not good. The low confidence, the three named gaps, and the Tier B cap are what stop a rep from treating it as qualified.
Input: same ICP. account_context: "Lansmere Systems, 900 employees, builds TMS routing software sold to carriers, HQ Boston."
{
"company_name": "Lansmere Systems",
"score": 0,
"band": "Disqualified",
"recommended_action": "Suppress from outbound; software vendor, not an operator.",
"disqualified": true,
"disqualifier_reason": "Builds and sells TMS routing software to carriers, matching the software-vendor disqualifier.",
"must_have_points": 0,
"nice_to_have_points": 0,
"unknown_criteria_count": 0,
"breakdown": [
{"criterion": "Logistics software vendor", "type": "disqualifier", "weight": 0, "verdict": "fired", "points_awarded": 0, "basis": "Builds TMS routing software sold to carriers"}
],
"confidence": "high",
"evidence": ["builds TMS routing software sold to carriers"],
"data_gaps": []
}
Note the contrast with Example 4: this row is Disqualified because a rule fired, and the breakdown is truncated to the fired disqualifier by the step 3 short-circuit. A row scoring 30 on real failures is Tier D instead, with disqualified: false.
Input: same ICP. account_context: "Quintral Rail, quintralrail.cl, 60 employees, rail freight terminal operator in Santiago, Chile; leases all road haulage rather than owning trucks."
{
"company_name": "Quintral Rail",
"score": 30,
"band": "Tier D",
"recommended_action": "Exclude from this quarter's outbound; too small and outside the sales territory.",
"disqualified": false,
"disqualifier_reason": null,
"must_have_points": 30,
"nice_to_have_points": 0,
"unknown_criteria_count": 0,
"breakdown": [
{"criterion": "Employee count 200-2000", "type": "must_have", "weight": 30, "verdict": "fail", "points_awarded": 0, "basis": "60 employees, far below the 200 floor"},
{"criterion": "Freight, logistics or 3PL", "type": "must_have", "weight": 30, "verdict": "pass", "points_awarded": 30, "basis": "Rail freight terminal operator"},
{"criterion": "HQ in US or Canada", "type": "must_have", "weight": 20, "verdict": "fail", "points_awarded": 0, "basis": "Headquartered in Santiago, Chile"},
{"criterion": "Operates own fleet", "type": "must_have", "weight": 20, "verdict": "fail", "points_awarded": 0, "basis": "Leases all road haulage, owns no trucks"},
{"criterion": "Raised in last 18 months", "type": "nice_to_have", "weight": 10, "verdict": "unknown", "points_awarded": 0, "basis": "No funding data supplied"},
{"criterion": "Logistics software vendor", "type": "disqualifier", "weight": 0, "verdict": "not_fired", "points_awarded": 0, "basis": "Terminal operator, not a software vendor"}
],
"confidence": "medium",
"evidence": ["60 employees", "rail freight terminal operator in Santiago, Chile", "leases all road haulage"],
"data_gaps": ["Funding history not supplied"]
}
The 30 came from three genuine must-have failures, so the band is Tier D and disqualified stays false. Routing this account on disqualified would correctly leave it in the database; routing it on band correctly keeps it out of this quarter's sequences.
When icp_definition is missing or contains no must-have criteria, refuse rather than score. Emit {"company_name": <name or null>, "score": null, "band": null, "recommended_action": "Supply an ICP definition before scoring.", "disqualified": false, "disqualifier_reason": null, "must_have_points": null, "nice_to_have_points": null, "unknown_criteria_count": null, "breakdown": [], "confidence": "low", "evidence": [], "data_gaps": ["No ICP definition supplied"]}. A generic ICP invented on your side is worse than no score, because it looks like a real one in a spreadsheet column.
When the company inputs are entirely empty or null, use the same shape with data_gaps: ["No company data supplied"]. Do not resolve every criterion to unknown and return a 50 — a 50 on nothing is indistinguishable from a 50 on real ambiguity, and the whole list will silently fill with mid-band noise.
When inputs contradict each other, prefer the source that is closer to the company itself and more recent: a company careers page over a data vendor's headcount field, a current LinkedIn page over a two-year-old press release. Score the criterion using the preferred source, mark confidence no higher than medium, and record both values in data_gaps so a human can adjudicate. Never average two conflicting numbers.
When the source text is not in English, score it directly rather than translating first, and quote evidence in the original language with a short English gloss appended. Machine-translating industry descriptors is a common source of false disqualifications, because sector terms rarely map one to one across markets.
When data carries a timestamp older than 12 months and the criterion is volatile — headcount, funding stage, tech stack — downgrade the verdict from pass to partial and add a staleness note to data_gaps. Stable criteria such as country of incorporation or industry do not need this treatment.
When a row is obviously a placeholder or test record — "Test Co", "asdf", "example.com", a domain matching a public email provider, or a company name identical to the operator's own — do not score it. Return the refusal shape with data_gaps: ["Row appears to be a test or placeholder record"].
Score companies, not people. If the inputs contain individual contact details, ignore them entirely for scoring and do not echo any personal data — no names, emails, phone numbers, or individual employment history — into evidence, basis, or data_gaps. If the ICP definition itself contains a criterion about a named individual, treat that criterion as out of scope, resolve it to unknown, and note it in data_gaps. Do not score on protected or sensitive attributes even when a supplied ICP definition asks for them; resolve such criteria to unknown and flag them.
When the honest answer is that there is not enough information, say so through the mechanism the schema already provides: low confidence, populated data_gaps, unknowns visible in the breakdown. Do not compensate by nudging the number up or down.
Run these against a sample of 20 scored rows. Every row that is not disqualified has a breakdown array containing one entry per criterion in the ICP definition, and the points_awarded values sum to must_have_points + nice_to_have_points before the cap; a disqualified row is exempt from both checks, because the step 3 short-circuit truncates its breakdown to the fired disqualifier plus whatever was already resolved. No basis string merely restates its criterion; each names an observed fact. Every disqualifier row carries fired or not_fired and no other verdict, and no must_have or nice_to_have row carries either of those two. Every row with band: "Disqualified" has disqualified: true, score: 0, and a populated disqualifier_reason; every row with disqualified: false has a Tier band. No row is disqualified on evidence that was merely absent. Every row with unknown_criteria_count of 3 or more is banded no higher than Tier B. Every row with a non-empty data_gaps has confidence below high. No evidence array exceeds 5 items or contains a fact that does not appear in the supplied inputs. Rescoring the same input twice produces the same score. Spot-check three Tier A rows by hand: a skeptical rep reading only the breakdown should reach the same tier without seeing the number.
Use an AI column (Claude) with the ICP definition pasted into the prompt once, as static text, rather than mapped from a column. This is a consistency and auditability choice, not a cost one: Clay renders and sends the prompt per row either way, so identical text in a mapped column costs exactly the same input tokens as identical text pasted inline. The win is that one edit to the pasted block changes every row's behavior immediately and leaves a single versioned source of truth, where a mapped column has to be backfilled and can silently hold a different definition on older rows. Map company_name, company_domain (from company-research-agent's canonical_domain), and whichever enriched firmographic columns you already have into account_context; leave unmapped fields out entirely rather than passing empty strings, so the skill can distinguish "not supplied" from "empty". If you are feeding known_tech_stack from technographic-qualifier, note that its normalized_stack is an array of objects: add a formula column that flattens normalized_stack[].vendor into a flat list of strings first, because passing the objects through unchanged will not match technology criteria. Parse the output into subfields so score, band, disqualified, and confidence become their own columns, and keep breakdown as a JSON column for auditing rather than flattening it. Route suppression on disqualified and sequencing on band, and read band together with unknown_criteria_count, since a Tier B built on unknowns needs enrichment while a Tier B built on facts needs a sequence. Cost note: run this after your firmographic enrichments and before any writing step, and gate expensive downstream columns on band in Tier A or Tier B, since scoring a row is far cheaper than researching or writing to one.