Marketing

Using an AI Agent to Enrich a Prospect Row with Public Data

Antonio Evans9 min read

A split scene: on the left, a spreadsheet row with three empty cells labeled Role, Industry, Trigger; on the right, an AI agent box pulling a public web page and returning a JSON object with values, a source URL.
Key points

Key points

  1. 1Send each prospect row to a model with a strict schema so it returns role, industry, trigger event, a source URL per value and a confidence score.
  2. 2Fetch every cited page and check that the claimed text actually appears before any value reaches the CRM.
  3. 3Set a confidence threshold and route everything below it to a human reviewer instead of guessing.
  4. 4Control spend with batching, caching, token limits and a small sample you verify by hand before scaling up.

The enrichment problem, stated in CRM terms

A prospect row usually arrives with three things: a name, a company and maybe a title someone typed by hand. What the sales team needs before writing a first line is different. They want to know what the person actually does, what industry the company sits in, and whether something just happened at that company that makes a conversation timely.

Signal-based outreach is the reason this matters. Data collected from benchmark reports puts the average cold email reply rate around 3.43% in the broad case, while campaigns built on specific buying signals such as funding rounds, leadership changes or hiring surges reach response rates of 15 to 25% (the Autobound cold email guide). The same source notes that smaller, targeted campaigns outperform large blasts. In other words, one correct trigger event per row is worth more than ten polished generic sentences.

Enrichment is therefore a data problem before it is a writing problem. The job is to fill three fields (role, industry, trigger event) with values you can defend, and to know when you cannot defend them.

A decision rule up front: treat any value without a working source URL as missing, not as true. This single rule prevents most of the damage an enrichment pipeline can do.

How the research list shapes the task

Two kinds of public sources anchor this work, and they behave very differently.

The first is company registry and filing data. The United States has no single federal incorporation database; businesses register at state level, and each state portal has its own interface and search options (the Commerce research library guide). A company's headquarters and its state of incorporation can also differ, which is a common source of wrong answers. Colorado's business database search is a typical state-level example, and its own disclaimer states that the office acts as a filing registry and cannot certify whether a business is operating legally (Colorado Secretary of State search). For corporate context rather than registration data, the SEC's EDGAR full text search covers the full text of electronic filings since 2001 (EDGAR full text search).

