Gil Allouche
← All writing
TutorialRunning a company with AI

How to start a business with AI agents in one file

Build teardown_agent.py: a 12-step-capped tool-calling agent that researches a prospect's site, writes the deliverable, and logs every token it spent.

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

What you will have running

teardown_agent.py is one file, two tools, twelve model turns. It fetches a prospect's public pages, writes a one-page website teardown into ./deliverables/, appends every model turn's token count to ledger.jsonl, and hard-stops at the cap whether or not it produced anything. Budget 20 minutes. Most of that is pip install and waiting on an API key.

That file is the smallest honest version of "a business with AI agents": one deliverable a customer would pay for, produced by a loop you own, with a receipt for what it cost.

Prerequisites

  • Python 3.10 or newer (the script uses str.removeprefix, added in 3.9).
  • An OpenAI API key with credit on it — create one at platform.openai.com/api-keys. API billing is separate from a ChatGPT subscription; a Plus plan does not fund API calls.
  • Two packages: openai and httpx.
  • A domain you actually want a teardown of. Not a hypothetical one.

No framework. No vector database. No orchestration platform. A framework buries the one thing you need to be able to read line by line — the loop — under a graph abstraction, and on run one there is nothing to put in a vector store anyway. If the first agent in your company needs a control plane, the first agent in your company is too big.

The build

  1. Make the project and a virtualenv: mkdir teardown && cd teardown && python3 -m venv .venv && source .venv/bin/activate. Your prompt now starts with (.venv).
  2. Install the two dependencies: pip install "openai>=1.40" httpx. The last line of output is Successfully installed openai-… httpx-….
  3. Create a key at platform.openai.com/api-keys and export it in this shell: export OPENAI_API_KEY="sk-...". It is only set for this terminal session.
  4. Prove the key authenticates before you write any agent code: python -c "from openai import OpenAI; print(OpenAI().models.list().data[0].id)". You should see a model id printed, e.g. gpt-4.1-mini. An error here is an account problem, not a code problem.
  5. Save the file from the next section as teardown_agent.py in this directory.
  6. Smoke-test it against a reserved documentation domain, which has almost no content and therefore exercises the error paths: python teardown_agent.py example.com. You should see [step 1] fetch_page -> … and finish with wrote deliverables/example-com-teardown.md.
  7. Read the receipt: tail -1 ledger.jsonl. One JSON object per model turn, with prompt_tokens and completion_tokens.
  8. Now point it at a real prospect domain and read the output yourself, line by line, before it goes anywhere near a customer.

teardown_agent.py

Two tools, one loop, no framework. The step cap is a for range in your code — the model has no way to extend it.

#!/usr/bin/env python3
"""
teardown_agent.py — a bounded tool-calling agent.
Reads a prospect's public pages, writes a one-page teardown to
./deliverables/, logs token usage to ./ledger.jsonl.

    python teardown_agent.py example.com
"""
import json
import os
import pathlib
import re
import sys
import time

import httpx
from openai import OpenAI

MODEL = os.environ.get("MODEL", "gpt-4.1-mini")
MAX_STEPS = 12        # model turns per run, enforced here
MAX_FETCHES = 6       # page budget per run
FETCH_TIMEOUT = 20.0  # seconds
MAX_CHARS = 6000      # page text handed back to the model
UA = "teardown-agent/0.1 (+https://example.com/bot)"

OUT_DIR = pathlib.Path("deliverables")
LEDGER = pathlib.Path("ledger.jsonl")
client = OpenAI()  # reads OPENAI_API_KEY from the environment

SYSTEM = """You write short, blunt website teardowns for a B2B audience.
Fetch the homepage first, then at most a few more pages you can justify
(pricing, product, about). Then call write_deliverable exactly once, with
markdown containing: who they sell to, what they claim, three specific
weaknesses each tied to something you actually read on the page, and the
one thing to fix first. Never invent a number or a customer name. If a
page failed to load, say so in the teardown."""

SCRIPTS = re.compile(r"<(script|style)\b.*?</\1>", re.S | re.I)
TAGS = re.compile(r"<[^>]+>")


def visible_text(html: str) -> str:
    text = TAGS.sub("\n", SCRIPTS.sub(" ", html))
    text = re.sub(r"[ \t]+", " ", text)
    return re.sub(r"\n{3,}", "\n\n", text).strip()


