Be more agentic by building one bounded agent
A complete Python agent that researches a domain, obeys robots.txt, caps itself at eight model turns, and writes its note to disk — plus the three errors you hit first.
What you'll have running when this works
qualify.py is about 110 lines. It takes one domain, fetches at most five pages on that domain, checks robots.txt before each fetch, writes a qualification note as JSON, and kills the run at eight model turns whether or not the model finished. Every guard is enforced in Python, not in the prompt.
Thirty minutes of typing. Most of it spent getting the API key.
You need Python 3.9 or newer (python3 --version) — the code calls str.removeprefix, which landed in 3.9. You need an Anthropic API key from console.anthropic.com/settings/keys with credit on the account; Sonnet 4.5 is listed at $3 per million input tokens and $15 per million output tokens on Anthropic's pricing page. Then pip install anthropic requests. Work in a directory you don't mind being written to, because the run appends to runs.jsonl and writes note-<domain>.json.
If you have never shipped a small service before, the 30-minute SaaS endpoint build is a gentler on-ramp. This one assumes you can read a stack trace.
Agentic is a loop you closed yourself
The word has been flattened into a personality trait. Operationally it is four parts: a goal, a model that can request actions, real tools that execute those actions, and a bound that ends the run. Take away the bound and you have not built an agent. You have built a spender. OWASP lists Excessive Agency — too much autonomy, too many permissions, no human-enforced limit — as one of its top ten risks for LLM applications.
So "be more agentic" has a concrete answer: close one loop end to end, with the cap outside the model.
Everything on the return wire passes through the cap. That is the whole difference between a loop and a leak.
The file
Save this as qualify.py. It is complete — no placeholders, no "your logic here".
#!/usr/bin/env python3
"""qualify.py - a bounded research agent. Usage: python qualify.py example.com"""
import json
import pathlib
import re
import sys
import time
from urllib import robotparser
from urllib.parse import urlparse
import requests
from anthropic import Anthropic
MODEL = "claude-sonnet-4-5"
MAX_STEPS = 8 # model turns before the run is killed
FETCH_BUDGET = 5 # pages per run
MAX_CHARS = 6000 # per page, after tag stripping
TIMEOUT_S = 10
USER_AGENT = "gtm-research-agent/0.1 (+https://example.com/bot)"
RUN_LOG = pathlib.Path("runs.jsonl")
client = Anthropic() # reads ANTHROPIC_API_KEY from the environment
state = {"domain": None, "fetches": 0, "note": None}
def log(event, **fields):
with RUN_LOG.open("a") as fh:
fh.write(json.dumps({"ts": round(time.time(), 3), "event": event, **fields}) + "\n")
print(f"[{event}] {json.dumps(fields)[:300]}", flush=True)
def fetch_page(url: str) -> str:
host = urlparse(url).netloc.split(":")[0].lower()
target = state["domain"]
if host != target and not host.endswith("." + target):
return f"BLOCKED: {host} is outside the allowlist ({target})"
if state["fetches"] >= FETCH_BUDGET:
return f"BLOCKED: fetch budget of {FETCH_BUDGET} pages is spent"
rp = robotparser.RobotFileParser()
rp.set_url(f"https://{host}/robots.txt")
try:
rp.read()
if not rp.can_fetch(USER_AGENT, url):
return f"BLOCKED: robots.txt disallows {url}"
except Exception as exc:
log("robots_unreadable", host=host, error=str(exc))
state["fetches"] += 1
resp = requests.get(url, timeout=TIMEOUT_S, headers={"User-Agent": USER_AGENT})
if resp.status_code != 200:
return f"HTTP {resp.status_code} for {url}"
text = re.sub(r"<(script|style)[^>]*>.*?</\1>", " ", resp.text, flags=re.S | re.I)
text = re.sub(r"<[^>]+>", " ", text)
return re.sub(r"\s+", " ", text).strip()[:MAX_CHARS]
def write_note(company: str, segment: str, evidence: list, next_action: str) -> str:
state["note"] = {
"company": company,
"domain": state["domain"],
"segment": segment,
"evidence": evidence,
"next_action": next_action,
"pages_fetched": state["fetches"],
}
path = pathlib.Path(f"note-{state['domain']}.json")
path.write_text(json.dumps(state["note"], indent=2))
return f"Note written to {path}"
TOOLS = [
{
"name": "fetch_page",
"description": "Fetch one public page on the target domain and return its visible text.",
"input_schema": {
"type": "object",
"properties": {"url": {"type": "string", "description": "Absolute https URL on the target domain"}},
"required": ["url"],
},
},
{
"name": "write_note",
"description": "Write the final qualification note and end the run. Call exactly once.",
"input_schema": {
"type": "object",
"properties": {
"company": {"type": "string"},
"segment": {"type": "string", "enum": ["fit", "not_fit", "unknown"]},
"evidence": {
"type": "array",
"items": {"type": "string"},
"description": "Facts taken only from fetched pages, each with the source URL",
},
"next_action": {"type": "string"},
},
"required": ["company", "segment", "evidence", "next_action"],
},
},
]
SYSTEM = (
"You research one company domain against this ICP: B2B SaaS with self-serve signup "
"and a published pricing page. Use fetch_page on that domain only. When you have "
"enough, call write_note exactly once. Never state a fact that is not in a fetched "
"page; if the evidence is missing, set segment to unknown and say what was missing."
)
DISPATCH = {"fetch_page": fetch_page, "write_note": write_note}
def run(domain: str):
state["domain"] = domain.lower().removeprefix("www.")
messages = [{"role": "user", "content": f"Qualify https://{state['domain']}/ and write the note."}]
for step in range(1, MAX_STEPS + 1):
resp = client.messages.create(
model=MODEL, max_tokens=1024, system=SYSTEM, tools=TOOLS, messages=messages
)
log("model", step=step, stop_reason=resp.stop_reason,
in_tokens=resp.usage.input_tokens, out_tokens=resp.usage.output_tokens)
messages.append({"role": "assistant", "content": resp.content})
results = []
for block in resp.content:
if block.type == "tool_use":
log("tool_call", step=step, name=block.name, input=block.input)
try:
out = DISPATCH[block.name](**block.input)
except Exception as exc:
out = f"TOOL ERROR: {type(exc).__name__}: {exc}"
log("tool_result", step=step, name=block.name, chars=len(str(out)))
results.append({"type": "tool_result", "tool_use_id": block.id, "content": str(out)})
if state["note"]:
log("done", step=step, path=f"note-{state['domain']}.json")
return state["note"]
if not results:
log("stalled", step=step, reason="model returned no tool call")
return None
messages.append({"role": "user", "content": results})
log("cap_hit", steps=MAX_STEPS, fetches=state["fetches"])
return None
if __name__ == "__main__":
if len(sys.argv) != 2:
sys.exit("usage: python qualify.py <domain>")
print(json.dumps(run(sys.argv[1]), indent=2) if run(sys.argv[1]) else "no note written")
The requests call carries a 10-second timeout and a named User-Agent. An agent that identifies itself is an agent you can get unblocked later.
How the loop actually turns
Each pass does four things in a fixed order: call the model, append the assistant's content, execute any tool blocks, append the results as a single user message.
The append has to be verbatim. messages.append({"role": "assistant", "content": resp.content}) hands back the exact block list the API returned, including every tool_use block and its id. Rebuild that content by hand and you will eventually drop an id, and the next request fails validation because a tool_result has nothing to attach to. For the same reason, all results from one assistant turn go back in one user message — the loop collects them in results and appends once, which keeps the roles strictly alternating.
stop_reason is logged on every step and tells you which branch you are on: tool_use means more work is queued, end_turn means the model has stopped asking for anything. That is why the if not results branch exists. If the model ends a turn without calling a tool and without having written a note, nothing further will happen on its own, so the loop records stalled and returns instead of paying for several more identical turns.
One structural detail decides the economics: every pass resends the entire transcript, fetched page text included. Tokens per step climb as the run goes on. A step cap is therefore cheaper insurance than it looks, because it bounds the tail where each turn is most expensive.
Run it
- Create an isolated environment and install the two dependencies:
python3 -m venv .venv && source .venv/bin/activate && pip install anthropic requests. The last line of output starts withSuccessfully installed anthropic-followed by the version numbers it resolved. - Put your key in the environment:
export ANTHROPIC_API_KEY="sk-ant-...". Confirm withpython -c "import os; print(bool(os.environ.get('ANTHROPIC_API_KEY')))", which printsTrue. The SDK picks this variable up on its own — you never pass the key in code. - Save the file above as
qualify.pyin the same directory, then check it parses before you spend a token:python -c "import ast; ast.parse(open('qualify.py').read()); print('ok')"printsok. - Do a dry run against a reserved domain:
python qualify.py example.com. The first stdout line is[model] {"step": 1, "stop_reason": "tool_use", "in_tokens": ..., "out_tokens": ...}, followed by[tool_call] {"step": 1, "name": "fetch_page", "input": {"url": "https://example.com/"}}and a[tool_result]line whosecharscount is in the low hundreds — example.com serves one short paragraph and nothing else. Expect a second[tool_call]for something likehttps://example.com/pricingreturningHTTP 404. - Confirm the run terminated on purpose. The last stdout line should be
[done]with a path, not[cap_hit]. Thencat note-example.com.json— you get an object with"segment": "unknown", apages_fetchedcount of 1 or 2, and anevidencearray saying there is no pricing page. That is the correct answer for example.com, which exists only for documentation under RFC 2606. - Read the audit trail:
python -c "import json;[print(json.loads(l)['event']) for l in open('runs.jsonl')]". You get the event sequence —model,tool_call,tool_result, repeating, thendone. Every decision the agent made is on that list with its inputs. - Now point it at a real target you are allowed to crawl — your own site is the honest first choice:
python qualify.py yourcompany.com. Comparepages_fetchedin the note againstFETCH_BUDGET. If it equals 5 every time, the model is browsing rather than deciding, and the fix is a tighter system prompt, not a bigger budget. - Lower the cap to prove the kill switch fires: set
MAX_STEPS = 2, rerun, and watch for[cap_hit] {"steps": 2, "fetches": 1}with no note file written. An agent you cannot stop is not yours.
What a run costs, from your own log
Every model line in runs.jsonl carries in_tokens and out_tokens copied straight from resp.usage, so the cost of a run is arithmetic rather than estimation. Sum it:
python -c "
import json
rows = [json.loads(l) for l in open('runs.jsonl')]
m = [r for r in rows if r['event'] == 'model']
i = sum(r['in_tokens'] for r in m); o = sum(r['out_tokens'] for r in m)
print(i, 'in', o, 'out', round(i/1e6*3 + o/1e6*15, 4), 'USD')"
At the listed $3 per million input and $15 per million output, a research run of this shape is dominated by the input side, because each step resends up to 6,000 characters per page already fetched while the output is only a tool call or a short JSON note. Two practical consequences follow. Trimming MAX_CHARS moves the bill more than trimming MAX_STEPS does. And any per-run number you quote to a colleague should come out of this sum, not out of a guess — the log is the only place the real figure exists.
The four dials
Each of these lives in Python, where the model cannot talk its way past it. Prompt-level limits are requests. These are rules.
| Dial | In the code | What it stops |
|---|---|---|
| Step cap | MAX_STEPS = 8, enforced by the for loop | Unbounded loops — the most common production failure, where a model repeats the same tool call until the budget is gone |
| Tool allowlist | host check at the top of fetch_page | The agent wandering onto third-party sites, or being talked into fetching an internal address by text on a page it read |
| Action budget | FETCH_BUDGET = 5 | Rate-limit bans and runaway token spend; a blocked call returns a string the model can read, so it adapts instead of crashing |
| Append-only log | runs.jsonl with usage token counts | Having no answer to "why did it do that" — and no measured cost, since the only honest cost number is the one in your own log |
The guard returning plain English matters. "BLOCKED: fetch budget of 5 pages is spent" goes back into the conversation as a tool result, and the model's next move is to call write_note with whatever it has. A hard exception would have ended the run with nothing on disk.
When it doesn't work
No key in the environment. Constructing Anthropic() raises immediately with a message about not being able to resolve an authentication method — it expects api_key or auth_token to be set. You exported the key in a different shell, or activated the venv in a new tab. Re-export in the tab you are running from.
Wrong model ID. The API returns HTTP 404 with a body of the form {"type": "error", "error": {"type": "not_found_error", "message": "model: ..."}}. Model aliases change, and your account may not have access to every one. Check the current IDs on Anthropic's model overview and change the MODEL constant.
Missing dependency. ModuleNotFoundError: No module named 'anthropic' means you installed outside the venv or activated after installing. Run which python and pip install anthropic requests again from the active environment.
The model invents a tool argument. You see [tool_result] carrying TOOL ERROR: TypeError: write_note() got an unexpected keyword argument — the model passed a key your function doesn't take. The try/except keeps the run alive and hands the error text back as the tool result, which is enough for it to retry correctly. If it recurs, the required list in input_schema is incomplete.
A redirect carries you off the domain. The host check runs on the URL the model proposed, and requests follows redirects by default, so a path on the target domain that 301s to a site elsewhere will still be fetched and still spend one unit of budget. If that matters for your use, compare resp.url against the allowlist after the request and discard anything that moved hosts.
robots.txt could not be read. The log shows [robots_unreadable] and the fetch goes ahead. That is a deliberate reading: a missing or erroring robots.txt is not a disallow. If your policy is stricter, return a BLOCKED: string from that except branch rather than logging — keeping it a string, not an exception, so the model can still adapt and finish.
Every fetch returns HTTP 403. The site is refusing the named User-Agent. Do not swap in a browser string to get past it; that converts a polite agent into an impolite one and makes the block permanent when it is noticed. Ask for access, or pick a different target.
It finishes with [cap_hit] and no note. Read runs.jsonl and look at consecutive tool_call inputs. Identical URLs means a loop; many distinct URLs means the ICP in SYSTEM is too vague to decide against. Fix the prompt. Do not raise MAX_STEPS.
What I would not hand to an agent yet
I took Metadata.io from $0 to $15M ARR, raised $50M, and hold six patents in AI-driven marketing. The documented guidance for production agents runs against the instinct to widen scope early: OWASP's Excessive Agency entry treats surplus permissions as the risk itself, independent of how well the model happens to behave on a given run. I would still not give this script spend authority, send permission, or write access to a CRM on day one. Read-only research that produces a file a human opens is the right first loop, because the blast radius of a wrong answer is one deleted JSON file.
The sequence that works: read-only with a log, then read-only with a budget you check weekly, then one write action with a reversal path. Each stage buys you something specific — the log tells you what the agent chose, the budget tells you what it costs, and the reversal path means the first bad write is an inconvenience rather than an incident. If you want the agent to earn its own keep, the metered-billing build is the next piece. If you are deciding whether to run the model yourself, the two meanings of "self-hosted agent" sorts that out before you buy hardware.
Being more agentic is not a mindset upgrade. It is this file, running, with a cap you tested by lowering it.
Sources
- Anthropic Messages API reference — Request/response shape for tool use: tools array, stop_reason tool_use, tool_result blocks, usage token counts
- Anthropic model overview — Current model IDs, including the claude-sonnet-4-5 alias used in the code
- Anthropic pricing — Published per-million-token input and output prices
- anthropic-sdk-python — Install name, and that the client reads the ANTHROPIC_API_KEY environment variable by default
- RFC 9309: Robots Exclusion Protocol — robots.txt is a published standard, not a convention
- Python urllib.robotparser — RobotFileParser.read() and can_fetch(), used in the fetch tool
- Requests documentation — requests.get with timeout and custom headers
- OWASP Top 10 for LLM Applications — Excessive Agency is a named top-ten risk class for LLM applications
- RFC 2606: Reserved Top Level DNS Names — example.com and .test/.invalid are reserved for documentation and testing