The second is people and role data. Contact tools expose filters for seniority and department so you can find decision makers by title, department and location (Hunter's decision maker guide). That is useful context, but it is a vendor tool, not a neutral register.

The practical consequence: ask the model for a public source per value (a filing, a company page, a press release, a job posting) and expect registry sources to confirm identity and corporate facts, not personal roles. If you let the model cite a registry page for a job title, you will get confident nonsense. A registry page proves a company exists and where it is registered; it rarely proves what one employee does.

The prompt: a schema that makes lying harder

Structured outputs are the right foundation. A schema-enforced response guarantees the model will not omit a required key or invent an enum value outside your list (the OpenAI structured outputs guide). You still have to design the fields so that "unknown" is an allowed, first-class answer. Non-native approaches that rely on prompt wording alone are fragile across models, which is why API-level schema enforcement is worth the vendor coupling (the Agenta structured output guide).

A working schema looks like this:

{
  "type": "object",
  "additionalProperties": false,
  "required": ["role", "industry", "trigger", "confidence"],
  "properties": {
    "role": {
      "type": "object",
      "required": ["value", "source_url", "evidence_quote"],
      "properties": {
        "value": {"type": ["string", "null"]},
        "source_url": {"type": ["string", "null"]},
        "evidence_quote": {"type": ["string", "null"]}
      }
    },
    "industry": { "$ref": "#/properties/role" },
    "trigger": {
      "type": "object",
      "required": ["value", "event_date", "source_url", "evidence_quote"],
      "properties": {
        "value": {"type": ["string", "null"]},
        "event_date": {"type": ["string", "null"]},
        "source_url": {"type": ["string", "null"]},
        "evidence_quote": {"type": ["string", "null"]}
      }
    },
    "confidence": {"type": "number", "minimum": 0, "maximum": 1},
    "notes": {"type": "string"}
  }
}

Three design choices carry most of the weight.

First, evidence_quote forces the model to copy a short excerpt from the page, not paraphrase it. Second, null is legal for every value, so "I could not find this" has a correct encoding. Third, event_date on the trigger gives you a staleness check later, because a trigger event from three years ago is not a trigger.

The system instruction should be short and unemotional. Something close to: "Fill only what a public page states. Quote the exact sentence. If no page states it, return null. Never infer a role from a name or a domain."

Verifying each value before it touches the CRM

The verification step is where most pipelines quietly fail. The model's quote is a claim, and you check it.

The check is mechanical. Fetch the cited URL, extract the visible text, normalize whitespace and case, and test whether the quote appears. If it does not appear, downgrade the field to null and log the reason. Research on schema-guided extraction with verification found that most extraction failures were systematic formatting problems rather than semantic misunderstanding, and that a post-processing recovery step rescued many of them (the MDPI extraction study). The same paper notes that human oversight remained essential even where accuracy was high.

Apply a matching rule per field type:

FieldAccepted sourceVerification testFail action
RoleCompany team page, press release, conference bio, filingExact title string appears in page text close to the person's nameSet null, flag row
IndustryCompany site, registry record, filing descriptionIndustry term or description appears verbatimSet null, flag row
Trigger eventPress release, filing, news article, job postingQuote appears and event date is inside your freshness windowSet null, flag row
Any fieldRegistry page cited for a personal roleStructural check on source typeReject, log as bad citation

Two edge cases deserve explicit handling. Pages behind consent walls or bot checks return no text, so verification fails even when the value is true; mark those as unverifiable rather than false so a reviewer can open the page by hand. And pages that render content with JavaScript will produce empty text unless you render them, so budget for a headless fetch on a subset of domains.

A light double-check helps on low-volume rows: ask a second model instance to read the fetched page and answer only "does this page state the role as X, yes or no." Community discussion of combining tool calls with a strict response format shows this pattern is workable (the OpenAI community thread), though simpler boolean calls usually beat elaborate tool orchestration for a task this narrow. Structured output has also been proposed as a full replacement for function calling where reliability matters more than flexibility (the Medium comparison), which fits here because you want one fixed shape, not a choice of tools.

Setting the confidence threshold and the human queue

Confidence scores from a language model are not calibrated probabilities. Treat them as a ranking signal, not a truth value. The threshold is a business decision, and it should be set from measured outcomes, not from intuition.

Here is a worked example. Suppose you run 200 rows through the pipeline and hand-verify all 200 as a calibration batch. You find:

Confidence bandRowsCorrect after URL check and quote checkPrecision
0.90 to 1.00848196%
0.70 to 0.89715983%
0.50 to 0.69311755%
Below 0.5014321%

At a 0.90 threshold, 84 rows write automatically and 3 wrong values get through, roughly 1.5% of the batch. At 0.70, 155 rows write and 15 wrong values land in the CRM. The second option writes 71 more rows and imports 12 more errors. Whether that trade is worth it depends on how visible a wrong role is in your first email: a wrong industry label rarely shows, but a wrong trigger event produces an opener the prospect will read as sloppy.

A defensible default is to auto-write above 0.85, queue 0.60 to 0.85 for a reviewer with the source link pre-opened, and drop below 0.60 back to null. Recalibrate the bands after each few hundred rows, because the distribution shifts as your source mix and prospect list change.

The human queue is not a failure state. It is the part of the system that makes the automatic part trustworthy, and it is the right home for any row where verification returned "unverifiable."

Cost control that does not hurt quality

Four levers do most of the work.

Batch by company, not by person. Fields like industry and trigger event depend on the company, not the individual. Enriching one company once and fanning the result across five contacts cuts your model calls by roughly 80% on multi-contact accounts, and it prevents two contacts at the same firm getting contradictory industry labels.

Cache on a stable key. Hash the company domain plus the field name plus a date bucket. Within the bucket, serve the cached answer. Trigger events are the field most sensitive to staleness, so let them expire faster than role and industry. If the cache reuses a value, keep the original source URL and the date it was first verified, so the row still carries provenance.

Cap tokens and stop early. Set a maximum output length in the request and give the model a fixed list of allowed source domains, such as the company's own site, the relevant registry, and a news index. A short allowed list reduces both wandering and output length.

Spend the expensive checks on a sample. Run the full page-fetch verification on 100% of values, because fetching is cheap and writing a wrong value is not. Spend larger model calls only on rows that pass the first filter and sit near the threshold.

Hunter's bulk workflow makes the batching point concrete: when revealing contacts for a set of companies, you can limit the number of email addresses revealed per company, which is cost control applied at the data layer rather than the prompt (Hunter's decision maker guide).

A small pipeline in Python makes the shape concrete:

import hashlib, json, time

def cache_key(domain, field, bucket):
    return hashlib.sha256(f"{domain}|{field}|{bucket}".encode()).hexdigest()

def enrich_row(row, llm, cache, fetcher, threshold=0.85):
    domain = row["domain"]
    out = {}
    for field in ("role", "industry", "trigger"):
        key = cache_key(domain, field, time.strftime("%Y-%W"))
        result = cache.get(key) or llm.extract(row, field)
        cache.set(key, result)

        if not result["value"] or not result["source_url"]:
            out[field] = {"value": None, "reason": "not_found"}
            continue

        page = fetcher.text(result["source_url"])
        quote_seen = result["evidence_quote"].lower() in page.lower()
        if not quote_seen:
            out[field] = {"value": None, "reason": "quote_not_found"}
        elif result["confidence"] >= threshold:
            out[field] = {"value": result["value"], "source_url": result["source_url"]}
        else:
            out[field] = {"value": None, "reason": "below_threshold"}

    return out

