Textify Voice: Architecture
Companion to
SPEC.md. This file is how: components, adapters, call flows (inbound, outbound, broadcast), data model, APIs, auth, communication, infra. Stack: Textify (Go) → Voice Engine (Java 21, Spring Boot) → Africa's Talking + LiveKit (self-hosted) + OpenAI. No Python, no message broker. [VERIFY] = check against provider docs or Africa's Talking before coding against it.
1. Overview
┌──────────────────────────────────────┐
Phones (any TZ network) │ Africa's Talking (AT) │
◄──── PSTN ──────►│ numbers · inbound callbacks · XML · │
│ outbound call API · recordings · SIP │
└───────┬───────────────────▲──────────┘
voice callbacks (POST)│ │ XML replies, call API (REST)
+ Dial to SIP │ │
┌──────────────────────── Voice box (dedicated) ───────┼─────────────────────────────┐
│ ▼ │ │
│ ┌──────────────┐ SIP ┌──────────────────────────────────────────┐ │
│ │ livekit-sip │◄──────┤ VOICE ENGINE (Spring Boot) │──► Postgres │
│ └──────┬───────┘ (AT) │ accounts · numbers · flows · agents · │──► Redis │
│ │ │ calls · campaigns · recordings · events │──► S3 store │
│ ┌──────▼──────────┐ │ │ │
│ │ livekit-server │◄──►│ TelephonyAdapter MediaAdapter AiAdapter│ │
│ │ + ingress (hold)│ └───────▲──────────────────┬───────────────┘ │
│ └──────▲──────────┘ │ REST (API key) │ webhooks (HMAC) │
└──────────┼───────────────────────┼──────────────────┼──────────────────────────────┘
│ WebRTC │ ▼
┌───────┴────────┐ ┌──────┴──────────────────────────┐ ┌─────────┐
│ Agent browser │◄─app─►│ TEXTIFY (Go) │ │ OpenAI │◄─ AiAdapter
│ (Textify UI + │ │ users · tenants · wallet · │ │ (Ph 2–3)│
│ LiveKit JS SDK)│ │ pricing · UI · SMS/WhatsApp │ └─────────┘
└────────────────┘ └─────────────────────────────────┘
Three rules hold the design together:
- AT is the only road to the phone network, both directions. Inbound arrives as AT callbacks; outbound starts with AT's call API.
- Talking to a person (or AI) happens in LiveKit. The engine's XML says
Dial sip:{callId}@<livekit-sip-host>and the call enters roomcall-{callId}. LiveKit only ever receives calls from AT. - The engine is blind. It knows
accountId,agentRef, numbers and calls, never users, roles or money.
1.1 Responsibilities
| Component | Owns | Never owns |
|---|---|---|
| Textify (Go) | Users, login, roles, tenants, partners, wallet, prices, all UI screens (flow editor, campaigns, dashboard), contact lists, SMS/WhatsApp follow-ups | Telephony / LiveKit / OpenAI logic |
| Voice Engine | Client apps, accounts (opaque), numbers, flows, agents (opaque refs), ring groups, presence, calls, campaigns, recordings, transcripts, usage events | Users, auth of people, prices, money |
| AT | PSTN, numbers, XML execution, outbound dialing, recording | Anything else |
| LiveKit | Rooms, WebRTC to browsers, SIP from AT, hold audio, (Ph 3) AI participant | Business logic |
| OpenAI | Ph 2: transcribe + summarize. Ph 3: live voice | State, routing |
2. Inside the engine
Stack: Java 21, Spring Boot 3.x, PostgreSQL + Flyway, Redis, @Scheduled jobs, virtual threads.
2.1 Adapters (plain interfaces, one implementation each, no plugin framework)
interface TelephonyAdapter { // impl: AfricasTalkingAdapter
String render(List<Action> actions); // → AT XML
OutboundResult placeCall(String from, List<String> to); // AT call API
InputStream downloadRecording(String url);
}
interface MediaAdapter { // impl: LiveKitAdapter
String sipUriFor(UUID callId); // sip:{callId}@sip.voice…
String roomToken(String room, String identity, boolean canPublish);
void sendData(String room, Object message); // ring / ring_cancel to lobbies
void startHoldAudio(String room); void stopHoldAudio(String room);
void deleteRoom(String room);
// Ph 3: dialAi(room, callId), removeParticipant(room, identity)
}
interface AiAdapter { // impl: OpenAiAdapter
Transcript transcribe(InputStream audio, String langHint); // Ph 2
Summary summarize(Transcript t); // Ph 2
// Ph 3: acceptLiveCall(openAiCallId, SessionConfig), control WebSocket, hangup
}
Swapping later (another telco, another AI/TTS) = a new class, nothing else changes.
2.2 Packages
com.qbitspark.voice
├── api // client REST /v1/** (API-key auth)
├── hooks // /hooks/at/{secret}/**, /hooks/livekit, /hooks/openai
├── telephony // AfricasTalkingAdapter, XML builder (Say, Play, GetDigits, Dial, Record, Reject)
├── media // LiveKitAdapter, tokens, lobby/ring, ingress hold audio
├── ai // OpenAiAdapter
├── flows // flow JSON model, validator, executor (node → XML)
├── calls // call lifecycle, state machine, timeline
├── routing // ring groups, presence, waiting list, claim (first accept wins)
├── campaigns // broadcast campaigns, dispatcher, retries, opt-outs
├── recording // download → S3, retention
├── jobs // Postgres job table + @Scheduled workers (transcribe, summarize, cleanup)
├── events // outbox + signed webhook dispatcher
├── accounts // client apps, accounts, numbers, agents
└── common
2.3 Flow model (stored per number, built in Textify's UI)
{
"record": true,
"hours": { "tz": "Africa/Dar_es_Salaam", "open": "Mon-Fri 08:00-17:00, Sat 09:00-13:00",
"closed": { "say": "Tumefunga kwa sasa. Acha ujumbe.", "next": "vm" } },
"start": "greet",
"nodes": {
"greet": { "type": "play", "url": "https://…/karibu.mp3", "next": "menu" },
"menu": { "type": "menu", "say": "Bonyeza 1 mauzo, 2 huduma kwa wateja",
"options": { "1": "sales", "2": "support" },
"noInput": "support", "retries": 2 },
"sales": { "type": "ring", "group": "grp_sales", "timeoutSec": 30, "noAnswer": "vm" },
"support": { "type": "ring", "group": "grp_support", "timeoutSec": 60, "noAnswer": "vm" },
"vm": { "type": "voicemail", "say": "Acha ujumbe baada ya mlio", "maxSec": 60 }
}
}
(The Swahili prompts read: "We're closed right now. Leave a message." · "Press 1 for sales, 2 for customer service" · "Leave a message after the tone".)
Node types: say, play, menu, ring, voicemail, hangup (Ph 3: ai). The engine validates on save (every next exists, no loops without input).
2.4 Executing a flow with AT callbacks
- Each number's voice callback URL in AT points to
https://voice-api…/hooks/at/{secret}/voice. - The executor finds the number → flow → current node, and returns XML for that node.
- State travels in the callback URL of each action (e.g. GetDigits
callbackUrl=/hooks/at/{secret}/step/{callId}/menu), plus thecallsrow. No session memory needed. - The final callback (
isActive=0) carries duration, cost (amount,currencyCode),recordingUrl, hangup cause → close the call. [VERIFY] exact AT field names. - XML responses must be fast (< 500 ms): number/flow config cached in memory, refreshed on change.
3. Call flows
3.1 Inbound → menu → browser agent
Caller AT Engine LiveKit Agent browser
│dial─►│── POST /voice ──────►│ number→account→flow │ │
│ │ │ can-call? (Textify, 1.5s, fail-open) │
│ │◄── XML: Say notice + Play greeting + GetDigits ─────│ │
│ "2" ►│── POST /step/{id}/menu (dtmfDigits=2) ─────────────►│ node=support (ring) │
│ │◄── XML: Dial sip:{callId}@sip.voice… record=true ───│ │
│ │══ SIP INVITE (user part = callId) ════════════════════════► room call-{callId}
│ │ │◄─ webhook participant_joined ─│ │
│ │ │ start hold audio (ingress) ──►│ │
│ │ │ pick available agents ─ ring data msg ─► lobby ─────►│ popup
│ │ │◄────────────── accept (via Textify) ──────────────────│
│ │ │ claim (Redis SET NX) → room token ───────────────────►│
│ │ │ stop hold audio ─────────────►│◄═════ joins, talks ══│
│◄═════╪══════════════════════╪═════════ audio ═══════════════╪══════════════════════│
│ hangup │ │ │
│ │── POST /voice (isActive=0: duration, amount, recordingUrl) ►│ close call → events
- Waiting: if no agent is available, the caller stays on hold in the room; when an agent becomes
AVAILABLE(or finishes a call), the engine rings again. Order = oldest waiting call first (Redis sorted set per group). - Timeout →
noAnswernode. Since the call is now in LiveKit, voicemail there = the engine plays the prompt via ingress and records via egress, or simpler: remove the SIP participant so AT'sDialends, then AT continues with the next XML (Record) on the Dial's callback. [VERIFY] AT continues the flow after Dial ends (prefer this: keeps voicemail on AT). - Missed: caller hangs up or nobody handled it →
call.missed.
3.2 Outbound click-to-call
Agent clicks number → Textify → POST /v1/calls/outbound {accountId, fromNumberId, to, agentRef}
Engine:
1. can-call(OUTBOUND)
2. create call + room call-{callId}; return agent token → browser joins (hears ringback tone from page)
3. TelephonyAdapter.placeCall(from, to) → AT dials the customer; store AT sessionId → callId
Customer answers → AT callback (direction=Outbound, sessionId) → engine finds callId
→ XML: Dial sip:{callId}@sip.voice… → customer joins the agent's room → talk
End → final callback → call.ended (pstnSeconds outbound, trunkSeconds)
3.3 Broadcast campaign
Textify → POST /v1/campaigns {accountId, fromNumberId, audioUrl|ttsText, keys:{1:"grp_sales",9:"optout"},
window, cps, maxConcurrent, retries} + recipients (chunks of ≤10k)
Dispatcher (@Scheduled every 1s, per campaign):
- inside calling window? not paused? concurrent < max?
- SELECT … FROM campaign_recipients WHERE status='PENDING' AND next_attempt_at<=now()
ORDER BY id LIMIT :cps FOR UPDATE SKIP LOCKED
- skip opt-outs → mark OPTED_OUT
- placeCall(from, batch) → mark DIALING, store AT sessionIds
Recipient answers → AT callback → XML: Play audio + GetDigits(keys)
- "1" → Dial sip:{callId}@livekit → ring group (same as 3.1)
- "9" → add to opt_outs → Say "Umeondolewa" ("You've been removed") → hangup
Final callback → recipient ANSWERED / NO_ANSWER / BUSY / FAILED (+seconds, amount)
- NO_ANSWER/BUSY & attempts < max → PENDING with next_attempt_at = now + retryDelay
Events: campaign.progress every 30s (counts) · campaign.recipient_results batches · campaign.completed
Textify: SMS fallback to NO_ANSWER numbers; pauses campaign (POST /pause) if wallet low
The campaign_recipients table is the queue. No broker needed.
3.4 Recording → transcript → summary (Phase 2)
final callback recordingUrl → job DOWNLOAD → S3 s3://rec/{accountId}/{callId}.mp3 → recording.ready
→ job TRANSCRIBE (OpenAI, sw/en) → job SUMMARIZE (OpenAI, JSON) → summary.ready
daily job RETENTION → delete where delete_after < now()
Recording comes from AT (Dial record="true"), so no LiveKit egress service is needed. [VERIFY] AT records the Dial-to-SIP leg; fallback = LiveKit egress.
Summary JSON: {summary, intent, resolved, followUps[], sentiment, topic, language}.
3.5 AI (Phase 3), same pattern
Flow node ai (or noAnswer: ai, or campaign key 1: ai) → call is dialed into LiveKit as usual → engine dials OpenAI Realtime into the room over SIP (LiveKit outbound trunk, TLS 5061 + SRTP) → OpenAI webhook → engine accepts with instructions/tools → controls via WebSocket from Java → transfer_to_human rings agents → agent joins → engine hangs up the AI leg. Details kept in Appendix A.
3.6 Call state machine
INBOUND: RINGING → IN_FLOW → WAITING_AGENT → ANSWERED → COMPLETED
OUTBOUND: DIALING → ANSWERED → COMPLETED
any → MISSED | NO_ANSWER | BUSY | FAILED | REJECTED_NO_BALANCE
handled_by: NONE | FLOW_ONLY | AGENT | AI | AI_THEN_AGENT
4. Browser agents
- Agent UI lives in Textify's web app, using the LiveKit JS SDK.
- Lobby room per agent:
lobby-{accountId}-{agentRef}, connected with mic off while logged in.- Presence = lobby
participant_joined/leftwebhooks (automatic online/offline) + explicit status from Textify. - Ringing = engine
sendData(lobby, {type:"ring", callId, from, numberLabel, groupName}).
- Presence = lobby
- Accept → browser → Textify backend →
POST /v1/calls/{id}/accept {agentRef}→ engine claims (SET call:{id}:agent NX EX 60) → returns call-room token → browser joins with mic on. Losers:409+ring_cancel. - Browser never holds engine credentials; only short-lived room tokens (≈10 min to join, room-scoped).
5. Data model (PostgreSQL)
client_apps (id uuid pk, name, api_key_hash, api_key_hash_next null, -- 2 keys for rotation
webhook_url, webhook_secret, can_call_url, created_at)
accounts (id uuid pk, client_app_id fk, external_ref, status, default_language default 'sw',
retention_days default 90, created_at, unique(client_app_id, external_ref))
phone_numbers (id uuid pk, e164 unique, provider default 'AT', type, -- REGULAR | TOLL_FREE
status, account_id fk null, label, flow jsonb, flow_version int, updated_at)
agents (id uuid pk, account_id fk, external_ref, display_name, status, last_seen_at,
unique(account_id, external_ref))
ring_groups (id uuid pk, account_id fk, name, strategy) -- ALL_AT_ONCE | ROUND_ROBIN
ring_group_members (ring_group_id fk, agent_id fk, position, pk(ring_group_id, agent_id))
calls (id uuid pk, account_id fk, number_id fk, campaign_id fk null,
direction, from_e164, to_e164, at_session_id, livekit_room,
status, handled_by, agent_id null, current_node,
started_at, answered_at, ended_at, end_reason,
pstn_seconds, trunk_seconds, ai_seconds,
at_amount numeric, at_currency, dtmf_path text, ai_usage jsonb null)
call_events (id bigserial pk, call_id fk, type, at, data jsonb)
campaigns (id uuid pk, account_id fk, from_number_id fk, status, -- DRAFT|RUNNING|PAUSED|DONE|CANCELLED
audio_url, tts_text, keys jsonb, window jsonb, cps int, max_concurrent int,
max_retries int, retry_delay_sec int, starts_at, created_at, completed_at)
campaign_recipients (id bigserial pk, campaign_id fk, e164, vars jsonb null,
status, attempts int, next_attempt_at, last_call_id fk null,
answered_seconds, keypress, index(campaign_id, status, next_attempt_at))
opt_outs (account_id fk, e164, created_at, pk(account_id, e164))
recordings (id uuid pk, call_id fk unique, source_url, storage_key, duration_sec, size_bytes,
status, delete_after)
transcripts (call_id pk fk, status, language, text, segments jsonb, summary jsonb, model, created_at)
jobs (id bigserial pk, type, ref_id, status, attempts, run_after, last_error) -- DOWNLOAD|TRANSCRIBE|SUMMARIZE|…
outbox_events (id uuid pk, client_app_id fk, type, payload jsonb, attempts, next_attempt_at,
delivered_at null, last_error)
Redis: presence:{accountId}:{agentRef}, call:{id}:agent (claim), wait:{groupId} (sorted set of waiting callIds), rr:{groupId}, campaign:{id}:active (concurrency counter).
6. APIs
6.1 Client API (Textify → engine)
| Area | Endpoints |
|---|---|
| Accounts | POST /accounts · PATCH /accounts/{id} |
| Numbers | GET /numbers?status=UNASSIGNED · POST /numbers/{id}/assign · POST /numbers/{id}/release · PUT /numbers/{id}/flow (validates) · GET /numbers/{id} |
| Agents | PUT /accounts/{id}/agents/{ref} · DELETE … · PUT …/status · POST …/lobby-token |
| Ring groups | POST/PUT/DELETE /accounts/{id}/ring-groups[/{gid}] |
| Calls | POST /calls/outbound · POST /calls/{id}/accept · POST /calls/{id}/hangup · POST /calls/{id}/transfer · GET /calls?… · GET /calls/{id} · GET /calls/{id}/recording (signed URL) · GET /calls/{id}/transcript |
| Campaigns | POST /campaigns · POST /campaigns/{id}/recipients (chunks) · `POST /campaigns/{id}/start |
| Opt-outs | GET/POST/DELETE /accounts/{id}/opt-outs |
| Ops only | POST /client-apps · POST /numbers (register AT number) |
6.2 Engine → Textify
- Balance check:
GET {can_call_url}?accountRef&direction&estimatedCalls?→{allowed, maxMinutes?}; 1.5 s timeout → allow. - Webhooks (outbox, signed, retried 1m/5m/30m/2h… up to 24h, idempotent by event id):
| Event | Key data |
|---|---|
call.started / call.answered |
callId, accountRef, direction, from, to, handledBy, agentRef |
call.ended |
status, usage {pstnSeconds, trunkSeconds, aiSeconds, atAmount}, recorded, endReason |
call.missed |
callId, from, to, numberLabel → Textify SMS/WhatsApp |
voicemail.received |
callId, from, recording |
recording.ready / summary.ready |
callId, (summary JSON) |
campaign.progress |
counts by status |
campaign.recipient_results |
batch of {e164, status, seconds, keypress, atAmount} |
campaign.completed |
totals |
6.3 Webhooks into the engine
| Endpoint | From | Verification |
|---|---|---|
POST /hooks/at/{secret}/voice and /step/{callId}/{node} |
AT | secret in path + AT IP allowlist + destinationNumber/callerNumber must match a known number/call [VERIFY AT IPs; any signature] |
POST /hooks/livekit |
LiveKit | signed JWT (WebhookReceiver) |
POST /hooks/openai (Ph 3) |
OpenAI | webhook signature + dedupe |
7. Communication choices
| Need | Mechanism |
|---|---|
| Textify ⇄ engine commands/queries | REST |
| Engine → Textify facts | Webhooks via Postgres outbox + @Scheduled dispatcher |
| AT call control | AT callbacks + XML; AT REST call API |
| Ring agents in browser | LiveKit data messages (lobby room) |
| Presence, claims, waiting lists, campaign concurrency | Redis |
| Background work (download, transcribe, summarize, retention) | Postgres jobs table + @Scheduled workers (FOR UPDATE SKIP LOCKED) |
| Broadcast queue | campaign_recipients table + dispatcher |
Not used in v1: MQTT (IoT protocol, and browsers are covered by LiveKit), Kafka (overkill), RabbitMQ. When to add one: several engine instances and very high event volume, or many consumers of the same events → Redis Streams first (already running Redis), RabbitMQ only if outgrown. The outbox/jobs tables make that a contained swap.
8. Security (service authentication)
- In (Textify → engine): API key per client app; stored hashed; two active keys for rotation; scoped to the client's accounts; rate-limited.
- Out (engine → Textify):
X-Voice-Event-Id,X-Voice-Timestamp,X-Voice-Signature: sha256=HMAC(secret, ts + "." + body); Textify rejects bad signatures and anything > 5 min old. - AT callbacks: unguessable secret in the path, IP allowlist, sanity checks against our numbers/calls.
- LiveKit / OpenAI webhooks: built-in signature verification.
- SIP: firewall and LiveKit inbound trunk
allowed_addresses= AT IPs only. Inbound trunk accepts any user part (our callId) only from AT IPs [VERIFY trunk config without anumbersfilter]. Unknown callId → reject. - Browsers: only short-lived room tokens, requested through Textify (where the human auth happens).
- Recordings: private bucket, signed URLs (~5 min), retention job, recording notice.
- No OAuth/Keycloak/mTLS between our services. Secrets only in env. Keep CrowdSec/fail2ban practice.
9. Infrastructure
9.1 Dedicated Voice box (Docker Compose; host networking for media)
| Service | Notes |
|---|---|
caddy / nginx |
TLS for voice-api.… (engine API + hooks) and wss://voice-rtc.… (LiveKit) |
livekit |
livekit/livekit-server, 7880 on localhost behind proxy |
sip |
livekit/sip, 5060 + RTP 10000–20000, receives AT only |
ingress |
livekit/ingress, hold audio into rooms [VERIFY URL input for short audio] |
redis |
shared by LiveKit + engine (separate DB index) |
postgres |
engine DB |
voice-engine |
Spring Boot jar |
(Ph 3 optional) egress |
only if AT recording isn't enough |
Storage: S3-compatible private bucket (Linode Object Storage or MinIO). Start 4 vCPU / 8 GB; measure. Choose the region with the lowest RTT to AT's SIP servers.
9.2 Ports
| Port | Open to | Purpose |
|---|---|---|
| 22 | admin IPs | SSH |
| 80/443 | world | API, hooks, WSS |
| 7881/tcp, 50000–60000/udp | world | WebRTC for browser agents |
| 5060 udp/tcp, 10000–20000/udp | AT IPs only | SIP + RTP from AT |
| 7880, 6379, 5432, 8080 | localhost | internal |
| (Ph 3) out 5061/tcp + RTP to OpenAI CIDRs | OpenAI | AI over SIP |
9.3 Env
AT_USERNAME / AT_API_KEY / AT_CALLBACK_SECRET / AT_IPS
LIVEKIT_URL=ws://127.0.0.1:7880 / LIVEKIT_PUBLIC_WS_URL / LIVEKIT_API_KEY / LIVEKIT_API_SECRET
LIVEKIT_SIP_HOST=sip.voice.textify.africa
DB_URL / DB_USER / DB_PASSWORD · REDIS_URL / REDIS_PASSWORD
S3_ENDPOINT / S3_BUCKET / S3_ACCESS_KEY / S3_SECRET_KEY
OPENAI_API_KEY (Ph 2) · OPENAI_WEBHOOK_SECRET / OPENAI_SIP_URI (Ph 3)
AUDIO_HOLD_URL / AUDIO_CONSENT_URL / AUDIO_NO_BALANCE_URL
9.4 LiveKit bootstrap (once, idempotent)
- Inbound trunk
at:allowed_addresses= AT IPs (no number filter, since the user part is our callId). - Dispatch rule
dispatch_rule_calleewithroom_prefix: "call-", sosip:{callId}@…→ roomcall-{callId}. [VERIFY] callee rule uses the SIP user part. - (Ph 3) Outbound trunk
openai(TLS + SRTP).
10. Observability & testing
- Logs: JSON, with
callId,accountId,atSessionId,campaignIdon every line. - Metrics: active calls, waiting callers, answer/missed rate, ring-to-accept time, callback latency, campaign dials/sec and answer rate, outbox backlog, job failures.
- Alerts: SIP down, callback errors > N%, zero inbound in business hours, outbox backlog, campaign stalled.
- PoC checklist = SPEC §10 Phase 0 steps 1–5.
- Unit: flow validator/executor (node → XML), routing claim race, waiting order, campaign dispatcher (window, cps, retries, opt-out), outbox retry, signatures.
- Integration: AT sandbox/real number, LiveKit dev, browser test page, Textify webhook mock.
11. Tech stack & tools (one place)
| Layer | Tool | Used for |
|---|---|---|
| Engine | Java 21, Spring Boot 3.x (Web, Validation, Data JPA/JDBC, Scheduling) | All engine logic |
| DB | PostgreSQL 16 + Flyway | State, outbox, jobs, campaign queue |
| Cache/locks | Redis 7 | Presence, claims, waiting lists, campaign concurrency (shared with LiveKit) |
| Telephony | Africa's Talking Voice API: callbacks + XML (Say, Play, GetDigits, Dial, Record, Reject), outbound call API, recordings; optional africastalking-java SDK for REST calls (XML built by our own small builder) |
PSTN both directions |
| Media | LiveKit server, LiveKit SIP, LiveKit Ingress (hold audio); JVM server SDK io.livekit:livekit-server; LiveKit JS SDK in Textify's agent UI |
Rooms, SIP from AT, browser agents, data messages |
| AI | OpenAI REST (transcription, summaries) from Java RestClient; Ph 3: OpenAI Realtime over SIP + Java WebSocket client |
Summaries; live AI |
| Storage | S3-compatible (Linode Object Storage / MinIO) via AWS SDK v2 | Recordings, audio prompts |
| HTTP clients | Spring RestClient (sync, virtual threads), java.net.http.WebSocket (Ph 3) |
AT, Textify, OpenAI |
| Security | API keys (hashed, BCrypt/SHA-256), HMAC-SHA256 webhooks, LiveKit WebhookReceiver, OpenAI webhook verify, ufw firewall, CrowdSec |
Service auth |
| Edge | Caddy (or nginx) | TLS, reverse proxy for API, hooks, WSS |
| Deploy | Docker Compose on a dedicated VPS | All services |
| Observability | JSON logs (Logback), Spring Actuator + Micrometer (Prometheus format) | Logs, metrics, health |
| Testing | JUnit 5, Testcontainers (Postgres, Redis), WireMock (AT/Textify/OpenAI mocks), browser test page | Unit + integration |
| Not used | MQTT, Kafka, RabbitMQ, OAuth/Keycloak, Python | Not needed (§7, Appendix A) |
12. Repository layout
textify-voice/
├── docs/ SPEC.md, ARCHITECTURE.md
├── voice-engine/ Spring Boot (Java 21)
├── deploy/ docker-compose.yaml, livekit.yaml, sip, ingress, Caddyfile, firewall.sh, .env.example
├── audio/ hold, consent, no-balance prompts (sw/en)
└── agent-test/ minimal HTML page with LiveKit JS for PoC (real UI lives in Textify)
13. Open items to verify
- AT: Dial to
sip:{callId}@host, IPs, codecs/DTMF, billing, recording of Dial-to-SIP, CPS/concurrency, outbound per-second vs per-minute, multi-number call API, callback fields and signature (SPEC §11). - AT: does XML continue after a
Dialleg ends (for voicemail after timeout)? - LiveKit: callee dispatch rule with arbitrary user part; inbound trunk without number filter; ingress URL input for hold audio.
- LiveKit JVM SDK (
io.livekit:livekit-server) method names for tokens, data messages, ingress, participants. - Ph 3: LiveKit → OpenAI SIP interop (TLS/SRTP), OpenAI Realtime vs Live API, Swahili quality and cost.
Appendix A: Phase 3 AI details (Java-only)
- LiveKit has no Java SDK that joins rooms as an audio participant, and Spring AI / LangChain4j are text-only, so OpenAI joins the room as a SIP participant instead.
- Engine:
CreateSIPParticipant(outbound trunk "openai", to = OpenAI project SIP URI, header X-Voice-Call-Id)→ OpenAI webhookrealtime.call.incoming(or Live APIlive.transport.incoming) → match header to a call inAI_DIALING→POST /v1/realtime/calls/{id}/acceptwith instructions (Swahili first, short answers, never invent prices), voice, tools (transfer_to_human,take_message,end_call) → WebSocketwss://api.openai.com/v1/realtime?call_id=…for tool calls/transcripts/usage →POST …/hangupafter handover. - Firewall: out TCP 5061 to
sip.api.openai.com; RTP with OpenAI media CIDRs13.79.45.80/28, 23.98.140.64/28, 40.67.149.176/28, 40.83.204.240/28(OpenAI SIP guide, Oct 2026; re-check). - Trade-off: OpenAI voices only. If Swahili voice isn't good enough → fallback: small Python LiveKit Agents worker with another TTS, calling engine internal endpoints; no other change.