Build your first AI agent from scratch in Python: a web research agent Humanbound published a single-file Python web research agent that runs on any OpenAI-compatible endpoint with one dependency, httpx, and four tools: search Wikipedia, fetch a page, save a note and read notes. In a recorded run on 23 October 2026, Ollama's llama3.1:8b answered from search snippets without fetching a page and got the question wrong, which the post attributes to small local models being weak at tool calling. The post states the agent is not hardened: fetched text can steer the model, persisted notes can carry a hostile page's influence into later runs, and fetch_url's private-address check is vulnerable to DNS rebinding because the name is resolved once for the check and again by the HTTP client. A web research agent in one Python file, with no agent framework. It has four tools search Wikipedia, fetch a page, save a note, read notes , a loop, stop rules, and a SQLite notes file that survives between runs. It accompanies the post "Build an AI agent from scratch in Python" on the Humanbound blog. Any OpenAI-compatible endpoint works. uv installs the one dependency httpx from the file's header. export AGENT API KEY=... your key export AGENT BASE URL=https://api.openai.com/v1 default export AGENT MODEL=gpt-4o-mini default uv run agent.py "Who coined the term 'prompt injection', and how is it different from jailbreaking?" The trace goes to stderr, the answer to stdout. --dump messages.json writes the full message history. OpenRouter: set AGENT BASE URL=https://openrouter.ai/api/v1 and a model id such as openai/gpt-5.6-luna . Local with Ollama, no key: AGENT BASE URL=http://localhost:11434/v1 AGENT MODEL=llama3.1:8b uv run agent.py "..." . Small local models are weak at tool calling. In the recorded run examples/trace-ollama-llama3.1-8b.txt the model answered from search snippets without fetching a page and got the answer wrong. Set PRICE IN PER MTOK and PRICE OUT PER MTOK to see a cost estimate in the trace. It is computed from the API's usage numbers, not read from a bill. The tests need no network and no key. The model is a scripted local server. uv run --with httpx --with pytest pytest tests The traces, answers and message histories quoted in the post, recorded on 23 October 2026, plus first-attempt/ , the runs from before read notes was fixed to match on any word. page-sizes.txt comes from measure pages.py ; token counts there are characters divided by four. The recorded runs predate a review pass that hardened the HTML extractor, the notes queries and the error handling in run tool , and reworded the read notes tool description. The logic the traces show is unchanged. postfix/ holds two runs of the hardened code on the same question. The second one reads the notes the first one saved. This agent is not hardened, and it is meant to be read before it is trusted. - Fetched text is written by strangers. The model can be steered by it. - Notes persist, so a note written because of a hostile page can mislead a later run. - fetch url will request any public address, including one that carries data out in its query string. It refuses private and loopback addresses, and it does not follow redirects, so each hop is checked. The check resolves the name once and the HTTP client resolves it again when it connects, so a hostile DNS server can pass the check and then point at a private address DNS rebinding . A production agent should connect to the address it checked. - It does not read robots.txt . - Cost is bounded only by MAX STEPS and TOKEN BUDGET . Humanbound https://github.com/humanbound/humanbound is an open-source tool that tests an agent like this over HTTP. It finds what it thinks to try, so a clean result is not proof of safety. Apache-2.0.