§ 00Where this starts
I wanted to understand agents by building one, not by reading about one. So this is a research agent written from scratch: an LLM, a handful of tools, and a loop that decides which tool to call next. No framework.
Everything below is the actual code, in the order I wrote it. The setup is a virtual environment and a .env file holding one key:
XAI_API_KEY=your_xai_key_here§ 01The client is just a convention
Three imports carry the whole first file: os for reading the environment, load_dotenv to pull the .env file into it, and the OpenAI SDK.
import os
from openai import OpenAI
from dotenv import load_dotenvUsing the OpenAI SDK to talk to xAI looks strange for about a minute. Then it clicks: “OpenAI-compatible” is a convention, not a dependency. It means the endpoint accepts the same request shape and returns the same response shape. Point the same client at a different base_url and you're talking to a different company's model.
A client is a relationship, not a library. You are the client because someone is serving you — and the API key is how the server knows which relationship this is.
class LLMClient:
def __init__(self,
model="grok-3",
base_url="https://api.x.ai/v1",
api_key=None):
self.model = model
self.base_url = base_url
self.api_key = os.getenv("XAI_API_KEY") or api_key
self.client = OpenAI(api_key=self.api_key, base_url=self.base_url)Model, address, credential. That is the entire object.
§ 02One call, and the shape of a conversation
The chat method builds the request and sends it. Tools are attached only if there are any — an empty tools array is not the same as no tools at all, and some endpoints reject it.
def chat(self, messages: list[dict], tools: list[dict] = None, temperature=0.2):
kwargs = {
"model": self.model,
"messages": messages,
"temperature": temperature,
}
if tools:
kwargs["tools"] = tools
kwargs["tool_choice"] = "auto"
response = self.client.chat.completions.create(**kwargs)
return responsetool_choice="auto" means the model decides whether to call a tool or answer directly. Later in the series this becomes the most useful lever I have.
People say model communication is “JSON-based,” which is true but not illuminating. What matters is that the conversation is a list of typed dictionaries, and every turn appends to it:
messages = [
{"role": "system", "content": "You are a research agent..."},
{"role": "user", "content": "What are the latest advances in solid-state batteries?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "web_search",
"arguments": "{\"query\": \"solid-state batteries 2026\"}"
}
}
]
},
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "1. Title...\nURL: ..."
},
# ... repeat until done
]| Role | What it carries |
|---|---|
| system | Instructions for the agent |
| user | What the human said |
| assistant | What the model replied — or the tool calls it made |
| tool | The result coming back from a tool |
Note the pairing: an assistant message carrying tool_calls is answered by a tool message carrying the matching tool_call_id. That pairing is load-bearing, and breaking it is the bug that cost me the most time later in this series.
§ 03The anatomy of a tool
A tool is two things wearing one name: a description the model reads, and a Python function the runtime executes. The class holds both.
from typing import Callable
class Tool:
def __init__(self, name, description, parameters: dict, func: Callable):
self.name = name
self.description = description
self.parameters = parameters
self.func = funcThe model never sees the function. It sees a JSON Schema — again, a convention:
def to_openai_schema(self) -> dict:
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters,
}
}And execution catches everything. A tool that raises kills the loop; a tool that returns an error string is just another observation the model can read and react to.
def run(self, **kwargs) -> str:
try:
result = self.func(**kwargs)
return str(result)
except Exception as e:
return f"Error executing tool '{self.name}': {str(e)}"Then a registry, so adding a tool is one decorator instead of three edits:
TOOLS: dict[str, Tool] = {}
def register_tool(name, description, parameters: dict):
def decorator(func: Callable):
TOOLS[name] = Tool(name, description, parameters, func)
return func
return decorator§ 04Registering real tools
Two dependencies do the heavy lifting: DDGS searches DuckDuckGo with no API key, and trafilatura downloads a page and extracts the main text, leaving the ads and navigation behind.
The decorator is where the design happens — the schema is the model's entire understanding of what this tool is for:
@register_tool(
name="web_search",
description="Search the web using DuckDuckGo and return the top results.",
parameters={
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query string."
},
"max_results": {
"type": "integer",
"description": "The maximum number of search results to return.",
"default": 6
}
},
"required": ["query"]
}
)
def web_search(query: str, max_results: int = 6) -> str:
with DDGS() as ddgs:
results = list(ddgs.text(query, max_results=max_results))
if not results:
return "No results found."
formatted = []
for i, r in enumerate(results, 1):
formatted.append(
f"{i}. {r['title']}\n"
f"URL: {r['href']}\n"
f"Snippet: {r.get('body', 'No snippet available')}\n"
)
return "\n".join(formatted)Searching finds URLs; it doesn't read them. browse_page does, and truncates so one long page can't swallow the context window:
@register_tool(
name="browse_page",
description="Fetch and extract the clean main content of a webpage. "
"Always use this after finding interesting URLs.",
parameters={
"type": "object",
"properties": {
"url": {"type": "string", "description": "Full URL of the page to read"}
},
"required": ["url"]
}
)
def browse_page(url: str) -> str:
try:
downloaded = trafilatura.fetch_url(url)
if not downloaded:
return f"Failed to download {url}"
text = trafilatura.extract(
downloaded,
include_comments=False,
include_tables=True,
favor_precision=True
)
if not text:
return f"Could not extract meaningful content from {url}"
# Limit length so we don't overwhelm the context
return text[:12000] + ("..." if len(text) > 12000 else "")
except Exception as e:
return f"Error browsing {url}: {str(e)}"The third tool does nothing at all, and it is the most interesting one:
@register_tool(
name="finish_research",
description="Call this when you have enough information and want to "
"produce the final research report.",
parameters={
"type": "object",
"properties": {
"report": {
"type": "string",
"description": "The complete final research report in Markdown"
}
},
"required": ["report"]
}
)
def finish_research(report: str) -> str:
return reportIt returns its own argument. Its value is that it gives the agent an explicit way to declare it is done. Without it, the loop ends on a step limit, or on the model quietly answering without calling anything — both accidents. finish_research turns stopping into a decision the runtime can observe. That is a layer of determinism bought for four lines.
§ 05The agent
The system prompt is not decoration. It sets the methodology and pins the output format, so the report doesn't arrive in a different shape every run.
SYSTEM_PROMPT = """You are an elite research agent. Your goal is to produce accurate,
well-sourced, and balanced research reports.
Methodology:
1. Break the user's question into clear sub-questions if needed.
2. Use web_search to discover relevant sources.
3. Use browse_page on the most promising URLs to get deep information.
4. Cross-check important claims across multiple sources.
5. Note any conflicting information or uncertainty.
6. When you have enough high-quality information, call the finish_research tool
with a complete Markdown report.
Report structure (use this exact structure):
# Research Report: [Title]
## Executive Summary
...
## Key Findings
- ...
## Detailed Analysis
...
## Sources
1. [Title](URL) - brief note
2. ...
## Limitations & Uncertainty
...
Always cite your sources with real URLs. Prefer primary and recent sources.
Be honest about gaps."""The agent itself holds four things: a client, a step budget, the message list, and the tool schemas.
class ResearchAgent:
def __init__(self, model: str = "grok-3", max_steps: int = 15):
self.llm = LLMClient(model=model)
self.max_steps = max_steps
self.messages = [{"role": "system", "content": SYSTEM_PROMPT}]
self.tools_schema = [t.to_openai_schema() for t in TOOLS.values()]And the loop. Strip the console output and the branching and it is four lines of idea: ask the model, run what it asked for, append the result, repeat.
def run(self, query: str) -> str:
self.messages.append({"role": "user", "content": query})
console.print(f"\n[bold blue]Research Query:[/bold blue] {query}\n")
for step in range(1, self.max_steps + 1):
console.print(f"[dim]── Step {step} ──[/dim]")
response = self.llm.chat(self.messages, tools=self.tools_schema)
if response.tool_calls:
self.messages.append(response) # assistant message with tool_calls
for tool_call in response.tool_calls:
name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
console.print(f"[yellow]→ Calling {name}[/yellow] {args}")
if name not in TOOLS:
result = f"Unknown tool: {name}"
else:
result = TOOLS[name].run(**args)
if name == "finish_research":
console.print("\n[bold green]Research complete![/bold green]\n")
return result
observation = result[:8000] + ("..." if len(result) > 8000 else "")
console.print(f"[dim]Observation length: {len(observation)} chars[/dim]")
self.messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": observation
})
else:
# Model answered directly (rare with a good system prompt)
return response.content or "No content returned."
return "Reached maximum steps without finishing. Try a more focused query."The context window is just self.messages growing. Every observation appended is memory in the loosest sense — and the fact that it's the only memory is what the third article in this series is about.
if __name__ == "__main__":
query = ("What are the latest advances in solid-state batteries in 2025-2026 "
"and the main remaining challenges?")
ResearchAgent().run(query)§ 06What v0.1 is and isn't
It works. It searches, reads, cross-checks, and writes a sourced report. It is also far from good: it starts searching before it has thought, it accepts nearly empty pages as evidence, and it stops whenever it feels like stopping.
Closing
The loop turned out to be the easy part. What decides whether an agent is useful is everything around the loop — the schemas, the constraints, and what it is forced to do before it is allowed to finish.
A tool is a description the model reads, and a function the runtime runs. Most of the design lives in the description.