Company search

Implementation notes for company.search. Search resolves and gates company identities; enrichment is a separate capability.

1 min read Updated Oct 5, 2026

Company search

The capability contract describes the public input and result. This page preserves reusable workflow details. It does not define a second input or result contract.

Product behavior

Company Search answers which companies match a description, supported account filters, or supplied identities. It returns selectable company references and observed display fields. It does not enrich, score or write CRM records. The Companies surface carries selected refs into Company Enrich or People Search through a separate user action.

The earlier enrich: True default is superseded. Search has no enrich, enrich_fields or depth parameter. Sonar remains a legacy surface during migration; it is not the new capability's product name.

Execution stages

The workflow has two paths. A request that supplies companies runs the waterfall tool. Every other request runs the finder Agent.

TEXT
company.search capability (root workflow)
  -> supplied companies?
       yes -> waterfall tool (tool id `company.search`)
                -> resolve supplied identities
                -> check the cache, then eligible providers in cost order
                -> merge duplicates, apply deterministic filters and the limit
       no  -> finder Agent (gpt-5.4) with company.search_exa
                -> write a checklist of hard requirements and a reject list
                -> write limits: the requirements one fact can check
                -> write Exa queries from the description and the filters
                -> check each company against the checklist; reject a contradiction
                -> check the final list against the checklist again
                -> answer domains, summaries, evidence and unconfirmed requirements
           -> company.compile_results
                -> read each domain from the company cache
                -> drop a company whose cached fact contradicts a filter or a limit
                -> put the companies with the fewest unconfirmed requirements first
                -> build the frozen output; the model-written summary becomes the company description field
  -> persist tenant-owned Run output

The public sources are supplied and provider_discovery; a caller may request both. provider_discovery is one product source, not a request to use one provider. The supplied-only tool keeps its unmetered grant.

Sources

PathSourcesDetail
Finder AgentExa company searchThe Agent judges fit from the facts and highlights Exa returns. A missing fact does not reject a company.
Waterfall toolCache, Hunter, Parallel, Exa, FullEnrich, then ExploriumStop when enough matching identities are found; report usage. Signals uses this path.

company.search_exa writes each company it finds to the company cache. The compile step reads every row from the cache, so each fact comes from Exa. Only the summary comes from the model. A domain the cache does not hold counts as unresolved. Exa company search answers the head office, the employee count, the founding year, the latest funding round with its date and amount, the total funding and the annual revenue in US dollars. It also answers highlights: page text that Exa selects for the query. The round, its date, the amount and the revenue reach the cache in canonical form. The total funding and the highlights reach the Agent only. The model does not know today's date, so the tool also states the age of each round in months. Exa company results carry no industry. The Agent judges an industry from the description, and no code check reads it. The finder Agent rejects agencies, directories, investors, universities and acquired companies, unless the request asks for them. A request runs one path, never both. The workflow ceiling is 450 cents. The Agent path spends at most 150 cents, its own Agent ceiling. The waterfall path spends at most 400 cents, the ceiling of the waterfall tool.

Identity resolution

Use the shared domain normalization rule. Use company LinkedIn when a valid company has no domain. A company name is evidence, never a deduplication key. Conflicting canonical keys require an explicit conflict result. An unresolved row stays visible in diagnostics. Do not silently drop a domainless company that has a valid LinkedIn identity. Search returns canonical IDs only when known. It does not create an Intelligence row merely to obtain a selectable ref.

The gate

The waterfall path has a deterministic gate. The gate applies supported filters to the merged observations. Discovery sources may fill the remaining target before this gate.

The finder Agent path has a code check in company.compile_results. It reads the cached facts of each company the Agent selected. It drops a company when a fact contradicts a filter the caller set, or a limit the Agent wrote. The limits are the head office country, the latest funding round, the age of that round in months, the employee count, the annual revenue in US dollars and the founding year. The age limit reads intel_companies.funding_date. A limit that code cannot read does not apply. A missing fact never drops a company. Customer group and business type have no stored fact, so only the Agent checks them. Keep a row when a field is unknown and report unknown_filter_value. Drop a row only when an observed value contradicts a filter. Record filter_mismatch and funnel counts. A row with unknown size does not become a verified size match. The closed filter contract rejects unsupported values. Free-text descriptions can also guide the semantic providers without becoming a verified filter.

