How Callbots works
The pieces
| Piece | Role | Where in code |
|---|---|---|
| Twilio | The only carrier today. It owns the phone number. An Elastic SIP Trunk sends each call to xAI. Twilio also handles SMS, the voicemail fallback, and outbound owner calls. | DEPLOY.md:12,33; src/twilio.js |
| xAI Grok Voice (Direct SIP) | Speech-to-speech AI. It answers the call, calls our webhook, then talks to our server over a websocket. | src/xai.js:2-6; src/runtime.js:69 |
| xAI text model | Writes the SMS answers. XAI_TEXT_MODEL defaults to grok-4.7. | src/runtime.js:98; render.yaml |
| Render | Hosts the Node 22 web service, with a 1 GB disk at /var/data. | render.yaml |
| Resend | Sends email: links for callers, and messages for owners. | src/email.js:68 [bdo] |
| Admin dashboard | /admin, behind an emailed login link. | src/admin.js; src/app.js:309+ |
Other carriers (Telnyx, Vonage, and so on) are planned (later beta or alpha). The code has no adapter for them. Every carrier path today is Twilio.
Inbound call
- Twilio receives the call and sends it over the SIP trunk to
sip:+1NUMBER@sip.voice.x.ai;transport=tls(DEPLOY.md:33). - xAI posts a signed
realtime.call.incomingevent toPOST /xai/webhook(src/app.js:528[bdo]). - The server looks up the tenant by the dialed
Tonumber. It checks the signature against that tenant'sxai_webhook_secret. The check is Standard-Webhooks HMAC with replay protection (src/xai.js:16-30;src/app.js:64-72). A bad signature or an unknown number gets a 401.- The code comment (
src/xai.js:10-12) still says "ASSUMED… UNCONFIRMED". Checked Nov 3: the code computes Standard Webhooks HMAC-SHA256 overid.timestamp.body, base64, headerv1,<sig>(space-separated list),whsec_secret base64-decoded, 300 s tolerance plus replay check. The xAI SIP page (docs) names only the three headers; xAI documents this exact Standard Webhooks format for Grok webhooks (docs). Real calls to (727) 632-0002 pass this check in production (verify-by-call works live). Status: matches in practice; not stated explicitly on the SIP page, so the code comment can stay until xAI documents it there.
- The code comment (
- If the call carries a valid one-time owner token, the server starts an owner briefing session instead (
src/app.js:79-94). - Otherwise it reads caller ID from the SIP headers, in this order: P-Asserted-Identity, then From, then Remote-Party-ID (
src/xai.js:37-55). It records consent asinbound(src/app.js:96). - It opens
wss://api.x.ai/v1/realtime?call_id=…(src/runtime.js:69) and sendssession.update. That message carries the voice, the instructions, and the tools (src/xai.js:120). Then it sends aforce_messagegreeting (src/xai.js:140). - Grok calls tools during the call. The server runs them (
src/tools.js:44,54). When the call ends, it writes the call log and a call record (src/app.js:100-110).
Tools in the voice session: kb_answer, note_unanswered, website_lookup, send_link, send_link_sms, take_message, end_call, transfer_to_human (src/tools.js:44).
- If email is on, it adds
set_email_addressandsend_email(src/tools.js:53[bdo]). - If texting is on, it adds
set_text_number(src/tools.js:48). - On
[bdo]with the claim flag on, it adds the Best Deal On claim tools (see verify-by-call.md).
Diagram source (Mermaid)
flowchart TD
A[Caller dials business number] --> B[Twilio number]
B -->|Elastic SIP trunk| C[xAI Grok Voice]
C -->|POST /xai/webhook signed| D{Tenant for To number<br/>and signature OK?}
D -- no --> X[401, call not handled]
D -- yes --> E{Owner token?}
E -- yes --> O[Owner briefing session]
E -- no --> F[Caller ID from SIP headers<br/>record consent: inbound]
F --> G[Open realtime websocket<br/>session.update + greeting]
G --> H{What does the caller want?}
H -->|question| K[kb_answer / website_lookup]
K -->|no answer| N[note_unanswered + offer message]
H -->|link| L{Texting on?}
L -- yes --> S[send_link_sms]
L -- no --> M[send_email if email on]
H -->|a person| T{transfer_target set,<br/>in hours if required?}
T -- yes --> R[refer via xAI /refer]
T -- no / failed --> TM[take_message]
H -->|leave message| TM
H -->|done| Z[end_call → call log + record]If Grok is unavailable (voicemail fallback)
POST /twilio/voice returns TwiML. It plays a <Say> disclosure, then records a voicemail (src/app.js:56-62,124-129). POST /twilio/voice/recording saves it and notifies the owner (src/app.js:131-142). Every Twilio webhook is signature-checked first.
- [bdo]: if
take_message.record_audioisfalse, no audio is recorded (the tests are in commita20587e).
Taking a message and the email fallback
Diagram source (Mermaid)
flowchart TD
A[Caller wants to leave a message] --> B[take_message: name, callback, message]
B --> C[notify owner outbox]
C --> D{take_message.deliver == email?}
D -- no --> E[saved only]
D -- yes --> F[Email owner via Resend<br/>from brand.email_from]
F --> G{PA_EMAIL_ALLOW_ONLY set?}
G -- yes, not on list --> H[blocked: scripted line]
G -- no / allowed --> I[sent]- Code:
src/tools.js:93-97[bdo]. - The allowlist guard is at
src/email.js:10,63[bdo]. When the caller confirms their email address on this call, that address is allowed (commit93fc899). - Note: while
PA_EMAIL_ALLOW_ONLYis set, owner mail to support@bestdealon.app is blocked unless that address is on the list. Removing the guard needs John's OK.
Texting gate
See texting.md.
Transfers
transfer_to_humanworks only on voice calls.- It needs
transfer_target(tel:+E164orsip:). - It refuses when the caller is the transfer target. If
transfer_hours_onlyis set, it also refuses after hours. - It calls
POST /v1/realtime/calls/{id}/refer(src/tools.js:85-91[bdo];src/xai.js:142-146[bdo]). - If the transfer fails, the bot takes a message.
Owner briefing
- The module is at
src/owner/. It does nothing unlessowner_briefing.enabled === true(src/owner/index.js:2-3). - Features: a daily call at 18:00 (America/New_York by default), emergency alerts, an on-demand "what happened today", and a transfer fallback.
- Settings and defaults:
src/owner/config.js:6-10. - Best Deal On: not configured (
owner_briefingabsent in the bestdealon tenant), so it is off.
Admin dashboard
- Sign-in:
/admin/loginemails a link to/admin/auth#t=…. That sets a session cookie, and forms are CSRF-protected (src/app.js:341-371[bdo]). - Pages:
/admin: calls, newsletter, and the channel toggles (src/admin.js:138-147)./admin/channels: texting and email on/off./admin/outbound/admin/subscribers/admin/usage/admin/messages
- CSV exports:
/admin/calls.csv,/admin/newsletter.csv,/admin/kb-gaps.csv,/admin/messages.csv(src/app.js:473-483[bdo]). - The API uses
Authorization: BearerwithPA_ADMIN_TOKEN:/admin/status/admin/tenants[/<id>/(status|approvals|config|kb-bundle|sms|…)](src/app.js:191[bdo])
- [bdo] Owner API:
/api/owner/v1/*. It is read-only and per business, and it serves the Best Deal On Bot extension (src/ownerapi.js;docs/OWNER_API.md).
Health and shutdown drain
GET /healthreturnsok, the tenant count,active_calls, andmode(src/app.js:520[bdo]).- Main today: on SIGTERM the server stops accepting connections and force-exits after 10 s (
src/shutdown.js:9-11). A deploy can cut off a live call. - PR #42, open and unmerged: the server drains. It refuses new calls with a 503 (
[PR #42] src/app.js:83), and/healthreturns 503 withdraining:true(:496). It waits until there are no active calls, up toPA_DRAIN_MAX_MS(default 280000) ([PR #42] src/shutdown.js:6-50). It addsmaxShutdownDelaySecondsto render.yaml.
Diagram source (Mermaid)
flowchart TD
A[Render sends SIGTERM on deploy] --> B{Code version}
B -- main today --> C[server.close<br/>exit after 10 s even if a call is live]
B -- PR #42 --> D[draining=true<br/>/health 503, new calls 503]
D --> E{active calls == 0?}
E -- yes, server closed or 10 s idle --> F[exit 0 'idle']
E -- no --> G{waited >= PA_DRAIN_MAX_MS?}
G -- no --> E
G -- yes --> H[exit 0 'cap']Render
- Blueprint (
render.yaml):- web service, Node runtime,
starterplan,virginiaregion - build:
npm test; start:node bin/server.js - health check:
/health - 1 GB disk at
/var/data;DATA_DIR=/var/data,TENANTS_DIR=/var/data/tenants
- web service, Node runtime,
PA_MODEisdemounless it is set tolive(bin/server.js:10). Live mode refuses to start withoutPUBLIC_BASE_URL,PA_ADMIN_TOKEN,PA_STATE_SECRET,PA_MASTER_KEY,XAI_API_KEYandDATA_DIR(src/runtime.js:19,36).- Services:
- Best Deal On:
callbots-bdo(deploy/render.yaml[bdo])
- Best Deal On:
- Live (checked Nov 3): callbots-bdo
srv-dbl01inf3r2c7382v4c0runsee5b1f2. ItsTENANTS_DIRpoints at the repo copydeploy/bestdealon/tenants(not the disk), so the tenant file in git is the live config.