Every tutorial about AI agents either hands you a 200-line framework config or explains the theory without runnable code. This guide does neither. You will build a working agent in under 150 lines of plain Python — one that can call tools, make decisions, and complete multi-step tasks.
No LangChain. No LangGraph. No abstraction layers. Just Python and an LLM API. Once you understand how the pieces fit, frameworks become optional, not mandatory.
1. What Is an AI Agent, Really?
A chatbot answers questions. An agent does things. The difference is tools.
When you ask a chatbot "What's the weather in Tokyo?", it generates text that looks like an answer but may be fabricated. When you ask an agent the same question, it calls a weather API, reads the actual data, and reports it back. The agent can also chain steps: fetch weather, compare it to tomorrow's forecast, and suggest what to pack.
Every agent follows the same core loop:
- Perceive — receive a task or user message
- Think — the LLM decides what to do next (answer directly, or call a tool)
- Act — execute the chosen tool (API call, file read, code execution)
- Observe — feed the tool result back to the LLM
- Repeat — until the LLM decides it has enough information to give a final answer
This is called the ReAct loop (Reason + Act). It is the foundation of every agent framework, but it is simple enough to implement from scratch.
2. Prerequisites and Setup
You need Python 3.10+ and an OpenAI API key (or any compatible LLM API — Ollama works too). Install the OpenAI library:
pip install openai
# For local LLMs (free alternative):
# Install Ollama, then:
pip install ollama
Set your API key as an environment variable:
export OPENAI_API_KEY="sk-your-key-here"
3. Building the Agent Loop
Here is the entire agent in one file. Read through it, then we will break down each part.
import json
from openai import OpenAI
client = OpenAI()
# --- Define tools the agent can use ---
def get_weather(city: str) -> str:
"""Simulated weather API. Replace with a real API call."""
fake_data = {
"Tokyo": "25C, sunny, humidity 60%",
"London": "15C, rainy, humidity 80%",
"New York": "28C, partly cloudy, humidity 55%",
}
return fake_data.get(city, f"Weather data not available for {city}")
def calculate(expression: str) -> str:
"""Safely evaluate a math expression."""
try:
result = eval(expression, {"__builtins__": {}}, {})
return str(result)
except Exception as e:
return f"Error: {e}"
# --- Tool registry ---
# Each tool has: a Python function, and a JSON schema for the LLM
TOOLS = {
"get_weather": {
"function": get_weather,
"description": "Get current weather for a given city",
"params": {
"type": "object",
"properties": {
"city": {"type": "string", "description": "City name"}
},
"required": ["city"]
}
},
"calculate": {
"function": calculate,
"description": "Evaluate a mathematical expression like '2 + 3 * 4'",
"params": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "Math expression to evaluate"}
},
"required": ["expression"]
}
}
}
# Build the tools list in OpenAI's function-calling format
def get_tool_schemas():
return [
{
"type": "function",
"function": {
"name": name,
"description": tool["description"],
"parameters": tool["params"]
}
}
for name, tool in TOOLS.items()
]
# --- The agent loop ---
def run_agent(user_message: str, max_steps: int = 10):
"""Run the agent loop: think -> act -> observe -> repeat."""
messages = [
{
"role": "system",
"content": (
"You are a helpful assistant. "
"When you need real-time data or calculations, use the available tools. "
"Always use tools when the question requires factual data. "
"Give your final answer when you have enough information."
)
},
{"role": "user", "content": user_message}
]
for step in range(max_steps):
# 1. THINK: ask the LLM what to do next
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
tools=get_tool_schemas(),
tool_choice="auto",
)
reply = response.choices[0].message
messages.append(reply)
# 2. ACT: if the LLM wants to call a tool, execute it
if reply.tool_calls:
for tool_call in reply.tool_calls:
tool_name = tool_call.function.name
args = json.loads(tool_call.function.arguments)
print(f" [Step {step+1}] Calling: {tool_name}({args})")
# Execute the actual Python function
if tool_name in TOOLS:
result = TOOLS[tool_name]["function"](**args)
else:
result = f"Unknown tool: {tool_name}"
print(f" [Step {step+1}] Result: {result}")
# 3. OBSERVE: feed the result back
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": str(result)
})
# Loop continues — LLM sees the tool results and decides next step
else:
# No tool call = LLM is ready to give a final answer
print(f" [Step {step+1}] Final answer ready.")
return reply.content
return "Agent reached maximum steps without a final answer."
Now run it:
# Simple question — no tools needed
answer = run_agent("What is the capital of France?")
print(answer)
# Output: Paris is the capital of France.
# Question that requires tools
answer = run_agent("What's the weather in Tokyo? Is it hotter than New York?")
print(answer)
# [Step 1] Calling: get_weather({'city': 'Tokyo'})
# [Step 1] Result: 25C, sunny, humidity 60%
# [Step 2] Calling: get_weather({'city': 'New York'})
# [Step 2] Result: 28C, partly cloudy, humidity 55%
# [Step 3] Final answer ready.
# Output: Tokyo is 25C and sunny, while New York is 28C and partly cloudy.
# New York is currently hotter than Tokyo by 3 degrees.
The agent made two tool calls in sequence — it fetched Tokyo's weather, then realized it needed New York's too. That is the agent loop in action.
4. How the Loop Works, Step by Step
The messages array is the agent's memory
Every turn appends to the same messages list. The LLM sees the full conversation history: system prompt, user message, its own tool calls, and the tool results. This is how it knows what it has already done.
Tool calls are structured, not free-text
The LLM does not output "I will call get_weather(Tokyo)" as text. It outputs a structured tool_call object with a function name and JSON arguments. The OpenAI API handles the parsing — your code just executes the function and returns the result.
The loop ends when the LLM stops calling tools
When reply.tool_calls is None or empty, the LLM has decided it has enough information. Its text response is the final answer. If it keeps calling tools, the loop runs again — up to max_steps to prevent infinite loops.
5. Adding More Tools
Adding a new tool is a two-step process: define the Python function, then register it with a JSON schema.
import requests
def search_web(query: str) -> str:
"""Search the web and return top results."""
# Using DuckDuckGo's instant answer API (no key needed)
resp = requests.get(
"https://api.duckduckgo.com/",
params={"q": query, "format": "json", "no_html": 1}
)
data = resp.json()
if data.get("AbstractText"):
return data["AbstractText"]
return f"No instant answer found for: {query}"
# Register it
TOOLS["search_web"] = {
"function": search_web,
"description": "Search the web for current information",
"params": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query"}
},
"required": ["query"]
}
}
Now the agent can answer questions about anything:
answer = run_agent("Who won the 2026 FIFA World Cup?")
# [Step 1] Calling: search_web({'query': '2026 FIFA World Cup winner'})
# [Step 1] Result: ...
# [Step 2] Final answer ready.
description field. Be specific: "Search the web for current information" beats "Search". The clearer your descriptions, the fewer wrong-tool calls you get.6. Multi-Step Task Example
Here is where agents shine — chaining multiple tool calls to solve a complex task:
answer = run_agent(
"I'm planning a trip. Compare the weather in Tokyo and London, "
"calculate the temperature difference, and tell me which city "
"to pack a raincoat for."
)
print(answer)
# [Step 1] Calling: get_weather({'city': 'Tokyo'})
# [Step 1] Result: 25C, sunny, humidity 60%
# [Step 2] Calling: get_weather({'city': 'London'})
# [Step 2] Result: 15C, rainy, humidity 80%
# [Step 3] Calling: calculate({'expression': '25 - 15'})
# [Step 3] Result: 10
# [Step 4] Final answer ready.
# Output: Tokyo is 25C and sunny, London is 15C and rainy.
# The temperature difference is 10 degrees.
# Pack a raincoat for London — it's rainy with 80% humidity.
The agent made three tool calls across four steps, combining weather data and a calculation to give a practical recommendation. No human intervention needed.
7. Common Mistakes
7.1. No max_steps limit
If the LLM gets stuck in a loop (calling the same tool repeatedly), your script runs forever. Always set a max_steps limit and handle the timeout case.
7.2. Unrestricted eval() in calculate
The calculate tool above passes {"__builtins__": {}} to restrict eval. Without this, a user could inject __import__('os').system('rm -rf /'). For production, use ast.literal_eval or a dedicated math parser like numexpr.
7.3. Returning unstructured data
If your tool returns a 500-line JSON blob, the LLM has to process all of it, which burns tokens and degrades reasoning. Return concise, pre-processed data. Instead of dumping the full API response, extract the fields the agent actually needs.
7.4. Vague tool descriptions
The LLM picks tools based on descriptions alone. "Does stuff with files" is useless. "Read a file from disk and return its contents as a string" is useful. Test your descriptions by asking: could a human pick the right tool from just this sentence?
7.5. Not handling tool errors gracefully
Tools fail — APIs timeout, files don't exist, expressions are invalid. Always return a string error message instead of raising an exception. The LLM can then try a different approach:
def safe_get_weather(city):
try:
# Real API call here
return weather_api.get(city)
except requests.Timeout:
return "Weather API timed out. Try again or use a different source."
except Exception as e:
return f"Weather lookup failed: {e}"
8. Using a Local LLM Instead (Free)
If you have Ollama installed, you can run the agent with a local model — no API key, no cost:
import ollama
def run_local_agent(user_message: str, max_steps: int = 10):
messages = [
{"role": "system", "content": "You are a helpful assistant with tools."},
{"role": "user", "content": user_message}
]
for step in range(max_steps):
response = ollama.chat(
model="llama3.1",
messages=messages,
tools=[{
"type": "function",
"function": {
"name": name,
"description": t["description"],
"parameters": t["params"]
}
} for name, t in TOOLS.items()]
)
reply = response["message"]
messages.append(reply)
if reply.get("tool_calls"):
for tc in reply["tool_calls"]:
fn = tc["function"]["name"]
args = tc["function"]["arguments"]
result = TOOLS[fn]["function"](**args) if fn in TOOLS else "Unknown"
messages.append({"role": "tool", "content": str(result)})
else:
return reply["content"]
return "Max steps reached."
llama3.1:8b or larger for best results.9. When to Use a Framework
The from-scratch approach works for most use cases. Reach for a framework when you need:
| Need | Framework | Why |
|---|---|---|
| Complex state management | LangGraph | Graph-based agent flows with branching |
| Many pre-built tools | LangChain | 100+ integrations (search, SQL, PDF, etc.) |
| Multi-agent orchestration | CrewAI | Multiple agents with roles and delegation |
| Production observability | LangSmith | Tracing, evaluation, debugging |
If your agent has 2-5 tools and a simple loop, plain Python is cleaner and easier to debug. Frameworks add abstraction that makes simple agents harder to understand, not easier.
Frequently Asked Questions
Do I need GPT-4 or is GPT-4o-mini enough?
GPT-4o-mini handles tool calling well for most tasks and costs 30x less than GPT-4. Use GPT-4o for complex multi-step reasoning where the agent needs to plan across 5+ tool calls.
Can I use this with Claude or Gemini?
Yes. Anthropic's Claude API supports tool calling with a similar format. Gemini also supports function calling. The agent loop is the same — only the API client code changes.
How do I add memory across conversations?
Store the messages array in a database (SQLite, Redis, or a vector store for semantic search). Load it back at the start of each conversation. The agent loop itself does not change.
What is the difference between an agent and a chain?
A chain follows a fixed sequence of steps (A -> B -> C). An agent decides its own path at each step based on the results it sees. Chains are deterministic and easier to test. Agents are flexible but less predictable.