Signals search

Superseded. The earlier on-demand buying-signal search component, kept as a record of the design that the agentic platform Signals Search workflow replaces.

1 min read Updated Aug 28, 2026

Signals search

Superseded. Read Signals Search instead. That page is the current design. It replaces the split between Sonar company discovery and Headhunter people discovery with one bounded workflow.

This page records the earlier Inngest component. It names workflow.signals_search, components/signals/ and src/agents/signals/, which the agentic platform replaces. Read it for history, not to build from.

Status: superseded · Superseded: 2026-08-28 · Original date: 2026-08-12


1. What we're building

On-demand search for buying signals across companies and people, packaged as a reusable component rather than a standalone app.

RequirementHow it lands
Search for all kinds of buying signalsFree-text query + optional signal_types[] filter
Company and person signals bothOne polymorphic store, subject_type discriminates
Always extract the company, and the person when namedExtraction contract on the agent, resolution deterministic
No enrichment inside the extractionAgent may not assert what the source doesn't state
The signal must be actionableA follow-up stage resolves and enriches the subject (§6)
Reusable from a workflow step or from chatInngest component + typed payload, callers deferred

This is the on-demand search producer from the decided signals intelligence database. It defines no new storage. It writes into the existing intel_signals spine alongside the manual, monitor and scraper producers.

A signal on its own is a fact with no address. "Acme raised a Series B" is useful only when we hold Acme's domain, its firmographics, and the people to contact. So the workflow does not stop at the write. It hands every new subject to company search, people search and enrichment. That follow-up is §6.

Out of scope, deliberately: per-org scoring and feed materialization (lives above the store), and the caller surfaces (workflow step / chat / user-composed workflow — designed for, not built yet).

2. The core decision: route by detection mechanism

The twelve signal types don't differ by topic. They differ by how the fact is detected, and only one of the two mechanisms is a search problem.

An agent with web search reads published prose. It cannot establish an absence or a delta. started_meta_ads means the company had no ads yesterday and has ads today; no quantity of web search proves the "yesterday" half. Same for a creative-count jump, a tech-stack change, a profile change. An agent handed one of those types emits plausible output with no way to know it's wrong — the worst failure mode available, because nothing downstream can catch it either.

Routing those types away from the agent is a ~20-line rule and it deletes the entire failure class. It is a larger quality lever than any tooling, prompting or model choice applied to the agent itself.

2.1 Taxonomy by lane

LaneSubjectTypesMechanismInstrument
A · open-web readcompanyfunding_round, executive_change, rebrand_or_relaunchevidence exists as published prosemanaged agent + search/fetch
A · open-web readpersonthought_leadership, speaking_eventevidence exists as published prosethe same agent, the same session
B · fetched snapshotcompanystarted_meta_ads, scaled_meta_ads, hired_growth_role, new_tech_stacka delta needs yesterday's snapshotdeterministic collectors against real APIs
B · fetched snapshotpersonjob_change, promotion, tenure_milestonea delta needs yesterday's snapshot, a tenure needs today'slicensed provider, fetched live

The subject is a second axis, not a second lane. Both subject types appear in both lanes, so the routing rule stays one rule: it splits by detection mechanism only. The subject decides which follow-up runs in §6, and nothing else.

A type asked of the wrong lane is rejected, never guessed. A caller passing signal_types: [started_meta_ads] to lane A gets an explicit rejection in rejected_types, not an invented signal.

On tenure_milestone: it's arithmetic, so it looks like a third "derived" lane. It isn't. The arithmetic is trivial; the role start date is what goes stale. Derived from a cached intel_people row it is confidently wrong — the person may have moved months ago, and we'd nudge them about a job they left. It belongs in lane B, derived from the same live provider fetch that already supplies job_change and promotion. Freshness is a precondition on the read, not a lane in the taxonomy.

3. Architecture

Signals Search — routed by detection mechanism, not by topic
Signals Search — routed by detection mechanism, not by topic
Only one of the two mechanisms is a search problem. Routing the other away from the agent is the biggest quality lever — it removes a whole class of hallucination.
Only one of the two mechanisms is a search problem. Routing the other away from the agent is the biggest quality lever — it removes a whole class of hallucination.
workflow/signals_search.requested
{ query, signal_types[], lookback_days, geo, target_per_type, depth }
workflow/signals_search.requested{ query, signal_types[], lookback_days, geo, target_per_type, depth }
workflow.signals_search  ·  Inngest orchestrator (thin step spine)
workflow.signals_search · Inngest orchestrator (thin step spine)
1 · prepare + preflight
budget guard · mock flag
1 · prepare + preflightbudget guard · mock flag
2 · start run
workflow_runs row + SSE open
2 · start runworkflow_runs row + SSE open
3 · route signal_types[]
split by detection mechanism
3 · route signal_types[]split by detection mechanism
4 · invoke the lane components
ctx.group.parallel — fan out by lane, never by type
4 · invoke the lane componentsctx.group.parallel — fan out by lane, never by type
5 · invoke signals.ingest
one write path, every lane
5 · invoke signals.ingestone write path, every lane
6 · follow up
resolve + enrich each new subject
6 · follow upresolve + enrich each new subject
Route by detection mechanism
a type asked of the wrong lane is rejected, never guessed — an agent cannot establish an absence, a delta, or a live profile fact
Route by detection mechanisma type asked of the wrong lane is rejected, never guessed — an agent cannot establish an absence, a delta, or a live profile fact
A · open-web read  ·  component.signals.search  ·  AGENT
A · open-web read · component.signals.search · AGENT
5 types  evidence exists as published prose
company funding_round · executive_change · rebrand_or_relaunch
person thought_leadership · speaking_event
5 types evidence exists as published prosecompany funding_round · executive_change · rebrand_or_relaunchperson thought_leadership · speaking_event
Managed agent session
one session covers every requested type — one prompt, five sections (source allowlist, date rule, payload shape); a query family per type before any one is deepened