Deduplication, gates and result limits use stable ordering. A replay cannot choose a different winning observation. Keep per-source status and unresolved counts when one source fails. Total source failure is not a successful empty roster. The design scenarios cover these cases.

Reuse from Signals

Signals owns signal research and scoring. It may pass discovered candidates through the supplied search source. It then calls Company Enrich explicitly when fields are needed. This composition preserves signal discovery without adding a web source or automatic enrichment to Company Search.

Legacy diagram

The diagram below records the superseded automatic-enrichment design. Its enrichment step, four-source claim and trailing scoring path are not the current contract.

Company search — find the companies, and nothing else
Company search — find the companies, and nothing else
Discovery picks the instrument that fits the brief. Enrichment is invoked, not embedded. A signal is read from the store, never searched again.
Discovery picks the instrument that fits the brief. Enrichment is invoked, not embedded. A signal is read from the store, never searched again.
workflow/company_search.requested
{ brief, sources[], target_count, depth, enrich }
workflow/company_search.requested{ brief, sources[], target_count, depth, enrich }
workflow.company_search  ·  Inngest orchestrator (thin step spine)
workflow.company_search · Inngest orchestrator (thin step spine)
1 · prepare
parse event · preflight · start run
1 · prepareparse event · preflight · start run
2 · compile the brief
request → CompanySearchBrief
2 · compile the briefrequest → CompanySearchBrief
3 · discover
ctx.group.parallel over the enabled sources
3 · discoverctx.group.parallel over the enabled sources
4 · resolve identity
candidate → one domain, then dedup
4 · resolve identitycandidate → one domain, then dedup
5 · invoke enrichment
ON by default — the roster comes back filled
5 · invoke enrichmentON by default — the roster comes back filled
6 · gate
hard filters · one serial pass · no refill
6 · gatehard filters · one serial pass · no refill
7 · persist + finalize
store the roster, then dispatch scoring
7 · persist + finalizestore the roster, then dispatch scoring
Pick the instrument the brief deserves
a firmographic brief, a thesis brief and an event brief are three different searches — the old spine ran only one of them
Pick the instrument the brief deservesa firmographic brief, a thesis brief and an event brief are three different searches — the old spine ran only one of them
Sources  ·  component.company.search.source.*  ·  each one self-gates on the brief
Sources · component.company.search.source.* · each one self-gates on the brief
.explorium  firmographic provider query
size · country · industry · revenue
deterministic, no agent, one billed call
.explorium firmographic provider querysize · country · industry · revenuedeterministic, no agent, one billed call
.signals_store  SQL read on intel_signals
the change: a stored signal costs one query. Sonar searched the web for the same fact on every run and then threw the evidence away.
.signals_store SQL read on intel_signalsthe change: a stored signal costs one query. Sonar searched the web for thesame fact on every run and then threw the evidence away.
.exa · .parallel  planner agent + web search
for a thesis no database holds. The planner now plans a COMPANY search — the signal extractor is gone from this lane.
.exa · .parallel planner agent + web searchfor a thesis no database holds. The planner now plans a COMPANY search —the signal extractor is gone from this lane.
.supplied  the caller's own list
a named list needs a gate, not a search. Import and the CRM enter here.
.supplied the caller's own lista named list needs a gate, not a search. Import and the CRM enter here.
Identity resolve + dedup  ·  deterministic
one domain per candidate · dedup on the domain · a candidate that resolves to no domain is dropped
it asserts identity only, and never a firmographic field
Identity resolve + dedup · deterministicone domain per candidate · dedup on the domain · a candidate that resolves to no domain is droppedit asserts identity only, and never a firmographic field
workflow.enrichment  ·  INVOKED, NOT EMBEDDED  ·  enrich=True IS THE DEFAULT
workflow.enrichment · INVOKED, NOT EMBEDDED · enrich=True IS THE DEFAULT
subject_type=company  refresh=stale
ownership moves out, the fields do not: the result carries dict[FieldName, FilledField] — value, source and filled_at
enrich=False is the opt-out, for a caller that needs identity alone
subject_type=company refresh=staleownership moves out, the fields do not: the result carries dict[FieldName, FilledField] — value, source and filled_atenrich=False is the opt-out, for a caller that needs identity alone
Gate  exclusions · country · industry · size · revenue · recency
keep on unknown. A filter drops a row only on a present, contradicting value. One serial pass has no refill, so an over-drop is permanent.
what no field can prove (funding bands · SEIS/EIS · semantic excludes) does not gate — it rides to the judge
Gate exclusions · country · industry · size · revenue · recencykeep on unknown. A filter drops a row only on a present, contradicting value. One serial pass has no refill, so an over-drop ispermanent.what no field can prove (funding bands · SEIS/EIS · semantic excludes) does not gate — it rides to the judge
Persist the ENRICHED roster — unscored
workflow_run_companies
results render before the judge runs
Persist the ENRICHED roster — unscoredworkflow_run_companiesresults render before the judge runs
lead_score_background
dispatched after finalize
patches badges in place
lead_score_backgrounddispatched after finalizepatches badges in place
What this workflow stopped doing