def fetch_page(url: str, state: dict) -> dict:
    if not url.startswith(("http://", "https://")):
        return {"error": "url must start with http:// or https://"}
    if state["fetches"] >= MAX_FETCHES:
        return {"error": f"fetch budget of {MAX_FETCHES} pages is spent. "
                         "Call write_deliverable now with what you have."}
    state["fetches"] += 1
    try:
        r = httpx.get(url, timeout=FETCH_TIMEOUT, follow_redirects=True,
                      headers={"User-Agent": UA})
        r.raise_for_status()
    except Exception as e:
        return {"error": f"fetch failed: {type(e).__name__}: {e}"}
    text = visible_text(r.text)[:MAX_CHARS]
    return {"url": str(r.url), "status": r.status_code,
            "chars": len(text), "text": text}


def write_deliverable(filename: str, markdown: str, state: dict) -> dict:
    safe = re.sub(r"[^a-z0-9._-]", "-", filename.lower()) or "teardown.md"
    if not safe.endswith(".md"):
        safe += ".md"
    OUT_DIR.mkdir(exist_ok=True)
    path = OUT_DIR / safe
    path.write_text(markdown, encoding="utf-8")
    state["written"] = str(path)
    return {"path": str(path), "bytes": len(markdown.encode("utf-8"))}


TOOLS = [
    {"type": "function", "function": {
        "name": "fetch_page",
        "description": "Fetch one public web page and return its visible text.",
        "parameters": {"type": "object", "properties": {
            "url": {"type": "string", "description": "Absolute http(s) URL."}},
            "required": ["url"]}}},
    {"type": "function", "function": {
        "name": "write_deliverable",
        "description": "Write the finished teardown to disk. Call once, last.",
        "parameters": {"type": "object", "properties": {
            "filename": {"type": "string"},
            "markdown": {"type": "string"}},
            "required": ["filename", "markdown"]}}},
]


def dispatch(name: str, args: dict, state: dict) -> str:
    if name == "fetch_page":
        return json.dumps(fetch_page(args.get("url", ""), state))
    if name == "write_deliverable":
        return json.dumps(write_deliverable(
            args.get("filename", "teardown.md"), args.get("markdown", ""), state))
    return json.dumps({"error": f"unknown tool {name}"})


def log_usage(step: int, resp) -> None:
    u = resp.usage
    with LEDGER.open("a") as f:
        f.write(json.dumps({
            "ts": int(time.time()), "step": step, "model": resp.model,
            "prompt_tokens": u.prompt_tokens,
            "completion_tokens": u.completion_tokens}) + "\n")


def run(domain: str) -> int:
    state = {"fetches": 0, "written": None}
    messages = [
        {"role": "system", "content": SYSTEM},
        {"role": "user", "content": f"Target: {domain}. Start at https://{domain}/."},
    ]
    for step in range(1, MAX_STEPS + 1):
        resp = client.chat.completions.create(
            model=MODEL, messages=messages, tools=TOOLS,
            tool_choice="auto", temperature=0.2)
        log_usage(step, resp)
        msg = resp.choices[0].message

        assistant = {"role": "assistant", "content": msg.content}
        if msg.tool_calls:
            assistant["tool_calls"] = [
                {"id": c.id, "type": "function",
                 "function": {"name": c.function.name,
                              "arguments": c.function.arguments}}
                for c in msg.tool_calls]
        messages.append(assistant)

        if not msg.tool_calls:
            print(f"[step {step}] no tool call, model said: {(msg.content or '')[:200]}")
            break

        for c in msg.tool_calls:
            try:
                args = json.loads(c.function.arguments or "{}")
            except json.JSONDecodeError:
                args = {}
            result = dispatch(c.function.name, args, state)
            print(f"[step {step}] {c.function.name} -> {result[:120]}")
            messages.append({"role": "tool", "tool_call_id": c.id, "content": result})

        if state["written"]:
            print(f"wrote {state['written']}")
            return 0

    print(f"stopped at the {MAX_STEPS}-step cap with no deliverable", file=sys.stderr)
    return 1


if __name__ == "__main__":
    if len(sys.argv) != 2:
        sys.exit("usage: python teardown_agent.py <domain>")
    target = (sys.argv[1].strip().lower()
              .removeprefix("https://").removeprefix("http://").rstrip("/"))
    sys.exit(run(target))