no per-type skills, subagents, tools or fan-out until a type needs its own eval gate

guardrails: effort · task_budget · max_searches · reaper — budget and search cap scale with the requested type count
Managed agent sessionone session covers every requested type — one prompt, five sections (sourceallowlist, date rule, payload shape); a query family per type before any one isdeepenedno per-type skills, subagents, tools or fan-out until a type needs its own evalgateguardrails: effort · task_budget · max_searches · reaper — budget and searchcap scale with the requested type count
tools — 2 in context, 3 providers behind them
search(...) → Exa + Parallel, merged · fetch(url) → Firecrawl

the agent picks the query, never the vendor — no feedback loop on index quality, and the engines benchmark as a tie. Fan-out keyed to depth.

our clients, not vendor MCP / CLI / native web_search — the Redis limiter paces under per-account ceilings across concurrent runs and ai_usage_log keeps spend attributable.
tools — 2 in context, 3 providers behind themsearch(...) → Exa + Parallel, merged · fetch(url) → Firecrawlthe agent picks the query, never the vendor — no feedback loop on indexquality, and the engines benchmark as a tie. Fan-out keyed to depth.our clients, not vendor MCP / CLI / native web_search — the Redis limiterpaces under per-account ceilings across concurrent runs and ai_usage_logkeeps spend attributable.
sources.json verbatim provider payloads  +  signals.json extracted rows
+ per_type — signal count, queries run and zero_result_reason for each requested type
sources.json verbatim provider payloads + signals.json extracted rows+ per_type — signal count, queries run and zero_result_reason for eachrequested type
B · fetched snapshot  ·  collect.*  ·  DETERMINISTIC
B · fetched snapshot · collect.* · DETERMINISTIC
7 types — 4 company, 3 person. Each needs a fetched snapshot, not a search: a delta needs yesterday's, a tenure needs today's. No agent can produce either.
The subject is a second axis, not a second lane — the routing rule still splits by mechanism only.
7 types — 4 company, 3 person. Each needs a fetched snapshot,not a search: a delta needs yesterday's, a tenure needs today's.No agent can produce either.The subject is a second axis, not a second lane — the routingrule still splits by mechanism only.
collect.meta_ads  Meta Ad Library API
started_meta_ads · scaled_meta_ads
collect.meta_ads Meta Ad Library APIstarted_meta_ads · scaled_meta_ads
collect.job_boards  Greenhouse · Lever
hired_growth_role
collect.job_boards Greenhouse · Leverhired_growth_role
collect.tech_stack  fetch + fingerprint diff
new_tech_stack · the rebrand sitemap half
collect.tech_stack fetch + fingerprint diffnew_tech_stack · the rebrand sitemap half
collect.people_diff  licensed provider, fetched live
job_change · promotion — snapshot diff
tenure_milestone — derived from the same fresh fetch, never from a cached intel_people row
never LinkedIn scraping: no legitimate API or MCP exists
collect.people_diff licensed provider, fetched livejob_change · promotion — snapshot difftenure_milestone — derived from the same fresh fetch, neverfrom a cached intel_people rownever LinkedIn scraping: no legitimate API or MCP exists
Why the split beats better tooling

started_meta_ads means the company had no ads yesterday. No amount of web search establishes an absence, so an agent handed a lane-B type produces plausible garbage with no way to know it is wrong.

Routing it away is a ~20-line rule that deletes the whole failure class.
Why the split beats better toolingstarted_meta_ads means the company hadno ads yesterday. No amount of web searchestablishes an absence, so an agent handeda lane-B type produces plausible garbagewith no way to know it is wrong.Routing it away is a ~20-line rule thatdeletes the whole failure class.
Freshness is a precondition, not a lane

tenure_milestone is arithmetic, so it looked like a third “derived” lane. It is not — the arithmetic is trivial and the role start date is what goes stale. Derived from a cached row it is confidently wrong: the person may have moved months ago.

Any derived signal inherits the staleness of the snapshot it reads, so the derive runs on the fetch, not on the store — and that fetch is already being made for job_change.
Freshness is a precondition, not a lanetenure_milestone is arithmetic, so it lookedlike a third “derived” lane. It is not — thearithmetic is trivial and the role start date iswhat goes stale. Derived from a cached rowit is confidently wrong: the person may havemoved months ago.Any derived signal inherits the staleness ofthe snapshot it reads, so the derive runs onthe fetch, not on the store — and that fetchis already being made for job_change.
Why ingest is its own component

Both lanes, plus manual entry, share one normalize → dedup → append path, so adding a collector adds no write code.

