In this post we will see what an AI Agent is, how to build one in Python using LangChain and GPT-4o, and how to test it with real natural language inputs.
First of all, how can we define an AI Agent in Python?
“An AI Agent is a Python program that uses a Large Language Model as its reasoning engine, combined with the ability to take real actions calling APIs, querying databases, reading files, parsing free text iterating in a loop until a goal is reached.“
What we will build
For this post, we will build a Weather Agent that accepts free form natural language questions, extracts a city and a date from any phrasing, calls a real weather API, and returns a personalised answer, or a clear alert if the input is incomplete.
What we will cover
[Environment setup]: How to create a Python virtual environment and why we should always use one.
[Libraries]: What each dependency does and how to install them correctly.
[Code]: A full walkthrough of the agent split into 4 blocks.
[Testing]: A set of real inputs to validate every scenario.
Libraries we will use
Here is a quick overview of the stack before we install anything:
[python-dotenv]: Loads the OPENAI_API_KEY from a .env file into the environment safely.
[langchain + langchain-openai]: Manages the reasoning loop, tool dispatch, and prompt construction.
[openai]: The official OpenAI SDK, used by LangChain under the hood to communicate with GPT-4o.
[requests]: HTTP client used inside the tool to call the OpenMeteo weather API (free, no key required).
[geopy]: Converts a city name into GPS coordinates so Open-Meteo can return the correct forecast.
What is LangChain and why are we using it?
LangChain is an open source Python framework designed to simplify the development of applications powered by Large Language Models.
It was created in 2022 and quickly became the most widely adopted library in the AI engineering space.
Without LangChain, building an agent means writing all the plumbing yourself: the reasoning loop, tool call parsing, observation injection, conversation history management, and error handling.
This is perfectly doable and understanding those primitives is valuable but, it takes around 150 lines of boilerplate just to get the loop running.
[ENVIRONMENT SETUP]
[Step 1]: Create the project folder using the command
mkdir AI_Agent
[Step 2]: Create and activate the virtual environment.
A virtual environment isolates our project’s dependencies from the system Python.
Always use one, it prevents version conflicts between projects.
In order to create the virtual environment, we have to use the command:
python -m venv .venv
Instead, to activate it, we can use the command:
source .venv/bin/activate
Once activated our terminal prompt will show (.venv). This confirms we are inside the isolated environment. Always check this before running the project.

[Step 3]: Create the .env file
The .env file stores our OpenAI API key securely, keeping it out of our source code and git repository. Create it in the project root:
OPENAI_API_KEY = sk-proj-##############################

[LIBRARIES]
pip install python-dotenv

pip install langchain langchain-openai

pip install openai

pip install requests

pip install geopy

