"AI agent" gets thrown around so much I figured I should just build one instead of reading more threads about it. The core idea turned out to be small: you put Claude in a loop and hand it some tools. It picks a tool, you run it, you hand back the result, and it keeps going until it has an answer.
Code is here if you want to skip ahead: claude-research-agent.
What it can do
Give it a task and it works out the steps on its own. Mine can:
- search the web (no API key for this part, it just hits DuckDuckGo's HTML page)
- open a URL and pull the readable text out
- do math without me trusting
eval - run read-only SQL against a SQLite file
- read local files, but only inside the project folder
- save findings to a notes file
The loop is basically the whole thing
Honestly this is most of it:
messages = [{"role": "user", "content": user_message}]
for _ in range(MAX_STEPS):
response = client.messages.create(
model="claude-sonnet-5",
max_tokens=2048,
tools=TOOL_SCHEMAS,
messages=messages,
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason != "tool_use":
return final_text(response)
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = run_tool(block.name, block.input)
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": result,
})
messages.append({"role": "user", "content": tool_results})
The thing to watch is stop_reason. If Claude says tool_use, it wants you to run something. You run it, drop the result back into the conversation as a tool_result, and loop. When it stops asking for tools, you're done. The MAX_STEPS cap is just there so a confused agent can't spin forever.
Tools are just functions
Each tool is a Python function plus a little JSON schema telling Claude when to reach for it. Want a new capability? Write a function, add its schema. The loop never changes, which was the part I liked most.
Guardrails, because this stuff can bite
The second your agent can run code or touch a database, you have to be careful:
- the calculator parses an AST and only allows math, so something like
__import__("os")...gets rejected instead of run - SQL is opened read-only and I block write keywords on top of that
- file reads can't escape the project folder, so no
../../etc/passwd
Skip this and you've basically built a foot-gun with a chat interface.
Seeing it actually run
Here's a real run. I asked it to look up the latest Claude model and do some math, and it chained five tool calls by itself:
you> Search for the latest Claude model, then calculate 15% of 2340.
web_search({"query": "latest Claude model"})
calculator({"expression": "2340 * 0.15"})
fetch_url({"url": "https://www.anthropic.com/news"})
save_note({...})
agent> 15% of 2340 = 351, plus a short rundown of the current models.
Watching it decide the order on its own is the part that finally made "agent" click for me.
The bug that ate an hour
My web search kept coming back empty even though the results were clearly in the HTML. Drove me a little crazy.
Turns out DuckDuckGo wraps the matched words in <b> tags inside each snippet. My parser was resetting its state on every closing tag, so the inner </b> wiped everything before the result ever got saved. The fix was to only reset on the closing </a> and flush the last result at the end. Classic "the real-world HTML is messier than your test case" moment.
If you want to poke at it
The repo has the CLI, a small FastAPI web UI that shows the tool trace, and a test suite: github.com/venkatarahul27/claude-research-agent.
Curious what tools other people would bolt on. I'm tempted to give it a real browser next.
Top comments (0)