It also keeps signals.search swappable: if the agent proves too slow or costly, a deterministic Exa / Parallel fan-out drops into lane A and nothing downstream changes.
Why ingest is its own componentBoth lanes, plus manual entry, share onenormalize → dedup → append path, soadding a collector adds no write code.It also keeps signals.search swappable: ifthe agent proves too slow or costly, adeterministic Exa / Parallel fan-out dropsinto lane A and nothing downstreamchanges.
Manual entry
user-asserted fact
same spine, dedup still guards
Manual entryuser-asserted factsame spine, dedup still guards
component.signals.ingest  ·  DETERMINISTIC  ·  the one write path
component.signals.ingest · DETERMINISTIC · the one write path
Normalize
domain → intel_companies
linkedin_url → intel_people

stamp subject_type + subject_id
rollup → related_company_id
(a person hops to the employer)
Normalizedomain → intel_companieslinkedin_url → intel_peoplestamp subject_type + subject_idrollup → related_company_id(a person hops to the employer)
Dedup
dedup_key = hash(subject · type · window)

MISS → new intel_signals row
HIT → no new signal; attach the new outlet as a source link
Dedupdedup_key = hash(subject · type ·window)MISS → new intel_signals rowHIT → no new signal; attach thenew outlet as a source link
Append — INSERT only
intel_sources
intel_signals
intel_signal_sources

no UPDATE, no DELETE
decay applied at read time
Append — INSERT onlyintel_sourcesintel_signalsintel_signal_sourcesno UPDATE, no DELETEdecay applied at read time
Signals intelligence database  ·  RLS deny-all, ac-python-api service role is the only door
Signals intelligence database · RLS deny-all, ac-python-api service role is the only door
intel_sources
raw provider payload
+ cost, one row per fetch
intel_sourcesraw provider payload+ cost, one row per fetch
intel_signals
append-only, polymorphic
subject_type · subject_id
related_company_id · dedup_key
intel_signalsappend-only, polymorphicsubject_type · subject_idrelated_company_id · dedup_key
intel_signal_sources
signal ↔ source link
is_primary on the discovering fetch
intel_signal_sourcessignal ↔ source linkis_primary on the discovering fetch
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 not built yet
dashed = another workflow, or not built yet
signal_types[]
signal_types[]
A
A
B
B
SignalSearchResult
SignalSearchResult
append
append
7 · finalize
counts · funnel · SSE done
7 · finalizecounts · funnel · SSE done
Follow-up — make the signal actionable  ·  dispatched AFTER the run completes
Follow-up — make the signal actionable · dispatched AFTER the run completes
company_search
resolve mode — a name in, one domain out

no planner agent, no vendor search: a signal already names its subject
company_searchresolve mode — a name in, onedomain outno planner agent, no vendorsearch: a signal already names itssubject
people_search
resolve mode — a name in, one linkedin_url out

a person signal also rolls up: job_change names a company that is often new to us
people_searchresolve mode — a name in, onelinkedin_url outa person signal also rolls up:job_change names a companythat is often new to us
enrichment
refresh=stale, so it can replace an employer that job_change just invalidated

a fresh subject costs one read
enrichmentrefresh=stale, so it can replacean employer that job_changejust invalidateda fresh subject costs one read
Why the follow-up is a dispatch

A signal is a fact with no address. "Acme raised a Series B" is useful only when we hold Acme's domain, its firmographics and the people to contact.

But the email lane alone adds 50–150s per person, so the roster ships first and the addresses fill in behind it.
Why the follow-up is a dispatchA signal is a fact with no address. "Acmeraised a Series B" is useful only when wehold Acme's domain, its firmographics andthe people to contact.But the email lane alone adds 50–150s perperson, so the roster ships first and theaddresses fill in behind it.
new subjects
new subjects
Text is not SVG - cannot display
Signals Search — routed by detection mechanism, not by topic
Signals Search — routed by detection mechanism, not by topic
Only one of the two mechanisms is a search problem. Routing the other away from the agent is the biggest quality lever — it removes a whole class of hallucination.
Only one of the two mechanisms is a search problem. Routing the other away from the agent is the biggest quality lever — it removes a whole class of hallucination.
workflow/signals_search.requested
{ query, signal_types[], lookback_days, geo, target_per_type, depth }
workflow/signals_search.requested{ query, signal_types[], lookback_days, geo, target_per_type, depth }
workflow.signals_search  ·  Inngest orchestrator (thin step spine)
workflow.signals_search · Inngest orchestrator (thin step spine)
1 · prepare + preflight
budget guard · mock flag
1 · prepare + preflightbudget guard · mock flag
2 · start run
workflow_runs row + SSE open
2 · start runworkflow_runs row + SSE open
3 · route signal_types[]
split by detection mechanism
3 · route signal_types[]split by detection mechanism
4 · invoke the lane components
ctx.group.parallel — fan out by lane, never by type
4 · invoke the lane componentsctx.group.parallel — fan out by lane, never by type
5 · invoke signals.ingest
one write path, every lane
5 · invoke signals.ingestone write path, every lane
6 · follow up
resolve + enrich each new subject
6 · follow upresolve + enrich each new subject
Route by detection mechanism
a type asked of the wrong lane is rejected, never guessed — an agent cannot establish an absence, a delta, or a live profile fact
Route by detection mechanisma type asked of the wrong lane is rejected, never guessed — an agent cannot establish an absence, a delta, or a live profile fact
A · open-web read  ·  component.signals.search  ·  AGENT
A · open-web read · component.signals.search · AGENT
5 types  evidence exists as published prose
company funding_round · executive_change · rebrand_or_relaunch
person thought_leadership · speaking_event
5 types evidence exists as published prosecompany funding_round · executive_change · rebrand_or_relaunchperson thought_leadership · speaking_event
Managed agent session
one session covers every requested type — one prompt, five sections (source allowlist, date rule, payload shape); a query family per type before any one is deepened

