BizzBuzz Voice AI
Everything needed to configure, run, and deploy the AI receptionist end to end — from a local checkout to a production call handling real customers. Written for the person operating this platform, not just the code that runs it.
What this is
BizzBuzz Voice AI is an outbound and inbound sales agent that talks to leads over the phone — or a browser microphone, in Sandbox mode — driven by a conversation agent built on LangGraph and a real-time audio pipeline built on Pipecat. It answers calls, qualifies leads, books meetings, transfers to a human when a conversation needs one, and dials out on campaigns, all while every provider it depends on (the LLM, the speech-to-text engine, the voice, the phone carrier) stays swappable from inside the app itself.
There is no environment-variable credential file to hunt through and no Django admin panel to learn. Every external account this platform talks to — OpenAI or Bedrock for reasoning, Deepgram for transcription, ElevenLabs for voice, Plivo/LiveKit for telephony, Razorpay for billing, Google for sign-in, calendar and mail — is entered once, on a settings page built for exactly that provider, and stored encrypted in the database.
Who does what here
Business owner
Signs up, owns their own tenant automatically, configures providers, buys credits, connects their calendar and Gmail, invites their team.
Team member
Invited into an existing tenant with a specific role — Admin, Manager, Agent or Viewer — and only ever sees that tenant's own data.
Superuser
Runs the platform itself, not any one tenant — the one-time setup of shared credentials (Razorpay, Google OAuth), plan catalog, and KYC review.
The regular dashboard (what a business owner sees) and the superadmin console (/console/, gated on is_superuser) are separate template systems with separate navigation. Most of this guide is dashboard-side; the Superadmin Console section covers the other one.
Architecture
One call is one independent operating-system process. There is no shared long-running worker handling every call — when a call starts, whether from the dashboard's Sandbox, a test dial, a campaign, or an inbound webhook, the dashboard spawns a fresh manage.py run_call subprocess for it and walks away. That process builds the full audio pipeline, runs the conversation to completion, writes every cost line and transcript line back to the database as it goes, and exits.
The live call pipeline
Audio flows through a Pipecat pipeline in one direction, with the conversation agent sitting in the middle in place of a typical LLM node:
The agent step does not use Pipecat's own LLM abstraction — it calls a LangGraph graph directly, one node execution per user utterance. Two ways that step can run, chosen by a single app setting:
- Non-streaming — one structured LLM call returns the reply text, the next conversation state, an emotion tag, and any extracted lead details together.
- Streaming — a plain-text call streams the reply to the voice sentence by sentence for lower time-to-first-audio, then a second, smaller call classifies the turn once the reply is already known. Costs roughly 1.9× the tokens in exchange for a faster-feeling conversation.
flowchart LR
A[Dashboard
Sandbox / Test dial / Campaign / Inbound] -->|spawns| B(("run_call
subprocess"))
B --> C[Pipecat pipeline]
C --> D{{LangGraph agent}}
D --> E[(Database)]
C -->|per turn| E
style B fill:#e8a857,stroke:#b8791f,color:#241a08
Two provider systems, not one
Don't conflate these — they solve different problems and live in different apps:
| System | Governs | Where it lives |
|---|---|---|
| Providers | Which LLM / STT / TTS / STS / telephony vendor is active, one row per vendor, exactly one active at a time per kind | Provider Settings page |
| App Settings | Every tunable pipeline constant — turn-taking delays, silence thresholds, feature flags, billing currency | App Settings page |
A call can override the platform's active provider for a one-off test (Sandbox and Telephony both offer a picker) without ever touching which provider is globally active for everyone else.
Local Setup
Everything below assumes a checkout with its own virtual environment at env/, working directory at the repo root.
python3 -m venv env
env/bin/python -m pip install --upgrade pip
env/bin/python -m pip install -r requirements.txt
cp .env.example .env
# fill in SECRET_KEY at minimum -- generate one with:
env/bin/python -c "import secrets; print(secrets.token_urlsafe(50))"
env/bin/python manage.py migrate
env/bin/python manage.py sync_app_settings
env/bin/python manage.py createsuperuser
env/bin/python manage.py runserver
The handful of variables Django reads at process startup — SECRET_KEY, DEBUG, ALLOWED_HOSTS, database connection details, Redis URLs — are the only real environment variables this project uses. Every vendor credential is entered from inside the running app once you've signed in.
Seeding a starter script
A brand-new tenant has no conversation script yet. Two ready-made ones ship as management commands:
env/bin/python manage.py seed_default_script
env/bin/python manage.py seed_debt_advisor_script
First Run & Superuser
Sign in with the superuser account you just created and you land in the regular dashboard as the Owner of a brand-new, empty tenant — the same starting point every real signup gets. From here, the order that actually gets a test call working is:
- Provider Settings — add and activate at minimum one LLM, one STT and one TTS row, plus a Transport provider if you intend to place a real phone call rather than test in-browser.
- Scripts — seed or write a conversation script (states, FAQ, objections).
- Sandbox — place a browser-microphone test call against that script before ever touching a real phone number.
A superuser account is also how you reach the separate superadmin console at /console/ — that's where the one-time platform-wide credentials (Razorpay, Google OAuth) and the plan catalog get configured, independent of any one tenant.
Provider Settings
Every vendor in the call pipeline is a row in one of five tables — LLM, STT, TTS, STS (realtime speech-to-speech), and Transport (telephony/SIP). Adding a provider is filling in a form, not editing code. Saving a row as active automatically deactivates whatever else was active for that same kind — exactly one active provider per kind, at all times.
| Kind | Example vendors | Feeds |
|---|---|---|
| LLM | OpenAI, AWS Bedrock | The LangGraph conversation agent's reasoning |
| STT | Deepgram, Sarvam AI | Turning the caller's speech into text |
| TTS | ElevenLabs, Murf AI, Sarvam AI | Turning the agent's reply into speech |
| STS | OpenAI Realtime, Google Gemini Live | Combined speech-to-speech, bypassing separate STT/TTS |
| Transport | LiveKit (SIP via Plivo) | The actual phone call — PSTN in and out |
Every credential field is Fernet-encrypted before it ever touches disk, using a key derived from SECRET_KEY. That has one real consequence worth knowing before you ever move a database between two environments: see Troubleshooting → moving credentials between environments.
Testing a specific pairing
Sandbox and the Telephony test-dial screen both let you pin a call to a specific provider row rather than whatever's globally active — useful for an A/B cost or quality comparison without disturbing production traffic. Picking a provider for one test call never flips that row's own active flag.
App Settings
Everything about pipeline behavior rather than pipeline identity — turn-taking delays, silence and call-ending thresholds, feature flags, the reporting currency — lives here as one row per tunable constant, grouped into categories like Billing, Conversation Behavior, Turn-Taking & Timing, and Silence & Call Ending.
Changes apply from the next call onward, never mid-call — each call is its own subprocess and reads every setting once, at startup.
The "Reset all to defaults" action on this page is exactly that — every value returns to whatever the platform's own registry declares as default. It cannot be undone with an "undo" button; re-enter any custom values by hand if you need them back.
Cost Tracking
Every LLM token, transcription second, voice character, and telephony minute becomes its own cost line item the moment it happens during a live call, not summarized after the fact. A call's total is computed live from those line items rather than trusted from a cached field — the cached total only becomes reliable a few seconds after a call finishes, once the pipeline has fully torn down.
Multi-currency by design
Different vendors genuinely bill in different currencies — a telephony carrier in INR, an LLM vendor in USD. Each provider row carries its own billing currency, and every cost line is converted into one configured reporting currency using a cached USD→INR rate that refreshes automatically in the background and never blocks a live call waiting on a network request. Both the original vendor-currency cost and the converted reporting-currency cost are kept, so a real vendor invoice can always be reconciled against what this platform recorded.
Roles & Team
Every tenant has exactly one true owner — whoever the data already belongs to. Ownership isn't a role you're assigned; it's simply the default for any account with no membership elsewhere. An Owner can invite other people into their tenant with a specific, lesser role, and only the Owner can promote or demote another Admin.
| Role | Can |
|---|---|
| Owner | Everything, including managing Admins. Not a stored role — the pseudo-role for anyone with no Membership row. |
| Admin | Billing, connecting Calendar & Gmail, buying phone numbers, KYC, inviting/removing Manager and below — but never another Admin. |
| Manager | Edit and delete scripts, handoffs, blocked callers, campaigns. |
| Agent | Sandbox and test calls, manage leads, mark messages read, resolve flagged answers. |
| Viewer | Read-only across everything above. |
Ranks stack — every role above also has everything the roles below it can do. Inviting someone requires they've already signed up (by any method, including Google); there's no separate invite-email flow.
Multi-user teams are gated behind a platform-wide rbac_enabled app setting, itself off until a superuser turns it on. Until then, every account is simply the sole Owner of its own tenant — no invitations, no shared access.
Wallet & Plans
Every tenant has one Wallet with a single balance, fed from two independent sources that expire differently: a monthly subscription grant (expires at the end of its billing cycle) and one-off top-ups (never expire). Spending draws from whichever bucket is about to expire first.
Switching plans and card mandates
Upgrading or downgrading updates the same Razorpay subscription in place. One real constraint worth knowing before it surprises a customer: a domestic Indian card mandate cannot have its plan changed at all — not immediately, not at the next cycle. That's a Razorpay/RBI rule, not a bug in this platform; only UPI Autopay mandates support an in-place plan change. A domestic-card subscriber who wants a different plan has to cancel and re-subscribe.
| Mandate type | In-place plan change |
|---|---|
| UPI Autopay | Supported, effective at the next billing cycle |
| Domestic card | Not supported at all — cancel and re-subscribe instead |
Razorpay Setup
Configured once, platform-wide, from App Settings → Billing in the superadmin console — not per tenant. Three values, then a webhook, then a one-click sync per plan.
- Enter
razorpay_key_id,razorpay_key_secretand turn onrazorpay_enabled. - Register a webhook in the Razorpay Dashboard pointed at your domain, subscribed to at least
payment.captured,payment.failed, and everysubscription.*event. - Paste the webhook's own secret into
razorpay_webhook_secret— a separate value from the API key/secret pair. - On the Plan Catalog page, click Sync to Razorpay on each plan — this creates the matching Razorpay-side plan and stores its id.
https://your-domain.example.com/webhooks/razorpay/
These are two unrelated values you invent and paste in twice — once into Razorpay's own webhook configuration, once into this platform's App Settings — purely so this backend can verify an incoming request genuinely came from Razorpay. A one-character mismatch between the two copies makes every webhook silently fail signature verification; nothing about your Wallet or subscription status will ever update, with no error surfaced anywhere obvious.
Google Sign-In
"Continue with Google" on the login and signup pages, configured from Settings → Google in the superadmin console. This is its own OAuth Client, entirely separate from the Calendar/Gmail one below — keep that distinction in mind, since they're easy to conflate and are covered on the same settings page precisely because they're both "the Google credentials," not because they're the same thing underneath.
A user signing in with Google never sees Google Cloud Console, never has a Client ID of their own — the platform's one registered app identity is what every sign-in flows through, and each person's own Google account is what determines whose account it is on this end, exactly like signing into any other site with Google.
https://your-domain.example.com/accounts/google/login/callback/
Calendar & Gmail
A completely separate integration from Sign-In, configured from App Settings → Integrations instead — it lets the AI agent book real meetings on a business's calendar and send real email as that business, on the caller's request. Can reuse the exact same Google Cloud OAuth Client as Sign-In, or a different one; either way it needs its own two redirect URIs registered.
| Flow | Redirect URI | Scope granted |
|---|---|---|
| Calendar | /calendar/connect/callback/ | calendar.events, calendar.freebusy |
| Gmail | /gmail/connect/callback/ | gmail.send only — never read or delete |
Connecting either is a per-business action, not a platform-wide one — an Admin or Owner clicks Connect on their own dashboard, signs into their own Google account when prompted, and grants access there. The refresh token that results is stored against that one tenant only.
A business that would rather not use Google OAuth at all can add a plain SMTP connection instead, from the same Email settings page. If both exist, SMTP takes priority.
Notifications
Each user controls their own notification categories independently from their own Notification Settings page — muting Business Metric Alerts, for instance, without losing anything else. Preferences are per-user, not per-tenant, so two people on the same team can have entirely different notification settings.
Browser push notifications are a separate, opt-in layer on top of in-app notifications, enabled per device from the notification bell.
Superadmin Console
A separate console at /console/, gated on is_superuser rather than any tenant role — this is platform operation, not any one business's dashboard. It has its own navigation, its own visual shell, and does not share templates with the regular dashboard except where a settings page (Plan Catalog, Provider Settings, App Settings) deliberately reuses dashboard-style cards and tables.
Users & Agents
Every tenant across the platform, credit adjustments, plan overrides, feature grants.
Plan Catalog
Create, edit and Razorpay-sync every subscription tier and top-up package.
KYC
Review business verification documents before a tenant can buy a real phone number.
Broadcasts
Send a message to every user, or a filtered segment, in one batch.
System Health
A live status page — database connectivity, disk space, error rates — without needing a separate APM tool.
Audit Log
Every superadmin action, who did it, and when.
Only a real Django superuser can reach any of this — created with manage.py createsuperuser, never through self-service signup, on purpose.
Bare-Metal Server
The production path this platform is actually built for: three systemd services on a single Linux box, gunicorn bound straight to port 80, no reverse proxy in front by default.
| Service | Runs |
|---|---|
| bizzbuzz-web | gunicorn, migrate/collectstatic/sync_app_settings before every start |
| bizzbuzz-worker | Celery worker |
| bizzbuzz-beat | Celery beat scheduler |
Binding directly to port 80 as a non-root user works through one narrow systemd grant, not setcap on the binary (which would get silently wiped the next time a dependency update rewrites it):
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
Redeploying
cd /path/to/Voice-Agent && git pull
env/bin/python -m pip install -r requirements.txt
sudo systemctl restart bizzbuzz-web bizzbuzz-worker bizzbuzz-beat
Nothing else is needed before the restart — migrations, static files and the app-settings registry are all handled by the service's own pre-start hook.
Backups
A safe, hot SQLite backup command ships with the project — it uses SQLite's own online-backup API rather than a plain file copy, which matters specifically because a bare cp can capture a torn snapshot mid-write. Not scheduled automatically; wire it into cron:
0 3 * * * cd /path/to/Voice-Agent && env/bin/python manage.py backup_db
These protect against a bad migration or a mistaken delete — not against the disk itself failing, since the backups live on that same disk. Copy them off the machine periodically for real disaster recovery.
CI/CD Pipeline
Three GitHub Actions workflows, each with one job: prove the code works, get it onto the server, and skip the manual click on a PR when it's ready.
| Workflow | Triggers on | Does |
|---|---|---|
| ci.yml | Every push and PR | Installs, runs manage.py check, runs the full test suite. Nothing deployment-related. |
| deploy-baremetal.yml | Push to main | Pulls, migrates, restarts services, then verifies with a real HTTP health check — not just "did the process start." |
| automerge.yml | CI finishing successfully | Merges the associated PR, then explicitly triggers the deploy workflow. |
Why a self-hosted runner
The deploy workflow runs on a GitHub Actions runner installed directly on the production server itself, not one of GitHub's own cloud runners. A server behind office NAT with no port forwarded for SSH can't be reached by an outside runner at all — a self-hosted runner sidesteps that entirely by polling GitHub outbound, so nothing needs to be exposed to the internet to make this work.
A merge doesn't always mean a push event
A merge performed by a workflow using the default repository token deliberately does not trigger another workflow's push event — that's GitHub preventing accidental infinite loops. It means an auto-merged PR would silently never reach the server unless something explicitly dispatches the deploy. automerge.yml handles this by calling the deploy workflow directly right after a successful merge, rather than relying on the push event that will never fire.
The health check has to be real
Confirming a service is "active" only proves the process started — it says nothing about whether it can serve a real request. The deploy pipeline's last step instead retries an actual HTTP call to the readiness endpoint, with a short per-attempt timeout so one slow attempt can't silently eat the whole retry budget, and a spoofed Host header so the request passes the same host validation a real browser request would need to pass.
Database
SQLite by default, in WAL mode — a deliberate choice for a single-server deployment, not an oversight. WAL lets a reader and a writer proceed concurrently instead of the whole database file locking on every write, which matters here specifically because the web process and every live call's own subprocess hit the same file at once.
When to consider MySQL
SQLite's real ceiling is horizontal scaling and built-in replication, not raw throughput at this scale. MySQL support is opt-in via one environment variable, meant for a deployment that has genuinely outgrown a single server — not a default recommendation.
DB_ENGINE=mysql
DB_NAME=bizzbuzz
DB_USER=bizzbuzz
DB_PASSWORD=
DB_HOST=127.0.0.1
DB_PORT=3306
Create the database with utf8mb4 at creation time, not as an afterthought — MySQL's plain utf8 is a legacy encoding that silently can't store full-width Unicode:
CREATE DATABASE bizzbuzz CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
Unlike Postgres, MySQL auto-commits schema changes one statement at a time — if a migration run fails partway through, every table it already created stays behind even though the overall command reported failure. A second attempt then collides on tables the first attempt already made. If a migrate run ever fails partway on a fresh MySQL database, drop and recreate the whole database before retrying rather than assuming it's still empty.
Troubleshooting
Every page redirects to a dead HTTPS port, only health checks work
Caused by SECURE_SSL_REDIRECT forcing an HTTPS redirect on a server that has no TLS in front of it yet — gunicorn bound directly to port 80, no reverse proxy configured. Health-check endpoints are commonly exempted from this redirect, which is exactly why they keep working while every real page 301s to a port nothing is listening on. Set SECURE_SSL_REDIRECT=false until real TLS exists in front of the app.
A reverse proxy sends the wrong OAuth scheme
If Google (or any OAuth provider) rejects a redirect_uri as a mismatch, check what scheme your own server is actually building into that URL — a reverse proxy that terminates TLS but forgets to forward X-Forwarded-Proto: https to the app behind it will make every generated URL come out as http:// even though the outside world sees https://. Confirm the proxy sends that header, or force the scheme independently of what the proxy reports.
Moving credentials between environments
Provider API keys are encrypted with a key derived from SECRET_KEY — and that key is deliberately different between environments. Copying the raw encrypted database rows from one environment to another does not work: the destination will try to decrypt them with its own, different key and fail silently, leaving the provider looking configured but functionally empty. The correct path is to decrypt on the source, transfer the plaintext over an already-secure channel, re-encrypt on the destination with its own key, then delete every trace of the intermediate plaintext file on both ends.
A health check that "still isn't ready" after a full minute
Two separate causes produce this same symptom. First: a request that lands on a worker process still mid-import can hang indefinitely without a per-attempt timeout on the client side, silently consuming the entire retry budget on one attempt instead of the many quick retries intended. Second, and easy to miss because it returns instantly rather than hanging: the request's Host header doesn't match anything in ALLOWED_HOSTS, and the framework rejects it immediately — a health check hitting a bare IP or 127.0.0.1 needs an explicit Host header matching a domain that's actually allowed.
Dark mode text is unreadable on part of a page
If a page mixes a design-system framework with hand-written theme tokens, the framework's own dark-mode variables need to be switched on independently — a custom light/dark toggle doesn't automatically flip a separate framework's palette unless something explicitly tells it to.
Command Reference
| Command | Does |
|---|---|
| migrate | Apply pending database migrations |
| sync_app_settings | Re-sync the App Settings registry after adding a new tunable |
| run_call --call-id <id> | Run one call end to end outside the dashboard — the exact code path a real call takes |
| dial_test --to +91… --script <id> | Place one real outbound PSTN call manually |
| provision_livekit_sip | Create LiveKit SIP trunks and a dispatch rule from a Transport provider's config |
| refresh_fx_rate | Force an immediate USD→INR rate refresh |
| seed_default_script | Seed a starter conversation script |
| backup_db --keep 14 | Safe hot SQLite backup, pruning anything past the 14 most recent |
| createsuperuser | The only way a superuser account is ever created |