Build a WhatsApp AI Sales Assistant

Build a grounded WhatsApp sales assistant with Gemini, Twilio, and SQLite.

Introduction

30 Second Summary

A customer can move from a product question to checkout in just a few messages. Without a shared record, the next request is easy to misroute or lose.

In this project, you will build a WhatsApp sales assistant that uses Gemini to recommend in-stock phones through a real webhook. A local SQLite CRM records the sales journey while financial actions remain visibly simulated.

What You'll Build

When you demo the finished assistant, a WhatsApp message returns an inventory-backed phone recommendation while the local CRM shows the conversation's sales stage.

By the end of this project, you'll have:

  • Inventory-backed recommendations that answer budget requests with an in-stock phone. Each reply includes the stored price. It also includes the available stock.
  • Sales-stage routing for trade-in requests. Simulated checkout has its own response. Post-purchase help has its own stage. Human handoff requests enter a clear queue.
  • A chronological CRM feed that shows every customer message. Each record includes its stage. Each record also includes the assistant's reply.
  • Secret Mission: Add a stockout fail-safe that refuses to invent availability. It creates a human-handoff record when every product is out of stock.

Are there any prerequisites?

You need a Windows computer with Python. You also need Visual Studio Code plus internet access.

You need a phone with WhatsApp. You also need a Google AI Studio account.

Before We Start

This first checkpoint locks in the purpose of your safe WhatsApp sales prototype before the hands-on work begins. You will serve phone shoppers with recommendations grounded in real inventory data while keeping trade-in, payment, and post-purchase actions visibly simulated.

Set Up the Sales Assistant Workspace

A reliable sales assistant needs a repeatable workspace before it handles customer messages. You will isolate the project's Python packages inside a virtual environment that you manage from Visual Studio Code.

The assistant also needs authenticated access to the Gemini API. A user environment variable keeps that credential outside your project files.

Twilio supplies the testing connection for WhatsApp. Later, ngrok gives Twilio a temporary route to your local application.

In this step, get ready to:
  • Create an isolated project workspace with pinned Python dependencies.
  • Configure the Gemini API credential for the Python SDK.
  • Prepare Twilio WhatsApp testing and ngrok for the later webhook connection.
Create the workspace and install dependencies

The project folder keeps every local file in one place. Its virtual environment keeps the project's package versions separate from other Python projects on your computer.

  • Press the Windows key to open Windows search.
  • Type Command Prompt.
  • Press Enter to open Command Prompt.
  • Create the whatsapp-sales-assistant folder on your Desktop by running these commands:
cd /d %USERPROFILE%\Desktop
mkdir whatsapp-sales-assistant
cd whatsapp-sales-assistant

What do these commands do?

  • The first cd command moves Command Prompt to your Desktop.
  • The mkdir command creates the whatsapp-sales-assistant folder.
  • The final cd command enters that folder.

Your Command Prompt path should now end with Desktop\whatsapp-sales-assistant. This confirms that later files land in the correct folder.

  • Press the Windows key to open Windows search.
  • Type Visual Studio Code.
  • Press Enter to launch Visual Studio Code.
  • Select File from the top menu.
  • Select Open Folder.
  • Choose the whatsapp-sales-assistant folder on your Desktop.
  • Select Select Folder.
  • Approve workspace trust if Visual Studio Code asks about the folder you created.

You should see whatsapp-sales-assistant at the top of the Explorer view. The folder is now your Visual Studio Code workspace.

  • Select Terminal from the top menu.
  • Select New Terminal.
  • Choose Command Prompt from the terminal profile dropdown.
  • Check the installed Python version by running this command:
python --version

What does this command check?

This command prints the Python version available to Command Prompt. The Google Gen AI SDK requires Python 3.10 or newer for this project.

✔️ I see version 3.10 or higher

Your Python version meets the project requirement. Keep this Command Prompt terminal open for the virtual environment setup.

ⓧ I see an older version

Your current Python installation is below the project's minimum version. Install Python 3.10 or newer before creating the virtual environment.

  • Open the official Python downloads page for Windows.
  • Download a current Windows installer for Python 3.10 or newer.
  • Run the installer.
  • Enable the installer option that makes Python available from Command Prompt.
  • Close the current terminal.
  • Create a new Command Prompt terminal from the terminal profile dropdown.
  • Check the upgraded version by running this command:
python --version

What should you see?

The new terminal should report Python 3.10 or higher. A fresh terminal picks up the command path created by the installer.

ⓧ Command not found

Command Prompt cannot currently find Python. Install a supported version before continuing.

  • Open the official Python downloads page for Windows.
  • Download a current Windows installer for Python 3.10 or newer.
  • Run the installer.
  • Enable the installer option that makes Python available from Command Prompt.
  • Close the current terminal.
  • Create a new Command Prompt terminal from the terminal profile dropdown.
  • Confirm the installation by running this command:
python --version

What should you see?

The new terminal should print Python 3.10 or higher. This confirms that Command Prompt can locate the installation.

  • Create the .venv virtual environment by running this command:
py -3 -m venv .venv

What does this command create?

Python creates a .venv folder inside whatsapp-sales-assistant. That folder holds the isolated Python interpreter and installed packages for this project.

  • Activate the virtual environment by running this command:
.venv\Scripts\activate

What does activation change?

Activation points Python package commands at .venv. Packages installed next stay isolated inside this workspace.

You should see .venv at the start of the terminal prompt. Your isolated environment is now active.

  • Select the Explorer view in the left sidebar.
  • Select the New File control.
  • Enter requirements.txt as the file name.

You should see requirements.txt inside the whatsapp-sales-assistant folder. This file records the exact dependency versions used by the project.

  • Add the pinned dependencies to requirements.txt by pasting this code:
Flask==3.1.3
google-genai==2.31.0
twilio==9.12.0

What do these dependencies provide?

  • Flask provides the local web application and routes used later in the project.
  • Google Gen AI SDK connects the Python application to Gemini.
  • Twilio Python SDK creates the messaging response returned to WhatsApp.
  • Save requirements.txt.
  • Install the pinned dependencies by running this command:
py -m pip install -r requirements.txt

What does this command install?

The command reads each package pin from requirements.txt. It installs those versions inside the active .venv environment.

The first installation can take a minute while Python downloads the packages. You should see successful installation output when it finishes.

Having trouble installing the packages?

  • Confirm that .venv appears at the start of the terminal prompt.
  • Confirm that the terminal path ends with whatsapp-sales-assistant.
  • Compare every line in requirements.txt with the full-file reference below.

Ask for help with the package installation error.

✔️ Awesome, I've got everything!

Your dependency file is saved. The pinned packages are installed inside the active virtual environment.

ⓧ I'd like to double check the full code

Flask==3.1.3
google-genai==2.31.0
twilio==9.12.0
Configure Gemini API access

An API key identifies your application when it contacts Gemini. A Windows user environment variable makes the key available to new terminals without placing it in requirements.txt.

Keep the API key private

This step handles a live credential, so a little caution is worthwhile. Keep the key out of screenshots and project files.

  • Open Google AI Studio in your browser.
  • Sign in with your Google AI Studio account.
  • Create an API key if your account does not already have one.
  • Copy the API key.

