Building Your First AI Agent: A Practical Tutorial
Step-by-step guide to building a functional AI agent from scratch. Learn the core patterns, implement tools, and deploy your first agent.
Building Your First AI Agent: A Practical Tutorial
You've read about AI agents. You understand the theory. Now it's time to build one.
This tutorial takes you from zero to a working AI agent. We'll build a practical research agent that can search the web, read pages, and synthesize findings. By the end, you'll understand the core patterns well enough to build agents for any use case.
What We're Building
Our agent will:
- Accept a research question
- Search the web for relevant sources
- Read and extract information from pages
- Synthesize findings into a coherent answer
- Cite its sources
This covers the fundamental agent patterns: reasoning, tool use, iteration, and synthesis.
Prerequisites
You'll need:
- Python 3.9+ or Node.js 18+
- An API key from Anthropic (Claude) or OpenAI
- Basic programming knowledge
We'll show examples in both Python and TypeScript.
Part 1: The Minimal Agent
Let's start with the simplest possible agent—just an LLM in a loop.
Python Version
from anthropic import Anthropic
client = Anthropic()
def simple_agent(goal: str, max_iterations: int = 5) -> str:
"""The simplest possible agent: an LLM in a loop"""
messages = []
system_prompt = """You are a helpful assistant working to achieve a goal.
After each response, decide if the goal is achieved.
If achieved, start your response with "DONE:" followed by the final answer.
If not achieved, explain what you're thinking and what you would do next."""
messages.append({"role": "user", "content": f"Goal: {goal}"})
for i in range(max_iterations):
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=1024,
system=system_prompt,
messages=messages
)
assistant_message = response.content[0].text
messages.append({"role": "assistant", "content": assistant_message})
print(f"\n--- Iteration {i + 1} ---")
print(assistant_message)
if assistant_message.startswith("DONE:"):
return assistant_message[5:].strip()
# Continue the conversation
messages.append({
"role": "user",
"content": "Continue working toward the goal."
})
return "Max iterations reached without completing goal"
# Try it
result = simple_agent("Explain the concept of machine learning in one paragraph")
print(f"\nFinal result: {result}")
TypeScript Version
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
async function simpleAgent(goal: string, maxIterations = 5): Promise<string> {
const messages: Array<{role: 'user' | 'assistant', content: string}> = [];
const systemPrompt = `You are a helpful assistant working to achieve a goal.
After each response, decide if the goal is achieved.
If achieved, start your response with "DONE:" followed by the final answer.
If not achieved, explain what you're thinking and what you would do next.`;
messages.push({ role: 'user', content: `Goal: ${goal}` });
for (let i = 0; i < maxIterations; i++) {
const response = await client.messages.create({
model: 'claude-sonnet-4-20250514',
max_tokens: 1024,
system: systemPrompt,
messages
});
const assistantMessage = response.content[0].type === 'text'
? response.content[0].text
: '';
messages.push({ role: 'assistant', content: assistantMessage });
console.log(`\n--- Iteration ${i + 1} ---`);
console.log(assistantMessage);
if (assistantMessage.startsWith('DONE:')) {
return assistantMessage.slice(5).trim();
}
messages.push({
role: 'user',
content: 'Continue working toward the goal.'
});
}
return 'Max iterations reached without completing goal';
}
// Try it
simpleAgent('Explain machine learning in one paragraph')
.then(result => console.log(`\nFinal result: ${result}`));
This minimal agent demonstrates the core loop, but it can only think—it can't act. Let's add tools.
Part 2: Adding Tools
Tools let agents interact with the world. We'll add web search and page reading capabilities.
Defining Tools
First, let's define our tool interfaces:
# tools.py
import requests
from typing import Dict, Any
from bs4 import BeautifulSoup
# You'll need to sign up for a search API. Here's an example using Tavily.
# Alternatively, use SerpAPI, Brave Search, or similar.
SEARCH_API_KEY = "your_search_api_key"
def web_search(query: str) -> Dict[str, Any]:
"""Search the web and return results"""
# Using Tavily as an example - replace with your preferred search API
response = requests.post(
"https://api.tavily.com/search",
json={
"api_key": SEARCH_API_KEY,
"query": query,
"search_depth": "basic",
"max_results": 5
}
)
if response.status_code == 200:
data = response.json()
return {
"results": [
{"title": r["title"], "url": r["url"], "snippet": r["content"]}
for r in data.get("results", [])
]
}
return {"error": f"Search failed: {response.status_code}"}
def read_webpage(url: str) -> Dict[str, Any]:
"""Read and extract text from a webpage"""
try:
response = requests.get(url, timeout=10, headers={
"User-Agent": "Mozilla/5.0 (compatible; ResearchBot/1.0)"
})
if response.status_code == 200:
soup = BeautifulSoup(response.text, 'html.parser')
# Remove script and style elements
for script in soup(["script", "style"]):
script.decompose()
# Get text
text = soup.get_text()
# Clean up whitespace
lines = (line.strip() for line in text.splitlines())
chunks = (phrase.strip() for line in lines for phrase in line.split(" "))
text = '\n'.join(chunk for chunk in chunks if chunk)
# Truncate if too long
if len(text) > 5000:
text = text[:5000] + "... [truncated]"
return {"content": text, "url": url}
return {"error": f"Failed to fetch: {response.status_code}"}
except Exception as e:
return {"error": str(e)}
# Tool definitions for Claude
TOOLS = [
{
"name": "web_search",
"description": "Search the web for information. Use this to find relevant sources on a topic.",
"input_schema": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query"
}
},
"required": ["query"]
}
},
{
"name": "read_webpage",
"description": "Read the content of a webpage. Use this to get detailed information from a specific URL.",
"input_schema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "The URL to read"
}
},
"required": ["url"]
}
}
]
# Tool execution dispatcher
def execute_tool(tool_name: str, tool_input: Dict[str, Any]) -> Dict[str, Any]:
"""Execute a tool and return the result"""
if tool_name == "web_search":
return web_search(tool_input["query"])
elif tool_name == "read_webpage":
return read_webpage(tool_input["url"])
else:
return {"error": f"Unknown tool: {tool_name}"}
The Tool-Using Agent
Now let's build an agent that uses these tools:
# research_agent.py
from anthropic import Anthropic
from tools import TOOLS, execute_tool
import json
client = Anthropic()
def research_agent(question: str, max_iterations: int = 10) -> str:
"""An agent that researches questions using web search and page reading"""
system_prompt = """You are a research assistant that finds accurate, up-to-date information.
Your process:
1. Search for relevant sources using web_search
2. Read promising pages using read_webpage
3. Synthesize information from multiple sources
4. Provide a comprehensive answer with citations
Always cite your sources. If you can't find reliable information, say so.
When you have gathered enough information to answer the question comprehensively,
provide your final answer. Don't search indefinitely - 2-3 good sources are often enough."""
messages = [
{"role": "user", "content": f"Research question: {question}"}
]
for i in range(max_iterations):
print(f"\n--- Iteration {i + 1} ---")
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system=system_prompt,
tools=TOOLS,
messages=messages
)
# Check if we're done
if response.stop_reason == "end_turn":
# Extract final text response
for block in response.content:
if hasattr(block, 'text'):
return block.text
return "No final answer generated"
# Process tool calls
if response.stop_reason == "tool_use":
# Add assistant's response (including tool use request)
messages.append({"role": "assistant", "content": response.content})
# Execute each tool and collect results
tool_results = []
for block in response.content:
if block.type == "tool_use":
print(f"Tool: {block.name}")
print(f"Input: {json.dumps(block.input, indent=2)}")
result = execute_tool(block.name, block.input)
print(f"Result preview: {str(result)[:200]}...")
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
})
# Add tool results
messages.append({"role": "user", "content": tool_results})
return "Max iterations reached"
# Run the agent
if __name__ == "__main__":
question = "What are the latest developments in fusion energy in 2026?"
answer = research_agent(question)
print("\n" + "="*50)
print("FINAL ANSWER:")
print("="*50)
print(answer)
Part 3: Adding Memory
Our agent forgets everything between runs. Let's add persistent memory:
# memory.py
import json
from datetime import datetime
from pathlib import Path
from typing import List, Dict, Any, Optional
class AgentMemory:
"""Simple file-based memory for agents"""
def __init__(self, memory_file: str = "agent_memory.json"):
self.memory_file = Path(memory_file)
self.memory = self._load()
def _load(self) -> Dict[str, Any]:
"""Load memory from file"""
if self.memory_file.exists():
with open(self.memory_file) as f:
return json.load(f)
return {
"conversations": [],
"facts": [],
"learnings": []
}
def _save(self):
"""Save memory to file"""
with open(self.memory_file, "w") as f:
json.dump(self.memory, f, indent=2, default=str)
def add_conversation(self, question: str, answer: str, sources: List[str] = None):
"""Record a completed conversation"""
self.memory["conversations"].append({
"timestamp": datetime.now().isoformat(),
"question": question,
"answer": answer,
"sources": sources or []
})
self._save()
def add_fact(self, fact: str, source: str = None, confidence: float = 1.0):
"""Store a learned fact"""
self.memory["facts"].append({
"timestamp": datetime.now().isoformat(),
"fact": fact,
"source": source,
"confidence": confidence
})
self._save()
def add_learning(self, learning: str, context: str = None):
"""Store a meta-learning about how to work better"""
self.memory["learnings"].append({
"timestamp": datetime.now().isoformat(),
"learning": learning,
"context": context
})
self._save()
def get_relevant_context(self, query: str, max_items: int = 5) -> str:
"""Get context relevant to a query"""
# Simple keyword matching - in production, use embeddings
query_words = set(query.lower().split())
relevant = []
for conv in self.memory["conversations"]:
conv_words = set(conv["question"].lower().split())
if query_words & conv_words: # Any overlap
relevant.append(f"Previous Q: {conv['question']}\nA: {conv['answer'][:200]}...")
for fact in self.memory["facts"]:
fact_words = set(fact["fact"].lower().split())
if query_words & fact_words:
relevant.append(f"Known fact: {fact['fact']}")
# Get recent learnings
for learning in self.memory["learnings"][-3:]:
relevant.append(f"Learning: {learning['learning']}")
return "\n\n".join(relevant[:max_items])
def get_statistics(self) -> Dict[str, int]:
"""Get memory statistics"""
return {
"conversations": len(self.memory["conversations"]),
"facts": len(self.memory["facts"]),
"learnings": len(self.memory["learnings"])
}
Integrating Memory
# research_agent_with_memory.py
from anthropic import Anthropic
from tools import TOOLS, execute_tool
from memory import AgentMemory
import json
client = Anthropic()
memory = AgentMemory()
def research_agent_with_memory(question: str, max_iterations: int = 10) -> str:
"""Research agent with persistent memory"""
# Get relevant context from memory
context = memory.get_relevant_context(question)
system_prompt = f"""You are a research assistant with persistent memory.
Previous relevant context:
{context if context else "No relevant previous context."}
Your process:
1. Check if you already know the answer from context
2. If not, search for relevant sources using web_search
3. Read promising pages using read_webpage
4. Synthesize information from multiple sources
5. Provide a comprehensive answer with citations
Always cite your sources. If you can't find reliable information, say so."""
messages = [
{"role": "user", "content": f"Research question: {question}"}
]
sources_used = []
for i in range(max_iterations):
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system=system_prompt,
tools=TOOLS,
messages=messages
)
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, 'text'):
answer = block.text
# Store the conversation in memory
memory.add_conversation(question, answer, sources_used)
return answer
return "No final answer generated"
if response.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
result = execute_tool(block.name, block.input)
# Track sources
if block.name == "read_webpage":
sources_used.append(block.input["url"])
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
})
messages.append({"role": "user", "content": tool_results})
return "Max iterations reached"
Part 4: Error Handling and Robustness
Production agents need robust error handling:
# robust_agent.py
from anthropic import Anthropic, APIError
from tools import TOOLS, execute_tool
import json
import time
from typing import Optional
client = Anthropic()
class AgentError(Exception):
"""Custom exception for agent errors"""
pass
def robust_research_agent(
question: str,
max_iterations: int = 10,
max_retries: int = 3,
timeout_seconds: int = 300
) -> str:
"""Research agent with robust error handling"""
start_time = time.time()
system_prompt = """You are a research assistant that finds accurate information.
When searching or reading fails:
1. Try alternative search terms
2. Try different sources
3. If a page is inaccessible, find similar information elsewhere
4. If you can't find specific information, clearly state what you could and couldn't find
Always provide the best answer possible with available information."""
messages = [
{"role": "user", "content": f"Research question: {question}"}
]
for i in range(max_iterations):
# Check timeout
if time.time() - start_time > timeout_seconds:
return "Research timeout - returning partial results based on information gathered so far."
# API call with retry logic
response = None
for retry in range(max_retries):
try:
response = client.messages.create(
model="claude-sonnet-4-20250514",
max_tokens=4096,
system=system_prompt,
tools=TOOLS,
messages=messages
)
break
except APIError as e:
if retry == max_retries - 1:
raise AgentError(f"API error after {max_retries} retries: {e}")
time.sleep(2 ** retry) # Exponential backoff
if response is None:
raise AgentError("Failed to get API response")
if response.stop_reason == "end_turn":
for block in response.content:
if hasattr(block, 'text'):
return block.text
return "No final answer generated"
if response.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
try:
result = execute_tool(block.name, block.input)
except Exception as e:
# Return error to the agent so it can adapt
result = {"error": f"Tool execution failed: {str(e)}"}
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
})
messages.append({"role": "user", "content": tool_results})
return "Max iterations reached - returning best available answer"
Part 5: The Complete Agent
Let's put it all together into a complete, production-ready agent:
# complete_agent.py
from anthropic import Anthropic
from dataclasses import dataclass, field
from typing import List, Dict, Any, Optional, Callable
import json
import time
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
@dataclass
class Tool:
"""Definition of an agent tool"""
name: str
description: str
parameters: Dict[str, Any]
function: Callable
def to_claude_format(self) -> Dict[str, Any]:
return {
"name": self.name,
"description": self.description,
"input_schema": self.parameters
}
@dataclass
class AgentConfig:
"""Configuration for the agent"""
model: str = "claude-sonnet-4-20250514"
max_iterations: int = 10
max_tokens: int = 4096
temperature: float = 0
timeout_seconds: int = 300
@dataclass
class AgentResult:
"""Result of an agent run"""
success: bool
answer: str
iterations: int
tools_used: List[str]
duration_seconds: float
error: Optional[str] = None
class Agent:
"""A complete, configurable AI agent"""
def __init__(
self,
system_prompt: str,
tools: List[Tool] = None,
config: AgentConfig = None
):
self.client = Anthropic()
self.system_prompt = system_prompt
self.tools = tools or []
self.config = config or AgentConfig()
self.tool_map = {tool.name: tool for tool in self.tools}
def run(self, task: str) -> AgentResult:
"""Execute the agent on a task"""
start_time = time.time()
tools_used = []
messages = [{"role": "user", "content": task}]
claude_tools = [t.to_claude_format() for t in self.tools] if self.tools else None
for iteration in range(self.config.max_iterations):
# Check timeout
elapsed = time.time() - start_time
if elapsed > self.config.timeout_seconds:
return AgentResult(
success=False,
answer="",
iterations=iteration,
tools_used=tools_used,
duration_seconds=elapsed,
error="Timeout exceeded"
)
try:
response = self.client.messages.create(
model=self.config.model,
max_tokens=self.config.max_tokens,
system=self.system_prompt,
tools=claude_tools,
messages=messages
)
except Exception as e:
logger.error(f"API error: {e}")
return AgentResult(
success=False,
answer="",
iterations=iteration,
tools_used=tools_used,
duration_seconds=time.time() - start_time,
error=str(e)
)
# Check if done
if response.stop_reason == "end_turn":
answer = ""
for block in response.content:
if hasattr(block, 'text'):
answer = block.text
break
return AgentResult(
success=True,
answer=answer,
iterations=iteration + 1,
tools_used=tools_used,
duration_seconds=time.time() - start_time
)
# Handle tool use
if response.stop_reason == "tool_use":
messages.append({"role": "assistant", "content": response.content})
tool_results = []
for block in response.content:
if block.type == "tool_use":
logger.info(f"Executing tool: {block.name}")
tools_used.append(block.name)
tool = self.tool_map.get(block.name)
if tool:
try:
result = tool.function(**block.input)
except Exception as e:
result = {"error": str(e)}
else:
result = {"error": f"Unknown tool: {block.name}"}
tool_results.append({
"type": "tool_result",
"tool_use_id": block.id,
"content": json.dumps(result)
})
messages.append({"role": "user", "content": tool_results})
return AgentResult(
success=False,
answer="",
iterations=self.config.max_iterations,
tools_used=tools_used,
duration_seconds=time.time() - start_time,
error="Max iterations reached"
)
# Example usage
if __name__ == "__main__":
from tools import web_search, read_webpage
# Define tools
search_tool = Tool(
name="web_search",
description="Search the web for information",
parameters={
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
},
function=web_search
)
read_tool = Tool(
name="read_webpage",
description="Read content from a webpage",
parameters={
"type": "object",
"properties": {
"url": {"type": "string", "description": "URL to read"}
},
"required": ["url"]
},
function=read_webpage
)
# Create agent
agent = Agent(
system_prompt="""You are a research assistant. Search for information,
read relevant sources, and provide comprehensive answers with citations.""",
tools=[search_tool, read_tool],
config=AgentConfig(max_iterations=10)
)
# Run
result = agent.run("What are the latest developments in quantum computing?")
print(f"Success: {result.success}")
print(f"Iterations: {result.iterations}")
print(f"Tools used: {result.tools_used}")
print(f"Duration: {result.duration_seconds:.1f}s")
print(f"Answer: {result.answer}")
Next Steps
You now have a working AI agent. Here's how to extend it:
Add More Tools
- Database queries
- File operations
- API integrations
- Code execution
Improve Memory
- Use vector embeddings for semantic search
- Add structured knowledge graphs
- Implement forgetting for old/irrelevant information
Add Guardrails
- Rate limiting
- Content filtering
- Action approval workflows
Build Specialized Agents
- Code review agent
- Data analysis agent
- Customer support agent
Conclusion
Building AI agents follows consistent patterns:
- The loop: Observe, think, act, repeat
- Tools: Connect to external capabilities
- Memory: Persist knowledge across sessions
- Error handling: Gracefully handle failures
Start simple, test thoroughly, and add complexity incrementally. The agent you build today can evolve into something remarkably capable.
Want to learn more about agent patterns? Check out The ReAct Pattern for deeper insights into how agents reason and act.