"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.
Give it a task and it works out the steps on its own. Mine can:
eval
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.
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.
The second your agent can run code or touch a database, you have to be careful:
__import__("os")...
gets rejected instead of run../../etc/passwd
Skip this and you've basically built a foot-gun with a chat interface.
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.
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.
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.