Gil Allouche
← All writing
TutorialRunning a company with AI

Get into the AI business by shipping one capped agent

Build qualify.py — a step-capped research agent that turns a CSV of domains into structured prospect briefs and prints its own token count every run.

October 20, 2026·Gil Allouche·13 min read
A build-along. Every step was run start to finish; commands and outputs are what the machine actually printed.

What you'll have running

qualify.py reads a CSV of company domains, fetches each homepage, and writes one structured qualification brief per row into out.csv. Four steps per row, hard capped. When a row hits the cap it is written with status incomplete rather than dropped, and the run prints its token count on the last line. Budget 30–40 minutes, most of it waiting on pip and pasting the file.

That is the whole shape of getting into the AI business, and it is not a chatbot. Someone already pays a human to look at a list of companies and say what each one sells and who buys it. You are going to make that specific job cost tokens instead of hours. And you are going to know the token number before you quote a price.

Prerequisites: Python 3.10 or newer (python3 --version) — 3.10 was released on 4 October 2021, and venv has shipped in the standard library since Python 3.3 under PEP 405, so there is nothing to install before step one. Also curl, a terminal, and an OpenAI API key from platform.openai.com/api-keys with billing enabled. The code targets the OpenAI Python SDK 1.x, whose 1.0.0 rewrite landed in November 2023 and changed the client surface enough that older snippets will not run against it. API credit is billed separately from a ChatGPT subscription — the subscription buys you nothing here. Also about two minutes of patience with pip.

Why this task and not a chatbot

A chatbot has no unit of output, so it has no price. A brief per domain has both. The model does exactly one thing in this build — turn page text into four named fields — and everything around it is ordinary Python: read the CSV (csv), fetch the URL (urllib.request), strip the tags (html.parser), count the steps (range), write the row. All of that is standard library, which is why the only third-party dependency in the file is openai itself.

That split is the point. The model is the judgement step inside a deterministic shell, which is the same argument I make in agent orchestration is a control layer, not a framework.

Unbounded loops are how these things fail in production: a model keeps calling a tool because the tool keeps returning something slightly wrong, and nothing in the system is counting. The fix is a step counter enforced in your code, outside the model, where the model cannot talk it out of existing. Telling the model in the prompt to stop after four steps is not a cap. It is a request, and it gets ignored on exactly the pages you most need it honoured on.

How the capped agent loop runs, and where the step cap breaks itCSV rowmodel callfetch_pagesubmit_briefstatus: incompleteout.csvstep cap = 4cap hitdone

Both exits write a row. A capped run that produces incomplete is a result you can see in the output file; a run that silently guesses is a result you find out about from the customer.

The file

Save this as qualify.py. Two tools, one loop, one cap, one token tally. The only thing it needs from you is OPENAI_API_KEY in the environment.

#!/usr/bin/env python3
"""qualify.py - a step-capped research agent: one brief per domain."""
import csv
import json
import os
import sys
import urllib.request
from html.parser import HTMLParser

from openai import OpenAI

MODEL = os.environ.get("MODEL", "gpt-4o-mini")
MAX_STEPS = 4        # hard cap, enforced here - not by the model
FETCH_TIMEOUT = 10   # seconds
MAX_BYTES = 400_000  # stop reading a page after this
MAX_CHARS = 6_000    # page text handed to the model

FIELDS = ["domain", "status", "steps", "sells", "buyer", "one_line", "confidence"]

SYSTEM = (
    "You qualify B2B prospects. For the given domain: call fetch_page once on the "
    "homepage, read it, then call submit_brief. Use only what the page actually says. "
    "If the page does not say, set confidence to 'low' and say so in the field. "
    "Never guess revenue, headcount, or funding."
)

TOOLS = [
    {
        "type": "function",
        "function": {
            "name": "fetch_page",
            "description": "Fetch a URL and return its visible text, truncated.",
            "parameters": {
                "type": "object",
                "properties": {"url": {"type": "string"}},
                "required": ["url"],
            },
        },
    },
    {
        "type": "function",
        "function": {
            "name": "submit_brief",
            "description": "Submit the finished brief. Call exactly once, last.",
            "parameters": {
                "type": "object",
                "properties": {
                    "sells": {"type": "string", "description": "What they sell, one clause"},
                    "buyer": {"type": "string", "description": "Who buys it, one clause"},
                    "one_line": {"type": "string", "description": "One sentence a rep could open with"},
                    "confidence": {"type": "string", "enum": ["high", "medium", "low"]},
                },
                "required": ["sells", "buyer", "one_line", "confidence"],
            },
        },
    },
]


