Developer Guide
Central reference for conventions, patterns, and rules across the Oktivo codebase. Read this before writing any code.
1. UID Convention No MongoDB ObjectId Dependency
This codebase has zero dependency on MongoDB _id / ObjectId. Every entity uses a custom string UID as its primary identifier. This makes the system database-agnostic if we migrate to PostgreSQL, DynamoDB, or any other store, nothing breaks.
Rules
- Never use
_id,ObjectId,findById, orfindByIdAndUpdatein application code. - Always query by the entity's UID field (e.g.,
orgUID,userUID,contactUID). - Never expose
_idin API responses. Return the UID field instead. - Foreign keys are string UIDs, not ObjectId references. No
Schema.Types.ObjectId+ref. - New entities must have a UID field use
generateUID("prefix")fromsrc/utils/uid.utils.ts.
UID Format
{prefix}_{nanoid(16)}- 3-4 char prefix + underscore + 16 random chars (nanoid, crypto-secure)
- ~95 bits of entropy per ID, collision-safe at scale
- Prefix makes IDs self-describing in logs, URLs, and debugging
Entity UID Map
| Entity | UID Field | Prefix | Example |
|---|---|---|---|
| Organization | orgUID | org_ | org_V1StGXR8_Z5jdHi |
| User | userUID | usr_ | usr_k2P9mNx3Qw7LbRt |
| SIPTrunk | trunkUID | trk_ | trk_Hj4KmNpQrStUvWx |
| PhoneNumber | phoneUID | phn_ | phn_Yz1Ab2Cd3Ef4Gh5 |
| PhoneNumberAssignment | assignmentUID | asgn_ | asgn_R4s5T6u7V8w9X0y |
| Campaign | campaignUID | (legacy, no prefix) | A1b2C3d4E5f6 |
| Contact | contactUID | (legacy, no prefix) | X7y8Z9a0B1c2 |
| Agent | agent_id | (legacy, no prefix) | M3n4O5p6Q7r8 |
| CreditTransaction | transactionId | (legacy, no prefix) | S9t0U1v2W3x4Y5z6 |
Legacy entities (Campaign, Contact, Agent, CreditTransaction) were created before the prefix convention. They use plain nanoid without prefix. Do not change them backward compatibility.
Foreign Key Convention
References between entities use the target's UID as a plain string:
// ✅ Correct string UID reference
orgUID: { type: String, index: true }
// ❌ Wrong ObjectId reference
organizationId: { type: Schema.Types.ObjectId, ref: "Organization" }Common foreign key fields:
orgUIDreferences OrganizationownerUID,createdByUID,performedByUIDreferences UsercustomerUIDreferences User (customer role)trunkUIDreferences SIPTrunk
Adding a New Entity
Add a generator in
src/utils/uid.utils.ts:typescriptexport function generateInvoiceUID(): string { return `inv_${nanoid(UID_LENGTH)}`; }Add the UID field to your model schema:
typescriptinvoiceUID: { type: String, unique: true, required: true }Generate the UID at creation time (in controller or repository):
typescriptconst invoiceUID = generateInvoiceUID();Query by UID, never by
_id:typescript// ✅ await Invoice.findOne({ invoiceUID }); // ❌ await Invoice.findById(someId);
Auth Context
JWT tokens carry userUID and orgUID. The auth middleware sets:
req.userUIDcurrent userreq.orgUIDcurrent organizationreq.userIdalias (same value as userUID)req.roleuser role
Never use req.organizationId it does not exist.
Backfill Script
If you add a UID field to an existing collection, write a backfill in src/scripts/backfill-uids.ts to populate it for existing documents. The script is idempotent and safe to run multiple times.
npx tsx src/scripts/backfill-uids.tsAgent / Analysis (Python) Services
The Python services receive UIDs via HTTP payloads (contactUID, orgUID, etc.) and use agent_id for direct MongoDB lookups. They follow the same rule no _id dependency in application logic.
2. Environment Variables & AWS Secrets Manager
Why We Migrated
Previously, each service had its own .env file (Agent/.env, Analysis/.env, client_backend/.env). This caused:
- Env drift between developers (3 devs, 3 different
.envfiles) - Manual syncing when new vars were added easy to miss
- Risk of deploying to stage/prod with missing env vars
- Secrets scattered across files with no audit trail
Now all secrets live in AWS Secrets Manager with a unified structure that's identical locally and in the cloud.
File Structure
oktivo_backend/
.env ← controls HOW secrets load + AWS credentials
env/
shared.env ← mirrors oktivo/{env}/shared in Secrets Manager
agent.env ← mirrors oktivo/{env}/agent
analysis.env ← mirrors oktivo/{env}/analysis
client-backend.env ← mirrors oktivo/{env}/client-backend
*.env.example ← templates with key names only (committed to git)The old per-service .env files (Agent/.env, Analysis/.env, client_backend/.env) have been removed. The env/ directory is gitignored only .example files are committed.
How It Works
┌──────────────────────────────────────────────────────────────┐
│ Startup Flow │
│ │
│ Root .env → USE_LOCAL_ENV=true? │
│ YES → load env/shared.env + env/{service}.env │
│ NO → fetch from AWS Secrets Manager: │
│ oktivo/{DEPLOY_ENV}/shared │
│ oktivo/{DEPLOY_ENV}/{service} │
│ │
│ Both paths → inject into os.environ / process.env │
│ → docker-compose environment: overrides win │
│ → then config.py / config/index.ts reads as usual│
└──────────────────────────────────────────────────────────────┘| Service | Loader File | Loads These Secrets |
|---|---|---|
| Agent | shared/secrets.py → called in Agent/config.py | shared + agent |
| Analysis | shared/secrets.py → called in Analysis/config.py | shared + analysis |
| client_backend | src/config/secrets.ts → called in server.ts | shared + client-backend |
Key technical detail for client_backend: server.ts uses dynamic await import() for all modules AFTER loadSecrets() completes. This ensures process.env is fully populated before config/index.ts evaluates. Static imports would be hoisted before the await, causing empty config values.
Secret Naming Convention in AWS
oktivo/{environment}/shared ← MONGO_URI, WEBHOOK_SECRET, LIVEKIT_*, LANGFUSE_*, OPENAI_API_KEY
oktivo/{environment}/agent ← DEEPGRAM_API_KEY, ELEVEN_API_KEY, TWILIO_*, SARVAM_API_KEY
oktivo/{environment}/analysis ← BACKEND_URL
oktivo/{environment}/client-backend ← ACCESS_TOKEN_SECRET, RESEND_API_KEY, S3_RECORDING_BUCKET, VOBIZ_*Environments: dev, stage, prod
The shared secret holds vars used by 2+ services. Service-specific secrets hold the rest. This avoids duplication and ensures vars like WEBHOOK_SECRET are always identical across services.
AWS Credentials How They Flow
AWS credentials (for S3, SES, Secrets Manager) are NOT stored in env files or Secrets Manager. They come from the AWS SDK default credential chain:
| Environment | How AWS Credentials Are Provided |
|---|---|
| Local dev (USE_LOCAL_ENV=true) | Not needed secrets come from local files |
| Local dev (USE_LOCAL_ENV=false) | AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY in root .env, passed to containers via docker-compose environment: |
| Stage / Prod | EC2 instance profile or ECS task role no credentials in env at all |
This means S3 and SES also use the same IAM credentials automatically. No separate S3 keys needed.
Getting Started (New Developer)
Step 1: Get your AWS IAM credentials
Ask the admin (dheeraj) to create your IAM user in the Oktivo_Developers group. You'll receive:
- AWS Console sign-in URL
- Console password
- Access Key ID and Secret Access Key (for CLI)
Your IAM user has access to:
oktivo/dev/*secrets in Secrets Manager (read/write)oktivo-recordings-dev-*S3 bucket (read/write)
You cannot access stage or prod secrets.
Step 2: Configure AWS CLI
aws configure --profile oktivo-devEnter:
AWS Access Key ID: <your-key>
AWS Secret Access Key: <your-secret>
Default region name: ap-south-1
Default output format: jsonSet the profile active:
# PowerShell (Windows)
$env:AWS_PROFILE = "oktivo-dev"
# To make it permanent:
[System.Environment]::SetEnvironmentVariable("AWS_PROFILE", "oktivo-dev", "User")# Bash (Mac/Linux)
export AWS_PROFILE=oktivo-dev
# Add to ~/.bashrc or ~/.zshrc to make permanentVerify:
aws secretsmanager list-secrets --region ap-south-1 --profile oktivo-devStep 3: Set up local env files
# Copy example files
cp .env.example .env
cp env/shared.env.example env/shared.env
cp env/agent.env.example env/agent.env
cp env/analysis.env.example env/analysis.env
cp env/client-backend.env.example env/client-backend.envFill in the real values. You can get them from:
- A teammate's env files
- AWS Console → Secrets Manager →
oktivo/dev/shared(click "Retrieve secret value")
Step 4: Run
docker compose -f docker-compose.dev.yml up --buildVerify in logs you should see:
[secrets] ✅ Loaded 16 vars from file: shared.env
[secrets] ✅ Loaded 18 vars from file: client-backend.env
[secrets] 📁 Local env files: injected 34 env vars totalSwitching Between Local Files and Secrets Manager
The root .env file controls the mode:
# Mode 1: Local env files (default no AWS needed)
USE_LOCAL_ENV=true
DEPLOY_ENV=dev
AWS_REGION=ap-south-1
# Mode 2: AWS Secrets Manager
USE_LOCAL_ENV=false
DEPLOY_ENV=dev
AWS_REGION=ap-south-1
AWS_ACCESS_KEY_ID=<your-iam-key>
AWS_SECRET_ACCESS_KEY=<your-iam-secret>When using Secrets Manager mode, logs will show:
[secrets] ✅ Loaded 16 vars from secret: oktivo/dev/shared
[secrets] ✅ Loaded 18 vars from secret: oktivo/dev/client-backend
[secrets] 🔐 Secrets Manager: injected 34 env vars totalBoth modes produce the same result the only difference is where the values come from.
Adding a New Environment Variable
Decide which file it belongs to:
- Used by 2+ services →
shared - Used by one service → that service's file
- Used by 2+ services →
Add to AWS Secrets Manager (dev environment first):
- AWS Console → Secrets Manager →
oktivo/dev/shared(or the service-specific secret) - Click "Retrieve secret value" → Edit → add the key-value pair → Save
- AWS Console → Secrets Manager →
Add to the local env file AND the
.examplefile:bash# env/shared.env (real value gitignored) MY_NEW_KEY=actual_value # env/shared.env.example (placeholder committed to git) MY_NEW_KEY=Add to code:
typescript// client_backend: src/config/index.ts myNewKey: process.env.MY_NEW_KEY?.trim() || "default_value",python# Agent/Analysis: config.py MY_NEW_KEY = os.getenv("MY_NEW_KEY")Add to stage and prod secrets before merging to those branches.
CI/CD Secrets Sync Check (PR Protection)
A GitHub Actions workflow (secrets-sync-check.yml) runs automatically on every PR to stage or main. It prevents deploying code that references secrets missing in the target environment.
How it works:
- PR to
stage: compares all key names inoktivo/dev/*vsoktivo/stage/* - PR to
main: compares all key names inoktivo/stage/*vsoktivo/prod/*
It checks all 4 secrets: shared, agent, analysis, client-backend.
If any keys exist in the source environment but are missing in the target:
- The PR check fails (red X)
- A comment is posted on the PR listing exactly which keys are missing and in which secret
- The PR cannot be merged until the missing keys are added to the target environment
Example PR comment when keys are missing:
🚨 Secrets Sync Check Failed
Comparing dev → stage secret keys in AWS Secrets Manager.
### ❌ shared Missing keys in stage
Source (dev): 16 keys | Target (stage): 14 keys
These keys exist in dev but are missing in stage:
- MY_NEW_KEY
- ANOTHER_KEY
Add them to oktivo/stage/shared before merging.
### ✅ agent In sync (14 keys)
### ✅ analysis In sync (1 keys)
### ✅ client-backend In sync (18 keys)The workflow authenticates to AWS using OIDC federation (no stored AWS credentials in GitHub). The GitHub Actions role has read-only access to all secrets across all environments.
Production / Stage Deployment
No local env files are deployed to stage or prod. The docker-compose.yml (production) passes only two env vars to containers:
DEPLOY_ENV=stageorDEPLOY_ENV=prodAWS_REGION=ap-south-1
Containers fetch all other config from Secrets Manager at startup using the EC2 instance profile (or ECS task role). The deploy workflow (deploy.yml) writes these to .env on the EC2 instance during deployment.
For stage/prod to work, the EC2 instance must have an IAM role with:
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue"],
"Resource": "arn:aws:secretsmanager:ap-south-1:ACCOUNT_ID:secret:oktivo/{env}/*"
}IAM Permissions Summary
| Role | Secrets Access | S3 Access |
|---|---|---|
| Developers (sahil, daivik, dheeraj) | Read/Write oktivo/dev/* | oktivo-recordings-dev-* only |
| GitHub Actions (OIDC) | Read-only oktivo/dev/*, oktivo/stage/*, oktivo/prod/* | None |
| Stage EC2 / ECS | Read oktivo/stage/* | oktivo-recordings-stage-* |
| Prod EC2 / ECS | Read oktivo/prod/* | oktivo-recordings-* (prod bucket) |
Developers cannot access stage or prod secrets. Stage/prod credentials are managed via IAM roles attached to the infrastructure, not stored anywhere.
Docker-Compose Environment Overrides
Some vars are set directly in docker-compose.dev.yml via the environment: block. These override any values from env files or Secrets Manager:
| Var | Value | Why |
|---|---|---|
PORT | 3000 | Fixed for local dev |
REDIS_URL | redis://redis:6379 | Docker internal network |
ANALYSIS_URL | http://analysis:8000 | Docker internal network |
MONGO_URI | mongodb://mongodb:27017/?replicaSet=rs0 | Local MongoDB container |
These are infrastructure-specific values that differ between local Docker and production. They're not secrets they're service discovery.
Rules
- Never commit real secrets. Only
.examplefiles go in git. Theenv/directory and.envare gitignored. - Never use
process.envdirectly in controllers/services. Always go throughconfig/index.ts(TypeScript) orconfig.py(Python). - Same key names in local
env/*.envfiles and AWS Secrets Manager. The 4-file structure is identical. - Docker-compose
environment:overrides always win over env files and Secrets Manager values. - The CI check blocks PRs if stage/prod secrets are missing keys that exist in the source environment.
- AWS credentials are never stored in env files or Secrets Manager. They come from the AWS SDK default credential chain (IAM user for local dev, instance role for stage/prod).
See ENV-CONFIG-GUIDE.md for .env syntax rules and config patterns.
3. Email Service
Modular, provider-agnostic email service with BullMQ queue and full audit logging.
See EMAIL-SERVICE.md for architecture, flow diagrams, and all email triggers.
Quick rules:
- Use
email.actions.tsfunctions to trigger emails from controllers - All email sends are fire-and-forget (wrapped in try/catch, never break the main flow)
- Low-balance alerts use Redis flags to prevent spam
- Critical emails (password reset, verification) use
sendImmediate()to bypass the queue
4. Inter-Service Webhook Authentication
All server-to-server webhook routes in client_backend are protected by the verifyInternalWebhook middleware. This validates the x-api-secret header against WEBHOOK_SECRET using timing-safe comparison.
Protected Routes
| Route | Caller | Purpose |
|---|---|---|
POST /transcription-sync | Agent | Stores call transcription on Contact after call ends |
POST /analysis-sync | Analysis | Stores analysis results (sentiment, interest, summary) on Contact |
POST /start-recording | Agent | Starts LiveKit egress recording after SIP call is answered |
POST /contact-observability | Agent | Stores usage metrics (LLM, TTS, STT) on Contact after call ends |
POST /update-call-status | Legacy (see note below) | Updates contact status + cleans up Redis active call sets |
How It Works
Every request to these routes must include:
x-api-secret: <WEBHOOK_SECRET>The middleware (src/middleware/webhook.middleware.ts → verifyInternalWebhook) checks:
WEBHOOK_SECRETis configured in env returns500if notx-api-secretheader is present returns401if missing- Header value matches
WEBHOOK_SECRETviacrypto.timingSafeEqualreturns401if mismatch
Calling From Python Services
Both Agent and Analysis services load the secret from their .env as WEBHOOK_SECRET and send it on every outbound HTTP call to client_backend:
# Agent (config.py)
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET")
# Usage
headers = {"x-api-secret": WEBHOOK_SECRET}
async with session.post(f"{BACKEND_URL}/transcription-sync", json=payload, headers=headers)# Analysis (config.py)
WEBHOOK_SECRET = os.getenv("WEBHOOK_SECRET")
# Usage
headers = {"x-api-secret": WEBHOOK_SECRET}
await client.post(f"{BACKEND_ENDPOINT}/analysis-sync", json=payload, headers=headers)Env Setup
All three services must share the same WEBHOOK_SECRET value. It lives in env/shared.env (local) or oktivo/{env}/shared (Secrets Manager).
Note on /update-call-status
This is a legacy route from the old Server backend. It updates contact status (in_progress → done/failed), sets callStatus and disconnectReason, and removes the phone from Redis active call sets.
In the current architecture, most of this work is handled by the /livekit-webhook handler (which processes participant_left and room_finished events with LiveKit's own signature verification). The /update-call-status route is kept for backward compatibility the legacy Server code and load-testing scripts still reference it. It should not be used for new integrations.
Not Covered by This Middleware
| Route | Auth Method | Reason |
|---|---|---|
POST /livekit-webhook | LiveKit signature verification (raw body) | LiveKit has its own webhook signing; registered before express.json() in app.ts |
POST /payments/webhook/:orgUID | Cashfree provider verification | Third-party payment provider with its own signature scheme |
POST /setup/superadmins | verifyWebhookSecret (uses SUPERADMIN_API_SECRET) | Separate secret for superadmin management scripts |
5. API Response Decorator Required for All New Endpoints
All API endpoints must use the response decorator functions from src/utils/apiResponse.ts instead of calling res.json() directly. This ensures every response follows a consistent structure that frontend consumers can rely on.
Response Structure
Success:
{
"success": true,
"message": "Human-readable message",
"data": { /* payload */ }
}Error:
{
"success": false,
"message": "Human-readable error message",
"data": null,
"errors": [{ "field": "email", "message": "Invalid format" }]
}The errors array is optional and only included when detailed validation errors are provided.
Functions
import { apiSuccess, apiError } from "../utils/apiResponse";
// apiSuccess<T>(res, data, message?, statusCode?)
// Defaults: message = "Success", statusCode = 200
// apiError(res, message, statusCode?, errors?)
// Defaults: statusCode = 500Usage Examples
Success response:
apiSuccess(res, { user, token }, "Login successful");
// → 200 { success: true, message: "Login successful", data: { user, token } }Success with custom status:
apiSuccess(res, { invoiceUID }, "Invoice created", 201);
// → 201 { success: true, message: "Invoice created", data: { invoiceUID } }Validation error:
apiError(res, "Validation failed", 400, [
{ field: "email", message: "Email is required" },
{ field: "name", message: "Name must be at least 2 characters" },
]);
// → 400 { success: false, message: "Validation failed", data: null, errors: [...] }Server error (default 500):
apiError(res, "Internal server error");
// → 500 { success: false, message: "Internal server error", data: null }Upstream service failure:
apiError(res, "Payment provider unavailable", 502);
// → 502 { success: false, message: "Payment provider unavailable", data: null }Rules
- Never use
res.json()orres.send()directly in controllers. Always useapiSuccess()orapiError(). - Always provide a meaningful message. The message is shown to users in toast notifications on the frontend.
- Use the
errorsarray for validation failures so the frontend can map errors to specific form fields. - Match HTTP status codes to the scenario: 200 success, 201 created, 400 validation, 401 auth, 403 forbidden, 404 not found, 429 rate limit, 500 server error, 502 upstream failure.
6. Rate Limiter Middleware
In-memory, per-user rate limiter available at src/middleware/rateLimiter.middleware.ts. Uses a Map<string, { count, resetTime }> keyed by req.userId (from JWT). Entries are lazily reset when the window expires no background timers.
Usage
import { createRateLimiter } from "../middleware/rateLimiter.middleware";Default configuration (10 requests per 60 seconds):
router.post("/my-route", verifyJwt, createRateLimiter(), myHandler);Custom configuration:
router.post(
"/expensive-operation",
verifyJwt,
createRateLimiter({ windowMs: 120_000, maxRequests: 5 }),
expensiveHandler,
);
// → 5 requests per 2-minute windowOptions
| Option | Type | Default | Description |
|---|---|---|---|
windowMs | number | 60000 | Time window in milliseconds |
maxRequests | number | 10 | Max requests allowed per window per user |
Response Headers
Every response (success or rate-limited) includes these headers:
| Header | Value | Example |
|---|---|---|
X-RateLimit-Limit | Max requests allowed | 10 |
X-RateLimit-Remaining | Requests left in current window | 7 |
X-RateLimit-Reset | Unix timestamp (seconds) when window resets | 1718900000 |
Rate Limit Exceeded
When a user exceeds maxRequests within the window, the middleware returns a 429 response via apiError():
{
"success": false,
"message": "Rate limit exceeded. Try again later.",
"data": null
}Applying to Routes
Place createRateLimiter() after verifyJwt in the middleware chain it needs req.userId from the JWT:
// ✅ Correct verifyJwt runs first, sets req.userId
router.post("/enhance-prompt", verifyJwt, createRateLimiter({ windowMs: 60000, maxRequests: 10 }), enhancePrompt);
// ❌ Wrong no userId available, all requests counted as "anonymous"
router.post("/enhance-prompt", createRateLimiter(), verifyJwt, enhancePrompt);Notes
- In-memory store rate limits reset on server restart. This is acceptable for the current single-instance deployment.
- Per-user isolation each user has an independent counter. User A's requests never affect User B's limit.
- No cleanup timer stale entries are lazily evicted on the next request from that user after the window expires.
7. Enhance Prompt API
AI-powered endpoint that transforms a raw agent prompt into a structured, comprehensive format using OpenAI GPT-4o-mini. Located at POST /enhance-prompt on the agent routes.
Endpoint
POST /enhance-promptMiddleware chain: verifyJwt → createRateLimiter({ windowMs: 60000, maxRequests: 10 }) → enhancePrompt
Any authenticated user can call this endpoint no role or feature-access restriction.
Request
{
"prompt": "You are a sales agent for Acme Corp selling widgets..."
}| Field | Type | Required | Notes |
|---|---|---|---|
prompt | string | Yes | Non-empty after trimming whitespace |
Success Response (200)
{
"success": true,
"message": "Prompt enhanced successfully",
"data": {
"enhancedPrompt": "## Objective\n\nYou are a sales agent for Acme Corp..."
}
}The enhancedPrompt is a structured text string containing sections: Objective, Response Guidelines, Variables, Beyond Context Guardrails, Language, Conversation Script, Subroutines, FAQs, and Sample Conversations.
Error Responses
| Status | Condition | Example Response |
|---|---|---|
400 | Prompt is empty, missing, or whitespace-only | { "success": false, "message": "Prompt is required", "data": null } |
401 | No valid JWT token provided | { "success": false, "message": "Access token is required" } |
429 | Rate limit exceeded (10 req/user/min) | { "success": false, "message": "Rate limit exceeded. Try again later.", "data": null } |
502 | OpenAI API call failed | { "success": false, "message": "Prompt enhancement service unavailable", "data": null } |
500 | Unexpected server error | { "success": false, "message": "Internal server error", "data": null } |
Authentication
Requires a valid JWT token via the verifyJwt middleware. The token must be sent in the Authorization header:
Authorization: Bearer <jwt_token>The middleware sets req.userId and req.userUID, which the rate limiter uses to track per-user request counts.
Rate Limiting
Limited to 10 requests per user per minute. See Section 6: Rate Limiter Middleware for header details (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset).
Frontend Usage
import { agentService } from "@/services/campaign.service";
const response = await agentService.enhancePrompt(rawPrompt);
const enhanced = response.data.data.enhancedPrompt;Add new sections below as conventions evolve.
8. Customer-Facing API Sanitization Required for All Preset/Config Endpoints
All API endpoints that return preset or agent configuration data to customers must apply the sanitizeConfigResponse middleware. This strips internal provider/model fields that customers should never see.
Why
Customers should not know which LLM, TTS, or STT providers we use internally. They see preset names, costs, voices, and languages not llm_provider: "openai" or stt_model: "nova-3". This is a business requirement for white-labeling and competitive protection.
What Gets Stripped
For customer role users, the middleware removes these fields from each preset:
| Field | Example Value | Why Hidden |
|---|---|---|
llm_provider | "openai", "google" | Internal LLM provider |
llm_model | "gpt-4o-mini", "gemini-2.5-flash" | Internal model name |
tts_provider | "cartesia", "elevenlabs" | Internal TTS provider |
tts_model | "sonic-3", "eleven_turbo_v2_5" | Internal TTS model |
stt_provider | "deepgram", "sarvam" | Internal STT provider |
stt_model | "nova-3", "saaras:v3" | Internal STT model |
realtime_model | true/false | Internal architecture flag |
system_prompt | "You are..." | Internal system prompt |
metadata | {...} | Internal extensible metadata |
Voice objects inside supported_voices are also sanitized internal fields like _id, __v, createdAt, updatedAt, orgUID, and tts_provider are removed. Customers only see: voice_id, name, sample_url, description, language, gender.
Admin, owner, and superadmin roles receive the full unmodified response.
How to Apply
import { sanitizeConfigResponse } from "../middleware/sanitize.middleware.js";
// Apply to any route that returns preset data to customers
router.get("/agent-config", verifyJwt, sanitizeConfigResponse, getAgentConfig);When to Apply
Apply sanitizeConfigResponse to any new endpoint that:
- Returns preset data (presets array with provider fields)
- Is accessible by customer-role users
The middleware is safe to apply broadly it only modifies responses when:
req.userRole === "customer"ANDbody.data.presetsexists in the response
If neither condition is met, the response passes through unchanged.
Rules
- Always apply
sanitizeConfigResponseto customer-facing config/preset endpoints. - Never return raw preset data to customers without sanitization.
- Admin CRUD endpoints (
/api/v2/config/*) don't need it they're behindrequireConfigEditPermissionwhich blocks customers. - Test with a customer JWT to verify fields are stripped. Use browser DevTools Network tab.
Location
src/middleware/sanitize.middleware.ts
9. Dynamic Agent Config Architecture Overview
Agent configuration (presets, voices, languages, features) is stored in MongoDB instead of a static JSON file. See DYNAMIC-AGENT-CONFIG.md for the full architecture, file changes, and verification guide.
Key Points for New Developers
Config hierarchy: There's a platform-wide default config (
orgUID = "__default__") managed by superadmin. Orgs withoutedit_system_configpermission use this default. Orgs with the permission can fork it into their own config.Preset-by-reference: Agents store only
preset_id. Provider/model fields are resolved at read/dispatch time viaPresetResolver. Never storestt_provider,llm_provider,tts_provider, orcost_per_minon Agent documents.Voice separation: Voices are in a separate
Voicecollection, referenced byvoice_idstrings in presets. ThePresetResolverpopulates full voice objects at API response time.Auto-seed: The
__default__SystemConfig is auto-seeded fromshared/agent_config.jsonon first access. After that, the JSON file is never read again.Frontend field mapping: The backend uses
preset_idbut the frontend expectsid. ThegetAgentConfigcontroller mapspreset_id → idin the response.
Files to Know
| File | Purpose |
|---|---|
src/services/config.service.ts | All config CRUD, versioning, fork/revert, auto-seed |
src/utils/preset.resolver.ts | Resolves preset_id → full details with voice population |
src/middleware/sanitize.middleware.ts | Strips provider fields for customer responses |
src/middleware/configPermission.middleware.ts | Gates config mutations behind permission + role |
src/controllers/config.controller.ts | GET /agent-config customer-facing config endpoint |
src/controllers/systemConfig.controller.ts | Admin CRUD + superadmin default config handlers |
src/models/SystemConfig.ts | SystemConfig + Preset + Voice schemas |
9. Dispatch Utilities DRY Helpers for Call Dispatch
All call dispatch logic (campaign calls, test calls, web calls, landing page calls) uses shared utilities from src/utils/dispatch.utils.ts. Never build dispatch metadata inline always use these helpers.
Why
Previously, each dispatch site (4 in agent.controller.ts, 1 in calling.service.ts, 2 in landing.controller.ts) had its own copy of:
- Agent config building (20+ fields)
- Dynamic values merging
- Dynamic tools fetching
- MCP server resolution
- IVR action extraction
- pronunciation_dict Map→Object conversion
This caused fields to be inconsistently included across dispatch paths. The shared utilities ensure every dispatch sends the same set of agent configuration fields.
Available Helpers
| Function | Purpose |
|---|---|
buildAgentConfig({ agentDoc, presetConfig }) | Builds the agent_config object from an agent document + resolved preset. Single source of truth for which fields are sent to the Python agent. |
mergeDynamicValues({ agentDynamicVar, campaignVariables, contactVariables }) | Merges dynamic values with proper priority: agent defaults < campaign < contact/test variables |
fetchDynamicTools(agentId) | Fetches dynamic HTTP tools for an agent. Returns undefined if none. Error-safe. |
fetchMcpServers(userOrCustomerUID, agentId) | Fetches MCP server configs for dispatch. Returns undefined if none. Error-safe. |
extractIvrActions(agent) | Extracts IVR actions array if non-empty. Returns undefined otherwise. |
extractVoiceBehaviorFields(agent) | Extracts backchannels, exit_phrases_agent, exit_phrases_user, pronunciation_dict as top-level dispatch metadata fields. |
normalizePronunciationDict(dict) | Converts Mongoose Map to plain object. Returns undefined if empty. |
Usage Pattern
import {
buildAgentConfig,
mergeDynamicValues,
fetchDynamicTools,
fetchMcpServers,
extractIvrActions,
extractVoiceBehaviorFields,
} from "../utils/dispatch.utils.js";
// 1. Build agent config
const agentDoc = (agent as any)._doc || agent;
const agentConfig = buildAgentConfig({ agentDoc, presetConfig: dispatchConfig });
// 2. Build dynamic values
const dynamicValues = mergeDynamicValues({
agentDynamicVar: agentConfig.DynamicVar,
campaignVariables, // optional campaign-level overrides
contactVariables, // optional per-contact or test UI variables
});
// 3. Fetch tools and MCP servers in parallel
const [dynamicTools, mcpServers] = await Promise.all([
fetchDynamicTools(agentId),
fetchMcpServers(customerUID, agentId),
]);
// 4. Extract IVR actions
const ivrActions = extractIvrActions(agentDoc);
// 5. Dispatch with spread for voice behavior fields
await createDispatch({
mode: "call",
// ... other fields ...
agent_config: agentConfig,
dynamic_values: Object.keys(dynamicValues).length > 0 ? dynamicValues : undefined,
dynamic_tools: dynamicTools,
mcp_servers: mcpServers,
ivr_actions: ivrActions,
...extractVoiceBehaviorFields(agentDoc),
});Rules
- Never inline agent config field lists. Use
buildAgentConfig()if a new field needs to be dispatched, add it once there. - Never manually convert
pronunciation_dict. UsenormalizePronunciationDict()orextractVoiceBehaviorFields(). - Always use
Promise.allfor dynamic tools + MCP servers they're independent async calls. extractVoiceBehaviorFields()goes last with spread (...extractVoiceBehaviorFields(agent)) so it cleanly adds the top-level voice behavior metadata.
Fields Sent in agent_config
The buildAgentConfig() function includes these fields from the agent document:
| Field | Type | Description |
|---|---|---|
Prompt | string | Agent system prompt |
first_message | string | Initial greeting message |
AgentName | string | Display name |
agent_id | string | Agent identifier |
keyWords | string[] | STT keywords (empty if preset doesn't support them) |
allow_interruptions | boolean | Whether user can interrupt agent speech |
auto_hangup_time | number | Seconds before auto-disconnect |
background_audio | boolean | Play background audio during calls |
background_audio_file | string | Background audio filename |
tools | string[] | Enabled tool IDs |
thinking_sound | boolean | Play typing sound during LLM inference |
thinking_sound_file | string | Thinking sound filename |
DynamicVar | object | Agent-level default dynamic variables |
templateVariables | array | Variable template definitions for CSV mapping |
Context | string | Additional agent context |
preset_id | string | Preset identifier |
tts_voice | string | TTS voice ID |
tts_language | string | TTS language code |
stt_language | string | STT language code |
tts_speed | number | TTS speed multiplier |
preemptive_llm | boolean | Whether to preemptively start LLM inference |
preemptive_tts | boolean | Whether to preemptively start TTS |
backchannels | string[] | Backchannel phrases (e.g., "hmm", "I see") |
exit_phrases_agent | string[] | Phrases agent uses to end call |
exit_phrases_user | string[] | User phrases that signal end of call |
pronunciation_dict | object | Word → phonetic replacement mapping |
Plus all resolved preset fields (providers, models, system_prompt) spread from presetConfig.
Top-Level Dispatch Metadata Fields
These are sent as top-level metadata (outside agent_config) via extractVoiceBehaviorFields():
| Field | Type | Why Separate |
|---|---|---|
backchannels | string[] | Python agent reads from job metadata without parsing full config |
exit_phrases_agent | string[] | Same direct metadata access |
exit_phrases_user | string[] | Same |
pronunciation_dict | object | Same |
These are intentionally duplicated (in agent_config AND top-level metadata) for backward compatibility and to allow the Python agent to access them directly from ctx.job.metadata without parsing the nested agent_config.
10. GST Billing & PDF Invoice System
Customers must provide their GST details (GSTIN mandatory, PAN optional) before making any credit purchase. This enables B2B GST input tax credit claims and ensures compliance.
Architecture
┌─────────────────────────────────────────────────────────────────┐
│ Credit Purchase Flow │
│ │
│ 1. Customer → BuyCreditsPage → checks hasBillingDetails? │
│ NO → show GSTBillingForm → POST /payments/billing-details │
│ YES → proceed │
│ │
│ 2. Customer → clicks "Buy" → POST /payments/create-order │
│ Backend verifies: BillingDetails exists for this customer │
│ Captures billing snapshot on PaymentOrder │
│ Passes GSTIN to Razorpay (notes) / Cashfree (order_tags) │
│ │
│ 3. After payment success → credits added → invoice generated │
│ PDFKit builds A4 invoice → emailed as PDF attachment │
│ Invoice data stored in PaymentOrder.invoiceData │
└─────────────────────────────────────────────────────────────────┘Key Files
| File | Purpose |
|---|---|
models/BillingDetails.ts | MongoDB schema one billing record per customer per org |
controllers/billing.controller.ts | CRUD for billing details + GSTIN validation endpoint |
utils/gst.utils.ts | Offline GSTIN checksum + PAN validation + cross-validation |
services/invoice.service.ts | Builds InvoiceData object + generates HTML/text versions |
services/pdf.service.ts | Generates PDF from InvoiceData using PDFKit (no browser) |
controllers/payment.controller.ts | generateAndSendInvoice() orchestrates invoice + email |
GSTIN Validation
All GSTIN validation is offline no external API calls:
- Format check: regex
[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][A-Z0-9]Z[A-Z0-9] - State code: first 2 digits must be a valid Indian state code (01–38, 97, 99)
- Checksum: Luhn mod-36 algorithm on first 14 characters, verifies 15th character
- PAN cross-validation (if PAN provided): characters 3–12 of GSTIN must equal PAN
This catches typos, fake numbers, and spam submissions without any API costs.
PDF Invoice Generation
Uses pdfkit (pure Node.js, ~2MB, no Chromium/browser dependency):
- Generates A4 PDF with: header, seller/buyer details, items table, tax breakdown, footer
- Tax calculation: 18% GST split as 9% CGST + 9% SGST (intra-state) or 18% IGST (inter-state)
- Inter-state detection: compares first 2 digits of buyer GSTIN vs seller GSTIN
- Invoice number:
INV-YYYYMMDD-XXXXXX(date + nanoid suffix)
Email Attachment Serialization
Important pattern: Email attachments with binary content (PDF Buffer) must be pre-encoded to base64 before being queued through BullMQ.
// ✅ Correct encode before queue
const serializedAttachments = params.attachments?.map((a) => ({
filename: a.filename,
content: Buffer.isBuffer(a.content) ? a.content.toString("base64") : a.content,
}));
// ❌ Wrong Buffer loses type through JSON serialization
message.attachments = rawBufferAttachments; // BullMQ JSON.stringify → brokenBullMQ stores jobs in Redis as JSON. JSON.stringify(Buffer) produces {"type":"Buffer","data":[...]} a plain object, NOT a real Buffer. When the worker reads it back, .toString("base64") fails. Pre-encoding avoids this.
Rules
- Billing preference required before purchase. Customer must choose "individual" or "company". GST is mandatory only for company type.
- PAN is optional but must match GSTIN if provided (chars 3-12).
- Only collected once stored in BillingDetails, reused for all subsequent purchases. Can be changed anytime from Settings → Billing.
- Billing snapshot on each order PaymentOrder stores the GSTIN/PAN/business name at time of purchase. If customer later updates details, old orders retain their original data.
- Invoice generation is non-blocking wrapped in try/catch; failure doesn't affect payment flow.
- Invoice is idempotent checks
invoiceSentAtbefore regenerating (safe across webhook + verify race). - No external GSTIN verification API offline checksum is sufficient for our use case and prevents latency + costs.
- Pricing is GST-exclusive
amountINR = credits × pricePerUnit × 1.18. Frontend shows base price + GST separately; gateway charges full amount. - Pending order auto-expire
createdorders expire inline after 2 min,payment_pendingafter 5 min. This prevents blocking users who dismiss the payment modal without paying.
12. Testing Before You Push
Every code change must pass tests before pushing. No exceptions.
Quick Commands
cd backend/client_backend
npm test # Runs all Vitest tests (unit + property + integration)
npm run build # TypeScript compile checkWhat's Tested
| Category | Files | What It Validates |
|---|---|---|
| Property tests (fast-check) | src/__tests__/*.test.ts + src/services/*.test.ts | Mathematical correctness of billing, rate resolution, cascading |
| Integration tests | src/__tests__/*integration*.test.ts | Full HTTP API endpoints + end-to-end billing flow |
| Unit tests | src/controllers/__tests__/, src/middleware/__tests__/, src/utils/__tests__/ | Controllers, middleware, utilities |
Key Rules
- Always run
npm testbefore pushing. CI will catch failures but it's faster to catch locally. - If you modify billing/credit logic the property tests will catch regressions in the math.
- If you modify API routes/controllers the integration tests will catch response shape changes.
- If a test fails and you didn't change the tested code you probably broke a dependency it imports.
Full Testing Documentation
See docs/testing/index.md for:
- Complete test file tree
- How to write new property tests
- How to write integration tests
- How to debug failing tests
- When to add property-based vs unit tests