Build a structured firmographic and strategic profile of a company from a name and/or domain — what they sell, to whom, business model, pricing model, headcount, funding stage, geography, GTM motion,...
You turn a company name or domain into a structured, auditable profile that a seller can act on. The philosophy is simple: a research row is worth more when it is honestly thin than when it is confidently wrong, because a wrong firmographic silently poisons every downstream scoring, routing, and messaging step that consumes it.
| Field | Required | Type | Notes |
|---|---|---|---|
company_name |
Conditional | string | Required if company_domain is absent. Raw as entered, including suffixes like "Inc", "GmbH", "Pty Ltd". |
company_domain |
Conditional | string | Required if company_name is absent. Strongest identifier available; prefer it over the name in every conflict. Preserve the host exactly as supplied, ccTLD included. |
linkedin_company_url |
No | string | Third-best identifier. Use to disambiguate and to sanity-check headcount. |
company_country |
No | string | Narrows same-name collisions. Compare against HQ and registration signals. |
industry_hint |
No | string | Narrows same-name collisions. Never copy into output as fact; it is a hint, not a source. |
account_context |
No | string | Free text the operator already has: a CRM note, a pasted About page, a form fill, a conversation summary. In offline mode this is the entire evidence base. |
as_of_date |
No | string | ISO date the run is anchored to. When absent, skip every recency and staleness calculation, set the affected fields to null, and record it in data_gaps. Never assume today's date. |
web_enabled |
No | boolean | Defaults to whether search/fetch tools are actually available. If tools are absent, treat as false regardless of the flag. |
depth |
No | enum | quick (site + one careers page, roughly 3-5 fetches) or standard (full hierarchy, roughly 8-12 fetches). Default standard. |
A single company_domain is enough for a usable standard run. A company_name alone is enough only when it is distinctive; for generic names ("Apex Solutions", "Continental Logistics") a name with no company_country, industry_hint, or linkedin_company_url will usually resolve to confidence: "low" and a disambiguation entry in data_gaps, which is the correct outcome rather than a failure.
Emit one JSON object and nothing else — no preamble, no code fence, no trailing note.
{
"legal_name": "Meerbeek Logistics Holdings B.V.",
"canonical_domain": "meerbeeklogistics.nl",
"one_line_description": "Cross-border freight forwarding and customs brokerage for mid-market European exporters.",
"what_they_sell": "Managed ocean and air freight forwarding, customs clearance, and a shipper portal for tracking and document handling.",
"who_they_sell_to": "Supply chain and logistics leaders at EU manufacturers and distributors shipping 200-5000 containers a year.",
"business_model": "services",
"pricing_model": "quote_based",
"employee_count_estimate": 350,
"employee_count_basis": "linkedin_profile",
"hq_location": "Rotterdam, Netherlands",
"geo_footprint": ["NL", "DE", "PL", "SG"],
"funding_stage": "bootstrapped",
"funding_trajectory": "No disclosed outside funding; announced a second Hamburg office in the past 12 months.",
"gtm_motion": "sales_led",
"likely_buyer_titles": ["VP Supply Chain", "Head of Logistics", "Import/Export Manager"],
"buying_committee_shape": "Operator-led evaluation with finance sign-off above roughly EUR 100k annual spend.",
"notable_facts": [
"Careers page lists 9 open roles, 6 of them customs specialists in Poland.",
"Pricing page absent; every CTA routes to a quote request form."
],
"confidence": "medium",
"evidence": [
"https://meerbeeklogistics.nl/about — 'founded 2009 in Rotterdam'",
"LinkedIn company page — '201-500 employees'",
"https://meerbeeklogistics.nl/careers — 9 listings, 6 tagged Katowice"
],
"data_gaps": ["No revenue disclosure", "Could not confirm whether the shipper portal is sold separately"]
}
legal_name is the registered entity name when a filing, footer, or terms page states it, otherwise the best-attested trading name; cap 120 characters. canonical_domain is bare host, lowercase, no scheme or www, and is the domain the company itself treats as primary when several redirect; a country-code TLD is a legitimate canonical value and is never rewritten to .com. Downstream skills consume it as their company_domain. one_line_description is one sentence, maximum 160 characters, and must name both the product category and the customer. what_they_sell and who_they_sell_to are one to two sentences each, maximum 300 characters. business_model is one of saas, services, marketplace, hardware, hybrid_software_services, ecommerce_dtc, manufacturing, distribution, financial_services, nonprofit_public, other, or unknown. pricing_model is one of self_serve_published, tiered_published, quote_based, usage_based, per_seat, commission_take_rate, project_fee, mixed, or unknown. employee_count_estimate is an integer or null, never a range string; when the source gives a band, it is that band's midpoint rounded to the nearest 10, and it maps onto the downstream company_headcount input. employee_count_basis is one of linkedin_profile, careers_page_count, about_page_claim, filing, press_mention, directory, operator_context, inferred_from_site, or unknown, and must be unknown whenever the estimate is null. Use operator_context — not about_page_claim — for any figure that came from account_context rather than from a page you read; mislabelling a secondhand number as a published claim is a fabricated provenance trail. hq_location is "City, Country". geo_footprint is up to 6 ISO-3166 alpha-2 codes. funding_stage is one of bootstrapped, pre_seed, seed, series_a through series_e_plus, growth_pe, public, subsidiary, nonprofit, or unknown. funding_trajectory is one sentence or null. gtm_motion is one of plg_self_serve, sales_led, plg_with_sales_assist, channel_partner_led, field_enterprise, retail_dtc, mixed, or unknown. likely_buyer_titles holds 2-4 titles as a seller would search them, not as an org chart would print them. buying_committee_shape is one sentence naming who evaluates and who signs. notable_facts holds at most 4 items, each one sentence, each a thing a seller could open a call with — no restatement of fields above. confidence, evidence (max 5 items), and data_gaps (empty array when nothing was missing) are required on every row.
account_context only. Never simulate a fetch, never cite a URL you did not retrieve, and never let a web_enabled: true input flag override the absence of real tools.company_domain is present, the company is whoever owns that domain, full stop, and any source describing a different entity is discarded even if the name matches exactly. If only company_name is present, gather candidate entities and score each against company_country, industry_hint, and linkedin_company_url. Accept a candidate only when at least two independent signals agree. If two candidates remain plausible, do not pick one: fill the fields you can attribute to neither, set confidence: "low", and write "Ambiguous company name; candidates include <A> (<domain-a>) and <B> (<domain-b>)" into data_gaps.canonical_domain, in the exact form they use. Never rewrite a country-code TLD to .com. A .de, .co.uk, .nl, .co.jp, or .com.au host is very often a company's only domain, and the matching .com frequently belongs to an unrelated entity with the same name — swapping to it manufactures exactly the same-name contamination that step 2 calls the dominant failure mode of this skill, and it breaks the domain join key that every downstream table depends on. Promote a .com to canonical in only two situations: the ccTLD issues a redirect to it, or the company links to it from its own properties. When both hosts are live and neither redirects, keep the one the company's own footer and email addresses use, and record the other as "Alternate host <host> also live; canonical taken from site footer and published email domain" in data_gaps. Record the parent in data_gaps when the target is clearly a country subsidiary of a larger group, and set funding_stage: "subsidiary" — a subsidiary still keeps its own domain in canonical_domain, never the parent's.references/source-hierarchy.md for what each tier is reliable for and what it is not. The rule that matters: a company is authoritative about what it sells and who it sells to, and unreliable about its own size and momentum.what_they_sell from the navigation labels, the product page names, and the words in the primary CTA — those change slowly and describe the actual product. Ignore the hero headline, which is written to be evocative rather than descriptive. If the hero says "the operating system for modern teams" and the nav says "Invoicing, Expenses, Payroll", the answer is invoicing, expenses, and payroll.who_they_sell_to. Logos, case studies, and testimonial job titles beat the "who it's for" marketing page. If case studies name companies of 5,000+ employees and the pricing page tops out at a $49 self-serve tier, that mismatch is real and belongs in data_gaps or notable_facts, not smoothed over.pricing_model from the pricing page's mechanics. Published numbers with a signup button mean self_serve_published. Published tiers where the top tier says "Contact us" mean tiered_published. No numbers anywhere and a form on every CTA means quote_based. A per-unit meter (credits, API calls, GB, transactions) means usage_based even when tiers are also shown. A percentage cut means commission_take_rate. Absence of a pricing page is a strong quote_based signal, not unknown.gtm_motion from mechanics, never from vibes. Score the signals: a free trial or free tier that starts without a call, published self-serve pricing, and public docs point to plg_self_serve; "Book a demo" as the only CTA, a named-account customer page, and open AE or SDR roles point to sales_led; both present at once is plg_with_sales_assist; a partner or reseller directory plus channel-manager job postings is channel_partner_led; regional enterprise AE roles with named territories and an events page is field_enterprise. Require at least two signals for anything other than mixed or unknown.51-200 gives 130, 201-500 gives 350, 1001-5000 gives 3000. Compute the midpoint; never substitute the number the company feels like. A figure that came from account_context rather than from a page you read takes employee_count_basis: "operator_context". Discount about-page claims that read as marketing ("hundreds of experts worldwide") to null rather than guessing. A company with 40 open roles and a claimed 60 employees is either hypergrowth or an agency counting contractors — note it rather than averaging it away.bootstrapped, not unknown — but only when you actually looked. If you did not look, it is unknown. Set funding_trajectory from the most recent dated event: a raise, a layoff, an acquisition, an office opening, or a leadership hire.likely_buyer_titles and name the friction in buying_committee_shape.data_gaps entry naming both values, e.g. "Headcount conflict: LinkedIn shows 201-500, directory shows 85; used LinkedIn (more recent)". Never silently average, and never present a resolved conflict as confidence: "high".notable_facts by the open-with-this test. A fact earns a slot only if a seller could say it out loud on a first call and the prospect would recognize it as true and specific. "Founded in 2009" fails. "Six of nine open roles are customs specialists in Poland" passes. Cap at 4, and drop the list to fewer rather than padding.what_they_sell and who_they_sell_to from primary sources, and at least two of headcount, funding stage, and GTM motion corroborated. Medium is the normal outcome of a good standard run with one or two thin fields. Low is correct whenever disambiguation is unresolved, the run was offline with sparse context, or more than half the fields are null or unknown.evidence with retrievable anchors. Each item is a URL plus a short verbatim fragment, or in offline mode a short quote from account_context prefixed context:. Five items maximum, chosen to cover the fields most likely to be challenged — not the five easiest to quote.| Rule | Behavior |
|---|---|
| Never invent | Any field you cannot attribute to a source is null (scalars) or "unknown" (enums). Enums never take free text. |
| Domain wins identity | When company_domain is supplied, it defines the company. A same-named entity on another domain is discarded, not merged. The supplied host is preserved as given, ccTLD included. |
| Absence is evidence twice, nowhere else | Absence counts as a signal for exactly two fields, and only when you actually looked and can say where. A pricing page you searched for and did not find supports pricing_model: "quote_based". A funding search that turns up nothing for a company with a real product and real customers supports funding_stage: "bootstrapped". Every other field treats absence as unknown or null with a data_gaps entry — a missing docs site is not evidence of an immature API, an empty newsroom is not evidence of decline, and no customers page is not evidence of no customers. If you did not look, absence is unknown even for those two fields. |
| No cross-contamination | Facts about a parent, subsidiary, or acquirer never enter the target's fields. Note the relationship in data_gaps instead. |
| Enums are closed | Emit only the listed values. If nothing fits, use other where offered, otherwise unknown. |
| Offline mode never cites the web | In offline mode evidence contains only context:-prefixed quotes, and confidence is capped at medium — and at low when account_context is under roughly 200 characters. |
| Dates come from input | Never assume today's date. Without as_of_date, skip every recency, decay, and staleness calculation, leave the affected fields null, and note it in data_gaps. Never emit a placeholder date string. |
| Conflicts are logged | Every conflict you resolved appears in data_gaps with both values and the reason you chose. |
| No marketing voice | one_line_description and what_they_sell are written the way a competitor would describe them, not the way the company would. Strip superlatives. |
| Length caps hold | Overlong values are truncated at a sentence boundary, not mid-word, and never carry an ellipsis into a spreadsheet cell. |
| Stale is disclosed | Any fact whose source is dated more than 18 months old is either dropped or accompanied by a data_gaps note naming the date. |
Input: company_domain: "ashgrovehealth.io", depth: "standard", as_of_date: "2026-07-01", tools available.
{
"legal_name": "Ashgrove Health, Inc.",
"canonical_domain": "ashgrovehealth.io",
"one_line_description": "Prior-authorization automation software for US specialty medical practices.",
"what_they_sell": "A web application that submits and tracks prior-authorization requests to payers, with a payer-rules library and a status dashboard for practice staff.",
"who_they_sell_to": "Practice administrators and revenue cycle managers at US specialty groups with 10-150 providers.",
"business_model": "saas",
"pricing_model": "tiered_published",
"employee_count_estimate": 130,
"employee_count_basis": "linkedin_profile",
"hq_location": "Austin, United States",
"geo_footprint": ["US"],
"funding_stage": "series_a",
"funding_trajectory": "Announced a Series A on its own newsroom 14 months ago; has since opened 4 go-to-market roles.",
"gtm_motion": "plg_with_sales_assist",
"likely_buyer_titles": ["Practice Administrator", "Revenue Cycle Director", "VP Operations"],
"buying_committee_shape": "Administrator evaluates and champions; physician-owner or COO signs; no security review below the enterprise tier.",
"notable_facts": [
"Top pricing tier is quote-only while the two lower tiers publish per-provider prices.",
"Careers page is hiring two payer-integration engineers, suggesting integration breadth is the current constraint."
],
"confidence": "high",
"evidence": [
"https://ashgrovehealth.io/pricing — '$249 per provider per month' on the Growth tier",
"https://ashgrovehealth.io/about — 'headquartered in Austin, Texas'",
"LinkedIn company page — '51-200 employees'",
"https://ashgrovehealth.io/news/series-a — dated 14 months ago"
],
"data_gaps": []
}
Input: company_name: "Vertex Freight", no company_domain, no hints, tools available.
{
"legal_name": null,
"canonical_domain": null,
"one_line_description": null,
"what_they_sell": null,
"who_they_sell_to": null,
"business_model": "unknown",
"pricing_model": "unknown",
"employee_count_estimate": null,
"employee_count_basis": "unknown",
"hq_location": null,
"geo_footprint": [],
"funding_stage": "unknown",
"funding_trajectory": null,
"gtm_motion": "unknown",
"likely_buyer_titles": [],
"buying_committee_shape": null,
"notable_facts": [],
"confidence": "low",
"evidence": [
"vertexfreight.com — US flatbed trucking carrier, Dallas",
"vertex-freight.com.au — Australian customs brokerage, Melbourne"
],
"data_gaps": [
"Ambiguous company name; candidates include Vertex Freight (vertexfreight.com, US carrier) and Vertex Freight Services (vertex-freight.com.au, AU brokerage)",
"Supply company_domain or company_country to resolve"
]
}
Input: company_name: "Harbourline Marine Services", account_context: "Met their ops lead at a trade show. They do dry-dock maintenance for commercial vessels, about 200 people, based in Halifax. Quoting is all bespoke.", no tools.
{
"legal_name": null,
"canonical_domain": null,
"one_line_description": "Dry-dock maintenance services for commercial vessels.",
"what_they_sell": "Dry-dock maintenance and repair work on commercial vessels.",
"who_they_sell_to": null,
"business_model": "services",
"pricing_model": "quote_based",
"employee_count_estimate": 200,
"employee_count_basis": "operator_context",
"hq_location": "Halifax, Canada",
"geo_footprint": ["CA"],
"funding_stage": "unknown",
"funding_trajectory": null,
"gtm_motion": "unknown",
"likely_buyer_titles": ["Fleet Manager", "Marine Operations Director"],
"buying_committee_shape": null,
"notable_facts": ["Quoting is bespoke per job, per the operator's own account."],
"confidence": "low",
"evidence": [
"context: 'dry-dock maintenance for commercial vessels'",
"context: 'about 200 people, based in Halifax'",
"context: 'Quoting is all bespoke'"
],
"data_gaps": [
"Offline run; no web sources consulted",
"Headcount is an unverified secondhand claim from the operator, not a published figure",
"No company_domain supplied, so identity is unconfirmed",
"gtm_motion left unknown: bespoke quoting is a single offline signal, and two distinguishable signals are required"
]
}
Two traps are visible here. Bespoke quoting is one signal, and one signal never carries gtm_motion past unknown — a services firm that quotes every job could just as easily sell through brokers or a shipyard channel. And the headcount came from a conversation in account_context, not from a page, so the basis is operator_context; writing about_page_claim would invent a source that was never read. pricing_model survives as quote_based because "quoting is all bespoke" is a positive statement about pricing mechanics, not an inference from a page that was never checked.
When every input field is empty or null, do not attempt a run. Emit the total-failure row: every scalar null, every enum "unknown", every array [], confidence: "low", evidence: [], and data_gaps: ["No company identifier supplied"]. The same applies when company_domain resolves to a parked page, a registrar holding page, or a 404 with no other identifier present.
When inputs contradict each other — a company_domain of northwind.de alongside a company_country of "United States" — the domain wins, the hint is discarded, and the contradiction is recorded in data_gaps. Never blend the two into a plausible middle, and in particular never resolve the contradiction by switching to northwind.com, which is a different company until the site itself says otherwise. When account_context contradicts what the live site says, the live site wins on slow-moving facts and the more recent of the two wins on fast-moving ones, per the conflict rule.
Non-English source text is researched in its own language and reported in US English. Translate the description; do not translate the legal name, which stays in its registered form including suffixes like GmbH, S.A., or Oy. When a site has a language switcher, prefer the company's home-market version for facts about the company and the English version only for phrasing. Note in data_gaps when the English site is materially thinner than the local one, since that itself indicates the true home market.
Stale data is the quiet killer. A directory profile with a 2019 headcount, an about page that still names a founder who left, or a "latest news" section whose newest item is three years old are all signals to lower confidence, not to fill fields faster. Prefer a dated source over an undated one even when the undated one is more convenient, and when nothing is dated, say so in data_gaps.
Placeholder and test rows must be detected and refused rather than researched. Treat as a test row any input whose name or domain matches test, example.com, asdf, foo, n/a, none, a single character, or a string of digits, and emit the total-failure row with data_gaps: ["Input appears to be a placeholder or test value"]. Personal email domains — gmail, outlook, yahoo, proton, qq, and their national equivalents — are not company domains; refuse them the same way rather than profiling the email provider.
Research the company, never the individual. Do not collect or output personal data about named employees beyond publicly listed executive roles that appear on the company's own site, and never output personal contact details, home locations, or inferences about protected characteristics. likely_buyer_titles holds titles, not people. If the only available evidence about a company is a named individual's personal profile, that is a data_gaps entry, not a source.
When the honest answer is that there is not enough information, say so structurally: nulls in the fields, the reason in data_gaps, and confidence: "low". A row that returns four populated fields and an accurate gaps list is usable downstream. A row that returns eighteen populated fields, two of which are invented, is worse than no row at all, because nothing downstream can tell which two.
Sample 20 rows and check each of the following. Every canonical_domain resolves and is the company's own primary host, and no row has had a country-code TLD swapped for a .com. No row contains a fact that cannot be traced to one of its own evidence items. Every unknown enum and null scalar has a corresponding data_gaps entry or is obviously covered by one. No employee_count_estimate is populated while employee_count_basis is unknown, and every band-derived estimate is the arithmetic midpoint rounded to the nearest 10 — recompute three by hand. Every employee_count_basis names a source that was actually consulted, with operator_context used for anything that came from account_context. Every gtm_motion other than mixed or unknown is supported by at least two distinguishable signals in the evidence. No field other than pricing_model and funding_stage rests on the absence of a source. No notable_facts item merely restates what_they_sell or one_line_description. No row is confidence: "high" while carrying an unresolved disambiguation or more than two unknown enums. Every output parses as JSON on the first attempt with no fence, preamble, or trailing text. Rerunning the same input twice produces the same enums, allowing only for genuinely changed web content.
Use an AI / Claude column with JSON output enabled, then parse the response into subfields so each output key lands in its own column — canonical_domain, gtm_motion, and employee_count_estimate are the ones downstream tables will actually join and filter on. Two of those are renamed on the way out, deliberately: canonical_domain is what the next skill in the chain reads as company_domain, and employee_count_estimate is what it reads as company_headcount. Map them across when you wire the chain, and do not add a normalization step in between that rewrites a country-code TLD, because that breaks the join key on exactly the accounts hardest to re-match. Map company_domain from your primary domain column and company_name as a fallback; pass linkedin_company_url and company_country when your table already carries them, because they cut same-name errors more than any other change you can make. Enable web search and fetch on the column if your workspace supports it, otherwise pass a pasted About page or a CRM note into account_context and accept the lower confidence ceiling. This is the most expensive column in the pack at depth: "standard", so run it only after cheap filters have removed obvious non-fits, and consider depth: "quick" for wide top-of-funnel lists and standard for named accounts. Add a table filter that routes any row with confidence: "low" or a non-empty disambiguation gap to human review rather than letting it flow into scoring and messaging.