no per-type skills, subagents, tools or fan-out until a type needs its own eval gate

guardrails: effort · task_budget · max_searches · reaper — budget and search cap scale with the requested type count
Managed agent sessionone session covers every requested type — one prompt, five sections (sourceallowlist, date rule, payload shape); a query family per type before any one isdeepenedno per-type skills, subagents, tools or fan-out until a type needs its own evalgateguardrails: effort · task_budget · max_searches · reaper — budget and searchcap scale with the requested type count
tools — 2 in context, 3 providers behind them
search(...) → Exa + Parallel, merged · fetch(url) → Firecrawl

the agent picks the query, never the vendor — no feedback loop on index quality, and the engines benchmark as a tie. Fan-out keyed to depth.

our clients, not vendor MCP / CLI / native web_search — the Redis limiter paces under per-account ceilings across concurrent runs and ai_usage_log keeps spend attributable.
tools — 2 in context, 3 providers behind themsearch(...) → Exa + Parallel, merged · fetch(url) → Firecrawlthe agent picks the query, never the vendor — no feedback loop on indexquality, and the engines benchmark as a tie. Fan-out keyed to depth.our clients, not vendor MCP / CLI / native web_search — the Redis limiterpaces under per-account ceilings across concurrent runs and ai_usage_logkeeps spend attributable.
sources.json verbatim provider payloads  +  signals.json extracted rows
+ per_type — signal count, queries run and zero_result_reason for each requested type
sources.json verbatim provider payloads + signals.json extracted rows+ per_type — signal count, queries run and zero_result_reason for eachrequested type
B · fetched snapshot  ·  collect.*  ·  DETERMINISTIC
B · fetched snapshot · collect.* · DETERMINISTIC
7 types — 4 company, 3 person. Each needs a fetched snapshot, not a search: a delta needs yesterday's, a tenure needs today's. No agent can produce either.
The subject is a second axis, not a second lane — the routing rule still splits by mechanism only.
7 types — 4 company, 3 person. Each needs a fetched snapshot,not a search: a delta needs yesterday's, a tenure needs today's.No agent can produce either.The subject is a second axis, not a second lane — the routingrule still splits by mechanism only.
collect.meta_ads  Meta Ad Library API
started_meta_ads · scaled_meta_ads
collect.meta_ads Meta Ad Library APIstarted_meta_ads · scaled_meta_ads
collect.job_boards  Greenhouse · Lever
hired_growth_role
collect.job_boards Greenhouse · Leverhired_growth_role
collect.tech_stack  fetch + fingerprint diff
new_tech_stack · the rebrand sitemap half
collect.tech_stack fetch + fingerprint diffnew_tech_stack · the rebrand sitemap half
collect.people_diff  licensed provider, fetched live
job_change · promotion — snapshot diff
tenure_milestone — derived from the same fresh fetch, never from a cached intel_people row
never LinkedIn scraping: no legitimate API or MCP exists
collect.people_diff licensed provider, fetched livejob_change · promotion — snapshot difftenure_milestone — derived from the same fresh fetch, neverfrom a cached intel_people rownever LinkedIn scraping: no legitimate API or MCP exists
Why the split beats better tooling

started_meta_ads means the company had no ads yesterday. No amount of web search establishes an absence, so an agent handed a lane-B type produces plausible garbage with no way to know it is wrong.

Routing it away is a ~20-line rule that deletes the whole failure class.
Why the split beats better toolingstarted_meta_ads means the company hadno ads yesterday. No amount of web searchestablishes an absence, so an agent handeda lane-B type produces plausible garbagewith no way to know it is wrong.Routing it away is a ~20-line rule thatdeletes the whole failure class.
Freshness is a precondition, not a lane

tenure_milestone is arithmetic, so it looked like a third “derived” lane. It is not — the arithmetic is trivial and the role start date is what goes stale. Derived from a cached row it is confidently wrong: the person may have moved months ago.

Any derived signal inherits the staleness of the snapshot it reads, so the derive runs on the fetch, not on the store — and that fetch is already being made for job_change.
Freshness is a precondition, not a lanetenure_milestone is arithmetic, so it lookedlike a third “derived” lane. It is not — thearithmetic is trivial and the role start date iswhat goes stale. Derived from a cached rowit is confidently wrong: the person may havemoved months ago.Any derived signal inherits the staleness ofthe snapshot it reads, so the derive runs onthe fetch, not on the store — and that fetchis already being made for job_change.
Why ingest is its own component

Both lanes, plus manual entry, share one normalize → dedup → append path, so adding a collector adds no write code.

