Build Your First AI Agent in Python (Tool-Calling, Step by Step)
Versions used in this post — read this first: Python 3.12 · google-genai 1.35.0 · model gemini-2.5-flash · No framework, no LangChain. Everything below runs from one file. If a newer SDK changes a method name, the loop in Step 4 stays the same — only the call syntax moves.
The direct answer
An AI agent is not a special kind of model. It is a normal program that does three things in a loop: it sends your question and a list of tools to a model, it runs whichever tool the model asks for, and it sends the tool result back so the model can answer — or ask for another tool. Execution stays with your program from start to finish — the model only ever asks.
This post builds that loop by hand, on purpose. Frameworks hide the loop, and the loop is the part you need when something breaks in production.
What you will build
A small command-line agent with two tools:
- Weather lookup — gets the current temperature and wind for a city from the free Open-Meteo service, which needs no API key.
- Calculator — evaluates arithmetic safely, without using Python's
eval().
You can ask it a question that needs both, such as: "Is it warmer in Ottawa or Toronto right now, and what is the difference times two?" The agent should call the weather tool twice, call the calculator once, and then answer in plain language.
Step 1 — Install and set your key
Create a folder, then install the single dependency:
pip install google-genai==1.35.0
Get a Gemini API key from Google AI Studio and set it as an environment variable. Never paste a key into source code that you might commit.
How to get your free Gemini API key (about 2 minutes, no card required):
- Open Google AI Studio and sign in with your Google account. Accept the terms if it is your first visit.
- Click Get API key (in the left sidebar, or on the dashboard).
- Click Create API key, then choose Create API key in new project — the simplest option for a first key.
- Google generates the key and shows it to you. Click Copy. A Gemini key starts with "AIza".
- Treat the key like a password: anyone who has it can use your quota. Never post it publicly, never commit it to GitHub, and if it ever leaks, delete it on the same page and create a fresh one.
- Now set it as the
GEMINI_API_KEYenvironment variable with the commands below — the code reads it from there, so the key never lives in your source.
The free tier is enough for everything in this post. You only need billing if you outgrow the free rate limits — and the 429 error in the errors section below is how you will know.
# macOS / Linux
export GEMINI_API_KEY="your-key-here"
# Windows PowerShell
$env:GEMINI_API_KEY="your-key-here"
Step 2 — Write the two tools
A tool is just a Python function with a clear name, typed arguments, and a short docstring. The docstring matters more than usual here: the model reads it to decide when the tool applies, so write it for the model, not only for humans.
import ast
import json
import operator
import urllib.parse
import urllib.request
def get_weather(city: str) -> dict:
"""Get the current weather for a city.
Args:
city: City name, for example "Ottawa" or "Toronto".
Returns:
Temperature in Celsius, wind speed, and a condition code.
"""
query = urllib.parse.quote(city)
geo_url = f"https://geocoding-api.open-meteo.com/v1/search?name={query}&count=1"
with urllib.request.urlopen(geo_url, timeout=10) as response:
places = json.load(response).get("results")
if not places:
return {"error": f"No location found for {city}"}
place = places[0]
weather_url = (
"https://api.open-meteo.com/v1/forecast"
f"?latitude={place['latitude']}&longitude={place['longitude']}"
"¤t=temperature_2m,wind_speed_10m,weather_code"
)
with urllib.request.urlopen(weather_url, timeout=10) as response:
current = json.load(response)["current"]
return {
"city": place["name"],
"temperature_c": current["temperature_2m"],
"wind_kmh": current["wind_speed_10m"],
"weather_code": current["weather_code"],
}
_OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.USub: operator.neg,
}
def _eval_node(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in _OPERATORS:
return _OPERATORS[type(node.op)](_eval_node(node.left), _eval_node(node.right))
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPERATORS:
return _OPERATORS[type(node.op)](_eval_node(node.operand))
raise ValueError("Expression contains something the calculator does not allow")
def calculate(expression: str) -> dict:
"""Calculate a basic arithmetic expression.
Args:
expression: Arithmetic only, for example "18.5 - 16.0" or "(4 + 6) * 2".
Returns:
The numeric result, or an error message if the expression is not allowed.
"""
try:
tree = ast.parse(expression, mode="eval")
return {"expression": expression, "result": _eval_node(tree.body)}
except Exception as exc:
return {"error": f"Could not calculate '{expression}': {exc}"}
Notice the calculator's design: it parses the expression into a tree and allows only numbers and five operators. Passing a model's output into eval() would let a bad prompt become arbitrary code execution. Treat every tool argument as untrusted input, because it is.
Step 3 — Write the agent loop
This is the whole agent. Automatic function calling is switched off deliberately, so you can watch each round trip instead of having the SDK hide it.
import os
from google import genai
from google.genai import types
TOOLS = {"get_weather": get_weather, "calculate": calculate}
MAX_STEPS = 6
def run_agent(question: str) -> str:
client = genai.Client(api_key=os.environ["GEMINI_API_KEY"])
config = types.GenerateContentConfig(
tools=list(TOOLS.values()),
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
system_instruction=(
"Use the tools when you need live weather or arithmetic. "
"Do not guess temperatures or do maths in your head."
),
)
contents = [question]
for step in range(1, MAX_STEPS + 1):
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=contents,
config=config,
)
if not response.function_calls:
return response.text
contents.append(response.candidates[0].content)
for call in response.function_calls:
tool = TOOLS.get(call.name)
if tool is None:
result = {"error": f"Unknown tool: {call.name}"}
else:
result = tool(**call.args)
print(f" step {step}: {call.name}({call.args}) -> {result}")
contents.append(
types.Part.from_function_response(name=call.name, response=result)
)
return "Stopped: the agent used all of its steps without finishing."
if __name__ == "__main__":
answer = run_agent(
"Is it warmer in Ottawa or Toronto right now, "
"and what is the temperature difference times two?"
)
print("\nAnswer:", answer)
Step 4 — How the loop works, round by round
- You send the question plus the tool list. The model sees the function names, arguments, and docstrings — not the function bodies.
- The model replies with a request, not an answer. For our question, the first reply asks for
get_weatherfor Ottawa and Toronto. Nothing has run yet. - Your code validates and runs the tools. This is your security boundary. Check the tool name, check the arguments, and only then execute.
- You send the results back. The results become new entries in
contents, so the model can reason over real numbers instead of inventing them. - The loop repeats or ends. The model may ask for the calculator next. When a reply contains no function call, that reply is the final answer and the loop stops.
- A step cap protects you.
MAX_STEPS = 6guarantees the program ends. An agent without a cap can loop — and bill you — for a very long time.
How to run it and what you should see
Save both parts in one file named first_agent.py, then run:
python first_agent.py
The exact temperatures will differ every run, but the shape should look like this:
step 1: get_weather({'city': 'Ottawa'}) -> {'city': 'Ottawa', 'temperature_c': 9.4,...}
step 1: get_weather({'city': 'Toronto'}) -> {'city': 'Toronto', 'temperature_c': 12.1,...}
step 2: calculate({'expression': '(12.1 - 9.4) * 2'}) -> {'result': 5.4}
Answer: Toronto is warmer right now, at 12.1°C compared with 9.4°C in Ottawa — a difference of 2.7 degrees, which doubled is 5.4.
If the printed tool calls are missing, the model answered from memory instead of using the tools. Strengthen the system instruction rather than adding more code — wording is a real control surface here.
Errors you may hit
KeyError: 'GEMINI_API_KEY' or a 400 API key error. The variable is not set in the same terminal session that runs the script, or the key was copied with a trailing space. Check only whether the variable exists — never print the key — and restart the terminal after setting it system-wide.
The model asks for a tool that does not exist. Models occasionally invent a plausible function name. That is why the loop looks the name up in TOOLS and returns an error result instead of crashing. The model usually corrects itself on the next step.
Malformed or missing arguments. A call may arrive with a city missing or an expression that is not arithmetic. Both tools above return an {"error":...} dictionary rather than raising, so the agent gets a chance to recover and the conversation does not die on one bad call.
429 rate-limit or quota errors. Free-tier keys have per-minute and per-day limits, and a loop multiplies requests: one question here costs three or four model calls. Add a short pause and retry on 429, lower MAX_STEPS, and expect this error during busy testing rather than treating it as a code bug.
503 or a temporary model error. Transient failures happen on hosted models. Retry a few times with increasing waits before changing your code — and log which step failed, so you know whether the failure was the model call or your own tool.
What does one run actually cost?
Honest answer: very little — but not zero, and the loop is why. A two-city question typically uses roughly 1,000–1,500 input tokens and 150–250 output tokens across all its model calls. At the published gemini-2.5-flash paid rates of US$0.30 per million input tokens and US$2.50 per million output tokens, that is well under a tenth of a cent per question. On the free tier, the same run counts against your daily quota instead.
Do not trust my estimate for your own app. Every response carries usage data, so build the habit on day one of logging response.usage_metadata for each step. Cost surprises almost never come from one expensive call. They come from a loop that quietly ran forty cheap ones.
Where to go next
Add one tool that touches your own world — reading a folder of notes, querying a small database, or checking a calendar — and keep the same discipline: typed arguments, errors returned as data, a hard step cap, and a log line for every call. That pattern, not any framework, carries from this script to larger agent systems, including the Gemini integration behind my own AI Portfolio Manager app.
Next in this series: MCP servers — the standard way to package tools like these so more than one agent can use them.
Comments
Post a Comment