Build an AI Agent from Scratch
Build a Python AI agent with tool use and deploy it as a Flask API.
Introduction
30 Second Summary
Every freelancer knows the chaos of juggling clients, deadlines, and invoices across scattered spreadsheets and sticky notes. What if you could ask a single assistant to handle all of that for you in plain English?
In this project, you will build an AI agent from scratch using Python and Google's Gemini API. Your agent will manage clients, track deadlines, log hours, and calculate invoices by choosing the right tool for each question you ask.
What You'll Build
A Python-powered AI agent that accepts natural language questions, reasons about which tool to use, and returns structured answers through a Flask API.
By the end of this project, you'll have:
- A working AI agent that reasons about which tool to use and executes it automatically.
- A deployed Flask API endpoint that accepts natural language questions and returns agent-powered answers.
- A clear mental model of the "LLM + tools + loop" pattern that underpins every agent framework.
- Secret Mission: Add a multi-step "weekly report" tool where the agent chains multiple tools together to produce a full weekly summary.
Are there any prerequisites?
You should be comfortable with Python basics (functions, loops, and dictionaries) and the command line. No prior experience with AI, APIs, or Flask is needed.
Before We Start
Every AI agent framework you see today is built on the same core pattern: an LLM that can call tools, wrapped in a loop that keeps running until the question is answered.
Before building the agent itself, you need your environment ready. In this step, you'll install your dependencies, grab a free API key for Google Gemini, set up your project folder, and confirm everything works with a test API call.
In this step, get ready to:
- Install the google-genai SDK and Flask.
- Get a free Gemini API key from Google AI Studio.
- Create your project folder structure.
- Verify your setup with a test API call.
Create your project folder and install dependencies
You need two Python packages for this project: google-genai (the SDK that talks to Gemini) and Flask (a lightweight web framework for your API).
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` (Windows) or Cmd+` (macOS).
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir freelancer-agent
cd freelancer-agent
- Install the google-genai SDK and Flask by running:
pip install google-genai Flask
What does this command do?
This installs two packages from PyPI. google-genai is Google's official SDK for calling the Gemini API. Flask is a web framework you'll use later to expose your agent as an API endpoint.
- Wait for the installation to finish. You should see Successfully installed followed by a list of packages near the end of the output.
Seeing a permission error?
Try running pip install --user google-genai Flask instead. If you see a message about pip not being found, make sure Python is installed and available on your system PATH.
Help me fix my pip installation error.
Get a free Gemini API key from Google AI Studio
The Gemini API needs an API key to authenticate your requests. Google AI Studio lets you generate one for free.
- Open https://ai.google.dev/aistudio in your browser.
- Sign in with your Google account if prompted.
- Click Get API key in the left navigation panel.
- Click Create API key.
- Copy the generated key. You will need it in the next step.
Keep your API key safe
Do not share your API key publicly or commit it to version control. You'll store it as an environment variable so it stays out of your code.
Now set the key as an environment variable in your terminal so your Python code can access it.
macOS
- Set your API key by running this command (replace the placeholder with your actual key):
export GEMINI_API_KEY=your-api-key-here
Windows
- Set your API key by running this command (replace the placeholder with your actual key):
$env:GEMINI_API_KEY="your-api-key-here"
Why an environment variable?
Storing your key in an environment variable keeps it out of your source code. Your Python scripts will read it at runtime using os.environ.get("GEMINI_API_KEY").
This variable only lasts for your current terminal session. If you close the terminal and reopen it, you will need to set it again.
Create the folder structure and test your setup
Your project needs a data/ folder to hold the simulated freelancer database files you'll create in the next step.
- Create the data folder by running:
mkdir data
- Confirm the folder exists by running:
ls
- You should see data listed in the output.
Now verify that your API key and the SDK are working together. You'll write a quick test script that sends a message to Gemini.
- Create a file called test_setup.py inside your freelancer-agent folder by running:
touch test_setup.py
- Open the freelancer-agent folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the freelancer-agent folder on your Desktop, then click Open.
- In the Explorer sidebar on the left, click test_setup.py to open it.
- Paste the following code into test_setup.py:
import os
from google import genai
# Create a client using your API key from the environment variable
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
# Send a test message to Gemini
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Say hello in one sentence.",
)
# Print the response to confirm everything works
print(response.text)
What does this code do?
- genai.Client() creates a connection to the Gemini API using your key.
- generate_content() sends a prompt to the gemini-2.5-flash model and returns its response.
- response.text contains the model's text output.
- Save test_setup.py by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Open the terminal again with Ctrl+` (Windows) or Cmd+` (macOS).
- Run the test script by entering:
python test_setup.py
- You should see a short greeting from Gemini printed in your terminal. The exact wording will vary each time.
Seeing an error instead of a greeting?
- If you see a message about an invalid API key, double check that you copied the full key from Google AI Studio and that your export or $env: command used the correct value.
- If you see a module not found error, confirm you installed google-genai (not the deprecated google-generativeai package).
- If you closed and reopened your terminal, you need to set GEMINI_API_KEY again because environment variables only last for the current session.
Help me debug my Gemini API test script error.
Your environment is ready and Gemini is responding to your requests. Next up, you'll build the freelancer database that your agent's tools will read and write.
Set Up Your Environment
Your AI agent needs a clean workspace before you start writing code. The agent will read data from JSON files and run Python modules side by side, so getting the folder structure right now saves confusion later.
In this step, you'll organize your project folder with the directories the agent needs. Your SDK, API key, and test call are already working. Now it's time to lay out the project structure.
In this step, get ready to:
- Create the project folder structure with a data/ directory.
- Confirm your environment is ready to build the agent.
Create the project folder structure
Your agent will read client data, project info, and time logs from JSON files stored in a dedicated data/ directory. Creating this directory now keeps your data files separate from your Python code.
- In your terminal (inside your project folder), create the data/ directory by running this command:
mkdir data
- Confirm the directory was created by running:
ls
You should see data listed in the output.
Why a separate data directory?
Keeping your JSON data files in data/ separates your agent's "knowledge" from its logic. This mirrors real-world projects where data sources (databases, APIs, files) live apart from the application code that reads them.
Your project folder should now look like this:
freelancer-agent/
data/
What goes where?
The data/ folder will hold JSON files representing your freelancer database (clients, projects, time logs).
Your Python files (tools.py, agent.py, app.py) will live in the project root alongside the data/ folder.
Your workspace is organized and ready to go. Next up, you'll populate that data/ directory with the freelancer database your agent will use to answer questions.
Build the Freelancer Database
Your environment is ready and your API key is confirmed working. Now it's time to give your agent something to work with.
Every AI agent needs access to data it can query and act on. In this step, you'll create a simulated freelancer database using JSON files. These files represent clients, projects, and time logs that your agent will read and update later.
In this step, get ready to:
- Create a client records file with contact details and billing rates.
- Create a projects file with deadlines and status tracking.
- Create a time log file tracking hours worked per project.
Create the clients data
The clients file stores information about each freelancer client. This includes their name, contact person, email, and hourly rate.
- In VS Code, right-click the data folder in the Explorer sidebar.
- Select New File.
- Name the file clients.json and press Enter.
- Paste the following JSON into the file:
[
{
"id": "client-001",
"name": "Acme Corp",
"contact": "Sarah Johnson",
"email": "sarah@acmecorp.com",
"rate_per_hour": 85,
"currency": "USD"
},
{
"id": "client-002",
"name": "TechStart Inc",
"contact": "Raj Patel",
"email": "raj@techstart.io",
"rate_per_hour": 100,
"currency": "USD"
},
{
"id": "client-003",
"name": "GreenLeaf Design",
"contact": "Maria Santos",
"email": "maria@greenleaf.design",
"rate_per_hour": 75,
"currency": "USD"
}
]
What does this data represent?
Each client object has a unique id that other files use to link data together. The rate_per_hour field is what the agent uses later to calculate invoices.
Think of this as a contacts database your freelancer assistant can look up anytime a question mentions a client by name.
- Save clients.json with Cmd+S (macOS) or Ctrl+S (Windows).
- You should see clients.json appear under the data folder in the Explorer sidebar.
File not showing up under data/?
Make sure you right-clicked the data folder itself before selecting New File. If you right-clicked the root folder instead, the file lands in the wrong location. Drag it into data/ or delete it and try again.
Also confirm the file is named exactly clients.json with no extra spaces or capital letters.
Help me figure out why my JSON file isn't in the right folder.
Create the projects data
The projects file tracks what work is in progress for each client. Each project has a deadline and an estimated number of hours to complete.
- Right-click the data folder again and select New File.
- Name it projects.json and press Enter.
- Paste the following JSON into the file:
[
{
"id": "proj-001",
"client_id": "client-001",
"name": "Website Redesign",
"deadline": "2026-06-15",
"status": "in_progress",
"estimated_hours": 40
},
{
"id": "proj-002",
"client_id": "client-002",
"name": "Mobile App MVP",
"deadline": "2026-06-10",
"status": "in_progress",
"estimated_hours": 60
},
{
"id": "proj-003",
"client_id": "client-003",
"name": "Brand Identity Package",
"deadline": "2026-06-20",
"status": "in_progress",
"estimated_hours": 25
},
{
"id": "proj-004",
"client_id": "client-001",
"name": "SEO Audit",
"deadline": "2026-06-08",
"status": "in_progress",
"estimated_hours": 10
}
]
What does this data represent?
The client_id field links each project back to a client in clients.json. This is how the agent knows which client a project belongs to.
Notice that client-001 (Acme Corp) has two projects. Your agent will use these relationships to answer questions like "What deadlines does Acme Corp have coming up?"
- Save projects.json.
- Confirm projects.json now appears under the data folder in your Explorer sidebar.
Seeing a red underline or error indicator?
VS Code validates JSON automatically. A red squiggly line means something is wrong with the structure. The most common cause is a trailing comma after the last item in the array (after the closing } before ]).
Help me fix my JSON validation error in projects.json.
Create the time logs
The time logs file records hours worked on each project. Your agent will use this data to calculate invoices and track productivity.
- Right-click the data folder and select New File.
- Name it time_logs.json and press Enter.
- Paste the following JSON into the file:
[
{"project_id": "proj-001", "date": "2026-06-02", "hours": 4, "description": "Homepage wireframes"},
{"project_id": "proj-001", "date": "2026-06-03", "hours": 3, "description": "Navigation design"},
{"project_id": "proj-002", "date": "2026-06-02", "hours": 5, "description": "Auth flow implementation"},
{"project_id": "proj-002", "date": "2026-06-03", "hours": 6, "description": "Dashboard UI"},
{"project_id": "proj-003", "date": "2026-06-03", "hours": 2, "description": "Color palette research"},
{"project_id": "proj-004", "date": "2026-06-01", "hours": 3, "description": "Technical SEO crawl"},
{"project_id": "proj-004", "date": "2026-06-02", "hours": 4, "description": "Keyword analysis"}
]
What does this data represent?
Each entry is a single work session. The project_id links back to projects.json, which in turn links to clients.json.
This chain of relationships (time log to project to client) is how the agent answers complex questions. When you ask "How much do I owe Acme Corp?", the agent can trace from client to projects to logged hours and multiply by the hourly rate.
- Save time_logs.json.
- Confirm all three files exist by running this in your terminal:
ls data/
You should see clients.json, projects.json, and time_logs.json listed in the output.
Missing one or more files?
Make sure your terminal is in the project root folder (the folder that contains the data/ directory). If you see "No such file or directory", you're in the wrong location.
If a file is missing from the list, check the Explorer sidebar. It may have been saved to the project root instead of inside data/. Drag it into the correct folder.
Help me find my missing JSON files.
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved and you're ready to move on.
ⓧ I'd like to double check the full code
Here are all three files for comparison. Make sure yours match exactly.
[
{
"id": "client-001",
"name": "Acme Corp",
"contact": "Sarah Johnson",
"email": "sarah@acmecorp.com",
"rate_per_hour": 85,
"currency": "USD"
},
{
"id": "client-002",
"name": "TechStart Inc",
"contact": "Raj Patel",
"email": "raj@techstart.io",
"rate_per_hour": 100,
"currency": "USD"
},
{
"id": "client-003",
"name": "GreenLeaf Design",
"contact": "Maria Santos",
"email": "maria@greenleaf.design",
"rate_per_hour": 75,
"currency": "USD"
}
]
[
{
"id": "proj-001",
"client_id": "client-001",
"name": "Website Redesign",
"deadline": "2026-06-15",
"status": "in_progress",
"estimated_hours": 40
},
{
"id": "proj-002",
"client_id": "client-002",
"name": "Mobile App MVP",
"deadline": "2026-06-10",
"status": "in_progress",
"estimated_hours": 60
},
{
"id": "proj-003",
"client_id": "client-003",
"name": "Brand Identity Package",
"deadline": "2026-06-20",
"status": "in_progress",
"estimated_hours": 25
},
{
"id": "proj-004",
"client_id": "client-001",
"name": "SEO Audit",
"deadline": "2026-06-08",
"status": "in_progress",
"estimated_hours": 10
}
]
[
{"project_id": "proj-001", "date": "2026-06-02", "hours": 4, "description": "Homepage wireframes"},
{"project_id": "proj-001", "date": "2026-06-03", "hours": 3, "description": "Navigation design"},
{"project_id": "proj-002", "date": "2026-06-02", "hours": 5, "description": "Auth flow implementation"},
{"project_id": "proj-002", "date": "2026-06-03", "hours": 6, "description": "Dashboard UI"},
{"project_id": "proj-003", "date": "2026-06-03", "hours": 2, "description": "Color palette research"},
{"project_id": "proj-004", "date": "2026-06-01", "hours": 3, "description": "Technical SEO crawl"},
{"project_id": "proj-004", "date": "2026-06-02", "hours": 4, "description": "Keyword analysis"}
]
Why JSON files instead of a real database?
Using JSON files keeps this project focused on the agent pattern itself. The tools you build in the next step read and write these files using the exact same approach they would use to call a real database or external API.
Once you understand the agent loop, swapping JSON for PostgreSQL or a REST API is just changing what happens inside each tool function.
Your freelancer database is ready. Next up, you'll build the Python tools that let your agent query and manipulate this data.
Create the Agent Tools
Your freelancer database is ready with clients, projects, and time logs. Now you need to give your AI agent the ability to actually do things with that data.
In this step, you'll write Python functions that serve as your agent's tools. Each tool handles one specific task: looking up a client, checking deadlines, logging hours, calculating invoices, or prioritizing work. The key detail is that each function needs type hints and docstrings so that Gemini can understand what each tool does and when to use it.
In this step, get ready to:
- Define 5 tools as Python functions with type hints and docstrings.
- Build tools for client lookup, deadlines, hours logging, invoicing, and task priority.
- Test each tool manually to confirm correct output.
Create the tools file with lookup functions
An AI agent's "tools" are just regular Python functions. The magic is in the type hints and docstrings. The Gemini SDK reads those annotations to automatically understand what each function does, what parameters it takes, and what it returns.
- In VS Code, create a new file called tools.py in your project's root folder (the same level as the data/ directory).
- Add the imports and helper function by pasting this code:
import json
from datetime import datetime, timedelta
def load_json(filepath: str) -> list:
with open(filepath, "r") as f:
return json.load(f)
What does this code do?
The load_json helper opens a JSON file and returns its contents as a Python list. Every tool will use this to read from the database files you created in the previous step.
- Save tools.py.
- You should see no red underlines or error indicators in the editor.
- Now add the first tool function below load_json. This tool looks up client information by name:
def get_client_info(client_name: str) -> str:
"""Gets detailed information about a client by name.
Args:
client_name: The name or partial name of the client to look up.
Returns:
A string with the client's contact info, email, and hourly rate.
"""
clients = load_json("data/clients.json")
for client in clients:
if client_name.lower() in client["name"].lower():
return (
f"Client: {client['name']}\n"
f"Contact: {client['contact']}\n"
f"Email: {client['email']}\n"
f"Rate: ${client['rate_per_hour']}/hr ({client['currency']})"
)
return f"No client found matching '{client_name}'."
What does this code do?
This function searches for a client by partial name match (case-insensitive) and returns their contact details and hourly rate.
Notice the detailed docstring with Args: and Returns: sections. Gemini's automatic function calling reads this docstring to understand when to call this tool. Without a clear description, the model won't know which tool fits a given question.
- Save tools.py.
- Confirm no errors appear in the editor.
- Add the second tool below get_client_info. This one finds upcoming project deadlines:
def get_upcoming_deadlines(days_ahead: int = 14) -> str:
"""Gets all project deadlines coming up within the specified number of days.
Args:
days_ahead: Number of days to look ahead for deadlines. Defaults to 14.
Returns:
A formatted string listing upcoming deadlines sorted by date.
"""
projects = load_json("data/projects.json")
clients = load_json("data/clients.json")
today = datetime.now().date()
cutoff = today + timedelta(days=days_ahead)
upcoming = []
for proj in projects:
if proj["status"] != "in_progress":
continue
deadline = datetime.strptime(proj["deadline"], "%Y-%m-%d").date()
if today <= deadline <= cutoff:
client = next((c for c in clients if c["id"] == proj["client_id"]), None)
client_name = client["name"] if client else "Unknown"
days_left = (deadline - today).days
upcoming.append((days_left, proj["name"], client_name, proj["deadline"]))
if not upcoming:
return "No upcoming deadlines in the next {days_ahead} days."
upcoming.sort(key=lambda x: x[0])
lines = ["Upcoming Deadlines:"]
for days_left, name, client_name, deadline in upcoming:
urgency = "URGENT" if days_left <= 3 else "soon" if days_left <= 7 else "upcoming"
lines.append(f" [{urgency}] {name} ({client_name}) - Due: {deadline} ({days_left} days left)")
return "\n".join(lines)
What does this code do?
This tool checks all in-progress projects and returns any deadlines within the next days_ahead days (defaulting to 14). It tags each deadline as URGENT, soon, or upcoming based on how many days remain.
The default parameter value of 14 means the agent can call this tool without specifying days, and Gemini will see that in the type hint.
- Save tools.py.
- Confirm no errors appear in the editor. You now have two tools defined.
Seeing red underlines?
Check that the import json and from datetime import datetime, timedelta lines are at the very top of the file. Missing imports cause "undefined name" errors on every function.
Also confirm your indentation uses spaces, not tabs. Python is strict about consistent indentation.
Help me fix syntax errors in my tools.py file.
Add the action and analysis tools
The first two tools only read data. The next three tools add more capabilities: logging hours (a write action), calculating invoices, and prioritizing tasks based on urgency.
- Add the log_hours function below get_upcoming_deadlines. This tool writes data, not just reads it:
def log_hours(project_name: str, hours: float, description: str) -> str:
"""Logs work hours for a specific project.
Args:
project_name: The name of the project to log hours for.
hours: Number of hours worked.
description: Brief description of work done.
Returns:
A confirmation message with the logged entry details.
"""
projects = load_json("data/projects.json")
project = next((p for p in projects if project_name.lower() in p["name"].lower()), None)
if not project:
return f"No project found matching '{project_name}'."
today = datetime.now().strftime("%Y-%m-%d")
new_entry = {
"project_id": project["id"],
"date": today,
"hours": hours,
"description": description,
}
time_logs = load_json("data/time_logs.json")
time_logs.append(new_entry)
with open("data/time_logs.json", "w") as f:
json.dump(time_logs, f, indent=2)
return (
f"Logged {hours} hours for '{project['name']}' on {today}.\n"
f"Description: {description}"
)
What does this code do?
This is the first tool that takes an action. It appends a new time entry to data/time_logs.json and saves the file. This proves your agent can modify data, not just report on it.
Notice it takes three parameters. Gemini will extract all three from the user's natural language request (e.g. "Log 3 hours on the Website Redesign for header design").
- Save tools.py.
- Confirm no errors appear in the editor.
- Add the calculate_invoice function below log_hours:
def calculate_invoice(client_name: str) -> str:
"""Calculates the total invoice amount for a client based on logged hours.
Args:
client_name: The name of the client to calculate an invoice for.
Returns:
A detailed invoice breakdown showing hours per project and total amount due.
"""
clients = load_json("data/clients.json")
client = next((c for c in clients if client_name.lower() in c["name"].lower()), None)
if not client:
return f"No client found matching '{client_name}'."
projects = load_json("data/projects.json")
time_logs = load_json("data/time_logs.json")
client_projects = [p for p in projects if p["client_id"] == client["id"]]
project_ids = [p["id"] for p in client_projects]
total_hours = 0
lines = [f"Invoice for {client['name']}:", f"Rate: ${client['rate_per_hour']}/hr", ""]
for proj in client_projects:
proj_hours = sum(log["hours"] for log in time_logs if log["project_id"] == proj["id"])
if proj_hours > 0:
lines.append(f" {proj['name']}: {proj_hours} hrs = ${proj_hours * client['rate_per_hour']:.2f}")
total_hours += proj_hours
total_amount = total_hours * client["rate_per_hour"]
lines.append(f"\nTotal: {total_hours} hrs = ${total_amount:.2f}")
return "\n".join(lines)
What does this code do?
This tool looks up all projects for a given client, sums the logged hours per project, and multiplies by the client's hourly rate. It produces a formatted invoice breakdown with a total.
- Save tools.py.
- Confirm no errors appear in the editor.
- Add the final tool, get_priority_tasks, below calculate_invoice:
def get_priority_tasks() -> str:
"""Gets a prioritized list of tasks based on deadline urgency and hours remaining.
Returns:
A prioritized task list showing what to focus on today.
"""
projects = load_json("data/projects.json")
clients = load_json("data/clients.json")
time_logs = load_json("data/time_logs.json")
today = datetime.now().date()
priorities = []
for proj in projects:
if proj["status"] != "in_progress":
continue
deadline = datetime.strptime(proj["deadline"], "%Y-%m-%d").date()
days_left = (deadline - today).days
hours_logged = sum(log["hours"] for log in time_logs if log["project_id"] == proj["id"])
hours_remaining = max(0, proj["estimated_hours"] - hours_logged)
client = next((c for c in clients if c["id"] == proj["client_id"]), None)
client_name = client["name"] if client else "Unknown"
if days_left <= 0:
urgency_score = 100
elif hours_remaining == 0:
urgency_score = 0
else:
urgency_score = hours_remaining / max(days_left, 1)
priorities.append((urgency_score, proj["name"], client_name, days_left, hours_remaining))
priorities.sort(key=lambda x: x[0], reverse=True)
lines = ["Priority Tasks (focus on these today):"]
for score, name, client_name, days_left, hrs_remaining in priorities:
lines.append(f" - {name} ({client_name}): {hrs_remaining} hrs left, {days_left} days to deadline")
return "\n".join(lines)
What does this code do?
This tool calculates an urgency score for each project by dividing hours remaining by days until the deadline. Projects with more work and less time rise to the top. Overdue projects get a score of 100 (always highest priority), and completed projects get 0.
- Save tools.py.
- Confirm no errors appear in the editor. You now have all five tools defined.
Seeing indentation errors?
Python requires consistent indentation throughout the file. If you mixed tabs and spaces, VS Code may show a "TabError" or "IndentationError" in the Problems panel. Select all the code with Cmd+A (macOS) or Ctrl+A (Windows), then use the command palette (Cmd+Shift+P or Ctrl+Shift+P) and search for "Convert Indentation to Spaces".
Help me debug indentation errors in tools.py.
✔️ Awesome, I've got everything!
Great. Double check you've saved tools.py with all five functions.
ⓧ I'd like to double check the full code
import json
from datetime import datetime, timedelta
def load_json(filepath: str) -> list:
with open(filepath, "r") as f:
return json.load(f)
def get_client_info(client_name: str) -> str:
"""Gets detailed information about a client by name.
Args:
client_name: The name or partial name of the client to look up.
Returns:
A string with the client's contact info, email, and hourly rate.
"""
clients = load_json("data/clients.json")
for client in clients:
if client_name.lower() in client["name"].lower():
return (
f"Client: {client['name']}\n"
f"Contact: {client['contact']}\n"
f"Email: {client['email']}\n"
f"Rate: ${client['rate_per_hour']}/hr ({client['currency']})"
)
return f"No client found matching '{client_name}'."
def get_upcoming_deadlines(days_ahead: int = 14) -> str:
"""Gets all project deadlines coming up within the specified number of days.
Args:
days_ahead: Number of days to look ahead for deadlines. Defaults to 14.
Returns:
A formatted string listing upcoming deadlines sorted by date.
"""
projects = load_json("data/projects.json")
clients = load_json("data/clients.json")
today = datetime.now().date()
cutoff = today + timedelta(days=days_ahead)
upcoming = []
for proj in projects:
if proj["status"] != "in_progress":
continue
deadline = datetime.strptime(proj["deadline"], "%Y-%m-%d").date()
if today <= deadline <= cutoff:
client = next((c for c in clients if c["id"] == proj["client_id"]), None)
client_name = client["name"] if client else "Unknown"
days_left = (deadline - today).days
upcoming.append((days_left, proj["name"], client_name, proj["deadline"]))
if not upcoming:
return "No upcoming deadlines in the next {days_ahead} days."
upcoming.sort(key=lambda x: x[0])
lines = ["Upcoming Deadlines:"]
for days_left, name, client_name, deadline in upcoming:
urgency = "URGENT" if days_left <= 3 else "soon" if days_left <= 7 else "upcoming"
lines.append(f" [{urgency}] {name} ({client_name}) - Due: {deadline} ({days_left} days left)")
return "\n".join(lines)
def log_hours(project_name: str, hours: float, description: str) -> str:
"""Logs work hours for a specific project.
Args:
project_name: The name of the project to log hours for.
hours: Number of hours worked.
description: Brief description of work done.
Returns:
A confirmation message with the logged entry details.
"""
projects = load_json("data/projects.json")
project = next((p for p in projects if project_name.lower() in p["name"].lower()), None)
if not project:
return f"No project found matching '{project_name}'."
today = datetime.now().strftime("%Y-%m-%d")
new_entry = {
"project_id": project["id"],
"date": today,
"hours": hours,
"description": description,
}
time_logs = load_json("data/time_logs.json")
time_logs.append(new_entry)
with open("data/time_logs.json", "w") as f:
json.dump(time_logs, f, indent=2)
return (
f"Logged {hours} hours for '{project['name']}' on {today}.\n"
f"Description: {description}"
)
def calculate_invoice(client_name: str) -> str:
"""Calculates the total invoice amount for a client based on logged hours.
Args:
client_name: The name of the client to calculate an invoice for.
Returns:
A detailed invoice breakdown showing hours per project and total amount due.
"""
clients = load_json("data/clients.json")
client = next((c for c in clients if client_name.lower() in c["name"].lower()), None)
if not client:
return f"No client found matching '{client_name}'."
projects = load_json("data/projects.json")
time_logs = load_json("data/time_logs.json")
client_projects = [p for p in projects if p["client_id"] == client["id"]]
project_ids = [p["id"] for p in client_projects]
total_hours = 0
lines = [f"Invoice for {client['name']}:", f"Rate: ${client['rate_per_hour']}/hr", ""]
for proj in client_projects:
proj_hours = sum(log["hours"] for log in time_logs if log["project_id"] == proj["id"])
if proj_hours > 0:
lines.append(f" {proj['name']}: {proj_hours} hrs = ${proj_hours * client['rate_per_hour']:.2f}")
total_hours += proj_hours
total_amount = total_hours * client["rate_per_hour"]
lines.append(f"\nTotal: {total_hours} hrs = ${total_amount:.2f}")
return "\n".join(lines)
def get_priority_tasks() -> str:
"""Gets a prioritized list of tasks based on deadline urgency and hours remaining.
Returns:
A prioritized task list showing what to focus on today.
"""
projects = load_json("data/projects.json")
clients = load_json("data/clients.json")
time_logs = load_json("data/time_logs.json")
today = datetime.now().date()
priorities = []
for proj in projects:
if proj["status"] != "in_progress":
continue
deadline = datetime.strptime(proj["deadline"], "%Y-%m-%d").date()
days_left = (deadline - today).days
hours_logged = sum(log["hours"] for log in time_logs if log["project_id"] == proj["id"])
hours_remaining = max(0, proj["estimated_hours"] - hours_logged)
client = next((c for c in clients if c["id"] == proj["client_id"]), None)
client_name = client["name"] if client else "Unknown"
if days_left <= 0:
urgency_score = 100
elif hours_remaining == 0:
urgency_score = 0
else:
urgency_score = hours_remaining / max(days_left, 1)
priorities.append((urgency_score, proj["name"], client_name, days_left, hours_remaining))
priorities.sort(key=lambda x: x[0], reverse=True)
lines = ["Priority Tasks (focus on these today):"]
for score, name, client_name, days_left, hrs_remaining in priorities:
lines.append(f" - {name} ({client_name}): {hrs_remaining} hrs left, {days_left} days to deadline")
return "\n".join(lines)
Test each tool manually
Before connecting these tools to Gemini, you should confirm each one returns sensible output on its own. You'll run quick tests in the Python interpreter.
- Open your terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Make sure you are in your project folder (the one containing tools.py and the data/ directory).
- Start a Python session and test the client lookup by running:
python3 -c "from tools import get_client_info; print(get_client_info('Acme'))"
- You should see output showing Acme Corp's contact details, email, and rate of $85/hr.
- Test the deadlines tool by running:
python3 -c "from tools import get_upcoming_deadlines; print(get_upcoming_deadlines())"
- You should see a list of upcoming deadlines with urgency tags like [URGENT], [soon], or [upcoming].
- Test the invoice calculator by running:
python3 -c "from tools import calculate_invoice; print(calculate_invoice('Acme'))"
- You should see an invoice breakdown for Acme Corp showing hours per project and a total dollar amount.
- Test the priority tasks tool by running:
python3 -c "from tools import get_priority_tasks; print(get_priority_tasks())"
- You should see a prioritized list of projects sorted by urgency, showing hours remaining and days to deadline for each.
Why not test log_hours?
The log_hours tool writes to data/time_logs.json. Running it now would add a test entry that changes your sample data. You'll see it work when the agent calls it in the next step.
Seeing ModuleNotFoundError?
If you see an error mentioning the tools module not being found, make sure your terminal is in the correct folder. Run ls (macOS/Linux) or dir (Windows) and confirm you see tools.py in the output.
If you see a different error about JSON or file paths, check that the data/ folder is in the same directory as tools.py.
Help me debug import or file path errors when testing my tools.
All five tools are working independently. Next up, you'll connect them to Gemini so the AI can choose which tool to call based on a natural language question.
Build the Agent Loop
Your tools are built and tested. Now you need something to decide which tool to use for any given question.
This is where the agent loop comes in. You will write a short Python file that sends user queries to Gemini, along with your tool definitions. The LLM reads the query, picks the right tool, executes it, and returns a natural language answer.
In this step, get ready to:
- Write the core agent that sends user queries to Gemini with tools attached.
- Understand automatic function calling and how Gemini executes your tools behind the scenes.
- Test the agent with various natural language questions to see it pick the right tools.
Create the agent setup function
The agent needs three things to work: a connection to Gemini, a system instruction that defines its personality, and a list of tools it can use.
- In VS Code, create a new file called agent.py in your project folder (the same folder that contains tools.py).
- Add the following code to set up the agent's configuration:
import os
from google import genai
from google.genai import types
from tools import (
get_client_info,
get_upcoming_deadlines,
log_hours,
calculate_invoice,
get_priority_tasks,
)
def create_agent():
# Connect to Gemini using your API key from the environment
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
# Define the agent's personality and behavior
system_instruction = (
"You are a helpful freelancer project management assistant. "
"You help freelancers track their clients, projects, deadlines, hours, and invoices. "
"Use the available tools to answer questions accurately. "
"Be concise and professional in your responses. "
"When presenting data from tools, format it clearly for the user."
)
# Register all five tools so Gemini knows what it can call
tools = [
get_client_info,
get_upcoming_deadlines,
log_hours,
calculate_invoice,
get_priority_tasks,
]
# Bundle system instruction and tools into a config object
config = types.GenerateContentConfig(
system_instruction=system_instruction,
tools=tools,
)
return client, config
What does this code do?
The imports pull in the google-genai SDK and all five tool functions you built in the previous step.
The create_agent() function does three things. It creates a Gemini client using your API key. It defines a system instruction that tells Gemini to act as a freelancer assistant. It registers your Python functions as tools that Gemini can call.
The GenerateContentConfig bundles the system instruction and tools together so you can pass them with every request.
What is a system instruction?
A system instruction is a hidden prompt that shapes how the LLM behaves. The user never sees it, but Gemini reads it before every response. It is how you give your agent a consistent personality and set of rules.
Write the query function
The setup function prepares the agent. Now you need a function that actually sends a user's question to Gemini and gets an answer back.
- Add the following function below create_agent() in agent.py:
def ask_agent(user_query: str) -> str:
client, config = create_agent()
# Send the query to Gemini with tools attached
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=user_query,
config=config,
)
return response.text
What does this code do?
The ask_agent() function takes a plain English question from the user. It calls create_agent() to get the client and config, then sends the query to gemini-2.5-flash along with the tools list.
Gemini reads the query, decides which tool to call (if any), executes it, and returns a final text response. All of this happens in a single generate_content call.
Why does one function call handle everything?
The google-genai SDK uses automatic function calling. When Gemini decides it needs a tool, the SDK automatically calls your Python function, sends the result back to Gemini, and repeats until Gemini produces a final text answer.
You do not need to write a manual loop that checks for tool calls, executes them, and re-sends results. The SDK handles that plumbing for you. This lets you focus on designing great tools and prompts.
Test the agent interactively
You have the core agent logic. Now add an interactive loop so you can chat with it in your terminal and watch it pick the right tools in real time.
- Add the following code at the bottom of agent.py:
if __name__ == "__main__":
print("Freelancer Agent Ready! Type 'quit' to exit.\n")
while True:
query = input("You: ")
if query.lower() in ("quit", "exit"):
break
answer = ask_agent(query)
print(f"\nAgent: {answer}\n")
What does this code do?
The if __name__ == "__main__" guard means this code only runs when you execute agent.py directly. It starts a loop that reads your input, passes it to ask_agent(), and prints the response.
Typing quit or exit breaks the loop and ends the program.
- Save agent.py.
- Run the agent by executing this command in your terminal:
python agent.py
You should see the prompt Freelancer Agent Ready! Type 'quit' to exit. appear.
- Try asking the following questions one at a time to see the agent pick different tools:
What do you know about Acme Corp?
What are my upcoming deadlines?
What should I work on today?
How much does TechStart Inc owe me?
For each question, the agent reads your query, selects the appropriate tool, executes it against your JSON data, and returns a formatted answer.
What should I see?
Each answer should contain real data from your JSON files. For example, asking about Acme Corp should return Sarah Johnson's contact info and the $85/hr rate. Asking about deadlines should list your projects sorted by urgency.
The exact wording will vary because Gemini generates natural language responses. The data values should match what is in your JSON files.
- Try one more query that triggers the log_hours tool (this one writes data):
Log 2 hours for Website Redesign, worked on footer design
The agent should confirm it logged the hours and show the details.
- Type quit to exit the interactive loop when you are done testing.
Agent returning an error?
If you see an error about the API key, confirm your GEMINI_API_KEY environment variable is still set in the same terminal session. Environment variables reset when you close the terminal.
If you see an import error for google.genai, make sure you installed the google-genai package (not the deprecated google-generativeai).
Help me debug my agent error
✔️ Awesome, I've got everything!
Great. Double check you have saved agent.py and move on to the next step.
ⓧ I'd like to double check the full code
import os
from google import genai
from google.genai import types
from tools import (
get_client_info,
get_upcoming_deadlines,
log_hours,
calculate_invoice,
get_priority_tasks,
)
def create_agent():
client = genai.Client(api_key=os.environ.get("GEMINI_API_KEY"))
system_instruction = (
"You are a helpful freelancer project management assistant. "
"You help freelancers track their clients, projects, deadlines, hours, and invoices. "
"Use the available tools to answer questions accurately. "
"Be concise and professional in your responses. "
"When presenting data from tools, format it clearly for the user."
)
tools = [
get_client_info,
get_upcoming_deadlines,
log_hours,
calculate_invoice,
get_priority_tasks,
]
config = types.GenerateContentConfig(
system_instruction=system_instruction,
tools=tools,
)
return client, config
def ask_agent(user_query: str) -> str:
client, config = create_agent()
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=user_query,
config=config,
)
return response.text
if __name__ == "__main__":
print("Freelancer Agent Ready! Type 'quit' to exit.\n")
while True:
query = input("You: ")
if query.lower() in ("quit", "exit"):
break
answer = ask_agent(query)
print(f"\nAgent: {answer}\n")
Your agent is alive. It reads natural language, reasons about which tool fits, and returns real answers from your data. Next up, you will wrap this agent in a web API so anyone can query it over HTTP.
Deploy as a Flask API
Your agent works perfectly in the terminal. You can ask it questions and it picks the right tool every time.
The problem is that only you can use it, sitting in front of your computer, typing into the same terminal. To make the agent useful to anyone (or any other application), you need to wrap it in a web server that accepts requests over HTTP. That is exactly what Flask does.
In this step, get ready to:
- Wrap the agent in a Flask web server with a POST endpoint.
- Test the API with curl commands from your terminal.
- Handle errors gracefully for missing input and API failures.
Create the Flask app file
Flask is a lightweight Python web framework. You give it a route (like /ask) and a function to call when that route receives a request. Flask handles the HTTP plumbing so you can focus on what the endpoint actually does.
Why Flask instead of FastAPI?
Flask is the simplest Python web framework for wrapping an existing function in an API. One file, minimal boilerplate, and no type system complexity to distract from the agent concepts you are learning.
- Create a new file called app.py in your project folder (the same folder where agent.py lives).
- Add the imports, app instance, and the main /ask POST endpoint by pasting this code:
from flask import Flask, request, jsonify
from agent import ask_agent
app = Flask(__name__)
@app.route("/ask", methods=["POST"])
def ask():
# Parse the incoming JSON body
data = request.get_json()
# Return a 400 error if the request is missing the query field
if not data or "query" not in data:
return jsonify({"error": "Missing 'query' field in request body"}), 400
try:
# Pass the user's question to the agent and return the answer
answer = ask_agent(data["query"])
return jsonify({"query": data["query"], "response": answer})
except Exception as e:
# Return a 500 error if anything goes wrong inside the agent
return jsonify({"error": str(e)}), 500
What does this code do?
- from flask import Flask, request, jsonify brings in the tools you need to create a server, read incoming data, and send JSON responses.
- from agent import ask_agent imports the function you built in the previous step so Flask can call your agent.
- The @app.route("/ask", methods=["POST"]) decorator tells Flask to listen for POST requests at the /ask URL.
- The if not data or "query" not in data check catches requests that are missing the required input and returns a 400 status code.
- The try/except block catches any error inside the agent (network issues, API failures) and returns a clean 500 status code instead of crashing.
- Now add the health check endpoint and the entry point below the ask function:
@app.route("/health", methods=["GET"])
def health():
# Simple health check endpoint to verify the server is running
return jsonify({"status": "ok", "agent": "Freelancer Project Manager"})
if __name__ == "__main__":
app.run(debug=True, port=5000)
What does this code do?
- The /health endpoint is a GET route that returns a simple JSON object. It lets you quickly confirm the server is alive without calling the agent.
- app.run(debug=True, port=5000) starts the development server on port 5000 with auto-reload enabled so code changes take effect immediately.
- Save app.py.
- Confirm the file exists by running:
ls app.py
You should see app.py printed in the output.
Don't see app.py?
Make sure you created the file in the same folder where agent.py and tools.py live. Run ls to see all files in your current directory and confirm you are in the right place.
Help me find where my file was saved
✔️ Awesome, I've got everything!
Great. Double check you have saved app.py before moving on.
ⓧ I'd like to double check the full code
from flask import Flask, request, jsonify
from agent import ask_agent
app = Flask(__name__)
@app.route("/ask", methods=["POST"])
def ask():
data = request.get_json()
if not data or "query" not in data:
return jsonify({"error": "Missing 'query' field in request body"}), 400
try:
answer = ask_agent(data["query"])
return jsonify({"query": data["query"], "response": answer})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/health", methods=["GET"])
def health():
return jsonify({"status": "ok", "agent": "Freelancer Project Manager"})
if __name__ == "__main__":
app.run(debug=True, port=5000)
Start the Flask server
With the file saved, you can start the server. It will keep running in the terminal and listen for incoming requests until you stop it.
- Start the Flask development server by running:
python app.py
You should see output that includes * Running on http://127.0.0.1:5000 and a note about debug mode being on.
What does this output mean?
Flask is now listening on your local machine at port 5000. Any HTTP request you send to http://127.0.0.1:5000 will be handled by your app.py code.
The terminal is now occupied by the running server. You will need a second terminal window to send test requests.
Seeing an error about the port being in use?
Another process may already be using port 5000. On macOS, AirPlay Receiver sometimes occupies this port. Close that service, or stop any other server you have running, then try again.
Help me free up port 5000
Test the API with curl
With the server running in one terminal, you will open a second terminal to send requests. This simulates what another application or user would do when calling your agent over the network.
- Open a new terminal window. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or cmd (Windows) and press Enter to open it.
- Navigate to your project folder (the same one where app.py lives).
- Test the health endpoint by running:
curl http://127.0.0.1:5000/health
You should see a JSON response like {"agent":"Freelancer Project Manager","status":"ok"}.
- Now test the main /ask endpoint by sending a real question to your agent:
macOS
curl -X POST http://127.0.0.1:5000/ask \
-H "Content-Type: application/json" \
-d '{"query": "What are my upcoming deadlines?"}'
Windows
curl -X POST http://127.0.0.1:5000/ask -H "Content-Type: application/json" -d "{\"query\": \"What are my upcoming deadlines?\"}"
You should see a JSON response containing a query field (your original question) and a response field with the agent's answer listing your project deadlines.
What just happened?
Your curl command sent an HTTP POST request with a JSON body to your Flask server. Flask read the body, pulled out the query field, passed it to ask_agent, and returned the result as JSON.
Behind the scenes, ask_agent sent your question to Gemini, Gemini called the get_upcoming_deadlines tool automatically, and the final answer came back through the whole chain.
- Test the error handling by sending a request without the required query field:
macOS
curl -X POST http://127.0.0.1:5000/ask \
-H "Content-Type: application/json" \
-d '{}'
Windows
curl -X POST http://127.0.0.1:5000/ask -H "Content-Type: application/json" -d "{}"
You should see {"error":"Missing 'query' field in request body"} returned with a 400 status. Your server handles bad input gracefully instead of crashing.
Getting connection refused?
Make sure your Flask server is still running in the first terminal. If you accidentally closed it, go back and run python app.py again.
Also confirm you are using http://127.0.0.1:5000 (not https). The development server does not support HTTPS.
Help me fix my connection to the Flask server
Your agent is now a real web service. Any application, script, or user on your network can send it a question and get a structured JSON answer back. Next up, you will take on the Secret Mission and teach your agent to chain multiple tools together for a weekly report.
Secret mission
Add a Weekly Report Multi-Tool Chain
Your agent can answer individual questions, but a real freelancer assistant should be able to produce a full weekly summary on demand. Can you build a tool that pulls together deadlines, hours, and revenue into one report?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine and uses the free tier of the Gemini API. There are no ongoing costs to worry about.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Your project folder on the Desktop (contains tools.py, agent.py, app.py, and the data/ directory).
- Python packages: google-genai and Flask.
- GEMINI_API_KEY environment variable.
- Gemini API key in Google AI Studio.
Keep everything running
No action needed. Choose this if you want to keep experimenting with your agent or add more tools.
- Your project folder stays on the Desktop, ready to edit.
- The Gemini API free tier has no ongoing charges, so your API key can stay active.
- To restart your Flask server later, open your terminal, navigate to your project folder, and run python app.py.
Pause - I'll come back to this later
Shut down the running process to free up your terminal. Your files and packages stay intact for when you return.
- Switch to the terminal where Flask is running.
- Press Ctrl+C to stop the Flask server.
- Your project folder, packages, and API key all stay in place.
Delete - I don't want to use this again
Remove all project resources and start fresh. Follow each step below.
- Stop the Flask server by pressing Ctrl+C in the terminal where it is running.
- Delete your project folder from the Desktop using Finder (macOS) or File Explorer (Windows). Right-click the folder and select Move to Trash (macOS) or Delete (Windows).
- Uninstall the Python packages by running this command in your terminal:
pip uninstall google-genai Flask -y
- Clear the GEMINI_API_KEY environment variable. On macOS/Linux, run:
unset GEMINI_API_KEY
On Windows (PowerShell), run:
Remove-Item Env:GEMINI_API_KEY
- Delete your Gemini API key. Navigate to https://ai.google.dev/aistudio, open the API keys section, find the key you created for this project, and delete it.
Nice Work!
Nice Work!
You just built an AI agent from the ground up. You now have a working Freelancer Project Manager that accepts natural language questions, reasons about which tool to use, executes it, and returns a human-readable answer.
You've learned how to:
- Build the core LLM + tools + loop pattern that every agent framework (LangGraph, CrewAI, OpenAI Agents SDK) is built on.
- Define Python functions as tools with type hints and docstrings so Gemini's function calling can reason about them and invoke them automatically.
- Deploy an agent as a Flask API that anyone can query from a browser or terminal.
- Chain multiple tools together for multi-step reasoning, producing a weekly summary report that aggregates data across deadlines, hours, and revenue.
Ready to quiz yourself?