The copied value is the credential that the Google Gen AI SDK detects through Windows. Keep it in your clipboard only long enough to store it.

  • Press the Windows key to open Windows search.
  • Type Environment Variables.
  • Open the result for your account's environment variables.
  • Create a new user variable.
  • Enter GEMINI_API_KEY as the variable name.
  • Paste your API key as the variable value.
  • Confirm the new user variable.
  • Confirm that the user variables list includes GEMINI_API_KEY.

Windows gives newly launched processes the updated user environment. Restarting Visual Studio Code ensures its next terminal can detect the key.

  • Close Visual Studio Code.
  • Press the Windows key to open Windows search.
  • Type Visual Studio Code.
  • Press Enter to relaunch Visual Studio Code.
  • Select whatsapp-sales-assistant from the recent folders list.
  • Select Terminal from the top menu.
  • Select New Terminal.
  • Choose Command Prompt from the terminal profile dropdown.
  • Reactivate the virtual environment by running this command:
.venv\Scripts\activate

Why activate it again?

Each new terminal starts outside the virtual environment. Activation reconnects this terminal to the packages already installed inside .venv.

The fresh Command Prompt terminal now inherits GEMINI_API_KEY from your Windows user environment. The credential remains outside the project files.

Connect Twilio and authenticate ngrok

The Twilio trial gives your phone a WhatsApp testing path without production sender registration. The ngrok Free account prepares a temporary public route for a later step.

These accounts begin with documented free testing allowances. This setup does not connect a live payment or trade-in service.

  • Open the Twilio account page in your browser.
  • Create a Twilio trial account.
  • Complete any phone verification requested during sign-up.
  • Open Messaging in the Twilio Console.
  • Find WhatsApp under the popular messaging channels.
  • Select Try out WhatsApp.
  • Acknowledge the testing terms if Twilio prompts you.
  • Scan the displayed QR code with WhatsApp on your phone.

WhatsApp prepares a join message for the testing environment. Sending that message lets Twilio associate your phone with this test connection.

  • Send the prepopulated join message from WhatsApp.
  • Complete the remaining on-screen connection prompts.
  • Confirm that Twilio reports your phone as connected.

That connection is in place. Your phone can now participate in the Twilio WhatsApp testing flow used later in the project.

Phone not connecting to Twilio?

  • Confirm that WhatsApp sent the prepopulated join message to the number shown by Twilio.
  • Scan the QR code again if the first join attempt expired.
  • Confirm that Twilio verified the phone number used by your trial account.

Help me connect my phone to the Twilio WhatsApp testing environment.

  • Open the ngrok account page in your browser.
  • Create an ngrok Free account.
  • Approve any Microsoft Store terms prompt that appears during installation.
  • Install ngrok by running this command in the activated Command Prompt terminal:
winget install ngrok -s msstore

What does this command install?

Windows installs the Microsoft Store version of ngrok. The command makes ngrok available from Command Prompt for the later tunnel step.

The installation can take a minute while Windows downloads the package. You should see completion output when it finishes.

Your ngrok authtoken grants access to your account. Keep it out of screenshots and project files.

  • Return to your ngrok account page.
  • Copy your account authtoken.
  • Replace the placeholder in the command below with your authtoken.
  • Authenticate the ngrok installation by running the completed command:
ngrok config add-authtoken "<YOUR_AUTHTOKEN>"

What does authentication change?

ngrok stores the account authtoken in its local configuration. Future tunnel commands can then use your authenticated account.

You should see confirmation that ngrok updated its configuration. The authtoken itself should stay private.

ngrok installation or authentication failing?

  • Confirm that the ngrok installation command finished before running the authentication command.
  • Copy a fresh authtoken from your ngrok account if the saved credential is rejected.
  • Create a new Command Prompt terminal if Windows cannot find ngrok immediately after installation.

Help me troubleshoot my ngrok setup without sharing my authtoken.

The previous terminal contains a credential in its command history. A fresh terminal keeps that authtoken out of your final verification screenshot.

  • Close the terminal that contains the authtoken command.
  • Create a new terminal from the Visual Studio Code terminal dropdown.
  • Choose Command Prompt as the terminal profile.
  • Reactivate the virtual environment by running this command:
.venv\Scripts\activate

Why use a fresh terminal?

The fresh terminal begins in the open whatsapp-sales-assistant workspace. Reactivating .venv restores access to the installed project packages without showing your authtoken.

Before you run the final checks, which three success signals do you expect from Python, ngrok, and the package imports?

  • Verify the complete workspace by running these commands in the activated Command Prompt terminal:
python --version
ngrok help
python -c "import flask, twilio; from google import genai; print('Environment ready')"

What should you see?

  • The first command prints a Python version of 3.10 or newer.
  • The second command prints ngrok help text.
  • The third command prints Environment ready after importing Flask, Twilio, and google.genai successfully.

That is the setup locked in. Your isolated Python workspace now has its required packages, credentials, messaging connection, and tunnel tool ready.

One of the final checks failed?

  • Upgrade Python if the reported version is below 3.10.
  • Reactivate .venv if the import check cannot find an installed package.
  • Run the earlier requirements installation again if a dependency import still fails.
  • Create another fresh Command Prompt terminal if ngrok remains unavailable after installation.

Help me diagnose which workspace check is failing.

Your sales assistant workspace is ready. Next, you will build a deliberately naive local recommendation and expose the inventory problem that grounding solves.

Build a Naive Local Sales Assistant

Your prepared workspace can now run the sales assistant locally. A convincing recommendation still needs a way to prove that the suggested product exists in stock.

In this step, you will build an intentionally naive Flask demo backed by a visible JSON inventory. Comparing the response with that inventory exposes the grounding problem you will solve next.

In this step, get ready to:
  • Record three fictional phones in an inspectable inventory file.
  • Build a local demo endpoint with an intentionally naive recommendation.
  • Test the recommendation against the available inventory.
Create the inventory file

The inventory file acts as the visible source of truth for product availability. Each product records the details that a sales recommendation should respect.

  • Create inventory.json inside the whatsapp-sales-assistant folder using the Explorer sidebar in Visual Studio Code.
  • Add the three available phones by pasting this inventory into inventory.json:
[
  {
    "sku": "NOVA-LITE",
    "name": "Nova Lite",
    "price": 299,
    "stock": 4,
    "features": ["long battery", "128 GB storage"]
  },
  {
    "sku": "NOVA-PRO",
    "name": "Nova Pro",
    "price": 549,
    "stock": 2,
    "features": ["high-resolution camera", "256 GB storage"]
  },
  {
    "sku": "NOVA-FOLD",
    "name": "Nova Fold",
    "price": 899,
    "stock": 1,
    "features": ["foldable display", "512 GB storage"]
  }
]

What Does This Inventory Contain?

  • Each sku gives one phone a unique product identifier.
  • Each price stores the phone's price as a number.
  • Each stock value records how many units are available.
  • Each features list stores the details that can support a recommendation.
  • Save inventory.json.
  • Confirm the file contains three product objects with the SKUs NOVA-LITE, NOVA-PRO, and NOVA-FOLD.

