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.

1 min read Updated Oct 8, 2026

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:

TEXT
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:

  1. Compile at most ten researched companies into supplied Company Search references.
  2. Run the read-only supplied Company Search tool through an empty-input wrapper.
  3. Match returned rows to the supplied canonical IDs and compile Company Enrich subjects.
  4. Run the Company Enrich child through an empty-input wrapper.
  5. 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.

  1. signal-people attaches 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 with research.find_person. The lookup keeps a profile only when exactly one confirmed profile holds the whole name.
  2. has-contact-persona reads the scope flag has_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.
  3. plan-people-search builds one research.discover_people call for each kept prospect, at most ten. discover-people fans the calls out to the first provider.
  4. plan-people-retry gives each company that the first provider left unsettled to the next provider. discover-people-retry fans that plan out. An empty plan fans out to nothing.
  5. people-windows runs four windows in parallel. A window holds at most three prospects and 25 candidates for each prospect. In each window, signals.compile_people_search joins the fan-out results by position. signals.compile_search_persona applies the supplied People Search rules in the step and builds the persona-fit input. The persona-window-N child runs the tool-less persona-fit Agent. A window with no candidate returns no-candidates, and the Agent does not run.
  6. signals.merge_people_enrich joins the selections of the four windows. It refuses a window output of any other shape.
  7. For a nonempty selection, intelligence.upsert_people_batch writes the selected people and their provider observations to Intelligence. prospect.attach_people_batch then attaches them. For an empty selection, signals.compile_empty_people writes 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.

SourceExampleWorkflow behavior
Discovery briefICP, geography, company criteria, signal criteria, buyer personadiscover companies/signals first, then relevant people
Bounded company setselected prospects, CRM list, selected CRM companies, normalized CSV/import, a list of peopleskip broad company discovery; refresh signals and find relevant people, or the supplied people
Saved monitoring scopesaved search, watched prospects, target listlook 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, seniority and country_codes, or a free-text persona_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.