It also keeps signals.search swappable: if the agent proves too slow or costly, a deterministic Exa / Parallel fan-out drops into lane A and nothing downstream changes.
Why ingest is its own componentBoth lanes, plus manual entry, share onenormalize → dedup → append path, soadding a collector adds no write code.It also keeps signals.search swappable: ifthe agent proves too slow or costly, adeterministic Exa / Parallel fan-out dropsinto lane A and nothing downstreamchanges.
Manual entry
user-asserted fact
same spine, dedup still guards
Manual entryuser-asserted factsame spine, dedup still guards
component.signals.ingest  ·  DETERMINISTIC  ·  the one write path
component.signals.ingest · DETERMINISTIC · the one write path
Normalize
domain → intel_companies
linkedin_url → intel_people

stamp subject_type + subject_id
rollup → related_company_id
(a person hops to the employer)
Normalizedomain → intel_companieslinkedin_url → intel_peoplestamp subject_type + subject_idrollup → related_company_id(a person hops to the employer)
Dedup
dedup_key = hash(subject · type · window)

MISS → new intel_signals row
HIT → no new signal; attach the new outlet as a source link
Dedupdedup_key = hash(subject · type ·window)MISS → new intel_signals rowHIT → no new signal; attach thenew outlet as a source link
Append — INSERT only
intel_sources
intel_signals
intel_signal_sources

no UPDATE, no DELETE
decay applied at read time
Append — INSERT onlyintel_sourcesintel_signalsintel_signal_sourcesno UPDATE, no DELETEdecay applied at read time
Signals intelligence database  ·  RLS deny-all, ac-python-api service role is the only door
Signals intelligence database · RLS deny-all, ac-python-api service role is the only door
intel_sources
raw provider payload
+ cost, one row per fetch
intel_sourcesraw provider payload+ cost, one row per fetch
intel_signals
append-only, polymorphic
subject_type · subject_id
related_company_id · dedup_key
intel_signalsappend-only, polymorphicsubject_type · subject_idrelated_company_id · dedup_key
intel_signal_sources
signal ↔ source link
is_primary on the discovering fetch
intel_signal_sourcessignal ↔ source linkis_primary on the discovering fetch
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 not built yet
dashed = another workflow, or not built yet
signal_types[]
signal_types[]
A
A
B
B
SignalSearchResult
SignalSearchResult
append
append
7 · finalize
counts · funnel · SSE done
7 · finalizecounts · funnel · SSE done
Follow-up — make the signal actionable  ·  dispatched AFTER the run completes
Follow-up — make the signal actionable · dispatched AFTER the run completes
company_search
resolve mode — a name in, one domain out

no planner agent, no vendor search: a signal already names its subject
company_searchresolve mode — a name in, onedomain outno planner agent, no vendorsearch: a signal already names itssubject
people_search
resolve mode — a name in, one linkedin_url out

a person signal also rolls up: job_change names a company that is often new to us
people_searchresolve mode — a name in, onelinkedin_url outa person signal also rolls up:job_change names a companythat is often new to us
enrichment
refresh=stale, so it can replace an employer that job_change just invalidated

a fresh subject costs one read
enrichmentrefresh=stale, so it can replacean employer that job_changejust invalidateda fresh subject costs one read
Why the follow-up is a dispatch

A signal is a fact with no address. "Acme raised a Series B" is useful only when we hold Acme's domain, its firmographics and the people to contact.

But the email lane alone adds 50–150s per person, so the roster ships first and the addresses fill in behind it.
Why the follow-up is a dispatchA signal is a fact with no address. "Acmeraised a Series B" is useful only when wehold Acme's domain, its firmographics andthe people to contact.But the email lane alone adds 50–150s perperson, so the roster ships first and theaddresses fill in behind it.
new subjects
new subjects
Text is not SVG - cannot display
Lane A reads the open web through an agent. Lane B fetches snapshots. Both append through one write path.

The ASCII below is the spine only.

Four Inngest functions, each invoked through step_invoke_typed, plus three workflows it calls:

ComponentKindOwns
workflow.signals_searchorchestratorpreflight, run lifecycle, routing, fan-out, follow-up, finalize
component.signals.searchagentplan → search → fetch → extract. No DB writes.
component.signals.collect.*deterministicsnapshot fetch + diff, one per source
component.signals.ingestdeterministicnormalize → dedup → append. The one write path.
TEXT
workflow/signals_search.requested
        ↓
  workflow.signals_search
    1 prepare + preflight        budget guard, mock flag
    2 start run                  workflow_runs + SSE
    3 route signal_types[]       split by detection mechanism
    4 invoke lanes               ctx.group.parallel, one lane may degrade alone
    5 invoke signals.ingest      one write path for every lane
    6 follow up                  resolve + enrich each new subject   ← §6
    7 finalize                   counts, funnel, SSE done
        ↓
  ┌─ A · agent ──────────┐   ┌─ B · collectors (4) ─┐   ┌─ manual entry ─┐
  └──────────┬───────────┘   └──────────┬───────────┘   └───────┬────────┘
             └──────────────────────────┴───────────────────────┘
                                  ↓
                        component.signals.ingest
                     normalize → dedup → append (INSERT only)
                                  ↓
              intel_sources · intel_signals · intel_signal_sources
                                  ↓
                      follow-up, by subject_type
              company_search · people_search · enrichment

The split is what makes it reusable: callers see typed payloads, never internals. The agent inside lane A can be swapped for a deterministic Exa/Parallel fan-out without touching a caller or the write path.

4. Lane A — the agent component

4.1 Why an agent here and nowhere else