class TextExtractor(HTMLParser):
    def __init__(self):
        super().__init__()
        self.parts = []
        self.skip = 0

    def handle_starttag(self, tag, attrs):
        if tag in ("script", "style"):
            self.skip += 1

    def handle_endtag(self, tag):
        if tag in ("script", "style") and self.skip:
            self.skip -= 1

    def handle_data(self, data):
        if not self.skip:
            text = data.strip()
            if text:
                self.parts.append(text)


def fetch_page(url: str) -> str:
    if not url.startswith(("http://", "https://")):
        url = "https://" + url
    req = urllib.request.Request(url, headers={"User-Agent": "qualify-agent/0.1"})
    with urllib.request.urlopen(req, timeout=FETCH_TIMEOUT) as resp:
        raw = resp.read(MAX_BYTES).decode("utf-8", errors="ignore")
    parser = TextExtractor()
    parser.feed(raw)
    return " ".join(parser.parts)[:MAX_CHARS]


def run_one(client: OpenAI, domain: str):
    msgs = [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": f"Domain: {domain}"},
    ]
    used = {"in": 0, "out": 0}

    for step in range(1, MAX_STEPS + 1):
        r = client.chat.completions.create(model=MODEL, messages=msgs, tools=TOOLS)
        used["in"] += r.usage.prompt_tokens
        used["out"] += r.usage.completion_tokens
        msg = r.choices[0].message
        msgs.append(msg.model_dump(exclude_none=True))

        if not msg.tool_calls:
            msgs.append({"role": "user", "content": "Call submit_brief now."})
            continue

        brief = None
        for call in msg.tool_calls:
            args = json.loads(call.function.arguments or "{}")
            if call.function.name == "fetch_page":
                try:
                    content = fetch_page(args.get("url") or domain)
                except Exception as exc:
                    content = f"FETCH_ERROR: {type(exc).__name__}: {exc}"
            elif call.function.name == "submit_brief":
                brief = args
                content = "accepted"
            else:
                content = f"UNKNOWN_TOOL: {call.function.name}"
            msgs.append({"role": "tool", "tool_call_id": call.id, "content": content})

        if brief is not None:
            return {"status": "ok", "steps": step, **brief}, used

    return {"status": "incomplete", "steps": MAX_STEPS}, used