Sonar discovered companies by searching the web for signal news, then grouped the news into companies.

A firmographic brief has no news to find, so the best instrument for it never ran.

It also enriched inline, so the CRM could not reach the waterfall without a fake search.
What this workflow stopped doingSonar discovered companies by searchingthe web for signal news, then grouped thenews into companies.A firmographic brief has no news to find,so the best instrument for it never ran.It also enriched inline, so the CRM couldnot reach the waterfall without a fakesearch.
Signals are a store, not a search

workflow.signals_search fills intel_signals. This workflow reads it.

One agent run pays for the fact once. Every later brief that wants the same fact pays for one query.

That is the whole reason the two were split.
Signals are a store, not a searchworkflow.signals_search fillsintel_signals. This workflow reads it.One agent run pays for the fact once.Every later brief that wants the same factpays for one query.That is the whole reason the two weresplit.
Why the gate stays here

Only search holds the brief. Enrichment holds no opinion about a match, and it must not learn one — four other callers use it.

So enrichment fills the fields and hands them back. The gate is the next step, in this workflow.
Why the gate stays hereOnly search holds the brief. Enrichmentholds no opinion about a match, and itmust not learn one — four other callersuse it.So enrichment fills the fields and handsthem back. The gate is the next step, inthis workflow.
Legend
Legend
trigger event
trigger event
costs an LLM call
costs an LLM call
the branch point
the branch point
deterministic step
deterministic step
durable write
durable write
dashed = another workflow, or a trailing lane
dashed = another workflow, or a trailing lane
brief
brief
sources[]
sources[]
candidates
candidates
domains
domains
filled fields
filled fields
matched
matched
Text is not SVG - cannot display
Company search — find the companies, and nothing else
Company search — find the companies, and nothing else
Discovery picks the instrument that fits the brief. Enrichment is invoked, not embedded. A signal is read from the store, never searched again.
Discovery picks the instrument that fits the brief. Enrichment is invoked, not embedded. A signal is read from the store, never searched again.
workflow/company_search.requested
{ brief, sources[], target_count, depth, enrich }
workflow/company_search.requested{ brief, sources[], target_count, depth, enrich }
workflow.company_search  ·  Inngest orchestrator (thin step spine)
workflow.company_search · Inngest orchestrator (thin step spine)
1 · prepare
parse event · preflight · start run
1 · prepareparse event · preflight · start run
2 · compile the brief
request → CompanySearchBrief
2 · compile the briefrequest → CompanySearchBrief
3 · discover
ctx.group.parallel over the enabled sources
3 · discoverctx.group.parallel over the enabled sources
4 · resolve identity
candidate → one domain, then dedup
4 · resolve identitycandidate → one domain, then dedup
5 · invoke enrichment
ON by default — the roster comes back filled
5 · invoke enrichmentON by default — the roster comes back filled
6 · gate
hard filters · one serial pass · no refill
6 · gatehard filters · one serial pass · no refill
7 · persist + finalize
store the roster, then dispatch scoring
7 · persist + finalizestore the roster, then dispatch scoring
Pick the instrument the brief deserves
a firmographic brief, a thesis brief and an event brief are three different searches — the old spine ran only one of them
Pick the instrument the brief deservesa firmographic brief, a thesis brief and an event brief are three different searches — the old spine ran only one of them
Sources  ·  component.company.search.source.*  ·  each one self-gates on the brief
Sources · component.company.search.source.* · each one self-gates on the brief
.explorium  firmographic provider query
size · country · industry · revenue
deterministic, no agent, one billed call
.explorium firmographic provider querysize · country · industry · revenuedeterministic, no agent, one billed call
.signals_store  SQL read on intel_signals
the change: a stored signal costs one query. Sonar searched the web for the same fact on every run and then threw the evidence away.
.signals_store SQL read on intel_signalsthe change: a stored signal costs one query. Sonar searched the web for thesame fact on every run and then threw the evidence away.
.exa · .parallel  planner agent + web search
for a thesis no database holds. The planner now plans a COMPANY search — the signal extractor is gone from this lane.
.exa · .parallel planner agent + web searchfor a thesis no database holds. The planner now plans a COMPANY search —the signal extractor is gone from this lane.
.supplied  the caller's own list
a named list needs a gate, not a search. Import and the CRM enter here.
.supplied the caller's own lista named list needs a gate, not a search. Import and the CRM enter here.
Identity resolve + dedup  ·  deterministic
one domain per candidate · dedup on the domain · a candidate that resolves to no domain is dropped
it asserts identity only, and never a firmographic field
Identity resolve + dedup · deterministicone domain per candidate · dedup on the domain · a candidate that resolves to no domain is droppedit asserts identity only, and never a firmographic field
workflow.enrichment  ·  INVOKED, NOT EMBEDDED  ·  enrich=True IS THE DEFAULT
workflow.enrichment · INVOKED, NOT EMBEDDED · enrich=True IS THE DEFAULT
subject_type=company  refresh=stale
ownership moves out, the fields do not: the result carries dict[FieldName, FilledField] — value, source and filled_at
enrich=False is the opt-out, for a caller that needs identity alone
subject_type=company refresh=staleownership moves out, the fields do not: the result carries dict[FieldName, FilledField] — value, source and filled_atenrich=False is the opt-out, for a caller that needs identity alone
Gate  exclusions · country · industry · size · revenue · recency
keep on unknown. A filter drops a row only on a present, contradicting value. One serial pass has no refill, so an over-drop is permanent.
what no field can prove (funding bands · SEIS/EIS · semantic excludes) does not gate — it rides to the judge
Gate exclusions · country · industry · size · revenue · recencykeep on unknown. A filter drops a row only on a present, contradicting value. One serial pass has no refill, so an over-drop ispermanent.what no field can prove (funding bands · SEIS/EIS · semantic excludes) does not gate — it rides to the judge
Persist the ENRICHED roster — unscored
workflow_run_companies
results render before the judge runs
Persist the ENRICHED roster — unscoredworkflow_run_companiesresults render before the judge runs
lead_score_background
dispatched after finalize
patches badges in place
lead_score_backgrounddispatched after finalizepatches badges in place
What this workflow stopped doing