The five lane-A types share one property: evidence is unstructured prose scattered across sources with no common API. A funding announcement lives in a press release, a trade publication, a company blog, or a filing. A hand-written query planner underfits that variety — exactly the case an agent handles well. Everything in lane B has a real API, and where a real API exists you want deterministic code driving it, not a model.

4.2 Prompt strategy — one prompt, five sections

TEXT
## funding_round
sources: crunchbase, techcrunch, press releases, regulatory filings
NOT a match: a VC fund closing its own fund (the investor, not the target)
date: the announcement date, not the article publish date
payload: { amount, currency, round, investors[] }

One session covers every requested type. The prompt's active sections are the requested types; the agent plans at least one query family per requested type before deepening any one, and labels an extracted signal by the section rules — never by which query surfaced the source. Both rules exist because a multi-type call otherwise degenerates: the agent burns the search cap on the first productive type, or it mislabels a funding article as an exec change because that's what it was looking for.

No per-type managed-agent skills yet. Skills earn their provisioning cost when a type needs its own eval gate; each one adds a provision_all cycle and drift risk. Extract a section into a skill when:

  • a type regresses and the failure can't be localized without an isolated eval
  • its rules outgrow ~15 lines and start crowding the shared grounding rules
  • the type count passes ~8 and the prompt stops being readable

Same call already made on the sonar extractor: narrow prompt, eval-gated, no skill layer.

4.3 Tools — two, shared, not per type

Two tools in the agent's context, three providers behind them:

TEXT
search(query, objective, lookback_days, allowed_domains[], category?)
   └─ Exa + Parallel, deterministic fan-out, merged and deduped by URL
fetch(url)
   └─ Firecrawl, via the sanitized wrapper

The agent picks the query, not the vendor. It has no feedback loop on which index recalls better — it never sees what the other one would have returned — and the independent benchmark puts Exa, Parallel and Firecrawl in a statistical tie, so there is no correct choice to learn. Exposing three search tools would add tool-definition tokens, a decision per call, and a hidden variable in every eval. Exa's category filter is exposed as the category parameter and routed internally: capability, not vendor.

Fan-out is keyed to depth, so the policy is deterministic and evaluable:

DepthPolicy
quickExa; Parallel only on thin results
standard · deepboth, merged

Why fetch is Firecrawl. Lane A reads press releases, company blogs and trade pubs — JS-heavy and often unscrapeable. src/shared/company/sanitized_firecrawl_tools.py already handles that surface: full markdown, injection sanitizing (HTML comments + zero-width chars stripped), error absorption so the agent doesn't retry-loop on a dead URL, a 50k char cap, firecrawl_rate_limit at 5/s, and FIRECRAWL_SCRAPE_COST_PER_PAGE in vendor_pricing. Exa/Parallel contents cover search results, not arbitrary fetches.

Not the hosted MCP servers, the vendor CLIs, or the model-native web_search. See §9 — vendor limits are reactive (Exa 10 QPS, Parallel 600/min), our limiters sit under them (exa_rate_limit 8/s, parallel_rate_limit 5/s) and are Redis-backed so one account budget divides across concurrent runs; and provider spend has to reach ai_usage_log for data we resell. An uncapped Exa burst turned 38s of work into a 443s retry stall (run a1e68be1). Model-native web_search fails separately: its results come back as encrypted_content, so it cannot populate intel_sources.payload at all.

Two prerequisites, both in src/clients/parallel_client.py: it wires no rate limiter (parallel_rate_limit() exists but its only caller is workflow_engine/.../people_parallel_tools.py) and no sanitizer (Exa has SanitizedExaTools, Firecrawl has its wrapper; Parallel excerpts reach the agent raw). Both land before §12 step 4.

4.4 Guardrails

effort · task_budget · a max-search count stated in the prompt · the existing agent-run reaper. Cost is otherwise unbounded, and that is the main risk this lane carries relative to the deterministic pipelines we've been converging on.

4.5 What the agent must never do

  • Emit a type outside the 12-value intel taxonomy. Distinct from the 13-value CRM SignalType in src/shared/signal_types.py; needs its own module + alias table on the same pattern.
  • Assert a field the fetched source doesn't state. No inferred country, size, funding total, revenue.
  • Resolve entity identity. It emits company_domain / person_linkedin_url; resolution is deterministic.
  • Write to the database.

Returns two artifacts: sources.json (provider responses verbatimintel_sources.payload, the replay and takedown record) and signals.json (extracted rows referencing sources by index).

5. Lane B — the collectors

Each polls a real API, stores a snapshot, emits signals from the diff. All deterministic, no model.

CollectorSourceTypes
collect.meta_adsMeta Ad Library APIstarted_meta_ads, scaled_meta_ads
collect.job_boardsGreenhouse, Leverhired_growth_role
collect.tech_stackfetch + fingerprint diffnew_tech_stack, the sitemap half of rebrand_or_relaunch
collect.people_difflicensed people-data providerjob_change, promotion, tenure_milestone

These are the same mechanism the monitor and scraper producers need, so they were always going to be built. This design only asserts the search agent shouldn't duplicate them.

5.1 On LinkedIn

There is no legitimate LinkedIn API or MCP for post search or profile-change feeds. The official APIs don't expose them; anything that does is a scraper wrapper — a ToS violation, aggressively blocked, and a poor foundation for a proprietary database we resell. The path for job_change / promotion / tenure_milestone is a licensed provider (CoreSignal-class, already wired) polled on a schedule and diffed.