Is the Inventory File Showing a Problem?

Check that every product object has a closing brace. Check that the final product has no comma after its closing brace.

Make sure inventory.json sits beside requirements.txt inside whatsapp-sales-assistant.

Help me check my inventory JSON

Build the local Flask app

A Flask route connects a URL to a Python function. The demo route will return a browser-readable response without using WhatsApp yet.

  • Create app.py beside inventory.json using the Explorer sidebar.
  • Add the app setup plus its two response helpers by pasting this code into app.py:
import json

from flask import Flask, request

app = Flask(__name__)

INVENTORY_PATH = "inventory.json"


def load_inventory():
    with open(INVENTORY_PATH, encoding="utf-8") as inventory_file:
        return json.load(inventory_file)


def naive_reply():
    return "I recommend the Titan Max. It is our best premium phone."

What Does This App Setup Do?

  • The imports provide JSON file handling plus the Flask application tools.
  • The app object holds the local web application.
  • INVENTORY_PATH points to the product data you just created.
  • load_inventory() reads the current product list from disk.
  • naive_reply() returns a fixed sales recommendation without checking that list.
  • Place your cursor two blank lines below the naive_reply() function.
  • Add the local routes plus the server startup block by pasting this code:
@app.get("/")
def home():
    return {
        "status": "ready",
        "demo": "/demo?message=I%20need%20a%20phone%20under%20500",
    }


@app.get("/demo")
def demo():
    message = request.args.get("message", "Recommend a phone")
    if request.args.get("mode") == "naive":
        return {"stage": "ungrounded", "reply": naive_reply()}
    return {"stage": "recommendation", "reply": "Grounded recommendations will be added next."}


if __name__ == "__main__":
    app.run(port=3000)

How Do the Routes Work?

  • The / route reports that the local service is ready.
  • The /demo route reads the message from the URL query string.
  • The mode check selects the intentionally naive response.
  • app.run(port=3000) serves the application on local port 3000.

Use these tabs to compare every project file before running the demo.

✔️ Awesome, I've got everything!

Great. Your inventory plus local app are ready for the first browser test.

ⓧ I'd like to double check the full code

  • Compare your complete app.py file with this reference:
import json

from flask import Flask, request

app = Flask(__name__)

INVENTORY_PATH = "inventory.json"


def load_inventory():
    with open(INVENTORY_PATH, encoding="utf-8") as inventory_file:
        return json.load(inventory_file)


def naive_reply():
    return "I recommend the Titan Max. It is our best premium phone."


@app.get("/")
def home():
    return {
        "status": "ready",
        "demo": "/demo?message=I%20need%20a%20phone%20under%20500",
    }


@app.get("/demo")
def demo():
    message = request.args.get("message", "Recommend a phone")
    if request.args.get("mode") == "naive":
        return {"stage": "ungrounded", "reply": naive_reply()}
    return {"stage": "recommendation", "reply": "Grounded recommendations will be added next."}


if __name__ == "__main__":
    app.run(port=3000)

How This File Fits

This complete file defines the Flask app plus both local routes. It also contains the fixed recommendation that creates the grounding test.

  • Compare your complete inventory.json file with this reference:
[
  {
    "sku": "NOVA-LITE",
    "name": "Nova Lite",
    "price": 299,
    "stock": 4,
    "features": ["long battery", "128 GB storage"]
  },
  {
    "sku": "NOVA-PRO",
    "name": "Nova Pro",
    "price": 549,
    "stock": 2,
    "features": ["high-resolution camera", "256 GB storage"]
  },
  {
    "sku": "NOVA-FOLD",
    "name": "Nova Fold",
    "price": 899,
    "stock": 1,
    "features": ["foldable display", "512 GB storage"]
  }
]

How This File Fits

This inventory is the inspectable product catalog. It contains the three products that the assistant can verify later.

  • Confirm your unchanged requirements.txt file matches this reference:
Flask==3.1.3
google-genai==2.31.0
twilio==9.12.0

How This File Fits

This file keeps the installed project dependencies pinned to the prepared versions.

Run the naive recommendation

The local server turns your routes into URLs that you can test in a browser. This is where the fixed recommendation meets the real inventory for the first time.

  • Save app.py.
  • Start the Flask server from the activated terminal by running this command:
python app.py

What Does This Command Do?

This command runs app.py with your active Python environment. The server listens for local requests on port 3000.

  • Keep the server terminal running.

Before you load the demo, do you think its recommendation will match one of the three products you recorded?

  • Visit http://127.0.0.1:3000/demo?mode=naive&message=recommend%20a%20phone in a browser tab.

You'll see an ungrounded stage with the reply I recommend the Titan Max. It is our best premium phone..

  • Switch back to inventory.json.
  • Search the file for Titan Max.

Your search returns no matching product. The assistant recommended an item that does not exist in its inventory.

You caught the intended failure. Your local assistant now proves exactly why sales recommendations need inventory grounding.

Can't Load the Naive Demo?

Check that the terminal still shows the running Flask server. Confirm that app.py and inventory.json are both inside whatsapp-sales-assistant.

If the browser shows the non-naive placeholder reply, confirm the URL includes mode=naive.

Help me debug my local Flask demo

Your local assistant runs successfully plus its inventory flaw is visible. Next, you will ground the recommendation in live inventory data.

Ground Gemini in Live Inventory

Your local Flask assistant has exposed the risk of an ungrounded recommendation. The naive route recommends Titan Max even though that product is absent from your inventory.

This step adds inventory grounding around Gemini. Gemini selects an SKU from the available products. Your Python code validates that selection before showing stored product details.

In this step, get ready to:
  • Send only in-stock inventory to Gemini.
  • Validate Gemini's structured SKU in Python.
  • Compare the naive recommendation with the grounded result.
Connect Gemini to the inventory

The Google Gen AI SDK reads your existing API key when the client starts. The model identifier keeps every request pointed at the same Gemini model.

  • In the Visual Studio Code Explorer sidebar, select app.py.
  • Find the setup block that starts with from flask import Flask, request.
  • Replace the setup block through INVENTORY_PATH = "inventory.json" with this code:
from flask import Flask, request
from google import genai

app = Flask(__name__)
client = genai.Client()

MODEL_ID = "gemini-3.8-flash"
INVENTORY_PATH = "inventory.json"

What does this setup do?

  • The genai import gives your application access to the Gemini SDK.
  • The client uses the API key stored in GEMINI_API_KEY.
  • The MODEL_ID value identifies gemini-3.8-flash for every model request.
  • Save app.py.
  • Stop the running Flask process in the Visual Studio Code terminal panel.
  • Restart Flask on port 3000 by running this command:
python app.py

What does this check prove?

Flask should start on port 3000 without an import error. This confirms the SDK client can initialize inside your application.

Does Flask stop during startup?

Confirm that your new terminal can access GEMINI_API_KEY. Restart Visual Studio Code if the terminal was open before you created the environment variable.

Check the imported name if the error points to the SDK. Help me diagnose my Gemini client startup error.

A grounded selector needs a clear boundary around the model. The prompt supplies only the current inventory and asks Gemini to return one SKU.

  • Stop the running Flask process in the terminal panel.
  • In app.py, locate naive_reply().
  • Add the first part of choose_product() below naive_reply() by copying this code:
def choose_product(message, available_inventory):
    prompt = f"""
You are selecting one phone for a WhatsApp sales assistant.
Choose only an SKU from the supplied inventory.
Match the customer's budget and requested features as closely as possible.

Customer message:
{message}

Inventory:
{json.dumps(available_inventory, indent=2)}
"""

How does the prompt create a boundary?

  • The message value carries the customer's budget or requested features.
  • The available_inventory value contains the products the model may select.
  • The prompt requests an SKU so product facts stay in your inventory file.
  • Save app.py.
  • Check the new function for matching triple quotes.
  • Start Flask with the updated function by running this command:
python app.py

What does this run confirm?

Flask should start again on port 3000. A successful start confirms the prompt string has valid Python syntax.

Seeing a syntax problem?

Confirm that the prompt starts with f""" and ends with """. Keep the closing quotes indented inside choose_product().

Check the braces around message and json.dumps(). Help me fix my choose_product prompt syntax.

The model response needs a predictable shape before your code can inspect it. A JSON schema limits the response to an object containing sku.

  • Stop the running Flask process in the terminal panel.
  • Place your cursor after the closing triple quotes inside choose_product().
  • Complete the selection logic by adding this code:
    interaction = client.interactions.create(
        model=MODEL_ID,
        input=prompt,
        response_format={
            "type": "text",
            "mime_type": "application/json",
            "schema": {
                "type": "object",
                "properties": {"sku": {"type": "string"}},
                "required": ["sku"],
            },
        },
    )
    selected_sku = json.loads(interaction.output_text).get("sku")
    selected = next(
        (item for item in available_inventory if item["sku"] == selected_sku),
        None,
    )
    return selected or min(available_inventory, key=lambda item: item["price"])

How is the selection validated?

  • The response_format schema asks Gemini for a JSON object containing one SKU.
  • The selected_sku value comes from the model's structured response.
  • The next() check accepts the SKU only when it exists in available_inventory.
  • The min() fallback selects the least expensive available product when the returned SKU is invalid.
  • Save app.py.
  • Start Flask with the completed selector by running this command:
python app.py

What does this run confirm?

Flask should start without a syntax error. Your application now contains a model call with a Python validation boundary.

Does the selector prevent startup?

Check that every line from interaction = through the final return stays indented inside choose_product().

Compare the braces in response_format if the error points to that object. Help me fix my structured response block.

Build replies from validated inventory data

Gemini now chooses a validated product record. The customer-facing reply should use the price and stock stored in inventory.json.

  • Stop the running Flask process in the terminal panel.
  • In app.py, locate the end of choose_product().
  • Add build_reply() below choose_product() by copying this code:
def build_reply(message):
    available_inventory = [
        item for item in load_inventory() if item["stock"] > 0
    ]
    selected = choose_product(message, available_inventory)
    features = ", ".join(selected["features"])
    reply = (
        f"{selected['name']} (${selected['price']}) matches your request. "
        f"Highlights: {features}. Stock: {selected['stock']}."
    )
    return reply

What does this reply builder do?

  • The inventory filter keeps products with stock greater than zero.
  • The validated product record supplies the name and price.
  • The same record supplies the features and stock count.
  • The final reply displays stored data instead of model-written product facts.
  • Save app.py.
  • Start Flask with the reply builder by running this command:
python app.py

What does this run confirm?

Flask should start on port 3000 without a syntax error. The new reply builder can now be connected to the demo route.

Does the reply builder fail to load?

Confirm that available_inventory stays inside build_reply().

Check each quote around name, price, features, and stock. Help me diagnose my build_reply function.

The demo route still returns its temporary placeholder response. Connecting build_reply() makes the grounded path available while preserving the naive comparison mode.

  • Stop the running Flask process in the terminal panel.
  • In app.py, locate the demo() function.
  • Replace the complete demo() function with this version:
@app.get("/demo")
def demo():
    message = request.args.get("message", "Recommend a phone")
    if request.args.get("mode") == "naive":
        return {"stage": "ungrounded", "reply": naive_reply()}
    return {"stage": "recommendation", "reply": build_reply(message)}

How does the route choose a path?

  • The mode=naive query keeps the Titan Max response available for comparison.
  • Every other request passes the customer's message to build_reply().
  • Flask converts the returned dictionary into the browser's JSON response.
  • Save app.py.

✔️ Awesome, I've got everything!

Your grounded selector and demo route are ready. Keep app.py saved before you run the comparison.

ⓧ I'd like to double check the full code

import json

from flask import Flask, request
from google import genai

app = Flask(__name__)
client = genai.Client()

MODEL_ID = "gemini-3.8-flash"
INVENTORY_PATH = "inventory.json"


def load_inventory():
    with open(INVENTORY_PATH, encoding="utf-8") as inventory_file:
        return json.load(inventory_file)


def naive_reply():
    return "I recommend the Titan Max. It is our best premium phone."


def choose_product(message, available_inventory):
    prompt = f"""
You are selecting one phone for a WhatsApp sales assistant.
Choose only an SKU from the supplied inventory.
Match the customer's budget and requested features as closely as possible.

Customer message:
{message}

Inventory:
{json.dumps(available_inventory, indent=2)}
"""

    interaction = client.interactions.create(
        model=MODEL_ID,
        input=prompt,
        response_format={
            "type": "text",
            "mime_type": "application/json",
            "schema": {
                "type": "object",
                "properties": {"sku": {"type": "string"}},
                "required": ["sku"],
            },
        },
    )
    selected_sku = json.loads(interaction.output_text).get("sku")
    selected = next(
        (item for item in available_inventory if item["sku"] == selected_sku),
        None,
    )
    return selected or min(available_inventory, key=lambda item: item["price"])


def build_reply(message):
    available_inventory = [
        item for item in load_inventory() if item["stock"] > 0
    ]
    selected = choose_product(message, available_inventory)
    features = ", ".join(selected["features"])
    reply = (
        f"{selected['name']} (${selected['price']}) matches your request. "
        f"Highlights: {features}. Stock: {selected['stock']}."
    )
    return reply


@app.get("/")
def home():
    return {
        "status": "ready",
        "demo": "/demo?message=I%20need%20a%20phone%20under%20500",
    }


@app.get("/demo")
def demo():
    message = request.args.get("message", "Recommend a phone")
    if request.args.get("mode") == "naive":
        return {"stage": "ungrounded", "reply": naive_reply()}
    return {"stage": "recommendation", "reply": build_reply(message)}


if __name__ == "__main__":
    app.run(port=3000)

This reference matches the complete app.py for this step.

Compare naive and grounded recommendations

Both recommendation paths now share one endpoint. Their responses reveal the difference between an invented product and an inventory-backed selection.

  • Start Flask on port 3000 by running this command:
python app.py

What does this command start?

This starts your updated local application. Keep the terminal running while you test both browser requests.

Before you check the naive path, do you expect its answer to reflect the products in inventory.json?

  • Paste http://127.0.0.1:3000/demo?mode=naive&message=recommend%20a%20phone into your browser address bar.
  • Press Enter.

