Build an AI Meal Accountability Agent
Build a WhatsApp AI agent that tracks protein intake and penalizes missed meals.
Introduction
30 Second Summary
Eating enough protein is hard. Remembering to eat enough protein, taking photos to prove it, and dealing with the guilt of skipping meals is even harder. What if an AI agent lived on your phone and held you accountable for every single meal?
In this project, you will build ProteinPunisher, a sarcastic AI health coach that lives on WhatsApp. It proactively nudges you at meal times, analyzes food photos for protein content, and penalizes you for skipping meals or eating low-protein food.
What You'll Build
A fully deployed AI agent on Google Cloud Run that uses Google ADK and Gemini 2.5 Flash to analyze meal photos, track protein and water intake in Firestore, and send proactive reminders via Twilio WhatsApp.
By the end of this project, you'll have:
- A Google ADK agent with custom Python tools for meal logging, water tracking, and daily summaries.
- A Twilio WhatsApp webhook that receives meal photos and passes them to Gemini for multimodal protein estimation.
- A proactive reminder system powered by Google Cloud Scheduler that nudges users and applies penalties for missed meals.
- Secret Mission: an ADK evaluation test suite and a streak system that amplifies penalties and rewards based on consistency.
Are there any prerequisites?
You need a Google Cloud account with billing enabled, a Twilio account with the WhatsApp Sandbox activated, a Gemini API key from Google AI Studio, Python 3.10+ installed, and Docker Desktop installed.
Step 1 walks you through verifying each of these and getting your credentials ready.
Set Up the Project Structure
ProteinPunisher has several moving parts: an AI agent, nutrition tracking tools, a Firestore database service, and a Flask webhook server. Organizing these into separate modules from the start keeps the codebase clean as you add features.
In this step, you will create the project skeleton, install all dependencies, and configure your environment variables so the agent can connect to Gemini, Twilio, and Google Cloud.
In this step, get ready to:
- Create the project directory structure with separate modules for agents, tools, services, and config.
- Set up a Python virtual environment and install all required packages.
- Configure environment variables and build the settings module.
Create the project directory structure
A well-organized Python project separates concerns into packages. You will create a project root folder on your Desktop, then build the internal package structure inside it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create the project root folder and move into it by running these commands:
mkdir protein-punisher
cd protein-punisher
- Create the Python package directories by running this command:
mkdir -p protein_punisher/agents protein_punisher/config protein_punisher/services protein_punisher/tools
What does this structure do?
Each subfolder becomes a Python package that handles one responsibility.
- `agents/` holds the ADK agent definition and runner logic.
- `tools/` holds the ADK-compatible tool functions the agent can call.
- `services/` holds database and reminder logic.
- `config/` holds environment variable loading and app constants.
- Create the required __init__.py files that make each folder a Python package by running this command:
touch protein_punisher/__init__.py protein_punisher/agents/__init__.py protein_punisher/config/__init__.py protein_punisher/services/__init__.py protein_punisher/tools/__init__.py
- Confirm the files exist by running:
ls protein_punisher
You should see __init__.py, agents, config, services, and tools listed in the output.
- Create the requirements.txt file in the project root (protein-punisher folder). Open a new file called requirements.txt in your text editor and paste the following:
google-adk
google-cloud-firestore
flask
twilio
python-dotenv
gunicorn
requests
What does this file do?
This file lists every Python package the project needs. You will use it with pip install -r requirements.txt to install everything in one command.
- Save requirements.txt.
Don't see the expected folders?
Make sure you ran cd protein-punisher before creating the inner protein_punisher package folders. The project root uses a hyphen (protein-punisher) while the Python package uses an underscore (protein_punisher).
help me fix my project directory structure
Set up the virtual environment and install packages
A virtual environment isolates this project's dependencies from your system Python. This prevents version conflicts between projects.
- Create a virtual environment by running this command from inside the protein-punisher folder:
python3 -m venv .venv
- Activate the virtual environment:
macOS
source .venv/bin/activate
Windows
.venv\Scripts\activate
You should see (.venv) appear at the beginning of your terminal prompt.
- Install all project dependencies by running:
pip install -r requirements.txt
What is getting installed?
- `google-adk` is Google's Agent Development Kit for building AI agents.
- `google-cloud-firestore` is the Python client for Firestore, a NoSQL database.
- `flask` is a lightweight web framework for handling webhook requests.
- `twilio` provides the SDK for sending and receiving WhatsApp messages.
- `python-dotenv` loads environment variables from a .env file.
- `gunicorn` is a production WSGI server for deploying the Flask app.
- `requests` handles HTTP calls to download images from Twilio.
The installation may take a minute. You should see a Successfully installed message at the end listing all packages.
Seeing a pip error?
If you see pip: command not found, make sure your virtual environment is activated. The (.venv) prefix must be visible in your terminal prompt.
If installation fails on google-cloud-firestore, confirm you are running Python 3.10+ by running python3 --version.
help me fix my pip installation error
Configure environment variables and the settings module
The agent needs API keys and credentials to connect to Gemini, Twilio, and Google Cloud. You will store these in a .env file that Python loads at runtime.
- Create a file called .env.example in the protein-punisher root folder and paste the following:
GOOGLE_API_KEY=your-gemini-api-key-here
GCP_PROJECT_ID=your-gcp-project-id
TWILIO_ACCOUNT_SID=your-twilio-account-sid
TWILIO_AUTH_TOKEN=your-twilio-auth-token
TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886
Why an .env.example file?
This template file shows which environment variables the project needs without containing real secrets. You can safely commit it to version control. The actual .env file (which contains your real keys) stays local and private.
- Save .env.example.
- Create your actual .env file by copying the example:
cp .env.example .env
- Open .env in your text editor and replace each placeholder with your real credentials:
- Set GOOGLE_API_KEY to your Gemini API key from Google AI Studio.
- Set GCP_PROJECT_ID to your Google Cloud project ID.
- Set TWILIO_ACCOUNT_SID to your Twilio Account SID from the Twilio Console dashboard.
- Set TWILIO_AUTH_TOKEN to your Twilio Auth Token.
- Leave TWILIO_WHATSAPP_NUMBER as whatsapp:+14155238886 (this is Twilio's sandbox number).
- Save .env.
Can't find your Twilio credentials?
Your Account SID and Auth Token are on the main Twilio Console dashboard at console.twilio.com. They appear in the **Account Info** section.
help me find my Twilio credentials for the ProteinPunisher project
Now create the settings module that loads these variables into Python.
- Open protein_punisher/config/settings.py in your text editor and paste the following:
import os
from dotenv import load_dotenv
load_dotenv()
# Gemini API
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
# Google Cloud
GCP_PROJECT_ID = os.getenv("GCP_PROJECT_ID")
# Twilio
TWILIO_ACCOUNT_SID = os.getenv("TWILIO_ACCOUNT_SID")
TWILIO_AUTH_TOKEN = os.getenv("TWILIO_AUTH_TOKEN")
TWILIO_WHATSAPP_NUMBER = os.getenv("TWILIO_WHATSAPP_NUMBER", "whatsapp:+14155238886")
# App
APP_NAME = "protein_punisher"
GEMINI_MODEL = "gemini-2.5-flash"
What does this code do?
- `load_dotenv()` reads the .env file and makes its values available through os.getenv().
- Each variable is loaded by name, keeping secrets out of your source code.
- `TWILIO_WHATSAPP_NUMBER` has a default value for the Twilio sandbox number in case the variable is not set.
- `APP_NAME` and `GEMINI_MODEL` are constants that other modules will import.
- Save settings.py.
- Open protein_punisher/config/__init__.py and replace its empty contents with:
from .settings import *
What does this import do?
This re-exports everything from settings.py at the package level. Other modules can now import directly from protein_punisher.config instead of protein_punisher.config.settings.
- Save protein_punisher/config/__init__.py.
- Verify the config loads correctly by running this from the protein-punisher root folder:
python3 -c "from protein_punisher.config import APP_NAME; print(APP_NAME)"
You should see protein_punisher printed to the terminal.
Seeing a ModuleNotFoundError?
Make sure you are running the command from the protein-punisher root folder (the one that contains the protein_punisher package folder). Also confirm your virtual environment is still active.
help me fix a ModuleNotFoundError when importing protein_punisher.config
✔️ Awesome, I've got everything!
Great. Double check you have saved all your files.
ⓧ I'd like to double check the full code
Here is what each file should look like at this point.
requirements.txt:
google-adk
google-cloud-firestore
flask
twilio
python-dotenv
gunicorn
requests
.env.example:
GOOGLE_API_KEY=your-gemini-api-key-here
GCP_PROJECT_ID=your-gcp-project-id
TWILIO_ACCOUNT_SID=your-twilio-account-sid
TWILIO_AUTH_TOKEN=your-twilio-auth-token
TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886
protein_punisher/config/settings.py:
import os
from dotenv import load_dotenv
load_dotenv()
# Gemini API
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
# Google Cloud
GCP_PROJECT_ID = os.getenv("GCP_PROJECT_ID")
# Twilio
TWILIO_ACCOUNT_SID = os.getenv("TWILIO_ACCOUNT_SID")
TWILIO_AUTH_TOKEN = os.getenv("TWILIO_AUTH_TOKEN")
TWILIO_WHATSAPP_NUMBER = os.getenv("TWILIO_WHATSAPP_NUMBER", "whatsapp:+14155238886")
# App
APP_NAME = "protein_punisher"
GEMINI_MODEL = "gemini-2.5-flash"
protein_punisher/config/__init__.py:
from .settings import *
All other __init__.py files (protein_punisher/__init__.py, protein_punisher/agents/__init__.py, protein_punisher/services/__init__.py, protein_punisher/tools/__init__.py) should be empty files.
Your project skeleton is ready with all dependencies installed and credentials configured. Next up, you will build the Firestore service and the ADK tool functions that give your agent real capabilities.
Build the Firestore Service and ADK Tools
Your project structure and packages are ready. Now you need a place to store user data persistently.
Every time a user sends a meal photo or logs water, that information needs to survive server restarts and be accessible across sessions. In this step, you will build the database layer using Google Cloud Firestore and wrap it with Google ADK-compatible tool functions that the agent calls to read and write user data.
In this step, get ready to:
- Create a Firestore database and authenticate your local environment.
- Build the Firestore service module with all database operations.
- Create ADK-compatible tool wrapper functions and verify Firestore connectivity.
Enable Firestore and configure local credentials
Before writing code that talks to Firestore, you need two things: a Firestore database in your Google Cloud project, and local credentials so your Python code can authenticate.
- Create a Firestore database in Native mode by running this command:
gcloud firestore databases create --location=nam5
What does this command do?
This creates a Firestore database in Native mode (the default type) in the nam5 multi-region location (United States). Firestore in Native mode gives you a document-based NoSQL database that organizes data into collections and documents.
If you already have a Firestore database in this project, the command will fail. That is fine. You can use the existing database.
- Set up Application Default Credentials so Python can authenticate to Firestore locally by running:
gcloud auth application-default login
A browser window opens asking you to sign in. After signing in, your credentials are stored locally and the Firestore Python client picks them up automatically.
What are Application Default Credentials?
Application Default Credentials (ADC) is a strategy where Google Cloud client libraries automatically find credentials based on the environment. Locally, ADC reads the credential file created by this command.
On Cloud Run, it uses the attached service account instead. This means your code works in both environments without changes.
Getting a permission error?
Make sure you are logged into the correct Google Cloud project. Run gcloud config get-value project to check. If the project is wrong, set it with gcloud config set project YOUR_PROJECT_ID.
Help me troubleshoot this permission error.
Create the Firestore service module
The Firestore service module is the single file responsible for all database reads and writes. It handles user profiles, meal logs, water logs, and penalty tracking.
- Open protein_punisher/services/firestore_service.py in your editor and add the imports and profile functions by pasting the following code:
from google.cloud import firestore
from datetime import datetime, timezone
db = firestore.Client()
def get_user_profile(phone_number: str) -> dict:
doc_ref = db.collection("users").document(phone_number)
doc = doc_ref.get()
if doc.exists:
return doc.to_dict()
return {}
def save_user_profile(phone_number: str, profile_data: dict) -> dict:
doc_ref = db.collection("users").document(phone_number)
doc_ref.set(profile_data, merge=True)
return {"status": "saved", "phone_number": phone_number}
What does this code do?
The firestore.Client() call creates a connection to your Firestore database. It uses ADC to authenticate automatically.
The get_user_profile function looks up a user document by phone number. If the document exists, it returns the data as a dictionary. If not, it returns an empty dict which tells the agent to start onboarding.
The save_user_profile function uses merge=True so you can update individual fields without overwriting the entire document.
- Below the profile functions, add the meal and water logging functions:
def log_meal_entry(phone_number: str, food_items: str, protein_grams: float, meal_number: int) -> dict:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
doc_ref = (
db.collection("users")
.document(phone_number)
.collection("meals")
.document(f"{today}_meal_{meal_number}")
)
meal_data = {
"date": today,
"meal_number": meal_number,
"food_items": food_items,
"protein_grams": protein_grams,
"timestamp": datetime.now(timezone.utc),
}
doc_ref.set(meal_data)
return {"status": "logged", "protein_grams": protein_grams, "meal_number": meal_number}
def log_water_entry(phone_number: str, amount_ml: float) -> dict:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
doc_ref = (
db.collection("users")
.document(phone_number)
.collection("water")
.document()
)
water_data = {
"date": today,
"amount_ml": amount_ml,
"timestamp": datetime.now(timezone.utc),
}
doc_ref.set(water_data)
return {"status": "logged", "amount_ml": amount_ml}
What does this code do?
Both functions store data in subcollections under the user's document. Meals use a deterministic document ID (2025-05-06_meal_1) so the same meal cannot be logged twice.
Water entries use an auto-generated ID (.document() with no argument) because users can log water multiple times. The Firestore data structure is: users/{phone_number}/meals/{document_id} and users/{phone_number}/water/{document_id}.
- Add the daily stats aggregation function below the logging functions:
def get_daily_stats(phone_number: str) -> dict:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
meals_ref = (
db.collection("users")
.document(phone_number)
.collection("meals")
.where("date", "==", today)
)
meals = [meal.to_dict() for meal in meals_ref.stream()]
total_protein = sum(m.get("protein_grams", 0) for m in meals)
meals_logged = len(meals)
water_ref = (
db.collection("users")
.document(phone_number)
.collection("water")
.where("date", "==", today)
)
total_water = sum(entry.to_dict().get("amount_ml", 0) for entry in water_ref.stream())
profile = get_user_profile(phone_number)
protein_target = profile.get("daily_protein_target", 0)
water_target = profile.get("daily_water_target", 0)
meal_count = profile.get("meal_count", 3)
return {
"total_protein_grams": total_protein,
"protein_target": protein_target,
"protein_percentage": round((total_protein / protein_target * 100) if protein_target > 0 else 0, 1),
"total_water_ml": total_water,
"water_target": water_target,
"water_percentage": round((total_water / water_target * 100) if water_target > 0 else 0, 1),
"meals_logged_today": meals_logged,
"meals_expected": meal_count,
}
What does this code do?
This function queries today's meals and water entries, sums up the totals, and compares them against the user's targets from their profile. It returns percentages so the agent can tell the user how close they are to their goals.
- Finally, add the reminder and penalty functions at the bottom of the file:
def get_users_due_for_reminder() -> list:
now = datetime.now(timezone.utc)
current_hour = now.strftime("%H:00")
today = now.strftime("%Y-%m-%d")
users_to_remind = []
users_ref = db.collection("users").stream()
for user_doc in users_ref:
profile = user_doc.to_dict()
phone_number = user_doc.id
meal_times = profile.get("meal_times", [])
for i, meal_time in enumerate(meal_times):
if meal_time == current_hour:
meal_number = i + 1
meal_doc_id = f"{today}_meal_{meal_number}"
meal_ref = (
db.collection("users")
.document(phone_number)
.collection("meals")
.document(meal_doc_id)
)
meal_doc = meal_ref.get()
if not meal_doc.exists:
users_to_remind.append({
"phone_number": phone_number,
"meal_number": meal_number,
"meal_time": meal_time,
})
return users_to_remind
def apply_missed_meal_penalty(phone_number: str, meal_number: int) -> dict:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
doc_ref = (
db.collection("users")
.document(phone_number)
.collection("penalties")
.document(f"{today}_meal_{meal_number}")
)
penalty_data = {
"date": today,
"meal_number": meal_number,
"reason": "missed_meal_photo",
"penalty_points": -10,
"timestamp": datetime.now(timezone.utc),
}
doc_ref.set(penalty_data)
return {"status": "penalty_applied", "points": -10}
What does this code do?
The get_users_due_for_reminder function checks all users to see if any have a meal scheduled for the current hour that has not been logged yet. Cloud Scheduler will call this every hour to find users who need a nudge.
The apply_missed_meal_penalty function writes a penalty document worth -10 points to a penalties subcollection. The deterministic document ID prevents double-penalizing the same missed meal.
- Save protein_punisher/services/firestore_service.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
Here is the complete firestore_service.py file for comparison:
from google.cloud import firestore
from datetime import datetime, timezone
db = firestore.Client()
def get_user_profile(phone_number: str) -> dict:
doc_ref = db.collection("users").document(phone_number)
doc = doc_ref.get()
if doc.exists:
return doc.to_dict()
return {}
def save_user_profile(phone_number: str, profile_data: dict) -> dict:
doc_ref = db.collection("users").document(phone_number)
doc_ref.set(profile_data, merge=True)
return {"status": "saved", "phone_number": phone_number}
def log_meal_entry(phone_number: str, food_items: str, protein_grams: float, meal_number: int) -> dict:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
doc_ref = (
db.collection("users")
.document(phone_number)
.collection("meals")
.document(f"{today}_meal_{meal_number}")
)
meal_data = {
"date": today,
"meal_number": meal_number,
"food_items": food_items,
"protein_grams": protein_grams,
"timestamp": datetime.now(timezone.utc),
}
doc_ref.set(meal_data)
return {"status": "logged", "protein_grams": protein_grams, "meal_number": meal_number}
def log_water_entry(phone_number: str, amount_ml: float) -> dict:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
doc_ref = (
db.collection("users")
.document(phone_number)
.collection("water")
.document()
)
water_data = {
"date": today,
"amount_ml": amount_ml,
"timestamp": datetime.now(timezone.utc),
}
doc_ref.set(water_data)
return {"status": "logged", "amount_ml": amount_ml}
def get_daily_stats(phone_number: str) -> dict:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
meals_ref = (
db.collection("users")
.document(phone_number)
.collection("meals")
.where("date", "==", today)
)
meals = [meal.to_dict() for meal in meals_ref.stream()]
total_protein = sum(m.get("protein_grams", 0) for m in meals)
meals_logged = len(meals)
water_ref = (
db.collection("users")
.document(phone_number)
.collection("water")
.where("date", "==", today)
)
total_water = sum(entry.to_dict().get("amount_ml", 0) for entry in water_ref.stream())
profile = get_user_profile(phone_number)
protein_target = profile.get("daily_protein_target", 0)
water_target = profile.get("daily_water_target", 0)
meal_count = profile.get("meal_count", 3)
return {
"total_protein_grams": total_protein,
"protein_target": protein_target,
"protein_percentage": round((total_protein / protein_target * 100) if protein_target > 0 else 0, 1),
"total_water_ml": total_water,
"water_target": water_target,
"water_percentage": round((total_water / water_target * 100) if water_target > 0 else 0, 1),
"meals_logged_today": meals_logged,
"meals_expected": meal_count,
}
def get_users_due_for_reminder() -> list:
now = datetime.now(timezone.utc)
current_hour = now.strftime("%H:00")
today = now.strftime("%Y-%m-%d")
users_to_remind = []
users_ref = db.collection("users").stream()
for user_doc in users_ref:
profile = user_doc.to_dict()
phone_number = user_doc.id
meal_times = profile.get("meal_times", [])
for i, meal_time in enumerate(meal_times):
if meal_time == current_hour:
meal_number = i + 1
meal_doc_id = f"{today}_meal_{meal_number}"
meal_ref = (
db.collection("users")
.document(phone_number)
.collection("meals")
.document(meal_doc_id)
)
meal_doc = meal_ref.get()
if not meal_doc.exists:
users_to_remind.append({
"phone_number": phone_number,
"meal_number": meal_number,
"meal_time": meal_time,
})
return users_to_remind
def apply_missed_meal_penalty(phone_number: str, meal_number: int) -> dict:
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
doc_ref = (
db.collection("users")
.document(phone_number)
.collection("penalties")
.document(f"{today}_meal_{meal_number}")
)
penalty_data = {
"date": today,
"meal_number": meal_number,
"reason": "missed_meal_photo",
"penalty_points": -10,
"timestamp": datetime.now(timezone.utc),
}
doc_ref.set(penalty_data)
return {"status": "penalty_applied", "points": -10}
Build ADK-compatible tool wrappers and test connectivity
Google ADK agents call Python functions as tools. Each tool function needs detailed docstrings with argument descriptions so the agent understands when and how to use them.
Why separate tools from services?
The service module handles raw database operations. The tools module wraps those operations with ADK-friendly interfaces including detailed docstrings that tell the AI agent what each parameter means.
This separation keeps your database logic reusable and your agent tools focused on describing themselves clearly.
- Open protein_punisher/tools/nutrition_tools.py and paste the complete tools module:
from protein_punisher.services.firestore_service import (
get_user_profile,
save_user_profile,
log_meal_entry,
log_water_entry,
get_daily_stats,
)
def get_profile(phone_number: str) -> dict:
"""Get the user profile including weight, protein target, water target, and meal schedule.
Args:
phone_number: The user's WhatsApp phone number.
Returns:
A dictionary with user profile data or empty dict if not found.
"""
return get_user_profile(phone_number)
def save_profile(
phone_number: str,
weight_kg: float,
fitness_goal: str,
daily_protein_target: float,
daily_water_target: float,
meal_count: int,
meal_times: list,
) -> dict:
"""Save or update the user profile with their health targets and meal schedule.
Args:
phone_number: The user's WhatsApp phone number.
weight_kg: User's body weight in kilograms.
fitness_goal: The user's fitness goal (e.g., muscle gain, fat loss, maintenance).
daily_protein_target: Daily protein target in grams.
daily_water_target: Daily water target in milliliters.
meal_count: Number of meals the user eats per day (2 or 3).
meal_times: List of meal times in HH:00 format (e.g., ["08:00", "13:00", "19:00"]).
Returns:
Confirmation that the profile was saved.
"""
profile_data = {
"weight_kg": weight_kg,
"fitness_goal": fitness_goal,
"daily_protein_target": daily_protein_target,
"daily_water_target": daily_water_target,
"meal_count": meal_count,
"meal_times": meal_times,
}
return save_user_profile(phone_number, profile_data)
def log_meal(phone_number: str, food_items: str, estimated_protein_grams: float, meal_number: int) -> dict:
"""Log a meal with estimated protein content.
Args:
phone_number: The user's WhatsApp phone number.
food_items: Description of food items in the meal.
estimated_protein_grams: Estimated protein in grams.
meal_number: Which meal this is (1, 2, or 3).
Returns:
Confirmation that the meal was logged.
"""
return log_meal_entry(phone_number, food_items, estimated_protein_grams, meal_number)
def log_water(phone_number: str, amount_ml: float) -> dict:
"""Log water intake in milliliters.
Args:
phone_number: The user's WhatsApp phone number.
amount_ml: Amount of water consumed in milliliters.
Returns:
Confirmation that the water was logged.
"""
return log_water_entry(phone_number, amount_ml)
def get_daily_summary(phone_number: str) -> dict:
"""Get the daily summary of protein and water intake vs targets, including meals logged.
Args:
phone_number: The user's WhatsApp phone number.
Returns:
A dictionary with total intake, targets, percentages, and meal progress.
"""
return get_daily_stats(phone_number)
What does this code do?
Each function has a detailed docstring with an Args section. Google ADK reads these docstrings to understand what each tool does and what arguments it expects. The agent uses this information to decide which tool to call based on the user's message.
Notice that save_profile explicitly lists every parameter rather than accepting a generic dictionary. This gives the agent clear instructions on what data to collect during onboarding.
- Save protein_punisher/tools/nutrition_tools.py.
- Verify Firestore connectivity by running this quick test from the project root:
python -c "from protein_punisher.services.firestore_service import get_user_profile; print(get_user_profile('test_user'))"
You should see {} printed in your terminal. This empty dictionary confirms your Python code can connect to Firestore and query documents successfully.
Seeing a DefaultCredentialsError?
This means ADC cannot find your credentials. Make sure you ran gcloud auth application-default login and completed the browser sign-in. Also confirm your GCP_PROJECT_ID in .env matches the project where you created the Firestore database.
Help me fix this DefaultCredentialsError.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete nutrition_tools.py file for comparison:
from protein_punisher.services.firestore_service import (
get_user_profile,
save_user_profile,
log_meal_entry,
log_water_entry,
get_daily_stats,
)
def get_profile(phone_number: str) -> dict:
"""Get the user profile including weight, protein target, water target, and meal schedule.
Args:
phone_number: The user's WhatsApp phone number.
Returns:
A dictionary with user profile data or empty dict if not found.
"""
return get_user_profile(phone_number)
def save_profile(
phone_number: str,
weight_kg: float,
fitness_goal: str,
daily_protein_target: float,
daily_water_target: float,
meal_count: int,
meal_times: list,
) -> dict:
"""Save or update the user profile with their health targets and meal schedule.
Args:
phone_number: The user's WhatsApp phone number.
weight_kg: User's body weight in kilograms.
fitness_goal: The user's fitness goal (e.g., muscle gain, fat loss, maintenance).
daily_protein_target: Daily protein target in grams.
daily_water_target: Daily water target in milliliters.
meal_count: Number of meals the user eats per day (2 or 3).
meal_times: List of meal times in HH:00 format (e.g., ["08:00", "13:00", "19:00"]).
Returns:
Confirmation that the profile was saved.
"""
profile_data = {
"weight_kg": weight_kg,
"fitness_goal": fitness_goal,
"daily_protein_target": daily_protein_target,
"daily_water_target": daily_water_target,
"meal_count": meal_count,
"meal_times": meal_times,
}
return save_user_profile(phone_number, profile_data)
def log_meal(phone_number: str, food_items: str, estimated_protein_grams: float, meal_number: int) -> dict:
"""Log a meal with estimated protein content.
Args:
phone_number: The user's WhatsApp phone number.
food_items: Description of food items in the meal.
estimated_protein_grams: Estimated protein in grams.
meal_number: Which meal this is (1, 2, or 3).
Returns:
Confirmation that the meal was logged.
"""
return log_meal_entry(phone_number, food_items, estimated_protein_grams, meal_number)
def log_water(phone_number: str, amount_ml: float) -> dict:
"""Log water intake in milliliters.
Args:
phone_number: The user's WhatsApp phone number.
amount_ml: Amount of water consumed in milliliters.
Returns:
Confirmation that the water was logged.
"""
return log_water_entry(phone_number, amount_ml)
def get_daily_summary(phone_number: str) -> dict:
"""Get the daily summary of protein and water intake vs targets, including meals logged.
Args:
phone_number: The user's WhatsApp phone number.
Returns:
A dictionary with total intake, targets, percentages, and meal progress.
"""
return get_daily_stats(phone_number)
Your database layer is complete. Next up, you will build the ProteinPunisher agent that uses these tools to analyze meals and hold users accountable.
Build the ProteinPunisher ADK Agent
Your Firestore tools are ready to log meals, track water, and manage user profiles. Now you need the brain that decides when to call each tool and what to say to the user.
In this step, you'll define the ProteinPunisher agent using Google ADK and wire it up with a runner module that bridges Flask's synchronous world to ADK's async internals. The agent uses Gemini 2.5 Flash for multimodal reasoning, meaning it can analyze food photos and estimate protein content directly from images.
In this step, get ready to:
- Define the ProteinPunisher agent with a personality instruction, custom tools, and Google Search grounding.
- Build the agent runner module with a synchronous wrapper for Flask.
- Verify the agent loads and responds correctly.
Define the root agent with personality and tools
A Google ADK agent needs three things: a model to power its reasoning, an instruction that defines its personality and behavior rules, and a set of tools it can call.
- Open protein_punisher/agents/protein_agent.py in your editor and add the following imports and instruction string:
from google.adk.agents import Agent
from google.adk.tools import google_search
from protein_punisher.tools.nutrition_tools import (
get_profile,
save_profile,
log_meal,
log_water,
get_daily_summary,
)
from protein_punisher.config.settings import GEMINI_MODEL
AGENT_INSTRUCTION = """You are the ProteinPunisher, a brutally honest but motivational AI health coach on WhatsApp.
Your personality:
- Sarcastic and witty when users eat low-protein meals or miss meals
- Genuinely celebratory when they hit their targets
- Use emojis liberally
- Keep responses concise (WhatsApp messages should be short)
- Be funny but never mean-spirited
- Extra harsh when users miss scheduled meals entirely
Your responsibilities:
1. ONBOARDING: If a user has no profile (get_profile returns empty), ask for:
- Their weight in kg
- Fitness goal (muscle gain, fat loss, maintenance)
- How many meals they eat per day (2 or 3)
- What times they eat (in HH:00 format, e.g., 08:00, 13:00, 19:00)
Calculate protein target as weight_kg * 1.6 grams/day. Calculate water target as weight_kg * 35 ml/day. Save everything using save_profile.
2. MEAL ANALYSIS: When the user sends a meal photo, identify the food items and estimate protein content per item. Sum total protein. Determine which meal number this is based on their schedule and time of day. Use log_meal to record it. Then compare against their daily target using get_daily_summary. PENALIZE if protein is below 20g per meal with sarcastic comments. CELEBRATE if above 30g.
3. WATER LOGGING: When a user mentions drinking water (e.g., 'drank 500ml', 'had a glass of water', 'water'), log it using log_water. A glass is approximately 250ml. Check their progress with get_daily_summary and comment if they are behind.
4. DAILY SUMMARY: When asked for a summary or progress, use get_daily_summary to show their stats including meals logged vs expected.
5. PROACTIVE REMINDER RESPONSE: When the system sends a reminder message (starting with [REMINDER]), respond with a punchy message demanding the meal photo. Be aggressive but funny. If it says [PENALTY], inform the user they just lost points for missing a meal.
6. NUTRITIONAL SUGGESTIONS: When a user is below their protein target, suggest high-protein foods. Use google_search to find protein content if you are unsure about a specific food.
IMPORTANT: The user's phone number is provided in the system context. Always use it when calling tools.
Never make up protein values. If you cannot estimate protein from the image, say so and ask the user to describe the meal.
"""
What does this instruction do?
The AGENT_INSTRUCTION string is the core of your agent's personality and decision-making logic. It tells Gemini exactly how to behave in every scenario.
- The personality section ensures responses are short, sarcastic, and WhatsApp-friendly.
- The responsibilities section routes different user inputs (photos, water mentions, summary requests) to the correct tool calls.
- The IMPORTANT section ensures the agent always passes the phone number to tools for Firestore lookups.
- Below the AGENT_INSTRUCTION string, add the agent definition that ties everything together:
root_agent = Agent(
model=GEMINI_MODEL,
name="protein_punisher",
description="A health accountability agent that tracks protein intake and water consumption via WhatsApp with proactive meal reminders.",
instruction=AGENT_INSTRUCTION,
tools=[get_profile, save_profile, log_meal, log_water, get_daily_summary, google_search],
)
What does this code do?
- model=GEMINI_MODEL points the agent at Gemini 2.5 Flash, which supports multimodal input (text and images).
- tools=[...] registers all five custom nutrition tools plus the built-in google_search tool. ADK automatically generates tool schemas from the function docstrings you wrote in the previous step.
- google_search is a built-in ADK tool that gives the agent access to real-time Google Search results. The agent uses it to look up protein content of foods it is uncertain about.
Why google_search instead of a nutrition API?
A dedicated nutrition API would require another API key, quota management, and error handling. The google_search built-in tool is free, requires no extra configuration, and gives Gemini access to the entire web for grounding its protein estimates in real data.
One limitation: google_search can only be assigned to one agent at a time. Since you only have one agent, this is not an issue.
- Save protein_punisher/agents/protein_agent.py.
Seeing import errors in your editor?
- Make sure your virtual environment is activated. Your editor's Python interpreter should point to the one inside your project's virtual environment.
- Confirm you installed google-adk in the previous step. Run pip list | grep google-adk to check.
Why is my editor showing import errors for google-adk?
Implement the agent runner module
ADK agents run asynchronously, but Flask routes are synchronous. You need a runner module that bridges the two worlds.
- Open protein_punisher/agents/runner.py and add the imports and module-level singletons:
import asyncio
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
from protein_punisher.agents.protein_agent import root_agent
from protein_punisher.config.settings import APP_NAME
# Initialize services (module-level singletons)
session_service = InMemorySessionService()
runner = Runner(
agent=root_agent,
app_name=APP_NAME,
session_service=session_service,
)
What does this code do?
- InMemorySessionService stores conversation history in memory. If the Cloud Run instance restarts, chat history resets, but all user data persists in Firestore.
- Runner orchestrates the agent's execution loop. It sends messages to Gemini, handles tool calls, and returns the final response.
- Both are created as module-level singletons so they persist across Flask requests without being re-initialized.
- Below the singletons, add the async helper functions that manage sessions and get responses:
async def _ensure_session(user_id: str, session_id: str):
"""Create a session if it does not already exist."""
existing = await session_service.get_session(
app_name=APP_NAME, user_id=user_id, session_id=session_id
)
if existing is None:
await session_service.create_session(
app_name=APP_NAME, user_id=user_id, session_id=session_id
)
async def _get_response(user_id: str, session_id: str, content: types.Content) -> str:
"""Run the agent and return the final text response."""
await _ensure_session(user_id, session_id)
final_response = "Sorry, I could not process that. Try again."
async for event in runner.run_async(
user_id=user_id,
session_id=session_id,
new_message=content,
):
if event.is_final_response():
if event.content and event.content.parts:
final_response = event.content.parts[0].text or final_response
break
return final_response
What does this code do?
- _ensure_session checks if a session already exists for this user before creating a new one. ADK raises an error if you try to create a session with an ID that already exists.
- _get_response sends the user's message to the agent and iterates through events until it finds the final response. The agent may make multiple tool calls before producing its answer.
- run_async is an async generator that yields events. Each event represents a step in the agent's reasoning (tool call, tool result, or final answer).
- Finally, add the synchronous wrapper function that Flask will call:
def get_agent_response(phone_number: str, message_text: str, image_bytes: bytes = None, image_mime: str = None) -> str:
"""Synchronous wrapper to get a response from the ProteinPunisher agent.
Args:
phone_number: The user's phone number (used as user_id and session_id).
message_text: The text message from the user.
image_bytes: Optional image data if the user sent a photo.
image_mime: MIME type of the image (e.g., image/jpeg).
Returns:
The agent's text response.
"""
# Build multimodal content parts
parts = []
# Add system context with the phone number
context_text = f"[User phone: {phone_number}] {message_text}"
parts.append(types.Part.from_text(text=context_text))
# Add image if present
if image_bytes and image_mime:
parts.append(types.Part.from_bytes(data=image_bytes, mime_type=image_mime))
content = types.Content(role="user", parts=parts)
# Use phone number as both user_id and session_id for persistence
return asyncio.run(_get_response(
user_id=phone_number,
session_id=phone_number,
content=content,
))
What does this code do?
- get_agent_response is the single entry point that Flask routes will call. It handles both text-only messages and multimodal messages (text + image).
- The phone number is prepended to the message as context so the agent knows which user's tools to call. It is also used as the session ID so conversations persist across messages.
- asyncio.run() bridges Flask's synchronous routes to ADK's async runner. Each request gets its own event loop.
- Save protein_punisher/agents/runner.py.
Confused about async vs sync?
Flask processes one request at a time per worker thread. ADK requires async/await internally. The asyncio.run() call creates a fresh event loop for each request, runs the async agent code, and returns the result synchronously. You do not need to understand async Python deeply for this project.
Help me understand how asyncio.run bridges sync Flask to async ADK
Verify the agent loads correctly
Before building the Flask server, confirm that the agent module imports cleanly and the runner initializes without errors.
- From your project root directory, test the import by running this command:
python -c "from protein_punisher.agents.runner import get_agent_response; print('Agent loaded successfully!')"
- You should see Agent loaded successfully! printed in your terminal. This confirms that all imports resolve, the agent is instantiated, and the runner is ready to process messages.
Seeing a ModuleNotFoundError?
- Make sure you are running the command from your project root (the folder that contains the protein_punisher/ directory, not from inside it).
- Confirm your virtual environment is activated. You should see (.venv) in your terminal prompt.
- Check that both protein_punisher/__init__.py and protein_punisher/agents/__init__.py exist (they can be empty but must be present).
Help me debug my ModuleNotFoundError when importing the agent runner
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete protein_agent.py:
from google.adk.agents import Agent
from google.adk.tools import google_search
from protein_punisher.tools.nutrition_tools import (
get_profile,
save_profile,
log_meal,
log_water,
get_daily_summary,
)
from protein_punisher.config.settings import GEMINI_MODEL
AGENT_INSTRUCTION = """You are the ProteinPunisher, a brutally honest but motivational AI health coach on WhatsApp.
Your personality:
- Sarcastic and witty when users eat low-protein meals or miss meals
- Genuinely celebratory when they hit their targets
- Use emojis liberally
- Keep responses concise (WhatsApp messages should be short)
- Be funny but never mean-spirited
- Extra harsh when users miss scheduled meals entirely
Your responsibilities:
1. ONBOARDING: If a user has no profile (get_profile returns empty), ask for:
- Their weight in kg
- Fitness goal (muscle gain, fat loss, maintenance)
- How many meals they eat per day (2 or 3)
- What times they eat (in HH:00 format, e.g., 08:00, 13:00, 19:00)
Calculate protein target as weight_kg * 1.6 grams/day. Calculate water target as weight_kg * 35 ml/day. Save everything using save_profile.
2. MEAL ANALYSIS: When the user sends a meal photo, identify the food items and estimate protein content per item. Sum total protein. Determine which meal number this is based on their schedule and time of day. Use log_meal to record it. Then compare against their daily target using get_daily_summary. PENALIZE if protein is below 20g per meal with sarcastic comments. CELEBRATE if above 30g.
3. WATER LOGGING: When a user mentions drinking water (e.g., 'drank 500ml', 'had a glass of water', 'water'), log it using log_water. A glass is approximately 250ml. Check their progress with get_daily_summary and comment if they are behind.
4. DAILY SUMMARY: When asked for a summary or progress, use get_daily_summary to show their stats including meals logged vs expected.
5. PROACTIVE REMINDER RESPONSE: When the system sends a reminder message (starting with [REMINDER]), respond with a punchy message demanding the meal photo. Be aggressive but funny. If it says [PENALTY], inform the user they just lost points for missing a meal.
6. NUTRITIONAL SUGGESTIONS: When a user is below their protein target, suggest high-protein foods. Use google_search to find protein content if you are unsure about a specific food.
IMPORTANT: The user's phone number is provided in the system context. Always use it when calling tools.
Never make up protein values. If you cannot estimate protein from the image, say so and ask the user to describe the meal.
"""
root_agent = Agent(
model=GEMINI_MODEL,
name="protein_punisher",
description="A health accountability agent that tracks protein intake and water consumption via WhatsApp with proactive meal reminders.",
instruction=AGENT_INSTRUCTION,
tools=[get_profile, save_profile, log_meal, log_water, get_daily_summary, google_search],
)
And here is the complete runner.py:
import asyncio
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
from protein_punisher.agents.protein_agent import root_agent
from protein_punisher.config.settings import APP_NAME
session_service = InMemorySessionService()
runner = Runner(
agent=root_agent,
app_name=APP_NAME,
session_service=session_service,
)
async def _ensure_session(user_id: str, session_id: str):
existing = await session_service.get_session(
app_name=APP_NAME, user_id=user_id, session_id=session_id
)
if existing is None:
await session_service.create_session(
app_name=APP_NAME, user_id=user_id, session_id=session_id
)
async def _get_response(user_id: str, session_id: str, content: types.Content) -> str:
await _ensure_session(user_id, session_id)
final_response = "Sorry, I could not process that. Try again."
async for event in runner.run_async(
user_id=user_id,
session_id=session_id,
new_message=content,
):
if event.is_final_response():
if event.content and event.content.parts:
final_response = event.content.parts[0].text or final_response
break
return final_response
def get_agent_response(phone_number: str, message_text: str, image_bytes: bytes = None, image_mime: str = None) -> str:
parts = []
context_text = f"[User phone: {phone_number}] {message_text}"
parts.append(types.Part.from_text(text=context_text))
if image_bytes and image_mime:
parts.append(types.Part.from_bytes(data=image_bytes, mime_type=image_mime))
content = types.Content(role="user", parts=parts)
return asyncio.run(_get_response(
user_id=phone_number,
session_id=phone_number,
content=content,
))
Your agent is defined and its runner is ready to process messages. Next up, you'll build the Flask server that receives WhatsApp messages from Twilio and routes them through this agent.
Build the Flask Server with Webhook and Reminder Endpoints
Your agent can think, analyze images, and call tools. But right now it has no way to receive messages from WhatsApp or send proactive reminders.
In this step, you will build the Flask web server that connects everything together. It receives Twilio webhook requests (including meal photos), routes them to the agent, and exposes a reminder endpoint that Cloud Scheduler will hit every hour.
In this step, get ready to:
- Create the reminder service that sends proactive WhatsApp messages via Twilio.
- Build the Flask application with webhook, reminder, dashboard, and health check endpoints.
- Write unit tests to verify your Firestore tools and target calculations.
Create the reminder service
The reminder service is the module responsible for sending proactive WhatsApp messages. When Cloud Scheduler triggers a reminder check, this service queries Firestore for users who have a meal due, generates a snarky nudge via the agent, and sends it through Twilio.
- Create and open a new file at protein_punisher/services/reminder_service.py.
- Paste the following code:
import os
from twilio.rest import Client
from protein_punisher.services.firestore_service import (
get_users_due_for_reminder,
apply_missed_meal_penalty,
)
from protein_punisher.agents.runner import get_agent_response
from protein_punisher.config.settings import (
TWILIO_ACCOUNT_SID,
TWILIO_AUTH_TOKEN,
TWILIO_WHATSAPP_NUMBER,
)
def send_whatsapp_message(to_number: str, body: str):
"""Send a WhatsApp message via Twilio."""
client = Client(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
client.messages.create(
from_=TWILIO_WHATSAPP_NUMBER,
to=to_number,
body=body,
)
def process_meal_reminders():
"""Check for users due for a meal and send reminders or apply penalties.
This function is called by Cloud Scheduler every hour.
It does two things:
1. Sends a reminder to users whose meal time is NOW
2. Applies a penalty to users whose meal time was in the PREVIOUS hour and still not logged
"""
users_due = get_users_due_for_reminder()
results = []
for user_info in users_due:
phone_number = user_info["phone_number"]
meal_number = user_info["meal_number"]
# Generate a reminder message using the agent
reminder_prompt = f"[REMINDER] It is meal {meal_number} time. The user has not submitted their meal photo yet. Nudge them aggressively."
agent_message = get_agent_response(
phone_number=phone_number,
message_text=reminder_prompt,
)
# Send via Twilio
try:
send_whatsapp_message(phone_number, agent_message)
results.append({"phone": phone_number, "status": "reminded", "meal": meal_number})
except Exception as e:
results.append({"phone": phone_number, "status": "failed", "error": str(e)})
return results
What does this code do?
This module has two functions that work together for proactive messaging.
- send_whatsapp_message creates a Twilio client and sends a WhatsApp message to the specified number.
- process_meal_reminders queries Firestore for users who have a meal due right now, uses the agent to generate a personalized nudge, then sends it. If the send fails, it logs the error instead of crashing.
- Save reminder_service.py.
Why does the agent generate the reminder text?
Instead of hard-coding a boring "Time to eat!" message, the reminder passes a [REMINDER] prompt to the agent. The agent's sarcastic personality generates a unique, funny nudge every time. This keeps the user engaged instead of ignoring repetitive notifications.
Build the Flask application with all endpoints
The main application file ties everything together. It creates a Flask server with four routes: a webhook for incoming WhatsApp messages, a reminder endpoint for Cloud Scheduler, a dashboard for viewing stats, and a health check for Cloud Run.
- Create and open protein_punisher/main.py.
- Start by pasting the imports, app setup, and the HTML dashboard template:
import os
import requests as http_requests
from flask import Flask, request, render_template_string, jsonify
from twilio.twiml.messaging_response import MessagingResponse
from twilio.request_validator import RequestValidator
from protein_punisher.agents.runner import get_agent_response
from protein_punisher.services.firestore_service import get_user_profile, get_daily_stats
from protein_punisher.services.reminder_service import process_meal_reminders
from protein_punisher.config.settings import TWILIO_AUTH_TOKEN
app = Flask(__name__)
# HTML template for the stats dashboard
DASHBOARD_HTML = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ProteinPunisher Dashboard</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; background: #1a1a2e; color: #eee; }
h1 { color: #e94560; }
.card { background: #16213e; padding: 20px; border-radius: 12px; margin: 16px 0; }
.stat { font-size: 2em; font-weight: bold; }
.protein { color: #e94560; }
.water { color: #4ea8de; }
.meals { color: #a8e6cf; }
.progress-bar { background: #0f3460; border-radius: 8px; height: 20px; margin: 8px 0; }
.progress-fill { height: 100%; border-radius: 8px; transition: width 0.3s; }
.protein-fill { background: #e94560; }
.water-fill { background: #4ea8de; }
input { padding: 10px; border-radius: 8px; border: 1px solid #0f3460; background: #1a1a2e; color: #eee; width: 200px; }
button { padding: 10px 20px; background: #e94560; color: white; border: none; border-radius: 8px; cursor: pointer; }
</style>
</head>
<body>
<h1>ProteinPunisher Dashboard</h1>
{% if error %}
<div class="card"><p>{{ error }}</p></div>
{% elif stats %}
<div class="card">
<h2>Meals Today</h2>
<p class="stat meals">{{ stats.meals_logged_today }} / {{ stats.meals_expected }} meals logged</p>
</div>
<div class="card">
<h2>Protein</h2>
<p class="stat protein">{{ stats.total_protein_grams }}g / {{ stats.protein_target }}g</p>
<div class="progress-bar"><div class="progress-fill protein-fill" style="width: {{ [stats.protein_percentage, 100] | min }}%"></div></div>
<p>{{ stats.protein_percentage }}% of daily target</p>
</div>
<div class="card">
<h2>Water</h2>
<p class="stat water">{{ stats.total_water_ml }}ml / {{ stats.water_target }}ml</p>
<div class="progress-bar"><div class="progress-fill water-fill" style="width: {{ [stats.water_percentage, 100] | min }}%"></div></div>
<p>{{ stats.water_percentage }}% of daily target</p>
</div>
{% else %}
<div class="card">
<form method="GET">
<p>Enter your WhatsApp number (with country code):</p>
<input type="text" name="phone" placeholder="+1234567890">
<button type="submit">View Stats</button>
</form>
</div>
{% endif %}
</body>
</html>
"""
What does this code do?
- import requests as http_requests aliases the requests library so it does not conflict with Flask's request object.
- RequestValidator from Twilio verifies that incoming webhook requests genuinely come from Twilio (not a random attacker).
- DASHBOARD_HTML is a Jinja2 template string that renders protein, water, and meal progress as styled cards with progress bars.
- Next, add the /webhook endpoint below the DASHBOARD_HTML string. This is the route that Twilio calls every time a user sends a WhatsApp message:
@app.route("/webhook", methods=["POST"])
def webhook():
"""Handle incoming WhatsApp messages from Twilio."""
# Validate Twilio request
validator = RequestValidator(TWILIO_AUTH_TOKEN)
request_url = request.url
request_form = request.form.to_dict()
signature = request.headers.get("X-Twilio-Signature", "")
if not validator.validate(request_url, request_form, signature):
return "Forbidden", 403
# Extract message details
from_number = request.form.get("From", "")
body = request.form.get("Body", "").strip()
num_media = int(request.form.get("NumMedia", 0))
# Handle image if present
image_bytes = None
image_mime = None
if num_media > 0:
media_url = request.form.get("MediaUrl0", "")
media_content_type = request.form.get("MediaContentType0", "image/jpeg")
if media_url:
# Download the image from Twilio
response = http_requests.get(
media_url,
auth=(os.getenv("TWILIO_ACCOUNT_SID"), TWILIO_AUTH_TOKEN),
)
if response.status_code == 200:
image_bytes = response.content
image_mime = media_content_type
# Default message if only image sent
if not body and image_bytes:
body = "Analyze this meal photo and estimate the protein content."
# Get agent response
agent_reply = get_agent_response(
phone_number=from_number,
message_text=body,
image_bytes=image_bytes,
image_mime=image_mime,
)
# Build TwiML response
twiml = MessagingResponse()
twiml.message(agent_reply)
return str(twiml), 200, {"Content-Type": "text/xml"}
What does the webhook do?
This endpoint handles the full lifecycle of an incoming WhatsApp message.
- First, it validates the request signature to confirm it actually came from Twilio. Faked requests get a 403 response.
- If the user attached a photo, the server downloads the image bytes from Twilio's hosted URL using HTTP Basic auth.
- The text (and optional image) get passed to get_agent_response, which sends them to Gemini via ADK.
- The agent's reply is wrapped in TwiML (Twilio's XML format) so Twilio can deliver it back as a WhatsApp message.
- Finally, add the remaining three endpoints below the webhook function: /remind, /dashboard, and /health:
@app.route("/remind", methods=["POST"])
def remind():
"""Endpoint called by Cloud Scheduler to send meal reminders.
This checks all users for pending meals and sends WhatsApp nudges.
"""
results = process_meal_reminders()
return jsonify({"status": "processed", "reminders_sent": len(results), "details": results}), 200
@app.route("/dashboard", methods=["GET"])
def dashboard():
"""Simple web dashboard to view user stats."""
phone = request.args.get("phone", "")
if not phone:
return render_template_string(DASHBOARD_HTML, stats=None, error=None)
# Format phone number for lookup
lookup_phone = f"whatsapp:{phone}" if not phone.startswith("whatsapp:") else phone
profile = get_user_profile(lookup_phone)
if not profile:
return render_template_string(DASHBOARD_HTML, stats=None, error="No profile found for this number. Send a message to ProteinPunisher on WhatsApp first!")
stats = get_daily_stats(lookup_phone)
return render_template_string(DASHBOARD_HTML, stats=stats, error=None)
@app.route("/health", methods=["GET"])
def health():
"""Health check endpoint for Cloud Run."""
return "OK", 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, debug=True)
What do these endpoints do?
- /remind is what Cloud Scheduler hits every hour. It calls process_meal_reminders() and returns a JSON summary of who got reminded.
- /dashboard renders the HTML template with live stats from Firestore. It prepends whatsapp: to the phone number since that is how Twilio stores it.
- /health returns a simple 200 OK. Cloud Run uses this to know your container is alive.
- Save protein_punisher/main.py.
Seeing import errors?
- Make sure your virtual environment is activated. You should see (.venv) in your terminal prompt.
- Confirm that flask and twilio are listed in your requirements.txt and were installed with pip install -r requirements.txt.
Why am I getting ModuleNotFoundError for flask or twilio?
✔️ Awesome, I've got everything!
Double check you have saved both reminder_service.py and main.py.
ⓧ I'd like to double check the full code
Here is the complete reminder_service.py:
import os
from twilio.rest import Client
from protein_punisher.services.firestore_service import (
get_users_due_for_reminder,
apply_missed_meal_penalty,
)
from protein_punisher.agents.runner import get_agent_response
from protein_punisher.config.settings import (
TWILIO_ACCOUNT_SID,
TWILIO_AUTH_TOKEN,
TWILIO_WHATSAPP_NUMBER,
)
def send_whatsapp_message(to_number: str, body: str):
client = Client(TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN)
client.messages.create(
from_=TWILIO_WHATSAPP_NUMBER,
to=to_number,
body=body,
)
def process_meal_reminders():
users_due = get_users_due_for_reminder()
results = []
for user_info in users_due:
phone_number = user_info["phone_number"]
meal_number = user_info["meal_number"]
reminder_prompt = f"[REMINDER] It is meal {meal_number} time. The user has not submitted their meal photo yet. Nudge them aggressively."
agent_message = get_agent_response(
phone_number=phone_number,
message_text=reminder_prompt,
)
try:
send_whatsapp_message(phone_number, agent_message)
results.append({"phone": phone_number, "status": "reminded", "meal": meal_number})
except Exception as e:
results.append({"phone": phone_number, "status": "failed", "error": str(e)})
return results
Here is the complete main.py:
import os
import requests as http_requests
from flask import Flask, request, render_template_string, jsonify
from twilio.twiml.messaging_response import MessagingResponse
from twilio.request_validator import RequestValidator
from protein_punisher.agents.runner import get_agent_response
from protein_punisher.services.firestore_service import get_user_profile, get_daily_stats
from protein_punisher.services.reminder_service import process_meal_reminders
from protein_punisher.config.settings import TWILIO_AUTH_TOKEN
app = Flask(__name__)
DASHBOARD_HTML = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>ProteinPunisher Dashboard</title>
<style>
body { font-family: system-ui, sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; background: #1a1a2e; color: #eee; }
h1 { color: #e94560; }
.card { background: #16213e; padding: 20px; border-radius: 12px; margin: 16px 0; }
.stat { font-size: 2em; font-weight: bold; }
.protein { color: #e94560; }
.water { color: #4ea8de; }
.meals { color: #a8e6cf; }
.progress-bar { background: #0f3460; border-radius: 8px; height: 20px; margin: 8px 0; }
.progress-fill { height: 100%; border-radius: 8px; transition: width 0.3s; }
.protein-fill { background: #e94560; }
.water-fill { background: #4ea8de; }
input { padding: 10px; border-radius: 8px; border: 1px solid #0f3460; background: #1a1a2e; color: #eee; width: 200px; }
button { padding: 10px 20px; background: #e94560; color: white; border: none; border-radius: 8px; cursor: pointer; }
</style>
</head>
<body>
<h1>ProteinPunisher Dashboard</h1>
{% if error %}
<div class="card"><p>{{ error }}</p></div>
{% elif stats %}
<div class="card">
<h2>Meals Today</h2>
<p class="stat meals">{{ stats.meals_logged_today }} / {{ stats.meals_expected }} meals logged</p>
</div>
<div class="card">
<h2>Protein</h2>
<p class="stat protein">{{ stats.total_protein_grams }}g / {{ stats.protein_target }}g</p>
<div class="progress-bar"><div class="progress-fill protein-fill" style="width: {{ [stats.protein_percentage, 100] | min }}%"></div></div>
<p>{{ stats.protein_percentage }}% of daily target</p>
</div>
<div class="card">
<h2>Water</h2>
<p class="stat water">{{ stats.total_water_ml }}ml / {{ stats.water_target }}ml</p>
<div class="progress-bar"><div class="progress-fill water-fill" style="width: {{ [stats.water_percentage, 100] | min }}%"></div></div>
<p>{{ stats.water_percentage }}% of daily target</p>
</div>
{% else %}
<div class="card">
<form method="GET">
<p>Enter your WhatsApp number (with country code):</p>
<input type="text" name="phone" placeholder="+1234567890">
<button type="submit">View Stats</button>
</form>
</div>
{% endif %}
</body>
</html>
"""
@app.route("/webhook", methods=["POST"])
def webhook():
validator = RequestValidator(TWILIO_AUTH_TOKEN)
request_url = request.url
request_form = request.form.to_dict()
signature = request.headers.get("X-Twilio-Signature", "")
if not validator.validate(request_url, request_form, signature):
return "Forbidden", 403
from_number = request.form.get("From", "")
body = request.form.get("Body", "").strip()
num_media = int(request.form.get("NumMedia", 0))
image_bytes = None
image_mime = None
if num_media > 0:
media_url = request.form.get("MediaUrl0", "")
media_content_type = request.form.get("MediaContentType0", "image/jpeg")
if media_url:
response = http_requests.get(
media_url,
auth=(os.getenv("TWILIO_ACCOUNT_SID"), TWILIO_AUTH_TOKEN),
)
if response.status_code == 200:
image_bytes = response.content
image_mime = media_content_type
if not body and image_bytes:
body = "Analyze this meal photo and estimate the protein content."
agent_reply = get_agent_response(
phone_number=from_number,
message_text=body,
image_bytes=image_bytes,
image_mime=image_mime,
)
twiml = MessagingResponse()
twiml.message(agent_reply)
return str(twiml), 200, {"Content-Type": "text/xml"}
@app.route("/remind", methods=["POST"])
def remind():
results = process_meal_reminders()
return jsonify({"status": "processed", "reminders_sent": len(results), "details": results}), 200
@app.route("/dashboard", methods=["GET"])
def dashboard():
phone = request.args.get("phone", "")
if not phone:
return render_template_string(DASHBOARD_HTML, stats=None, error=None)
lookup_phone = f"whatsapp:{phone}" if not phone.startswith("whatsapp:") else phone
profile = get_user_profile(lookup_phone)
if not profile:
return render_template_string(DASHBOARD_HTML, stats=None, error="No profile found for this number. Send a message to ProteinPunisher on WhatsApp first!")
stats = get_daily_stats(lookup_phone)
return render_template_string(DASHBOARD_HTML, stats=stats, error=None)
@app.route("/health", methods=["GET"])
def health():
return "OK", 200
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8080, debug=True)
Write unit tests for your tools
Before testing the full flow end-to-end, it is good practice to verify that your Firestore service and calculation logic work correctly in isolation. You will write a small test suite using Python's built-in unittest module.
- Create a tests/ directory at the root of your project (next to protein_punisher/) by running:
mkdir tests
- Create and open tests/test_tools.py and paste the following test code:
"""Basic tests for the nutrition tools and services."""
import unittest
from unittest.mock import patch, MagicMock
class TestNutritionTools(unittest.TestCase):
"""Test the nutrition tool functions."""
@patch("protein_punisher.services.firestore_service.db")
def test_get_user_profile_exists(self, mock_db):
"""Test retrieving an existing user profile."""
mock_doc = MagicMock()
mock_doc.exists = True
mock_doc.to_dict.return_value = {
"weight_kg": 75,
"daily_protein_target": 120,
"meal_count": 3,
"meal_times": ["08:00", "13:00", "19:00"],
}
mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
from protein_punisher.services.firestore_service import get_user_profile
result = get_user_profile("whatsapp:+1234567890")
self.assertEqual(result["weight_kg"], 75)
self.assertEqual(result["meal_count"], 3)
@patch("protein_punisher.services.firestore_service.db")
def test_get_user_profile_not_found(self, mock_db):
"""Test retrieving a non-existent user profile."""
mock_doc = MagicMock()
mock_doc.exists = False
mock_db.collection.return_value.document.return_value.get.return_value = mock_doc
from protein_punisher.services.firestore_service import get_user_profile
result = get_user_profile("whatsapp:+0000000000")
self.assertEqual(result, {})
def test_protein_target_calculation(self):
"""Test that protein target is calculated correctly (weight * 1.6)."""
weight_kg = 80
expected_target = weight_kg * 1.6
self.assertEqual(expected_target, 128.0)
def test_water_target_calculation(self):
"""Test that water target is calculated correctly (weight * 35)."""
weight_kg = 70
expected_target = weight_kg * 35
self.assertEqual(expected_target, 2450)
def test_meal_times_format(self):
"""Test that meal times follow HH:00 format."""
valid_times = ["08:00", "13:00", "19:00"]
for t in valid_times:
self.assertEqual(len(t), 5)
self.assertTrue(t.endswith(":00"))
if __name__ == "__main__":
unittest.main()
What do these tests cover?
- The first two tests use @patch to mock the Firestore client. This lets you test your logic without needing a live database connection.
- The calculation tests verify the protein formula (weight x 1.6) and water formula (weight x 35) that the agent uses during onboarding.
- The format test confirms meal times follow the expected HH:00 pattern that the reminder service relies on.
- Save tests/test_tools.py.
- Run the tests by executing this command from your project root:
python -m pytest tests/test_tools.py -v
You should see all 5 tests pass with green output.
Tests failing or not running?
- If you see ModuleNotFoundError: No module named 'protein_punisher', make sure you are running the command from the project root directory (the folder containing both protein_punisher/ and tests/).
- If pytest is not installed, run pip install pytest first. You can also run the tests with python -m unittest tests.test_tools -v instead.
Why are my unit tests failing with import errors?
Your Flask server is fully built with all four endpoints wired up. Next, you will test the complete flow end-to-end by running the app locally and connecting it to Twilio via ngrok.
Test End-to-End Locally with ngrok
You have built every piece of ProteinPunisher. The agent, tools, Firestore service, reminder logic, and Flask server are all in place. Now you need to prove the whole system works together before deploying to the cloud.
In this step, you will run your unit tests, start the Flask server, expose it to the internet with ngrok, and test the full WhatsApp flow from onboarding to meal analysis.
In this step, get ready to:
- Run the unit tests to verify your tools and services.
- Start the Flask server and expose it with ngrok.
- Test the full WhatsApp flow and endpoint functionality.
Run the unit tests
Before connecting external services, verify that your tool functions and business logic work correctly in isolation.
- Make sure your virtual environment is active and you are inside the project root directory (the folder that contains the protein_punisher package).
- Run the test suite by running this command:
python -m unittest discover tests/
What does this command do?
The discover flag tells Python's unittest module to find all test files in the tests/ directory automatically.
The tests mock Firestore so they run without a live database connection. They verify profile retrieval logic, protein/water target calculations, and meal time format validation.
- You should see output showing all 5 tests passing:
.....
----------------------------------------------------------------------
Ran 5 tests in 0.01s
OK
Tests failing with import errors?
Make sure you are running the command from the project root (the folder containing protein_punisher/ and tests/), not from inside the protein_punisher package itself.
Also confirm your virtual environment is activated. You should see (.venv) at the start of your terminal prompt.
help me debug my failing unit tests
Start the Flask server and expose it with ngrok
Your Flask app needs to be reachable from the internet so that Twilio can forward WhatsApp messages to it. The ngrok tool creates a secure tunnel from a public HTTPS URL to your local machine.
- Start the Flask development server by running this command:
python -m protein_punisher.main
- You should see Flask start up on port 8080 with output like Running on http://0.0.0.0:8080.
- Open a second terminal window. Make sure you are in the same project directory with your virtual environment active.
- Start the ngrok tunnel by running this command:
ngrok http 8080
What does ngrok do here?
ngrok opens an outbound TLS connection to the ngrok cloud and assigns your server a public HTTPS URL. All traffic to that URL gets forwarded through the encrypted tunnel to your local port 8080.
No firewall changes or port forwarding required. The tunnel stays active as long as the ngrok process runs.
- Look for the Forwarding line in the ngrok console output. It shows your public URL (for example, https://1aaa-123-45-678-910.ngrok-free.app).
- Copy this HTTPS URL. You will need it in the next substep.
Keep both terminals open
You now need two terminal windows running simultaneously. One runs Flask, the other runs ngrok. Closing either will break the connection.
ngrok command not found?
Confirm ngrok is installed by running ngrok help. If it is not recognized, revisit the ngrok installation step.
If ngrok starts but immediately shows an auth error, run ngrok config add-authtoken YOUR_TOKEN with your token from the ngrok dashboard.
help me fix my ngrok setup
Update Twilio and test the full WhatsApp flow
With your server exposed, you need to point Twilio's WhatsApp Sandbox at your ngrok URL so incoming messages reach your agent.
- Open the Twilio Console in your browser.
- Navigate to Messaging, then Try it Out, then Send a WhatsApp message.
- Click Sandbox settings.
- In the Sandbox Configuration section, find the When a message comes in field.
- Paste your ngrok forwarding URL with /webhook appended (for example, https://1aaa-123-45-678-910.ngrok-free.app/webhook).
- Make sure the dropdown to the right is set to HTTP Post.
- Click Save at the bottom of the page.
Why append /webhook?
Your Flask app defines the /webhook route to handle incoming Twilio POST requests. Without that path, Twilio would hit the root URL and get a 404.
Now test the onboarding flow by sending a message from your phone.
- Open WhatsApp on your phone.
- Send any message (like Hello) to the Twilio Sandbox number (+14155238886).
- The ProteinPunisher agent should respond by asking for your weight, fitness goal, meal count, and meal times. Answer each question.
- After completing onboarding, send a photo of a meal. The agent should analyze it and estimate protein content.
- Send a water message like drank 500ml water. The agent should log it and report your progress.
What if the agent asks me to rejoin the sandbox?
Twilio Sandbox sessions expire after 3 days. If you see a message about joining the sandbox, send the join code displayed on your Twilio Sandbox page (for example, join cozy-falcon) from WhatsApp first, then try again.
Next, test the reminder endpoint manually to confirm it works before Cloud Scheduler calls it in production.
- Open a third terminal window.
- Send a POST request to the /remind endpoint by running this command:
curl -X POST http://localhost:8080/remind
- You should see a JSON response like {"status": "processed", "reminders_sent": 0, "details": []}. The count depends on whether your current hour matches one of your configured meal times.
Why is reminders_sent 0?
The reminder system checks if the current UTC hour matches any user's meal time AND that meal has not been logged yet. If no meals are due right now, you get 0 reminders. This confirms the logic is working correctly.
Finally, verify the dashboard shows your stats.
- Open your browser and navigate to http://localhost:8080/dashboard.
- Enter your WhatsApp phone number (with country code, for example +1234567890) and click View Stats.
- You should see your protein intake, water intake, and meals logged from the onboarding test.
Dashboard says no profile found?
The phone number stored in Firestore includes the whatsapp: prefix that Twilio adds. The dashboard automatically prepends this, so just enter your number with the country code (like +1234567890).
If it still does not work, check that your Flask terminal shows incoming requests. If you see no requests when you click View Stats, verify you are hitting localhost:8080 and not the ngrok URL.
help me debug my ProteinPunisher dashboard
Your entire application is working end-to-end locally. Next up, you will containerize it with Docker and deploy to Cloud Run so the agent runs 24/7 without your laptop.
Dockerize and Deploy to Cloud Run
Your ProteinPunisher agent works locally. Now it is time to ship it to the cloud so it runs 24/7 without ngrok.
In this step you will containerize the Flask app with Docker, deploy it to Cloud Run, and wire up Cloud Scheduler to fire hourly meal reminders.
In this step, get ready to:
- Write a Dockerfile that packages the app with gunicorn.
- Deploy the container to Google Cloud Run with environment variables.
- Create a Cloud Scheduler job that POSTs to the /remind endpoint every hour.
Write the Dockerfile
Cloud Run needs a container image to run your app. A Dockerfile tells Docker how to package your Python code, install dependencies, and start the server.
- Create a file called Dockerfile in the root of your protein_punisher project folder (next to requirements.txt).
- Paste the following into the file:
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PORT=8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "4", "protein_punisher.main:app"]
What does this Dockerfile do?
- FROM python:3.11-slim starts from a lightweight Python image.
- COPY requirements.txt and RUN pip install installs your dependencies in a cached layer so rebuilds are fast.
- COPY . . copies all your source code into the container.
- CMD starts gunicorn with 1 worker and 4 threads. Cloud Run handles scaling by spinning up more container instances rather than more workers inside one container.
- Save Dockerfile.
- Confirm the file exists by running:
ls Dockerfile
You should see Dockerfile printed in the output.
Don't see the file?
Make sure you created it in the same directory as requirements.txt. The filename must be exactly Dockerfile with a capital D and no file extension.
help me find where to put my Dockerfile
✔️ Awesome, I've got everything!
Great. Double check you have saved the file.
ⓧ I'd like to double check the full code
FROM python:3.11-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
ENV PORT=8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "4", "protein_punisher.main:app"]
Build and deploy to Cloud Run
Cloud Run deploys container images stored in Google Artifact Registry. The simplest path is gcloud run deploy --source . which builds, pushes, and deploys in one command.
- Make sure you are in the project root (the folder containing Dockerfile and requirements.txt).
- Enable the required Google Cloud APIs by running:
gcloud services enable run.googleapis.com cloudbuild.googleapis.com artifactregistry.googleapis.com cloudscheduler.googleapis.com
What does this command do?
This enables four APIs on your project: Cloud Run (hosts the container), Cloud Build (builds the image remotely), Artifact Registry (stores the image), and Cloud Scheduler (triggers reminders). You only need to do this once per project.
- Deploy the app to Cloud Run by running the following command. Replace your-gcp-project-id with your actual Google Cloud project ID, and substitute your real credentials for each placeholder value:
gcloud run deploy protein-punisher \
--source . \
--region us-central1 \
--allow-unauthenticated \
--set-env-vars GOOGLE_API_KEY=your-gemini-api-key-here,GCP_PROJECT_ID=your-gcp-project-id,TWILIO_ACCOUNT_SID=your-twilio-account-sid,TWILIO_AUTH_TOKEN=your-twilio-auth-token,TWILIO_WHATSAPP_NUMBER=whatsapp:+14155238886
What does this command do?
- --source . tells Cloud Build to build your Dockerfile remotely and push the image to Artifact Registry.
- --region us-central1 deploys to the US Central region.
- --allow-unauthenticated lets Twilio and Cloud Scheduler reach the webhook without IAM authentication.
- --set-env-vars injects your secrets as runtime environment variables so the container can read them via os.getenv().
Respond yes to prompts
The first time you deploy with --source, gcloud may ask to create an Artifact Registry repository and enable additional APIs. Respond y to all prompts.
The deploy takes 2-4 minutes. When it finishes, gcloud prints the live service URL.
- Copy the service URL from the output. It looks like https://protein-punisher-XXXXXXXX-uc.a.run.app.
- Verify the deployment is healthy by visiting /health in your browser. Append /health to the URL. You should see the text OK.
Deploy failed or health check times out?
- Check that your Dockerfile is in the same directory you ran the command from.
- Make sure you have billing enabled on your Google Cloud project.
- If you see a permission error, run gcloud auth login and try the deploy command again.
help me debug my Cloud Run deployment
Set up Cloud Scheduler and update Twilio
The /remind endpoint checks for pending meals and sends WhatsApp nudges. Cloud Scheduler calls it every hour so the agent can hold users accountable even when they forget to message first.
- Create a Cloud Scheduler job by running the following command. Replace the URI with your Cloud Run URL:
gcloud scheduler jobs create http meal-reminder \
--location us-central1 \
--schedule "0 * * * *" \
--uri "https://protein-punisher-XXXXXXXX-uc.a.run.app/remind" \
--http-method POST
What does this command do?
- meal-reminder is the job name you will use to pause or delete it later.
- --schedule "0 * * * *" means "at minute 0 of every hour" in cron syntax.
- --uri points to your live /remind endpoint on Cloud Run.
- --http-method POST matches what the Flask route expects.
- Verify the job was created by running:
gcloud scheduler jobs list --location us-central1
You should see meal-reminder listed with a state of ENABLED.
Scheduler creation failed?
If you see an error about an App Engine app, Cloud Scheduler requires one per project. Run gcloud app create --region us-central1 and try again.
help me create a Cloud Scheduler job
Now update your Twilio webhook so WhatsApp messages route to Cloud Run instead of ngrok.
- Open the Twilio Console in your browser.
- Navigate to Messaging, then Try it out, then Send a WhatsApp message.
- In the Sandbox Configuration section, find the When a message comes in field.
- Replace the ngrok URL with your Cloud Run URL followed by /webhook (for example, https://protein-punisher-XXXXXXXX-uc.a.run.app/webhook).
- Click Save.
- Test the live deployment by sending a WhatsApp message to the Twilio Sandbox number.
- You should receive a reply from the ProteinPunisher agent within a few seconds.
No reply from the agent?
- Double check that the webhook URL ends with /webhook and uses https.
- Make sure your environment variables were set correctly during deployment. Run gcloud run services describe protein-punisher --region us-central1 and look for the env vars section.
- Check the Cloud Run logs for errors: gcloud run services logs read protein-punisher --region us-central1.
help me debug why my WhatsApp agent is not responding on Cloud Run
Your ProteinPunisher agent is live. Cloud Scheduler pings the /remind endpoint every hour. When it matches a user's meal time, the agent fires off a nudge via WhatsApp. The full system is now running in production without any local dependencies.
Secret mission
ADK Evaluation Tests + Streak System
Your ProteinPunisher works, but how do you know it keeps working after you tweak the prompt? And what happens when users hit their targets three days in a row? In this secret mission, you will add ADK evaluation tests that catch regressions in agent behavior, plus a streak system that amplifies penalties for users who break their consistency.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely within free tier limits for demo usage, so there are no ongoing costs.
Resources you used:
- Google Cloud Run service (protein-punisher).
- Google Cloud Scheduler job (meal-reminder).
- Firestore database with user profiles, meal logs, and water logs.
- Twilio WhatsApp Sandbox configuration.
- Gemini API key from Google AI Studio.
- Local project files, virtual environment, and Docker images.
Keep everything running
No action needed. Choose this if you are still actively building or testing your ProteinPunisher agent.
- Cloud Run scales to zero when idle, so it costs nothing unless actively receiving requests.
- Cloud Scheduler runs hourly but is free (up to 3 jobs per account).
- Firestore free tier covers 1 GiB storage and 50K reads per day.
- Your Twilio WhatsApp Sandbox remains active with no cost.
Pause - I'll come back to this later
Shut down the scheduled reminders while keeping your deployment and data intact. You can resume later without redeploying.
- Pause the Cloud Scheduler job by running:
gcloud scheduler jobs pause meal-reminder --location=us-central1
- Your Cloud Run service stays deployed but receives no scheduled traffic. It scales to zero automatically.
- All Firestore data (profiles, meals, water logs) remains intact.
- To resume later, run:
gcloud scheduler jobs resume meal-reminder --location=us-central1
Delete - I don't want to use this again
Remove all cloud resources and start fresh. This is irreversible for your Firestore data.
- Delete the Cloud Run service by running:
gcloud run services delete protein-punisher --region=us-central1
- Delete the Cloud Scheduler job by running:
gcloud scheduler jobs delete meal-reminder --location=us-central1
- Delete the Firestore database from the Google Cloud Console. Navigate to Firestore in the console, select your database, and click Delete.
- Remove your local project files and virtual environment by deleting the protein_punisher folder from your Desktop.
- Remove the Docker image locally by running docker image prune to clean up unused images.
- Optionally revoke your Gemini API key from Google AI Studio if you no longer need it.
Nice Work!
Nice Work!
You just built a production-grade AI health agent that proactively holds users accountable on WhatsApp. From analyzing meal photos with multimodal AI to sending scheduled reminders and tracking penalties, ProteinPunisher is a fully deployed, cloud-native agent.
You've learned how to:
- Built a Google ADK agent with custom Python tools for nutrition tracking, connected to Gemini 2.5 Flash for multimodal food photo analysis.
- Implemented a complete health tracking backend with Firestore for persistent user profiles, meal logs, and penalty tracking, plus proactive reminders via Cloud Scheduler.
- Deployed a Docker containerized Flask application to Cloud Run with Twilio webhook integration and a web dashboard for viewing stats.
- Secret Mission: Added an ADK evaluation test suite and a streak-based penalty amplification system that rewards consistency and punishes streak-breakers.
Ready to quiz yourself?