Build a lead triage agent that scores inbound in one pass
A ~150-line Python script that reads your inbound CSV, reads each company's homepage, and returns a strict-schema fit verdict plus one opener per lead.
What you will have working
One Python file, about 150 lines. It reads a CSV of inbound form submissions, fetches each company's homepage, strips it to text, and asks gpt-4o-mini for a verdict constrained by a JSON schema: an ICP fit score 0–100, a tier, the reasons, the disqualifiers, a confidence level, and one opener that has to cite something specific from the input. It writes triaged.csv and prints the exact token count and the dollar cost of the run. Twenty minutes to set up, if Python and an API key are already on the machine.
You need Python 3.11 or later (python3 --version), an OpenAI API key from platform.openai.com/api-keys exported as OPENAI_API_KEY, and two packages: openai and httpx. Bring a CSV of inbound leads, or use the five sample rows below.
This is not an SDR that emails people. It is the step before that, and it is the step most teams skip — see what an AI SDR agent is, and what it is not. The script decides who is worth a human minute and hands you a draft first line. The send stays manual.
Why the cap sits in the code and not the prompt
Unbounded loops are the most common way agent pipelines fail in production, and a prompt that says "only process 25 leads" is not a cap — it is a suggestion the model is free to ignore. The limit in this script is a Python slice. The model never sees it, never reasons about it, and cannot talk its way past it.
Same principle for the page fetch. MAX_PAGE_CHARS = 6000 truncates before the text ever reaches the API, so one bloated marketing site cannot turn a 2,000-token call into a 40,000-token call. Control belongs in the layer around the model, which is the whole argument in agent orchestration is a control layer, not a framework.
Two httpx defaults matter before you trust the fetch at all. follow_redirects is False unless you pass it, so an apex-to-www or http-to-https hop comes back as a bare 301 and the lead scores on form text alone — the script sets it explicitly. The default timeout is 5 seconds; HTTP_TIMEOUT = 10.0 raises it because a cold marketing page behind a CDN regularly needs more. The robots check uses urllib.robotparser from the standard library, which implements the Robots Exclusion Protocol standardised as RFC 9309 in September 2022; a robots.txt that 404s is treated as permission to fetch, which is the behaviour that RFC specifies for any 4xx response.
Build it
- Make a directory and a virtual environment:
mkdir lead-triage && cd lead-triage && python3 -m venv .venv && source .venv/bin/activate. Your shell prompt should now start with(.venv). - Install the two dependencies:
pip install "openai>=1.40" httpx. The last line of output reads likeSuccessfully installed httpx-0.27.2 openai-1.51.0— the versions will differ, the shape will not. - Get an API key at platform.openai.com/api-keys and export it:
export OPENAI_API_KEY="sk-...". Confirm it is set withecho ${OPENAI_API_KEY:0:7}, which should printsk-projorsk-and nothing else. - Save the sample CSV below as
leads.csv. Check it parsed withpython3 -c "import csv;print(len(list(csv.DictReader(open('leads.csv')))))"— it prints5. - Save the script below as
triage.py. Verify the file is syntactically whole before you spend any tokens:python3 -m py_compile triage.pyprints nothing on success. - Dry-run the wiring with a single lead:
python3 triage.py --limit 1. You get one line on stdout of the form[1/1] example.com site=ok tier=B fit=55 conf=medium, then a token and cost summary. - Run the whole file:
python3 triage.py. Inspect the result withcolumn -s, -t triaged.csv | less -S, or open it in a spreadsheet. Sort byicp_fitdescending and read thereasonscolumn before you believe any score.
leads.csv
Every domain here is in a reserved TLD per RFC 2606, so .example, .test and .invalid will not resolve. RFC 6761, published in February 2013, restates those three as special-use domain names, which is why a resolver returns NXDOMAIN instead of landing you on somebody's parked page. example.com is reserved for documentation but does serve HTML. That is the one row that exercises the fetch path end to end.
company,domain,contact_email,job_title,employees_self_reported,pain_note,source
Acme Billing Co,example.com,dana@acme.example,VP Finance,180,"Closing the books takes nine days because usage data sits in three systems",webinar
Northwind Metrics,northwind.example,sam@northwind.example,Head of Platform Engineering,420,"Our homegrown metering service drifts from what we invoice",docs-signup
Pine Street Partners,pinestreet.test,chris@pinestreet.test,Managing Partner,6,"Interested in a reseller or referral partnership",contact-form
Kestrel Talent,kestrel.invalid,hiring@kestrel.invalid,Recruiter,35,"Do you have a free plan for personal projects?",blog
Harbour Logistics,harbour.example,ops@harbour.example,Director of Operations,900,"We get billed per shipment and cannot reconcile the line items",referral
triage.py
#!/usr/bin/env python3
"""
triage.py - qualify inbound leads against an ICP and draft one opener each.
Reads a CSV of form submissions, fetches each company's homepage, asks the model
for a schema-constrained verdict, writes triaged.csv. Capped at --limit calls.
"""
import argparse
import csv
import json
import sys
from html.parser import HTMLParser
from urllib import robotparser
import httpx
from openai import OpenAI
MODEL = "gpt-4o-mini-2024-07-18"
MAX_PAGE_CHARS = 6000
HTTP_TIMEOUT = 10.0
USER_AGENT = "lead-triage/0.1"
# USD per 1M tokens. Verify at https://openai.com/api/pricing/ before trusting these.
PRICE_IN_PER_M = 0.15
PRICE_OUT_PER_M = 0.60
ICP = """\
We sell a usage-based billing API to B2B SaaS and logistics companies.
Good fit: 50-1000 employees, bills customers by seat or by usage, employs engineers,
and the inbound person sits in finance, revenue operations, or engineering leadership.
Bad fit: agencies, consultancies, staffing and recruiting firms, companies under 10
people, students, job seekers, and anyone asking about a free personal plan.
"""
SYSTEM = (
"You qualify inbound B2B leads. Use ONLY the ICP, the form submission, and the "
"homepage text in the user message. Never infer headcount, funding, or tech stack "
"that is not in the input. If homepage_status is not 'ok', return confidence 'low' "
"and evidence_used ['form']. The opener must paraphrase one specific detail from "
"the input, must be under 240 characters, and must contain no placeholders."
)
VERDICT_SCHEMA = {
"name": "lead_verdict",
"strict": True,
"schema": {
"type": "object",
"additionalProperties": False,
"required": ["icp_fit", "tier", "reasons", "disqualifiers",
"opener", "confidence", "evidence_used"],
"properties": {
"icp_fit": {"type": "integer", "description": "0 to 100"},
"tier": {"type": "string", "enum": ["A", "B", "C", "disqualify"]},
"reasons": {"type": "array", "items": {"type": "string"}},
"disqualifiers": {"type": "array", "items": {"type": "string"}},
"opener": {"type": "string"},
"confidence": {"type": "string", "enum": ["high", "medium", "low"]},
"evidence_used": {
"type": "array",
"items": {"type": "string", "enum": ["site", "form"]},
},
},
},
}
class TextOnly(HTMLParser):
"""Strip tags, drop script/style/svg bodies, keep visible text."""
SKIP = {"script", "style", "noscript", "svg"}
def __init__(self):
super().__init__(convert_charrefs=True)
self.chunks = []
self.depth = 0
def handle_starttag(self, tag, attrs):
if tag in self.SKIP:
self.depth += 1
def handle_endtag(self, tag):
if tag in self.SKIP and self.depth:
self.depth -= 1
def handle_data(self, data):
if self.depth == 0:
text = " ".join(data.split())
if text:
self.chunks.append(text)
def robots_allows(http, domain):
parser = robotparser.RobotFileParser()
try:
resp = http.get(f"https://{domain}/robots.txt", timeout=HTTP_TIMEOUT)
except httpx.HTTPError:
return True
if resp.status_code >= 400:
return True
parser.parse(resp.text.splitlines())
return parser.can_fetch(USER_AGENT, f"https://{domain}/")
def homepage_text(http, domain):
if not robots_allows(http, domain):
return "", "robots_disallow"
try:
resp = http.get(f"https://{domain}/", timeout=HTTP_TIMEOUT,
follow_redirects=True)
except httpx.HTTPError as exc:
return "", f"fetch_failed:{type(exc).__name__}"
if resp.status_code != 200:
return "", f"http_{resp.status_code}"
if "text/html" not in resp.headers.get("content-type", ""):
return "", "not_html"
parser = TextOnly()
parser.feed(resp.text)
text = " ".join(parser.chunks)[:MAX_PAGE_CHARS]
return (text, "ok") if text else ("", "empty")
def verdict(ai, lead, site_text, status):
payload = {
"icp": ICP,
"form_submission": lead,
"homepage_text": site_text,
"homepage_status": status,
}
resp = ai.chat.completions.create(
model=MODEL,
temperature=0,
max_tokens=500,
response_format={"type": "json_schema", "json_schema": VERDICT_SCHEMA},
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": json.dumps(payload, ensure_ascii=False)},
],
)
message = resp.choices[0].message
if getattr(message, "refusal", None):
raise RuntimeError(f"model refused: {message.refusal}")
return json.loads(message.content), resp.usage
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--in", dest="infile", default="leads.csv")
ap.add_argument("--out", dest="outfile", default="triaged.csv")
ap.add_argument("--limit", type=int, default=25,
help="hard cap on model calls for this run")
args = ap.parse_args()
with open(args.infile, newline="", encoding="utf-8") as fh:
leads = list(csv.DictReader(fh))[: args.limit]
if not leads:
sys.exit(f"no rows read from {args.infile}")
ai = OpenAI()
fields = ["company", "domain", "tier", "icp_fit", "confidence",
"homepage_status", "reasons", "disqualifiers", "opener"]
tok_in = tok_out = 0
with httpx.Client(headers={"User-Agent": USER_AGENT}) as http, \
open(args.outfile, "w", newline="", encoding="utf-8") as out:
writer = csv.DictWriter(out, fieldnames=fields)
writer.writeheader()
for i, lead in enumerate(leads, 1):
domain = (lead.get("domain") or "").strip()
text, status = (homepage_text(http, domain) if domain
else ("", "no_domain"))
data, usage = verdict(ai, lead, text, status)
tok_in += usage.prompt_tokens
tok_out += usage.completion_tokens
writer.writerow({
"company": lead.get("company", ""),
"domain": domain,
"tier": data["tier"],
"icp_fit": data["icp_fit"],
"confidence": data["confidence"],
"homepage_status": status,
"reasons": " | ".join(data["reasons"]),
"disqualifiers": " | ".join(data["disqualifiers"]),
"opener": data["opener"],
})
print(f"[{i}/{len(leads)}] {domain or lead.get('company', '?')} "
f"site={status} tier={data['tier']} fit={data['icp_fit']} "
f"conf={data['confidence']}", flush=True)
cost = tok_in / 1e6 * PRICE_IN_PER_M + tok_out / 1e6 * PRICE_OUT_PER_M
print(f"\n{len(leads)} leads | {tok_in} input tokens | {tok_out} output tokens "
f"| ${cost:.4f} at the prices set in this file -> {args.outfile}")
if __name__ == "__main__":
main()
Why the verdict has seven fields
The shape is enforced, not suggested. Structured Outputs with strict: true shipped in the OpenAI API on 6 August 2024 and is supported on gpt-4o-mini-2024-07-18, which is why MODEL pins that dated snapshot rather than the floating gpt-4o-mini alias — a schema that validates today keeps validating. That snapshot takes a 128,000-token context and emits at most 16,384 tokens, so the max_tokens=500 in the call is a budget you picked, not a ceiling the model imposed.
icp_fit and tier carry the same judgement at two resolutions on purpose. The integer sorts a spreadsheet; the enum routes a row. Keeping both means you can move your routing thresholds — decide next month that anything under 70 goes to nurture — without paying to re-score a single lead.
reasons and disqualifiers are separate arrays rather than one explanation string because a disqualifier is a veto, not a negative weight. A staffing firm with 400 employees, an engineering team and a genuine invoicing problem will collect plenty of positive reasons. It is still a staffing firm, and you want that fact in its own column where a filter can find it.
evidence_used and confidence exist so the router can tell the difference between a verdict standing on a homepage and a verdict standing on a headcount somebody typed into a form in a hurry. Self-reported fields are the weakest input in the payload and the easiest to score off by accident. A tier=A with confidence=low is not a lead to work, it is a lead to look up.
opener sits inside the same call rather than a second one because the specific detail it cites is the cheapest audit trail available. An opener that references nothing concrete is a verdict with nothing behind it, and you can spot that by reading one column.
One caveat on that opener: the 240-character limit in the system message is a request, not a constraint, in exactly the way the lead cap would be if it lived in the prompt. If the length matters downstream, truncate or flag it in the writer loop where the value is already in hand.
Tuning the ICP
The ICP string is the only part of this file most teams need to edit, and it rewards being specific rather than being long. Every lead pays for those lines in input tokens, so keep it tight — a couple of hundred words is plenty. Two hundred words of ICP text is roughly 260 tokens, about $0.04 per thousand leads at $0.15 per million input tokens: cheap, but it is the one cost you pay on every single row, including the ones you disqualify in the first sentence.
Write the exclusions as nouns, not adjectives. "Agencies, consultancies, staffing and recruiting firms" is checkable against a homepage; "companies that are not serious" is not, and the model will fill that gap with its own priors. Name the job titles that count as a buying signal, because title is one of the few form fields that is both reliable and cheap to evaluate.
Then test it against leads where you already know the answer. Take three closed-won accounts and three you turned down, run them through at --limit 6, and read the reasons column. If a known-good account comes back tier C, the ICP text is wrong, not the model. Fixing it there is free; discovering it after a thousand rows is not.
What the run prints
Scores and wording vary between runs even at temperature=0. The API documents this directly: temperature is not a determinism switch, and the closest thing on offer is the seed parameter plus the system_fingerprint returned with each response, which is explicitly best-effort. Treat the values as shape, not as a result to match.
[1/5] example.com site=ok tier=C fit=35 conf=medium
[2/5] northwind.example site=fetch_failed:ConnectError tier=B fit=65 conf=low
[3/5] pinestreet.test site=fetch_failed:ConnectError tier=disqualify fit=10 conf=low
[4/5] kestrel.invalid site=fetch_failed:ConnectError tier=disqualify fit=5 conf=low
[5/5] harbour.example site=fetch_failed:ConnectError tier=B fit=60 conf=low
5 leads | 3182 input tokens | 1043 output tokens | $0.0011 at the prices set in this file -> triaged.csv
Four of the five rows report fetch_failed:ConnectError because reserved TLDs do not resolve. That is correct behaviour, not a bug, and it is why the schema has a confidence field: a verdict built on a form submission alone is a different object than a verdict built on a form plus a homepage. Swap in real prospect domains and those rows flip to site=ok.
Row 3, the six-person partnership enquiry, comes back disqualify on the form text alone. That is the row the script earns its keep on. Nobody has to read it.
The loop is serial by design: one robots check, one fetch, one model call, next row. At roughly a second and a half per lead that is about twenty-five minutes for a thousand rows, which is fine for a batch you run overnight. Resist the urge to parallelise it as the first optimisation. Add retry-with-backoff around the model call first, because a run that dies on row 840 has already spent the money for 840 leads and written them to a file you are about to overwrite.
When it doesn't work
| Symptom | What you see | Fix |
|---|---|---|
| Key not set or wrong project | openai.AuthenticationError: Error code: 401 - {'error': {'message': 'Incorrect API key provided: sk-...' | export OPENAI_API_KEY="sk-..." in the same shell you run from. A key exported in another tab does not carry over. Regenerate at platform.openai.com/api-keys if you pasted a truncated one. |
| Schema rejected by strict mode | A 400 reading roughly Invalid schema for response_format 'lead_verdict': In context=(), 'additionalProperties' is required to be supplied and to be false. | Strict Structured Outputs requires additionalProperties: false on every object and every property listed in required. If you add a field, add it to required too. Optionality is expressed as a nullable type, not by omission. |
| Every row fetch-fails | site=fetch_failed:ConnectError on all rows, including example.com | Your network blocks outbound 443, or a corporate proxy intercepts it. Test with python3 -c "import httpx;print(httpx.get('https://example.com').status_code)"; a working setup prints 200. Set HTTPS_PROXY if you are behind one. |
| Import fails | ModuleNotFoundError: No module named 'openai' | The virtual environment is not active. source .venv/bin/activate, then pip install "openai>=1.40" httpx again. |
| Rate limited mid-run | openai.RateLimitError: Error code: 429 partway through, with the rows before it already written | New projects start on a low tier. Wrap the verdict call in a retry with exponential backoff and re-run the remainder. Raising --limit to compensate is the wrong direction. |
| Domains ignored | site=no_domain on every row even though the CSV has domains | The script reads a header named domain exactly. Rename a website or url column, or change the key. If the first column is the one misbehaving, an Excel export has prefixed a byte-order mark (U+FEFF) to the header; read the file with encoding="utf-8-sig". |
The failure mode that costs you money is the silent one. A homepage that renders entirely client-side returns site=ok with almost no text, and the model will score confidently off a nav bar. Guard it. Add a length floor in homepage_text — if len(text) < 400, return ("", "thin_page") — so a JavaScript-only site routes to needs human instead of producing a confident number from a cookie banner.
What a run of this actually costs
The arithmetic, using the per-million prices in the script and verifiable on the OpenAI pricing page. Six thousand characters of homepage text truncates to roughly 1,500 tokens, on the documented rule of thumb of about 4 characters per token for English prose; add the ICP, the system message and the form JSON and call it 2,000 input tokens per lead. The schema is small, so output lands at 200–250.
| Per lead | Per 1,000 leads | Cost | |
|---|---|---|---|
| Input | ~2,000 tokens | 2M tokens | $0.30 at $0.15/1M |
| Output | 200–250 tokens | 250K tokens | $0.15 at $0.60/1M |
| Total | $0.45 |
That is arithmetic on list prices, not a measurement of your run. The script prints your real figure from resp.usage at the end of every execution, which is the only cost number I would quote to anyone. Instrument first, estimate second. If you want the pattern generalised, get into the AI business by shipping one capped agent is the shortest version of it.
What I would not hand this agent next
The obvious next move is to let it send the opener. I would not, and not because of brand risk — because the output has no feedback loop yet. You have no idea whether a tier=A, fit=85 verdict correlates with anything until you have a few hundred of them scored against closed-won. Route tier A and B to a human queue, keep the opener as a draft, and compare the agent's tier against what your reps actually booked. That comparison is the asset. The script is just the thing that produces it cheaply.
Two other extensions I would hold back. Do not give it CRM write access to owner, stage, or amount fields; a wrong tier that lands in a spreadsheet is a bad row, and a wrong tier that lands in Salesforce is a forecast problem. And do not let it decide spend — no ad budget, no enrichment credits, no paid data calls it chooses on its own. I took Metadata.io from $0 to $15M ARR building AI that moved real ad money, and the controls around the spend decision were always heavier than the model making it.
The change I would make first is cheaper and duller: log every verdict with the exact homepage_text that produced it. When a score is wrong, you want to see what the model was looking at. Most of the time it was looking at a cookie banner.
Sources
- OpenAI — Structured Outputs guide — json_schema response_format, strict mode rules (additionalProperties false, all keys required), model support from gpt-4o-mini-2024-07-18, refusal field
- OpenAI — API pricing — gpt-4o-mini per-million input and output token prices used in the cost arithmetic
- OpenAI — API keys — where the reader gets OPENAI_API_KEY
- openai-python SDK — client construction, reads OPENAI_API_KEY from the environment, usage object fields
- HTTPX documentation — httpx.Client, follow_redirects, httpx.HTTPError base exception
- Python — html.parser — HTMLParser subclassing used to strip tags without an extra dependency
- Python — urllib.robotparser — RobotFileParser.parse and can_fetch for the robots.txt check
- RFC 2606 — Reserved Top Level DNS Names — .example, .test and .invalid are reserved and do not resolve, which is why the sample rows fail the fetch step
- IANA — Example Domains — example.com is reserved for documentation and does serve HTML, so it exercises the fetch path