Notice what the function refuses to do. It does not write a value on model say-so, it does not treat an empty page as a negative, and it does not hide the reason a field was dropped. Those three habits make the output auditable months later.

Compliance and hygiene for the data you collect

Enriched rows are business contact data, and the personal data in them (name, role, employer) is collected for outreach. In the US, the CAN-SPAM Act applies to commercial email with no exemption for business-to-business messages, requires accurate header information and a working opt-out, and carries penalties of up to $53,088 per email in violation (the FTC compliance guide). Opt-out requests must be honored within 10 business days and the mechanism must remain usable for at least 30 days (the Usercentrics compliance guide).

Two practical rules follow. First, store the source URL and verification date alongside each enriched field, because if a prospect asks where a claim came from you want to answer in seconds. Second, make sure your enrichment pipeline cannot resurrect a contact who has opted out. Practical guidance recommends purging mailing lists against a do-not-email list at the last commercially reasonable moment before sending, and warns that a third-party sender does not shift your legal responsibility (Lewis Rice compliance tips). Enrichment that writes back into the CRM should therefore never clear or overwrite an opt-out flag, and any merge logic should be tested on that specific case.

There is a second check worth writing into the pipeline: confirm the company is real and current before you spend anything on the contact. A registry search gives you legal name, entity type, status and registered agent details, though interfaces and fees vary by state (the Commerce research library guide). Dropping dissolved or misidentified companies at that step removes a whole class of wasted model calls.

Choosing your trigger, and the bar that decides whether it is worth sending

Not every data point deserves an email. A rule that works: a row is sendable only if the role is verified at director level or above, the industry is confirmed, and the trigger event was verified with a date inside your freshness window. Otherwise the row goes to the human queue or waits.

Two more comparison points help set that bar. Three emails is a reasonable starting sequence, one initial message plus two follow-ups, with automated follow-ups stopping the moment someone replies (the Resquared sequence guidance). And funnel benchmarks suggest response rates improve with more follow-ups up to a point, with one report finding you were 3 times more likely to receive a response sending 4 to 7 follow-ups than 1 to 3 (Jacob Tuwiner's follow-up analysis). If your sequence runs five touches, a wrong trigger wastes five touches, not one. That math is why the confidence threshold belongs high and the human queue belongs staffed.

When the row volume grows past what a script and one reviewer can hold, the bottleneck moves from prompting to orchestration: retries, cache invalidation, reviewer tooling and a place to see the whole flow. Quanta's build service (https://quantaintelligence.ai/) is one route for a specialist to assemble that with you, and if you would rather keep the row-level work and just standardize the model calls, Quanta Inference (https://quantaintelligence.ai/inference) exposes text and embedding models behind one OpenAI-compatible key, which keeps the schema-enforced call small and the swap between models a config change rather than a rewrite.

Start with 200 rows, hand-verify all of them, set your bands from the result, and let the threshold do the work.

FAQ

FAQ

What is the minimum prompt one needs to get usable enrichment?

You need four things: an explicit schema with null allowed, an instruction to copy an exact quote, a required source URL per value, and a confidence score. Anything less and you spend the savings on cleanup.

Is a model confidence score reliable enough to skip review?

No. It is a ranking signal, not a probability. Calibrate it against a hand-verified batch, then set bands from measured precision, keeping anything near or below the cutoff out of the CRM.

How should trigger events be handled differently from role and industry?

Trigger events go stale, so require an event date, set a freshness window, and let cached triggers expire faster than role or industry values. A role found on a company page stays broadly true for a while; a funding round does not.

What do I do when the cited page cannot be fetched?

Mark the field unverifiable rather than false, keep the URL, and route the row to a reviewer. Consent walls and JavaScript rendering produce empty text on pages that are perfectly valid.

Where does enrichment data create legal exposure?

Collecting business contact data for outreach brings email rules into scope: the CAN-SPAM Act covers business-to-business messages and requires working opt-outs honored within 10 business days. Store provenance, keep opt-out flags authoritative, and never let an enrichment write clear them.

Sources

Sources

  1. Cold Email Guide 2026: Best Practices & Benchmarks | Autobound
  2. Incorporation Status - Company & Industry Research
  3. Search Business Database
  4. EDGAR Full Text Search
  5. How can I find decision makers using Hunter?
  6. Structured model outputs | OpenAI API
  7. The guide to structured outputs and function calling with ...
  8. Efficient and Verified Research Data Extraction with LLM
  9. How can I use function calling with response format ...
  10. Structured Output as a Full Replacement for Function Calling
  11. CAN-SPAM Act: A Compliance Guide for Business
  12. The CAN-SPAM Act: Compliance Guide with Best Practices
  13. Practical Tips for CAN-SPAM Act Compliance | Billee Elliott McAuliffe
  14. How many emails should a cold outreach sequence include?
  15. Best Cold Email Follow-Up Sequence (Sales) - Jacob Tuwiner
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles