Build an AI SMS Customer Support Agent
Build an AI support agent that replies to SMS and auto-creates tickets.
Introduction
30 Second Summary
Think about the last time you texted a company for help. The best ones reply instantly with answers that actually solve your problem.
In this project, you will build an AI-powered SMS support agent that receives real text messages, generates intelligent responses using an LLM, and automatically creates support tickets in a database.
What You'll Build
A Python webhook server that receives SMS messages via Twilio, generates AI responses using Groq, and stores support tickets in Supabase.
By the end of this project, you'll have:
- A FastAPI webhook server that processes incoming SMS messages and returns intelligent replies.
- AI-powered response generation using Groq's free inference API and Llama 3.1.
- An automatic ticketing system with conversation history stored in a real PostgreSQL database.
- A full cloud deployment on Render with a public URL connected to Twilio for live SMS.
- Secret Mission: Add conversation memory so the AI references past messages in a support thread.
Are there any prerequisites?
Basic familiarity with Python and APIs is helpful. You should also have Python, VS Code, and a GitHub account ready before starting.
Create Accounts and Set Up the Project
Before writing any code, you need four free accounts and a local Python project ready to go. Each account provides a piece of the puzzle: a phone number for SMS, an AI model for responses, a database for tickets, and a host for deployment.
In this step, you will create accounts on Twilio, Groq, Supabase, and Render. Then you will set up a Python project folder with all the dependencies installed and your credentials securely stored.
In this step, get ready to:
- Create free accounts for Twilio, Groq, Supabase, and Render.
- Set up a local project folder with a virtual environment and dependencies.
- Configure environment variables to securely store your API keys.
Create your free accounts
You need accounts on four platforms. All of them offer free tiers with no credit card required.
Start with Twilio:
- Open https://www.twilio.com/try-twilio in your browser.
- Sign up with your email address.
- Verify your email address when prompted.
- Verify your personal phone number via SMS. This number becomes your first verified caller ID.
- When prompted to select a plan, click Continue with trial.
- On the Console home page, click Get phone number to receive your free Twilio phone number.
- Note your Account SID and Auth Token from the Account Dashboard. You will need these in Step 5.
What is Twilio's free trial?
Twilio's trial gives you 100 free SMS messages over 30 days. Your trial account can only send and receive messages from verified phone numbers (up to 5). The phone number you signed up with is automatically verified.
Next, set up Groq:
- Open https://console.groq.com in your browser.
- Sign up for a free account.
- Once logged in, click API Keys in the left sidebar.
- Click Create API Key.
- Give it a name like sms-support-agent and click Submit.
- Copy your API key immediately and save it somewhere safe. You will not be able to see it again.
Why Groq for AI inference?
Groq's free tier gives you 14,400 requests per day and 500,000 tokens per day with no credit card required. The API is OpenAI-compatible, so you can use the standard OpenAI Python SDK pointed at Groq's endpoint.
Now set up Supabase:
- Open https://supabase.com in your browser.
- Click Start your project and sign up. You can use your GitHub account for quick sign-in.
- Click New Project.
- Enter a project name like sms-support.
- Set a database password and save it somewhere safe.
- Choose a region close to you and click Create new project.
- Wait for the project to finish setting up (about 1 minute).
- Navigate to Settings then API Keys in the left sidebar.
- Copy your Project URL (found in the Integrations section under Data API). It looks like https://abc123.supabase.co.
- Copy your anon key from the Legacy API keys tab.
Which Supabase key do I use?
Your Supabase project has multiple keys. For this project, you need the anon key (also called the publishable key). This key allows your backend to interact with the database. Row Level Security on your tables controls what operations are permitted.
Finally, set up Render:
- Open https://render.com in your browser.
- Click Get Started for Free and sign up. Using your GitHub account makes deployment easier later.
- No additional configuration is needed now. You will use Render in Step 5 to deploy your app.
Set up the project folder and dependencies
With your accounts ready, it is time to create a local project folder. This folder will hold all your Python code, dependencies, and configuration files.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder, move into it, and initialize a Git repository by running these commands:
mkdir sms-support-agent
cd sms-support-agent
git init
- Confirm the folder was created by running:
ls
You should see a blank output (the folder is empty except for the hidden .git directory).
Next, create a Python virtual environment. This keeps your project's packages isolated from other projects on your machine.
- Create and activate the virtual environment by running:
macOS
python3 -m venv venv
source venv/bin/activate
Windows
python -m venv venv
venv\Scripts\activate
You should see (venv) appear at the start of your terminal prompt. This confirms the virtual environment is active.
Don't see (venv) in your prompt?
Make sure you ran both commands. The first creates the environment. The second activates it. On Windows, if you see an execution policy error, run Set-ExecutionPolicy -Scope CurrentUser RemoteSigned first, then try the activate command again.
help me fix my virtual environment activation
Now create the requirements.txt file that lists your project dependencies.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the sms-support-agent folder on your Desktop, then click Open.
- In the Explorer sidebar, click the new file icon and name the file requirements.txt.
- Paste the following contents into requirements.txt:
fastapi==0.115.0
uvicorn[standard]==0.30.0
twilio==9.3.0
openai==1.50.0
supabase==2.9.0
python-dotenv==1.0.1
What are these packages?
- fastapi is the web framework that receives incoming SMS webhooks.
- uvicorn is the server that runs your FastAPI app.
- twilio generates TwiML XML responses for SMS replies.
- openai is the SDK used to call Groq's OpenAI-compatible API.
- supabase is the Python client for your Supabase database.
- python-dotenv loads your .env file so credentials stay out of your code.
- Save requirements.txt.
- Back in your terminal (make sure (venv) is showing), install the dependencies by running:
pip install -r requirements.txt
You should see output showing each package being downloaded and installed. The final line should say Successfully installed followed by the package names.
Installation failed?
Make sure your virtual environment is active (you see (venv) in your prompt). Also confirm you are in the sms-support-agent directory where requirements.txt lives. Run ls to verify the file is there.
help me fix pip install errors
Configure environment variables
Your app needs API keys to talk to Groq and Supabase. Storing these in a .env file keeps them out of your source code and out of version control.
- In VS Code's Explorer sidebar, click the new file icon and name the file .env.
- Paste the following into .env, replacing each placeholder with your actual credentials from earlier:
GROQ_API_KEY=your-groq-api-key-here
SUPABASE_URL=your-supabase-project-url-here
SUPABASE_KEY=your-supabase-anon-key-here
- Replace your-groq-api-key-here with the Groq API key you copied earlier.
- Replace your-supabase-project-url-here with your Supabase project URL (e.g. https://abc123.supabase.co).
- Replace your-supabase-anon-key-here with your Supabase anon key.
- Save .env.
Why only three variables?
Your Twilio Account SID and Auth Token are not needed in your code. Twilio sends messages to your webhook and your app responds with TwiML XML. You only need to authenticate outgoing requests to Groq and Supabase.
Now create a .env.example file. This acts as a template so anyone who clones your repo knows which environment variables to set.
- In VS Code's Explorer sidebar, click the new file icon and name the file .env.example.
- Paste the following into .env.example:
GROQ_API_KEY=your-groq-api-key-here
SUPABASE_URL=your-supabase-project-url-here
SUPABASE_KEY=your-supabase-anon-key-here
- Save .env.example.
Add .env to your .gitignore
Your .env file contains real credentials. Create a .gitignore file with .env and venv/ listed on separate lines to keep them out of Git.
✔️ Awesome, I've got everything!
Great. Double check you have saved all three files: requirements.txt, .env, and .env.example.
ⓧ I'd like to double check the full code
Here is what each file should look like at this point:
requirements.txt:
fastapi==0.115.0
uvicorn[standard]==0.30.0
twilio==9.3.0
openai==1.50.0
supabase==2.9.0
python-dotenv==1.0.1
.env.example:
GROQ_API_KEY=your-groq-api-key-here
SUPABASE_URL=your-supabase-project-url-here
SUPABASE_KEY=your-supabase-anon-key-here
.env (with your actual keys filled in):
GROQ_API_KEY=your-groq-api-key-here
SUPABASE_URL=your-supabase-project-url-here
SUPABASE_KEY=your-supabase-anon-key-here
Your project is set up and ready to go. Next up, you will build the FastAPI webhook that receives incoming SMS messages from Twilio.
Build the FastAPI Webhook
Your project folder is set up with dependencies installed and environment variables configured. Now it is time to write the server that actually receives SMS messages.
When someone texts your Twilio phone number, Twilio sends an HTTP POST request to a URL you configure. This is called a webhook. Your server needs to receive that request and send back a reply in Twilio's XML format called TwiML.
In this step, get ready to:
- Create a FastAPI server with a webhook endpoint that accepts SMS data.
- Test the endpoint locally by simulating a Twilio request.
Create the basic FastAPI app
Twilio sends POST requests with form-encoded data when someone texts your number. The two fields you need are From (the sender's phone number) and Body (the message text).
- Create a file called main.py in your project folder and add the following imports and app initialization:
import os
from fastapi import FastAPI, Form
from fastapi.responses import Response
from twilio.twiml.messaging_response import MessagingResponse
from dotenv import load_dotenv
# Load environment variables from .env file
load_dotenv()
# Create the FastAPI application
app = FastAPI()
What does this code do?
The imports pull in everything your webhook needs. FastAPI is the web framework, Form reads form-encoded POST data, and MessagingResponse builds the TwiML XML that Twilio expects back.
load_dotenv() reads your .env file so your API keys are available as environment variables without hardcoding them.
You won't see a change yet. The next chunk adds the endpoint that handles incoming messages.
- Below the app = FastAPI() line, add the webhook endpoint:
@app.post("/sms")
async def receive_sms(From: str = Form(...), Body: str = Form(...)):
# Extract the sender's phone number and message text
phone_number = From
message_body = Body
# Create a TwiML response to send back via SMS
twiml = MessagingResponse()
twiml.message("Hello! We received your message.")
# Return the response as XML for Twilio to process
return Response(content=str(twiml), media_type="application/xml")
What does this endpoint do?
- @app.post("/sms") tells FastAPI to listen for POST requests at the /sms path. This is the URL you will give Twilio later.
- From: str = Form(...) and Body: str = Form(...) extract the sender's phone number and message text from Twilio's form-encoded POST data.
- MessagingResponse() builds the TwiML XML response. Twilio reads this XML and sends the message content back to the original sender.
- Save main.py.
Seeing a red underline on an import?
Make sure your virtual environment is activated. If VS Code shows import errors, click the Python version in the bottom status bar and select the interpreter inside your virtual environment folder.
Help me fix import errors in my FastAPI project
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on.
ⓧ I'd like to double check the full code
import os
from fastapi import FastAPI, Form
from fastapi.responses import Response
from twilio.twiml.messaging_response import MessagingResponse
from dotenv import load_dotenv
load_dotenv()
app = FastAPI()
@app.post("/sms")
async def receive_sms(From: str = Form(...), Body: str = Form(...)):
phone_number = From
message_body = Body
twiml = MessagingResponse()
twiml.message("Hello! We received your message.")
return Response(content=str(twiml), media_type="application/xml")
Test locally with a simulated request
Before deploying anything, you can verify your webhook works by running the server locally and sending it a fake request that mimics what Twilio would send.
- Start your FastAPI server by running this command:
uvicorn main:app --reload
What does this command do?
uvicorn is the server that runs your FastAPI app. main:app tells it to look for the app object inside main.py. The --reload flag auto-restarts the server when you save changes to your code.
You should see output that includes Uvicorn running on http://127.0.0.1:8000. This means your server is live and waiting for requests.
Server won't start?
Make sure your virtual environment is activated and you are in the same folder as main.py. If you see ModuleNotFoundError, re-run pip install -r requirements.txt inside your virtual environment.
Help me fix my uvicorn server startup error
- Open a second terminal window (keep the server running in the first one).
- Send a simulated Twilio request to your local server by running this command:
macOS
curl -X POST http://localhost:8000/sms -d "From=%2B1234567890&Body=Hello"
Windows
Invoke-RestMethod -Method Post -Uri http://localhost:8000/sms -Body @{From="+1234567890"; Body="Hello"}
You should see TwiML XML in the response that looks like this:
<?xml version="1.0" encoding="UTF-8"?><Response><Message><Body>Hello! We received your message.</Body></Message></Response>
Why XML and not JSON?
Twilio uses an XML-based format called TwiML (Twilio Markup Language) to understand what action to take. When your server returns this XML, Twilio reads the <Message> tag and sends that text as an SMS back to the person who messaged you.
Getting connection refused?
Make sure your uvicorn server is still running in the first terminal. If you see Connection refused, the server may have crashed. Check the first terminal for error messages and restart it.
Help me debug my local FastAPI test request
Your webhook is working locally and returning valid TwiML. Next up, you will connect Groq's AI to generate intelligent responses instead of a static message.
Connect Groq for AI Responses
Your webhook can receive SMS messages and send back a static reply. That proves the plumbing works, but a canned "Hello!" message is not going to help anyone with a support question.
In this step, you will connect your endpoint to Groq's free AI inference API so every incoming text gets a smart, unique response generated by Llama 3.1 8B.
In this step, get ready to:
- Initialize the Groq client using the OpenAI SDK.
- Build an AI response function with a system prompt.
- Test AI-powered SMS replies locally.
Add the Groq client to your project
Groq provides an OpenAI-compatible API, which means you can use the standard openai Python SDK and just point it at Groq's servers instead of OpenAI's.
- Open main.py in VS Code.
- Find this import block at the top of the file:
import os
from fastapi import FastAPI, Form
from fastapi.responses import Response
from twilio.twiml.messaging_response import MessagingResponse
from dotenv import load_dotenv
- Add the OpenAI import by inserting this line between the twilio import and the dotenv import:
from openai import OpenAI
- Now, directly below the app = FastAPI() line, add the Groq client initialization:
groq_client = OpenAI(
api_key=os.getenv("GROQ_API_KEY"),
base_url="https://api.groq.com/openai/v1"
)
What does this code do?
You are creating an OpenAI client instance but pointing it at Groq's servers using the base_url parameter. This is possible because Groq's API follows the same request/response format as OpenAI's.
The GROQ_API_KEY is loaded from your .env file, keeping your credentials out of the source code.
Why Groq instead of OpenAI directly?
Groq's free tier gives you 14,400 requests per day and 500,000 tokens per day with no credit card required. That is more than enough for building and testing a support agent.
The llama-3.1-8b-instant model runs at 560 tokens per second, making responses feel nearly instant for SMS.
Build the AI response function
Your support agent needs instructions on how to behave. A system prompt tells the AI what role to play and how to format its replies.
- Below the groq_client initialization you just added (and above the @app.post("/sms") decorator), add the system prompt and response function:
SYSTEM_PROMPT = """You are a helpful customer support agent. You assist customers with their questions and issues.
Keep your responses concise and friendly, under 160 characters when possible to fit in a single SMS.
If the customer has a problem that needs tracking, acknowledge it and let them know a ticket has been created."""
def generate_ai_response(message_body: str, history: list) -> str:
# Build the messages list starting with the system prompt
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
# Add any conversation history for context
messages.extend(history)
# Add the current user message
messages.append({"role": "user", "content": message_body})
# Call Groq's API using the OpenAI SDK
response = groq_client.chat.completions.create(
model="llama-3.1-8b-instant",
messages=messages,
max_tokens=256,
temperature=0.7
)
return response.choices[0].message.content
What does this code do?
- SYSTEM_PROMPT defines the AI's personality and constraints. It tells the model to keep replies short (under 160 characters for SMS) and to act like a support agent.
- generate_ai_response builds a conversation in the format the API expects: system message first, then any history, then the new user message.
- The history parameter is an empty list for now. You will populate it with real conversation data in a later step.
- temperature=0.7 gives the model some creativity while keeping responses on-topic.
- Save main.py.
Seeing a red underline on the OpenAI import?
Make sure your virtual environment is active (you should see its name in your terminal prompt). The openai package was installed in Step 1 inside the virtual environment.
Help me fix my OpenAI import error in VS Code.
Wire the AI response into the TwiML reply
Your /sms endpoint still returns the hardcoded "Hello!" message. Time to swap that out for the AI-generated response.
- In main.py, scroll down to the receive_sms function and find these lines:
twiml = MessagingResponse()
twiml.message("Hello! We received your message.")
return Response(content=str(twiml), media_type="application/xml")
- Replace those lines with the following, which calls your new AI function and passes its response to the TwiML reply:
ai_response = generate_ai_response(message_body, [])
twiml = MessagingResponse()
twiml.message(ai_response)
return Response(content=str(twiml), media_type="application/xml")
What changed?
Instead of a static string, the endpoint now calls generate_ai_response with the customer's message. The empty list [] is a placeholder for conversation history that you will fill in when you add Supabase.
- Save main.py.
✔️ Awesome, I've got everything!
Great. Double check you have saved your file before moving on to testing.
ⓧ I'd like to double check the full code
Here is the complete main.py at this point:
import os
from fastapi import FastAPI, Form
from fastapi.responses import Response
from twilio.twiml.messaging_response import MessagingResponse
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
app = FastAPI()
groq_client = OpenAI(
api_key=os.getenv("GROQ_API_KEY"),
base_url="https://api.groq.com/openai/v1"
)
SYSTEM_PROMPT = """You are a helpful customer support agent. You assist customers with their questions and issues.
Keep your responses concise and friendly, under 160 characters when possible to fit in a single SMS.
If the customer has a problem that needs tracking, acknowledge it and let them know a ticket has been created."""
def generate_ai_response(message_body: str, history: list) -> str:
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
messages.extend(history)
messages.append({"role": "user", "content": message_body})
response = groq_client.chat.completions.create(
model="llama-3.1-8b-instant",
messages=messages,
max_tokens=256,
temperature=0.7
)
return response.choices[0].message.content
@app.post("/sms")
async def receive_sms(From: str = Form(...), Body: str = Form(...)):
phone_number = From
message_body = Body
ai_response = generate_ai_response(message_body, [])
twiml = MessagingResponse()
twiml.message(ai_response)
return Response(content=str(twiml), media_type="application/xml")
Test with different message types
Time to see your AI in action. You will start the server and send it a few different simulated support messages to confirm it generates appropriate responses.
- Start your local server by running this command:
uvicorn main:app --reload
- Open a second terminal window. Send a test support request by running this command:
macOS
curl -X POST http://127.0.0.1:8000/sms -d "From=%2B1234567890" -d "Body=My order hasn't arrived yet"
Windows
Invoke-RestMethod -Uri http://127.0.0.1:8000/sms -Method POST -Body @{From="+1234567890"; Body="My order hasn't arrived yet"}
You should see an XML response with an AI-generated message inside the <Message> tag instead of the old static text.
- Try a second request with a different type of question:
macOS
curl -X POST http://127.0.0.1:8000/sms -d "From=%2B1234567890" -d "Body=How do I reset my password?"
Windows
Invoke-RestMethod -Uri http://127.0.0.1:8000/sms -Method POST -Body @{From="+1234567890"; Body="How do I reset my password?"}
Each request should return a different, contextually relevant response. The AI adapts its answer based on the message content.
Getting an authentication or API error?
- Check that your .env file contains a valid GROQ_API_KEY value. Copy it fresh from the Groq Console if needed.
- Make sure there are no extra spaces or quotes around the key in your .env file.
- Restart the server after editing .env. The --reload flag watches code changes but does not detect environment file edits.
Help me debug my Groq API connection error.
Your support agent now generates real AI responses to any message it receives. Next up, you will give it a database so it can track support tickets and remember conversations.
Add Supabase for Tickets and Conversation History
Your AI agent can respond to messages with intelligent answers, but every conversation vanishes the moment it ends. There's no record of who asked what, and no way to track unresolved issues.
In this step, you'll connect Supabase as your database layer. Your agent will automatically create support tickets and store every message, giving it persistent memory across conversations.
In this step, get ready to:
- Create tickets and messages tables in your Supabase database.
- Add automatic ticket creation and message logging to your Python code.
- Wire conversation history into the AI prompt for contextual responses.
Create the database tables
Before your Python code can store data, the database needs tables to receive it. You'll create two tables: one for support tickets and one for individual messages within each ticket.
Why two tables instead of one?
A single ticket groups all messages from one support interaction together. Storing messages separately lets you query the full conversation thread, track how many exchanges happened, and attach multiple messages to a single issue.
This is a standard pattern in support systems. One ticket can have many messages, forming a conversation thread.
- Open your Supabase project dashboard.
- In the left sidebar, click SQL Editor.
- Create the tickets table by pasting this SQL and clicking Run:
create table tickets (
id int8 generated always as identity primary key,
phone_number text not null,
subject text,
status text default 'open',
created_at timestamptz,
updated_at timestamptz
);
What does this SQL do?
- id auto-increments so each ticket gets a unique number.
- phone_number tracks which caller opened the ticket.
- subject stores the first 100 characters of the customer's message as a quick summary.
- status defaults to open so you can track which tickets are resolved.
- created_at and updated_at record when the ticket was opened and last active.
- You should see a Success. No rows returned message confirming the table was created.
- Clear the SQL Editor, then create the messages table by pasting this SQL and clicking Run:
create table messages (
id int8 generated always as identity primary key,
ticket_id int8 references tickets(id),
phone_number text not null,
direction text not null,
body text,
created_at timestamptz
);
What does this SQL do?
- ticket_id links each message to its parent ticket using a foreign key.
- direction is either inbound (from the customer) or outbound (from your AI agent).
- body stores the actual message content.
- You should see another Success. No rows returned message.
- In the left sidebar, click Table Editor to verify both tables appear in your table list.
Don't see your tables?
- Make sure you ran each SQL statement separately. If you pasted both together, the second may have failed silently.
- Check for typos in the SQL. Column names and types must match exactly.
- Try refreshing the page and checking the Table Editor again.
Help me troubleshoot my Supabase table creation
Add ticket creation and message storage
Your tables are ready to receive data. Now you'll write the Python functions that create tickets and log messages every time someone texts your agent.
- In main.py, find the import block at the top of your file. Add the Supabase and datetime imports below the existing from openai import OpenAI line:
from supabase import create_client, Client
from dotenv import load_dotenv
from datetime import datetime, timezone
What are these imports for?
- create_client and Client are from the Supabase Python SDK. They let you connect to your database and run queries.
- datetime and timezone generate UTC timestamps for your ticket and message records.
- Next, find the line where you initialized groq_client. Add the Supabase client initialization directly below it (above SYSTEM_PROMPT):
supabase: Client = create_client(
os.getenv("SUPABASE_URL"),
os.getenv("SUPABASE_KEY")
)
What does this do?
This creates a connection to your Supabase PostgreSQL database using the URL and key from your .env file. Every database query in this project goes through this supabase client.
- Save main.py.
Seeing an import error?
If your editor underlines from supabase import create_client, Client, make sure your virtual environment is active and that you installed supabase==2.9.0 from your requirements.txt in Step 1.
Help me fix my Supabase import error
Now you'll write the function that creates a new support ticket or updates an existing one. If a customer already has an open ticket, the function updates its timestamp instead of creating a duplicate.
- In main.py, add the create_or_update_ticket function above generate_ai_response:
def create_or_update_ticket(phone_number: str, message_body: str) -> int:
# Check if this phone number already has an open ticket
existing = supabase.table("tickets").select("*").eq(
"phone_number", phone_number
).eq("status", "open").execute()
now = datetime.now(timezone.utc).isoformat()
if existing.data:
# Update the existing ticket's timestamp
supabase.table("tickets").update({
"updated_at": now
}).eq("id", existing.data[0]["id"]).execute()
return existing.data[0]["id"]
else:
# Create a new ticket with the message as the subject
result = supabase.table("tickets").insert({
"phone_number": phone_number,
"subject": message_body[:100],
"status": "open",
"created_at": now,
"updated_at": now
}).execute()
return result.data[0]["id"]
What does this function do?
- First, it queries the tickets table for any open ticket matching the caller's phone number.
- If a ticket already exists, it updates updated_at to mark the ticket as recently active and returns its ID.
- If no open ticket exists, it creates a new one using the first 100 characters of the message as the subject.
- Either way, it returns the ticket ID so messages can be linked to it.
- Save main.py.
Next, you'll write a simpler function that stores each individual message in the database.
- Add the store_message function below create_or_update_ticket, still above generate_ai_response:
def store_message(phone_number: str, body: str, direction: str, ticket_id: int):
# Insert a message record linked to the ticket
supabase.table("messages").insert({
"phone_number": phone_number,
"body": body,
"direction": direction,
"ticket_id": ticket_id,
"created_at": datetime.now(timezone.utc).isoformat()
}).execute()
What does this function do?
It inserts one row into the messages table. The direction parameter will be inbound for customer messages and outbound for AI replies. This lets you reconstruct the full conversation later.
- Save main.py.
Finally, update your /sms endpoint to use these new functions. The endpoint will create a ticket, store the inbound message, generate a response, and then store the outbound message.
- In main.py, find your existing @app.post("/sms") endpoint and replace the entire function with this updated version:
@app.post("/sms")
async def receive_sms(From: str = Form(...), Body: str = Form(...)):
phone_number = From
message_body = Body
# Create or find the open ticket for this caller
ticket_id = create_or_update_ticket(phone_number, message_body)
# Store the incoming customer message
store_message(phone_number, message_body, "inbound", ticket_id)
# Generate AI response (still using empty history for now)
ai_response = generate_ai_response(message_body, [])
# Store the outbound AI reply
store_message(phone_number, ai_response, "outbound", ticket_id)
twiml = MessagingResponse()
twiml.message(ai_response)
return Response(content=str(twiml), media_type="application/xml")
What changed in the endpoint?
- create_or_update_ticket runs first, so every inbound message is attached to a ticket.
- store_message is called twice. Once for the inbound message and once for the AI's outbound reply.
- The AI still receives an empty history ([]). You'll fix that in the next substep.
- Save main.py.
- Start your server by running this command:
uvicorn main:app --reload
- Open a second terminal and send a test request by running:
curl -X POST http://127.0.0.1:8000/sms -d "From=%2B15551234567" -d "Body=My+order+hasn't+arrived+yet"
- You should see a TwiML XML response with an AI-generated reply in your terminal.
- Open your Supabase dashboard and click Table Editor in the left sidebar.
- Click the tickets table. You should see one row with the phone number and your message as the subject.
- Click the messages table. You should see two rows. One inbound (the customer message) and one outbound (the AI reply).
No rows appearing in Supabase?
- Check your terminal for error messages. A 401 error means your SUPABASE_URL or SUPABASE_KEY is incorrect in your .env file.
- Make sure you copied the anon public key from your Supabase project settings, not the service_role key.
- Verify your table names match exactly: tickets and messages (lowercase, plural).
Help me debug why data isn't appearing in my Supabase tables
Add conversation history retrieval
Your agent stores messages, but it doesn't read them back yet. Right now the AI receives an empty history with every request, so it has no context from prior exchanges.
You'll fix this by querying the last 10 messages for the caller's phone number and including them in the prompt.
- In main.py, add the get_conversation_history function above create_or_update_ticket (below SYSTEM_PROMPT):
def get_conversation_history(phone_number: str) -> list:
# Fetch the last 10 messages from this phone number
response = supabase.table("messages").select("*").eq(
"phone_number", phone_number
).order("created_at", desc=True).limit(10).execute()
# Convert to the role/content format the AI expects
messages = []
for msg in reversed(response.data):
role = "user" if msg["direction"] == "inbound" else "assistant"
messages.append({"role": role, "content": msg["body"]})
return messages
What does this function do?
- It queries the messages table for the 10 most recent messages from this phone number.
- reversed() puts them in chronological order (oldest first) so the AI reads the conversation naturally.
- Each message is converted to the role/content format that the chat completions API expects. Inbound messages become user and outbound become assistant.
- Save main.py.
Now wire the history into your endpoint so the AI actually receives prior context.
- In your receive_sms function, find this line:
# Create or find the open ticket for this caller
ticket_id = create_or_update_ticket(phone_number, message_body)
- Add this line directly above it:
history = get_conversation_history(phone_number)
- Next, find this line:
# Generate AI response (still using empty history for now)
ai_response = generate_ai_response(message_body, [])
- Replace [] with history so the line now looks like this:
ai_response = generate_ai_response(message_body, history)
- Save main.py.
- Your server should auto-reload thanks to the --reload flag. Send another test request from the same phone number:
curl -X POST http://127.0.0.1:8000/sms -d "From=%2B15551234567" -d "Body=Can+you+check+the+status+of+my+order?"
- You should see a TwiML response. The AI now has access to the first message you sent earlier, so its response may reference the prior conversation.
- Check your messages table in Supabase. You should now see four rows total (two inbound, two outbound) all linked to the same ticket.
AI response doesn't seem contextual?
- History only works when prior messages exist in the database. Make sure your first test request from the previous substep completed successfully.
- Confirm you're using the same phone number (+15551234567) in both requests. History is retrieved by phone number.
- Check that you replaced [] with history in the generate_ai_response call. If the AI still receives an empty list, it has no context.
Help me debug why my AI agent doesn't reference earlier messages
✔️ Awesome, I've got everything!
Great. Double check you've saved main.py and that both test requests completed without errors.
ⓧ I'd like to double check the full code
Here's the complete main.py at this point:
import os
from fastapi import FastAPI, Form
from fastapi.responses import Response
from twilio.twiml.messaging_response import MessagingResponse
from openai import OpenAI
from supabase import create_client, Client
from dotenv import load_dotenv
from datetime import datetime, timezone
load_dotenv()
app = FastAPI()
groq_client = OpenAI(
api_key=os.getenv("GROQ_API_KEY"),
base_url="https://api.groq.com/openai/v1"
)
supabase: Client = create_client(
os.getenv("SUPABASE_URL"),
os.getenv("SUPABASE_KEY")
)
SYSTEM_PROMPT = """You are a helpful customer support agent. You assist customers with their questions and issues.
Keep your responses concise and friendly, under 160 characters when possible to fit in a single SMS.
If the customer has a problem that needs tracking, acknowledge it and let them know a ticket has been created."""
def get_conversation_history(phone_number: str) -> list:
response = supabase.table("messages").select("*").eq(
"phone_number", phone_number
).order("created_at", desc=True).limit(10).execute()
messages = []
for msg in reversed(response.data):
role = "user" if msg["direction"] == "inbound" else "assistant"
messages.append({"role": role, "content": msg["body"]})
return messages
def create_or_update_ticket(phone_number: str, message_body: str) -> int:
existing = supabase.table("tickets").select("*").eq(
"phone_number", phone_number
).eq("status", "open").execute()
now = datetime.now(timezone.utc).isoformat()
if existing.data:
supabase.table("tickets").update({
"updated_at": now
}).eq("id", existing.data[0]["id"]).execute()
return existing.data[0]["id"]
else:
result = supabase.table("tickets").insert({
"phone_number": phone_number,
"subject": message_body[:100],
"status": "open",
"created_at": now,
"updated_at": now
}).execute()
return result.data[0]["id"]
def store_message(phone_number: str, body: str, direction: str, ticket_id: int):
supabase.table("messages").insert({
"phone_number": phone_number,
"body": body,
"direction": direction,
"ticket_id": ticket_id,
"created_at": datetime.now(timezone.utc).isoformat()
}).execute()
def generate_ai_response(message_body: str, history: list) -> str:
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
messages.extend(history)
messages.append({"role": "user", "content": message_body})
response = groq_client.chat.completions.create(
model="llama-3.1-8b-instant",
messages=messages,
max_tokens=256,
temperature=0.7
)
return response.choices[0].message.content
@app.post("/sms")
async def receive_sms(From: str = Form(...), Body: str = Form(...)):
phone_number = From
message_body = Body
history = get_conversation_history(phone_number)
ticket_id = create_or_update_ticket(phone_number, message_body)
store_message(phone_number, message_body, "inbound", ticket_id)
ai_response = generate_ai_response(message_body, history)
store_message(phone_number, ai_response, "outbound", ticket_id)
twiml = MessagingResponse()
twiml.message(ai_response)
return Response(content=str(twiml), media_type="application/xml")
Your agent now has a full memory layer. Every message is stored, every caller gets a ticket, and the AI receives conversation context before responding. Next up, you'll deploy this entire system to the cloud so it can receive real SMS messages from Twilio.
Deploy to Render and Connect Twilio
Your AI support agent works perfectly on your local machine. The problem is that Twilio needs a publicly accessible URL to send webhook requests to. Your laptop is hidden behind routers and firewalls, so Twilio has no way to reach it.
In this step, you will push your code to GitHub and deploy it on Render to get a permanent public URL. Then you will point your Twilio phone number at that URL so real SMS messages flow through your AI agent.
In this step, get ready to:
- Push your project to GitHub and deploy it on Render.
- Connect your Twilio phone number's webhook to the deployed service.
- Test the full end-to-end loop with a real SMS message.
Push to GitHub and deploy on Render
Render pulls your code directly from a GitHub repository and builds it in the cloud. You need to get your project into GitHub first, making sure your secret .env file stays out of the repo.
- Create a .gitignore file in your project folder to exclude sensitive files from Git by running:
echo ".env
venv/
__pycache__/" > .gitignore
What does this do?
The .gitignore file tells Git which files to never track. Your .env contains API keys that should never be uploaded to GitHub. The venv/ folder and __pycache__/ are local artifacts Render does not need.
- Stage and commit all your files by running:
git add .
git commit -m "Initial commit: AI support agent"
- Go to github.com/new in your browser.
- Enter a repository name such as ai-sms-support-agent.
- Leave the repo set to Public (Render's free tier needs public repos).
- Do not check any of the initialization options (README, .gitignore, license). Click Create repository.
- GitHub will show you commands to push an existing repository. Copy the two commands under the heading "push an existing repository from the command line" and run them in your terminal. They will look like this:
git remote add origin https://github.com/[[GITHUB_USER="your-github-username"]]/ai-sms-support-agent.git
git branch -M main
git push -u origin main
What does this do?
These commands link your local Git repository to the remote GitHub repository and push your committed code to the main branch. Render will pull from this branch to build your service.
- Refresh your GitHub repository page. You should see main.py, requirements.txt, and .env.example listed. Your .env file should NOT appear.
Don't see your files on GitHub?
Make sure you ran git add . before git commit. If you forgot, run both commands again. If the push failed with a permission error, make sure you are logged in to the GitHub CLI or have an SSH key configured.
help me push my code to GitHub
Now deploy the code on Render so it gets a public URL.
- Open dashboard.render.com and log in with your Render account.
- Click New in the top navigation, then select Web Service.
- Under Source, select Git Provider and click GitHub. Authorize Render to access your repositories if prompted.
- Find your ai-sms-support-agent repository in the list and click Connect.
- Fill in the service settings:
- Name: ai-sms-support-agent (this becomes part of your public URL).
- Region: pick the region closest to you.
- Branch: main.
- Language: Python.
- Build Command: pip install -r requirements.txt.
- Start Command: uvicorn main:app --host 0.0.0.0 --port $PORT.
Why $PORT instead of a fixed number?
Render assigns a port number through the $PORT environment variable. Using $PORT means your server automatically binds to whatever port Render provides. The default is 10000, but hardcoding it would break if Render ever changes the assignment.
- Under Instance Type, select Free.
- Scroll down to the Environment Variables section. Add these three variables with the values from your local .env file:
- GROQ_API_KEY = your Groq API key.
- SUPABASE_URL = your Supabase project URL.
- SUPABASE_KEY = your Supabase anon key.
- Click Create Web Service.
Render kicks off your first build. You can watch the deploy logs in real time on the Events page.
- Wait for the deploy status to show Live. This usually takes 1-2 minutes.
- Copy your service URL from the top of the page. It will look like https://ai-sms-support-agent.onrender.com.
Build failed?
Check the deploy logs for the specific error. The most common issue is a typo in requirements.txt (wrong package name or version). Make sure your requirements.txt is committed and pushed to GitHub.
help me fix my Render deploy
Configure the Twilio webhook
Your server is live on the internet. Now you need to tell Twilio where to forward incoming SMS messages.
- Open the Twilio Console and log in.
- In the left sidebar, click Phone Numbers, then Manage, then Active Numbers.
- Click on the phone number you set up in Step 1.
- Scroll down to the Messaging Configuration section.
- Find the A message comes in row. Make sure the dropdown is set to Webhook.
- Paste your Render URL with /sms appended. It should look like:
https://[[RENDER_URL="your-service-name.onrender.com"]]/sms
- Make sure the method dropdown next to the URL is set to HTTP POST.
- Click Save configuration at the bottom of the page.
What is a webhook?
A webhook is a URL that another service calls when something happens. In this case, when someone texts your Twilio number, Twilio sends an HTTP POST request to your /sms endpoint with the message details. Your server processes it and returns TwiML XML telling Twilio what to reply.
Can't find the Messaging section?
Make sure you clicked on the phone number itself, not just the Phone Numbers list page. The Messaging Configuration section appears on the individual number's settings page. If you see a different layout, look for a Configure tab.
help me find where to set my Twilio webhook URL
Test the full loop with a real SMS
Everything is wired up. Time to send a real text message and confirm the entire system works end-to-end.
About Render's free tier spin-down
Render's free tier spins down your service after 15 minutes of inactivity. The first request after spin-down takes about 30-60 seconds while the service restarts. If Twilio times out on your first text, send a second message. Twilio retries the webhook automatically.
- Open the text messaging app on your phone.
- Send a message like "Hi, I need help with my order" to your Twilio phone number.
- Wait 10-30 seconds. You should receive an AI-generated reply from your support agent.
Trial account prefix
On a Twilio trial account, all outbound messages are prefixed with "Sent from your Twilio trial account." This is normal and goes away when you upgrade to a paid account.
- Open your Supabase dashboard and navigate to the Table Editor.
- Click on the tickets table. You should see a new row with your phone number and the subject set to the first part of your message.
- Click on the messages table. You should see two rows: one with direction set to inbound (your message) and one set to outbound (the AI's reply).
Didn't receive a reply?
- Check that you sent the message from a verified phone number. Twilio trial accounts can only communicate with verified numbers (up to 5).
- Open the Render dashboard and check the deploy logs for any errors. Look for 500 status codes or missing environment variable errors.
- In the Twilio Console, check the Monitor section for error logs. A 502 or timeout error means your Render service is still starting up. Wait a minute and try again.
- Double-check that your webhook URL ends with /sms and uses https://.
help me debug why my Twilio webhook isn't reaching my Render server
Your AI support agent is live. Anyone with a verified phone number can text it and get intelligent responses, with every conversation tracked in your database.
Secret mission
Add Conversation Memory
Your AI agent retrieves conversation history, but it doesn't actually know what to do with it. Right now, past messages are passed in without any instruction to use them. Give your agent true memory so it references earlier messages and provides continuity across a multi-message support thread.
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 on free tiers, so there are no ongoing costs.
Resources you used:
- Render web service (free tier, spins down after 15 minutes of inactivity).
- Supabase project with tickets and messages tables (free tier, pauses after 1 week of inactivity).
- Twilio trial account with a phone number (expires after 30 days).
- Groq API key (free tier, no expiration).
- GitHub repository.
- Local project folder with Python virtual environment and .env file.
Keep everything running
No action needed. Choose this if you are still actively building or plan to extend the project soon.
- All services are on free tiers with no charges.
- Render will spin down after 15 minutes of inactivity and restart when a new request comes in.
- Supabase will auto-pause after 1 week of inactivity. You can unpause it from the dashboard at any time.
- Your Twilio trial will expire after 30 days. You will need to upgrade or create a new trial if you want to keep using it.
Pause - I'll come back to this later
Shut down active services to free up your free-tier quotas while keeping everything ready to resume later.
- In the Render dashboard, navigate to your web service and click Suspend Service. This stops the service without deleting it.
- Your Supabase project will auto-pause after 1 week of inactivity. No action needed.
- Keep your GitHub repository and local project folder intact for when you return.
- Your Groq API key and .env file remain valid whenever you are ready to resume.
Delete - I don't want to use this again
Remove all project resources completely. This cannot be undone.
- In the Render dashboard, open your web service, go to Settings, scroll to the bottom, and click Delete Web Service.
- In the Supabase dashboard, go to Project Settings, scroll to Danger Zone, and click Delete project.
- In the Twilio Console, navigate to Phone Numbers, select your number, and click Release this number.
- Delete your Groq API key from the Groq Console under API Keys.
- Delete your GitHub repository from the repository Settings page under Danger Zone.
- Delete the local project folder from your Desktop.
That's a Wrap!
That's a Wrap!
Nice work! You just built a fully functional AI customer support agent from scratch and deployed it to the cloud.
You've learned how to:
- Build a FastAPI webhook server that receives real SMS messages from Twilio and responds with AI-generated replies.
- Connect Groq's free inference API to generate intelligent support responses using Llama 3.1 8B.
- Create an automatic ticketing system with conversation history in Supabase, and deploy the entire system to Render with a live public URL.
- Added conversation memory so the AI references past messages and provides continuity across multi-message support threads.
Ready to quiz yourself?