Three details matter more than the prompt.

fetch_page returns its own budget message as a tool result, so when the sixth page is spent the model is told, in-band, to write and finish. Every tool result is a JSON string — a 404 is data the model can reason about, not an exception that kills the run. And write_deliverable setting state["written"] is what ends the loop. Not the model announcing it is done.

Three smaller knobs are set deliberately. temperature=0.2 keeps tool arguments repeatable without pinning the prose flat at 0. tool_choice="auto" lets the model finish in plain text rather than being forced into a call it does not want, which is why the loop carries a not msg.tool_calls branch at all. And FETCH_TIMEOUT = 20.0 overrides httpx's default timeout of 5 seconds, which is too tight for a marketing homepage served from a cold CDN cache.

What a good run looks like

The step sequence is not deterministic. Which pages it tries will vary run to run; what stays stable is that the run ends on write_deliverable and exits 0. The exit status is part of the contract: 0 when a file reached disk, 1 when the 12-step cap was hit with nothing written, so echo $? — or a cron wrapper — can tell the two apart without parsing stdout.

$ python teardown_agent.py example.com
[step 1] fetch_page -> {"url": "https://example.com/", "status": 200, "chars": 184, "text
[step 2] fetch_page -> {"error": "fetch failed: HTTPStatusError: Client error '404 Not Fo
[step 3] write_deliverable -> {"path": "deliverables/example-com-teardown.md", "bytes": 9
wrote deliverables/example-com-teardown.md

The ledger line shape, with token counts that will differ on your run:

$ tail -1 ledger.jsonl
{"ts": 1762000000, "step": 3, "model": "gpt-4.1-mini-2025-04-14", "prompt_tokens": 3914, "completion_tokens": 512}

Then read the artifact: cat deliverables/example-com-teardown.md. Against a reserved documentation domain with 184 characters of visible text, the teardown should be short and should say in words that there was nothing to analyse. If it instead describes a product and a target buyer, your system prompt is losing to the model's urge to be helpful. Tighten the "never invent" instruction and re-run before you point it at anything real.

Where the cap goes, and why it is not in the prompt

Excessive Agency — an agent holding more autonomy, permissions or tool reach than the job needs — is a named risk category in the OWASP Top 10 for LLM Applications. In the 2025 edition it is catalogued as LLM06:2025 Excessive Agency, and the money side of a runaway loop has its own entry alongside it, LLM10:2025 Unbounded Consumption. On day one both take one form: a loop that never terminates. The model fetches, reasons, fetches again, and bills you on every turn.

A prompt that says "use at most six pages" is a request. A for step in range(1, MAX_STEPS + 1) is a limit.

The step cap lives in your loop, outside the modelyour code (trusted)model turntool dispatchstep cap = 12tool_callstool resultcount++deliverables/*.mdexit 1the model cannot raise the cap

Anthropic's own engineering guidance on agents — the "Building effective agents" post published in December 2024 — points the same way: start with the simplest composition that works, and keep the control flow in code you can read. Two caps and a tool whitelist are the whole safety story for a first agent. I wrote more about shipping a company this way, one bounded agent at a time, in Starting a company with AI, one capped agent at a time.

When it doesn't work

openai.OpenAIError: The api_key client option must be set either by passing api_key to the client or by setting the OPENAI_API_KEY environment variable The export happened in a different terminal, or the venv was re-opened. export OPENAI_API_KEY="sk-..." in the shell you are actually running from. Check with printenv OPENAI_API_KEY | cut -c1-6.

openai.RateLimitError: Error code: 429 — 'You exceeded your current quota, please check your plan and billing details' The key is valid and the account has no API credit. ChatGPT Plus does not fund API usage. Add credit in the platform billing settings, then re-run. Step 4 of the build catches this before you have written a line of agent code, which is the whole reason it is there.

stopped at the 12-step cap with no deliverable and exit code 1 The model kept fetching and never called write_deliverable. The site is big and the model is being thorough. Drop MAX_FETCHES to 3 so the budget message arrives sooner, and cut the list of pages in the system prompt down to homepage and pricing. This is the failure the cap exists to make loud and cheap rather than silent and expensive.

{"error": "fetch failed: ConnectTimeout: "} on every page Nothing is reaching the network — a corporate proxy, a VPN, or DNS. Confirm with curl -I https://example.com/ before touching the script. If a single real target is just slow, raise FETCH_TIMEOUT from 20.0 and accept the longer run. Note that each timeout still costs one of the six fetches, because state["fetches"] increments before the request is issued; that ordering is deliberate, so a hanging site cannot be retried until the step cap runs out.

openai.BadRequestError: ... an assistant message with 'tool_calls' must be followed by tool messages responding to each 'tool_call_id' You modified the loop and broke out of it after appending the assistant message but before appending every tool result. Every tool_call id gets exactly one tool message back. Keep the append-assistant-then-append-all-results order intact.

{"error": "fetch failed: HTTPStatusError: Client error '403 Forbidden'"} on a real prospect Many sites block unknown clients. The run still finishes — the error is a tool result, not a crash — and the teardown will say the page did not load. Before you start spoofing a browser User-Agent to get around it, read the target's /robots.txt. The Robots Exclusion Protocol is a published standard, RFC 9309, and an agent you run commercially honours it.

What a run costs, from your own ledger

Do not take a per-run cost figure from an article, including this one. Take it from the file the script already writes.

python - <<'PY'
import json
p = c = n = 0
for line in open("ledger.jsonl"):
    r = json.loads(line)
    p += r["prompt_tokens"]; c += r["completion_tokens"]; n += 1
print(f"turns={n} prompt_tokens={p} completion_tokens={c}")
PY

Multiply those two totals by the current input and output rates for your model on OpenAI's API pricing page. That is your cost of goods sold for one deliverable, measured rather than assumed.

Prompt tokens dominate in this design. Every fetched page stays in the message history for the rest of the run, which is also the lever: dropping MAX_CHARS from 6000 shrinks every subsequent turn, not just the one that fetched. The arithmetic is worth doing once. At the usual rule of thumb of roughly four characters per token for English prose, a full 6,000-character page comes back as about 1,500 prompt tokens, so a maxed-out six-page run leaves something like 9,000 tokens of page text riding along in every later turn. Halve MAX_CHARS to 3000 and you halve that carry cost, usually without losing a weakness — the signal on a homepage is concentrated in the first screenful of copy.

Log it per run from the beginning. A service business priced before you know your COGS is a guess with invoices attached.

What the agent does and what you still do

StepOwnerWhy
Pick the prospectYouTargeting is the business. It is not a tool call.
Fetch and read the pagesAgentBounded, cheap, repeatable.
Draft the teardownAgentFirst draft, every time.
Approve and sendYouYour name is on the claim about their business.
Price and invoiceYouI would not delegate spend or send authority to an agent yet.

That last row is a judgement, not a limitation of the tooling. At Metadata.io I took the company from $0 to $15M ARR, raised $50M, and filed six patents in AI-driven marketing — with a human go-to-market team. The zero-human company I am building now removes the headcount from the steps above, not the judgement from the last two. The agent produces the artifact. You decide whether it is true.

Once this one runs clean, the second agent is the same shape with different tools: inbound qualification from a CSV (here is that build), or outreach drafting that stops at the draft, which is the honest boundary of an AI SDR agent. Where these actually run — your box or someone's platform — gets decided per agent, and I split it here.

Sources

  1. OpenAI — API keys — Where to create the OPENAI_API_KEY the script reads.
  2. OpenAI — Function calling guide — Tool schema shape, and that the model returns tool_calls which you must answer with a matching tool_call_id.
  3. OpenAI — API pricing — Current per-million input/output token rates; API billing is separate from ChatGPT subscriptions.
  4. openai-python (official SDK repository) — Client behaviour, including reading the API key from the environment, and the OpenAIError raised when it is missing.
  5. HTTPX documentation — timeout, follow_redirects and raise_for_status behaviour used in the fetch tool.
  6. Anthropic — Building effective agents — Guidance to use the simplest workflow that works, and to treat the tool loop as the thing you control.
  7. OWASP Top 10 for LLM Applications — Excessive Agency is a named risk category: agents given more autonomy, permissions or tool reach than the task requires.
  8. RFC 9309 — Robots Exclusion Protocol — The standard your fetch tool should respect when it reads other people's sites.
  9. Python docs — str.removeprefix — Added in Python 3.9; sets the minimum interpreter version for this script.

Related