def main():
    dry = "--dry-run" in sys.argv
    pos = [a for a in sys.argv[1:] if not a.startswith("-")]
    infile = pos[0] if pos else "domains.csv"
    outfile = pos[1] if len(pos) > 1 else "out.csv"

    with open(infile, newline="") as f:
        domains = [row["domain"].strip() for row in csv.DictReader(f) if row.get("domain")]

    if dry:
        print("SYSTEM:\n" + SYSTEM)
        print("\nFIRST USER MESSAGE:\nDomain: " + (domains[0] if domains else "(none)"))
        print(f"\nmodel={MODEL} max_steps={MAX_STEPS} rows={len(domains)}")
        print("DRY RUN - no API calls made")
        return

    client = OpenAI()
    total_in = total_out = 0

    with open(outfile, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        for domain in domains:
            result, used = run_one(client, domain)
            total_in += used["in"]
            total_out += used["out"]
            row = {"domain": domain}
            row.update({k: result.get(k, "") for k in FIELDS if k != "domain"})
            writer.writerow(row)
            print(
                f"{domain}\t{result['status']}\tsteps={result['steps']}"
                f"\tin={used['in']}\tout={used['out']}",
                file=sys.stderr,
            )

    print(f"TOTAL tokens in={total_in} out={total_out} model={MODEL}", file=sys.stderr)


if __name__ == "__main__":
    main()

Two things in there are doing more work than they look like. MAX_STEPS is a range, not a line in the prompt — the model is never asked to stop, it is stopped, which also means a 200-row batch has a known ceiling of 800 model calls before you start it. And every single tool_call gets a tool message appended with its tool_call_id, including the failures. Miss one and the next request comes back 400.

The three size limits are deliberate too. MAX_BYTES = 400_000 stops urllib pulling a multi-megabyte page into memory; FETCH_TIMEOUT = 10 bounds a hanging socket at ten seconds; and MAX_CHARS = 6_000 is roughly 1,500 input tokens at the approximately four-characters-per-token ratio OpenAI documents for English text. One further compatibility note: the tools / tool_calls shape used here superseded the older functions / function_call parameters in the November 2023 API update, so tutorials written before that date will not run against this client.

The build

  1. Create and activate a virtual environment: python3 -m venv .venv && source .venv/bin/activate. Your prompt now starts with (.venv). On Windows the activate path is .venv\Scripts\activate.
  2. Install the client: pip install openai. The last line is Successfully installed openai-1.x.x plus its dependencies — httpx, pydantic, anyio and friends.
  3. Get a key from platform.openai.com/api-keys, then export it: export OPENAI_API_KEY="sk-...". Confirm the shell actually has it: echo ${OPENAI_API_KEY:0:3} prints sk- and nothing else.
  4. Prove the key works before you debug Python: curl -s https://api.openai.com/v1/models -H "Authorization: Bearer $OPENAI_API_KEY" | head -c 60. You should see the response start with {"object":"list","data":[{"id":". A 401 body here means the key is wrong; see the failure section below.
  5. Save the file from the section above as qualify.py in the same directory.
  6. Create domains.csv with a domain header and three rows — example.com, example.org, notarealsite.invalid. Those are reserved for documentation under RFC 2606, published in June 1999: section 3 reserves example.com, example.net and example.org, and section 2 reserves the .invalid top-level domain, which RFC 6761 restated in February 2013. So you are not hammering a stranger's site while you debug. The .invalid row is deliberate: it exercises the fetch-failure path.
  7. Dry-run it first: python qualify.py domains.csv --dry-run. You get the system prompt, the first user message, the line model=gpt-4o-mini max_steps=4 rows=3, and DRY RUN - no API calls made. Zero tokens spent. If the prompt is wrong, you find out here.
  8. Run it live: python qualify.py domains.csv out.csv. Progress goes to stderr, one line per domain, tab-separated: example.com ok steps=2 in=1512 out=96. Your token numbers will differ — they depend on how much text the page returns. The last line is TOTAL tokens in=... out=... model=gpt-4o-mini.
  9. Check the output: cat out.csv. Row one is the header domain,status,steps,sells,buyer,one_line,confidence. The example.com row comes back low-confidence, because that page says only that the domain is for use in illustrative examples — which is the correct answer, and a useful check that the model is reading the page instead of recalling the brand.
  10. Now swap in ten real prospect domains and run it again. That output is the thing you can show someone.

Step 9 is where most people learn something uncomfortable. If a model writes a confident brief for a page with no product information on it, your prompt is not holding and your confidence enum is decoration. Fix the prompt before you scale the list.

What one run costs

Every call adds r.usage.prompt_tokens and r.usage.completion_tokens to a running tally, and the last stderr line prints both. Multiply those two numbers by the current per-million input and output rates on the OpenAI pricing page and you have the cost of the run. Not an estimate of it.

LeverWhere it is setEffect
Page text sizeMAX_CHARS = 6_000Caps input tokens per fetch; the page text is re-sent on every subsequent call in the loop
Calls per rowMAX_STEPS = 4Hard ceiling on model calls per domain, regardless of model behaviour
ModelMODEL env varMODEL=gpt-4o python qualify.py domains.csv changes rate without touching code

The second one is the expensive one and it is not obvious. Conversation history grows: the fetched page text sits in msgs and gets re-sent as input on call two, call three, call four. A row that burns all four calls re-sends that 6,000-character page three times — about 4,500 extra input tokens against a row that finished in two. The difference is almost all input tokens. That is the argument for a tight cap and a short tool list — one capped agent at a time, as in starting a company with AI, one capped agent at a time.

The default model, gpt-4o-mini, launched on 18 July 2024 at $0.15 per million input tokens and $0.60 per million output tokens, with a 128,000-token context window. Take those launch rates as a worked example: a ten-domain run averaging 3,000 input and 150 output tokens per row comes to 30,000 input and 1,500 output tokens, which is $0.0045 plus $0.0009 — about half a cent for the batch. Confirm the current rates on the pricing page before you quote anything. The point is that the total is arithmetic over two numbers the script already printed, not a forecast.

Run it on ten domains, read the total, divide by ten. Now you know your cost of goods per brief. Price above it.

When it doesn't work

401 on the curl check or on the first model call.

openai.AuthenticationError: Error code: 401 - {'error': {'message': 'Incorrect API key provided: sk-xxx...'

The key is missing, stale, or the shell you ran export in is not the shell you ran Python in. Usually the third one. echo ${OPENAI_API_KEY:0:3} in the same terminal returns sk- if the variable survived. The v1 client reads OPENAI_API_KEY from the environment automatically, at OpenAI() construction time — do not paste the key into the file.

429 with insufficient_quota.

openai.RateLimitError: Error code: 429 - {'error': {'message': 'You exceeded your current quota,
please check your plan and billing details', 'type': 'insufficient_quota'}}

This is billing, not throttling. API usage is billed separately from any ChatGPT plan; add credit to the API account and the same key starts working. A plain rate-limit 429 — the status code defined in RFC 6585, April 2012 — without insufficient_quota means slow down: add a time.sleep(1) between rows.

400 about tool messages.

openai.BadRequestError: Error code: 400 - {'error': {'message': "An assistant message with
'tool_calls' must be followed by tool messages responding to each tool_call_id..."

You skipped a tool result. This is the failure you will reintroduce the first time you edit run_one — a continue, an early return, or an exception that escapes before the msgs.append for that call. Every tool_call in the assistant message needs a {"role": "tool", "tool_call_id": ...} reply, even if the content is just FETCH_ERROR: .... The loop above wraps fetch_page in try/except for exactly this reason: a fetch that raises still produces a tool message.

FETCH_ERROR on sites that exist.

FETCH_ERROR: HTTPError: HTTP Error 403: Forbidden

Plenty of production sites block a non-browser User-Agent or any datacentre IP. A 403 — RFC 9110 section 15.5.4 — is a server decision, not an SDK bug: this script sends qualify-agent/0.1 rather than urllib's default Python-urllib/3.x, and bot-management layers reject both. The model is not broken; the network is. Two options: a hosted fetch service with a residential path, or a confidence: low row and move on. Take the second one until the volume justifies paying for the first. What you must not do is let the model answer from memory when the fetch failed — that is how briefs end up describing a company's 2019 positioning.

What to sell, and what not to automate yet

The sellable unit here is out.csv, not the agent. Do not sell it as "AI" — sell 200 qualified briefs with a named confidence field, and show the incomplete rows alongside the good ones, because the honest denominator is what makes the number believable. At the launch rates above and the same 3,000-in/150-out average, 200 briefs is roughly 600,000 input and 30,000 output tokens — about $0.11 of model spend for work that is otherwise quoted in human hours. Pricing per output row also means your margin is arithmetic you can do from the token tally, not a guess.

Give this agent no spend authority and no send authority. Research is a safe first job precisely because a wrong brief costs a reader thirty seconds; a wrong email costs a relationship, which is a different class of problem — see what an AI SDR agent is, and what it is not for where that line sits.

The step after this one is not a bigger model. It is a second deterministic check: a rule that rejects any brief whose sells field contains words that never appeared in the fetched page text. The model is a judgement step. The business is the shell you wrap around it.

Sources

  1. OpenAI — Function calling — Supports the tools array, tool_calls response field, and the requirement that every tool_call_id gets a tool message back
  2. OpenAI — API keys — Where the reader gets OPENAI_API_KEY
  3. OpenAI — API pricing — Current per-million input/output token rates, which the reader multiplies by the script's own token counts
  4. OpenAI — Error codes — 401 authentication and 429 quota error semantics quoted in the failure section
  5. openai-python repository — The client library the script imports; v1 client reads OPENAI_API_KEY from the environment
  6. Python docs — urllib.request — urlopen timeout and URLError/HTTPError behaviour used by fetch_page
  7. Python docs — html.parser — HTMLParser subclassing used for the text extractor
  8. Python docs — venv — Virtual environment creation and activation
  9. RFC 2606 — Reserved Top Level DNS Names — Why example.com, example.org and .invalid are safe to put in sample data

Related