Signals Search
One bounded discovery workflow that finds companies, verifies signals, finds relevant people, and produces evidence-backed organization prospects without prematurely creating CRM records.
Signals Search
Signals Search is the canonical discovery workflow for Agentic CRM. It replaces the older split between Sonar company discovery and Headhunter people discovery.
The workflow starts from an ICP/query, a bounded company set, or a saved monitoring scope. It finds companies and evidence, verifies meaningful signals, finds the relevant people inside the strongest companies, and returns one reviewable Organization Prospect result.
It consumes the shared Runtime, Tools, Context, Trigger, Policy, Idempotency and Observability layers. It owns no private scheduler, queue, intelligence store, people store, CRM, runtime or policy engine.
Product boundary
Signals Search owns the discovery experience and its product state. The Agentic CRM design owns the three-state boundary:
Signals Search
│
▼
Global Intelligence
companies + people + evidence + signals
│
▼
Organization Prospect
fit + scores + relevant people + review state
│
├── watch / dismiss
│
├── add a named person / find people
│
└── promote ──> CRM
Discovery is not CRM creation. A company or person may be fully researched and reviewable before the organization chooses to manage that relationship in CRM.
Phase 7 capability composition
signals.search is the stable product ID. The capability contract owns its registry and version rules.
The three root source shapes and existing product outputs remain compatible.
The published node list below includes both Phase 7 composition lanes.
ENG-2282 replaces company mechanics with explicit Company Search and Company Enrich steps.
ENG-2302 replaces people mechanics with explicit people.search and people.enrich children.
Published subworkflow nodes hold the derived executor UUIDs of the same organization. A running graph does not query the registry again.
Signals keeps bounded web and thesis research. It passes discovered company or person identities through the supplied child search path. Direct Company/People Search does not gain web sources from this composition. Company-set and saved-search starts retain their scope confinement, including their existing smaller limits. The larger direct-search limits never widen a Signals Run implicitly.
Scoring, persona-fit evaluation, signal evidence, smart-feed publication, saved-search diffs and prospect promotion remain Signals responsibilities. The compile steps adapt child results to the existing scoring and publication contracts. No capability child adds automatic CRM writes or outreach.
Signals Search starts no People Enrich child and looks up no email. The email lookup is paid for each person. Outreach looks up the email when the reader adds a person to a sequence. The design scenarios define the regression cases.
Company lane contract
The research Agent keeps web discovery, source verification, canonical identity writes and signal recording.
A signal needs a canonical company ID before the Agent can store its evidence.
The Agent and its Skills do not declare research.enrich_company.
Company Enrich owns the profile provider calls.
The company lane runs after research and before signal scoring:
- Compile at most ten researched companies into supplied Company Search references.
- Run the read-only supplied Company Search tool through an empty-input wrapper.
- Match returned rows to the supplied canonical IDs and compile Company Enrich subjects.
- Run the Company Enrich child through an empty-input wrapper.
- Validate the child result and pass the admitted company and signal IDs to the existing scoring steps.
Each wrapper uses a branch node. The search wrapper calls company.search_supplied directly; the enrichment wrapper starts the Company Enrich subworkflow.
Their empty arms return an explicit empty batch without starting a capability.
The wrappers return the existing node-output map. The next compile step accepts only the expected empty or product result shape, including the earlier nested search result shape for in-flight Runs.
This avoids references to skipped nodes. It does not change the Company Search or Company Enrich schemas or the workflow DSL.
Company Search uses sources: [supplied], no firmographic filters and a target of ten.
It makes no provider call and does not pass through the metered discovery tool.
Signals keeps free-text criteria in its opportunity scorer. It does not translate thesis text into Explorium filters.
The compile step preserves each company's signal IDs, citations and gaps through both steps.
It matches by canonical ID and validated input indexes, never by result order.
Conflicting, replaced or unknown IDs cannot reach enrichment or promotion.
For a company-set start, resolve each seed member after research.
A member with a canonical ID admits that ID.
A member with a domain or a LinkedIn page admits only the company that answers to that key: the domain first, then the LinkedIn page.
The LinkedIn page therefore admits a company that now uses another domain.
A keyed member that resolves to no company admits nothing and reads domain_changed.
Only a member with no key, such as a name alone or a person profile alone, opens an unnamed position.
Every match must be unique, or the member stays unresolved.
A named member takes the one answered company whose stored name is its name, compared on letters and digits; two such companies, or two members with the name, leave it unresolved.
A member that profile-only people opened takes the one answered company that Global Intelligence holds as the current employer of one of its people.
A member with no name and no stored employer takes a company only when it is the one such member and one company is left.
The Agent's order never decides.
A missing or conflicting key never grants a spare position to an unrelated company.
A saved search with no CRM list keeps the five-company rotation and can discover new companies within the ten-company total. A saved search that watches a CRM list checks every company on it in each Run.
Enrichment uses the Standard preset with an explicit field selection:
name, description, industry, business_model, country_code, location,
employee_count_exact, employee_count_band, annual_revenue, funding_round and funding_amount.
Use refresh: stale. The existing scorer reads canonical facts after the child completes.
Retained facts and partial field failures remain usable. A missing canonical identity does not.
For a warm row with only country_code, the scorer derives the country name with the shared country converter.
An existing country name takes precedence. The projection does not write to Intelligence.
Child inputs and complete wrapper results must fit the existing 256 KiB boundaries. Compile steps measure their complete payloads and refuse overflow before the next child starts. They reject a truncation marker, a missing product result and an invalid product contract. They do not report a malformed or incomplete child result as an empty search. Cancellation, budget stops and platform faults stop the lane. A partial Run cannot publish a Smart Feed result. An ordinary per-field provider failure can preserve other company results.
The runtime owns child keys, parent spans, root budgets, retries and usage accounting. Each child uses the existing parent Run ID and node ID key. Compile steps add no aggregate charge. Completed child results replay from durable state. Interrupted external calls retain the existing uncertain-charge limitation.
Publication validates the active tenant Company Enrich executor. The bundle accepts resolved executor UUIDs. ENG-2275 owns registry resolution; the sync owns installation. The bundle publishes the two wrappers before the root definition and reports partial publication failures. ENG-2303 owns upgrades and rollback. Existing published Run snapshots remain unchanged. Root, batch child, enrichment wrapper and capability occupy all four supported workflow levels. A custom workflow cannot add another enclosing level. Publication reports the existing depth refusal.
People lane contract
Signals keeps its bounded Exa and Parallel research. The people lane runs after promote-prospects.
It starts one persona window child for each window of prospects, and no People Search child.
signal-peopleattaches each person that a signal names to the prospect of that signal. It does not claim that the person fits the persona. A named person that matches no stored profile is looked up withresearch.find_person. The lookup keeps a profile only when exactly one confirmed profile holds the whole name.has-contact-personareads the scope flaghas_people_search. The flag needs a persona and a provider. The flag is off when a supplied person opened every seed company and the persona is the default. The default arm attaches nobody.plan-people-searchbuilds oneresearch.discover_peoplecall for each kept prospect, at most ten.discover-peoplefans the calls out to the first provider.plan-people-retrygives each company that the first provider left unsettled to the next provider.discover-people-retryfans that plan out. An empty plan fans out to nothing.people-windowsruns four windows in parallel. A window holds at most three prospects and 25 candidates for each prospect. In each window,signals.compile_people_searchjoins the fan-out results by position.signals.compile_search_personaapplies the supplied People Search rules in the step and builds the persona-fit input. Thepersona-window-Nchild runs the tool-less persona-fit Agent. A window with no candidate returnsno-candidates, and the Agent does not run.signals.merge_people_enrichjoins the selections of the four windows. It refuses a window output of any other shape.- For a nonempty selection,
intelligence.upsert_people_batchwrites the selected people and their provider observations to Intelligence.prospect.attach_people_batchthen attaches them. For an empty selection,signals.compile_empty_peoplewrites a complete-empty or partial-pending outcome for each attempted prospect.
The search evidence holds each selected person. The lane therefore calls no enrichment provider.
Inputs
A Signals Search Run may start from one of three source shapes.
| Source | Example | Workflow behavior |
|---|---|---|
| Discovery brief | ICP, geography, company criteria, signal criteria, buyer persona | discover companies/signals first, then relevant people |
| Bounded company set | selected prospects, CRM list, selected CRM companies, normalized CSV/import, a list of people | skip broad company discovery; refresh signals and find relevant people, or the supplied people |
| Saved monitoring scope | saved search, watched prospects, target list | look for new/materially changed evidence and refresh only what changed |
The brief may contain:
- an ICP text that states the target-company and signal thesis;
- company criteria;
- a buyer persona with
titles,departments,seniorityandcountry_codes, or a free-textpersona_description.
V1 has no separate signal-theme or result-limit field. The workflow passes the ICP text and company criteria to opportunity scoring and signal research. A surface must not collect a separate value that the Run input would discard.
Every source shape carries a brief. The brief must contain an ICP or company
criteria, because the opportunity score reads them. The contact persona is
optional. A structured persona takes precedence. Without one, the planner
derives a persona from persona_description when supplied; with neither, the
scope uses owner, founder, C-suite, vice president and director seniority.
Under this default, persona fit prefers an evidenced marketing or
growth decision-maker, then a verified founder or owner when no such buyer is
supported. A supplied or planned persona keeps its own criteria. A bounded
company set names which companies to research; it does not answer how valuable
acting on one is. Phase 4 does not read Knowledge as a fallback.
The Front Door or product UI may help construct the brief. The Signals Search workflow owns execution.
Shared persona contract
ENG-2324 owns this input change. ENG-2302 owns people capability composition.
Import People Search's PersonaCriteria; do not copy its schema or weaken its filters.
The persona contains titles, departments, seniority and country_codes.
When a structured persona is supplied, at least one field is required. Each
supplied list contains 1 to 20 unique values.
Omit unused fields. Reject null, empty lists, unknown keys and duplicates after whitespace and case normalization.
Text values contain 1 to 1,000 characters. Country codes use the existing ISO alpha-2 contract.
The full Run input must also fit the platform byte limit. Field limits do not replace that limit.
Use OR within each list and AND between fields. Match text without case differences. Use the person's country. Do not substitute the employer's country or infer a country from free-text location. Keep missing facts unknown. Any observed contradiction excludes the merged identity, including a later duplicate observation. Use the same deterministic gate as People Search before persona-fit scoring. Unknown department or seniority vocabulary remains unknown under the supplied-source rules. Explorium-specific vocabulary checks remain at the Explorium boundary; Signals web research does not select that source here. Keep unknown diagnostics and source evidence so the scorer cannot claim verified fit from missing facts. Persona-fit scores and evidence-based reasons remain separate from signal scores.
Validate the brief before company reads or paid provider work for discovery and company-set starts.
Saved-search create, corrected-brief patch, manual start and scheduled dispatch use the same reader.
A rejected scheduled dispatch records input_unbuildable and starts no Run. Keep the schedule unchanged.
Provider queries carry all persona alternatives explicitly. Do not select the first value, truncate a list, or expand related titles.
Keep the existing company/provider call count. Do not create a Cartesian product of criteria.
Before company work, preflight the full six-call people plan with bounded company fields and the complete persona.
Check the exact scope output and company research input before their first consumer. Refuse overflow before paid work.
Check the complete plan again before provider fan-out. Never let the platform truncate a call or a criterion.
These resource checks can refuse a schema-valid persona. Return an actionable size error.
Measure the complete persona child input, including Unicode bytes, before starting that child.
Reduce the existing candidate budget by the persona envelope size. Never trim persona criteria to fit.
Legacy persona correction
Inspect stored briefs before a write. Export a private dry-run report for each selected organization. Include the original brief, saved-search ID, update token, field errors and proposed correction. A valid shared persona needs no write. Do not guess an equivalent for title families, functions, regions or combined criteria. Even a legacy title can represent a broader search. Require a reviewed replacement when equivalent meaning cannot be proved. Keep the original brief in the migration report before applying a confirmed correction. Apply only the reviewed persona to that original brief through the existing organization boundary and update-token check. Reject a changed or deleted row. If the exact corrected brief is already stored, report it without another write. Keep saved-search IDs, watch membership, schedules and unrelated brief fields unchanged. An incompatible brief remains readable. Permit name-only edits; require a valid shared persona when the brief itself changes. A new execution returns actionable persona field errors until the brief is corrected.
Do not rewrite admitted Run inputs, snapshots, results or replay data. Before rollout, stop new legacy admissions and let admitted legacy Runs finish, or cancel them through the normal Run API. Do not deploy changed live tool contracts while such Runs can resume. Preserve historical reads after rollout. Capability installation and definition upgrades remain ENG-2303 responsibilities.
The Signals Search journey
owns direct launch, saved-search management, correction and diff review.
The saved-search route composes its private source fields and resolves the
stable signals.search capability. The frontend never accepts an executor UUID.
One Run input, three shapes
The Run input names its own source. The persona guard and scope step read it.
{
"source": "discovery | company_set | saved_search",
"signal_recency": "1w | 1m | 3m | 6m | 12m | 24m",
"natural_language_query": null,
"structured_filters": {},
"brief": {},
"companies": [],
"people": [],
"saved_search_id": null,
"baseline_run_id": null
}
signal_recency sets the signal lookback window. The default is 3m.
natural_language_query describes the companies to find, in at most 2,000 characters.
structured_filters maps a target filter key, such as industries or countries, to a list of values.
An unknown filter key refuses the start.
The scope step passes the query and the filters to the research Agent as target_companies.
A saved-search Run carries signal_recency and structured_filters from the stored brief.
It also carries natural_language_query. The route takes that text from the first company criterion of the brief.
companies belongs to a bounded company set and holds at most ten members. Each member names a company by canonical Intelligence id, by prospect id, or by a domain and an organization LinkedIn URL, one or both. It may carry the name the caller had for it and the crm_company_id of the CRM row it came from. A member may also carry a name alone: the Agent then researches the company. These references are top-level source fields, not brief fields.
people belongs to a bounded company set too, and holds at most ten members. A member carries a linkedin_url, or a full_name with an email, a domain or a company_name. Either form may carry an optional title and the crm_person_id of the CRM row it came from. A bounded set needs one company or one person. The search form sends the CRM contacts of each listed company as people, first contact of each company before any second. The scope step places each person at the company of their domain, their work email host, or their company name, and a person with a profile alone opens an unnamed seed entry the Agent resolves from the profile. The email derives the company and never reaches the Agent. The scope step keeps a CRM id only when it names a live row of this organization. The Agent never reads a CRM id. saved_search_id and baseline_run_id belong to a saved monitoring scope, and surfaces owns the route that composes them.
The Run freezes one as_of time. The scope step freezes it, so no caller supplies it, and the workflow step memo keeps it stable across a retry. Scoring and prospect writes use this time as their source-observation time.
The workflow
input brief / company set / saved scope
│
▼
Gate: plan from `persona_description` when supplied without a structured persona
│
▼
0. Resolve the source shape into one search scope
│
▼
1. Discover or resolve companies + signals
│
▼
2. Normalize entities + dedupe evidence
│
▼
3. Fill coverage gaps / verify strong candidates
│
▼
4. Grade signal evidence
│
▼
5. Grade opportunity from the brief
│
▼
6. Rank, prune, and upsert the kept prospects
│
▼
7. Find relevant people for bounded top companies
│
▼
8. Verify / dedupe / attach people
final Organization Prospect
company + signals + relevant people + reasons
│
▼
9. Freeze the saved-search publication projection
other source shapes -> not applicable
│
├── watch / dismiss
├── add a named person, or find people with one People Search Run
├── promote selected records to CRM
└── hand selected people to the email sequence workflow
This is one product workflow, not two chained product Runs. Internal units may be Agents, Skills, Tools or deterministic workflow steps depending on the job.
The three research rounds are not three workflow nodes. A workflow has no loops, so the rounds happen inside the company and signals agent step, which decides whether another bounded round is useful and stops on its own judgement inside the runtime ceilings. The workflow owns the order of the stages; the agent owns how many rounds a stage takes.
The published node list
The root sequence serves all three source shapes. It plans the persona once, reads the scope once, and then holds one slot for each batch of ten companies. The slots run one after the other. A discovery Run and a company set fill batch 1 only, so one graph serves every shape.
| # | Node | Kind | Width |
|---|---|---|---|
| 0 | needs-persona-plan | branch; plan-persona Agent when persona_description is supplied without a structured persona, empty-persona-plan (signals.empty_persona_plan) otherwise | 1 |
| 1 | compile-run-scope | signals.compile_run_scope, read | 1 |
| 2 to 11 | guard-batch-K, batch-K | for K from 1 to 5: signals.guard_batch, read, then a signals.batch subworkflow that tolerates a failure | 1 |
| 12 | merge-batches | signals.merge_batches, read | 1 |
| 13 | compile-smart-feed-observations | signals.compile_smart_feed_observations, read | 1 |
The batch child branches on the answer of its guard. The skip path holds one
node, skip-batch (signals.empty_batch). The batch path holds the nodes
below. A row that names an arm or a window is a node inside the node above it.
The four windows hold the same three nodes, so the table writes them once with
N for the window number.
| # | Node | Kind | Width |
|---|---|---|---|
| 1 | compile-search-scope | signals.compile_batch_scope, read | 1 |
| 2 | company-signals | agent | 1 |
| 3 | compile-company-search | signals.compile_company_search, read | 1 |
| 4 | company-search | subworkflow wrapper; company.search_supplied in the nonempty arm | 1 |
| 5 | compile-company-enrich | signals.compile_company_enrich, read | 1 |
| 6 | company-enrich | subworkflow wrapper; Company Enrich child in the nonempty arm | 1 |
| 7 | compile-company-result | signals.compile_company_result, read | 1 |
| 8 | compile-signal-batch | signals.compile_signal_batch, read | 1 |
| 9 | score-signals | agent | 1 |
| 10 | compile-opportunity-batch | signals.compile_opportunity_batch, read | 1 |
| 11 | score-opportunities | agent | 1 |
| 12 | promote-prospects | prospect.promote_batch, write | 1 |
| 13 | signal-people | sequence | 1 |
| 14 | compile-signal-people | signals.compile_signal_people, read in signal-people | 1 |
| 15 | plan-signal-lookups | signals.plan_signal_lookups, read in signal-people; at most three people for each prospect | 1 |
| 16 | find-signal-people | research.find_person, read in signal-people | fan out, at most 10 |
| 17 | resolve-signal-people | signals.resolve_signal_people, write in signal-people | 1 |
| 18 | attach-signal-people | prospect.attach_people_batch, write in signal-people | 1 |
| 19 | has-contact-persona | branch on the scope flag has_people_search; skip-people-search (prospect.attach_people_batch with no outcome) otherwise | 1 |
| 20 | plan-people-search | signals.plan_people_search, read in the persona arm | 1 |
| 21 | discover-people | research.discover_people, read in the persona arm; the first provider | fan out, at most 10 |
| 22 | plan-people-retry | signals.plan_people_retry, read in the persona arm | 1 |
| 23 | discover-people-retry | research.discover_people, read in the persona arm; the companies the first provider left unsettled | fan out, at most 10 |
| 24 | people-windows | parallel, four windows in the persona arm | 4 |
| 25 | compile-people-search-N | signals.compile_people_search, read in window N | 1 |
| 26 | compile-search-persona-N | signals.compile_search_persona, read in window N | 1 |
| 27 | persona-window-N | subworkflow in window N; the tool-less persona-fit Agent, or no-candidates (signals.empty_people_window) for an empty window | 1 |
| 28 | merge-people-enrich | signals.merge_people_enrich, read in the persona arm | 1 |
| 29 | has-people-enrich | branch in the persona arm | 1 |
| 30 | compile-person-evidence | signals.compile_person_evidence, read in the selected arm | 1 |
| 31 | persist-person-evidence | intelligence.upsert_people_batch, write in the selected arm | 1 |
| 32 | compile-people-result | signals.compile_people_result, read in the selected arm | 1 |
| 33 | attach-selected-people | prospect.attach_people_batch, write in the selected arm | 1 |
| 34 | compile-enrich-empty | signals.compile_empty_people, read in the empty arm | 1 |
| 35 | attach-enrich-empty | prospect.attach_people_batch, write in the empty arm | 1 |
| 36 | supplied-people | sequence | 1 |
| 37 | compile-supplied-people | signals.compile_supplied_people, read in supplied-people | 1 |
| 38 | attach-supplied-people | prospect.attach_people_batch, write in supplied-people | 1 |
| 39 | compile-batch-result | signals.compile_batch_result, read | 1 |
The batch child reads one normalized scope. Each company wrapper branches on a validated nonempty batch and returns either the child output or an explicit empty result. The root therefore never refers to a skipped node.
Deterministic read tools carry data between reasoning steps. A node input
names input.*, steps.<id>.output.* and steps.<id>.partial_reason only. The
language has no expression, array index or list join. Each reshape is therefore
a tool.
signals.compile_company_search reads steps.company-signals.partial_reason.
When the research child stopped short, the tool reads its answer as empty. The
Run then promotes no prospect, and the reason stays on the root result. A
company the scorer marks dismiss is stored as a dismissed prospect on
insert. prospect.promote_batch answers it in dismissed_prospects, not in
prospects. The paid people lanes and the smart feed read prospects only, so
they spend nothing on it. The three lanes that attach a person at no cost read
both lists, so a restored prospect is complete. The Run refs name both lists,
so the Run view counts the dismissed prospects with its one read.
| Tool | Reads | Answers |
|---|---|---|
signals.compile_run_scope | the whole Run input, the saved-search membership and the watched CRM list of this organization | the shared scope, one batch of at most ten raw company references for each slot, watched_total, beyond_limit (the live companies past the first 50) and dropped |
signals.compile_batch_scope | the shared scope, one batch, Intelligence company identity and the prospects of this organization | the scope every later node of the batch reads |
signals.guard_batch | whether the slot holds work, the spend of the whole tree and the root deadline | whether the batch runs, and why not |
signals.empty_batch | no data | an explicit skipped batch |
signals.compile_batch_result | the prospects, the skipped rows and the supplied people of one batch | the batch result the merge reads |
signals.merge_batches | the guard answer, the batch and the result of each slot, and watched_total and beyond_limit from the run scope | the joined prospects and skipped rows, unchecked_companies, and watched_total and beyond_limit copied through to the Run result |
signals.compile_search_scope | what signals.compile_run_scope reads, then Intelligence company identity and the prospects of this organization for batch 1 | the scope of batch 1, for a graph that Runs admitted before the batch children still run |
signals.compile_company_search | researched companies, exact seed identities and the scope's unresolved inputs | confined canonical references for supplied Search, a reason for each seed member research did not keep, and one plan row for each supplied person |
signals.compile_company_enrich | complete Search output and research evidence | admitted companies and selected-field Enrich input |
signals.compile_company_result | complete Enrich output and research evidence | validated companies for scoring |
signals.empty_company_batch | no data | an explicit empty wrapper result |
signals.compile_signal_batch | admitted companies, Intelligence signals and sources, stored prospect grades | the signal scoring input, holding ungraded signals only |
signals.compile_opportunity_batch | the new grades, the stored grades, Intelligence company facts, the seed | the opportunity scoring input, with the resolved grades under each company |
signals.plan_people_search | the kept prospects and the buyer persona | one ordered research.discover_people argument list |
signals.plan_people_retry | the kept prospects, the buyer persona and the first provider's fan-out results | one research.discover_people argument for each company the first provider left unsettled, with the next provider |
signals.compile_people_search | that argument list and the fan-out results | a branch flag, stable supplied Search input and index-to-prospect evidence map |
signals.compile_search_persona | the Search result and the index-to-prospect evidence map | a bounded persona-fit input grouped by prospect, with per-prospect completeness |
signals.merge_people_enrich | the persona-fit input and the child output of each window | a branch flag, the selected people of every window and their source observations |
signals.compile_person_evidence | selected source observations and complete Enrich results | canonical-checked Intelligence person evidence writes |
signals.compile_people_result | selected evidence, evidence-write results and the complete Enrich result | one validated attachment argument for each attempted prospect |
signals.compile_signal_people | the scored companies and their signal grades, the kept and dismissed prospects, Intelligence signals and the stored people of each company | one attachment argument for each prospect whose leadership signal names one stored person |
signals.compile_empty_people | exactly one empty Search, persona or Enrich stage | one complete-empty or partial-pending attachment argument for each attempted prospect |
signals.plan_people_fallback | the kept prospects, the merged persona selection, the scorer company facts, the persona and the seed | the prospects with no selected person, and the persona of each one by company size |
signals.compile_supplied_people | the supplied-people plan, the seed, the promoted prospects, Intelligence people and the people already attached | one outcome for each supplied person, and the attachments for confirmed people |
signals.compile_smart_feed_observations | the source, frozen time, promoted prospect ids, and their current signal grades and people | one frozen publication projection, or not_applicable, superseded or overflow |
No Agent declares one of them. They are workflow plumbing, and a model never calls them.
The scope step answers one key set
A node input that names an absent key fails, and a null fails the same way. So
the scope step answers the same field set whichever shape started the Run,
and variance lives inside a value rather than in the presence of a key. Every
later node reads a scope step and never input.*.
The root step resolves no identity, because every identity read holds ten values. It answers these fields:
| Field | Holds |
|---|---|
shared | the source, as_of, the lookback, the brief, the target companies, the persona and the people providers every batch shares |
batches | batch_1 to batch_5, each with whether it runs, whether it confines, and at most ten raw company references |
watched_total | how many companies the saved search watches. For a watched CRM list, how many live companies the list holds. Zero for a discovery Run and a company set |
beyond_limit | how many live companies of the list are past the first 50. The Run does not read or check them |
dropped | list members the root dropped because an earlier member named the same company |
The first step of each batch child resolves its own batch and answers the
fields below. Every batch writes the observation time of the root step, so one
Run has one as_of across all its prospects.
| Field | Holds |
|---|---|
source | the Run input source |
as_of | the frozen observation time |
brief | the ICP and the company criteria, and nothing else |
persona | the buyer persona |
seed_companies | the companies the Run starts from, each with its id, domain, LinkedIn key, name and supplied people. It is empty for a discovery brief |
confine_to_seed | whether a company outside the seed may become a prospect |
unresolved_inputs | each supplied company reference that names no company |
unresolved_people | each supplied person the seed could not place, by position and name |
persona_source | supplied, planned or default. Supplied people under the default persona keep the persona lane off |
dropped | supplied references a company already in the seed had claimed |
watched_total | how many companies the saved search watches. The root step answers it, and a batch scope answers 0 |
The step also reads the brief, so a Run with no ICP and no company criterion stops at scope compilation, before company or people provider work. The persona planner runs before scope compilation only when the brief supplies a free-text persona without a structured persona. Other inputs skip the model call and pass an empty plan.
The step writes nothing. A supplied domain, LinkedIn page or name that
Global Intelligence does not hold yet is research work: the seed keeps the
key and no id, and the Agent writes the canonical row. A reference with a
malformed key, a conflicting key or an unknown id becomes an unresolved
input, and no entity is created for it. The Run result carries
skipped_companies, skipped_people and the supplied_people outcomes,
so a reviewer sees which imported rows never became companies or people.
skipped_companies also names each seed member that research did not keep,
with its reason. no_event_companies names each seed member the Agent
researched and found no dated event for. A scanned company is never a skipped
input.
A bounded company set is a promise about the answer. The Agent is told to
search inside the seed, and signals.compile_opportunity_batch proves it: a
company outside the seed never reaches the scorer, so it cannot become a
prospect row. A seed entry with no id admits one company the seed could not
name, because the Agent created that row during the Run. The kept set is never
wider than the seed.
A saved scope with no CRM list refreshes and discovers. It seeds the
companies the search already surfaced and nobody promoted into the CRM, oldest observation
first, and it does not confine, because a new company is one of the changes the Smart Feed
reports. The Agent answers at most ten companies, so the seed takes at most
five, and a wider set rotates across Runs. watched_total records how many the
search watches. A company a person promoted into the CRM is not in the seed or
in watched_total.
A saved scope with a CRM list checks every company on it. See A saved search watches a CRM list.
The final node freezes publication input
signals.compile_smart_feed_observations is always the last node. A discovery
or company-set Run returns not_applicable and reads no publication rows. A
saved-search Run reads all promoted prospects, their signal grades and their
attached people, ten prospects in each database statement. The same snapshot requires each
prospect last_seen_run_id to name this Run. Any mismatch returns
superseded.
The statement orders each embedded relation by its Intelligence id and asks
for at most 100 signal grades and 100 people per prospect. V1 supports 99 of
each. A relation that returns 100 makes the result overflow; the node hashes
nothing and cannot publish a partial relation. This explicit sentinel stays
inside the Data API ceiling at the maximum ten-prospect width and detects a
prospect that has outgrown the V1 projection bound.
A ready answer holds the scope's frozen as_of time and one versioned digest
envelope for each prospect. The immutable Run result therefore holds the
complete comparison input. A later Run can change mutable prospect state, but
it cannot change this projection. The node moves no saved-search pointer and
writes no publication row. Saved-search surfaces
own the durable publisher and the digest rules.
The workflow selects this node as its completed result under the
smart_feed key, beside the rows the merge reads from every batch. It does not
carry the outputs of the thirteen earlier top-level nodes into the completed
Run result. A batch that stops at its own clock leaves the root running, so the
merge and this node still run and the Run publishes. A root that stops before
this node keeps the normal diagnostic map and cannot publish.
This final node never prunes. The promoted set has at most fifty prospects, and
each digest envelope has fixed-size identifiers, counts and hashes. The
handler measures the complete serialized answer before it returns. An answer
over the tool-output limit also returns overflow, not ready. The publisher
then writes nothing. Boundary tests prove that 99 components are ready, 100
are not, that ten maximum relations return every sentinel, and that ten
maximum digest envelopes fit the current output limit and remain intact after
the Run-result boundary.
intelligence.upsert_company and intelligence.record_signal resolve identity
and cluster evidence in their handlers. The Agent returns canonical IDs.
Company Search validates those IDs against current Intelligence before enrichment.
Company Enrich owns the single profile provider path.
Every compile tool bounds its own answer
A tool result over the tool output limit is bounded, and the call still succeeds. A compile tool that relies on that boundary reports success on a batch it silently cut. Each one therefore prunes to its own declared limit, in a stable order, and reports the count it dropped.
Company capability handoffs preserve the whole batch or refuse overflow.
They never trim signal evidence. The final Smart Feed projection also preserves
the whole bounded set or returns a non-ready overflow result.
The same rule holds at the two run boundaries. A Run input over the refusal
limit is refused, so the scoring and persona inputs must fit it. A default Run
result over the result limit is bounded and marked truncated. Signals Search
uses the workflow result selector to keep the final Smart Feed projection as
one top-level item. Its saved prospect rows and result.item_ready stream
remain the record for the other product surfaces.
Bounded research rounds
The research portion may perform up to three purposeful rounds. They are not three repeats of the same query.
| Round | Purpose | Typical work |
|---|---|---|
| 1. Discovery | Find candidate companies/signals with useful recall | focused ICP queries, category search, news/web search, existing-intelligence lookup |
| 2. Coverage | Fill meaningful gaps | missing signal categories, weak company attributes, stale evidence, missing geography |
| 3. Verification | Confirm strongest or ambiguous candidates | identity/domain validation, date verification, source agreement, conflicting evidence |
Stop after round 1 or 2 when evidence is sufficient. Stop searching when any one is true:
- required evidence is sufficient for the decision;
- another round is producing little new information;
- the result limit is reached;
- no candidates survive verification;
- maximum round count is reached.
The Runtime and Policy limits for tools, tokens, spend, turns, and duration are hard ceilings. If one stops the segment, the Run records its partial_reason. The Agent decides only whether another bounded round is useful inside those ceilings.
Company and signals agent result
The company/signals Agent has three Skills: discover companies, search signals, and verify evidence. Its tool set is:
research.search_webresearch.search_companyresearch.find_personresearch.fetch_pageintelligence.upsert_companyintelligence.upsert_personintelligence.record_signal
The Agent passes the selected lookback_days to each web search and checks
the event date against the frozen as_of time. An article's publication date
alone does not establish when its event happened. Each recorded signal source
must cite an HTTP(S) page and include an excerpt supporting the event and its
date. Before scoring, the compile step checks the stored event date again and
uses only cited sources with evidence, including when a prior grade exists.
A signal names the entity it is filed under: its anchor. The default anchor
is the company, so a person signal in this Agent names its related_company_id
or the tool refuses it. The People Signals Agent files a signal under the
person with anchor: person. Each research binding accepts one anchor kind,
so a signal filed under a person never reaches a company row here, and a
company signal never reaches a person row there.
A hire, an appointment or a promotion that a source names a person for is a
person signal. The Agent finds the person with research.find_person, upserts
the person with the employer keys, and records executive_change, job_change
or promotion with the person as the subject. signals.compile_signal_people
then attaches that person to the company's prospect from the signals that
passed scoring. Being named in a signal does not establish buyer persona fit,
so the attachment has no persona score until the persona lane assesses it.
The Agent records the event under the company only when the source names no
person, or research.find_person answers no profile. The Agent finds a
supplied person with the same tool.
research.find_person takes a name, the company name, the company website and
optionally the company's LinkedIn page. The Agent never writes the query:
- The tool searches Exa people by the name and the company name only. A new title in the query ranks namesakes first, and a recent appointment is often not on the profile yet.
- It looks up the company's Exa entities by the domain, as the employer gate does. It keeps a profile only when the name matches and a current role carries one of those entities.
- When no profile passes, it runs one web search for the profile page. It keeps a page only when a role marked current links the company's LinkedIn page: the caller's, else the page Global Intelligence holds for the domain.
A namesake at another company never passes. The tool meters each call.
A person keys on one identity in a precedence: a LinkedIn profile, a personal
site, or one social profile. The store holds every key a row answers to in an
indexed array and finds the row by any of them. A LinkedIn agreement decides
on its own, because a LinkedIn profile names one person. Below LinkedIn, a row
is the same person only when every identity kind both sides hold agrees, so
two people who share a site keep two rows when their social profiles differ.
A person first seen by a site alone is raised to the LinkedIn profile of a
later write that shares the site and disagrees on nothing. A page on a creator platform, a
podcast directory or a URL shortener is not a personal site, and a site whose
host is the host of the person's email is the employer's. The CRM holds a person by an email
or a LinkedIn profile, so a create_task or promote the scorer suggests for
a person who has neither becomes a watch until one is known.
Company enrichment runs through the company.enrich child.
The compile boundary preserves the following bounded Agent output for scoring.
The Agent returns one structured object. It contains:
- a
summaryof at most 100 characters; rounds_completed, from 1 through 3;stop_reason:evidence_sufficient,diminishing_returns,result_limit,max_rounds, orno_candidates;- at most ten
companies, each with one canonical company ID, one to ten unique canonical signal IDs, one to three source URLs, and zero to three evidence gaps; - at most three
unresolvedcandidates, each with one to three sources, one to threeevidencenotes, and the reasonunresolvedorconflict; - one
supplied_peoplerow for each supplied person the Agent researched, with itsinput_index, the outcomesignal_recorded,no_signal,no_profileoremployer_mismatch, and theperson_idthatintelligence.upsert_personreturned when a profile was found; - at most ten
seed_outcomes, each with aseed_indexand the outcomeno_signal, for each seed member the Agent researched and found no dated event for.
The reason conflict means that two different companies answer to one seed member. A seed domain that no longer serves the company is not a conflict. The Agent writes the company under the domain it found, with the LinkedIn page of the member, and returns it. The company step then admits it through that LinkedIn page. For a supplied person, the Agent writes the employer's name, domain and LinkedIn page on the person row.
An unresolved or conflicting identity cannot enter companies. It stays in unresolved. A company or signal without source evidence cannot enter the result. The result uses no extra fields and stays inside the 256 KiB Run result limit.
A runtime or Policy ceiling can stop the segment before the Agent returns this object. That path reports the exact partial_reason from the runtime. It does not create an incomplete structured result.
Deterministic processing between rounds
Reasoning may be agentic. Identity and duplicate handling should not be.
Between batches use deterministic domain services/Tools for:
- canonical company/domain normalization;
- company and person entity resolution;
- deduplication across providers and queries;
- signal/evidence clustering and deduplication;
- freshness comparison and provenance retention;
- CRM lookup and existing-state projection;
- duplicate-safe Intelligence/Prospect writes.
Do not ask the model whether two rows with the same canonical domain or known person identity are separate entities when deterministic resolution can answer it.
Identity and cluster contract
Use conservative strong keys. A company first uses its exact normalized domain. One domain resolves the company. Different domains return conflict. When no domain exists, one organization LinkedIn key resolves the company. Different organization LinkedIn keys return conflict. A person uses one identity in a precedence: one normalized LinkedIn profile key, else one personal site host, else one social profile. Two values of one kind return conflict. A store row answers to every key its identities give. A LinkedIn agreement makes two records one person; below LinkedIn, every kind both sides hold must agree. Between two stored rows that are one person, the row that agrees on LinkedIn wins.
Names, email addresses and brand roots are evidence. They are not entity keys. A missing strong key returns unresolved. The resolver does not select one key from a conflict.
Every resolved signal observation carries:
- one canonical subject key;
- one signal type;
- one source-independent event key;
- one source reference;
- the observed and fetched times when known;
- one or more claims.
The subject key, signal type and event key identify one cluster. A UTC date alone cannot identify an event. Two distinct events on the same date must stay separate. A missing subject stays unresolved. A conflicting subject stays conflict. Neither subject can form a resolved cluster.
The Intelligence write boundary keeps the same identity. It derives dedup_key from the canonical subject, signal type and event key. A repeated cluster returns the existing signal and adds only new source links. It never derives event identity from the observed date.
A cluster deduplicates source references and keeps each distinct claim. A newer claim does not remove an older conflicting claim. Freshness compares valid timestamps in UTC. Missing or invalid time returns unknown.
Resolution and clustering are pure batch operations. Reordering or repeating the same input must not change the result. The operations must not mutate their input.
Light scan, then deep scan
Use bounded signal verification on selected candidates. This section does not define a Deep enrichment preset. Company and People Enrich support only the Basic and Standard field sets. Do not fully enrich every candidate.
many candidate companies
│
▼
light evidence + basic fit
│
▼
rank / prune
│
▼
deep company verification on strongest / ambiguous candidates
│
▼
people discovery only for bounded top companies
│
▼
contact enrichment / verification only where useful
A broad search might cheaply scan dozens of companies, deeply research only the strongest subset, then search people inside those companies.
The limits are product configuration, and two of them are declared on the workflow rather than chosen at run time. The people discovery step is a fan out, so it carries max_fanout and fanout_concurrency: the first bounds the step against the Inngest step budget, and the second bounds how hard the step hits a search vendor at one instant. See runtime definitions.
They are configuration, not a new execution mechanism.
People discovery is part of the same prospect
Signals attaches relevant people to each opportunity. Standalone People Search also exists as a separate Phase 7 product capability.
The mechanics below describe the Phase 4 research baseline. Phase 7 keeps the web discovery and replaces private identity, enrichment, contact and attachment decisions with the child composition above. Identity gates, candidate bounds, verification meaning and prospect attachment remain required.
For each bounded company that survives company/signal qualification:
company + buyer persona
-> find candidate people
-> resolve/dedupe identity against Intelligence + CRM + current batch
-> cap the merged candidate observations
-> score/explain persona fit
-> verify available contact data for selected people
-> attach relevant people to the Organization Prospect
The persona uses the shared contract.
Legacy title, title_family, function and free-text location are not new Signals inputs.
research.discover_people runs one company and provider pair per call. The workflow owns the bounded fan out across companies and providers. This keeps each paid vendor call linked to one tool span and one usage row.
The workflow fans in every provider call before People Search. A fan-out item returns ok and data only. It does not echo its own arguments, so position is the only link back to the company and provider pair. One deterministic step reads the argument list and result list together, preserves each observation as one supplied input, and joins them by position. Each provider has a fixed position in the fan-out input, and each result keeps its provider and result positions. The earliest position pair ranks an observation, and the normalized profile key is the final sort value. People Search owns identity merging and the deterministic persona gate. The child input can contain at most 256 KB and at most 25 candidate observations for each company. A candidate without a valid profile key stays unresolved and cannot enter People Search in V1.
The persona-fit agent runs once for one bounded set of at most three companies. A workflow has no loop and an agent step has width one, so one Run covers every kept company. The deterministic input separates candidates by prospect and preserves stable Search result IDs. The agent returns structured judgments only. It does not call discovery, contact, Intelligence or Prospect Tools.
Signals Search writes no email. Outreach starts People Enrich for the email when the reader adds a person to a sequence. The attachment step never constructs or stores an address. The one exception is an email that a reader types when they add a person; the prospect write contract owns it.
A company with no matching person is still a valid company prospect; it carries a clear no_matching_people result rather than disappearing.
The contact fallback
The reader can name a type of contact, as a structured persona or as a description. At some prospects the persona lane selects nobody of that type. The Run then searches those prospects again for the person who most likely decides at a company of that size.
signals.plan_people_fallbackreads the merged persona selection. It names each kept prospect with no selected person. It skips the run when the persona is the default, because the default persona is already the decision maker. It skips a company that a supplied person opened.- The step gives each prospect a persona from the size of its company. The
size comes from the scorer fact
employee_count_exact, else from the midpoint ofemployee_count_band. A band with no upper bound is large.Headcount Ranks Function 50 or fewer, or unknown owner, founder, c-suite, president none 51 to 500 c-suite, vice president, director the function of the request more than 500 vice president, director, senior manager the function of the request
The function is the departments of the persona. A persona of titles only names its function in the title words, and the brief can too. A company of more than 500 with no known function takes the ranks of 51 to 500. - The
signals.people.fallbackchild runs the same lane as the persona lane: the provider ladder, the four persona windows, the merge and the person write. Each call, retry and gate reads the persona of its own company (company_personas). The persona Agent readspersona_sourcefallback. - The child attaches nothing. It returns the outcomes of the people it found. The persona lane puts each found outcome in place of the empty outcome of that prospect, and attaches all outcomes once. The people stage of the progress closes on that attach, so one attach keeps it from opening again.
- The reason of each fallback person starts with
FALLBACK_REASON, so the export shows which contacts are fallback contacts.
The second lane is a child because the root has no state budget for it. The
child node sets continue_on_error, so a failed fallback leaves the persona
lane free to attach its own people. The fallback costs one more people search,
and only at the prospects with no selected person.
The employer gate
A name match does not prove an employer. Two companies can share a name. At a prospect with a domain, the gate therefore uses a provider key:
- Each Exa person row carries the Exa entity id of each company in its work history.
research.discover_peoplealso searches Exa companies by the domain. The entity whose site host is the domain counts. A LinkedIn or Facebook page entity counts only when a host in the text the search returned for it equals the domain exactly, or when it is the company's known LinkedIn page. A longer host that contains the domain, such asjoinsouk.com.other.example, is another company. A page name is never evidence:linkedin.com/company/intercom-groupis not Intercom.- A row reads
confirmedwhen a current role carries one of those entity ids. It readsmismatchwhen its current roles carry ids and none match. Else it readsunknown. - A candidate passes only when one of its rows is
confirmedand none ismismatch.
A candidate with unknown rows alone does not pass. When no candidate passes, the prospect reason reads employer_unverified, or employer_entity_not_found when Exa holds no entity for the domain. Parallel returns no employer key. For each Parallel person with a LinkedIn profile, research.discover_people searches Exa by name and company, keeps the Exa profile with the same LinkedIn key, and compares its current roles in the same way. A Parallel person with no such Exa profile stays unknown. A failed check also leaves the person unknown, and the tool keeps the Parallel answer it already paid for. The company-name rule decides only at a prospect without a domain. A later Run does not remove a person that an earlier Run attached.
Supplied people
signals.compile_supplied_people runs after the persona lane. It gives each supplied person one outcome:
| Outcome | Meaning |
|---|---|
attached | the employer is confirmed and the person was sent for attachment, or a prospect of this run already holds them: the prospect of the member's own company, or another whose own company is the stored employer |
company_not_found | the employer is confirmed, but the Run made no prospect for the company |
employer_unconfirmed | no Intelligence row, or no stored employer key that equals the company |
employer_mismatch | the Agent found the person at another company |
no_profile | the Agent found no public profile |
not_researched | the Agent did not reach the person |
The step finds the Intelligence row by the supplied LinkedIn profile, else by the person_id of the Agent. An id whose row has another profile key is refused. With no supplied profile, an id is refused unless the stored name holds every word of the supplied name, so a colleague at the same employer never takes the supplied person's place. The employer is confirmed when the row's company id, company domain or company LinkedIn key equals the prospect's. A supplied person gets persona fit 90 with the reason "Supplied with the list." The step never sends a person that is attached already, because a second write at the same time with another score is a conflict. It still sends that person's CRM link, in an outcome that carries links and no people.
A supplied CRM id links the CRM row. prospect.promote_batch sets prospects.crm_company_id, and the supplied-people attachment sets prospect_people.crm_person_id. Each write sets the link only when it is empty and the CRM row is a live row of this organization.
Two scores, separate meanings
Do not collapse evidence strength and customer fit into one number.
| Score | Scope | Answers |
|---|---|---|
| Signal score | Organization Prospect signal | How well does the linked evidence support this observed event at the frozen as_of time? |
| Lead / opportunity score | Organization Prospect | Given this organization's ICP and context, how valuable is acting on this company now? |
Two tool-less scoring agents enforce the boundary through their inputs:
- The signal agent receives the Intelligence signal, its linked sources and
as_of. It receives no ICP, persona, Knowledge or opportunity data. Relevance means direct support for the event. It does not mean customer fit. - The opportunity agent receives the brief criteria, company facts, resolved signal grades and the stored event type, description and observed date. A resolved grade is new output or a stored grade. The agent does not read Knowledge. Persona fit stays separate from both V1 scores.
Both agents return bounded structured output. Scores use one 0-100 scale: 0-39 weak, 40-69 moderate and 70-100 strong. An explicit hard ICP mismatch caps the opportunity score at 39 and requires a dismiss action. A strong-fit company with a moderate signal can therefore outrank a poor-fit company with a strong signal. The company scorer names a role in a task recommendation, not a person ID; people are matched after this score.
One scoring batch contains at most ten companies and ten signals for each company. Each compact signal keeps at most three sources and three evidence statements. A reason and a recommended action each contain at most 200 characters.
A scoring batch may be empty, and an empty batch answers an empty result. A workflow has no loop and no way to skip a node and still feed the node after it, so a retry that reuses every stored grade, and a search that resolves no company, both reach the scorer with nothing to grade.
The workflow scores only an ungraded signal. It reuses the stored grade when a retry or another search finds the same organization and intel_signal_id. This keeps the immutable grade stable when model output or the brief changes. The grade is a first-observation snapshot. The review shows current evidence freshness separately. A new Intelligence signal gets a new grade.
Missing linked source evidence refuses a signal score. Missing ICP and company criteria refuses an opportunity score. A scorer never substitutes a Knowledge source or an inferred default.
Every score keeps a short reason. Every opportunity keeps one short recommended next action. The action is a recommendation and causes no side effect.
Result shape
The workflow returns results grouped around the Organization Prospect, not separate company and people products.
Organization Prospect
company
name / domain / location / size
fit + opportunity score + reason
CRM state
signals
signal score + reason
evidence + provenance + freshness
relevant people
Person A: role, seniority, location, persona-fit reason,
evidence, contact verification state
Person B: ...
review state
new / watching / dismissed / promoted
recommended next action
The result should be useful without opening raw Run traces.
The root result names each supplied input that did not become a result:
| Key | Holds | Reasons |
|---|---|---|
skipped_companies | each company input or seed member with no result | unresolved, conflict, not_found, identity_conflict, domain_changed, not_researched |
no_event_companies | each seed member the Agent scanned with no dated event | no_signal |
skipped_people | each supplied person the scope could not place | no_identity, no_company, conflict, over_limit |
supplied_people | one outcome for each supplied person | see Supplied people |
not_researched means the research segment stopped at its round or cost limit before it reached the member.
The prospect ref kind names one public.prospects row. The future prospect.person kind names one public.prospect_people row. A discovery Run reports ready company results with prospect refs. A selected person can move to outreach with a prospect.person ref before CRM promotion.
Incremental results
Signals Search streams useful stable updates while later work continues.
company discovered/resolved
-> preliminary evidence
-> signal/fit updated
-> company verified
-> relevant people discovered
-> contact/person evidence improved
-> final prospect ready
The same visible prospect card may improve during the Run. SSE is live transport; durable Intelligence, Prospect rows, Runs and spans remain the record.
Each successful prospect write Tool reads the current durable ProspectSummary and publishes it as result.item_ready. This includes prospect.upsert, prospect.attach_people, prospect.attach_people_batch and prospect.set_review_state. A batch publishes one event for each changed prospect. The event uses the prospect id as its stable item key and updated_at as its revision. A repeated, lost or out-of-order event cannot change the durable result.
Saved search and Smart Feed
The discovery surface keeps product state needed for repeat use:
- saved discovery query / ICP reference;
- Search History;
- companies already surfaced for that search;
- review state: new / watching / dismissed / promoted;
- evidence and signal references;
- latest signal score;
- latest opportunity score and reasons;
- relevant people snapshot/reference set;
- Smart Feed diff state.
The Smart Feed emphasizes diffs: new prospects, new signals, new relevant people, materially changed evidence or meaningful score changes. A scheduled Run must not replay the same unchanged list.
V1 serves the diff for the latest published successful Run. Each Run writes immutable observation rows and compares them with the published Run that its input names. Only one Run can publish from a given baseline. A successful overlapping Run whose baseline became stale remains in Run Explorer and does not replay the same delta. The API accepts no historical Run id, and the observation-row retention period is not a product promise. Run Explorer remains the product Run-history surface.
A saved search watches a CRM list
A Signals saved search can watch one CRM list of companies. One Run checks
every company on the list, up to 50, and does not discover a company outside
it. The save route refuses a longer list with the 422 code
watch_list_too_large. It checks the company count that the CRM keeps on the
list row.
The root splits the list into batches of ten. signals.compile_run_scope
reads the first 50 live companies in list order, drops a member that names no
company or the same company as an earlier member, and fills one batch for each
ten. A list that grew past 50 after the save does not fail the Run. The Run
checks the first 50, and the result counts the rest in beyond_limit.
The batches run one after the other. The account holds few concurrent steps, so a parallel Run would hold every slot of every tenant. A sequence also lets the guard stop the Run between two batches.
The guard keeps the Run inside its ceilings. Every definition of the
Signals tree names 5,000 cents, because each child compares the spend of the
whole tree with its own ceiling. Before each batch, signals.guard_batch
reads the spend of the tree and the time left on the root clock. It starts the
batch only when both hold one more batch: 1,000 cents and the batch clock plus
the merge. A batch that would pass a ceiling never starts, so the tree never
stops inside a batch, and the merge and the Smart Feed step always run.
A failed batch does not stop the next batch. The slot tolerates the
failure of its child. The merge names the companies of a failed batch, and of
a batch the guard did not start, in unchecked_companies with the reason. A
batch that stopped at its own clock keeps the prospects it wrote.
No Run stores a position in the list. The saved_searches.watch_cursor
column is no longer read or written, and a contract migration drops it later.
A Run result stored before the change still validates and publishes.
Chat column
The search page holds a chat column left of the form. A person describes the search, and the chat writes it into the form. The person checks the form and presses the launch button. The chat starts no Run.
The column runs a draft conversation for signals.search and
people.signals. Each message carries the form state, so a hand edit and a
picked list survive the next revision. The chat switches the form to People
for a search about people. It cannot add or remove a company or a person: the
person picks the rows in the form.
The chat may research before it writes the form. It searches the web for ideas and for the events that come before a purchase, reads a page the person names, and reads the CRM, so "find me companies like Acme" writes a search from the Acme row. Each tool reads. The chat still starts no Run.
The first release shows the column to superadmins only. Surfaces § Draft conversations owns the contract.
Scheduled monitoring
A saved search may own a Trigger. The Trigger starts the same published Signals Search workflow used by an active search.
saved search + schedule
-> Trigger
-> RunManager
-> Signals Search
-> compare with existing Intelligence / Prospect state
-> Smart Feed: new or materially changed opportunities
There is no product-specific scheduler.
The Trigger cannot carry the baseline in a static template. A Run publishes
a Smart Feed diff only when run.input['baseline_run_id'] equals the saved
search's current last_run_id, and that value changes on every run. A fixed
template therefore publishes once and answers stale for ever after.
The trigger row names the saved_search input builder instead. The builder
reads the saved search at dispatch and returns the same frozen input the
interactive start route builds.
{'source': 'saved_search',
'saved_search_id': str(search.id),
'baseline_run_id': str(search.last_run_id) if search.last_run_id else None,
'brief': search.brief}
The builder lives with this product and registers itself. The trigger layer imports the registry alone. See triggers.
Signal-driven refresh
Signals may arrive for watched prospects or existing CRM entities.
new evidence / signal
-> normalize + signal dedupe
-> update Intelligence
-> evaluate organization relevance
-> targeted verification when needed
-> refresh relevant people only when stale/missing/materially affected
-> update Prospect / Smart Feed
or recommend action on existing CRM entity
Do not rerun a broad discovery workflow when one targeted verification or people refresh is enough.
Agent / Workflow / Skill / Tool shape
A clean V1 shape is one published Signals Search Workflow Definition coordinating bounded deterministic stages, with reasoning inside focused Agents.
Example internal responsibilities:
| Unit | Responsibility |
|---|---|
| Company/signals Agent | search strategy, evidence gaps and canonical company/signal references |
| Signal scoring Agent | evidence strength and freshness for each ungraded signal |
| Opportunity scoring Agent | company value from the brief, company facts and resolved signal grades |
| People Agent | tool-less persona-fit judgment after supplied People Search |
| Deterministic steps | normalization, entity resolution, dedupe, limits, fan-out/fan-in |
| Skills | discover companies, search signals and verify evidence |
| Capability children | company search and enrichment; no email lookup |
| Tools | signal research, scoped CRM reads, Intelligence/Prospect persistence and provenance; no CRM writes |
The workflow defines order and bounded fan-out. Agents reason inside steps. Tools own safe effects, credentials, policy checks, idempotency, vendor limits and result limits.
Do not create a separate Sonar runtime, Headhunter runtime, people database or discovery queue.
Prospect write contract
V1 has three model-facing prospect write tools and three workflow-only batch
writes. Each tool declares repeatable = False. A repeated call returns the
first result through the tool journal.
| Tool | Contract |
|---|---|
prospect.upsert | Resolve one subject row: (organization_id, intel_company_id) for a company, (organization_id, intel_person_id) for a person. Upsert at most 25 signal grades. Preserve first_seen_at, CRM references and review state. A model call also writes a prospect in the promoted review state. |
prospect.attach_people | Add or improve at most 25 relevant people. An empty list requires a reason and is valid only when the prospect has no person row. |
prospect.set_review_state | Set new, watching or dismissed. Refuse promoted, because the promotion tool owns that state. |
prospect.promote_batch | Rank scored companies or people, prune them to the result limit, then apply the prospect.upsert contract to at most ten of them in one call. A batch holds one subject kind. It returns one ordered row for each kept subject: in prospects, or in dismissed_prospects when the stored prospect is dismissed. A new prospect whose suggested action is dismiss is inserted as dismissed. An existing prospect keeps its review state. A stored prospect that a person promoted into the CRM is held: the Run writes nothing to it and does not list it, because the subject is in the CRM. It answers the subject in held_subject_ids. No Agent declares it. |
intelligence.upsert_people_batch | Persist original source observations for at most 75 selected people in stable order. Reuse the existing Intelligence person service, return canonical IDs and refuse identity changes. No Agent declares it. |
prospect.attach_people_batch | Apply a found, complete-empty or partial-pending people outcome to at most three prospects in stable order. It returns one row for each attempted prospect and emits one item-ready event for each changed row. No Agent declares it. |
Why batch writes, and not a write fan out. A wide step must be read
only, because a partial write has no recovery rule yet. A batch write inside
one step needs no new rule. Each row keys on its subject, (organization_id, intel_company_id) or (organization_id, intel_person_id), and is idempotent on
its own, and the invoker releases the
idempotency claim when a handler raises. A retry therefore repeats the whole
batch and resolves the same rows. The handler writes each row independently. It
never opens one transaction over the batch.
prospect.promote_batch deduplicates by the subject id before it writes.
The company and signals Agent result marks its companies array
uniqueItems, and that rule compares whole objects. Two entries that name one
company with different sources both satisfy it.
The invocation supplies the organization and Run. No tool argument supplies
either value. A signal score requires its short reason. A people batch with
items sets people_state to found and clears the empty reason.
The workflow fans in before each batch write. Each prospect and person write
includes the time of its source observation. A newer observation can replace
its score, reason and contact state. An older observation cannot. Equal-time
writes must have the same values or the tool refuses the conflict. The write
keeps first_seen_at and advances last_seen_at only to the greater value.
A signal grade is immutable for one intel_signal_id, because that identity
names one observed event. A repeated grade must have the same values. A
correction creates a new Intelligence signal instead of changing the old
event. Before scoring, the workflow reads existing prospect signal grades and
passes only ungraded signals to the signal agent. A retry or another search
reuses the stored grade. These rules make the merge independent of Run
completion order.
People attachment is additive in V1. The workflow can write an empty result
only after every bounded provider call for that prospect succeeds and People
Search settles each owned input. A partial outcome with no usable person keeps
people_state: pending and writes the exact failure reason only while the
stored state is still pending. It cannot regress an existing found or
no_matching_people state. The batch carries
one result record for each provider search of that prospect. Its result count
records the candidates that the search found. It can be positive when no
candidate matches the buyer persona. people_state_reason is required plain
text, not a second state enum.
A reader can also give a company prospect more people. A reader's add is a
found write through the prospect service, and the name is the one required
field. A typed job title or email never reaches intel_people, because
every organization reads that table. An add with a LinkedIn profile records
one Intelligence fetch with the provider user, and it writes only the
profile, the name and the employer of the prospect, each into an empty field.
The prospect_people row keeps the typed current_title and email. A
reader typed them to correct the profile, so the read shows each one over the
profile value, and the profile row keeps its own value. Promotion writes them
to the CRM person, also to a CRM person it finds by the profile. A typed email
that another CRM person holds refuses the promotion before any person write.
Outreach sends to a typed email when the person is not in the CRM. An add with
no profile has no identity
intel_people can key. The prospect_people row then holds full_name,
current_title and email, and intel_person_id is null. The partial
unique index uq_prospect_people_prospect_named_person keys such a row on its
prospect and its name, without case and with each run of spaces as one
space, so a second add of the same name answers that row, also when two adds
race. Promotion resolves such a person at the
company: by the email when it has one, else by the name, as a person a signal
names.
A reader can correct a listed person. The edit writes the same columns. A new
LinkedIn profile records one user fetch, as an add does, and moves the row
in place to that global person: intel_person_id takes the new id,
full_name becomes null, contact_state returns to unverified, and the
persona fit is cleared, because each one described the old profile. A new
email also returns contact_state to unverified. A profile write that read
no stored row passes an empty observation to the store, so a value another
writer stored first stays.
The edit writes the row only when updated_at still equals the value the
reader read. A promoted person's CRM row takes the values the review shows.
On a re-link, a CRM title or email that still equals the old profile value
takes the new profile value, and a value a user set in the CRM stays. A
cleared typed title or email resets the CRM value only when the CRM value
still equals the cleared one.
The API decides every value, and the ac-backend function
edit_prospect_person writes the row and the CRM person in one transaction:
a unique index or a deleted CRM person rolls both writes back. A person a signal named has no row, so the drawer stores them with an
add that names the signal. prospect_people.named_signal_id keeps that
signal, and the drawer then hides every signal card that names the person,
also after a rename. A reader's search is one people.search Run
for one company. The drawer opens a form on a description box. An empty
description searches for the decision-maker persona. The reader can describe
the people in free text instead, or switch to the exact fields, which hold the
decision-maker persona to change. People Search then plans the persona from the description with the same
persona planner Agent that Signals Search runs. prospects.people_search_run_id
links the Run to the prospect while the Run is live. The prospect.people-attach
function attaches the people on agent/run.succeeded and clears the link. A
failed Run only clears the link. A search that finds nobody sets
no_matching_people with the reason search_no_match.
The empty path first confirms that the prospect has no person row. It then
updates only a pending or no_matching_people prospect. A non-empty path
writes found after its people rows. Therefore, found wins a concurrent
empty write. An empty result never deletes an earlier person result.
A partial-pending path also confirms that the prospect has no person row, then
updates only people_state: pending. If that compare-and-set changes no row,
it reads and preserves the current complete state.
The tool handlers and the product endpoints call one platform-local prospect
service. A product endpoint never passes through ToolInvoker. Prospect curation writes
product state and does not write an approval row by itself.
CRM promotion
Promotion is explicit and can happen after the combined prospect is reviewed.
Organization Prospect
+ selected relevant people
-> user / policy-approved promotion
-> CRM domain Tools
-> canonical company upsert / resolve
-> selected person upserts / relationships
-> optional list membership
-> CRM references returned
If company/person records already exist, promotion resolves to the canonical records. Discovery never silently populates CRM merely because a company or person was found.
Promotion uses recovery, not a business-logic database function. It writes
each canonical CRM reference after its idempotent upsert. It sets the prospect
state to promoted only after every selected reference is linked. A retry
resolves the same CRM rows and completes the missing links. A partial attempt
can leave canonical CRM rows present while the prospect keeps its earlier
review state.
Two callers share one promotion service: the prospect.promote tool, and
POST /api/v1/agentic/prospects/{id}/promote. A person promotes over the
route and an agent promotes over the tool, and the two cannot diverge. The
optional list receives the company. A selected person who already holds a
different CRM company link is a conflict that names that person; the reviewer
deselects that person and retries.
A person prospect promotes its subject person. When the person record links
an employer (intel_people.current_intel_company_id), the promotion resolves
or creates the CRM company of that employer, links the CRM person to it, and
writes it as the prospect's crm_company_id. The link never refuses the
promotion: a CRM person that already holds a company link keeps it, and an
employer with no usable identity, or one the CRM cannot resolve to one
company, links no company while the person still promotes. The optional list
receives the person, never the employer. The employer company moves to the
Prospect stage when it is at Identified. A company further along keeps its
stage.
A company signal can name a person whose profile does not resolve. The
research Agent then records named_person (full name and title) in the
signal payload. GET /api/v1/agentic/prospects/{id}/signals returns it on each
signal as named_person. A promotion of a
company prospect takes named_signal_ids. Each named person joins CRM People
by name at the company, at the Prospect stage, with no email and no LinkedIn
profile. A person who already holds that name at the company is reused. Only
the promotion writes a person this way. The global intelligence store and
every model tool still refuse a person with no identity key.
The batch also looks up such a person in code, because the research Agent can
skip the lookup or miss on its first try. After the stored matches,
signals.plan_signal_lookups plans one research.find_person call for each
person a leadership signal names on a kept prospect, when no stored profile
matches the whole name and no prospect row keeps the signal. A dismissed
prospect is not looked up, because a lookup costs. The plan takes at most
three people per prospect and ten per batch. signals.resolve_signal_people
keeps a profile only when exactly one profile holds the whole name and the
provider confirmed the current employer. Two such profiles name two people,
and a name cannot choose. The profile fills only empty fields of the global
person. The step adds the found people to the stored matches, so one
prospect.attach_people_batch writes both, and it answers attempted and
resolved counts. Each attached person keeps named_signal_id, so the
review drawer shows no signal card for them.
Existing CRM companies may use Signals Search without re-promoting the company; newly discovered people still require the normal explicit/domain write path when the organization chooses to manage them.
Outreach handoff
selected relevant people
+ canonical company/person context
+ current signals / prospect reasons
-> email sequence workflow
-> personalized draft(s) -> approval -> send
The handoff may use Intelligence/Prospect identities when records are not yet promoted, plus CRM references when available. It does not require premature CRM creation and does not copy prompt blobs into another product store.
Failure / edge cases
| Case | Behavior |
|---|---|
| Same company returned by multiple searches | one canonical entity; merge evidence/provenance |
| Same signal appears in several sources | one logical signal/evidence cluster with source references |
| Duplicate person found from several sources | merge/dedupe and preserve provenance |
| Company already exists in CRM | project CRM state; update Intelligence; never duplicate company |
| Person already exists in CRM | resolve canonical person; do not duplicate on promotion |
| Company has no matching people | keep company prospect with explicit empty people result |
| Contact data cannot be verified | keep person; mark verification state; never fabricate |
| One candidate/company fails | independent candidates continue unless workflow semantics require stop |
| CSV/imported company cannot be resolved | the scope step reports it in unresolved_inputs; the Run result names it in skipped_companies; no entity is created for it |
| Supplied person cannot be placed at a company | the scope step reports it in unresolved_people; the Run result names it in skipped_people |
| Supplied person has no findable profile | the Agent reports no_profile; the company is still researched |
| Supplied person's employer cannot be confirmed | supplied_people reads employer_unconfirmed; the person is not attached |
| Seed domain moved to another domain | admitted through the member's LinkedIn page; without one, skipped_companies reads domain_changed |
| Seed member has no dated event | the Run result names it in no_event_companies, not in skipped_companies |
| Research stops at its limit | each seed member and supplied person it did not reach reads not_researched |
| Evidence conflicts | verification round or lower confidence; preserve disagreement |
| Scheduled Run finds nothing new | successful Run with empty Smart Feed diff |
| Search/persona too broad | enforce bounded result limits; return best set / ask user to narrow next Run |
| Budget exhausted | a Run that already produced node output succeeds with partial_reason and truncation metadata; a Run that produced none fails and still names the clock |
V1 rules
- One canonical Signals Search workflow replaces separate Sonar and Headhunter execution flows.
- Company discovery, signal research and relevant-people discovery produce one Organization Prospect result.
- Search is bounded to at most three purposeful research rounds.
- Use light signal research before selective verification. Company/People enrichment uses only Basic or Standard targets.
- Entity resolution, dedupe, provenance and CRM identity projection are deterministic.
- Signals are evidence, not leads; signal score and opportunity score remain separate.
- A discovered company/person is not automatically a CRM record.
- Scheduled work uses Trigger +
RunManager+ Inngest. - Stream useful partial prospect updates.
- CRM promotion is explicit and idempotent through CRM Tools.
- A separate explicit Phase 8 action can hand selected people to outreach.
- No Sonar→Headhunter Run chaining, separate discovery scheduler, people database, queue or runtime.
Minimum scenarios
| Scenario | Expected behavior |
|---|---|
| Broad ICP search | bounded company/signal rounds → strongest companies → relevant people → combined prospect results; no CRM writes |
| Existing CRM target list | skip broad company discovery; refresh signals + relevant people against canonical CRM companies; no company outside the set becomes a prospect |
| Same company from five searches | one normalized entity with merged evidence, not five prospects |
| Strong funding signal on poor-fit company | high signal score may coexist with low opportunity score; people search may be skipped after pruning |
| Strong-fit company with no people found | prospect remains reviewable with no_matching_people state |
| Person found by three providers | one person result with merged provenance/contact state |
| New signal on existing CRM customer | update Intelligence and recommend action against existing CRM entity; no duplicate CRM record |
| Scheduled run finds no material change | success with no Smart Feed additions |
| Discovery or company-set Run reaches the final node | not_applicable; no publication rows are read or written |
| Another Run writes during the final saved-search read | one ready snapshot or superseded; never mixed prospect state |
| User promotes prospect + two people | idempotent CRM Tools resolve/create canonical company and selected people only |
| User selects person for outreach before CRM promotion | the email sequence workflow can use authorized Intelligence/Prospect context; CRM creation is not forced |