For these types the binding constraint is data licensing, not agent capability — no skill, tool or subagent moves it.

6. Follow-up — make the signal actionable

A signal is a fact about a subject. Nobody can act on it until the subject has an address.

funding_round on acme.com is useful when we hold the firmographics and the people to contact. job_change on a LinkedIn URL is useful when we hold the new employer and a work email. The ingest step writes the fact. The follow-up step makes it usable.

The follow-up runs after ingest, and only for a subject that needs it.

subject_typeFollow-up
companycompany_search in resolve mode, then enrichment(subject_type=company)
personpeople_search in resolve mode, then enrichment(subject_type=person), then the employer roll-up

Three rules keep the cost bounded.

  1. Skip a subject we already hold and that is fresh. Enrichment already owns the freshness rule, so the follow-up passes refresh: stale and lets it decide. A well-known company costs one read.
  2. The search runs in resolve mode, not discovery mode. It takes a name or a URL and returns one identity. It runs no planner agent and no vendor search. Discovery mode is for a brief; a signal already names its subject.
  3. The follow-up is a dispatch, not a block. It fires after complete_workflow_run, in the same shape as the two trailing lanes on Sonar and Headhunter. The signal roster appears at once. The addresses fill in behind it.

A person signal also enriches the employer. job_change names a new company. That company is often new to us. The roll-up resolves it and enriches it, so the person signal seeds a company we can sell to.

follow_up: none turns the whole stage off. A monitor that re-checks known companies every night does not need it.

7. The shared write path

component.signals.ingest, identical for every producer:

  1. Normalize — resolve company_domainintel_companies, person_linkedin_urlintel_people. Stamp subject_type + subject_id. Write related_company_id so a person signal rolls up to the employer feed.
  2. Dedupdedup_key = hash(subject · type · observed window), enforced by a unique index. MISS appends a new intel_signals row. HIT appends no signal and instead attaches the new outlet as an intel_signal_sources link, so a second outlet reporting the same raise never doubles the fact.
  3. Append — INSERT only. No UPDATE, no DELETE. Decay applied at read time.

Adding a collector therefore adds no write code.

8. Data contracts

A call carries a list of types, not one type — see §9. The accounting fields are therefore per-type, or a five-type run reports one number and no one can tell which section failed.

PYTHON
class SignalSearchInput(InngestPayload):
    run_context: ComponentRunContext
    query: str
    signal_types: list[IntelSignalType] | None   # None → agent picks within lane A
    lookback_days: int = 30
    geo: str | None = None
    target_per_type: int = 10    # per requested type, NOT a run total
    depth: Literal["quick", "standard", "deep"] = "standard"
    require_company: bool = True
    emit_progress: bool = True
    follow_up: Literal["none", "resolve", "enrich"] = "enrich"   # §6


class ExtractedSignal(BaseModel):
    signal_type: IntelSignalType
    description: str
    observed_at: date            # when the event happened, NOT when it was published
    source_ref: int              # index into sources[]
    snippet: str
    company_domain: str | None
    person_linkedin_url: str | None


class PerTypeResult(BaseModel):
    signal_count: int
    queries_run: int
    zero_result_reason: str | None   # why THIS type came back empty


class SignalSearchResult(InngestPayload):
    sources: list[RawSource]                        # verbatim provider payloads
    signals: list[ExtractedSignal]
    per_type: dict[IntelSignalType, PerTypeResult]  # attribution per requested type
    queries_run: int                                # run total
    rejected_types: list[IntelSignalType]           # asked for, but not lane A
    zero_result_reason: str | None                  # whole-run failure only
    follow_up: FollowUpSummary                      # subjects resolved / enriched / skipped
    usage: AgentUsage

target_per_type is per requested type because a run total silently shrinks each type's quota as the list grows — five types against a total of 25 is five each, and deep stops meaning anything.

depth maps to agent controls so callers never touch them. Budget and search cap scale with the number of requested types, for the same reason:

DepthEffortBase task budgetBase max searches
quicklow30k8
standardmedium80k25
deephigh250k80
TEXT
task_budget  = base × (1 + 0.5 × (n_types − 1))    capped at 2× base
max_searches = base × (1 + 0.5 × (n_types − 1))    capped at 2× base

Sub-linear on purpose: types share a corpus, so the second type costs far less than the first. The 2× cap keeps a five-type call from being five times the bill. Both the multiplier and the cap are guesses until §10 Q4 is measured.

9. Decisions and rejected alternatives

