Most AI tutorials show you how to call a chat API. That is not an agent. An agent is a system that can take a goal, reason about what steps are needed, execute actions using tools, observe the results, and loop — until the job is done.
This post shows you exactly how to build one from scratch in Python. No frameworks. Just the core loop.
What Is an AI Agent, Actually?
Before writing code, let us be precise about the term. The word "agent" gets thrown around loosely in 2026 — every chatbot is marketed as an agent. The meaningful definition is narrower.
An AI agent is a system that:
- Receives a goal (not just a one-shot prompt)
- Decides what actions to take using an LLM as the reasoning engine
- Executes those actions via tools (functions it can call)
- Observes the results and feeds them back into its reasoning
- Loops until the goal is achieved or it determines it cannot proceed
The key distinction from a chatbot: a chatbot responds to a single message. An agent runs a loop, taking multiple actions across multiple steps to complete a task.
The Observe-Think-Act Loop
Every agent — regardless of framework — runs some variation of this loop:
while goal_not_achieved:
observation = get_current_state()
action = llm.think(goal, history, observation)
result = execute(action)
history.append(result)
This is the ReAct pattern (Reasoning + Acting), originally from a 2022 paper by Yao et al., and still the foundation of how most production agents work in 2026.
Observe: What is the current state of the world? What did the last action return?
Think: Given the goal and what I know, what is the best next action? The LLM answers this question. Critically, you give the LLM a list of available tools and let it decide which one to call.
Act: Execute the chosen tool. Return the result. Feed it back to the LLM as context.
A Minimal Working Agent in Python
Here is a complete agent implementation. It supports multiple tools, maintains conversation history, and runs the full reasoning loop. Dependencies: just openai (or swap in anthropic — the pattern is identical).
import json
import openai
client = openai.OpenAI()
# --- Define tools ---
def search_web(query: str) -> str:
# In production, call a real search API (Tavily, Serper, etc.)
# This stub simulates a result
return f"Search results for '{query}': Found 3 relevant articles about {query}."
def calculate(expression: str) -> str:
try:
result = eval(expression) # Use safer eval in production
return str(result)
except Exception as e:
return f"Error: {e}"
def get_current_date() -> str:
from datetime import date
return str(date.today())
# Tool registry — maps name to function
TOOLS = {
"search_web": search_web,
"calculate": calculate,
"get_current_date": get_current_date,
}
# Tool schemas for the LLM
TOOL_SCHEMAS = [
{
"type": "function",
"function": {
"name": "search_web",
"description": "Search the web for current information on a topic",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "The search query"}
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "calculate",
"description": "Evaluate a mathematical expression",
"parameters": {
"type": "object",
"properties": {
"expression": {"type": "string", "description": "Math expression to evaluate"}
},
"required": ["expression"],
},
},
},
{
"type": "function",
"function": {
"name": "get_current_date",
"description": "Get today's date",
"parameters": {"type": "object", "properties": {}},
},
},
]
# --- The agent loop ---
def run_agent(goal: str, max_steps: int = 10) -> str:
messages = [
{"role": "system", "content": "You are a helpful agent. Use tools to complete the user's goal. When you have enough information, provide a final answer."},
{"role": "user", "content": goal},
]
for step in range(max_steps):
response = client.chat.completions.create(
model="gpt-4o",
messages=messages,
tools=TOOL_SCHEMAS,
tool_choice="auto",
)
message = response.choices[0].message
# If no tool call, the agent is done
if not message.tool_calls:
return message.content
# Process each tool call
messages.append(message) # Add assistant message with tool_calls
for tool_call in message.tool_calls:
tool_name = tool_call.function.name
tool_args = json.loads(tool_call.function.arguments)
print(f"[Step {step + 1}] Calling {tool_name} with {tool_args}")
if tool_name in TOOLS:
result = TOOLS[tool_name](**tool_args)
else:
result = f"Error: unknown tool '{tool_name}'"
messages.append({
"role": "tool",
"tool_call_id": tool_call.id,
"content": result,
})
return "Max steps reached without completing the goal."
# Run it
if __name__ == "__main__":
result = run_agent("What is today's date, and what is 2847 divided by 13?")
print("\nFinal answer:", result)
Run this and you will see the agent call get_current_date, then calculate, then synthesize a final answer — without you telling it which tools to use or in what order. That is the LLM doing the reasoning.
What Is Happening Under the Hood
When you call the API with tools=TOOL_SCHEMAS, you are telling the LLM: "These functions exist. You can choose to call any of them. Return a structured JSON object describing which one to call and with what arguments."
The LLM does not execute the tools — your Python code does. The LLM just decides what to call. This separation is important: the LLM handles reasoning, your code handles execution.
The message history is what makes the loop work. Each iteration, you append the previous tool calls and their results. The LLM sees the full history and can build on previous observations.
Four Things to Add Before This Is Production-Ready
1. Error handling and retries. Tool calls fail. APIs timeout. Add try/except around every tool execution and give the LLM a clean error message to reason about.
2. Token budget management. Long agent runs accumulate large histories. Track token usage and truncate or summarize older turns before they exceed the context window.
3. A stopping condition beyond "no tool call". Add explicit logic: if the LLM says "I cannot find this information" or "Task complete", stop. Do not rely solely on the absence of a tool call.
4. Logging. Log every tool call and result with timestamps. Debugging a production agent without logs is painful. You need to see the full reasoning trace.
Next Steps
If you want to go deeper on agents — multi-step workflows, memory, parallel tool execution, and building agents that can handle real production tasks — Phase 3 of the Agentic AI course at MindloomHQ covers exactly this.
Phase 3 starts where this post ends: you have a working agent, and now you need to make it reliable, observable, and useful for tasks that actually matter. The 12 lessons cover memory management, tool design, error recovery, streaming output, and more — each with full code implementations and a real project to build.
The first two phases are free to start, no credit card required.