[CODE]
The complete agent is a single file called “main.py”, with around 200 lines of code. Rather than dumping the entire file at once and explaining it at the end, I decided to split it into 4 logical blocks. Each block has a specific responsibility, and I will explain what it does and why before showing the code.
Block 1 – Imports & API key
Load dependencies and validate the key at startup.
import os
import requests
from geopy.geocoders import Nominatim
from dotenv import load_dotenv
from datetime import datetime
from langchain_openai import ChatOpenAI
from langchain.agents import create_openai_functions_agent
from langchain.agents import AgentExecutor
from langchain_core.tools import tool
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
import re
from datetime import datetime, date
# Reads the .env file and injects its contents into os.environ
load_dotenv()
# Retrieves the key explicitly.
# If the key is missing, the app crashes immediately with a clear message rather than failing silently later.
openai_api_key = os.getenv("OPENAI_API_KEY")
if not openai_api_key:
raise ValueError("OPENAI_API_KEY not found. Please add it to your .env file.")
Block 2 – The tool: get_weather_forecast()
The @tool decorator registers this function with LangChain. It reads the docstring and converts it into a JSON Schema description that is sent to GPT-4o on every request. This is how the LLM knows when to call the function and what arguments to pass: the docstring is not documentation, it is the contract between the LLM and our code.
# The @tool decorator tells LangChain to use this function's metadata
# (name, docstring, and arguments) as a tool schema for the LLM.
@tool
def get_weather_forecast(city: str, date_str: str) -> dict:
"""
Fetches the daily weather forecast for a given city and date.
Only call this tool when BOTH city and date are clearly identified
from the user's message. The date must be in ISO format YYYY-MM-DD.
Args:
city: The city name extracted from the user's message.
date_str: The date in ISO format YYYY-MM-DD, converted from
whatever format the user provided (dd/MM/yyyy, etc.)
Returns:
A dict with temperature, precipitation, wind speed, and a
human-readable weather description.
"""
# Initialize the OpenStreetMap Nominatim geocoder to convert text to coordinates
geocoder = Nominatim(user_agent="weather_agent_nlp_v1")
location = geocoder.geocode(city)
# Validate that the geocoding service actually found the requested city
if location is None:
return {"error": f"Could not find the city '{city}'. Please check the spelling."}
lat = location.latitude
lon = location.longitude
# Enforce strict date formatting to prevent downstream API errors
try:
datetime.strptime(date_str, "%Y-%m-%d")
except ValueError:
return {"error": f"Invalid date format received: '{date_str}'."}
# Prepare parameters for the Open-Meteo API
# We use start_date and end_date as the same value to get data for a specific day
params = {
"latitude": lat,
"longitude": lon,
"daily": [
"temperature_2m_max", "temperature_2m_min",
"precipitation_sum", "windspeed_10m_max", "weathercode",
],
"timezone": "auto",
"start_date": date_str,
"end_date": date_str,
}
# Fetch the forecast data; timeout=10 ensures the agent doesn't hang indefinitely
response = requests.get(
"https://api.open-meteo.com/v1/forecast",
params=params, timeout=10
)
response.raise_for_status()
daily = response.json()["daily"]
# Mapping of WMO (World Meteorological Organization) weather codes to human-readable text
WMO_CODES = {
0: "Clear sky", 1: "Mainly clear", 2: "Partly cloudy", 3: "Overcast",
45: "Foggy", 48: "Icy fog",
51: "Light drizzle", 61: "Slight rain", 63: "Moderate rain", 65: "Heavy rain",
71: "Slight snow", 73: "Moderate snow", 75: "Heavy snow",
80: "Slight showers", 81: "Moderate showers", 82: "Violent showers",
95: "Thunderstorm", 99: "Thunderstorm with hail",
}
# Extract the weather code for the first (and only) day in the result set
code = daily["weathercode"][0]
# Return a structured dictionary for the LLM to interpret and explain to the user
return {
"city": city,
"date": date_str,
"description": WMO_CODES.get(code, f"Unknown (WMO {code})"),
"temp_max_c": daily["temperature_2m_max"][0],
"temp_min_c": daily["temperature_2m_min"][0],
"precipitation_mm": daily["precipitation_sum"][0],
"wind_max_kmh": daily["windspeed_10m_max"][0],
}
Block 3 – Agent builder: build_agent()
This function wires everything together. The system prompt is the most important part and it defines the three-step job the LLM must perform.
The validation logic (what to do when city or date is missing) lives entirely here, in natural language instructions, not in Python code.
# Builds and returns the AgentExecutor — the LangChain object that
# manages the full ReAct loop: it wires together the LLM, the prompt,
# and the tools, and exposes a single .invoke() method to run the agent.
def build_agent() -> AgentExecutor:
# Initialize the "Brain": Using GPT-4o with temperature 0 for consistent tool calling.
llm = ChatOpenAI(model="gpt-4o", temperature=0, openai_api_key=openai_api_key)
# Define the "Instruction Manual": This prompt guides the LLM through the reasoning cycle.
prompt = ChatPromptTemplate.from_messages([
("system", """You are a weather assistant that understands natural language.
STEP 1 - EXTRACT: identify the CITY and the DATE from the user message.
The date can be in any of these formats:
- dd/MM/yyyy (e.g. 08/04/2026)
- MM/dd/yyyy (e.g. 04/08/2026)
- written out (e.g. "8th April 2026", "April 8 2026")
- relative (e.g. "tomorrow", "next Monday")
If a date is present in ANY of these formats, extract it. Do NOT say the date is missing.
STEP 2 - VALIDATE:
- City missing -> respond: "I could not identify a city. Could you specify where?"
- Date missing -> respond: "I could not identify a date. Could you specify when?"
- Both missing -> ask for both.
- Both present -> convert date to YYYY-MM-DD and call the tool.
STEP 3 - RESPOND: answer the user's ACTUAL question using the forecast data.
"Do I need a jacket?" -> focus on temperature and wind.
"Will it rain?" -> focus on precipitation.
"What will it be like?" -> give a full summary.
Always be concise and practical. Today's date is {today}."""),
# User's input (e.g., "What's the weather like in Paris?")
("human", "{input}"),
# Scratchpad stores intermediate steps (thoughts and tool results)
MessagesPlaceholder(variable_name="agent_scratchpad"),
])
# List of functions available for the LLM to call
tools = [get_weather_forecast]
# create_openai_functions_agent uses OpenAI's specific API for tool calling
agent = create_openai_functions_agent(llm, tools, prompt)
# AgentExecutor handles the execution loop and verbose logging
return AgentExecutor(agent=agent, tools=tools, verbose=True)
Block 4 – Runner & entry point
# Build the agent once at startup and reused it for every question.
agent_executor = build_agent()
# Entry point for a single question.
# Injects today's date into the prompt so the LLM can handle
# relative expressions like "tomorrow", then invokes the agent
# and returns its natural language response.
def ask_weather(user_input: str) -> str:
today = datetime.today().strftime("%d/%m/%Y")
# Convert dd/MM/yyyy to "8 April 2026" before sending to the LLM.
# This removes date format ambiguity entirely the LLM always
# receives a clear spelled-out date and never misreads the format.
match = re.search(r'(\d{1,2})/(\d{1,2})/(\d{4})', user_input)
if match:
try:
day, month, year = match.groups()
month_name = date(int(year), int(month), int(day)).strftime("%B")
user_input = user_input.replace(
match.group(0),
f"{int(day)} {month_name} {year}"
)
except ValueError:
pass
response = agent_executor.invoke({
"input": user_input,
"today": today,
})
return response["output"]
if __name__ == "__main__":
print("Type your question and press Enter. Type 'quit' to exit.\n")
while True:
user_input = input("Ask: ").strip()
if user_input.lower() in ("quit", "exit", "q"):
print("Goodbye!")
break
if not user_input:
continue
print("-" * 60)
result = ask_weather(user_input)
print(f"Agent: {result}")
print("-" * 60 + "\n")
[TESTING]
“what will the weather be like in Rome on 08/04/2026?”

“I will be in Paris on 21/04/2026. Do I need a jacket?”

“New York on 08/04/2026. What should I wear?”

City and date both missing

Date missing

City missing

In this post we have seen what an AI Agent is, how to create one in Python using LangChain and GPT-4o, and how to test it with real natural language inputs.
The architecture scales easily: adding a new capability means simply adding a new @tool function, without touching anything else.