You should see the ungrounded stage with a reply recommending Titan Max. That result preserves the designed failure from the previous step.

Before you check the grounded path, which in-stock product should fit a budget under 500?

  • Paste http://127.0.0.1:3000/demo?message=I%20need%20a%20phone%20under%20500 into your browser address bar.
  • Press Enter.

You should see the recommendation stage with Nova Lite. The reply should include its stored price of $299 and stock count of 4.

That is the grounding gap closed. Your assistant now turns a model-selected SKU into a recommendation backed by live inventory data.

Does the grounded request fail?

Confirm that Flask is still running in the Visual Studio Code terminal panel. Check that inventory.json remains beside app.py.

If the request reaches Gemini but returns an error, confirm that your new terminal can access GEMINI_API_KEY. Help me debug my grounded demo request.

Your assistant can now recommend a real in-stock product within the customer's budget. Next, you'll record each stage of the wider sales journey.

Add the CRM and Sales Journey

Your grounded assistant now recommends a real product from inventory.json. That gives the sales journey a reliable starting point.

A recommendation alone disappears after the response. You will add deterministic routing plus a persistent CRM powered by SQLite.

In this step, get ready to:
  • Route explicit customer requests to the correct sales stage.
  • Label trade-in and checkout operations as simulated.
  • Persist every interaction in a local CRM feed.
Route each sales request

Explicit keywords should take predictable paths through the sales journey. This keeps important customer intents under Python's control.

  • In app.py, place this function between naive_reply() and choose_product() by copying the code below:
def classify_stage(message):
    lowered = message.lower()
    if any(word in lowered for word in ("human", "agent", "representative")):
        return "human_handoff"
    if "trade" in lowered:
        return "trade_in"
    if any(word in lowered for word in ("buy", "pay", "checkout")):
        return "payment_pending"
    if any(word in lowered for word in ("order", "delivery", "warranty")):
        return "post_purchase"
    return "recommendation"

What does this code do?

  • The lowercase copy lets each keyword match regardless of the customer's capitalization.
  • Human requests receive the highest priority because they need a predictable escape path from automation.
  • Messages without a workflow keyword continue to the product recommendation stage.

The stage now needs a matching reply. Each commerce reply clearly signals that the action is a simulation.

  • In app.py, replace the existing build_reply() function with the code below:
def build_reply(message):
    stage = classify_stage(message)

    if stage == "human_handoff":
        return "A human handoff has been requested. Status: queued.", stage
    if stage == "trade_in":
        return "SIMULATED TRADE-IN: Your device details were captured for manual review.", stage
    if stage == "payment_pending":
        return "SIMULATED CHECKOUT: A payment link would be created here. Status: pending.", stage
    if stage == "post_purchase":
        return "SIMULATED FOLLOW-UP: Your order-support request was queued.", stage

    available_inventory = [
        item for item in load_inventory() if item["stock"] > 0
    ]
    selected = choose_product(message, available_inventory)
    features = ", ".join(selected["features"])
    reply = (
        f"{selected['name']} (${selected['price']}) matches your request. "
        f"Highlights: {features}. Stock: {selected['stock']}."
    )
    return reply, stage

How does the journey stay safe?

  • Explicit sales stages return fixed replies before Gemini receives the message.
  • Trade-in details are captured for manual review.
  • Checkout only describes where a payment link would be created.
  • Recommendation requests still use the grounded inventory path.
Create the SQLite CRM

The CRM needs a durable record of each message. A local database keeps that history available after Flask restarts.

  • In app.py, replace everything from the first import through the line above load_inventory() with the code below:
import json
import sqlite3

from flask import Flask, request
from google import genai

app = Flask(__name__)
client = genai.Client()

MODEL_ID = "gemini-3.8-flash"
INVENTORY_PATH = "inventory.json"
DB_PATH = "sales.db"


def init_db():
    with sqlite3.connect(DB_PATH) as connection:
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS leads (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                customer_id TEXT NOT NULL,
                message TEXT NOT NULL,
                stage TEXT NOT NULL,
                reply TEXT NOT NULL,
                created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
            )
            """
        )
        connection.commit()

What does this database setup create?

  • The sqlite3 module opens the database stored at sales.db.
  • The leads table stores the customer identifier and incoming message.
  • Each row also stores the chosen stage plus the assistant's reply.
  • The creation timestamp gives every interaction a chronological position.
  • Near the bottom of app.py, add this initialization call directly above the existing if __name__ == "__main__": line:
init_db()

Why initialize on startup?

The call creates sales.db when the application starts. The table remains unchanged when it already exists.

  • Save app.py.
  • Switch back to the terminal running Flask.
  • Stop the current server by pressing Ctrl+C.
  • Restart Flask by running this command:
python app.py

What does this command prove?

Flask starts the application on port 3000. The startup call also creates the CRM database and its table.

  • Return to the VS Code window from earlier.
  • Confirm that sales.db is now listed beside app.py.

Good progress. Your assistant now has a database ready to receive customer interactions.

Missing sales.db?

Check that init_db() appears above the final startup condition. Confirm that the terminal is running from the whatsapp-sales-assistant folder.

If startup still fails, compare the parentheses around connection.execute() with the snippet above.

Help me troubleshoot the SQLite database initialization

Persist and inspect the full journey

The database exists. The request path now needs to save each message before Flask returns its response.

  • In app.py, place these persistence functions between build_reply() and the home route by copying the code below:
def save_lead(customer_id, message, stage, reply):
    with sqlite3.connect(DB_PATH) as connection:
        connection.execute(
            """
            INSERT INTO leads (customer_id, message, stage, reply)
            VALUES (?, ?, ?, ?)
            """,
            (customer_id, message, stage, reply),
        )
        connection.commit()


def handle_message(customer_id, message):
    reply, stage = build_reply(message)
    save_lead(customer_id, message, stage, reply)
    return reply, stage

How is each interaction saved?

  • The ? placeholders keep message values separate from the SQL statement.
  • The values tuple binds the customer identifier and message to those placeholders.
  • The same tuple also binds the selected stage plus the generated reply.
  • The handle_message() function keeps reply generation and persistence in one reusable path.

The browser demo must use that reusable path. The home response will also advertise the CRM endpoint.

  • In app.py, replace the current home and demo routes with the code below:
@app.get("/")
def home():
    return {
        "status": "ready",
        "demo": "/demo?message=I%20need%20a%20phone%20under%20500",
        "crm": "/leads",
    }


@app.get("/demo")
def demo():
    message = request.args.get("message", "Recommend a phone")
    if request.args.get("mode") == "naive":
        return {"stage": "ungrounded", "reply": naive_reply()}

    reply, stage = handle_message("browser-demo", message)
    return {"stage": stage, "reply": reply}

What changes in the demo route?

  • The grounded path sends every browser message through handle_message().
  • The browser demo uses browser-demo as its local customer identifier.
  • The returned stage now comes from the workflow classifier.
  • The home response points to /leads as the CRM feed.
  • Save app.py.
  • Switch back to the Flask terminal.
  • Stop the server by pressing Ctrl+C.
  • Restart the updated application by running this command:
python app.py

What happens after the restart?

Flask loads the updated routes on port 3000. The existing sales.db file stays available for new records.

Before you check, predict which stage a trade-in request should produce.

  • Open http://127.0.0.1:3000/demo?message=trade-in in your browser.

You should see the stage trade_in. The reply begins with SIMULATED TRADE-IN.

Seeing a recommendation instead?

Confirm that classify_stage() checks for trade. Check that build_reply() calls the classifier before loading inventory.

Help me debug the trade-in route

The interaction is now stored. A read-only route will expose the saved records as chronological JSON.

  • In app.py, place this route below demo() by copying the code below:
@app.get("/leads")
def leads():
    with sqlite3.connect(DB_PATH) as connection:
        connection.row_factory = sqlite3.Row
        rows = connection.execute(
            """
            SELECT id, customer_id, message, stage, reply, created_at
            FROM leads
            ORDER BY id DESC
            """
        ).fetchall()
    return [dict(row) for row in rows]

How does the CRM feed work?

  • The row factory makes each database row accessible by its column names.
  • The query retrieves the stored customer and conversation details.
  • The descending identifier places the newest interaction first.
  • Flask converts the returned list of dictionaries into JSON.
  • Save app.py.
  • Switch back to the Flask terminal.
  • Stop the server by pressing Ctrl+C.
  • Restart the completed local journey by running this command:
python app.py

What is ready now?

The application now exposes both the sales journey and its local CRM feed. Each grounded demo request can create a persistent record.

Before you inspect the CRM, predict the order in which its newest stages should appear.

  • Open http://127.0.0.1:3000/demo?message=buy in your browser.
  • Open http://127.0.0.1:3000/demo?message=order in your browser.
  • Open http://127.0.0.1:3000/demo?message=human in your browser.
  • Open http://127.0.0.1:3000/leads in your browser.

You should see records for human_handoff, post_purchase, payment_pending, and trade_in. Each row includes the original message plus its simulated reply.

That completes the local sales journey. Every explicit customer intent now produces a visible stage and a persistent CRM record.

Missing CRM records?

Confirm that demo() calls handle_message(). Check that save_lead() commits after inserting the row.

If /leads is unavailable, save app.py before restarting Flask.

Help me troubleshoot the local CRM feed

✔️ Awesome, I've got everything!

Great work. Save app.py before moving on.

ⓧ I'd like to double check the full code

Your cumulative app.py should match this file.

import json
import sqlite3

from flask import Flask, request
from google import genai

app = Flask(__name__)
client = genai.Client()

MODEL_ID = "gemini-3.8-flash"
INVENTORY_PATH = "inventory.json"
DB_PATH = "sales.db"


def init_db():
    with sqlite3.connect(DB_PATH) as connection:
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS leads (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                customer_id TEXT NOT NULL,
                message TEXT NOT NULL,
                stage TEXT NOT NULL,
                reply TEXT NOT NULL,
                created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
            )
            """
        )
        connection.commit()


