How to Build an AI Agent in Python: Step-by-Step Guide

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:

  1. Perceive — receive a task or user message
  2. Think — the LLM decides what to do next (answer directly, or call a tool)
  3. Act — execute the chosen tool (API call, file read, code execution)
  4. Observe — feed the tool result back to the LLM
  5. 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"
Tested with: Python 3.12.4, openai 1.51.0, gpt-4o-mini model. The code below works with any OpenAI-compatible API — just change the base_url and model name.

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.
Tool design matters: The LLM chooses tools based on the 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."
Note: Local models like Llama 3.1 support tool calling, but smaller models (7B) are less reliable at choosing the right tool. Test with 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:

NeedFrameworkWhy
Complex state managementLangGraphGraph-based agent flows with branching
Many pre-built toolsLangChain100+ integrations (search, SQL, PDF, etc.)
Multi-agent orchestrationCrewAIMultiple agents with roles and delegation
Production observabilityLangSmithTracing, 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.