Deploy AI Voice Agents for Small Businesses
Build a 24/7 AI phone agent with live dashboard and self-hosted automation.
Introduction
30 Second Summary
Every missed phone call costs a small business money. Whether it's a homeowner trying to book a cleaning or a hungry customer wanting to place a pizza order, unanswered calls mean lost revenue.
In this project, you will build and deploy a complete AI phone agent that answers calls 24/7 for a home services business, books real appointments into a calendar, and displays all call data on a live dashboard the business owner can check anytime.
What You'll Build
A production-ready AI voice agent system with automated appointment scheduling, self-hosted workflow automation on your own server, and a real-time client dashboard deployed to the web.
By the end of this project, you'll have:
- A fully autonomous AI phone agent on Retell AI that answers calls, books appointments into Cal.com, and handles FAQs using a knowledge base.
- A self-hosted n8n automation layer running on an Oracle Cloud VPS (free forever) that processes every call and stores structured data in Supabase.
- A deployed client-facing dashboard on Vercel showing real-time calls, bookings, sentiment scores, and stats that update within seconds of each call.
- Secret Mission: Provision a pizza place order-taking agent and display its orders alongside home services appointments on the same dashboard.
Are there any prerequisites?
You'll need Python (3.10 or higher) and VS Code installed on your machine. You'll also need accounts on Cal.com, Supabase, Vercel, and Oracle Cloud (Always Free tier). Basic Python experience and comfort with web deployment will help you move through the steps smoothly.
Set Up Retell AI, Python SDK, and Supabase
Before your voice agent can answer a single call, it needs infrastructure. You need an account on the platform that handles the real-time audio pipeline, a local project where your Python scripts live, and a database ready to store every call and booking.
In this step, you will create your Retell AI account, install the Python SDK, and set up your Supabase database tables. You will also create a Cal.com event type so the agent can book real appointments later.
In this step, get ready to:
- Create your Retell AI account and copy your API key.
- Install the Python SDK and verify it connects.
- Set up Supabase tables and a Cal.com scheduling event.
Create your Retell AI account and get your API key
Retell AI is the platform that manages the entire voice pipeline for your agent. It handles speech-to-text, LLM processing, text-to-speech, and telephony in one managed service. You get $10 in free credits when you sign up, which covers roughly 90 minutes of conversation.
- Open your browser and navigate to dashboard.retellai.com.
- Click Sign Up and create your account. No credit card is required.
- Once you are inside the dashboard, click the API Keys tab in the left sidebar.
- Copy your API key and save it somewhere temporarily. You will paste it into a file in the next substep.
What is Retell AI?
Retell AI is a managed voice agent platform. It handles the full pipeline from the moment a caller speaks to the moment they hear a response. Your job is to configure the agent's personality, knowledge, and actions. Retell handles the real-time audio engineering.
Can't find the API Keys tab?
Look for it in the left sidebar navigation. If your dashboard opened to a template selection screen, close it first to access the main navigation.
help me find my API key in the Retell AI dashboard
Install the Python SDK and create your project
Your local project folder will hold the Python scripts that interact with Retell's API. The SDK lets you create agents, trigger calls, and pull analytics programmatically.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir voice-agent
cd voice-agent
- Install the Retell Python SDK and the dotenv package by running:
pip install retell-sdk python-dotenv
What are these packages?
The retell-sdk package is the official Python SDK for Retell AI. It provides methods to create agents, trigger phone calls, and retrieve call data.
The python-dotenv package loads environment variables from a .env file so you can keep secrets out of your code.
pip command not found?
Try using pip3 instead of pip. On some systems, Python 3 uses pip3 as the default command.
help me fix a pip installation issue on my system
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Select the voice-agent folder on your Desktop, then click Open.
Now create your environment file to store the API key securely.
- In the VS Code Explorer sidebar, click the new file icon (or press Cmd+N on macOS / Ctrl+N on Windows).
- Name the file .env and add the following content, replacing your-key-here with the API key you copied from the Retell dashboard:
RETELL_API_KEY=your-key-here
Why a .env file?
Storing your API key in a .env file keeps it separate from your code. This means you can share your scripts without accidentally exposing credentials.
- Create another new file called test_retell.py and paste the following code:
import os
from dotenv import load_dotenv
from retell import Retell
load_dotenv()
client = Retell(api_key=os.environ['RETELL_API_KEY'])
print(client)
What does this code do?
This script loads your API key from the .env file, initializes the Retell client, and prints the client object. If the connection works, you will see the client info printed to your terminal.
- Save both files.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on both macOS and Windows).
- Run the test script:
python test_retell.py
- You should see the Retell client object printed in your terminal, confirming the SDK is connected.
Seeing a ModuleNotFoundError?
If you see an error about retell or dotenv not being found, make sure you installed the packages with the same Python version you are using to run the script. Try pip3 install retell-sdk python-dotenv and then python3 test_retell.py.
help me debug a ModuleNotFoundError for retell-sdk
✔️ Awesome, I've got everything!
Great. Double check you have saved both files in your voice-agent folder.
ⓧ I'd like to double check the full code
Here is what your .env file should look like:
RETELL_API_KEY=your-key-here
And here is the full test_retell.py:
import os
from dotenv import load_dotenv
from retell import Retell
load_dotenv()
client = Retell(api_key=os.environ['RETELL_API_KEY'])
print(client)
Set up your Supabase tables and Cal.com event type
Your Supabase database will store every call the agent handles and every booking it makes. You need two tables: one for call records and one for appointment bookings.
- Open your Supabase dashboard at supabase.com/dashboard.
- Click New Project.
- Name the project voice-agent and choose a region close to you.
- Set a database password (save it somewhere safe) and click Create new project.
- Wait for the project to finish provisioning. This takes about 30 seconds.
Now create the tables using the SQL Editor.
- In the left sidebar, click SQL Editor.
- Click New Query.
- Create the calls table by pasting the following SQL and clicking Run (or pressing Cmd+Enter on macOS / Ctrl+Enter on Windows):
create table calls (
call_id text primary key,
agent_id text,
from_number text,
duration_seconds int,
transcript_summary text,
sentiment text,
service_booked boolean,
created_at timestamptz default now()
);
What does this table store?
Every time your voice agent finishes a call, the webhook will write a row here. It captures who called, how long they spoke, what the conversation was about (summary), the caller's sentiment, and whether they booked a service.
- Clear the editor and paste the next query to create the bookings table. Click Run:
create table bookings (
booking_id text primary key,
call_id text references calls(call_id),
customer_name text,
service_type text,
scheduled_time timestamptz,
phone_number text,
status text,
created_at timestamptz default now()
);
What does this table store?
When a caller books an appointment through the voice agent, this table records it. The call_id references the call that created the booking, so you can trace every appointment back to the original conversation.
- Verify both tables exist by clicking Table Editor in the left sidebar. You should see calls and bookings listed.
Don't see your tables?
Make sure each query returned "Success" after you clicked Run. If you see a syntax error, check that you copied the full SQL including the closing semicolon. Also confirm you are creating the calls table first, because bookings references it with a foreign key.
help me fix a SQL error when creating tables in Supabase
Finally, set up your Cal.com event type so the voice agent has somewhere to book appointments.
- Open cal.com in your browser and sign in to your account.
- Click Event Types in the left sidebar.
- Click the + New button in the top right.
- Set the Title to Home Service Appointment.
- Set the Length to 30 minutes.
- Click Continue to save the event type.
Now configure the availability schedule for this event type.
- In the event type settings, click the Availability tab.
- Set the available days to Monday through Saturday.
- Set the hours to 8:00 AM to 6:00 PM.
- Remove Sunday from the available days if it is enabled by default.
- Save your changes.
Why set up Cal.com now?
In a later step, you will connect this event type to your Retell agent using a preset function. When a caller wants to book, the agent will check this schedule for open slots and create a real appointment. Setting it up now means the integration is ready when you need it.
Your infrastructure is ready. You have a Retell AI account, a connected Python SDK, a database waiting for call data, and a calendar ready to accept bookings. Next up, you will deploy n8n on your Oracle Cloud VPS to process webhooks.
Deploy n8n on Your Oracle Cloud VPS
Your data layer is ready. Now you need an automation engine that catches every post-call webhook from Retell, writes structured data to Supabase, and sends notifications.
That engine is n8n. In this step, you will self-host n8n on your Oracle Cloud VPS using Docker Compose and Traefik for automatic HTTPS. The result is a workflow server running 24/7 on free infrastructure with no execution limits.
In this step, get ready to:
- Confirm Docker and Docker Compose are installed on your VPS.
- Deploy n8n with Traefik reverse proxy for automatic HTTPS.
- Install the Retell AI community node and verify your webhook URL is public.
SSH into your VPS and verify Docker
Your Oracle Cloud VPS (Ampere A1 ARM instance, Always Free tier) gives you up to 4 OCPUs, 24 GB RAM, and 200 GB storage. More than enough for n8n.
- SSH into your Oracle Cloud VPS by running this command:
ssh -i [[PEMPATH="path to your .pem key file"]] ubuntu@[[VPS_IP="your VPS public IP address"]]
- Check if Docker is installed by running:
docker --version
- Check if Docker Compose is installed by running:
docker compose version
✔️ I see version numbers for both
Docker and Docker Compose are ready. Skip ahead to the next substep.
ⓧ I see a version lower than expected or command not found
You need to install Docker Engine and the Docker Compose plugin. Follow Docker's official instructions for Ubuntu on ARM.
- Set up Docker's apt repository and install Docker by running these commands one at a time:
# Update package index
sudo apt-get update
# Install prerequisites
sudo apt-get install ca-certificates curl gnupg
# Add Docker's official GPG key
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
# Add the Docker repository
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
# Install Docker Engine, CLI, and Compose plugin
sudo apt-get update
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
- Verify the installation by running:
docker --version
docker compose version
You should see version numbers printed for both Docker and Docker Compose.
Why self-host n8n instead of using n8n Cloud?
n8n Cloud limits workflow executions on lower plans. Self-hosting on Oracle's Always Free tier gives you zero execution limits, zero monthly fees, and full control over your data.
For an agency handling multiple clients, this means your automation costs stay at $0 no matter how many calls your agents process.
Set up n8n with Docker Compose and Traefik
Traefik is a reverse proxy that automatically provisions HTTPS certificates from Let's Encrypt. Combined with n8n in a single Docker Compose file, it gives you a production-ready workflow server behind a secure URL.
- Create the project directory and move into it by running:
mkdir n8n-compose && cd n8n-compose
- Create a local-files directory for shared data between n8n and the host:
mkdir local-files
- Create the .env file with your domain configuration by running:
cat > .env << 'EOF'
DOMAIN_NAME=[[DOMAIN_NAME="yourdomain.com"]]
SUBDOMAIN=n8n
SSL_EMAIL=[[SSL_EMAIL="you@example.com"]]
GENERIC_TIMEZONE=[[TIMEZONE="America/New_York"]]
EOF
What do these environment variables do?
- DOMAIN_NAME is the base domain you own (e.g. yourdomain.com).
- SUBDOMAIN is set to n8n. Your n8n instance will be accessible at https://n8n.yourdomain.com.
- SSL_EMAIL is the email Let's Encrypt uses to notify you about certificate renewals.
- GENERIC_TIMEZONE sets the timezone for scheduled workflows and timestamps.
- Create the compose.yaml file by running:
cat > compose.yaml << 'EOF'
services:
traefik:
image: "traefik"
restart: always
command:
- "--api.insecure=true"
- "--providers.docker=true"
- "--providers.docker.exposedbydefault=false"
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entrypoint.scheme=https"
- "--entrypoints.websecure.address=:443"
- "--certificatesresolvers.mytlschallenge.acme.tlschallenge=true"
- "--certificatesresolvers.mytlschallenge.acme.email=${SSL_EMAIL}"
- "--certificatesresolvers.mytlschallenge.acme.storage=/letsencrypt/acme.json"
ports:
- "80:80"
- "443:443"
volumes:
- traefik_data:/letsencrypt
- /var/run/docker.sock:/var/run/docker.sock:ro
n8n:
image: docker.n8n.io/n8nio/n8n
restart: always
ports:
- "127.0.0.1:5678:5678"
labels:
- traefik.enable=true
- traefik.http.routers.n8n.rule=Host(`${SUBDOMAIN}.${DOMAIN_NAME}`)
- traefik.http.routers.n8n.tls=true
- traefik.http.routers.n8n.entrypoints=web,websecure
- traefik.http.routers.n8n.tls.certresolver=mytlschallenge
- traefik.http.middlewares.n8n.headers.SSLRedirect=true
- traefik.http.middlewares.n8n.headers.STSSeconds=315360000
- traefik.http.middlewares.n8n.headers.browserXSSFilter=true
- traefik.http.middlewares.n8n.headers.contentTypeNosniff=true
- traefik.http.middlewares.n8n.headers.forceSTSHeader=true
- traefik.http.middlewares.n8n.headers.SSLHost=${DOMAIN_NAME}
- traefik.http.middlewares.n8n.headers.STSIncludeSubdomains=true
- traefik.http.middlewares.n8n.headers.STSPreload=true
- traefik.http.routers.n8n.middlewares=n8n@docker
environment:
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
- N8N_HOST=${SUBDOMAIN}.${DOMAIN_NAME}
- N8N_PORT=5678
- N8N_PROTOCOL=https
- NODE_ENV=production
- WEBHOOK_URL=https://${SUBDOMAIN}.${DOMAIN_NAME}/
- GENERIC_TIMEZONE=${GENERIC_TIMEZONE}
- TZ=${GENERIC_TIMEZONE}
volumes:
- n8n_data:/home/node/.n8n
- ./local-files:/files
volumes:
traefik_data:
n8n_data:
EOF
What does this Docker Compose file do?
It defines two services that work together:
- Traefik listens on ports 80 and 443, automatically redirects HTTP to HTTPS, and provisions TLS certificates from Let's Encrypt. It reads Docker labels to know which containers to route traffic to.
- n8n runs on internal port 5678. The Traefik labels on the n8n service tell Traefik to route requests for n8n.yourdomain.com to this container with a valid HTTPS certificate.
- WEBHOOK_URL tells n8n the public URL to use when generating webhook addresses. This is what Retell will POST to after each call.
- Before starting the containers, point your DNS A record for your n8n subdomain to your VPS public IP address. Go to your domain registrar's DNS settings and add:
- Type: A
- Name: n8n
- Value: your VPS public IP address.
- TTL: automatic (or 300).
DNS propagation takes time
DNS changes can take a few minutes to propagate. If Traefik fails to issue a certificate on first boot, wait 5 minutes and restart the containers.
- Start both containers by running:
sudo docker compose up -d
- Verify both containers are running by running:
sudo docker compose ps
You should see two services listed with a status of "running" or "Up".
Containers not starting?
- Check the logs with sudo docker compose logs to see error messages.
- Make sure ports 80 and 443 are open in your Oracle Cloud security list (VCN ingress rules). Oracle blocks these by default.
- Confirm your DNS A record points to the correct IP by running dig n8n.yourdomain.com from your local machine.
Still stuck? Help me troubleshoot my n8n Docker Compose deployment on Oracle Cloud
Access n8n and install the Retell AI community node
- Open your browser and navigate to https://n8n.yourdomain.com (replacing with your actual subdomain and domain).
- Create your admin account by filling in the registration form. Choose a strong password and save it somewhere secure.
You should see the n8n dashboard with an empty workflow canvas.
- Install the Retell AI community node. Click the gear icon to open Settings.
- Click Community nodes in the left sidebar.
- Click Install (or Install a community node).
- Enter the Retell AI package name in the npm package name field.
- Check the box to acknowledge the risks of installing unverified code.
- Click Install.
What is a community node?
Community nodes are third-party integrations published to npm that extend n8n with new triggers and actions. The Retell AI community node adds Retell-specific nodes to your workflow canvas, making it easier to work with Retell webhooks and API calls.
- Verify your webhook URL is publicly accessible. In n8n, click the + to create a new workflow, then add a Webhook trigger node.
- Copy the Production URL shown in the webhook node. It should start with https://n8n.yourdomain.com/webhook/.
- Open that URL in a new browser tab. You should see a response (like "Workflow was started") confirming the webhook is publicly reachable.
This public webhook URL is what Retell will POST to after every call. You will configure it in a later step.
Webhook URL not accessible?
- Make sure the workflow is saved and active (toggle the switch in the top-right corner of n8n).
- Confirm your Oracle Cloud security list allows inbound traffic on ports 80 and 443 from all sources (0.0.0.0/0).
- If you recently added DNS, wait a few minutes for propagation and try again.
Still stuck? Help me make my n8n webhook URL publicly accessible
Your automation server is live. Next up, you will build your first voice agent in the Retell AI dashboard and teach it to handle home services calls.
Build Your First Voice Agent
Your automation infrastructure is live. n8n is running on your Oracle Cloud VPS, ready to process webhooks the moment a call ends. Now it is time to build the thing that actually picks up the phone.
In this step, you will create your first Retell AI voice agent named Alex. Alex is a friendly receptionist for a home services company who can answer questions about services, pricing, and hours.
In this step, get ready to:
- Create and configure a voice agent in the Retell dashboard.
- Build a knowledge base with service details and pricing.
- Test the agent with a live web call through your browser.
Create your agent and configure the global prompt
Every Retell agent needs three things: a voice (how it sounds), an LLM (how it thinks), and a global prompt (what it knows and how it behaves). The global prompt defines the agent's persona and influences every response it generates.
- Return to your Retell AI dashboard at dashboard.retellai.com.
- Click the Agents tab in the left sidebar.
- Click Create an agent.
- Retell will show template options. Choose a template to start from, or select a blank conversation flow agent.
What is a conversation flow agent?
Retell offers two agent types: Single/Multi Prompt agents (simpler, one continuous prompt) and Conversation Flow agents (node-based, structured paths). A conversation flow agent lets you define exact paths and transitions for different scenarios. You will use this approach because it gives you precise control over the booking path later in the project.
- Click on the empty canvas area, then click Settings to access the global agent settings.
- In the voice settings section, open the voice selection dropdown.
- Listen to the available voice samples and select a voice you like for Alex. Platform voices are optimized for phone conversations.
- Under the language model selection, choose GPT-4.1. This is Retell's recommended model for balancing response quality and latency.
Now write the global prompt that defines Alex's personality and knowledge. This text is available in every node and influences all response generation.
- Locate the Global Prompt section in the settings panel.
- Paste the following prompt into the Global Prompt field:
You are Alex, a friendly and professional receptionist for Sunshine Home Services.
Your role:
- Answer incoming calls warmly and helpfully
- Help callers book appointments for home services
- Answer questions about services, pricing, and availability
- Collect caller details: name, phone number, address, preferred date/time
Services offered:
- House Cleaning: $120 (standard, 2-3 hours)
- Deep Clean: $200 (full home, 4-5 hours)
- Handyman: $80/hour (repairs, installations, odd jobs)
Business hours: Monday through Saturday, 8am to 6pm
Escalation rules:
- If a caller asks about something outside your knowledge, say: "Let me connect you with our team for that."
- If a caller is upset or requests a manager, offer to transfer them immediately
- Never make up information about services not listed above
Tone: Friendly, concise, and professional. Use short sentences. Confirm details by repeating them back to the caller.
What does this prompt do?
The global prompt sets three things: who Alex is (persona), what Alex knows (services and pricing), and how Alex behaves (escalation rules and tone). Every response the agent generates is filtered through this prompt.
Keeping the prompt under 3,500 tokens is important for cost. Retell charges the base LLM rate for prompts up to 3,500 tokens. Above that threshold, pricing scales proportionally.
- All changes in the Retell dashboard auto-save. Verify you see your prompt text in the Global Prompt field.
Don't see the Global Prompt field?
Make sure you clicked on the empty canvas area first (not a specific node), then clicked Settings in the panel that appears. The Global Prompt section is part of the global settings, not node-level settings.
If you created a Single Prompt agent instead of a Conversation Flow agent, the prompt field is directly visible in the agent editor view. Help me find the Global Prompt field in my Retell dashboard.
Add a knowledge base with service details
The global prompt gives Alex a persona, but a knowledge base gives Alex detailed reference material it can pull from mid-call. Instead of stuffing all the detail into the prompt, the agent retrieves only the relevant chunks when a caller asks a specific question.
- In the Retell dashboard, select the Knowledge Base tab.
- Click the Add button in the top-right corner.
- Select Text as the source type. This lets you paste plain text content directly.
- Paste the following service information into the text field:
SUNSHINE HOME SERVICES - Complete Service Guide
SERVICES AND PRICING:
1. Standard House Cleaning - $120
- Duration: 2-3 hours
- Includes: vacuuming, mopping, dusting, bathroom cleaning, kitchen cleaning
- Does NOT include: inside oven, inside fridge, windows exterior, garage
- Frequency discounts: weekly 15% off, bi-weekly 10% off
2. Deep Clean - $200
- Duration: 4-5 hours
- Includes everything in standard PLUS: inside appliances, baseboards, ceiling fans, light fixtures, inside cabinets, window tracks
- Recommended for first-time customers or seasonal cleaning
3. Handyman Services - $80/hour
- Minimum booking: 1 hour
- Services: furniture assembly, TV mounting, shelf installation, minor plumbing, minor electrical, drywall patching, painting touch-ups
- Materials not included in hourly rate
- Free estimates for jobs over 3 hours
SERVICE AREA:
- We serve within 25 miles of downtown
- No travel fee within 15 miles
- $15 travel fee for 15-25 mile radius
BUSINESS HOURS:
- Monday through Saturday: 8:00 AM - 6:00 PM
- Closed Sundays and major holidays
- Emergency handyman available Saturday for +$40 surcharge
CANCELLATION POLICY:
- Free cancellation up to 24 hours before appointment
- 50% charge for cancellations within 24 hours
- No charge if we cancel or reschedule
PAYMENT:
- Payment due at completion of service
- Accepted: cash, credit card, Venmo, Zelle
- Invoices sent via email for card payments
Why use a knowledge base instead of putting this in the prompt?
The knowledge base uses RAG (Retrieval-Augmented Generation). Instead of stuffing all this text into the prompt for every call, the agent pulls only the relevant chunks when a caller asks a specific question.
This keeps your per-minute LLM costs lower. It also helps the agent focus on the most relevant information rather than processing hundreds of tokens of context on every turn.
- Save the knowledge base item.
- Return to your agent by clicking the Agents tab, then selecting your Alex agent.
- In the agent editor, locate the Knowledge Base section.
- Select the knowledge base item you just created to link it to this agent.
Your agent now automatically retrieves relevant information from this knowledge base when callers ask about pricing, services, or policies.
Knowledge base not showing in agent settings?
Make sure you created the knowledge base item from the Knowledge Base tab first. The agent editor only lets you select existing knowledge base items. If you still cannot see it, refresh the page.
Also verify you are in the correct agent (not a different one you may have created during testing). Help me connect my knowledge base to my Retell agent.
Test your agent with a web call
Retell lets you test your agent directly in the browser using your laptop microphone. This is the fastest way to verify everything works before assigning a phone number.
- In the Retell dashboard with your Alex agent open, click Test in the top area of the agent editor.
- Select Web Call.
- Your browser will request microphone access. Click Allow when prompted.
- Start speaking as if you were a caller. Try saying something like: "Hi, I'd like to book a house cleaning for this Saturday."
What should I test for?
During your test call, verify these four things:
- The agent greets you naturally and asks how it can help.
- When you ask about pricing (e.g., "How much is a deep clean?"), the agent pulls the correct price from the knowledge base.
- The agent handles interruptions smoothly. Try talking over it mid-sentence.
- Response latency feels conversational. You should notice roughly 600ms between when you stop talking and when Alex responds.
- End the test call by clicking the hang-up button in the browser.
- Review the call transcript that appears after the call ends. Verify the agent referenced the correct service pricing and maintained its Alex persona throughout.
Agent not responding or giving generic answers?
If the agent is not using your global prompt, check that you saved it in the correct location (global settings on the empty canvas, not a node-level prompt). If it is not referencing the knowledge base for pricing, verify the knowledge base item is linked in the Knowledge Base section of the agent editor.
If your microphone is not working, check your browser's site permissions for dashboard.retellai.com and ensure your mic is not muted at the OS level.
Each test call uses your $10 free credits at the standard per-minute rate, so you have plenty of room to test multiple times. Help me troubleshoot my Retell AI web call test.
Your voice agent is alive. Alex can greet callers, answer pricing questions from the knowledge base, and hold a natural conversation. Next up, you will design the full conversation flow and connect it to Cal.com so Alex can actually book real appointments.
Design the Conversation Flow and Connect Booking
Your agent can handle basic questions, but a real home services receptionist needs a structured conversation that leads callers to an outcome. Right now, someone calling to book a house cleaning would get a freeform chat with no guarantee it ends in an appointment.
The conversation flow builder gives you a visual, node-based system to define exactly what happens at each stage of a call. In this step, you will map the happy path from greeting to confirmed booking, add branches for common detours, and wire up Cal.com so the agent books real appointments during live calls.
In this step, get ready to:
- Design the primary conversation path from greeting to booking confirmation.
- Add branching logic for pricing inquiries, human transfers, rescheduling, and confused callers.
- Connect the Book Appointments preset function to Cal.com for live scheduling.
Open the conversation flow builder and design the primary path
Every call to your home services business follows a predictable shape. The caller says hello, states what they need, picks a service, gives their details, and confirms a booking. The conversation flow builder lets you represent each of those stages as a separate node with its own instructions and transition rules.
- Return to the Retell AI dashboard at dashboard.retellai.com.
- Click on your Alex agent in the Agents tab.
- Open the conversation flow canvas. You will see the drag-and-drop builder where you add and connect nodes.
What is the conversation flow builder?
Retell calls this the "agentic framework." It is a visual canvas where each node represents one stage of a phone call. You connect nodes with edges that define when the agent transitions to the next stage. Each node has its own instructions, so the LLM only focuses on one task at a time.
Now you will create seven nodes that form the happy path. Each one handles a single stage of the call.
- Add a Conversation Node from the left sidebar. Name it Greeting.
- Click on the node and write its instruction: greet the caller warmly, introduce yourself as Alex from the home services company, and ask how you can help today.
- Add a second Conversation Node. Name it Intent Detection.
- Write its instruction: determine whether the caller wants to book a service, ask about pricing, reschedule an existing appointment, or speak to a human.
- Add a third Conversation Node named Service Selection. Instruct it to present the available services (house cleaning $120, deep clean $200, handyman $80/hr) and confirm the caller's choice.
- Add a fourth Conversation Node named Detail Collection. Instruct it to collect the caller's name, address, and preferred date/time for the appointment.
- Add a Function Node named Booking. You will attach the Cal.com function to this node in the third substep.
- Add a Conversation Node named Confirmation. Instruct it to confirm the appointment details (service, date, time) and let the caller know they will receive a calendar invite.
- Add an End Node named Closing. This terminates the call gracefully after the confirmation.
Now connect the nodes into a chain.
- Click and hold the circle at the bottom of the Greeting node, then drag a line to the Intent Detection node.
- Repeat to connect Intent Detection to Service Selection, then Service Selection to Detail Collection, then Detail Collection to Booking, then Booking to Confirmation, then Confirmation to Closing.
- Add a transition condition on the Greeting node by clicking the + button. Choose Prompt and enter: "User has stated what they need or responded to the greeting."
- Add a prompt transition condition on Intent Detection: "User wants to book a new service." Connect this edge to Service Selection.
What are transition conditions?
Transition conditions are rules that determine when the agent leaves one node and enters the next. A "Prompt" condition is evaluated by the LLM based on the conversation context. An "Equation" condition uses a hardcoded check on a variable. For most of this flow, Prompt conditions work best because callers express themselves in unpredictable ways.
- Verify your canvas shows a linear chain of seven nodes from Greeting to Closing. All changes auto-save in Retell.
Nodes looking cluttered on the canvas?
Click the Organize button to automatically tidy the layout. You can also zoom in and out using the scroll wheel or trackpad pinch.
If a node does not appear after selecting it from the sidebar, scroll or zoom out on the canvas to find it.
My nodes aren't connecting or transitions aren't appearing.
Add branching logic for common caller intents
Not every caller wants to book a new appointment. Some ask about pricing. Others want to speak with a human or reschedule. Your Intent Detection node already identifies what the caller wants. Now you will add separate branches so each intent gets handled properly.
- Add a new Conversation Node named Pricing Inquiry. Write its instruction: answer the caller's pricing questions using the knowledge base, then ask if they would like to book a service.
- In the node settings panel on the right, find Knowledge Base and attach the knowledge base you created in Step 3. This gives the node access to detailed pricing tiers and service area info.
- Connect Intent Detection to Pricing Inquiry with a new edge. Add a prompt transition condition: "User is asking about pricing, costs, or what services cost."
- Connect Pricing Inquiry back to Service Selection with a transition condition: "User wants to proceed with booking a service."
Now add the human transfer branch.
- Add a Call Transfer Node named Human Transfer. This node type transfers the call to a phone number you specify.
- In the node settings, enable Global Node. This allows the node to be reached from anywhere in the flow, not just from one specific edge.
- Set the global node condition to: "User explicitly asks to speak with a human, a manager, or a real person."
- Enter a placeholder phone number for the transfer destination. You can update this later with the business owner's number.
Why use a Global Node for human transfer?
A Global Node can be triggered from any point in the conversation. This means if a caller says "let me talk to a person" during pricing questions, detail collection, or anywhere else, the agent immediately routes them to a human. You do not need to wire a separate edge from every single node.
- Add a Conversation Node named Rescheduling. Write its instruction: ask for the caller's name and original appointment date, then explain that you will look up their existing booking and help them find a new time.
- Connect Intent Detection to Rescheduling with a prompt transition condition: "User wants to reschedule or change an existing appointment."
- Add a Conversation Node named Confused Caller. Write its instruction: gently ask the caller to clarify what they need, restate the available options (book, pricing, reschedule, or speak to a human), and guide them back to the correct path.
- Connect Intent Detection to Confused Caller with a prompt transition condition: "User's intent is unclear or they have not stated what they need after being asked."
- Connect Confused Caller back to Intent Detection with a transition condition: "User has now stated what they need."
Your canvas should now show the main linear path plus four branches coming off Intent Detection. Retell auto-saves all changes as you work.
Add the Book Appointments function and connect to Cal.com
The Booking Function Node you created earlier is empty. It needs a function attached that actually creates the appointment in Cal.com when the agent reaches this stage. Retell provides a pre-built "Book Appointment" tool type specifically for Cal.com integration.
First, you need two pieces of information from Cal.com.
- Switch to your Cal.com dashboard in another browser tab.
- Open the Home Service Appointment event type you created earlier.
- Look at the URL in your browser. The Event Type ID is the number at the end of the URL. For example, in https://app.cal.com/your-username/event-type/1427703, the ID is 1427703. Copy your Event Type ID.
- Navigate to Settings in Cal.com.
- Click Developer, then API Keys.
- Create a new API key and copy it. You will paste it into Retell in the next steps.
What does the Book Appointment function do?
This pre-built function connects directly to Cal.com's API. When the agent reaches the Booking node during a call, it sends the caller's details (name, date, time) to Cal.com and creates a real calendar event. The caller gets an appointment without anyone manually entering it.
Now configure both calendar functions in Retell.
- Return to the Retell dashboard and open your Alex agent's conversation flow.
- Click on the Booking Function Node you created.
- In the function settings, click + Add to add a function. From the tool type dropdown, select Check Calendar Availability.
- Fill in the fields: enter a unique tool name (e.g. check_availability_cal), paste your Cal.com API Key, enter your Event Type ID, and set the description to "Checks availability for 30-minute home service appointments."
- Click Save.
- Add a second function on the same agent. Select Book Appointment from the tool type dropdown.
- Fill in the fields: enter a unique tool name (e.g. book_appointment), paste the same Cal.com API Key and Event Type ID, and set the description to "Book the appointment for 30-minute home service calls. Pass customer_name, service_type, preferred_datetime, and phone_number."
- Click Save.
Keep your prompts explicit about when to call functions
Retell recommends adding explicit instructions about when to invoke each function. The tool descriptions you wrote tell the LLM what each function does and what data to pass, which helps it decide the right moment to trigger a booking.
Now you need to handle the case where the caller's requested time is not available.
- Add a new Conversation Node named Unavailable Time. Write its instruction: apologize that the requested time is not available, present the next 2-3 open slots from the availability check results, and ask the caller to pick one.
- Connect the Booking node to Unavailable Time with a transition condition: "The requested time slot is not available based on the calendar check."
- Connect Unavailable Time back to the Booking node with a transition condition: "User has selected one of the offered alternative time slots."
- Enable Speak During Execution on the Booking Function Node. Set the utterance to: "Let me check that for you and get it booked." This keeps the caller engaged while the function executes.
- Enable Wait for Result on the Booking node so the agent waits for Cal.com's response before transitioning.
Why add an Unavailable Time node?
Without this node, the agent would silently fail if the caller picks a time that is already booked. By routing to a dedicated node, the agent gracefully offers alternatives and loops back to complete the booking. This mirrors how a human receptionist would handle the same situation.
Test the complete flow to make sure the transitions work.
- Click Test in the top right of the dashboard, then select Web Call.
- Walk through a full booking conversation: greet, say you want to book a house cleaning, give a name and address, pick a time, and confirm.
- Watch the canvas highlight each node as the agent transitions through them. Verify that the flow reaches the Booking node and then Confirmation.
- Test a pricing branch: start a new web call and ask "How much does a deep clean cost?" Verify the agent routes to Pricing Inquiry and answers using the knowledge base.
- Test the human transfer: say "Can I speak to someone?" and verify the agent routes to the Human Transfer global node.
Agent getting stuck on a node or taking the wrong branch?
Check that your transition conditions are clear and specific. Vague conditions like "user responds" can fire too early. Rewrite them to describe the exact intent (e.g. "User explicitly asks about pricing").
If a wrong transition keeps firing, add fine-tune examples on that node. Click the node, open Fine-tuning Examples, and add an example of what the user says and which transition should fire.
Help me debug why my Retell conversation flow transitions are not working correctly.
Your conversation flow is now production-ready. The agent follows a structured path to booking, handles detours gracefully, and creates real Cal.com appointments. Next up, you will deploy this agent to a live phone number and wire the post-call data into your automation pipeline.
Deploy to a Live Number and Wire n8n to Supabase
Your agent Alex has a voice, a brain, a conversation flow, and a calendar connection. But right now it only exists inside the Retell test environment.
In this step, you will deploy Alex to a real phone number that answers calls 24/7. Then you will build the automation pipeline that makes every call self-documenting: Retell fires a webhook after each call, your self-hosted n8n instance catches it, and the structured data flows into Supabase without any manual intervention.
In this step, get ready to:
- Purchase a phone number and assign Alex to answer it autonomously.
- Build an n8n workflow that processes post-call data into Supabase.
- Test the full autonomous loop from live call to database record.
Buy a phone number and assign it to Alex
Retell needs a payment method on file before you can purchase a phone number. The number costs $2/month and gives your agent a permanent, publicly-reachable line.
- Return to your Retell dashboard at dashboard.retellai.com.
- Click the Billing tab in the left sidebar.
- Click Change payment methods and add a credit or debit card.
Why do I need a payment method?
Retell gives you $10 in free credits when you sign up. The payment method is required specifically for purchasing a phone number ($2/month). Your free credits cover call usage during testing.
- Click the Phone Numbers tab in the left sidebar.
- Click Buy New Number.
- Optionally enter an area code to get a local number for your target market, then click Purchase.
- Once purchased, assign your Alex agent to this number using the agent dropdown next to the phone number.
Your agent is now live. Retell's infrastructure handles all inbound calls to this number 24/7 with zero involvement from you.
- Call your new number from your personal phone.
- Have a complete booking conversation: say you need a house cleaning, give a name, preferred time, and phone number.
- Confirm the agent books the appointment and says goodbye naturally.
- After hanging up, open your Cal.com dashboard and verify the appointment appears in your Home Service Appointment event type.
What should I see?
In Cal.com, you should see a new booking with the customer name, service type, and scheduled time that you provided during the call. This confirms the voice agent and Cal.com integration are working end to end.
Agent not answering the call?
Make sure the agent is assigned to the phone number. In the Phone Numbers tab, the number should show Alex's name next to it.
If you hear ringing but no answer, double check that your payment method was saved successfully in the Billing tab.
Why isn't my Retell agent answering calls on the phone number I purchased?
Build the n8n post-call workflow
Every time a call ends, Retell analyzes it and fires a call_analyzed webhook event containing the full call data. Your n8n instance will catch this event, extract the structured fields, and write them to Supabase automatically.
What is the call_analyzed event?
After a call ends, Retell runs post-call analysis (sentiment, summary, success status). Once analysis completes, it POSTs a JSON payload to your webhook URL with "event": "call_analyzed" and the full call object including the call_analysis field.
This is the final event in the call lifecycle: call_started then call_ended then call_analyzed. You only need to listen for call_analyzed because it contains everything.
- Switch back to your n8n instance at https://n8n.yourdomain.com.
- Click Create new workflow and name it Retell Post-Call Processing.
- Click the + button on the canvas to add your first node.
- Search for Webhook and select it as the trigger node.
- Leave the HTTP Method as POST (Retell sends POST requests).
- Copy the Production URL displayed at the top of the Webhook node panel. It will look like https://n8n.yourdomain.com/webhook/.... Save this URL for the next step.
Now register this URL in Retell so it knows where to send post-call data.
- Switch back to the Retell dashboard.
- Click the Webhooks tab in the left sidebar (under system settings).
- Paste your n8n Production webhook URL into the account-level webhook URL field.
- Save the webhook configuration.
Why account-level instead of agent-level?
An account-level webhook sends events for all agents under your account to a single URL. This means when you add more agents later (like the pizza place in the Secret Mission), they all route through the same n8n workflow. One workflow handles everything.
Next, build the processing logic in n8n. You need a filter to only act on call_analyzed events, then three output branches.
- Return to your n8n workflow.
- Add an IF node (search for "IF" in the node picker) after the Webhook trigger.
- Configure the condition: set the value to {{ $json.event }}, operation to equals, and comparison value to call_analyzed.
What does this filter do?
Retell sends multiple event types to your webhook (call_started, call_ended, call_analyzed). You only want to process the final call_analyzed event because it contains the complete call data including sentiment analysis and transcript summary.
Now set up the Supabase credentials in n8n so your workflow can write to your database.
- From the IF node's true output, add a Supabase node.
- When prompted for credentials, click Create new credential.
- In your Supabase dashboard, go to Project Settings then API.
- Copy your Project URL and paste it as the Host in n8n.
- Reveal and copy the service_role API key and paste it as the Service Role Secret in n8n.
- Save the credential.
Configure this first Supabase node to insert call records.
- Set Resource to Row and Operation to Create.
- Select the calls table.
- Map the incoming webhook data to your table columns: call_id from {{ $json.call.call_id }}, agent_id from {{ $json.call.agent_id }}, from_number from {{ $json.call.from_number }}, duration_seconds from {{ $json.call.duration_seconds }}, transcript_summary from {{ $json.call.call_analysis.call_summary }}, and sentiment from {{ $json.call.call_analysis.user_sentiment }}.
Where do these field paths come from?
The call_analyzed webhook payload has a call object containing all call metadata. Inside that object, call_analysis holds the post-call analysis results including call_summary (a text summary) and user_sentiment (Positive, Negative, Neutral, or Unknown).
Add the conditional bookings insert next.
- From the same IF node's true output, add a second IF node to check whether a booking was made during this call.
- Set the condition to check if {{ $json.call.call_analysis.call_successful }} equals true (or use custom analysis data if you configured a booking-specific field).
- From this second IF node's true output, add another Supabase node.
- Set it to Row > Create on the bookings table.
- Map the fields: call_id, customer_name, service_type, scheduled_time, phone_number, and set status to confirmed.
Finally, add an email notification so the business owner knows when a call comes in.
- From the first IF node's true output, add a Send Email node (or use the Gmail/SMTP node depending on your preference).
- Configure it to send to the business owner's email address.
- Set the subject to something like New call from {{ $json.call.from_number }}.
- Include the transcript summary, sentiment, and whether a booking was made in the email body.
- Save the workflow by pressing Ctrl+S (Windows) or Cmd+S (macOS).
- Toggle the workflow to Active using the switch in the top-right corner. This registers the Production webhook URL so Retell can reach it.
Why must the workflow be active?
n8n only listens on the Production webhook URL when the workflow is published and active. If the workflow is inactive, Retell's POST requests will get no response and the data will be lost.
Supabase node showing connection error?
Double check that you used the service_role key, not the anon key. The anon key has row-level security restrictions that can block inserts from n8n.
Also verify the Host URL is your full Supabase project URL (including https://).
Help me fix my n8n Supabase connection error.
Test the full autonomous loop
The entire data pipeline is wired. Time to verify that a single phone call flows through every system without any manual steps.
- Call your agent's phone number from your personal phone.
- Have a full booking conversation: request a service, provide your name, pick a time, and confirm the appointment.
- Hang up and wait about 30 seconds for Retell to finish its post-call analysis.
Now verify each link in the chain.
- Open your Cal.com dashboard. Confirm the appointment appears with the correct details.
- Open your Supabase dashboard. Navigate to the calls table and confirm a new row exists with your call's call_id, from_number, duration_seconds, transcript_summary, and sentiment.
- Navigate to the bookings table and confirm a new row exists with the customer_name, service_type, scheduled_time, and status set to confirmed.
- Check your email inbox. You should have received a notification email with the call summary and sentiment.
What is this data pipeline?
The complete loop works like this: a caller dials your number, Retell handles the conversation and books into Cal.com during the call, then after hanging up Retell POSTs the call_analyzed event to your n8n instance on your Oracle Cloud VPS. n8n filters the event, inserts into calls and bookings tables in Supabase, and sends a notification email.
Everything is now dashboard-ready. The next step will build the client-facing dashboard that reads from these Supabase tables in real time.
No data appearing in Supabase after the call?
Check the n8n Executions tab to see if the workflow was triggered. If no execution appears, the webhook URL may not be reachable. Verify your workflow is set to Active and that the Retell webhook URL matches the Production URL from your Webhook node.
If the execution shows an error, click into it to see which node failed. Common issues are field name mismatches between the Retell payload and your Supabase column names.
Help me debug why my n8n workflow isn't receiving Retell webhook data.
Your agent is now fully autonomous. Calls come in, appointments get booked, data gets stored, and the owner gets notified. Next, you will build the client-facing dashboard that turns this raw Supabase data into a polished interface the business owner checks every day.
Build and Deploy the Client Dashboard
Your voice agent is live, your n8n workflow is writing every call to Supabase, and bookings are flowing into Cal.com. The missing piece is visibility.
In this step, you will build a client-facing dashboard that pulls data from your Supabase tables and displays it in real time. This is the product you show business owners: a single URL where they see every call their AI agent handles.
In this step, get ready to:
- Build a dashboard page with Today's Appointments, Recent Calls, and Stats sections.
- Add Supabase Realtime subscriptions so new data appears within seconds.
- Deploy the dashboard to Vercel and test the full end-to-end loop.
Create the dashboard project and connect to Supabase
You will use plain HTML and JavaScript for maximum simplicity. The Supabase JS client loads from a CDN, so there is no build step required.
- In your Supabase dashboard, open the project's Connect dialog (or go to Settings then API Keys).
- Copy your Project URL (it looks like https://yourproject.supabase.co).
- Copy your anon (publishable) key for client-side operations.
Where are my Supabase credentials?
The Project URL and anon key are in the Connect dialog at the top of your Supabase dashboard. You can also find them under Settings then API Keys.
The anon key is safe to use on the client side because Row Level Security controls what data it can access.
- Enable Realtime for your tables. In the Supabase SQL Editor, run the following SQL:
alter publication supabase_realtime add table calls;
alter publication supabase_realtime add table bookings;
What does this do?
This adds your calls and bookings tables to the supabase_realtime publication. Supabase uses this publication to stream INSERT, UPDATE, and DELETE events to connected clients in real time.
- Grant anonymous read access so the dashboard can query without authentication by running:
-- Allow the dashboard to read call and booking data
grant select on public.calls to anon;
grant select on public.bookings to anon;
Why grant select to anon?
The Supabase anon key can only access data that has been explicitly granted to the anon role. Without this grant, your dashboard would get empty results even though the tables have data.
- Switch back to VS Code. Create a new folder for your dashboard by running these commands:
cd ~/Desktop
mkdir dashboard
cd dashboard
- Create the dashboard file by running:
touch index.html
- Confirm the file exists by running:
ls
You should see index.html in the output.
- Open the dashboard folder in VS Code. Click File in the top menu bar.
- Click Open Folder.
- Select the dashboard folder on your Desktop, then click Open.
- Open index.html and paste the following code:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Agent Dashboard</title>
<!-- Load Supabase JS client from CDN -->
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; padding: 2rem; }
h1 { margin-bottom: 1.5rem; color: #1a1a1a; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem; }
.card { background: white; border-radius: 12px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.card h2 { font-size: 1.1rem; color: #333; margin-bottom: 1rem; }
.stats-row { display: flex; gap: 1rem; margin-bottom: 2rem; }
.stat-card { flex: 1; background: white; border-radius: 12px; padding: 1.5rem; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.stat-card .number { font-size: 2rem; font-weight: 700; color: #2563eb; }
.stat-card .label { font-size: 0.85rem; color: #666; margin-top: 0.25rem; }
.call-item, .booking-item { padding: 0.75rem 0; border-bottom: 1px solid #eee; }
.call-item:last-child, .booking-item:last-child { border-bottom: none; }
.sentiment-badge { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 0.5rem; }
.sentiment-positive { background: #22c55e; }
.sentiment-neutral { background: #eab308; }
.sentiment-negative { background: #ef4444; }
.duration { color: #666; font-size: 0.85rem; }
.time { color: #2563eb; font-weight: 500; }
.summary { color: #555; font-size: 0.9rem; margin-top: 0.25rem; }
.empty { color: #999; font-style: italic; padding: 1rem 0; }
</style>
</head>
<body>
<h1>AI Agent Dashboard</h1>
<!-- Stats section -->
<div class="stats-row">
<div class="stat-card">
<div class="number" id="calls-today">0</div>
<div class="label">Calls Today</div>
</div>
<div class="stat-card">
<div class="number" id="avg-sentiment">--</div>
<div class="label">Avg Sentiment</div>
</div>
<div class="stat-card">
<div class="number" id="bookings-week">0</div>
<div class="label">Bookings This Week</div>
</div>
</div>
<!-- Main grid: appointments and calls -->
<div class="grid">
<div class="card">
<h2>Today's Appointments</h2>
<div id="appointments-list"><p class="empty">Loading...</p></div>
</div>
<div class="card">
<h2>Recent Calls</h2>
<div id="calls-list"><p class="empty">Loading...</p></div>
</div>
</div>
<script>
// Initialize Supabase client using the global 'supabase' variable from CDN
const { createClient } = supabase;
const SUPABASE_URL = 'YOUR_SUPABASE_URL';
const SUPABASE_ANON_KEY = 'YOUR_SUPABASE_ANON_KEY';
const db = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// Helper: get sentiment badge class
function sentimentClass(sentiment) {
if (!sentiment) return 'sentiment-neutral';
const s = sentiment.toLowerCase();
if (s === 'positive') return 'sentiment-positive';
if (s === 'negative') return 'sentiment-negative';
return 'sentiment-neutral';
}
// Helper: format duration in seconds to readable string
function formatDuration(seconds) {
if (!seconds) return '0s';
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return min > 0 ? `${min}m ${sec}s` : `${sec}s`;
}
// Helper: truncate text to a max length
function truncate(text, maxLen) {
if (!text) return '';
return text.length > maxLen ? text.slice(0, maxLen) + '...' : text;
}
// Load today's appointments sorted by scheduled_time
async function loadAppointments() {
const today = new Date().toISOString().split('T')[0];
const { data, error } = await db
.from('bookings')
.select('*')
.gte('scheduled_time', today + 'T00:00:00')
.lte('scheduled_time', today + 'T23:59:59')
.order('scheduled_time', { ascending: true });
const container = document.getElementById('appointments-list');
if (error || !data || data.length === 0) {
container.innerHTML = '<p class="empty">No appointments today.</p>';
return;
}
container.innerHTML = data.map(b => `
<div class="booking-item">
<span class="time">${new Date(b.scheduled_time).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
- ${b.customer_name || 'Unknown'} (${b.service_type || 'General'})
</div>
`).join('');
}
// Load last 20 calls with duration, sentiment, and transcript summary
async function loadCalls() {
const { data, error } = await db
.from('calls')
.select('*')
.order('created_at', { ascending: false })
.limit(20);
const container = document.getElementById('calls-list');
if (error || !data || data.length === 0) {
container.innerHTML = '<p class="empty">No calls recorded yet.</p>';
return;
}
container.innerHTML = data.map(c => `
<div class="call-item">
<span class="sentiment-badge ${sentimentClass(c.sentiment)}"></span>
<span class="duration">${formatDuration(c.duration_seconds)}</span>
<span class="duration"> | ${new Date(c.created_at).toLocaleString()}</span>
<p class="summary">${truncate(c.transcript_summary, 80)}</p>
</div>
`).join('');
}
// Load stats: calls today, average sentiment, bookings this week
async function loadStats() {
const today = new Date().toISOString().split('T')[0];
// Calls today
const { data: callsToday } = await db
.from('calls')
.select('*')
.gte('created_at', today + 'T00:00:00');
// Bookings this week
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
const { data: bookingsWeek } = await db
.from('bookings')
.select('*')
.gte('created_at', weekAgo.toISOString());
// Average sentiment from today's calls
const { data: recentCalls } = await db
.from('calls')
.select('sentiment')
.gte('created_at', today + 'T00:00:00');
document.getElementById('calls-today').textContent = callsToday ? callsToday.length : 0;
document.getElementById('bookings-week').textContent = bookingsWeek ? bookingsWeek.length : 0;
if (recentCalls && recentCalls.length > 0) {
const scores = recentCalls.map(c => {
if (c.sentiment === 'positive') return 1;
if (c.sentiment === 'negative') return -1;
return 0;
});
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
let label = 'Neutral';
if (avg > 0.3) label = 'Positive';
if (avg < -0.3) label = 'Negative';
document.getElementById('avg-sentiment').textContent = label;
}
}
// Subscribe to real-time changes on calls and bookings tables
function subscribeRealtime() {
db
.channel('dashboard-calls')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'calls' }, () => {
loadCalls();
loadStats();
})
.subscribe();
db
.channel('dashboard-bookings')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'bookings' }, () => {
loadAppointments();
loadStats();
})
.subscribe();
}
// Initialize dashboard on page load
loadAppointments();
loadCalls();
loadStats();
subscribeRealtime();
</script>
</body>
</html>
What does this code do?
The dashboard loads the Supabase JS client from a CDN and connects using your project URL and anon key. It fetches data from two tables:
- bookings (filtered to today, sorted by scheduled_time) shows upcoming appointments.
- calls (last 20, ordered by created_at) shows recent calls with a colored sentiment badge and a truncated transcript summary.
- Stats show total calls today, average sentiment, and bookings in the past week.
The subscribeRealtime() function creates two channels listening for INSERT events on calls and bookings. When n8n writes a new row, the dashboard refreshes automatically with no page reload needed.
- Replace YOUR_SUPABASE_URL with your Supabase Project URL.
- Replace YOUR_SUPABASE_ANON_KEY with your anon (publishable) key.
- Save index.html.
- Test locally by opening index.html directly in your browser (double-click the file, or right-click and choose Open with your browser).
You should see the Stats row at the top with numbers from your test calls. The Appointments and Calls sections should show data already in your Supabase tables from earlier testing.
Dashboard showing zeros or empty sections?
- Make sure you replaced both placeholder strings with your actual Supabase URL and anon key.
- Check your browser console for errors. A CORS or 401 error means the anon key is incorrect or your table permissions are not set.
- Confirm you ran the grant select SQL commands earlier to allow anonymous read access.
Why can't my dashboard read from Supabase?
Add real-time updates and verify the live data flow
The Realtime subscriptions are already in your code. Now you will verify they work by making a live call and confirming the dashboard updates without a page refresh.
- Keep your dashboard open in the browser.
- Call your Retell agent phone number from your personal phone.
- Have a short conversation. Request a service and provide your name and preferred time so the agent books an appointment.
- Hang up and watch the dashboard.
Within a few seconds of hanging up, the n8n workflow processes the webhook, writes to Supabase, and the dashboard updates automatically. You should see the new call appear in Recent Calls and (if you booked) a new entry in Today's Appointments.
How does the real-time update work?
When n8n inserts a row into the calls or bookings table, Supabase detects the change through its supabase_realtime publication. It pushes the event to all subscribed clients over a WebSocket connection.
Your dashboard receives the event and re-fetches the latest data. No page refresh needed.
New call not appearing on the dashboard?
- Verify the call completed successfully by checking your n8n workflow execution log.
- Confirm you ran the alter publication supabase_realtime add table SQL for both tables.
- Open your browser console and look for WebSocket connection errors. If the Realtime service is not connecting, go to your Supabase project's Realtime settings and confirm the service is enabled.
Help me debug why Supabase Realtime is not pushing updates to my dashboard.
Deploy to Vercel and test end-to-end
Your dashboard works locally. Now you will push it to Vercel so the business owner can access it from any browser with a simple URL.
- Initialize a Git repository in your dashboard folder by running:
git init
git add .
git commit -m "Initial dashboard"
- Create a new repository on GitHub. Name it something like ai-agent-dashboard.
- Push your local code to GitHub by running:
git remote add origin [[YOUR_REPO_URL="https://github.com/your-username/ai-agent-dashboard.git"]]
git branch -M main
git push -u origin main
- Go to vercel.com and sign in with your Vercel account.
- Click Add New... then Project.
- Import the ai-agent-dashboard repository you just pushed.
- For Framework Preset, select Other.
- Leave the Build Command field empty.
- Set Output Directory to . (a single period).
- Click Deploy.
Why select Other with no build command?
Your dashboard is a plain HTML file with no framework or build step. Setting the framework to Other and the output directory to . tells Vercel to serve your files as-is from the repository root.
Vercel will deploy within seconds. You will see a live URL like https://ai-agent-dashboard.vercel.app.
- Open the Vercel deployment URL in your browser.
- Verify you see the same dashboard with your Stats, Appointments, and Calls data.
- Run the final end-to-end test: call your agent phone number, book an appointment, and watch the deployed dashboard update live.
You should see the new call appear in Recent Calls and the booking appear in Today's Appointments within seconds of hanging up. This deployed URL is what you share with the business owner.
Seeing a 404 on Vercel?
- Make sure your file is named index.html in all lowercase. Vercel is case-sensitive.
- Confirm the Output Directory is set to . (a period) in your project settings.
- Check that index.html is at the root of your repository, not inside a subfolder.
Help me fix my Vercel 404 for a static HTML deployment.
✔️ Awesome, I've got everything!
Great. Double check you have saved index.html with your Supabase URL and anon key filled in, and that your Vercel deployment is live.
ⓧ I'd like to double check the full code
Here is the complete index.html file for comparison:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Agent Dashboard</title>
<!-- Load Supabase JS client from CDN -->
<script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #f5f5f5; padding: 2rem; }
h1 { margin-bottom: 1.5rem; color: #1a1a1a; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; margin-bottom: 2rem; }
.card { background: white; border-radius: 12px; padding: 1.5rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.card h2 { font-size: 1.1rem; color: #333; margin-bottom: 1rem; }
.stats-row { display: flex; gap: 1rem; margin-bottom: 2rem; }
.stat-card { flex: 1; background: white; border-radius: 12px; padding: 1.5rem; text-align: center; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
.stat-card .number { font-size: 2rem; font-weight: 700; color: #2563eb; }
.stat-card .label { font-size: 0.85rem; color: #666; margin-top: 0.25rem; }
.call-item, .booking-item { padding: 0.75rem 0; border-bottom: 1px solid #eee; }
.call-item:last-child, .booking-item:last-child { border-bottom: none; }
.sentiment-badge { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 0.5rem; }
.sentiment-positive { background: #22c55e; }
.sentiment-neutral { background: #eab308; }
.sentiment-negative { background: #ef4444; }
.duration { color: #666; font-size: 0.85rem; }
.time { color: #2563eb; font-weight: 500; }
.summary { color: #555; font-size: 0.9rem; margin-top: 0.25rem; }
.empty { color: #999; font-style: italic; padding: 1rem 0; }
</style>
</head>
<body>
<h1>AI Agent Dashboard</h1>
<!-- Stats section -->
<div class="stats-row">
<div class="stat-card">
<div class="number" id="calls-today">0</div>
<div class="label">Calls Today</div>
</div>
<div class="stat-card">
<div class="number" id="avg-sentiment">--</div>
<div class="label">Avg Sentiment</div>
</div>
<div class="stat-card">
<div class="number" id="bookings-week">0</div>
<div class="label">Bookings This Week</div>
</div>
</div>
<!-- Main grid: appointments and calls -->
<div class="grid">
<div class="card">
<h2>Today's Appointments</h2>
<div id="appointments-list"><p class="empty">Loading...</p></div>
</div>
<div class="card">
<h2>Recent Calls</h2>
<div id="calls-list"><p class="empty">Loading...</p></div>
</div>
</div>
<script>
// Initialize Supabase client using the global 'supabase' variable from CDN
const { createClient } = supabase;
const SUPABASE_URL = 'YOUR_SUPABASE_URL';
const SUPABASE_ANON_KEY = 'YOUR_SUPABASE_ANON_KEY';
const db = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
// Helper: get sentiment badge class
function sentimentClass(sentiment) {
if (!sentiment) return 'sentiment-neutral';
const s = sentiment.toLowerCase();
if (s === 'positive') return 'sentiment-positive';
if (s === 'negative') return 'sentiment-negative';
return 'sentiment-neutral';
}
// Helper: format duration in seconds to readable string
function formatDuration(seconds) {
if (!seconds) return '0s';
const min = Math.floor(seconds / 60);
const sec = seconds % 60;
return min > 0 ? `${min}m ${sec}s` : `${sec}s`;
}
// Helper: truncate text to a max length
function truncate(text, maxLen) {
if (!text) return '';
return text.length > maxLen ? text.slice(0, maxLen) + '...' : text;
}
// Load today's appointments sorted by scheduled_time
async function loadAppointments() {
const today = new Date().toISOString().split('T')[0];
const { data, error } = await db
.from('bookings')
.select('*')
.gte('scheduled_time', today + 'T00:00:00')
.lte('scheduled_time', today + 'T23:59:59')
.order('scheduled_time', { ascending: true });
const container = document.getElementById('appointments-list');
if (error || !data || data.length === 0) {
container.innerHTML = '<p class="empty">No appointments today.</p>';
return;
}
container.innerHTML = data.map(b => `
<div class="booking-item">
<span class="time">${new Date(b.scheduled_time).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</span>
- ${b.customer_name || 'Unknown'} (${b.service_type || 'General'})
</div>
`).join('');
}
// Load last 20 calls with duration, sentiment, and transcript summary
async function loadCalls() {
const { data, error } = await db
.from('calls')
.select('*')
.order('created_at', { ascending: false })
.limit(20);
const container = document.getElementById('calls-list');
if (error || !data || data.length === 0) {
container.innerHTML = '<p class="empty">No calls recorded yet.</p>';
return;
}
container.innerHTML = data.map(c => `
<div class="call-item">
<span class="sentiment-badge ${sentimentClass(c.sentiment)}"></span>
<span class="duration">${formatDuration(c.duration_seconds)}</span>
<span class="duration"> | ${new Date(c.created_at).toLocaleString()}</span>
<p class="summary">${truncate(c.transcript_summary, 80)}</p>
</div>
`).join('');
}
// Load stats: calls today, average sentiment, bookings this week
async function loadStats() {
const today = new Date().toISOString().split('T')[0];
// Calls today
const { data: callsToday } = await db
.from('calls')
.select('*')
.gte('created_at', today + 'T00:00:00');
// Bookings this week
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
const { data: bookingsWeek } = await db
.from('bookings')
.select('*')
.gte('created_at', weekAgo.toISOString());
// Average sentiment from today's calls
const { data: recentCalls } = await db
.from('calls')
.select('sentiment')
.gte('created_at', today + 'T00:00:00');
document.getElementById('calls-today').textContent = callsToday ? callsToday.length : 0;
document.getElementById('bookings-week').textContent = bookingsWeek ? bookingsWeek.length : 0;
if (recentCalls && recentCalls.length > 0) {
const scores = recentCalls.map(c => {
if (c.sentiment === 'positive') return 1;
if (c.sentiment === 'negative') return -1;
return 0;
});
const avg = scores.reduce((a, b) => a + b, 0) / scores.length;
let label = 'Neutral';
if (avg > 0.3) label = 'Positive';
if (avg < -0.3) label = 'Negative';
document.getElementById('avg-sentiment').textContent = label;
}
}
// Subscribe to real-time changes on calls and bookings tables
function subscribeRealtime() {
db
.channel('dashboard-calls')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'calls' }, () => {
loadCalls();
loadStats();
})
.subscribe();
db
.channel('dashboard-bookings')
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'bookings' }, () => {
loadAppointments();
loadStats();
})
.subscribe();
}
// Initialize dashboard on page load
loadAppointments();
loadCalls();
loadStats();
subscribeRealtime();
</script>
</body>
</html>
Your client dashboard is live. Every call your AI agent handles shows up here instantly. Next up, you will break down the cost model and learn to manage agents programmatically with the Python SDK.
Understand the Cost Model and Use the Python SDK
Your voice agent is live, answering calls, booking appointments, and feeding data into a dashboard. The system runs itself.
Now it is time to understand the business side. In this step, you will map out the per-minute cost of every call, calculate your margin as an agency, and write Python scripts that give you programmatic control over your agents.
In this step, get ready to:
- Map the per-minute cost breakdown and agency pricing model.
- Write a Python script that triggers outbound calls via the Retell SDK.
- Build an automated reporting script for client analytics.
Review the cost breakdown and map agency economics
Before you can price a service, you need to know what each call actually costs you. Retell AI bills per minute across several components that add up to a predictable all-in rate.
- In the Retell dashboard, click the call history section to review your recent test calls.
- Note the duration of each call and the cost breakdown shown per call.
What does each call cost you?
Here is the per-minute cost breakdown for a standard configuration using GPT-4.1, platform voices, and a knowledge base:
- Retell Voice Infra: $0.055/min.
- TTS (Platform Voices): $0.015/min.
- LLM (GPT-4.1 standard): $0.04/min.
- Telephony: $0.015/min.
- Knowledge Base: $0.005/min.
All-in, a typical call costs about $0.11-0.13/min depending on configuration. A 4-minute home services call costs roughly $0.44-0.52.
Now translate that into agency economics. Here is what a typical home services client looks like:
- 500 calls/month at 4 minutes average = 2,000 minutes.
- Your cost: 2,000 min x $0.11-0.13 = ~$220-260/month.
- You charge the client: $500-800/month flat fee.
- Your margin: $240-580/month per client.
What about infrastructure costs?
Your Oracle Cloud VPS is Always Free tier. Supabase is free tier. Vercel is free tier. The only recurring costs are the $2/month phone number and Retell per-minute usage.
This means your margin is almost entirely the gap between Retell usage costs and what you charge the client. No server bills eating into profit.
Write a Python script to trigger outbound calls
The Retell SDK lets you trigger calls programmatically. This is how you would trigger confirmation calls or outbound campaigns for clients without touching the dashboard.
- In VS Code, create a new file called outbound_call.py in your project folder.
- Add the following code to trigger an outbound call:
import os
from dotenv import load_dotenv
from retell import Retell
load_dotenv()
client = Retell(api_key=os.environ['RETELL_API_KEY'])
# Trigger an outbound call from your Retell number to a target number
call = client.call.create_phone_call(
from_number='+1XXXXXXXXXX',
to_number='+1YYYYYYYYYY'
)
print(call)
What does this code do?
- It loads your API key from the .env file using dotenv.
- It initializes the Retell client with your credentials.
- It calls client.call.create_phone_call() to instruct Retell to dial the target number from your purchased Retell number.
- The response object contains call details like the call_id and status.
- Replace +1XXXXXXXXXX with your purchased Retell phone number (the one assigned to your agent).
- Replace +1YYYYYYYYYY with your personal phone number (include country code).
- Save outbound_call.py and run it:
python outbound_call.py
Your personal phone should ring within a few seconds. When you answer, the AI agent responds just like it does for inbound calls.
Phone not ringing?
- Make sure both phone numbers include the country code (e.g. +1 for US).
- Verify you have added a payment method in the Retell dashboard under the Billing tab. Outbound calls require an active payment method.
- Check that your from_number matches the number you purchased in the Phone Numbers tab exactly.
Why is my outbound call not connecting?
Write an automated reporting script
As an agency, you need to report usage back to clients. This script lists all your agents, pulls call history, and calculates totals. It is the foundation of automated client reporting.
- Create a new file called agent_report.py in your project folder.
- Add the following reporting code:
import os
from dotenv import load_dotenv
from retell import Retell
load_dotenv()
client = Retell(api_key=os.environ['RETELL_API_KEY'])
# List all agents in your account
agents = client.agent.list()
for agent in agents:
print(f'Agent: {agent.agent_id} - {agent.agent_name}')
# Retrieve call history and calculate totals
calls = client.call.list()
total_calls = len(calls)
total_minutes = sum(c.duration_ms or 0 for c in calls) / 60000
estimated_cost = total_minutes * 0.12
print(f'Total calls: {total_calls}')
print(f'Total minutes: {total_minutes:.1f}')
print(f'Estimated cost: ${estimated_cost:.2f}')
What does this code do?
- client.agent.list() fetches all voice agents on your account. Each agent object has an agent_id and agent_name.
- client.call.list() retrieves your call history. Each call object includes a duration_ms field in milliseconds. The script divides by 60,000 to convert to minutes.
- The estimated cost multiplies total minutes by $0.12 (the approximate all-in rate). In production, you would pull exact costs from detailed billing data in the Retell dashboard.
- Save agent_report.py and run it:
python agent_report.py
You should see your agent listed by ID and name, followed by a summary of total calls, total minutes, and estimated cost across all calls.
Seeing zero calls or no agents?
- If the agents list is empty, confirm you created at least one agent in the Retell dashboard.
- If total calls is 0, make sure you have completed at least one test call (web call or phone call) that has ended. Only completed calls appear in the history.
- Check that your RETELL_API_KEY in .env matches the key shown in your dashboard under the API Keys tab.
Why is my agent report showing no data?
✔️ Awesome, I've got everything!
Great. Make sure both outbound_call.py and agent_report.py are saved in your project folder.
ⓧ I'd like to double check the full code
Here is the complete code for both files:
import os
from dotenv import load_dotenv
from retell import Retell
load_dotenv()
client = Retell(api_key=os.environ['RETELL_API_KEY'])
call = client.call.create_phone_call(
from_number='+1XXXXXXXXXX',
to_number='+1YYYYYYYYYY'
)
print(call)
import os
from dotenv import load_dotenv
from retell import Retell
load_dotenv()
client = Retell(api_key=os.environ['RETELL_API_KEY'])
agents = client.agent.list()
for agent in agents:
print(f'Agent: {agent.agent_id} - {agent.agent_name}')
calls = client.call.list()
total_calls = len(calls)
total_minutes = sum(c.duration_ms or 0 for c in calls) / 60000
estimated_cost = total_minutes * 0.12
print(f'Total calls: {total_calls}')
print(f'Total minutes: {total_minutes:.1f}')
print(f'Estimated cost: ${estimated_cost:.2f}')
Secret mission
Provision a Pizza Place Agent with Orders on the Dashboard
Your home services agent proves the concept. Now prove the system scales to a completely different business type. A pizza place has no appointments. It has orders: sizes, toppings, totals, pickup times. Build a second agent from a JSON config file, route its call data through your existing n8n pipeline, and display live orders on the same dashboard. Two businesses, one unified platform.
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. Your phone number costs $2/month and Retell charges per-minute usage whenever calls come in. All other infrastructure (Oracle Cloud VPS, Supabase, Vercel) runs on free tiers with no ongoing charges.
Cost warning
Your Retell AI phone number charges $2/month regardless of whether it receives calls. Retell also charges ~$0.11-0.13/min for each call your agent handles. If you leave the number active, you will be billed for any incoming calls.
If you are not ready to use this in production, choose Pause or Delete below to stop charges immediately.
Resources you used:
- Retell AI agents (Alex home services agent, Sal's Pizza order-taking agent).
- Retell AI phone number ($2/month).
- Cal.com event type (Home Service Appointment).
- n8n instance on Oracle Cloud VPS (Always Free tier).
- Supabase project with calls, bookings, and orders tables.
- Vercel deployment (client dashboard).
- Local project folder with Python scripts.
Keep everything running
No action needed. Choose this if you are actively using the voice agents for a real business or continuing to test.
- Retell answers calls 24/7 on its infrastructure. Your Oracle VPS runs n8n processing webhooks around the clock. The Vercel dashboard reads from Supabase in real-time. Cal.com handles bookings. Nothing runs on your local machine.
- Total recurring cost: $2/month for the phone number plus Retell per-minute usage whenever calls come in.
- Oracle Cloud VPS, Supabase, and Vercel remain on their free tiers indefinitely.
Pause - I'll come back to this later
Stop incoming calls to prevent per-minute charges while keeping all your configuration intact.
- In the Retell dashboard, go to the Phone Numbers tab.
- Unassign your agent from the phone number. This stops incoming calls without deleting the number or agents.
- Your phone number still costs $2/month while it exists, but no per-minute charges accrue since no calls are answered.
- Your n8n instance on the VPS, Supabase tables, and Vercel dashboard remain intact and ready to resume.
- To resume later, reassign the agent to the phone number in the Phone Numbers tab.
Delete - I don't want to use this again
Remove all project resources completely. Follow each step below to ensure nothing continues running or charging.
Retell AI resources
- In the Retell dashboard, go to the Phone Numbers tab. Click on your purchased number, then delete it. This stops the $2/month charge immediately.
- Go to the Agents tab. Delete both the Alex (home services) agent and the Sal's Pizza agent.
- Go to the Knowledge Base section and delete any knowledge bases you created.
n8n on Oracle Cloud VPS
- SSH into your Oracle Cloud VPS and navigate to the n8n-compose directory.
- Stop and remove the containers by running:
cd n8n-compose
sudo docker compose down
- This stops both the n8n and Traefik containers and removes them. Your Oracle Cloud VPS itself remains on the Always Free tier with no charges.
- To also remove the project files from the VPS, run:
cd ~
rm -rf n8n-compose
Cal.com event type
- In Cal.com, go to Event Types. Find the "Home Service Appointment" event type and delete it.
Supabase project
- In the Supabase dashboard, open your project.
- Go to Project Settings and click Delete project. This permanently removes all tables and data.
Vercel deployment
- In the Vercel dashboard, open your project.
- Go to Settings and scroll to the bottom. Click Delete to remove the deployment.
Local project files
- Delete your local project folder (containing test_retell.py, outbound_call.py, agent_report.py, provision_agent.py, sals_pizza_config.json, and .env) by running:
cd ~/Desktop
rm -rf retell-voice-agents
- Uninstall the Retell SDK from your Python environment by running:
pip uninstall retell-sdk
- Confirm the removal by typing y when prompted.
That's a Wrap!
That's a Wrap!
Nice work! You just built a complete, deployable AI phone agent service with a live client dashboard and self-hosted automation infrastructure.
You've learned how to:
- Build a production-quality Retell AI voice agent with a crafted prompt, knowledge base, and conversation flow that answers calls autonomously on a live phone number and books real appointments into Cal.com.
- Self-host n8n on an Oracle Cloud VPS with Docker Compose and Traefik for zero-cost webhook automation that processes post-call data and writes structured records to Supabase.
- Deploy a real-time client dashboard on Vercel showing today's appointments, recent calls with sentiment badges, booking stats, and use the Python SDK to programmatically trigger outbound calls and generate cost reports for agency pricing.
- Secret Mission: Provisioned a pizza place order-taking agent from a JSON config file, with live orders appearing on the same dashboard alongside home services appointments.
Ready to quiz yourself?