def load_inventory():
    with open(INVENTORY_PATH, encoding="utf-8") as inventory_file:
        return json.load(inventory_file)


def naive_reply():
    return "I recommend the Titan Max. It is our best premium phone."


def classify_stage(message):
    lowered = message.lower()
    if any(word in lowered for word in ("human", "agent", "representative")):
        return "human_handoff"
    if "trade" in lowered:
        return "trade_in"
    if any(word in lowered for word in ("buy", "pay", "checkout")):
        return "payment_pending"
    if any(word in lowered for word in ("order", "delivery", "warranty")):
        return "post_purchase"
    return "recommendation"


def choose_product(message, available_inventory):
    prompt = f"""
You are selecting one phone for a WhatsApp sales assistant.
Choose only an SKU from the supplied inventory.
Match the customer's budget and requested features as closely as possible.

Customer message:
{message}

Inventory:
{json.dumps(available_inventory, indent=2)}
"""

    interaction = client.interactions.create(
        model=MODEL_ID,
        input=prompt,
        response_format={
            "type": "text",
            "mime_type": "application/json",
            "schema": {
                "type": "object",
                "properties": {"sku": {"type": "string"}},
                "required": ["sku"],
            },
        },
    )
    selected_sku = json.loads(interaction.output_text).get("sku")
    selected = next(
        (item for item in available_inventory if item["sku"] == selected_sku),
        None,
    )
    return selected or min(available_inventory, key=lambda item: item["price"])


def build_reply(message):
    stage = classify_stage(message)

    if stage == "human_handoff":
        return "A human handoff has been requested. Status: queued.", stage
    if stage == "trade_in":
        return "SIMULATED TRADE-IN: Your device details were captured for manual review.", stage
    if stage == "payment_pending":
        return "SIMULATED CHECKOUT: A payment link would be created here. Status: pending.", stage
    if stage == "post_purchase":
        return "SIMULATED FOLLOW-UP: Your order-support request was queued.", stage

    available_inventory = [
        item for item in load_inventory() if item["stock"] > 0
    ]
    selected = choose_product(message, available_inventory)
    features = ", ".join(selected["features"])
    reply = (
        f"{selected['name']} (${selected['price']}) matches your request. "
        f"Highlights: {features}. Stock: {selected['stock']}."
    )
    return reply, stage


def save_lead(customer_id, message, stage, reply):
    with sqlite3.connect(DB_PATH) as connection:
        connection.execute(
            """
            INSERT INTO leads (customer_id, message, stage, reply)
            VALUES (?, ?, ?, ?)
            """,
            (customer_id, message, stage, reply),
        )
        connection.commit()


def handle_message(customer_id, message):
    reply, stage = build_reply(message)
    save_lead(customer_id, message, stage, reply)
    return reply, stage


@app.get("/")
def home():
    return {
        "status": "ready",
        "demo": "/demo?message=I%20need%20a%20phone%20under%20500",
        "crm": "/leads",
    }


@app.get("/demo")
def demo():
    message = request.args.get("message", "Recommend a phone")
    if request.args.get("mode") == "naive":
        return {"stage": "ungrounded", "reply": naive_reply()}

    reply, stage = handle_message("browser-demo", message)
    return {"stage": stage, "reply": reply}


@app.get("/leads")
def leads():
    with sqlite3.connect(DB_PATH) as connection:
        connection.row_factory = sqlite3.Row
        rows = connection.execute(
            """
            SELECT id, customer_id, message, stage, reply, created_at
            FROM leads
            ORDER BY id DESC
            """
        ).fetchall()
    return [dict(row) for row in rows]


init_db()

if __name__ == "__main__":
    app.run(port=3000)

Your complete sales journey now works locally. Next, you will connect the same workflow to a real WhatsApp message.

Put the Assistant on WhatsApp

Your local Flask assistant already routes the full sales journey. Its SQLite CRM preserves each result.

Real WhatsApp messages still need a public path into your computer. Twilio sends each inbound message to a public webhook.

Ngrok forwards the webhook to Flask. Flask returns a TwiML reply.

In this step, get ready to:
  • Add a webhook route that returns TwiML from the existing workflow.
  • Expose the local Flask server through ngrok.
  • Send a complete WhatsApp sales journey into the CRM.
