Tech With Galvan
Back to Articles
AI2/22/2026 • 5 min read

Build Your First AI Agent with Python

Build a real AI agent from scratch with Python — an LLM that thinks, calls tools like a calculator and API, and loops until the job is done.

Galvan
Galvan

Founder & Creator

Introduction

2025 was the year of chatbots. 2026 is the year of agents — AI systems that don’t just talk, but act: calling calculators, searching the web, reading files, and chaining steps until a task is done.

The word sounds futuristic, but an agent is just three parts in a loop:

  1. A brain — an LLM that decides what to do
  2. Tools — functions it can call (calculator, weather API, file reader)
  3. A loop — think → act → observe → repeat until done

You already have all three skills: the LLM calls from the chatbot tutorial, functions from Module 7 of the Python course, and loops from Module 6. Today we assemble them into an agent — running on free local AI via Ollama, so it costs nothing to experiment.

The Agent Loop (the entire concept on one screen)

User: "What is 247 times 38?"
   ↓
LLM thinks: "I shouldn't do math myself — I have a calculator tool"
   ↓
Agent: CALL calculator(247, 38, "*")
   ↓
Tool returns: 9386
   ↓
LLM thinks: "I have the answer now"
   ↓
Agent: "247 × 38 = 9,386"

A chatbot would guess the math (and sometimes get it wrong). An agent uses tools for facts and computation, reserving the LLM’s intelligence for decisions and language. That reliability difference is why agents matter.

Prerequisites

  • Ollama installed with llama3.2 pulled — see the local AI tutorial
  • Python packages: pip install requests
  • Everything else is standard library

Step 1: Define the Tools

Tools are ordinary Python functions with a description the LLM can read:

import json
import requests


def calculator(a, b, operation):
    a, b = float(a), float(b)
    if operation == "+": return a + b
    if operation == "-": return a - b
    if operation == "*": return a * b
    if operation == "/":
        if b == 0:
            return "Error: division by zero"
        return a / b
    return f"Unknown operation: {operation}"


def get_weather(city):
    # using a free API — no key needed
    try:
        r = requests.get(
            f"https://wttr.in/{city}?format=j1", timeout=10
        )
        current = r.json()["current_condition"][0]
        return f"{current['temp_C']}°C, {current['weatherDesc'][0]['value']}"
    except Exception as e:
        return f"Weather lookup failed: {e}"


TOOLS = {
    "calculator": calculator,
    "get_weather": get_weather,
}

The TOOLS dictionary maps names the LLM will use to the real functions. Adding a new capability later is one function plus one dictionary entry.

Step 2: Tell the LLM About Its Tools

The agent’s system prompt is the instruction manual. Small local models need it spelled out precisely:

SYSTEM_PROMPT = """You are a helpful agent with access to these tools:

1. calculator(a, b, operation) - does math. operation is +, -, *, or /
2. get_weather(city) - current weather for a city

To use a tool, reply with ONLY a JSON object:
{"tool": "tool_name", "args": {"a": 1, "b": 2, "operation": "+"}}

After you receive a tool result, answer the user in plain text.
If no tool is needed, just answer normally."""

This is the ReAct pattern (Reason + Act) in its simplest form: the model either answers or requests a tool — nothing else. Strict output formats keep small models on rails.

Step 3: The Loop — where the agent lives

def run_agent(user_input):
    messages = [
        {"role": "system", "content": SYSTEM_PROMPT},
        {"role": "user", "content": user_input},
    ]

    for step in range(5):                      # safety limit!
        response = requests.post(
            "http://localhost:11434/api/chat",
            json={"model": "llama3.2", "messages": messages, "stream": False},
            timeout=120,
        )
        reply = response.json()["message"]["content"].strip()

        # Is the reply a tool call? (starts with { and names a tool)
        if reply.startswith("{") and "tool" in reply:
            try:
                call = json.loads(reply)
                tool_name = call["tool"]
                args = call.get("args", {})
                print(f"🔧 Using tool: {tool_name}({args})")

                result = TOOLS[tool_name](**args)
                print(f"🔧 Result: {result}")

                messages.append({"role": "assistant", "content": reply})
                messages.append({"role": "user",
                                 "content": f"Tool result: {result}"})
                continue                        # back to the LLM
            except (json.JSONDecodeError, KeyError, TypeError):
                pass                            # not a valid call — treat as answer

        return reply                            # plain answer — done!

    return "I couldn't finish within 5 steps."

Read the loop slowly — it is the agent:

  1. Send the conversation to the LLM
  2. If the reply is a tool call → run the real function, append the result, loop again
  3. If it’s a plain answer → return it
  4. The range(5) cap prevents runaway loops — every real agent has one

Step 4: Run It

while True:
    user_input = input("\nYou: ")
    if user_input.lower() in ("quit", "exit"):
        break
    print("Agent:", run_agent(user_input))

Try:

You: What is 144 divided by 12?
🔧 Using tool: calculator({'a': 144, 'b': 12, 'operation': '/'})
🔧 Result: 12.0
Agent: 144 divided by 12 is 12.

You: Weather in Delhi?
🔧 Using tool: get_weather({'city': 'Delhi'})
🔧 Result: 31°C, Sunny
Agent: It's currently 31°C and sunny in Delhi.

You: What's the capital of France?
Agent: The capital of France is Paris.      ← no tool needed — it just knows

The model chose when to use tools and when to answer directly. That decision-making is what makes it an agent rather than a script.

Common Errors & Fixes

  • Model returns broken JSON — small models are imperfect JSON writers; the try/except falls through to a plain answer. Improve reliability by tightening the system prompt’s format example.
  • Agent loops forever on one task — your step cap is missing or too high. Keep range(5) until you have a reason otherwise.
  • Connection refused from Ollama — the Ollama app isn’t running; launch it first.
  • Tool name hallucinated — the model invented multiply() instead of calculator. List the exact tool names in the prompt and return a friendly error from TOOLS.get(tool_name) so the model can self-correct.

Key Concepts

  • Agent = LLM + tools + loop — nothing more mysterious than that
  • Tool descriptions are prompts — the model only knows what you declare
  • The step cap — every real agent bounds its own looping
  • Observe → decide → act — the loop that scales from calculator agents to web-browsing ones

What to Try Next

  • Add a read_file(path) tool — the agent can now answer questions about your own notes.
  • Add a web search tool (DuckDuckGo’s ddgs package) — an agent that researches.
  • Chain tools: “What’s 3× the current temperature in Mumbai?” needs weather, then calculator — watch the multi-step loop shine.
  • Wrap it in Streamlit with the chat UI from the Mistral chatbot and show the tool calls in an expander.

FAQ

Why build the loop manually instead of using a framework?

Frameworks (LangChain, CrewAI) are excellent — but they hide the loop you’re learning here. Once you’ve written it in 40 lines, every framework becomes an optimization you understand rather than magic you hope works.

Why is my local model worse at tool calls than ChatGPT?

Small models (3B) are learning to drive. Larger Ollama models (8B+) follow tool formats more reliably — the loop stays identical. Cloud APIs also offer native function-calling modes; the concept transfers one-to-one.

Is this how real production agents work?

The loop, the tools, the cap, the observation feeding — yes, this is the skeleton. Production adds memory, better parsing, parallel tools, and guardrails. You’ve built the part that matters.