JSON
{
  "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

TEXT
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.

#NodeKindWidth
0needs-persona-planbranch; plan-persona Agent when persona_description is supplied without a structured persona, empty-persona-plan (signals.empty_persona_plan) otherwise1
1compile-run-scopesignals.compile_run_scope, read1
2 to 11guard-batch-K, batch-Kfor K from 1 to 5: signals.guard_batch, read, then a signals.batch subworkflow that tolerates a failure1
12merge-batchessignals.merge_batches, read1
13compile-smart-feed-observationssignals.compile_smart_feed_observations, read1

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.

#NodeKindWidth
1compile-search-scopesignals.compile_batch_scope, read1
2company-signalsagent1
3compile-company-searchsignals.compile_company_search, read1
4company-searchsubworkflow wrapper; company.search_supplied in the nonempty arm1
5compile-company-enrichsignals.compile_company_enrich, read1
6company-enrichsubworkflow wrapper; Company Enrich child in the nonempty arm1
7compile-company-resultsignals.compile_company_result, read1
8compile-signal-batchsignals.compile_signal_batch, read1
9score-signalsagent1
10compile-opportunity-batchsignals.compile_opportunity_batch, read1
11score-opportunitiesagent1
12promote-prospectsprospect.promote_batch, write1
13signal-peoplesequence1
14compile-signal-peoplesignals.compile_signal_people, read in signal-people1
15plan-signal-lookupssignals.plan_signal_lookups, read in signal-people; at most three people for each prospect1
16find-signal-peopleresearch.find_person, read in signal-peoplefan out, at most 10
17resolve-signal-peoplesignals.resolve_signal_people, write in signal-people1
18attach-signal-peopleprospect.attach_people_batch, write in signal-people1
19has-contact-personabranch on the scope flag has_people_search; skip-people-search (prospect.attach_people_batch with no outcome) otherwise1
20plan-people-searchsignals.plan_people_search, read in the persona arm1
21discover-peopleresearch.discover_people, read in the persona arm; the first providerfan out, at most 10
22plan-people-retrysignals.plan_people_retry, read in the persona arm1
23discover-people-retryresearch.discover_people, read in the persona arm; the companies the first provider left unsettledfan out, at most 10
24people-windowsparallel, four windows in the persona arm4
25compile-people-search-Nsignals.compile_people_search, read in window N1
26compile-search-persona-Nsignals.compile_search_persona, read in window N1
27persona-window-Nsubworkflow in window N; the tool-less persona-fit Agent, or no-candidates (signals.empty_people_window) for an empty window1
28merge-people-enrichsignals.merge_people_enrich, read in the persona arm1
29has-people-enrichbranch in the persona arm1
30compile-person-evidencesignals.compile_person_evidence, read in the selected arm1
31persist-person-evidenceintelligence.upsert_people_batch, write in the selected arm1
32compile-people-resultsignals.compile_people_result, read in the selected arm1
33attach-selected-peopleprospect.attach_people_batch, write in the selected arm1
34compile-enrich-emptysignals.compile_empty_people, read in the empty arm1
35attach-enrich-emptyprospect.attach_people_batch, write in the empty arm1
36supplied-peoplesequence1
37compile-supplied-peoplesignals.compile_supplied_people, read in supplied-people1
38attach-supplied-peopleprospect.attach_people_batch, write in supplied-people1
39compile-batch-resultsignals.compile_batch_result, read1

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.

ToolReadsAnswers
signals.compile_run_scopethe whole Run input, the saved-search membership and the watched CRM list of this organizationthe 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_scopethe shared scope, one batch, Intelligence company identity and the prospects of this organizationthe scope every later node of the batch reads
signals.guard_batchwhether the slot holds work, the spend of the whole tree and the root deadlinewhether the batch runs, and why not
signals.empty_batchno dataan explicit skipped batch
signals.compile_batch_resultthe prospects, the skipped rows and the supplied people of one batchthe batch result the merge reads
signals.merge_batchesthe guard answer, the batch and the result of each slot, and watched_total and beyond_limit from the run scopethe joined prospects and skipped rows, unchecked_companies, and watched_total and beyond_limit copied through to the Run result
signals.compile_search_scopewhat signals.compile_run_scope reads, then Intelligence company identity and the prospects of this organization for batch 1the scope of batch 1, for a graph that Runs admitted before the batch children still run
signals.compile_company_searchresearched companies, exact seed identities and the scope's unresolved inputsconfined 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_enrichcomplete Search output and research evidenceadmitted companies and selected-field Enrich input
signals.compile_company_resultcomplete Enrich output and research evidencevalidated companies for scoring
signals.empty_company_batchno dataan explicit empty wrapper result
signals.compile_signal_batchadmitted companies, Intelligence signals and sources, stored prospect gradesthe signal scoring input, holding ungraded signals only
signals.compile_opportunity_batchthe new grades, the stored grades, Intelligence company facts, the seedthe opportunity scoring input, with the resolved grades under each company
signals.plan_people_searchthe kept prospects and the buyer personaone ordered research.discover_people argument list
signals.plan_people_retrythe kept prospects, the buyer persona and the first provider's fan-out resultsone research.discover_people argument for each company the first provider left unsettled, with the next provider
signals.compile_people_searchthat argument list and the fan-out resultsa branch flag, stable supplied Search input and index-to-prospect evidence map
signals.compile_search_personathe Search result and the index-to-prospect evidence mapa bounded persona-fit input grouped by prospect, with per-prospect completeness
signals.merge_people_enrichthe persona-fit input and the child output of each windowa branch flag, the selected people of every window and their source observations
signals.compile_person_evidenceselected source observations and complete Enrich resultscanonical-checked Intelligence person evidence writes
signals.compile_people_resultselected evidence, evidence-write results and the complete Enrich resultone validated attachment argument for each attempted prospect
signals.compile_signal_peoplethe scored companies and their signal grades, the kept and dismissed prospects, Intelligence signals and the stored people of each companyone attachment argument for each prospect whose leadership signal names one stored person
signals.compile_empty_peopleexactly one empty Search, persona or Enrich stageone complete-empty or partial-pending attachment argument for each attempted prospect
signals.plan_people_fallbackthe kept prospects, the merged persona selection, the scorer company facts, the persona and the seedthe prospects with no selected person, and the persona of each one by company size
signals.compile_supplied_peoplethe supplied-people plan, the seed, the promoted prospects, Intelligence people and the people already attachedone outcome for each supplied person, and the attachments for confirmed people
signals.compile_smart_feed_observationsthe source, frozen time, promoted prospect ids, and their current signal grades and peopleone 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:

FieldHolds
sharedthe source, as_of, the lookback, the brief, the target companies, the persona and the people providers every batch shares
batchesbatch_1 to batch_5, each with whether it runs, whether it confines, and at most ten raw company references
watched_totalhow 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_limithow many live companies of the list are past the first 50. The Run does not read or check them
droppedlist 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.

FieldHolds
sourcethe Run input source
as_ofthe frozen observation time
briefthe ICP and the company criteria, and nothing else
personathe buyer persona
seed_companiesthe 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_seedwhether a company outside the seed may become a prospect
unresolved_inputseach supplied company reference that names no company
unresolved_peopleeach supplied person the seed could not place, by position and name
persona_sourcesupplied, planned or default. Supplied people under the default persona keep the persona lane off
droppedsupplied references a company already in the seed had claimed
watched_totalhow 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.

RoundPurposeTypical work
1. DiscoveryFind candidate companies/signals with useful recallfocused ICP queries, category search, news/web search, existing-intelligence lookup
2. CoverageFill meaningful gapsmissing signal categories, weak company attributes, stale evidence, missing geography
3. VerificationConfirm strongest or ambiguous candidatesidentity/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_web
  • research.search_company
  • research.find_person
  • research.fetch_page
  • intelligence.upsert_company
  • intelligence.upsert_person
  • intelligence.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:

  1. 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.
  2. 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.
  3. 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 summary of at most 100 characters;
  • rounds_completed, from 1 through 3;
  • stop_reason: evidence_sufficient, diminishing_returns, result_limit, max_rounds, or no_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 unresolved candidates, each with one to three sources, one to three evidence notes, and the reason unresolved or conflict;
  • one supplied_people row for each supplied person the Agent researched, with its input_index, the outcome signal_recorded, no_signal, no_profile or employer_mismatch, and the person_id that intelligence.upsert_person returned when a profile was found;
  • at most ten seed_outcomes, each with a seed_index and the outcome no_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.

TEXT
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:

TEXT
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.

  1. signals.plan_people_fallback reads 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.
  2. 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 of employee_count_band. A band with no upper bound is large.
    HeadcountRanksFunction
    50 or fewer, or unknownowner, founder, c-suite, presidentnone
    51 to 500c-suite, vice president, directorthe function of the request
    more than 500vice president, director, senior managerthe 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.
  3. The signals.people.fallback child 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 reads persona_source fallback.
  4. 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.
  5. 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:

  1. Each Exa person row carries the Exa entity id of each company in its work history.
  2. research.discover_people also 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 as joinsouk.com.other.example, is another company. A page name is never evidence: linkedin.com/company/intercom-group is not Intercom.
  3. A row reads confirmed when a current role carries one of those entity ids. It reads mismatch when its current roles carry ids and none match. Else it reads unknown.
  4. A candidate passes only when one of its rows is confirmed and none is mismatch.

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:

OutcomeMeaning
attachedthe 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_foundthe employer is confirmed, but the Run made no prospect for the company
employer_unconfirmedno Intelligence row, or no stored employer key that equals the company
employer_mismatchthe Agent found the person at another company
no_profilethe Agent found no public profile
not_researchedthe 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.

ScoreScopeAnswers
Signal scoreOrganization Prospect signalHow well does the linked evidence support this observed event at the frozen as_of time?
Lead / opportunity scoreOrganization ProspectGiven 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.

TEXT
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:

KeyHoldsReasons
skipped_companieseach company input or seed member with no resultunresolved, conflict, not_found, identity_conflict, domain_changed, not_researched
no_event_companieseach seed member the Agent scanned with no dated eventno_signal
skipped_peopleeach supplied person the scope could not placeno_identity, no_company, conflict, over_limit
supplied_peopleone outcome for each supplied personsee 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.

TEXT
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.

TEXT
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.

PYTHON
{'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.

TEXT
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:

UnitResponsibility
Company/signals Agentsearch strategy, evidence gaps and canonical company/signal references
Signal scoring Agentevidence strength and freshness for each ungraded signal
Opportunity scoring Agentcompany value from the brief, company facts and resolved signal grades
People Agenttool-less persona-fit judgment after supplied People Search
Deterministic stepsnormalization, entity resolution, dedupe, limits, fan-out/fan-in
Skillsdiscover companies, search signals and verify evidence
Capability childrencompany search and enrichment; no email lookup
Toolssignal 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.

ToolContract
prospect.upsertResolve 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_peopleAdd 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_stateSet new, watching or dismissed. Refuse promoted, because the promotion tool owns that state.
prospect.promote_batchRank 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_batchPersist 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_batchApply 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.

TEXT
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

TEXT
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

CaseBehavior
Same company returned by multiple searchesone canonical entity; merge evidence/provenance
Same signal appears in several sourcesone logical signal/evidence cluster with source references
Duplicate person found from several sourcesmerge/dedupe and preserve provenance
Company already exists in CRMproject CRM state; update Intelligence; never duplicate company
Person already exists in CRMresolve canonical person; do not duplicate on promotion
Company has no matching peoplekeep company prospect with explicit empty people result
Contact data cannot be verifiedkeep person; mark verification state; never fabricate
One candidate/company failsindependent candidates continue unless workflow semantics require stop
CSV/imported company cannot be resolvedthe 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 companythe scope step reports it in unresolved_people; the Run result names it in skipped_people
Supplied person has no findable profilethe Agent reports no_profile; the company is still researched
Supplied person's employer cannot be confirmedsupplied_people reads employer_unconfirmed; the person is not attached
Seed domain moved to another domainadmitted through the member's LinkedIn page; without one, skipped_companies reads domain_changed
Seed member has no dated eventthe Run result names it in no_event_companies, not in skipped_companies
Research stops at its limiteach seed member and supplied person it did not reach reads not_researched
Evidence conflictsverification round or lower confidence; preserve disagreement
Scheduled Run finds nothing newsuccessful Run with empty Smart Feed diff
Search/persona too broadenforce bounded result limits; return best set / ask user to narrow next Run
Budget exhausteda 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

ScenarioExpected behavior
Broad ICP searchbounded company/signal rounds → strongest companies → relevant people → combined prospect results; no CRM writes
Existing CRM target listskip broad company discovery; refresh signals + relevant people against canonical CRM companies; no company outside the set becomes a prospect
Same company from five searchesone normalized entity with merged evidence, not five prospects
Strong funding signal on poor-fit companyhigh signal score may coexist with low opportunity score; people search may be skipped after pruning
Strong-fit company with no people foundprospect remains reviewable with no_matching_people state
Person found by three providersone person result with merged provenance/contact state
New signal on existing CRM customerupdate Intelligence and recommend action against existing CRM entity; no duplicate CRM record
Scheduled run finds no material changesuccess with no Smart Feed additions
Discovery or company-set Run reaches the final nodenot_applicable; no publication rows are read or written
Another Run writes during the final saved-search readone ready snapshot or superseded; never mixed prospect state
User promotes prospect + two peopleidempotent CRM Tools resolve/create canonical company and selected people only
User selects person for outreach before CRM promotionthe email sequence workflow can use authorized Intelligence/Prospect context; CRM creation is not forced