Add the WhatsApp webhook route

The new route receives message data through a POST request. It sends the existing workflow result back in TwiML format.

  • In app.py, find this import group:
from flask import Flask, request
from google import genai

What does this import group support?

The current Flask imports support routes and incoming request data. The WhatsApp route also needs a custom XML response.

  • Replace the two lines with this import group:
from flask import Flask, Response, request
from google import genai
from twilio.twiml.messaging_response import MessagingResponse

What do the new imports add?

  • Response lets the route return XML with the correct content type.
  • MessagingResponse builds the TwiML message that Twilio sends back to the customer.
  • In app.py, find the leads() route.
  • Add the following route below return [dict(row) for row in rows]:
@app.post("/whatsapp")
def whatsapp():
    sender = request.form.get("From", "unknown")
    message = request.form.get("Body", "").strip()
    if not message:
        message = "I need help choosing a phone."

    reply, _ = handle_message(sender, message)
    twiml = MessagingResponse()
    twiml.message(reply)
    return Response(str(twiml), mimetype="text/xml")

What does this route do?

  • sender captures the Twilio-provided customer address from From.
  • message captures the incoming text from Body.
  • handle_message() routes the request through the existing sales workflow.
  • MessagingResponse() wraps the reply in TwiML for WhatsApp.
  • Response returns the TwiML as text/xml.
  • Save app.py.
  • Restart the Flask server by running this command in the existing terminal:
python app.py

Why does the terminal stay occupied?

The Flask server keeps listening on port 3000 for incoming requests. Leave this process running while Twilio sends messages to the webhook.

You will see the Flask development server start on port 3000. The terminal remains occupied while the server listens for requests.

Server not starting?

Make sure the terminal is inside the whatsapp-sales-assistant folder. Confirm the virtual environment is active before running the app.

If port 3000 is already occupied, stop the earlier Flask process with Ctrl+C before restarting it.

Help me diagnose why my Flask server is not starting on port 3000.

✔️ Awesome, I've got everything!

Your app.py file now accepts WhatsApp messages and returns TwiML replies.

ⓧ I'd like to double check the full code

import json
import sqlite3

from flask import Flask, Response, request
from google import genai
from twilio.twiml.messaging_response import MessagingResponse

app = Flask(__name__)
client = genai.Client()

MODEL_ID = "gemini-3.8-flash"
INVENTORY_PATH = "inventory.json"
DB_PATH = "sales.db"


def init_db():
    with sqlite3.connect(DB_PATH) as connection:
        connection.execute(
            """
            CREATE TABLE IF NOT EXISTS leads (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                customer_id TEXT NOT NULL,
                message TEXT NOT NULL,
                stage TEXT NOT NULL,
                reply TEXT NOT NULL,
                created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
            )
            """
        )
        connection.commit()


def load_inventory():
    with open(INVENTORY_PATH, encoding="utf-8") as inventory_file:
        return json.load(inventory_file)


def naive_reply():
    return "I recommend the Titan Max. It is our best premium phone."


def classify_stage(message):
    lowered = message.lower()
    if any(word in lowered for word in ("human", "agent", "representative")):
        return "human_handoff"
    if "trade" in lowered:
        return "trade_in"
    if any(word in lowered for word in ("buy", "pay", "checkout")):
        return "payment_pending"
    if any(word in lowered for word in ("order", "delivery", "warranty")):
        return "post_purchase"
    return "recommendation"


def choose_product(message, available_inventory):
    prompt = f"""
You are selecting one phone for a WhatsApp sales assistant.
Choose only an SKU from the supplied inventory.
Match the customer's budget and requested features as closely as possible.

Customer message:
{message}

Inventory:
{json.dumps(available_inventory, indent=2)}
"""

    interaction = client.interactions.create(
        model=MODEL_ID,
        input=prompt,
        response_format={
            "type": "text",
            "mime_type": "application/json",
            "schema": {
                "type": "object",
                "properties": {"sku": {"type": "string"}},
                "required": ["sku"],
            },
        },
    )
    selected_sku = json.loads(interaction.output_text).get("sku")
    selected = next(
        (item for item in available_inventory if item["sku"] == selected_sku),
        None,
    )
    return selected or min(available_inventory, key=lambda item: item["price"])


def build_reply(message):
    stage = classify_stage(message)

    if stage == "human_handoff":
        return "A human handoff has been requested. Status: queued.", stage
    if stage == "trade_in":
        return "SIMULATED TRADE-IN: Your device details were captured for manual review.", stage
    if stage == "payment_pending":
        return "SIMULATED CHECKOUT: A payment link would be created here. Status: pending.", stage
    if stage == "post_purchase":
        return "SIMULATED FOLLOW-UP: Your order-support request was queued.", stage

    available_inventory = [
        item for item in load_inventory() if item["stock"] > 0
    ]
    selected = choose_product(message, available_inventory)
    features = ", ".join(selected["features"])
    reply = (
        f"{selected['name']} (${selected['price']}) matches your request. "
        f"Highlights: {features}. Stock: {selected['stock']}."
    )
    return reply, stage


def save_lead(customer_id, message, stage, reply):
    with sqlite3.connect(DB_PATH) as connection:
        connection.execute(
            """
            INSERT INTO leads (customer_id, message, stage, reply)
            VALUES (?, ?, ?, ?)
            """,
            (customer_id, message, stage, reply),
        )
        connection.commit()


def handle_message(customer_id, message):
    reply, stage = build_reply(message)
    save_lead(customer_id, message, stage, reply)
    return reply, stage


@app.get("/")
def home():
    return {
        "status": "ready",
        "demo": "/demo?message=I%20need%20a%20phone%20under%20500",
        "crm": "/leads",
    }


@app.get("/demo")
def demo():
    message = request.args.get("message", "Recommend a phone")
    if request.args.get("mode") == "naive":
        return {"stage": "ungrounded", "reply": naive_reply()}

    reply, stage = handle_message("browser-demo", message)
    return {"stage": stage, "reply": reply}


@app.get("/leads")
def leads():
    with sqlite3.connect(DB_PATH) as connection:
        connection.row_factory = sqlite3.Row
        rows = connection.execute(
            """
            SELECT id, customer_id, message, stage, reply, created_at
            FROM leads
            ORDER BY id DESC
            """
        ).fetchall()
    return [dict(row) for row in rows]


@app.post("/whatsapp")
def whatsapp():
    sender = request.form.get("From", "unknown")
    message = request.form.get("Body", "").strip()
    if not message:
        message = "I need help choosing a phone."

    reply, _ = handle_message(sender, message)
    twiml = MessagingResponse()
    twiml.message(reply)
    return Response(str(twiml), mimetype="text/xml")


init_db()

if __name__ == "__main__":
    app.run(port=3000)

Use this full-file reference to compare every import and route in your saved app.py file.

Connect the public webhook

Your Flask server currently accepts requests only on your computer. The ngrok tunnel gives Twilio a temporary HTTPS address that forwards requests to port 3000.

  • Leave the terminal running the Flask server.
  • Select the plus button in the VS Code Terminal panel to create a second terminal.
  • Start the public tunnel by running this command in the second terminal:
