- Streaming AI chat via Vercel AI SDK v6 UI message stream — tool-based
generative UI (pick_branch, list_departments, show_clinic_timings,
show_doctors, show_doctor_slots, suggest_booking). Typing indicator,
markdown suppressed, text parts hidden when widgets are rendered.
- Centralized Preact store (store.tsx) for visitor, leadId, captchaToken,
bookingPrefill, doctors roster, branches, selectedBranch — replaces prop
drilling across chat/book/contact tabs.
- Cloudflare Turnstile captcha gate rendered via light-DOM portal so it
renders correctly inside the shadow DOM (Turnstile CSS doesn't cross
shadow boundaries).
- Lead dedup helper (findOrCreateLeadByPhone, 24h phone window) shared
across chat-start / book / contact so one visitor == one lead. Booking
upgrades existing lead status NEW → APPOINTMENT_SET via updateLeadStatus.
- Pre-chat name+phone form captures the visitor; chat transcript logged
to leadActivity records after each stream.
- Booking wizard gains a branch step 0 (skipped for single-branch
hospitals); departments + doctors filtered by selectedBranch. Chat slot
picks prefill the booking details step and lock the branch.
- Window-level captcha gate, modal maximize mode, header badge showing
selected branch, widget font inherits from host page (fix :host { all:
initial } override).
- 23 FA Pro 7.1 duotone icons bundled — medical departments, nav, actions,
hospital/location-dot for branch context.
- main.ts: resolve public/ from process.cwd() so widget.js serves in both
dev and prod. tsconfig: exclude widget-src/public/data from server tsc.
- captcha.guard: switch from reCAPTCHA v3 to Cloudflare Turnstile verify.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Helix Engage Server — Sidecar Backend
NestJS sidecar that bridges Ozonetel telephony APIs with the FortyTwo platform. Handles agent auth, call control, disposition, missed call queue, worklist aggregation, AI enrichment, and live call assist.
Owner: Karthik
Architecture
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ helix-engage │ │ helix-engage-server │ │ FortyTwo Platform │
│ React frontend │────▶│ (this repo) │────▶│ GraphQL API │
│ │ │ Port 4100 │ │ Port 4000 │
└─────────────────────┘ └──────────────────────┘ └─────────────────────┘
│
│ Ozonetel CloudAgent APIs
▼
┌──────────────┐
│ Ozonetel │
│ in1-ccaas-api│
└──────────────┘
This server has no database. All persistent data flows to/from the FortyTwo platform via GraphQL. Ozonetel is the telephony provider (CloudAgent APIs).
Three repos:
| Repo | Purpose | Owner |
|---|---|---|
helix-engage |
React frontend | Mouli |
helix-engage-server (this) |
NestJS sidecar | Karthik |
helix-engage-app |
FortyTwo SDK app — entity schemas | Shared |
Getting Started
npm install
npm run start:dev # http://localhost:4100 (watch mode)
npm run build # Production build
npm run start:prod # Run production build
Environment Variables
| Variable | Purpose | Default |
|---|---|---|
PORT |
Server port | 4100 |
CORS_ORIGIN |
Allowed frontend origin | http://localhost:5173 |
PLATFORM_GRAPHQL_URL |
FortyTwo GraphQL endpoint | http://localhost:4000/graphql |
PLATFORM_API_KEY |
FortyTwo API key (server-to-server) | — |
EXOTEL_API_KEY |
Ozonetel API key | — |
EXOTEL_API_TOKEN |
Ozonetel API token | — |
EXOTEL_ACCOUNT_SID |
Ozonetel account SID | — |
OZONETEL_AGENT_ID |
Default agent ID | agent3 |
OZONETEL_AGENT_PASSWORD |
Default agent password | — |
OZONETEL_SIP_ID |
Default SIP extension | 521814 |
OZONETEL_DID |
Inbound DID number | 918041763265 |
OZONETEL_CAMPAIGN_NAME |
Default campaign | Inbound_918041763265 |
MISSED_QUEUE_POLL_INTERVAL_MS |
Missed call ingestion interval | 30000 |
OPENAI_API_KEY |
For AI enrichment / call assist | — |
ANTHROPIC_API_KEY |
Alternative AI provider | — |
DEEPGRAM_API_KEY |
Live transcription (STT) | — |
Module Structure
src/
├── ozonetel/ # ⚡ Ozonetel telephony — WHERE MOST WORK HAPPENS
│ ├── ozonetel-agent.controller.ts # REST endpoints for agent operations
│ ├── ozonetel-agent.service.ts # Ozonetel API wrapper (token, CDR, abandon calls)
│ ├── ozonetel-agent.module.ts # Module wiring
│ └── kookoo-ivr.controller.ts # IVR callback handler (XML responses)
│
├── worklist/ # Agent task queue + missed call queue
│ ├── worklist.controller.ts # GET /api/worklist, missed queue endpoints
│ ├── worklist.service.ts # Aggregates leads + missed calls + follow-ups
│ ├── missed-queue.service.ts # Ingestion, dedup, auto-assignment
│ ├── missed-call-webhook.controller.ts # Webhook receiver
│ └── kookoo-callback.controller.ts # Kookoo webhook
│
├── call-events/ # Real-time call event processing
│ ├── call-events.service.ts # Incoming call handling, AI enrichment, disposition logging
│ ├── call-events.gateway.ts # WebSocket push to frontend (Socket.IO)
│ └── call-lookup.controller.ts # Reverse phone lookup + AI enrichment
│
├── platform/ # FortyTwo platform GraphQL client
│ ├── platform-graphql.service.ts # query() for server-to-server, queryWithAuth() for user JWT
│ ├── platform.types.ts # Lead, Call, Activity types
│ └── platform.module.ts
│
├── search/ # Cross-entity search
│ └── search.controller.ts # GET /api/search — leads + patients + appointments
│
├── call-assist/ # Live call assistance
│ └── (Socket.IO namespace /call-assist, Deepgram STT, AI suggestions)
│
├── ai/ # AI enrichment (lead summaries, suggested actions)
├── auth/ # User auth proxy
├── graphql-proxy/ # GraphQL passthrough to platform
├── health/ # Health check endpoint
├── config/
│ └── configuration.ts # All env var loading
├── app.module.ts # Root module — imports all feature modules
└── main.ts # NestJS bootstrap (port 4100, CORS)
API Endpoints
Ozonetel Agent (/api/ozonetel/)
| Method | Path | Purpose |
|---|---|---|
| POST | /agent-login |
Agent login to Ozonetel |
| POST | /agent-logout |
Agent logout |
| POST | /agent-state |
Change state (Ready/Pause) + auto-assign missed call on Ready |
| POST | /agent-ready |
Force ready (logout + login) |
| POST | /dispose |
Submit call disposition + update missed call status + auto-assign next |
| POST | /dial |
Manual outbound dial |
| POST | /call-control |
CONFERENCE, HOLD, UNHOLD, MUTE, UNMUTE, KICK_CALL |
| POST | /recording |
Pause/unpause recording |
| GET | /missed-calls |
Raw Ozonetel abandon calls |
| GET | /call-history?date= |
CDR for a date |
| GET | /performance?date= |
Aggregated agent metrics |
Worklist (/api/worklist/)
| Method | Path | Purpose |
|---|---|---|
| GET | / |
Agent's worklist (missed calls + follow-ups + leads) |
| GET | /missed-queue |
Missed calls grouped by callback status |
| PATCH | /missed-queue/:id/status |
Update callback status on a missed call |
Other
| Method | Path | Purpose |
|---|---|---|
| GET | /api/search?q= |
Cross-entity search (leads, patients, appointments) |
| POST | /api/call/lookup |
Reverse phone lookup + AI enrichment |
| GET | /api/health |
Health check |
| POST | /graphql |
GraphQL proxy to platform |
Troubleshooting Guide — Where to Look
"Agent can't log in to Ozonetel"
File: src/ozonetel/ozonetel-agent.controller.ts → agentLogin()
Service: src/ozonetel/ozonetel-agent.service.ts → loginAgent()
Uses HTTP Basic auth to Ozonetel's AgentAuthenticationV2 endpoint. "Already logged in" responses have status: "error" but are not real errors. Check OZONETEL_AGENT_ID and OZONETEL_AGENT_PASSWORD env vars.
"Disposition failing / ACW not releasing"
File: src/ozonetel/ozonetel-agent.controller.ts → dispose()
Service: src/ozonetel/ozonetel-agent.service.ts → setDisposition()
All dispositions currently map to 'General Enquiry' (campaign limitation). Uses autoRelease: 'true' to end ACW. If agent stays in ACW, the Ozonetel campaign's wrapup time (8s) may not have elapsed.
"Missed calls not being ingested"
File: src/worklist/missed-queue.service.ts → ingest()
Runs on a 30s interval (onModuleInit). Polls Ozonetel abandonCalls API for the last 5 minutes. Look for log lines with [MissedQueueService]. Common issues: Ozonetel token expired (55-min cache), platform API key missing, phone number format mismatch.
"Auto-assignment not working"
File: src/worklist/missed-queue.service.ts → assignNext()
Triggered from two places: dispose() and agent-state() in ozonetel-agent.controller.ts. Queries platform for oldest PENDING_CALLBACK call with empty agentName. Uses a mutex to prevent race conditions. If no calls are assigned, check that callbackstatus field exists on the Call entity (custom field, all-lowercase in GraphQL).
"Worklist returning empty"
File: src/worklist/worklist.service.ts
Three parallel queries: getMissedCalls(), getPendingFollowUps(), getAssignedLeads(). All filter by agentName. If the agent name from the JWT doesn't match what's stored in lead/call records, results will be empty. Check resolveAgentName() in worklist.controller.ts.
"Call events / webhooks not arriving"
File: src/call-events/call-events.service.ts
Ozonetel sends webhooks to the sidecar. Check that the webhook URL is configured in the Ozonetel dashboard and that the sidecar is reachable from the internet (Caddy reverse proxy on the VPS).
"AI enrichment / call assist broken"
Files: src/ai/, src/call-assist/
Live transcription uses Deepgram Nova STT via raw WebSocket. AI suggestions use OpenAI gpt-4o-mini. Check DEEPGRAM_API_KEY and OPENAI_API_KEY env vars. The call-assist gateway uses Socket.IO namespace /call-assist.
"Search not finding records"
File: src/search/search.controller.ts
Runs three parallel GraphQL queries (leads, patients, appointments), filters client-side. Requires minimum 2 characters. Uses the user's JWT (passed from frontend auth header).
Key Technical Patterns
Two Auth Models
- User JWT passthrough —
platform.queryWithAuth(query, vars, authHeader)— for user-facing endpoints (worklist, search). The frontend sends its JWT and the sidecar forwards it. - Server API key —
platform.query(query, vars)— for server-to-server operations (missed call ingestion, auto-assignment). UsesPLATFORM_API_KEY.
Ozonetel Token Caching
ozonetel-agent.service.ts → getToken() caches the bearer token for 55 minutes (tokens expire at 60 min). All CloudAgent API calls use this cached token.
Custom Field Naming
Fields added via the FortyTwo admin portal use all-lowercase GraphQL names:
callbackstatus(notcallbackStatus)callsourcenumber(notcallSourceNumber)missedcallcount(notmissedCallCount)callbackattemptedat(notcallbackAttemptedAt)
App-defined (managed) fields keep camelCase: callStatus, agentName, etc.
Error Handling Pattern
Ozonetel endpoints return { status: 'error', message } instead of throwing — this prevents UI from blocking on telephony failures. The frontend catches errors silently on disposition and recording.
Deployment
npm run build
# Then tar + scp + docker cp + restart (see deploy script in project docs)
The sidecar runs inside a Docker container (fortytwo-staging-sidecar-1) on the staging VPS.
Git Workflow
dev— active developmentmaster— stable baseline