Sonar discovered companies by searching the web for signal news, then grouped the news into companies.

A firmographic brief has no news to find, so the best instrument for it never ran.

It also enriched inline, so the CRM could not reach the waterfall without a fake search.
What this workflow stopped doingSonar discovered companies by searchingthe web for signal news, then grouped thenews into companies.A firmographic brief has no news to find,so the best instrument for it never ran.It also enriched inline, so the CRM couldnot reach the waterfall without a fakesearch.
Signals are a store, not a search

workflow.signals_search fills intel_signals. This workflow reads it.

One agent run pays for the fact once. Every later brief that wants the same fact pays for one query.

That is the whole reason the two were split.
Signals are a store, not a searchworkflow.signals_search fillsintel_signals. This workflow reads it.One agent run pays for the fact once.Every later brief that wants the same factpays for one query.That is the whole reason the two weresplit.
Why the gate stays here

Only search holds the brief. Enrichment holds no opinion about a match, and it must not learn one — four other callers use it.

So enrichment fills the fields and hands them back. The gate is the next step, in this workflow.
Why the gate stays hereOnly search holds the brief. Enrichmentholds no opinion about a match, and itmust not learn one — four other callersuse it.So enrichment fills the fields and handsthem back. The gate is the next step, inthis workflow.
Legend
Legend
trigger event
trigger event
costs an LLM call
costs an LLM call
the branch point
the branch point
deterministic step
deterministic step
durable write
durable write
dashed = another workflow, or a trailing lane
dashed = another workflow, or a trailing lane
brief
brief
sources[]
sources[]
candidates
candidates
domains
domains
filled fields
filled fields
matched
matched
Text is not SVG - cannot display
Legacy reference only. Current Company Search uses a finder Agent with Exa, and a waterfall for supplied identities, without automatic enrichment.