Build a Tool-Calling AI Agent
Build a Python AI agent that calls live APIs and tools to answer questions.
Introduction
30 Second Summary
AI assistants like ChatGPT and Gemini can look up the weather, do math, and search the web. They do this by calling tools behind the scenes, using a loop that decides which tool to use, runs it, reads the result, and repeats until it has an answer.
In this project, you will build a tool-calling AI agent from scratch using Python and the Gemini API. You'll write every line yourself, learning core Python patterns (functions, classes, loops, error handling) through a real working project.
What You'll Build
A command-line AI agent powered by Gemini 2.5 Flash that decides which tools to call to answer your questions.
By the end of this project, you'll have:
- A working get_weather tool that fetches live temperatures from the Open-Meteo API.
- A calculator tool that evaluates math expressions safely.
- A reusable Agent class with a configurable tool-calling loop and error handling.
- Secret Mission: Add a unit converter tool and test multi-step reasoning across chained tool calls.
Are there any prerequisites?
You'll need Python installed on your machine, VS Code as your code editor, and a Google account to get a free Gemini API key. Step 1 walks you through verifying your setup and installing everything else.
Before We Start
Before We Start...
Set Up Your Project
Before you can build an AI agent, you need a clean workspace with the right tools installed. Your agent will talk to the Gemini API, manage secrets securely, and call external services.
In this step, you will create an isolated Python project, install three packages, and confirm your connection to the Gemini API works.
In this step, get ready to:
- Create a project folder and virtual environment.
- Install the three project dependencies.
- Get your Gemini API key and verify the connection works.
Create your project folder and virtual environment
Every Python project should live in its own folder with its own virtual environment. This keeps your packages isolated so they do not conflict with other projects on your machine.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder by running this command:
mkdir ai-agent
- Confirm the folder was created by running:
ls
You should see ai-agent listed in the output.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the ai-agent folder, and click Open.
- Open the integrated terminal by pressing Ctrl+` (Windows) or Control+` (macOS).
What is the integrated terminal?
VS Code has a built-in terminal at the bottom of the window. It opens directly inside your project folder, so every command you run targets the right location.
- Create and activate your virtual environment by running the command for your operating system:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
Your terminal prompt should now start with (venv). This confirms the virtual environment is active.
What is a virtual environment?
A virtual environment is an isolated Python installation inside your project folder. Packages you install here stay contained to this project and will not affect any other Python work on your machine.
Don't see (venv) in your prompt?
Make sure you ran the activation command (the second line) for your operating system. On macOS, it starts with source. On Windows, it uses the backslash path venv\Scripts\activate.
If you see a permissions error on Windows, try running VS Code as administrator, or use PowerShell and run Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned first.
Help me activate my virtual environment.
Install your project dependencies
Your agent needs three packages to function. You will install them all in one command.
- Install the three packages by running:
pip install google-genai==2.4.0 python-dotenv==1.2.2 requests==2.34.2
What do these packages do?
- google-genai is the official SDK for communicating with the Gemini API.
- python-dotenv loads secrets from a .env file so you never hardcode API keys in your code.
- requests makes HTTP calls to external APIs like the weather service your agent will use.
- Verify the packages installed correctly by running:
pip list
You should see google-genai, python-dotenv, and requests in the output along with their version numbers.
Seeing an install error?
Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt. If you do not, re-run the activation command from the previous substep.
If you see a network error, check your internet connection and try the pip install command again.
Help me fix my pip install error.
Get your Gemini API key and test the connection
Your agent needs an API key to talk to Gemini. Google AI Studio provides free API keys that work with the Gemini Developer API.
- Open your browser and go to aistudio.google.com.
- Sign in with your Google account.
- Click Get API Key in the left sidebar.
- Click Create API Key.
- Copy the API key that appears. You will only see it once.
Is this free?
The Gemini Developer API has a free tier. The API key creation is free and the usage for this project stays well within the free tier limits.
Now you will create two files: a .env file to store your key and an agent.py file to test the connection.
- In VS Code, click File in the top menu bar.
- Click New File.
- Type .env as the file name and press Enter.
- Paste the following into your .env file, replacing your-api-key-here with the API key you just copied:
GEMINI_API_KEY=your-api-key-here
- Save the file with Cmd+S (macOS) or Ctrl+S (Windows).
Why use a .env file?
A .env file keeps secrets out of your code. You can share your agent.py freely without worrying about leaking your API key. The python-dotenv package loads these values into your environment at runtime.
- Create another new file by clicking File then New File.
- Type agent.py as the file name and press Enter.
- Paste the following code into agent.py:
import os
from dotenv import load_dotenv
from google import genai
# Load environment variables from .env file
load_dotenv()
# Create a client that connects to the Gemini API
client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))
# Send a simple test message to confirm the connection works
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Say hello!",
)
print(response.text)
What does this code do?
- load_dotenv() reads your .env file and makes GEMINI_API_KEY available to your script.
- genai.Client(...) creates a connection to the Gemini API using your key.
- client.models.generate_content(...) sends a prompt to Gemini 2.5 Flash and gets a response.
- print(response.text) displays the model's reply in your terminal.
- Save agent.py with Cmd+S (macOS) or Ctrl+S (Windows).
- Run your test script by running this command in the terminal:
python agent.py
You should see a friendly greeting from Gemini printed in your terminal. The exact wording varies, but any response confirms your API key and connection are working.
Seeing an error instead of a greeting?
If you see ModuleNotFoundError, your virtual environment may not be active. Check for (venv) in your terminal prompt and re-activate if needed.
If you see an authentication or API key error, open your .env file and confirm there are no extra spaces around the key. The format should be GEMINI_API_KEY=sk... with no quotes.
Help me fix my agent.py connection error.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Here is the complete .env file:
GEMINI_API_KEY=your-api-key-here
Here is the complete agent.py file:
import os
from dotenv import load_dotenv
from google import genai
load_dotenv()
client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Say hello!",
)
print(response.text)
Your environment is ready and your agent can already talk to Gemini. Next up, you will build your first tools and teach the agent how to use them.
Build Your First Tools
You confirmed your Gemini connection works. Now it's time to give your agent something useful to do.
AI agents need tools. Without them, a model can only generate text. In this step, you'll write two Python functions that your agent will call on its own: one that fetches live weather data and one that performs math calculations.
In this step, get ready to:
- Write a get_weather function that calls a free weather API.
- Write a calculator function with input validation.
- Create tool declarations that describe your tools to the AI model.
Write the get_weather function
Your agent's first tool will fetch live weather data for any city. You'll use the Open-Meteo API, which is free and requires no API key.
- Select all the code in agent.py and delete it.
- Replace it with these updated imports by pasting the following code:
import os
import json
import requests
from dotenv import load_dotenv
from google import genai
from google.genai import types
load_dotenv()
What changed in the imports?
- json is added for working with structured data later.
- requests lets you make HTTP calls to external APIs (like the weather service).
- types from google.genai provides the data structures Gemini needs for tool calling.
- The test code (client, response, print) is gone because you'll rebuild the interaction properly in Step 3.
- Add the weather tool below load_dotenv() by pasting this function:
# --- Tool implementations (what actually runs on your machine) ---
def get_weather(city: str) -> dict:
"""Get current temperature for a city using Open-Meteo API."""
# Step 1: Convert city name to coordinates using geocoding
geo = requests.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "count": 1},
timeout=10,
).json()
if not geo.get("results"):
return {"error": f"Could not find city: {city}"}
loc = geo["results"][0]
# Step 2: Get the forecast using those coordinates
forecast = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": loc["latitude"],
"longitude": loc["longitude"],
"current_weather": True,
},
timeout=10,
).json()
temp = forecast["current_weather"]["temperature"]
return {"city": city, "temperature_celsius": temp}
What does this code do?
This function teaches several Python patterns at once:
- city: str is a type hint. It tells you (and your editor) that city should be a string.
- -> dict is a return type hint. This function always returns a dictionary.
- requests.get() makes an HTTP GET request to a URL. The params argument adds query parameters to the URL automatically.
- The function calls two APIs in sequence: first the geocoding API to turn a city name into latitude/longitude coordinates, then the forecast API to get the temperature at those coordinates.
- geo.get("results") safely checks if the key exists. If the city isn't found, the function returns an error dictionary instead of crashing.
- Save agent.py.
- Test the function by running this in your terminal:
python -c "from agent import get_weather; print(get_weather('London'))"
You should see a dictionary with the city name and a temperature value, like {'city': 'London', 'temperature_celsius': 18.5}.
Seeing a ModuleNotFoundError?
- Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt.
- If you see an error about requests, re-run pip install requests inside your activated virtual environment.
Help me debug my get_weather function
Write the calculator function
Your second tool lets the agent do math. It uses Python's eval() to evaluate arithmetic expressions, but only after validating that the input contains safe characters.
- Add this function below get_weather in agent.py:
def calculator(expression: str) -> dict:
"""Evaluate a simple arithmetic expression."""
# Only allow numbers and basic math symbols
allowed = set("0123456789+-*/(). ")
if not set(expression) <= allowed:
return {"error": "Expression contains disallowed characters"}
try:
result = eval(expression)
return {"expression": expression, "result": result}
except Exception as e:
return {"error": str(e)}
What does this code do?
- set("0123456789+-*/(). ") creates a set of allowed characters. A set is like a list but only stores unique items and is fast to check membership against.
- set(expression) <= allowed checks whether every character in the input is in the allowed set. The <= operator means "is a subset of" when used with sets.
- The try/except block catches errors gracefully. If eval() fails (e.g. from a malformed expression like 5 + + 3), the function returns an error message instead of crashing your program.
This validation-then-execute pattern appears everywhere in production code. You check input safety first, then perform the operation.
- Save agent.py.
- Test the calculator by running this in your terminal:
python -c "from agent import calculator; print(calculator('15 * 47'))"
You should see {'expression': '15 * 47', 'result': 705} printed in your terminal.
Getting a SyntaxError?
- Check that your quotes match. The outer quotes around the -c argument should be double quotes, and the string inside calculator() should use single quotes.
- On Windows, swap the quote types: use single quotes on the outside and double quotes inside.
Help me fix my calculator function
Create the tool declarations
Your functions work, but your AI model doesn't know they exist yet. You need two things: a registry that maps tool names to functions, and declarations that describe each tool's purpose and parameters in a format the model can read.
- Add the tool registry below your calculator function in agent.py:
# --- Tool registry (maps function names to implementations) ---
TOOL_IMPLEMENTATIONS = {
"get_weather": get_weather,
"calculator": calculator,
}
What is a tool registry?
TOOL_IMPLEMENTATIONS is a dictionary that maps string names to actual Python functions. When the AI model says "call get_weather", your code will look up "get_weather" in this dictionary to find the real function to execute. This is the same plugin pattern used by production AI agents.
- Add the tool declarations below TOOL_IMPLEMENTATIONS:
# --- Tool declarations (what the model sees) ---
TOOL_DECLARATIONS = [
{
"name": "get_weather",
"description": "Get the current temperature for a city in Celsius. Use this when the user asks about weather.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. London or Tokyo",
}
},
"required": ["city"],
},
},
{
"name": "calculator",
"description": "Evaluate a simple arithmetic expression. Use this for any math calculations.",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The arithmetic expression, e.g. '15 * 3.5' or '(10 + 5) / 3'",
}
},
"required": ["expression"],
},
},
]
What are tool declarations?
Tool declarations are JSON schema descriptions that tell the model what tools are available. Each declaration includes a name, a description of when to use it, and the parameters it accepts.
The model reads these descriptions to decide which tool to call and what arguments to pass. The better your descriptions, the more accurately the model picks the right tool.
- Save agent.py.
You won't see a change yet. The declarations are data structures that get used in the next step when you build the agent loop.
Seeing an indentation error?
- Make sure TOOL_IMPLEMENTATIONS and TOOL_DECLARATIONS are at the top level of your file (no indentation), not nested inside a function.
- Check that all opening braces { and brackets [ have matching closing ones. Nested dictionaries are easy to close too early.
Help me fix the tool declarations in my agent.py
✔️ Awesome, I've got everything!
Great. Double check you've saved agent.py and move on to the next step.
ⓧ I'd like to double check the full code
Here is the complete agent.py at this point:
import os
import json
import requests
from dotenv import load_dotenv
from google import genai
from google.genai import types
load_dotenv()
# --- Tool implementations (what actually runs on your machine) ---
def get_weather(city: str) -> dict:
"""Get current temperature for a city using Open-Meteo API."""
geo = requests.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "count": 1},
timeout=10,
).json()
if not geo.get("results"):
return {"error": f"Could not find city: {city}"}
loc = geo["results"][0]
forecast = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": loc["latitude"],
"longitude": loc["longitude"],
"current_weather": True,
},
timeout=10,
).json()
temp = forecast["current_weather"]["temperature"]
return {"city": city, "temperature_celsius": temp}
def calculator(expression: str) -> dict:
"""Evaluate a simple arithmetic expression."""
allowed = set("0123456789+-*/(). ")
if not set(expression) <= allowed:
return {"error": "Expression contains disallowed characters"}
try:
result = eval(expression)
return {"expression": expression, "result": result}
except Exception as e:
return {"error": str(e)}
# --- Tool registry (maps function names to implementations) ---
TOOL_IMPLEMENTATIONS = {
"get_weather": get_weather,
"calculator": calculator,
}
# --- Tool declarations (what the model sees) ---
TOOL_DECLARATIONS = [
{
"name": "get_weather",
"description": "Get the current temperature for a city in Celsius. Use this when the user asks about weather.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. London or Tokyo",
}
},
"required": ["city"],
},
},
{
"name": "calculator",
"description": "Evaluate a simple arithmetic expression. Use this for any math calculations.",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The arithmetic expression, e.g. '15 * 3.5' or '(10 + 5) / 3'",
}
},
"required": ["expression"],
},
},
]
Your tools are built and ready to go. Next up, you'll build the agent loop that connects these tools to Gemini so the model can decide when and how to call them.
Build the Agent Loop
You have two working tools ready to go. Now you need the brain that decides when to use them.
In this step, you will write the core agent loop. This is the piece that sends your question to Gemini, checks whether the model wants to call a tool, executes that tool, sends the result back, and repeats until the model has a final answer.
In this step, get ready to:
- Write the agent loop that orchestrates tool calls.
- Build an interactive CLI to chat with your agent.
- Test multi-step reasoning across sequential tool calls.
Write the run() function
The agent loop is a for loop that keeps going until the model either returns a plain text answer or hits a maximum step limit. Each iteration sends a message, checks for a function call, and feeds the result back in.
- In agent.py, scroll to the bottom of your file (below the TOOL_DECLARATIONS list) and add the run() function by copying the following code:
def run(user_message: str) -> str:
"""Run the agent loop: send message, handle tool calls, return final answer."""
# Initialize the Gemini client with your API key
client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))
# Wrap our tool declarations into a Tool object the SDK understands
tools = types.Tool(function_declarations=TOOL_DECLARATIONS)
# Tell Gemini about our tools but disable automatic calling (we handle it)
config = types.GenerateContentConfig(
tools=[tools],
automatic_function_calling=types.AutomaticFunctionCallingConfig(
disable=True
),
)
# Start the conversation with just the user's message
contents = [user_message]
max_steps = 5
for step in range(max_steps):
# Send the current conversation to Gemini
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=contents,
config=config,
)
candidate = response.candidates[0]
# Add the model's reply to the conversation history
contents.append(candidate.content)
# Check if the model wants to call a function
function_call = candidate.content.parts[0].function_call
if not function_call:
# No function call means the model has a final text answer
return candidate.content.parts[0].text or "[No response]"
# Log which tool the model chose and what arguments it passed
print(f" [Step {step + 1}] Tool call: {function_call.name}({function_call.args})")
# Look up and execute the tool
tool_fn = TOOL_IMPLEMENTATIONS.get(function_call.name)
if tool_fn:
result = tool_fn(**function_call.args)
else:
result = {"error": f"Unknown tool: {function_call.name}"}
print(f" [Step {step + 1}] Result: {result}")
# Send the tool result back to the model for the next iteration
fn_response = types.Part.from_function_response(
name=function_call.name,
response=result,
id=function_call.id,
)
contents.append(types.Content(role="user", parts=[fn_response]))
return "[Agent stopped: maximum steps reached]"
What does this code do?
This function is the heart of your agent. Here is what each section handles:
- client connects to the Gemini API using your key from .env.
- config tells Gemini about your tools but explicitly disables automatic calling. This means the model will ask you to run tools instead of running them itself. You handle the execution so you can learn what is happening.
- contents is a list that acts as the full conversation history. You start with the user message, then append each model reply and each tool result.
- The for loop sends the conversation to Gemini each iteration. If the model responds with a function_call, you execute the matching tool and feed the result back. If there is no function call, the model has its final answer and you return it.
- max_steps = 5 is a safety limit. It prevents infinite loops if the model keeps calling tools without settling on an answer.
- You will not see a change yet. The next chunk adds the interactive loop that actually calls this function.
Wire up the interactive CLI
Python uses if __name__ == "__main__" to mark code that only runs when you execute the file directly. This is where you will create a chat loop that reads your input and prints the agent's response.
- At the very bottom of agent.py, below the run() function, add the main loop:
if __name__ == "__main__":
print("Agent ready! Type 'quit' to exit.\n")
while True:
user_input = input("You: ")
if user_input.lower() in ("quit", "exit"):
print("Goodbye!")
break
print()
answer = run(user_input)
print(f"\nAgent: {answer}\n")
What does this code do?
- if __name__ == "__main__" ensures this block only runs when you execute python agent.py directly. If another file imports your module later, this block is skipped.
- while True creates an infinite loop. The only way out is typing quit or exit, which triggers break to stop the loop.
- input("You: ") pauses the program and waits for you to type something. Whatever you type gets stored in user_input.
- Save agent.py.
- Run your agent by executing this command in your terminal:
python agent.py
- You should see Agent ready! Type 'quit' to exit. printed in your terminal.
- Type the following and press Enter:
What's the weather in London?
- You should see a tool call log like [Step 1] Tool call: get_weather({'city': 'London'}) followed by the temperature result and a natural language answer from the agent.
- Now test the calculator by typing:
What is 15 * 47?
- You should see [Step 1] Tool call: calculator({'expression': '15 * 47'}) followed by the result 705 and the agent's answer.
Not seeing any output?
- Make sure your virtual environment is active. You should see (venv) at the start of your terminal prompt.
- Check that your .env file contains a valid GEMINI_API_KEY and is in the same folder as agent.py.
- If you see a connection error, confirm you have internet access and your API key is correct in Google AI Studio.
Why isn't my agent responding to questions?
Test multi-step reasoning
The real power of an agent loop is that it can chain multiple tool calls automatically. When a question requires two pieces of information, the model calls one tool, reads the result, then calls another.
- With your agent still running, type this question:
What's 3 times the temperature in Paris?
- Watch the output carefully. You should see two tool calls in sequence:
- Step 1: get_weather is called with Paris to fetch the temperature.
- Step 2: calculator is called to multiply the temperature by 3.
You did not write any special logic for this. The loop structure handles it automatically because each iteration checks for a new function call.
Why does multi-step work without extra code?
Each time through the loop, the model sees the entire conversation history. After the first tool result comes back, the model has the temperature. It then decides it needs calculator to finish the job. The loop simply keeps going until there is no more function_call in the response.
This is the exact architecture behind tools like Claude Code and Cursor. A loop, a list of tools, and a model that decides what to call next.
- Type quit to exit the agent when you are done testing.
✔️ Awesome, I've got everything!
Great. Make sure you have saved agent.py with both the run() function and the if __name__ == "__main__" block.
ⓧ I'd like to double check the full code
import os
import json
import requests
from dotenv import load_dotenv
from google import genai
from google.genai import types
load_dotenv()
# --- Tool implementations (what actually runs on your machine) ---
def get_weather(city: str) -> dict:
"""Get current temperature for a city using Open-Meteo API."""
geo = requests.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "count": 1},
timeout=10,
).json()
if not geo.get("results"):
return {"error": f"Could not find city: {city}"}
loc = geo["results"][0]
forecast = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": loc["latitude"],
"longitude": loc["longitude"],
"current_weather": True,
},
timeout=10,
).json()
temp = forecast["current_weather"]["temperature"]
return {"city": city, "temperature_celsius": temp}
def calculator(expression: str) -> dict:
"""Evaluate a simple arithmetic expression."""
allowed = set("0123456789+-*/(). ")
if not set(expression) <= allowed:
return {"error": "Expression contains disallowed characters"}
try:
result = eval(expression)
return {"expression": expression, "result": result}
except Exception as e:
return {"error": str(e)}
# --- Tool registry (maps function names to implementations) ---
TOOL_IMPLEMENTATIONS = {
"get_weather": get_weather,
"calculator": calculator,
}
# --- Tool declarations (what the model sees) ---
TOOL_DECLARATIONS = [
{
"name": "get_weather",
"description": "Get the current temperature for a city in Celsius. Use this when the user asks about weather.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. London or Tokyo",
}
},
"required": ["city"],
},
},
{
"name": "calculator",
"description": "Evaluate a simple arithmetic expression. Use this for any math calculations.",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The arithmetic expression, e.g. '15 * 3.5' or '(10 + 5) / 3'",
}
},
"required": ["expression"],
},
},
]
def run(user_message: str) -> str:
"""Run the agent loop: send message, handle tool calls, return final answer."""
client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))
tools = types.Tool(function_declarations=TOOL_DECLARATIONS)
config = types.GenerateContentConfig(
tools=[tools],
automatic_function_calling=types.AutomaticFunctionCallingConfig(
disable=True
),
)
contents = [user_message]
max_steps = 5
for step in range(max_steps):
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=contents,
config=config,
)
candidate = response.candidates[0]
contents.append(candidate.content)
function_call = candidate.content.parts[0].function_call
if not function_call:
return candidate.content.parts[0].text or "[No response]"
print(f" [Step {step + 1}] Tool call: {function_call.name}({function_call.args})")
tool_fn = TOOL_IMPLEMENTATIONS.get(function_call.name)
if tool_fn:
result = tool_fn(**function_call.args)
else:
result = {"error": f"Unknown tool: {function_call.name}"}
print(f" [Step {step + 1}] Result: {result}")
fn_response = types.Part.from_function_response(
name=function_call.name,
response=result,
id=function_call.id,
)
contents.append(types.Content(role="user", parts=[fn_response]))
return "[Agent stopped: maximum steps reached]"
if __name__ == "__main__":
print("Agent ready! Type 'quit' to exit.\n")
while True:
user_input = input("You: ")
if user_input.lower() in ("quit", "exit"):
print("Goodbye!")
break
print()
answer = run(user_input)
print(f"\nAgent: {answer}\n")
Your agent can now autonomously decide which tools to call and chain them together. Next up, you will wrap everything in a proper class to make it reusable and add error handling so it never crashes.
Wrap It in a Class
Your agent works, but the run() function creates a brand new client, tools, and config on every single call.
In this step, you'll wrap everything into a reusable Agent class. This is the same pattern used by production AI agents like Claude Code and Cursor. A class gives you encapsulation (group related data together), reusability (create multiple agents), and state management (each agent tracks its own settings).
In this step, get ready to:
- Convert the standalone function into an Agent class with instance variables.
- Make the model and step limit configurable via default parameters.
- Add error handling so the agent never crashes.
Convert to an Agent class
A Python class is a blueprint for creating objects. Instead of a loose function that recreates everything on each call, a class stores setup once and reuses it.
- In agent.py, find the def run(user_message: str) -> str: line.
- Select everything from that line down to the end of the file (including the if __name__ block) and delete it.
- In its place, add the class definition and __init__ method by copying the following code:
# --- The Agent class ---
class Agent:
"""A simple AI agent that can use tools to answer questions."""
def __init__(self, model: str = "gemini-2.5-flash", max_steps: int = 5):
self.client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))
self.model = model
self.max_steps = max_steps
self.tools = types.Tool(function_declarations=TOOL_DECLARATIONS)
self.config = types.GenerateContentConfig(
tools=[self.tools],
automatic_function_calling=types.AutomaticFunctionCallingConfig(
disable=True
),
)
What does this code do?
This introduces several Python concepts at once:
- class Agent: defines a new class called Agent. Think of it as a template for creating agent objects.
- __init__ is a special method that runs automatically when you create a new Agent. It sets up everything the agent needs.
- self refers to the specific agent instance being created. Every method in the class takes self as its first argument.
- self.client, self.model, etc. are instance variables. They store data that belongs to this specific agent and persist across method calls.
You won't see a change yet. The next substep explains the configuration options, and then you'll add the run() method that makes it all work.
Add configuration options
Default parameters let you set sensible defaults while still allowing customization. This is the key to making your agent reusable.
What are default parameters?
Look at the __init__ signature you just wrote: model: str = "gemini-2.5-flash", max_steps: int = 5.
The = "gemini-2.5-flash" part means "use this value if the caller doesn't provide one." So Agent() uses all defaults, while Agent(max_steps=10) overrides just the step limit.
This pattern lets you create multiple agents with different settings from the same class. One agent might have a generous step limit for complex questions. Another might be limited to a single step for quick lookups.
Add error handling
Your old run() function would crash entirely if the network went down or a tool threw an unexpected error. Production agents need graceful degradation. They return a useful error message instead of crashing.
- Directly below the __init__ method (still inside the Agent class), add the run() method by copying this code:
def run(self, user_message: str) -> str:
"""Run the agent loop: send message, handle tool calls, return final answer."""
contents = [user_message]
for step in range(self.max_steps):
try:
response = self.client.models.generate_content(
model=self.model,
contents=contents,
config=self.config,
)
except Exception as e:
return f"[Error calling model: {e}]"
candidate = response.candidates[0]
contents.append(candidate.content)
# Check if the model wants to call a function
function_call = candidate.content.parts[0].function_call
if not function_call:
# No function call means the model has a final text answer
return candidate.content.parts[0].text or "[No response]"
# Execute the tool
print(f" [Step {step + 1}] Tool call: {function_call.name}({function_call.args})")
tool_fn = TOOL_IMPLEMENTATIONS.get(function_call.name)
if tool_fn:
try:
result = tool_fn(**function_call.args)
except Exception as e:
result = {"error": str(e)}
else:
result = {"error": f"Unknown tool: {function_call.name}"}
print(f" [Step {step + 1}] Result: {result}")
# Send the tool result back to the model
fn_response = types.Part.from_function_response(
name=function_call.name,
response=result,
id=function_call.id,
)
contents.append(types.Content(role="user", parts=[fn_response]))
return "[Agent stopped: maximum steps reached]"
Where is the error handling?
There are two try/except blocks protecting your agent from crashes:
- The first try/except wraps the API call. If the network drops or the API returns an error, your agent returns a message like [Error calling model: ...] instead of crashing.
- The second try/except wraps tool execution. If a tool function throws an unexpected error, the agent catches it and sends a clean error dict back to the model. The model can then decide what to do next.
This is called graceful degradation. The agent always responds with something useful, even when things go wrong.
What about self.model and self.max_steps?
Notice how the method uses self.model and self.max_steps instead of hardcoded values. These come from the instance variables you set up in __init__. That's why classes are powerful. You configure once, and every method automatically uses those settings.
- Now add the updated main loop below the class (outside the class, at the top level of the file) by copying this code:
# --- Main loop ---
if __name__ == "__main__":
agent = Agent()
print("Agent ready! Type 'quit' to exit.\n")
while True:
user_input = input("You: ")
if user_input.lower() in ("quit", "exit"):
print("Goodbye!")
break
print()
answer = agent.run(user_input)
print(f"\nAgent: {answer}\n")
What changed in the main loop?
- agent = Agent() creates an agent instance using all the default settings. The client, tools, and config are set up once here.
- agent.run(user_input) calls the run() method on that specific agent instance. The method uses all the settings stored during __init__.
- Save agent.py.
- Test your class-based agent by running:
python agent.py
- When you see Agent ready! Type 'quit' to exit., try these questions:
- Type What's the weather in London? and press **Enter**.
- Type What is 15 * 47? and press **Enter**.
- Type What's 3 times the temperature in Paris? and press **Enter**. Watch the agent call weather first, then calculator.
- Type quit to exit.
You should see tool call logs like [Step 1] Tool call: get_weather({...}) followed by the model's final answer. The multi-step question should show two sequential tool calls.
Agent not responding or crashing?
- Check your indentation. The run() method must be indented inside the Agent class (same level as __init__).
- Make sure the if __name__ block is at the top level (no indentation), not inside the class.
- Verify you deleted the entire old run() function. Having both the old function and the new class will cause confusion.
Help me debug my Agent class
✔️ Awesome, I've got everything!
Great. Double check you've saved agent.py and confirmed the agent responds to both single and multi-step questions.
ⓧ I'd like to double check the full code
Here's the complete agent.py file as it should look now:
import os
import json
import requests
from dotenv import load_dotenv
from google import genai
from google.genai import types
load_dotenv()
# --- Tool implementations (what actually runs on your machine) ---
def get_weather(city: str) -> dict:
"""Get current temperature for a city using Open-Meteo API."""
geo = requests.get(
"https://geocoding-api.open-meteo.com/v1/search",
params={"name": city, "count": 1},
timeout=10,
).json()
if not geo.get("results"):
return {"error": f"Could not find city: {city}"}
loc = geo["results"][0]
forecast = requests.get(
"https://api.open-meteo.com/v1/forecast",
params={
"latitude": loc["latitude"],
"longitude": loc["longitude"],
"current_weather": True,
},
timeout=10,
).json()
temp = forecast["current_weather"]["temperature"]
return {"city": city, "temperature_celsius": temp}
def calculator(expression: str) -> dict:
"""Evaluate a simple arithmetic expression."""
allowed = set("0123456789+-*/(). ")
if not set(expression) <= allowed:
return {"error": "Expression contains disallowed characters"}
try:
result = eval(expression)
return {"expression": expression, "result": result}
except Exception as e:
return {"error": str(e)}
# --- Tool registry (maps function names to implementations) ---
TOOL_IMPLEMENTATIONS = {
"get_weather": get_weather,
"calculator": calculator,
}
# --- Tool declarations (what the model sees) ---
TOOL_DECLARATIONS = [
{
"name": "get_weather",
"description": "Get the current temperature for a city in Celsius. Use this when the user asks about weather.",
"parameters": {
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "The city name, e.g. London or Tokyo",
}
},
"required": ["city"],
},
},
{
"name": "calculator",
"description": "Evaluate a simple arithmetic expression. Use this for any math calculations.",
"parameters": {
"type": "object",
"properties": {
"expression": {
"type": "string",
"description": "The arithmetic expression, e.g. '15 * 3.5' or '(10 + 5) / 3'",
}
},
"required": ["expression"],
},
},
]
# --- The Agent class ---
class Agent:
"""A simple AI agent that can use tools to answer questions."""
def __init__(self, model: str = "gemini-2.5-flash", max_steps: int = 5):
self.client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))
self.model = model
self.max_steps = max_steps
self.tools = types.Tool(function_declarations=TOOL_DECLARATIONS)
self.config = types.GenerateContentConfig(
tools=[self.tools],
automatic_function_calling=types.AutomaticFunctionCallingConfig(
disable=True
),
)
def run(self, user_message: str) -> str:
"""Run the agent loop: send message, handle tool calls, return final answer."""
contents = [user_message]
for step in range(self.max_steps):
try:
response = self.client.models.generate_content(
model=self.model,
contents=contents,
config=self.config,
)
except Exception as e:
return f"[Error calling model: {e}]"
candidate = response.candidates[0]
contents.append(candidate.content)
# Check if the model wants to call a function
function_call = candidate.content.parts[0].function_call
if not function_call:
# No function call means the model has a final text answer
return candidate.content.parts[0].text or "[No response]"
# Execute the tool
print(f" [Step {step + 1}] Tool call: {function_call.name}({function_call.args})")
tool_fn = TOOL_IMPLEMENTATIONS.get(function_call.name)
if tool_fn:
try:
result = tool_fn(**function_call.args)
except Exception as e:
result = {"error": str(e)}
else:
result = {"error": f"Unknown tool: {function_call.name}"}
print(f" [Step {step + 1}] Result: {result}")
# Send the tool result back to the model
fn_response = types.Part.from_function_response(
name=function_call.name,
response=result,
id=function_call.id,
)
contents.append(types.Content(role="user", parts=[fn_response]))
return "[Agent stopped: maximum steps reached]"
# --- Main loop ---
if __name__ == "__main__":
agent = Agent()
print("Agent ready! Type 'quit' to exit.\n")
while True:
user_input = input("You: ")
if user_input.lower() in ("quit", "exit"):
print("Goodbye!")
break
print()
answer = agent.run(user_input)
print(f"\nAgent: {answer}\n")
Your agent is now a proper Python class with configurable settings and robust error handling. This is production-quality architecture. Next up, you'll extend it with new tools in the Secret Mission.
Secret mission
Multi-Step Tool Chaining
Your agent can call one tool at a time. But what about questions that need two tools in sequence? Add a unit converter and watch your agent chain tools together automatically.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally and uses the free tier of the Gemini API, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- ai-agent project folder (includes virtual environment, .env, and agent.py).
- Gemini API key in Google AI Studio.
Keep everything running
No action needed. Choose this if you're still actively building or want to experiment with adding more tools to your agent.
- Your ai-agent folder stays on your Desktop with all files intact.
- Your Gemini API key remains active in Google AI Studio.
- The virtual environment stays ready to reactivate whenever you return.
Pause - I'll come back to this later
Deactivate your virtual environment to free up your terminal session. Your files and API key stay in place for when you return.
- Deactivate the virtual environment by running:
deactivate
- Your project folder and API key remain untouched. When you come back, reactivate the environment with venv\Scripts\activate (Windows) or source venv/bin/activate (macOS/Linux).
Delete - I don't want to use this again
Remove all project resources and revoke your API key. This is permanent.
- Deactivate your virtual environment first:
deactivate
- Move out of the project folder and delete it by running:
cd ~/Desktop
rm -rf ai-agent
On Windows, use this instead:
cd ~\Desktop
rmdir /s /q ai-agent
- Confirm the folder is gone by running:
ls
- You should no longer see ai-agent in the output.
- Delete your Gemini API key from Google AI Studio:
- Go to aistudio.google.com.
- Open the API Keys page.
- Find the key you created for this project and click the trash icon to delete it.
Nice Work!
Nice Work!
You just built a tool-calling AI agent from scratch. Every line of code was yours, from the tool functions to the agent loop to the class that ties it all together.
You've learned how to:
- Build Python functions with type hints, write classes with __init__ and methods, and use error handling to make your code resilient.
- Implement the tool-calling pattern used by production AI agents, including a while loop that sends messages, executes function calls, and returns results back to the model.
- Connect to external APIs with the requests library, structure tool declarations as nested dictionaries, and manage secrets with environment variables and a .env file.
- Add a unit converter tool and prove that multi-step tool chaining works automatically with the plugin architecture pattern.
Ready to quiz yourself?