DecisionRejectedWhy
Route by detection mechanismsend all 12 types to the agentabsence and delta aren't searchable; the agent can't know it's wrong
Many types per callone type per callthe search corpus is shared. Per-type calls replan, re-query overlapping terms, and refetch the same URLs with no cache between sibling sessions. A single call also keeps mixed-lane requests ([funding_round, started_meta_ads]) inside the component instead of pushing routing onto every caller — and signal_types: None is already multi-type, so a scalar contract couldn't express its own default.
Fan out by lane, never by typeorchestrator fans out one agent invoke per typesame reason. Per-type fan-out re-earns exactly the cost the shared corpus saves, and buys only isolation — which evals get by running single-type without the runtime doing so. Fan-out stays available as a later scaling axis over targets or query batches, not types.
One agent, one prompt, five sectionsone subagent per typecost multiplies and cross-type evidence reuse is lost — one funding article routinely evidences three types
Two shared toolsone tool or MCP per typeper-type tools only pay off where a structured API exists, and exactly there the deterministic collector is the right instrument
Two tools, three providers behind themone tool per vendor, agent picksthe agent has no feedback loop on index quality and the engines benchmark as a statistical tie — the "choice" would be noise, plus a hidden variable in every eval
fetch = FirecrawlExa / Parallel contentsthose cover search results, not arbitrary URLs; the sanitized Firecrawl wrapper already handles JS pages, injection stripping, dead-URL absorption and cost metering
Wrap our own Exa/Parallel clientshosted MCP servers (mcp.exa.ai, search.parallel.ai); the official Parallel CLI in the sandboxNot because the vendors advise it — they're neutral, and the usual "MCP for agent-driven calls, API for deterministic ones" framing arguably favours MCP here. It turns on requirements the vendors don't address: proactive pacing under a per-account ceiling (their limits are enforced reactively by 429), one rate budget shared across concurrent Inngest runs (an MCP session is blind to its siblings), and per-run cost attribution into ai_usage_log / vendor_pricing for data we resell. MCP genuinely wins on maintenance and on being native to Managed Agents; those don't outweigh billing we can't see.
Prompt sections now, skills laterper-type skills from day oneskills earn their provisioning cost only at an isolated eval gate
Licensed provider for people signalsLinkedIn MCP or scraperno legitimate API exists; constraint is licensing, not capability
Write into the intel_signals spinenew tables owned by this componentthe store is decided and shared with three other producers
Search and ingest as separate componentsone component that searches and writeschat needs a read-only search, a composing workflow would double-write, and the agent must stay swappable
Follow up by calling search + enrichmentresolve and enrich inside the ingest stepingest is the one write path and it must stay deterministic and cheap; resolve and enrich are owned elsewhere, and duplicating them here would fork two waterfalls
The follow-up is a trailing dispatchblock the run until every subject is enrichedthe signal roster is useful at once; enrichment adds 50 to 150 seconds per person for the email lane alone
Resolve mode on the search workflowsa private resolver inside signalsa second resolver drifts from the one that search uses, and dedup then splits
New components/signals/ packageplacing it under components/company/rows are signal-first; company and people components are consumers, not parents

10. Open questions

  1. Identity creation policy. When the agent surfaces a signal for a company absent from intel_companies, does normalize create the entity or drop the signal? Dropping loses real signals; creating means an agent extraction seeds the intelligence DB. The §6 follow-up shifts this: normalize can create an identity-only row and let the follow-up fill it from real sources, so the seeded row is never an agent's guess for long. It does not remove the question, because a wrong identity still creates a row that the follow-up then fails to resolve.
  2. Undated signals. Decay runs off observed_at. When a source states no date: drop, fall back to source publish date, or store with a confidence marker? A silent fallback corrupts decay.
  3. Ingest provenance and feed materialization. The decided DB design includes intel_signal_ingests (one row per discovery, so a run can list every signal it surfaced including re-finds) and intel_org_signal_feed (per-org scored copy). Neither is written by this component today. Without the ingest log, a dedup hit leaves no record that this run found the signal — so the Signals app can't list its own results. Either both come back into scope, or a run-scoped results table replaces them.
  4. Cost ceiling per run. Sets task_budget and the search cap, and calibrates the §8 per-type scaling multiplier. Unset, lane A is the only unbounded cost in the design.
  5. Cap on len(signal_types)? Moot while lane A holds five types — asking for all of them is the normal case. It stops being moot if the taxonomy grows: at some type count a single session's context can no longer hold the sections plus the fetched corpus, and that is the point where per-type fan-out becomes right after all. Revisit at the same threshold as the §4.2 skill-extraction trigger (~8 types).

11. Package layout

TEXT
src/inngest_functions/
  workflows/signals_search/
    signals_search.py            orchestrator
    steps/{prepare,route,follow_up,finalize}.py
  components/signals/
    search.py                    lane A, agent
    ingest.py                    normalize → dedup → append
    collect/{meta_ads,job_boards,tech_stack,people_diff}.py
    schemas.py
src/agents/signals/
    open_web_search.py           agent spec + prompt
src/shared/
    intel_signal_types.py        the 12-value vocabulary + aliases

12. Build order

#StepWhy first
1intel_signal_types.py + schemas.pyevery other piece types against it
2component.signals.ingestthe write contract; unblocks all four producers
3Routing rule in the orchestratorcheapest lever, and it gates what lane A ever sees
3.5parallel_client: wire parallel_rate_limit + a sanitizerlane A is otherwise uncapped against 600/min and feeds raw excerpts to the agent (§4.3)
4component.signals.search + agent specthe new capability
5collect.people_diff3 of 7 lane-B types from one provider fetch
6Remaining collectorsad library, job boards, tech stack
7The follow-up step (§6)needs resolve mode on both search workflows and the freshness rule in enrichment

Resolve open questions 1 and 4 before step 4. Question 3 blocks any UI that lists a run's own results. Step 7 depends on enrichment build step 4; without the freshness rule the follow-up cannot correct an employer that a job_change signal just invalidated.

13. References