Build an AI Voice Receptionist: Full Stack Node.js + React Tutorial (2026)
Learn how to build AskOra AI, an AI voice receptionist using Retell AI, Node.js, and RAG. Automate inbound calls, answer FAQs, and log leads instantly!
Build an AI Voice Receptionist with Retell AI and RAG
A dental clinic's phone rings during lunch. Nobody picks up. The caller hangs up, calls the clinic down the street, and books there instead. Multiply that by every lunch break, every evening, every Saturday — and a busy practice quietly bleeds patients it never even knew it lost.
That's the problem AskOra AI solves: an AI receptionist named Clara who answers every call instantly, talks to the caller in natural voice, looks up clinic-specific answers from a knowledge base, and logs the lead to a live dashboard before the human staff even notice the phone rang. By the end of this tutorial, you'll have built and deployed the whole thing yourself — a Node.js + Firestore backend, a RAG knowledge pipeline, a React dashboard, and a real AI voice agent powered by Retell AI.
You'll need Node.js 16+, a free Firebase project (for Firestore), a Retell AI account, and an OpenRouter API key. All free-tier friendly.
Table of Contents
- [Architecture Overview](#architecture-overview)
- [Step 1: Scaffold the Project and Set Up Accounts](#step-1-scaffold-the-project-and-set-up-accounts)
- [Step 2: Build the Express + Firestore Backend](#step-2-build-the-express–firestore-backend)
- [Step 3: Build the RAG Knowledge Pipeline](#step-3-build-the-rag-knowledge-pipeline)
- [Step 4: Build the React Dashboard](#step-4-build-the-react-dashboard)
- [Step 5: Add Live AI Voice Calls with Retell AI](#step-5-add-live-ai-voice-calls-with-retell-ai)
- [Step 6: Deploy to Render](#step-6-deploy-to-render)
- [Wrapping Up](#wrapping-up)
Architecture Overview
Before writing code, it helps to see how the pieces talk to each other. A caller's voice goes through Retell AI (or a real phone number through VAPI), hits your Express backend over a webhook when the call ends, and the backend extracts the lead, saves everything to Firestore, and the React dashboard reflects it live.
flowchart LR
A[Caller - Browser Demo or Phone] -->|Voice| B[Retell AI / VAPI]
B -->|Live RAG lookup mid-call| C[Express Backend]
B -->|Webhook: call ended| C
C -->|Save call + lead| D[(Google Firestore)]
C -->|Keyword/vector search| E[Knowledge Base Chunks]
F[React Dashboard] -->|REST API calls| C
C -->|JSON response| F
Four pieces, four responsibilities:
- Retell AI / VAPI — handles speech-to-text, text-to-speech, and the actual conversation with the caller.
- Express backend — the brain. Receives webhooks, extracts lead info with GPT-4o-mini, runs the RAG search, talks to Firestore.
- Firestore — stores calls, leads, settings, and the knowledge base chunks.
- React dashboard — the human-facing window into all of it.
Step 1: Scaffold the Project and Set Up Accounts
What we are doing: Getting the project scaffolding in place and gathering the API keys you'll need before writing any backend logic.
This tutorial builds the AskOra AI project structure from the ground up:
ai_voice_assistant_mvp/
├── backend/ # Express.js API + Firestore
│ ├── server.js
│ ├── mockDb.js
│ ├── package.json
│ ├── .env
│ └── Dockerfile
├── dashboard/ # React 19 + Vite frontend
│ ├── src/
│ │ ├── App.jsx
│ │ ├── mockDatabase.js
│ │ └── main.jsx
│ └── package.json
└── mock_data/
└── sample_faqs.txt
Create the two folders and initialize each as a Node project:
mkdir ai_voice_assistant_mvp && cd ai_voice_assistant_mvp
mkdir backend dashboard mock_data
cd backend && npm init -y
You'll need four accounts before Step 2:
- Firebase — create a project at console.firebase.google.com, enable Firestore, then go to Project Settings → Service Accounts → Generate New Private Key. This downloads a JSON file you'll paste into
.env. - OpenRouter — sign up at openrouter.ai → API Keys → Create Key. This powers lead extraction and RAG embeddings via GPT-4o-mini.
- Retell AI — sign up at retellai.com. This powers the live, browser-based voice demo.
- VAPI (optional, for real phone numbers) — sign up at vapi.ai if you want an actual phone number later.
What just happened: You now have the empty project skeleton and the credentials you'll wire in over the next few steps. Nothing runs yet — that starts in Step 2.
Step 2: Build the Express + Firestore Backend
What we are doing: Standing up the Express server, connecting it to Firestore, and building the core dashboard endpoints (stats, calls, leads, settings) with a graceful fallback to mock data if Firestore isn't reachable.
Install the backend dependencies:
cd backend
npm install express cors dotenv firebase-admin jsonwebtoken multer pdf-parse cheerio axios openai
npm install --save-dev nodemon
backend/package.json:
{
"name": "askora-ai-backend",
"version": "1.0.0",
"description": "Production Backend for AskOra AI Voice Assistant MVP",
"main": "server.js",
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js"
},
"dependencies": {
"express": "^4.19.2",
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"firebase-admin": "^12.1.0",
"jsonwebtoken": "^9.0.2",
"multer": "^1.4.5-lts.1",
"pdf-parse": "^1.1.1",
"cheerio": "^1.0.0-rc.12",
"axios": "^1.7.2",
"openai": "^4.51.0"
},
"devDependencies": {
"nodemon": "^3.1.2"
}
}
Create backend/.env (paste your real Firebase service account JSON as a single-line string — never commit this file):
PORT=5000
FIREBASE_SERVICE_ACCOUNT='your_value_here'
OPENAI_API_KEY=your_value_here
OPENROUTER_MODEL=openai/gpt-4o-mini
VAPI_PHONE_NUMBER_ID=your_value_here
RETELL_API_KEY=your_value_here
RETELL_AGENT_ID=your_value_here
VAPI_WEBHOOK_SECRET=your_value_here
Even though the variable is named
OPENAI_API_KEY, it points at OpenRouter's gateway — OpenRouter is OpenAI-SDK compatible, so you get GPT-4o-mini at a fraction of the cost without changing any SDK code.
Now build a mock database for offline fallback — backend/mockDb.js:
// Backend in-memory database for demo/fallback purposes
let stats = {
totalCalls: 142,
totalLeads: 41,
conversionRate: 28.8,
missedCalls: 5,
trendData: [
{ date: "Mon", calls: 18, leads: 4 },
{ date: "Tue", calls: 24, leads: 7 },
{ date: "Wed", calls: 32, leads: 9 },
{ date: "Thu", calls: 28, leads: 8 },
{ date: "Fri", calls: 22, leads: 6 },
{ date: "Sat", calls: 10, leads: 4 },
{ date: "Sun", calls: 8, leads: 3 }
]
};
let callLogs = [
{
id: "call-1",
callerName: "Sarah Jenkins",
callerPhone: "+1 (555) 234-5678",
duration: "2m 15s",
status: "Booking Made",
timestamp: "10 minutes ago",
summary: "Caller wanted to book an appointment for dental cleaning. AI confirmed booking for Friday at 3:00 PM and sent confirmation SMS via Twilio.",
transcript: [
{ speaker: "Assistant", text: "Hello! Welcome to Radiant Dental Clinic. I am Ora, your AI receptionist. How can I help you today?" },
{ speaker: "Caller", text: "Hi, I'd like to book an appointment for dental cleaning, please." },
{ speaker: "Assistant", text: "I can help with that! Do you have a preferred day or time this week?" },
{ speaker: "Caller", text: "Is Friday afternoon available? Maybe around three?" },
{ speaker: "Assistant", text: "Yes! We have Friday at 3:00 PM open. May I get your name and phone number to secure the slot?" },
{ speaker: "Caller", text: "Sure, my name is Sarah Jenkins and my number is +1 (555) 234-5678." },
{ speaker: "Assistant", text: "Got it, Sarah. I have successfully scheduled you for a dental cleaning on Friday, May 22nd at 3:00 PM. A confirmation text message has been sent to your phone. We look forward to seeing you!" }
]
}
];
let leads = [
{
id: "lead-1",
name: "Sarah Jenkins",
phone: "+1 (555) 234-5678",
email: "sarah.j@example.com",
service: "Dental Cleaning",
status: "Converted",
lastContact: "10 mins ago",
notes: "Appointment booked for Friday 3:00 PM. Booking confirmation SMS dispatched."
}
];
let settings = {
businessName: "Radiant Dental Clinic",
businessHours: "Monday - Friday: 8:00 AM - 5:00 PM, Saturday: 9:00 AM - 2:00 PM",
bookingLink: "https://calendly.com/radiant-dental/consult",
systemPrompt: "You are Ora, a warm and professional virtual receptionist for Radiant Dental Clinic. Your goal is to answer patient questions about services, hours, pricing, and insurance, and guide them to schedule appointments using the booking link. Be concise and conversational. If a caller wants to book, collect their name and phone number and send them the booking link.",
openRouterModel: "openai/gpt-4o-mini",
botVoice: "en-US-Neural-F",
vapiPhoneNumber: "+1 (234) 567-8900"
};
let documents = [
{ id: "doc-1", name: "radiant_dental_faqs.pdf", type: "PDF Document", size: "142 KB", status: "Synced", dateAdded: "Yesterday", chunksCount: 24 },
{ id: "doc-2", name: "invisalign_info_sheet.pdf", type: "PDF Document", size: "95 KB", status: "Synced", dateAdded: "Yesterday", chunksCount: 16 }
];
module.exports = { stats, callLogs, leads, settings, documents };
Now the core of backend/server.js — Firestore initialization, middleware, and the dashboard CRUD endpoints:
const express = require('express');
const cors = require('cors');
const dotenv = require('dotenv');
const multer = require('multer');
const pdfParse = require('pdf-parse');
const cheerio = require('cheerio');
const axios = require('axios');
const path = require('path');
const fs = require('fs');
const { OpenAI } = require('openai');
const admin = require('firebase-admin');
dotenv.config();
const app = express();
const PORT = process.env.PORT || 5000;
app.use(cors());
app.use(express.json());
// Initialize Firestore — supports a raw JSON string in .env (local)
// or a path to a secret file (e.g. on Render)
let db = null;
let serviceAccount = null;
if (process.env.FIREBASE_SERVICE_ACCOUNT_FILE) {
try {
const fileContent = fs.readFileSync(process.env.FIREBASE_SERVICE_ACCOUNT_FILE, 'utf8');
serviceAccount = JSON.parse(fileContent);
} catch (err) {
console.error("Failed to read Firebase service account file:", err.message);
}
} else if (process.env.FIREBASE_SERVICE_ACCOUNT) {
try {
serviceAccount = JSON.parse(process.env.FIREBASE_SERVICE_ACCOUNT);
} catch (err) {
console.error("Failed to parse FIREBASE_SERVICE_ACCOUNT env variable:", err.message);
}
}
if (serviceAccount) {
admin.initializeApp({ credential: admin.credential.cert(serviceAccount) });
db = admin.firestore();
console.log("Firebase Admin SDK initialized successfully. Firestore is active.");
} else {
console.log("No Firebase credentials found. Running in mock/demo fallback mode.");
}
const mockDb = require('./mockDb');
// ── AUTHENTICATION ──
app.post('/api/auth/login', (req, res) => {
const { email, password, role } = req.body;
if (!email || !password) {
return res.status(400).json({ message: "Email and password are required" });
}
const users = {
'admin@askoraai.com': { password: 'admin123', role: 'admin', name: 'Admin Owner' },
'client@askoraai.com': { password: 'client123', role: 'client', name: 'Client Partner' },
'staff@askoraai.com': { password: 'staff123', role: 'staff', name: 'Receptionist Staff' }
};
const matchedUser = users[email.toLowerCase()];
if (matchedUser && matchedUser.password === password) {
return res.json({ user: { email: email.toLowerCase(), role: matchedUser.role, name: matchedUser.name } });
}
// Demo mode: accept any credentials so beginners can log straight in
res.json({
user: {
email: email.toLowerCase(),
role: role || 'admin',
name: (role || 'admin').charAt(0).toUpperCase() + (role || 'admin').slice(1) + ' User'
}
});
});
// ── DASHBOARD DATA ──
app.get('/api/stats', async (req, res) => {
if (db) {
const statsDoc = await db.collection('stats').doc('main').get();
if (statsDoc.exists) return res.json(statsDoc.data());
await db.collection('stats').doc('main').set(mockDb.stats);
return res.json(mockDb.stats);
}
res.json(mockDb.stats);
});
app.get('/api/calls', async (req, res) => {
if (db) {
const snapshot = await db.collection('calls').orderBy('timestamp', 'desc').limit(50).get();
const calls = [];
snapshot.forEach(doc => calls.push({ id: doc.id, ...doc.data() }));
return res.json(calls);
}
res.json(mockDb.callLogs);
});
app.get('/api/leads', async (req, res) => {
if (db) {
const snapshot = await db.collection('leads').orderBy('createdAt', 'desc').get();
const leads = [];
snapshot.forEach(doc => leads.push({ id: doc.id, ...doc.data() }));
return res.json(leads);
}
res.json(mockDb.leads);
});
app.post('/api/leads', async (req, res) => {
const { name, phone, email, service, status, notes } = req.body;
if (!name || !phone) {
return res.status(400).json({ message: "Name and phone are required" });
}
const newLead = {
name, phone,
email: email || null,
service: service || "General inquiry",
status: status || "Warm",
lastContact: "Just now",
notes: notes || "",
createdAt: new Date().toISOString()
};
if (db) {
const leadRef = await db.collection('leads').add(newLead);
newLead.id = leadRef.id;
const statsRef = db.collection('stats').doc('main');
await db.runTransaction(async (transaction) => {
const statsDoc = await transaction.get(statsRef);
let currentStats = statsDoc.exists ? statsDoc.data() : { ...mockDb.stats };
currentStats.totalLeads = (currentStats.totalLeads || 0) + 1;
if (currentStats.totalCalls > 0) {
currentStats.conversionRate = parseFloat(((currentStats.totalLeads / currentStats.totalCalls) * 100).toFixed(1));
}
transaction.set(statsRef, currentStats);
});
return res.json({ message: "Lead saved successfully", lead: newLead });
}
newLead.id = `lead-${Date.now()}`;
mockDb.leads.unshift(newLead);
mockDb.stats.totalLeads = mockDb.leads.length;
res.json({ message: "Lead saved successfully", lead: newLead });
});
app.get('/api/settings', async (req, res) => {
if (db) {
const settingsDoc = await db.collection('settings').doc('main').get();
if (settingsDoc.exists) return res.json(settingsDoc.data());
await db.collection('settings').doc('main').set(mockDb.settings);
return res.json(mockDb.settings);
}
res.json(mockDb.settings);
});
app.post('/api/settings', async ({ body }, res) => {
if (db) {
await db.collection('settings').doc('main').set(body, { merge: true });
return res.json({ message: "Settings saved successfully", settings: body });
}
mockDb.settings = { ...mockDb.settings, ...body };
res.json({ message: "Settings saved successfully", settings: mockDb.settings });
});
app.listen(PORT, () => {
console.log(`AskOra AI MVP Backend running on http://localhost:${PORT}`);
});
What just happened: Every dashboard read goes through Firestore first, and falls back to mockDb if Firestore is unreachable — so the app never shows a blank screen, even offline. The runTransaction call on lead creation prevents race conditions when two leads come in at the same moment and both try to bump totalLeads.
Security note: The login endpoint above accepts any email/password if it doesn't match the three hardcoded demo accounts — that's intentional for this tutorial so you can log in immediately, but it is not real authentication. Before putting this anywhere near production, replace it with real password hashing (bcrypt) and JWT-based sessions (the
jsonwebtokenpackage is already installed for exactly this).
Test it now:
npm start
curl http://localhost:5000/api/stats
Expected output:
{"totalCalls":142,"totalLeads":41,"conversionRate":28.8,"missedCalls":5,"trendData":[...]}
Step 3: Build the RAG Knowledge Pipeline
What we are doing: Adding the ability to upload PDFs or scrape a website, break the text into chunks, optionally embed those chunks with OpenAI, and search them later when Clara needs to answer a clinic-specific question.
Add the chunking and scoring utilities, plus the upload/scrape/query routes, to server.js:
// Initialize OpenAI client if API key is present
let openai = null;
if (process.env.OPENAI_API_KEY) {
openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
console.log("OpenAI API client initialized successfully for RAG.");
} else {
console.log("No OPENAI_API_KEY found. RAG will fall back to local keyword-matching search.");
}
let ragVectorStore = [];
const uploadDir = path.join(__dirname, 'uploads');
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir);
const upload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, uploadDir),
filename: (req, file, cb) => cb(null, Date.now() + '-' + file.originalname)
}),
fileFilter: (req, file, cb) => {
if (file.mimetype === 'application/pdf') cb(null, true);
else cb(new Error('Only PDF files are allowed!'), false);
}
});
// Splits raw text into ~800-character chunks with 150-char overlap
const chunkText = (text, maxLength = 800, overlap = 150) => {
const sentences = text.replace(/\s+/g, ' ').trim().split(/(?<=[.?!])\s+/);
const chunks = [];
let currentChunk = '';
for (const sentence of sentences) {
if ((currentChunk + sentence).length > maxLength) {
if (currentChunk.trim()) chunks.push(currentChunk.trim());
const words = currentChunk.split(' ');
currentChunk = words.slice(-Math.floor(overlap / 6)).join(' ') + ' ' + sentence + ' ';
} else {
currentChunk += sentence + ' ';
}
}
if (currentChunk.trim()) chunks.push(currentChunk.trim());
return chunks;
};
// Fallback scorer when no OpenAI embeddings are available
const computeKeywordScore = (query, text) => {
const queryWords = query.toLowerCase().split(/\W+/).filter(w => w.length > 2);
const textLower = text.toLowerCase();
let matches = 0;
for (const word of queryWords) if (textLower.includes(word)) matches++;
return matches / (queryWords.length || 1);
};
// PDF upload → parse → chunk → embed → store
app.post('/api/documents/upload', upload.single('file'), async (req, res) => {
try {
if (!req.file) return res.status(400).json({ message: "No PDF file uploaded" });
const dataBuffer = fs.readFileSync(req.file.path);
const parsedPdf = await pdfParse(dataBuffer);
const chunks = chunkText(parsedPdf.text);
const docId = `doc-${Date.now()}`;
const docName = req.file.originalname;
const newDoc = {
id: docId, name: docName, type: "PDF Document",
size: `${Math.round(req.file.size / 1024)} KB`, status: "Synced",
dateAdded: "Today", createdAt: new Date().toISOString(), chunksCount: chunks.length
};
if (db) await db.collection('documents').doc(docId).set(newDoc);
else mockDb.documents.unshift(newDoc);
for (let i = 0; i < chunks.length; i++) {
let embedding = null;
if (openai) {
try {
const embRes = await openai.embeddings.create({ model: "text-embedding-ada-002", input: chunks[i] });
embedding = embRes.data[0].embedding;
} catch (embErr) {
console.error("OpenAI Embedding generation failed:", embErr.message);
}
}
const chunkData = { docId, docName, text: chunks[i], embedding, createdAt: new Date().toISOString() };
if (db) {
const chunkRef = await db.collection('knowledge_base').add(chunkData);
ragVectorStore.push({ id: chunkRef.id, ...chunkData });
} else {
ragVectorStore.push({ id: `${docId}-chunk-${i}`, ...chunkData });
}
}
fs.unlinkSync(req.file.path);
res.json({ message: "PDF parsed, embedded, and added to knowledge base successfully.", document: newDoc, chunksCount: chunks.length });
} catch (err) {
res.status(500).json({ message: "Failed to upload and parse PDF", error: err.message });
}
});
// Website scrape → chunk → embed → store
app.post('/api/documents/scrape', async ({ body: { url } }, res) => {
try {
if (!url) return res.status(400).json({ message: "URL is required" });
const response = await axios.get(url, {
headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' },
timeout: 10000
});
const $ = cheerio.load(response.data);
$('script, style, nav, footer, header, noscript').remove();
const cleanText = $('body').text().replace(/\s+/g, ' ').trim();
if (cleanText.length < 50) {
return res.status(400).json({ message: "Insufficient text content found at URL to scrape" });
}
const chunks = chunkText(cleanText);
const docId = `doc-${Date.now()}`;
const newDoc = {
id: docId, name: url, type: "Website URL",
size: `${chunks.length} Chunks Scraped`, status: "Synced",
dateAdded: "Today", createdAt: new Date().toISOString(), chunksCount: chunks.length
};
if (db) await db.collection('documents').doc(docId).set(newDoc);
else mockDb.documents.unshift(newDoc);
for (let i = 0; i < chunks.length; i++) {
const chunkData = { docId, docName: url, text: chunks[i], embedding: null, createdAt: new Date().toISOString() };
if (db) {
const chunkRef = await db.collection('knowledge_base').add(chunkData);
ragVectorStore.push({ id: chunkRef.id, ...chunkData });
} else {
ragVectorStore.push({ id: `${docId}-chunk-${i}`, ...chunkData });
}
}
res.json({ message: "Website URL scraped and synced successfully", document: newDoc, chunksCount: chunks.length });
} catch (err) {
res.status(500).json({ message: "Failed to scrape Website URL", error: err.message });
}
});
// RAG search — vector similarity if embeddings exist, keyword overlap otherwise
app.post('/api/rag/query', async ({ body: { query } }, res) => {
try {
if (!query) return res.status(400).json({ message: "Query string is required" });
let results = [];
if (openai && ragVectorStore.some(c => c.embedding !== null)) {
const queryEmbRes = await openai.embeddings.create({ model: "text-embedding-ada-002", input: query });
const queryEmbedding = queryEmbRes.data[0].embedding;
results = ragVectorStore
.filter(c => c.embedding !== null)
.map(chunk => {
let dotProduct = 0, normA = 0, normB = 0;
for (let i = 0; i < queryEmbedding.length; i++) {
dotProduct += queryEmbedding[i] * chunk.embedding[i];
normA += queryEmbedding[i] * queryEmbedding[i];
normB += chunk.embedding[i] * chunk.embedding[i];
}
return { text: chunk.text, source: chunk.docName, score: dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)) };
})
.sort((a, b) => b.score - a.score)
.slice(0, 3);
}
if (results.length === 0) {
results = ragVectorStore
.map(chunk => ({ text: chunk.text, source: chunk.docName, score: computeKeywordScore(query, chunk.text) }))
.filter(c => c.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, 3);
}
res.json({ results });
} catch (err) {
res.status(500).json({ message: "RAG query processing failed", error: err.message });
}
});
Seed your knowledge base with a sample FAQ file — mock_data/sample_faqs.txt:
DENTAL CLINIC AI RECEPTIONIST FAQ DATA
1. What are the clinic's operating hours?
We are open Monday through Friday from 8:00 AM to 6:00 PM, and Saturday from 9:00 AM to 2:00 PM.
2. What dental insurance plans do you accept?
We accept major PPO insurance plans including Delta Dental, Cigna, Aetna, MetLife, and Guardian.
3. Do you treat dental emergencies?
Yes, we offer same-day emergency appointments for issues like severe toothaches or broken teeth.
What just happened: The chunker splits text on sentence boundaries (not mid-word) and keeps a small overlap between chunks so context isn't lost at the edges. If you set OPENAI_API_KEY, every chunk gets a real vector embedding and search uses cosine similarity; without it, the app falls back to simple keyword overlap — degraded, but never broken.
Heads up:
text-embedding-ada-002is an OpenAI-specific endpoint — OpenRouter (which Step 1 has you sign up for) generally does not proxy embeddings calls, only chat completions. If you pasted an OpenRouter key, thetry/catcharound the embedding call will silently fail and every search will quietly fall back to keyword matching. To get real vector search, use an actual OpenAI API key forOPENAI_API_KEY(you can still use OpenRouter separately for the GPT-4o-mini chat calls in Step 5).
Test it now:
curl -X POST http://localhost:5000/api/rag/query \
-H "Content-Type: application/json" \
-d '{"query": "do you accept Delta Dental"}'
Expected output:
{"results":[{"text":"We accept major PPO insurance plans including Delta Dental...","source":"sample_faqs.txt","score":0.4}]}
Step 4: Build the React Dashboard
What we are doing: Standing up the Vite + React frontend with a login screen and five tabs (Dashboard, Call Logs, Leads, Knowledge Base, Settings) that pull live data from the backend you just built, with mock-data fallback if the API is offline.
cd ../dashboard
npm create vite@latest . -- --template react
npm install
npm install retell-client-js-sdk
dashboard/package.json:
{
"name": "dashboard",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.6",
"react-dom": "^19.2.6",
"retell-client-js-sdk": "^2.0.7"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"vite": "^8.0.12"
}
}
dashboard/vite.config.js:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})
Before App.jsx, create the mock data it falls back to when the backend is offline — dashboard/src/mockDatabase.js:
// Mock Database for AI Voice Assistant SaaS Dashboard
export const mockStats = {
totalCalls: 1248,
totalLeads: 312,
conversionRate: 25,
missedCalls: 42,
trendData: [
{ date: 'May 14', calls: 42, leads: 9 },
{ date: 'May 15', calls: 58, leads: 15 },
{ date: 'May 16', calls: 35, leads: 8 },
{ date: 'May 17', calls: 62, leads: 18 },
{ date: 'May 18', calls: 78, leads: 22 },
{ date: 'May 19', calls: 85, leads: 28 },
{ date: 'May 20', calls: 94, leads: 31 },
]
};
export const mockCallLogs = [
{
id: "call-001",
callerName: "Robert Miller",
callerPhone: "+1 (555) 234-5678",
timestamp: "May 20, 2026, 4:12 PM",
duration: "2m 14s",
status: "Booking Made",
summary: "Caller requested an emergency appointment for a severe toothache. The AI assistant successfully verified insurance (Delta Dental) and sent the scheduling link via SMS.",
transcript: [
{ speaker: "Assistant", text: "Hello! Thank you for calling Radiant Dental. This is Clara, your virtual assistant. How can I help you today?" },
{ speaker: "Caller", text: "Hi Clara, I have a really bad toothache on my lower left side. Do you have any emergency openings today or tomorrow?" },
{ speaker: "Assistant", text: "I'm so sorry to hear you're in pain, Robert. We have an opening tomorrow morning at 9:00 AM. Would that work?" },
{ speaker: "Caller", text: "Tomorrow at 9:00 AM works perfectly. Do you accept Delta Dental insurance?" },
{ speaker: "Assistant", text: "Yes, we accept Delta Dental PPO. I've reserved the 9:00 AM slot and texted you the confirmation." }
]
},
{
id: "call-002",
callerName: "Sarah Connor",
callerPhone: "+1 (555) 876-5432",
timestamp: "May 20, 2026, 2:45 PM",
duration: "1m 32s",
status: "Lead Generated",
summary: "Caller inquired about pricing for dental implants and cosmetic veneers. The AI assistant provided estimated pricing and sent our services brochure to her phone.",
transcript: [
{ speaker: "Assistant", text: "Hello! Thank you for calling Radiant Dental. This is Clara, your virtual assistant. How can I help you today?" },
{ speaker: "Caller", text: "Hi, I wanted to know how much dental implants usually cost at your clinic." },
{ speaker: "Assistant", text: "Dental implants typically range from $3,000 to $4,500 per tooth. Would you like our brochure?" },
{ speaker: "Caller", text: "Yes, that would be great." }
]
},
{
id: "call-003",
callerName: "Unknown Caller",
callerPhone: "+1 (555) 432-1098",
timestamp: "May 20, 2026, 11:15 AM",
duration: "0m 45s",
status: "Missed Call",
summary: "Caller asked for Dr. Howard directly and hung up before booking.",
transcript: [
{ speaker: "Assistant", text: "Hello! Thank you for calling Radiant Dental. This is Clara, your virtual assistant. How can I help you today?" },
{ speaker: "Caller", text: "Yeah, is Dr. Howard there?" },
{ speaker: "Caller", text: "Nah, I'll just try back later. Thanks." }
]
}
];
export const mockLeads = [
{
id: "lead-01",
name: "Robert Miller",
phone: "+1 (555) 234-5678",
email: "robert.miller@example.com",
service: "Emergency Dentistry",
status: "Hot",
lastContact: "May 20, 2026, 4:12 PM",
notes: "Severe throbbing toothache. Appt booked for May 21st, 9:00 AM."
},
{
id: "lead-02",
name: "Sarah Connor",
phone: "+1 (555) 876-5432",
email: "s.connor@example.com",
service: "Dental Implants / Veneers",
status: "Warm",
lastContact: "May 20, 2026, 2:45 PM",
notes: "Requested brochure and CareCredit financing link."
}
];
export const mockSettings = {
businessName: "Radiant Dental Clinic",
businessHours: "Mon-Fri: 8:00 AM - 6:00 PM, Sat: 9:00 AM - 2:00 PM",
bookingLink: "https://calendly.com/radiant-dental/appointment",
botVoice: "Clara (US English - Warm)",
systemPrompt: `You are Clara, a friendly and highly professional virtual receptionist for Radiant Dental Clinic. Your primary objectives are to answer FAQs accurately, capture leads (name, phone, email), and direct callers to the booking link if they want to schedule an appointment.`,
openRouterModel: "openai/gpt-4o-mini",
vapiPhoneNumber: "+1 (888) 555-9087"
};
export const mockDocuments = [
{ id: "doc-1", name: "radiant_dental_faqs.pdf", type: "PDF Document", size: "142 KB", status: "Synced", dateAdded: "May 15, 2026", chunksCount: 24 },
{ id: "doc-2", name: "implants_pricing_sheet.pdf", type: "PDF Document", size: "88 KB", status: "Synced", dateAdded: "May 18, 2026", chunksCount: 12 },
{ id: "doc-3", name: "https://radiantdentalhouston.com", type: "Website URL", size: "8 Pages Scraped", status: "Synced", dateAdded: "May 19, 2026", chunksCount: 45 }
];
Now the App shell, login screen, data loading, and tab navigation — dashboard/src/App.jsx:
import React, { useState, useEffect, useRef } from 'react';
import { RetellWebClient } from 'retell-client-js-sdk';
import {
mockStats,
mockCallLogs,
mockLeads,
mockSettings,
mockDocuments
} from './mockDatabase';
const API_BASE_URL = 'http://localhost:5000/api';
export default function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [user, setUser] = useState(null);
const [loginEmail, setLoginEmail] = useState('');
const [loginPassword, setLoginPassword] = useState('');
const [loginRole, setLoginRole] = useState('admin');
const [loginError, setLoginError] = useState('');
const [isAuthLoading, setIsAuthLoading] = useState(false);
const [activeTab, setActiveTab] = useState('dashboard');
const [stats, setStats] = useState(mockStats);
const [callLogs, setCallLogs] = useState(mockCallLogs);
const [selectedCall, setSelectedCall] = useState(mockCallLogs[0]);
const [settings, setSettings] = useState(mockSettings);
const [documents, setDocuments] = useState(mockDocuments);
const [leads, setLeads] = useState(mockLeads);
// Pulls every dashboard collection in parallel; falls back to mock data on failure
const loadDashboardData = async () => {
const fetchJson = async (path) => {
const res = await fetch(`${API_BASE_URL}${path}`);
if (!res.ok) throw new Error(`${path} returned ${res.status}`);
return res.json();
};
try {
const [statsData, callsData, leadsData, settingsData, documentsData] = await Promise.all([
fetchJson('/stats'),
fetchJson('/calls'),
fetchJson('/leads'),
fetchJson('/settings'),
fetchJson('/documents'),
]);
if (statsData) setStats(statsData);
if (Array.isArray(callsData)) {
setCallLogs(callsData);
if (callsData.length > 0) setSelectedCall(callsData[0]);
}
if (Array.isArray(leadsData)) setLeads(leadsData);
if (settingsData) setSettings(settingsData);
if (Array.isArray(documentsData)) setDocuments(documentsData);
} catch (err) {
console.log('Backend API unavailable — showing mock fallback data.', err);
}
};
useEffect(() => {
if (isAuthenticated) loadDashboardData();
}, [isAuthenticated]);
const handleLogin = async (e) => {
e.preventDefault();
setLoginError('');
setIsAuthLoading(true);
try {
const response = await fetch(`${API_BASE_URL}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: loginEmail, password: loginPassword, role: loginRole })
});
if (response.ok) {
const data = await response.json();
setUser(data.user);
setIsAuthenticated(true);
setActiveTab(loginRole === 'staff' ? 'calls' : 'dashboard');
} else {
const errData = await response.json();
setLoginError(errData.message || 'Invalid credentials');
}
} catch (err) {
// Backend offline — demo mode still lets you in
if (loginEmail && loginPassword) {
setUser({ email: loginEmail, role: loginRole, name: 'Admin Owner' });
setIsAuthenticated(true);
setActiveTab(loginRole === 'staff' ? 'calls' : 'dashboard');
} else {
setLoginError('Please enter any email and password.');
}
} finally {
setIsAuthLoading(false);
}
};
const handleLogout = () => {
setIsAuthenticated(false);
setUser(null);
setLoginEmail('');
setLoginPassword('');
};
// ── Knowledge Base (RAG) handlers ──
const [newUrl, setNewUrl] = useState('');
const handleAddUrl = async (e) => {
e.preventDefault();
if (!newUrl) return;
try {
const res = await fetch(`${API_BASE_URL}/documents/scrape`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ url: newUrl })
});
if (res.ok) await loadDashboardData();
} catch (err) {
console.log("Scrape request failed:", err);
}
setNewUrl('');
};
// Sends the selected PDF to the /api/documents/upload route built in Step 3
const handleFileUpload = async (e) => {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch(`${API_BASE_URL}/documents/upload`, { method: 'POST', body: formData });
if (res.ok) await loadDashboardData();
else alert('Upload failed — check the file is a PDF.');
} catch (err) {
console.log("Upload request failed:", err);
}
};
if (!isAuthenticated) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', minHeight: '100vh' }}>
<div className="panel" style={{ width: '100%', maxWidth: '420px', padding: '2.5rem' }}>
<h2>AskOra AI</h2>
<p>AI Voice Assistant SaaS Platform Login</p>
{loginError && <div style={{ color: 'red' }}>{loginError}</div>}
<form onSubmit={handleLogin}>
<input type="email" placeholder="admin@askoraai.com" value={loginEmail}
onChange={(e) => setLoginEmail(e.target.value)} required />
<input type="password" placeholder="********" value={loginPassword}
onChange={(e) => setLoginPassword(e.target.value)} required />
<select value={loginRole} onChange={(e) => setLoginRole(e.target.value)}>
<option value="admin">Admin Owner (Full Access)</option>
<option value="client">Client Business</option>
<option value="staff">Front-desk Staff</option>
</select>
<button type="submit" disabled={isAuthLoading}>
{isAuthLoading ? 'Authenticating...' : 'Sign In'}
</button>
</form>
</div>
</div>
);
}
return (
<div className="app-container">
<nav className="sidebar">
<div className="logo-container"><span>AskOra AI</span></div>
<ul className="nav-links">
{user?.role !== 'staff' && (
<li><div className={activeTab === 'dashboard' ? 'active' : ''} onClick={() => setActiveTab('dashboard')}>Dashboard</div></li>
)}
<li><div className={activeTab === 'calls' ? 'active' : ''} onClick={() => setActiveTab('calls')}>Call Logs</div></li>
<li><div className={activeTab === 'leads' ? 'active' : ''} onClick={() => setActiveTab('leads')}>Leads</div></li>
{user?.role === 'admin' && (
<li><div className={activeTab === 'kb' ? 'active' : ''} onClick={() => setActiveTab('kb')}>Knowledge Base</div></li>
)}
{user?.role !== 'staff' && (
<li><div className={activeTab === 'settings' ? 'active' : ''} onClick={() => setActiveTab('settings')}>Settings</div></li>
)}
</ul>
<button onClick={handleLogout}>Sign Out</button>
</nav>
<main className="main-content">
{activeTab === 'dashboard' && user?.role !== 'staff' && (
<section className="stats-grid">
<div className="stat-card">
<span>Total Calls Handled</span>
<div className="stat-value">{stats.totalCalls}</div>
</div>
<div className="stat-card">
<span>Leads Extracted</span>
<div className="stat-value">{stats.totalLeads}</div>
</div>
<div className="stat-card">
<span>Booking Conversion</span>
<div className="stat-value">{stats.conversionRate}%</div>
</div>
<div className="stat-card">
<span>Missed Calls</span>
<div className="stat-value">{stats.missedCalls}</div>
</div>
</section>
)}
{activeTab === 'leads' && (
<table>
<thead>
<tr><th>Name</th><th>Service</th><th>Status</th><th>Notes</th></tr>
</thead>
<tbody>
{leads.map((lead) => (
<tr key={lead.id}>
<td>{lead.name}</td>
<td>{lead.service}</td>
<td>{lead.status}</td>
<td>{lead.notes}</td>
</tr>
))}
</tbody>
</table>
)}
{activeTab === 'kb' && user?.role === 'admin' && (
<div className="kb-grid">
<div className="panel">
<h3>Upload Guidelines (PDF)</h3>
<input type="file" accept="application/pdf" onChange={handleFileUpload} />
<p style={{ fontSize: '0.75rem', marginTop: '0.75rem' }}>
Files are parsed, chunked, embedded, and sent to the knowledge base.
</p>
</div>
<div className="panel">
<h3>Scrape Website URL</h3>
<form onSubmit={handleAddUrl} style={{ display: 'flex', gap: '0.5rem' }}>
<input
type="url"
placeholder="https://clientbusiness.com/faqs"
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
required
/>
<button type="submit">Scrape</button>
</form>
</div>
<div className="panel">
<h3>Synced Knowledge Index</h3>
<div className="file-list">
{documents.map((doc) => (
<div key={doc.id} className="file-item">
<span>{doc.name}</span>
<span>{doc.type} · {doc.size} · {doc.chunksCount} chunks</span>
<span className={`badge ${doc.status === 'Synced' ? 'badge-success' : 'badge-warning'}`}>{doc.status}</span>
</div>
))}
</div>
</div>
</div>
)}
</main>
</div>
);
}
The Dashboard, Leads, and Knowledge Base tabs above are wired end-to-end — the original project's App.jsx additionally includes a Call Logs transcript viewer and a fuller Settings form, both following the same pattern: local state seeded from mock data, then overwritten by loadDashboardData() once the API responds.
What just happened: The dashboard never assumes the backend is reachable. Every fetch is wrapped in a try/catch that silently falls back to mock data, which is what lets you demo the UI to a client even before the backend is deployed.
Test it now:
npm run dev
Open http://localhost:5173, log in with any email/password, and you should see live numbers pulled from http://localhost:5000/api/stats instead of the hardcoded mock values. Click into the Knowledge Base tab and upload a PDF — it should appear in the Synced Knowledge Index list within a few seconds, backed by the /api/documents/upload route from Step 3.
Step 5: Add Live AI Voice Calls with Retell AI
What we are doing: Wiring up a real, browser-based voice conversation with Clara using the Retell AI SDK — no phone number required — and saving the call + extracted lead to Firestore the moment it ends.
First, two setup endpoints in server.js that create the Retell LLM and Agent once:
const RETELL_API_KEY = process.env.RETELL_API_KEY;
const RETELL_BASE = 'https://api.retellai.com';
// Run once to create the Retell LLM + Agent
app.post('/api/retell/setup', async (req, res) => {
if (!RETELL_API_KEY) return res.status(400).json({ error: 'RETELL_API_KEY not found in .env' });
let settings = mockDb.settings;
if (db) {
const snap = await db.collection('settings').doc('main').get();
if (snap.exists) settings = snap.data();
}
const llmRes = await axios.post(`${RETELL_BASE}/create-retell-llm`, {
model: 'gpt-4o-mini',
general_prompt: settings.systemPrompt,
begin_message: 'Hello! Thank you for calling Radiant Dental Clinic. This is Clara, your AI receptionist. How can I help you today?',
}, { headers: { 'Authorization': `Bearer ${RETELL_API_KEY}`, 'Content-Type': 'application/json' } });
const llmId = llmRes.data.llm_id;
const agentRes = await axios.post(`${RETELL_BASE}/create-agent`, {
response_engine: { type: 'retell-llm', llm_id: llmId },
voice_id: 'cartesia-Cleo',
agent_name: 'Clara - Radiant Dental',
language: 'en-US',
responsiveness: 1,
interruption_sensitivity: 0.8,
end_call_after_silence_ms: 10000,
}, { headers: { 'Authorization': `Bearer ${RETELL_API_KEY}`, 'Content-Type': 'application/json' } });
res.json({
success: true,
llm_id: llmId,
agent_id: agentRes.data.agent_id,
next_step: `Add RETELL_AGENT_ID=${agentRes.data.agent_id} to your .env file`
});
});
// Called by the frontend every time someone clicks "Start Call"
app.post('/api/retell/create-web-call', async (req, res) => {
if (!RETELL_API_KEY) return res.status(400).json({ error: 'RETELL_API_KEY not found' });
const agentId = process.env.RETELL_AGENT_ID;
if (!agentId) return res.status(400).json({ error: 'RETELL_AGENT_ID not set. Run /api/retell/setup first.' });
const response = await axios.post(`${RETELL_BASE}/v2/create-web-call`,
{ agent_id: agentId },
{ headers: { 'Authorization': `Bearer ${RETELL_API_KEY}`, 'Content-Type': 'application/json' } }
);
res.json({ access_token: response.data.access_token, call_id: response.data.call_id });
});
Lead extraction — GPT-4o-mini reads the transcript and pulls out structured data, with a regex-based fallback if the LLM call fails:
const extractLeadFromTranscript = async (transcript, callerPhone) => {
const defaultResult = {
callerName: 'Unknown Caller', serviceRequested: 'General Inquiry',
leadTemperature: 'Cold', summary: 'Call received. No details captured.',
wantsBooking: false, email: null
};
if (!transcript || transcript.length < 10) return defaultResult;
if (openai) {
try {
const completion = await openai.chat.completions.create({
model: process.env.OPENROUTER_MODEL || 'openai/gpt-4o-mini',
messages: [
{
role: 'system',
content: `You are an expert AI parser for a dental clinic. Analyze the call transcript and extract structured data. Return ONLY valid JSON with no extra text. Format:
{
"callerName": "extracted full name or Unknown Caller",
"serviceRequested": "specific dental service or General Inquiry",
"leadTemperature": "Hot | Warm | Cold",
"summary": "one sentence call summary",
"wantsBooking": true or false,
"email": "email address if mentioned or null"
}`
},
{ role: 'user', content: `Caller phone: ${callerPhone}\nTranscript:\n${transcript}` }
],
temperature: 0.1,
max_tokens: 300
});
const parsed = JSON.parse(completion.choices[0].message.content.trim());
return { ...defaultResult, ...parsed };
} catch (err) {
console.log('OpenRouter extraction failed, using keyword fallback:', err.message);
}
}
// Keyword fallback — runs if no API key is set or the LLM call fails
const lowerTranscript = transcript.toLowerCase();
let callerName = 'Unknown Caller';
const namePatterns = [/my name is ([A-Z][a-z]+ [A-Z][a-z]+)/i, /this is ([A-Z][a-z]+ [A-Z][a-z]+)/i];
for (const pattern of namePatterns) {
const match = transcript.match(pattern);
if (match) { callerName = match[1]; break; }
}
const emailMatch = transcript.match(/[\w.-]+@[\w.-]+\.\w{2,}/);
const wantsBooking = /book|schedul|appointment|confirm|yes please|when can/i.test(transcript);
const showsUrgency = /emergency|urgent|pain|today|asap/i.test(transcript);
const leadTemperature = (wantsBooking || showsUrgency) ? 'Hot' : (callerName === 'Unknown Caller' ? 'Cold' : 'Warm');
return {
callerName,
serviceRequested: 'General Inquiry',
leadTemperature,
summary: `${callerName} called. Lead temperature: ${leadTemperature}.`,
wantsBooking,
email: emailMatch ? emailMatch[0] : null
};
};
And the webhook Retell hits when the call ends:
app.post('/webhook/retell', async (req, res) => {
res.status(200).json({ received: true }); // Reply fast — Retell has a short timeout
const { event, call } = req.body;
if (event !== 'call_ended') return;
const callerPhone = call.from_number || 'Web Call';
const durationSecs = Math.round((call.duration_ms || 0) / 1000);
const durationStr = `${Math.floor(durationSecs / 60)}m ${durationSecs % 60}s`;
const leadInfo = await extractLeadFromTranscript(call.transcript || '', callerPhone);
let callStatus = 'Completed';
if (durationSecs < 8) callStatus = 'Missed Call';
else if (leadInfo.wantsBooking) callStatus = 'Booking Made';
else if (leadInfo.callerName !== 'Unknown Caller') callStatus = 'Lead Generated';
const callLog = {
id: call.call_id, callerName: leadInfo.callerName, callerPhone,
duration: durationStr, status: callStatus,
timestamp: new Date().toLocaleString('en-US'),
summary: call.call_analysis?.call_summary || leadInfo.summary,
source: 'Retell Web Demo', createdAt: new Date().toISOString()
};
if (db) await db.collection('calls').doc(call.call_id).set(callLog);
else mockDb.callLogs.unshift(callLog);
if (callStatus !== 'Missed Call') {
const lead = {
name: leadInfo.callerName, phone: callerPhone, email: leadInfo.email || null,
service: leadInfo.serviceRequested || 'General Inquiry',
status: leadInfo.leadTemperature || 'Warm',
notes: leadInfo.summary, source: 'Retell Web Demo', createdAt: new Date().toISOString()
};
if (db) await db.collection('leads').add(lead);
else mockDb.leads.unshift(lead);
}
});
On the frontend, the "Start Call" button gets an access token from your backend, then hands it to the Retell Web Client SDK, which handles the microphone and audio streaming entirely in the browser:
const retellClientRef = useRef(null);
const callTimerRef = useRef(null);
const [callStatus, setCallStatus] = useState('idle');
const [liveTranscript, setLiveTranscript] = useState([]);
const [callDuration, setCallDuration] = useState(0);
const startDemoCall = async () => {
try {
setCallStatus('connecting');
setLiveTranscript([]);
setCallDuration(0);
const res = await fetch(`${API_BASE_URL}/retell/create-web-call`, { method: 'POST' });
if (!res.ok) {
const err = await res.json();
alert(`Failed to start call: ${err.error}`);
setCallStatus('idle');
return;
}
const { access_token } = await res.json();
const client = new RetellWebClient();
retellClientRef.current = client;
client.on('call_started', () => {
setCallStatus('active');
callTimerRef.current = setInterval(() => setCallDuration(d => d + 1), 1000);
});
client.on('call_ended', () => {
setCallStatus('ended');
clearInterval(callTimerRef.current);
setTimeout(() => {
loadDashboardData();
setCallStatus('idle');
setLiveTranscript([]);
setCallDuration(0);
}, 3000);
});
client.on('update', (update) => {
if (update.transcript) {
setLiveTranscript(update.transcript.map(m => ({
speaker: m.role === 'agent' ? 'Clara' : 'You',
text: m.content
})));
}
});
await client.startCall({ accessToken: access_token });
} catch (err) {
alert('Failed to start call. Please make sure the backend is running.');
setCallStatus('idle');
}
};
const endDemoCall = () => {
if (retellClientRef.current) retellClientRef.current.stopCall();
clearInterval(callTimerRef.current);
setCallStatus('idle');
};
What just happened: The browser never talks to Retell directly with a secret key — it gets a short-lived access_token from your backend, which is the pattern that keeps your Retell API key off the client. The update event streams the live transcript in real time, so the caller sees their own words appear as Clara responds.
Test it now:
curl -X POST http://localhost:5000/api/retell/setup
Expected output:
{"success":true,"llm_id":"llm_xxxx","agent_id":"agent_xxxx","next_step":"Add RETELL_AGENT_ID=agent_xxxx to your .env file"}
Add that agent_id to .env, restart the server, click "Start Call" in the dashboard, and talk to Clara.
Want real phone numbers instead of (or alongside) the browser demo? The same backend exposes
POST /webhook/vapifor VAPI.ai phone integration — it uses the sameextractLeadFromTranscriptfunction, just triggered by an incoming call instead of a browser session.
Step 6: Deploy to Render
What we are doing: Getting the backend off localhost and onto a public URL, since both Retell and VAPI need somewhere real to send webhooks.
backend/Dockerfile:
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
EXPOSE 5000
CMD ["npm", "start"]
To deploy on Render:
- Push your code to GitHub (make sure
.envis in.gitignore— never commit real credentials). - In the Render dashboard: New → Web Service → connect your repo.
- Root Directory:
backend - Start Command:
npm start - Under Environment, add
PORT,FIREBASE_SERVICE_ACCOUNT,OPENAI_API_KEY,OPENROUTER_MODEL,RETELL_API_KEY,RETELL_AGENT_IDas secret environment variables (paste real values directly into Render's dashboard, never into your repo). - Deploy. Your backend is now live at
https://your-service.onrender.com.
What just happened: Render builds the Dockerfile, installs only production dependencies, and exposes the same Express app on a public HTTPS URL — which is the URL you point Retell's webhook and VAPI's server URL at.
Don't forget the frontend.
App.jsxstill hasAPI_BASE_URLhardcoded tohttp://localhost:5000/apifrom Step 4. Once your backend is live, update it before deploying the dashboard:
“`javascript
const API_BASE_URL = 'https://your-service.onrender.com/api';
“`
In a real project you'd read this from a Vite env variable (
import.meta.env.VITE_API_URL) so local and production builds don't need a manual code edit — but for this tutorial, a direct swap is enough to get the live dashboard talking to your deployed backend.
Test it now:
curl https://your-service.onrender.com/webhook/vapi/status
Expected output:
{"status":"online","service":"AskOra AI Backend","firestore":"connected","rag_chunks":0}
Wrapping Up
You now have a working AI voice receptionist: an Express + Firestore backend with graceful mock-data fallback, a RAG pipeline that chunks and searches PDFs and websites, a React dashboard with role-based access, and a live voice agent powered by Retell AI that extracts leads automatically from every conversation.
From here, natural next steps are activating the n8n post-call automation (SMS + email notifications), swapping in real JWT authentication instead of the demo login, and connecting a VAPI phone number for real inbound calls.
Built this along with me? I'd love to hear what you ran into or what you'd extend next — drop a comment below.
