1 · How it works
A caller dials a client's business number. Twilio hits POST /voice, which returns TwiML that opens a bidirectional Media Stream to /media-stream. The engine bridges that stream to the OpenAI Realtime API — the caller talks to the AI live. Both legs are G.711 μ-law @ 8 kHz, so audio passes through with no resampling.
The AI hears the caller, speaks back, and can call two tools: capture_lead (record name/number/reason) and transfer_to_human (request a live handoff — allowed or denied by your policy, not the model). On hangup the call's capture + transcript are delivered, and usage is metered.
| Piece | What |
|---|---|
| Host | Railway — service vc, vc-production-517e.up.railway.app |
| Console | console.worldvc.business (and /console) |
| Telephony | Twilio (YoreVox account), Media Streams |
| AI | OpenAI Realtime API |
| Store | Postgres (Railway) — agent_config, call_capture, call_usage |
2 · Onboard a number
Everything is per-number and hot-swappable — a config change takes effect on the next call to that number, no redeploy.
- In Twilio, point the number's Voice webhook at
https://vc-production-517e.up.railway.app/voice(HTTP POST). - Open the console, paste the
ADMIN_TOKEN, click Connect. - + New, then fill the number's card:
| Field | Notes |
|---|---|
| Number | E.164, e.g. +19205330466. This is the primary key and the billable client. |
| Business name | What the caller hears. "Northside Dental", not a product name. |
| Voice | OpenAI voice — sage (default), alloy, echo, shimmer, coral, ballad, verse. |
| Greeting | The exact first line the AI speaks. Blank = a sensible default. |
| Instructions | Persona & behavior. Blank = default that tells the AI to capture leads. Add domain rules here (hours, services, what to never promise). |
| Handoff SMS | E.164 that receives a text when a lead is captured. Blank = off. |
| Transfer number | The human's phone. Presence of this is what offers transfer to the AI at all. |
Click Save. Call the number to verify.
3 · Transfer policy
The AI can request a transfer; the engine decides whether to allow it via evaluateTransfer(). You control this per number — the model never gets the final say.
| Mode | Behavior |
|---|---|
| always | Any transfer request connects the caller to the human number. |
| never | AI only. Requests are denied; the AI takes a message instead. |
| rules | Allowed only when the rules below pass. This is the nuanced mode. |
| (blank) | No explicit policy — falls back to rules defaults. |
Rules-mode fields
- Business hours — timezone (e.g.
Asia/Manila), start/end (09:00–18:00), and days. Outside hours → denied, message taken. Hours are evaluated in the given timezone viaIntl.DateTimeFormat. - Caller asks for a person — allow when the trigger is a direct request.
- Urgent matters — allow when urgency is high.
- Trigger keywords — comma-separated (
emergency, complaint). If the trigger matches, allow.
<Dial>). The capture collected so far is delivered before the handoff, so nothing is lost.timeout="20"), Twilio falls to /voice/transfer-fallback: the caller hears an apology and can leave a recorded voicemail. So "transfer" degrades gracefully to "message" — it never dead-ends.4 · Capture delivery
On hangup, deliverCapture() fires all three channels in parallel (Promise.allSettled — one failing never blocks the others):
| Channel | Detail |
|---|---|
| Postgres | call_capture row — structured lead + full transcript. Always on (when a DB is configured). This backs the Captures tab. |
| SMS | Sent from the client's own Twilio number to the Handoff SMS number. Blank = off. |
| Webhook | Signed POST to the configured URL (Nexus/workspace ingest). Blank = off. |
Webhook contract
POST JSON: { callSid, to, from, lead, transcript }. If a webhook secret is set, the request carries x-vc-signature: sha256=<HMAC-SHA256 of the raw body>. Verify it before trusting the payload.
signature = "sha256=" + hmac_sha256(secret, rawRequestBody)
// constant-time compare against the x-vc-signature header
5 · Usage & billing
Every call writes a call_usage row attributed to the called number (= the client). It records call seconds and the AI input_tokens/output_tokens summed from each response.done.
The Usage tab (and GET /admin/usage) rolls this up per number for the current month, or ?month=YYYY-MM. Minutes are rounded up per call (billing convention); tokens are raw sums.
/usage endpoint (so metered spend lands on a tenant invoice) is the marked TODO in usage.ts — wire it when the control plane runs and numbers map to tenants.6 · Call forwarding (GSM)
To route an existing mobile line into a VocalizeCloud number, use the carrier's call-forwarding codes from the handset:
| Code | Action |
|---|---|
**21*<number># | Forward all calls unconditionally. |
*#21# | Check unconditional-forwarding status. |
##21# | Cancel unconditional forwarding. |
**61*<number># | Forward on no-answer. |
**21*…# can return "Setting Registration Failed" and international forwarding may be blocked carrier-side (Globe). That's a carrier limitation, not an engine fault — register the SIM or forward to a domestic number first.7 · Environment (Railway)
| Var | Purpose |
|---|---|
| OPENAI_API_KEY | Realtime API. Without it the media socket closes immediately. |
| DATABASE_URL | Postgres. Unset = env-only mode (no store, no console data). |
| ADMIN_TOKEN | Bearer token for /admin/* and the console login. Timing-safe compared. |
| TWILIO_ACCOUNT_SID | For outbound SMS + call-update (transfer). |
| TWILIO_AUTH_TOKEN | Twilio auth + inbound signature validation. Never paste in chat — set it directly in Railway. |
| PUBLIC_BASE_URL | Public https base; used to build the wss:// stream URL and callback URLs. |
| OPENAI_REALTIME_MODEL | Model id. preview in the name switches to the preview API shape. |
| TWILIO_VALIDATE | Set false to disable signature checks (debug only). Absent + auth token present = validation on. |
Health: GET /healthz → { ok, realtime, configStore, admin }. All-green means the key, Postgres, and admin token are all set.
8 · Troubleshooting
| Symptom | Likely cause → fix |
|---|---|
| Call connects then drops | /healthz shows missing OPENAI_API_KEY → set the key in Railway. |
| Connected but silent | Historically two causes, both fixed: greeting sent before the session's audio format applied, and a GA vs preview event-name mismatch. If it recurs, check logs for [openai] receiving audio — absence means the model side never produced audio. |
403 on /voice | Twilio signature mismatch. Confirm PUBLIC_BASE_URL matches the exact webhook URL Twilio calls (scheme + host), or the proxied host headers line up. |
| No SMS delivered | Check the client number can send SMS in Twilio; PH delivery can be finicky. The DB capture still lands — SMS is best-effort. |
| Transfer never rings | Confirm a transfer number is set and the policy allows it at call time (mode/hours). Logs show [transfer] … ALLOW/DENY with the reason. |
| Console shows no data | configStore is env-only → no DATABASE_URL. The console reads the store; without a DB there's nothing to list. |
9 · Admin API
All routes take Authorization: Bearer <ADMIN_TOKEN>. The console is a UI over exactly these.
| Route | Does |
|---|---|
| GET /admin/agents | List every configured number. |
| PUT /admin/agents/:number | Create or update a number's config (E.164). Takes effect next call. |
| DELETE /admin/agents/:number | Remove a number's config. |
| GET /admin/captures[?to=] | Last 50 captures, optionally filtered to one number. |
| GET /admin/usage[?month=YYYY-MM] | Per-number monthly rollup (calls, minutes, tokens). |
curl -sX PUT https://vc-production-517e.up.railway.app/admin/agents/+19205330466 \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"businessName":"Northside Dental","greeting":"Thanks for calling Northside Dental!",
"transferNumber":"+63917...","notifySms":"+63917...",
"transferPolicy":{"mode":"always"}}'