ngrok http 3000

What does this command do?

Ngrok creates a temporary HTTPS forwarding address for the Flask server on port 3000. Twilio uses that public address to reach the local webhook.

Keep this tunnel for testing only. Closing the ngrok process removes the public path to your computer.

  • Find the HTTPS forwarding URL in the ngrok output.
  • Record it here: your ngrok HTTPS forwarding URL.
  • Leave the ngrok terminal running.

The webhook setting tells Twilio where to send each incoming WhatsApp message. Saving the route suffix ensures the request reaches whatsapp().

  • Return to the Twilio Try WhatsApp page from earlier.
  • Select Sandbox settings.
  • Locate the When a message comes in field.
  • Enter your ngrok HTTPS forwarding URL followed by /whatsapp.
  • Click Save.

The saved webhook field now ends with /whatsapp. This confirms Twilio points incoming messages at the new route.

Webhook URL not saving?

Check that the address begins with HTTPS. Confirm that /whatsapp appears once at the end.

If the ngrok terminal has stopped, restart the tunnel. Save its new HTTPS forwarding URL in Twilio because temporary URLs can change.

Help me troubleshoot my Twilio WhatsApp webhook URL.

Test the complete WhatsApp journey

Every live message now enters the same workflow you tested locally. These five tests use a small part of your 100 trial-message allowance.

Before you send the first message, do you expect the grounded recommendation to match the browser demo? Your prediction is ready to test.

  • Return to the connected Twilio testing conversation in WhatsApp.
  • Send I need a phone under 500 to the connected WhatsApp testing number.

You will receive an inventory-backed recommendation for Nova Lite. The reply includes its stored price and stock.

  • Send trade-in to test the trade-in stage.

You will receive a reply labelled as a simulated trade-in. The workflow records the stage as trade_in.

  • Send buy to test the checkout stage.

You will receive a simulated checkout reply. The workflow records the stage as payment_pending.

  • Send order to test the post-purchase stage.

You will receive a simulated follow-up reply. The workflow records the stage as post_purchase.

  • Send human to test the human-handoff stage.

You will receive a queued handoff reply. The workflow records the stage as human_handoff.

Before you inspect the CRM, do you expect the customer IDs to match the earlier browser records? Your prediction is ready to check.

  • Return to your browser from earlier.
  • Enter http://127.0.0.1:3000/leads in the address bar.

You will see recent CRM rows whose customer_id values begin with whatsapp:. This proves Twilio supplied the customer address to your webhook.

The recent stage values include recommendation, trade_in, payment_pending, post_purchase, and human_handoff.

You have now carried a real WhatsApp message through grounded product selection and into the local CRM. The complete sales journey works through one shared workflow.

Secret mission

Add a Stockout Fail-Safe

Your grounded assistant works while at least one phone is available. Add a defensive stockout path that skips Gemini, gives the customer a clear response, and records a human handoff in the CRM.

Clean Up Your Resources

Clean Up Your Resources

Your initial test fits within the documented free allowances. Decide whether to keep the assistant available, pause its running services, or remove its resources entirely.

Cost warning

Your Twilio trial includes 100 WhatsApp messages as trial free units. After those units, Twilio charges $0.005 per inbound or outbound message. Applicable Meta fees are separate.

The Gemini API Free Tier lists input and output for gemini-3.8-flash as free of charge.

The ngrok Free plan costs $0. Paid usage only begins if you deliberately move to a paid tier.

The Pause or Delete options prevent additional test traffic from reaching your assistant.

Resources you used:

  • The whatsapp-sales-assistant folder containing app.py, inventory.json, requirements.txt, .venv, and sales.db.
  • The running Flask process serving the assistant on port 3000.
  • The active ngrok tunnel with its local authtoken configuration.
  • The Gemini API key exposed locally through the GEMINI_API_KEY Windows user variable.
  • The Twilio trial account with its WhatsApp testing connection and saved Sandbox webhook.
  • The local SQLite CRM containing your sales journey records.

Keep everything running

No action is needed. Choose this if you want to keep demonstrating the complete WhatsApp sales journey.

  • Flask continues serving the assistant on port 3000.
  • The ngrok tunnel keeps its temporary public endpoint reachable while the terminal process remains active.
  • The Twilio Sandbox webhook continues forwarding incoming WhatsApp messages to the assistant.
  • Your local project files remain available for further development.
  • Your CRM records remain available through the /leads route.
  • Your provider accounts and credentials remain available for future tests.

Pause - I'll come back to this later

Pausing shuts down the local server and public tunnel. Your files, CRM records, credentials, and account configurations remain available for later.

  • Return to the terminal running Flask.
  • Press Ctrl+C to stop the Flask server.
  • Return to the terminal running ngrok.
  • Press Ctrl+C to stop the ngrok tunnel.

The saved Twilio webhook no longer has a reachable local tunnel. Incoming messages cannot reach your paused Flask application.

Delete - I don't want to use this again

This option permanently removes the local project and disconnects its service credentials. The Keep or Pause options preserve an easier route back.

  • Return to the terminal running Flask.
  • Press Ctrl+C to stop the Flask server.
  • Return to the terminal running ngrok.
  • Press Ctrl+C to stop the ngrok tunnel.
  • Return to Try WhatsApp in Twilio.
  • Select Sandbox settings.
  • Clear the When a message comes in field.
  • Click Save.

The Twilio Sandbox no longer points incoming messages at your local webhook.

Credentials can outlive deleted files. Revoking them closes access from this prototype.

  • Return to the API key management page in Google AI Studio.
  • Revoke the Gemini API key used by this project.
  • Open Windows Environment Variables.
  • Delete the GEMINI_API_KEY user variable.
  • Open the authtoken management page in your ngrok account.
  • Revoke the authtoken used on this computer.
  • Open the account settings for your Twilio trial account.
  • Close the Twilio trial account if you no longer need it.

The final copy of your CRM sits inside the project folder. Deleting that folder also removes sales.db and its saved conversation history.

  • Press Windows+E to open File Explorer.
  • Return to the parent folder containing whatsapp-sales-assistant.
  • Select the whatsapp-sales-assistant folder.
  • Press the Delete key.
  • Open the Recycle Bin.
  • Empty the Recycle Bin to remove the project permanently.

Cleanup is complete. Your local assistant, CRM history, public webhook, and project credentials have been removed.

Nice Work!

Nice Work!

Excellent work. You built a WhatsApp sales assistant that records its complete journey in a local SQLite CRM.

You've learned how to:

  • Connected the Twilio WhatsApp testing environment to a Flask webhook. Returned a TwiML response to each real message. The matching CRM record preserves a customer ID beginning with whatsapp:.
  • Applied inventory grounding to Gemini product selections. Validated every returned SKU in Python before constructing the recommendation from inventory.json.
  • Implemented deterministic workflow routing for trade-in requests, simulated checkout, post-purchase support, and human handoffs. Persisted every message, stage, and reply in the SQLite CRM.
  • Secret Mission: Added a stockout fail-safe that sends empty-inventory requests to a person. The branch skips Gemini. It saves a human_handoff record.

Ready to quiz yourself?