Skip to content

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 ​

  1. Never use _id, ObjectId, findById, or findByIdAndUpdate in application code.
  2. Always query by the entity's UID field (e.g., orgUID, userUID, contactUID).
  3. Never expose _id in API responses. Return the UID field instead.
  4. Foreign keys are string UIDs, not ObjectId references. No Schema.Types.ObjectId + ref.
  5. New entities must have a UID field use generateUID("prefix") from src/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 ​

EntityUID FieldPrefixExample
OrganizationorgUIDorg_org_V1StGXR8_Z5jdHi
UseruserUIDusr_usr_k2P9mNx3Qw7LbRt
SIPTrunktrunkUIDtrk_trk_Hj4KmNpQrStUvWx
PhoneNumberphoneUIDphn_phn_Yz1Ab2Cd3Ef4Gh5
PhoneNumberAssignmentassignmentUIDasgn_asgn_R4s5T6u7V8w9X0y
CampaigncampaignUID(legacy, no prefix)A1b2C3d4E5f6
ContactcontactUID(legacy, no prefix)X7y8Z9a0B1c2
Agentagent_id(legacy, no prefix)M3n4O5p6Q7r8
CreditTransactiontransactionId(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:

typescript
// ✅ Correct string UID reference
orgUID: { type: String, index: true }

// ❌ Wrong ObjectId reference
organizationId: { type: Schema.Types.ObjectId, ref: "Organization" }

Common foreign key fields:

  • orgUID references Organization
  • ownerUID, createdByUID, performedByUID references User
  • customerUID references User (customer role)
  • trunkUID references SIPTrunk

Adding a New Entity ​

  1. Add a generator in src/utils/uid.utils.ts:

    typescript
    export function generateInvoiceUID(): string {
      return `inv_${nanoid(UID_LENGTH)}`;
    }
  2. Add the UID field to your model schema:

    typescript
    invoiceUID: { type: String, unique: true, required: true }
  3. Generate the UID at creation time (in controller or repository):

    typescript
    const invoiceUID = generateInvoiceUID();
  4. 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.userUID current user
  • req.orgUID current organization
  • req.userId alias (same value as userUID)
  • req.role user 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.

bash
npx tsx src/scripts/backfill-uids.ts

Agent / 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 .env files)
  • 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│
└──────────────────────────────────────────────────────────────┘
ServiceLoader FileLoads These Secrets
Agentshared/secrets.py → called in Agent/config.pyshared + agent
Analysisshared/secrets.py → called in Analysis/config.pyshared + analysis
client_backendsrc/config/secrets.ts → called in server.tsshared + 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:

EnvironmentHow 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 / ProdEC2 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 ​

bash
aws configure --profile oktivo-dev

Enter:

AWS Access Key ID: <your-key>
AWS Secret Access Key: <your-secret>
Default region name: ap-south-1
Default output format: json

Set the profile active:

powershell
# PowerShell (Windows)
$env:AWS_PROFILE = "oktivo-dev"

# To make it permanent:
[System.Environment]::SetEnvironmentVariable("AWS_PROFILE", "oktivo-dev", "User")
bash
# Bash (Mac/Linux)
export AWS_PROFILE=oktivo-dev
# Add to ~/.bashrc or ~/.zshrc to make permanent

Verify:

bash
aws secretsmanager list-secrets --region ap-south-1 --profile oktivo-dev

Step 3: Set up local env files ​

bash
# 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.env

Fill 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 ​

bash
docker compose -f docker-compose.dev.yml up --build

Verify 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 total

Switching Between Local Files and Secrets Manager ​

The root .env file controls the mode:

bash
# 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 total

Both modes produce the same result the only difference is where the values come from.


Adding a New Environment Variable ​

  1. Decide which file it belongs to:

    • Used by 2+ services → shared
    • Used by one service → that service's file
  2. 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
  3. Add to the local env file AND the .example file:

    bash
    # env/shared.env (real value gitignored)
    MY_NEW_KEY=actual_value
    
    # env/shared.env.example (placeholder committed to git)
    MY_NEW_KEY=
  4. 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")
  5. 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 in oktivo/dev/* vs oktivo/stage/*
  • PR to main: compares all key names in oktivo/stage/* vs oktivo/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:

  1. The PR check fails (red X)
  2. A comment is posted on the PR listing exactly which keys are missing and in which secret
  3. 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=stage or DEPLOY_ENV=prod
  • AWS_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:

json
{
  "Effect": "Allow",
  "Action": ["secretsmanager:GetSecretValue"],
  "Resource": "arn:aws:secretsmanager:ap-south-1:ACCOUNT_ID:secret:oktivo/{env}/*"
}

IAM Permissions Summary ​

RoleSecrets AccessS3 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 / ECSRead oktivo/stage/*oktivo-recordings-stage-*
Prod EC2 / ECSRead 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:

VarValueWhy
PORT3000Fixed for local dev
REDIS_URLredis://redis:6379Docker internal network
ANALYSIS_URLhttp://analysis:8000Docker internal network
MONGO_URImongodb://mongodb:27017/?replicaSet=rs0Local MongoDB container

These are infrastructure-specific values that differ between local Docker and production. They're not secrets they're service discovery.


Rules ​

  1. Never commit real secrets. Only .example files go in git. The env/ directory and .env are gitignored.
  2. Never use process.env directly in controllers/services. Always go through config/index.ts (TypeScript) or config.py (Python).
  3. Same key names in local env/*.env files and AWS Secrets Manager. The 4-file structure is identical.
  4. Docker-compose environment: overrides always win over env files and Secrets Manager values.
  5. The CI check blocks PRs if stage/prod secrets are missing keys that exist in the source environment.
  6. 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.ts functions 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 ​

RouteCallerPurpose
POST /transcription-syncAgentStores call transcription on Contact after call ends
POST /analysis-syncAnalysisStores analysis results (sentiment, interest, summary) on Contact
POST /start-recordingAgentStarts LiveKit egress recording after SIP call is answered
POST /contact-observabilityAgentStores usage metrics (LLM, TTS, STT) on Contact after call ends
POST /update-call-statusLegacy (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:

  1. WEBHOOK_SECRET is configured in env returns 500 if not
  2. x-api-secret header is present returns 401 if missing
  3. Header value matches WEBHOOK_SECRET via crypto.timingSafeEqual returns 401 if 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:

python
# 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)
python
# 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 ​

RouteAuth MethodReason
POST /livekit-webhookLiveKit signature verification (raw body)LiveKit has its own webhook signing; registered before express.json() in app.ts
POST /payments/webhook/:orgUIDCashfree provider verificationThird-party payment provider with its own signature scheme
POST /setup/superadminsverifyWebhookSecret (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:

json
{
  "success": true,
  "message": "Human-readable message",
  "data": { /* payload */ }
}

Error:

json
{
  "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 ​

typescript
import { apiSuccess, apiError } from "../utils/apiResponse";

// apiSuccess<T>(res, data, message?, statusCode?)
// Defaults: message = "Success", statusCode = 200

// apiError(res, message, statusCode?, errors?)
// Defaults: statusCode = 500

Usage Examples ​

Success response:

typescript
apiSuccess(res, { user, token }, "Login successful");
// → 200 { success: true, message: "Login successful", data: { user, token } }

Success with custom status:

typescript
apiSuccess(res, { invoiceUID }, "Invoice created", 201);
// → 201 { success: true, message: "Invoice created", data: { invoiceUID } }

Validation error:

typescript
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):

typescript
apiError(res, "Internal server error");
// → 500 { success: false, message: "Internal server error", data: null }

Upstream service failure:

typescript
apiError(res, "Payment provider unavailable", 502);
// → 502 { success: false, message: "Payment provider unavailable", data: null }

Rules ​

  1. Never use res.json() or res.send() directly in controllers. Always use apiSuccess() or apiError().
  2. Always provide a meaningful message. The message is shown to users in toast notifications on the frontend.
  3. Use the errors array for validation failures so the frontend can map errors to specific form fields.
  4. 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 ​

typescript
import { createRateLimiter } from "../middleware/rateLimiter.middleware";

Default configuration (10 requests per 60 seconds):

typescript
router.post("/my-route", verifyJwt, createRateLimiter(), myHandler);

Custom configuration:

typescript
router.post(
  "/expensive-operation",
  verifyJwt,
  createRateLimiter({ windowMs: 120_000, maxRequests: 5 }),
  expensiveHandler,
);
// → 5 requests per 2-minute window

Options ​

OptionTypeDefaultDescription
windowMsnumber60000Time window in milliseconds
maxRequestsnumber10Max requests allowed per window per user

Response Headers ​

Every response (success or rate-limited) includes these headers:

HeaderValueExample
X-RateLimit-LimitMax requests allowed10
X-RateLimit-RemainingRequests left in current window7
X-RateLimit-ResetUnix timestamp (seconds) when window resets1718900000

Rate Limit Exceeded ​

When a user exceeds maxRequests within the window, the middleware returns a 429 response via apiError():

json
{
  "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:

typescript
// ✅ 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-prompt

Middleware chain: verifyJwt → createRateLimiter({ windowMs: 60000, maxRequests: 10 }) → enhancePrompt

Any authenticated user can call this endpoint no role or feature-access restriction.

Request ​

json
{
  "prompt": "You are a sales agent for Acme Corp selling widgets..."
}
FieldTypeRequiredNotes
promptstringYesNon-empty after trimming whitespace

Success Response (200) ​

json
{
  "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 ​

StatusConditionExample Response
400Prompt is empty, missing, or whitespace-only{ "success": false, "message": "Prompt is required", "data": null }
401No valid JWT token provided{ "success": false, "message": "Access token is required" }
429Rate limit exceeded (10 req/user/min){ "success": false, "message": "Rate limit exceeded. Try again later.", "data": null }
502OpenAI API call failed{ "success": false, "message": "Prompt enhancement service unavailable", "data": null }
500Unexpected 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 ​

typescript
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:

FieldExample ValueWhy 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_modeltrue/falseInternal 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 ​

typescript
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:

  1. Returns preset data (presets array with provider fields)
  2. Is accessible by customer-role users

The middleware is safe to apply broadly it only modifies responses when:

  • req.userRole === "customer" AND
  • body.data.presets exists in the response

If neither condition is met, the response passes through unchanged.

Rules ​

  1. Always apply sanitizeConfigResponse to customer-facing config/preset endpoints.
  2. Never return raw preset data to customers without sanitization.
  3. Admin CRUD endpoints (/api/v2/config/*) don't need it they're behind requireConfigEditPermission which blocks customers.
  4. 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 ​

  1. Config hierarchy: There's a platform-wide default config (orgUID = "__default__") managed by superadmin. Orgs without edit_system_config permission use this default. Orgs with the permission can fork it into their own config.

  2. Preset-by-reference: Agents store only preset_id. Provider/model fields are resolved at read/dispatch time via PresetResolver. Never store stt_provider, llm_provider, tts_provider, or cost_per_min on Agent documents.

  3. Voice separation: Voices are in a separate Voice collection, referenced by voice_id strings in presets. The PresetResolver populates full voice objects at API response time.

  4. Auto-seed: The __default__ SystemConfig is auto-seeded from shared/agent_config.json on first access. After that, the JSON file is never read again.

  5. Frontend field mapping: The backend uses preset_id but the frontend expects id. The getAgentConfig controller maps preset_id → id in the response.

Files to Know ​

FilePurpose
src/services/config.service.tsAll config CRUD, versioning, fork/revert, auto-seed
src/utils/preset.resolver.tsResolves preset_id → full details with voice population
src/middleware/sanitize.middleware.tsStrips provider fields for customer responses
src/middleware/configPermission.middleware.tsGates config mutations behind permission + role
src/controllers/config.controller.tsGET /agent-config customer-facing config endpoint
src/controllers/systemConfig.controller.tsAdmin CRUD + superadmin default config handlers
src/models/SystemConfig.tsSystemConfig + 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 ​

FunctionPurpose
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 ​

typescript
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 ​

  1. Never inline agent config field lists. Use buildAgentConfig() if a new field needs to be dispatched, add it once there.
  2. Never manually convert pronunciation_dict. Use normalizePronunciationDict() or extractVoiceBehaviorFields().
  3. Always use Promise.all for dynamic tools + MCP servers they're independent async calls.
  4. 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:

FieldTypeDescription
PromptstringAgent system prompt
first_messagestringInitial greeting message
AgentNamestringDisplay name
agent_idstringAgent identifier
keyWordsstring[]STT keywords (empty if preset doesn't support them)
allow_interruptionsbooleanWhether user can interrupt agent speech
auto_hangup_timenumberSeconds before auto-disconnect
background_audiobooleanPlay background audio during calls
background_audio_filestringBackground audio filename
toolsstring[]Enabled tool IDs
thinking_soundbooleanPlay typing sound during LLM inference
thinking_sound_filestringThinking sound filename
DynamicVarobjectAgent-level default dynamic variables
templateVariablesarrayVariable template definitions for CSV mapping
ContextstringAdditional agent context
preset_idstringPreset identifier
tts_voicestringTTS voice ID
tts_languagestringTTS language code
stt_languagestringSTT language code
tts_speednumberTTS speed multiplier
preemptive_llmbooleanWhether to preemptively start LLM inference
preemptive_ttsbooleanWhether to preemptively start TTS
backchannelsstring[]Backchannel phrases (e.g., "hmm", "I see")
exit_phrases_agentstring[]Phrases agent uses to end call
exit_phrases_userstring[]User phrases that signal end of call
pronunciation_dictobjectWord → 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():

FieldTypeWhy Separate
backchannelsstring[]Python agent reads from job metadata without parsing full config
exit_phrases_agentstring[]Same direct metadata access
exit_phrases_userstring[]Same
pronunciation_dictobjectSame

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 ​

FilePurpose
models/BillingDetails.tsMongoDB schema one billing record per customer per org
controllers/billing.controller.tsCRUD for billing details + GSTIN validation endpoint
utils/gst.utils.tsOffline GSTIN checksum + PAN validation + cross-validation
services/invoice.service.tsBuilds InvoiceData object + generates HTML/text versions
services/pdf.service.tsGenerates PDF from InvoiceData using PDFKit (no browser)
controllers/payment.controller.tsgenerateAndSendInvoice() orchestrates invoice + email

GSTIN Validation ​

All GSTIN validation is offline no external API calls:

  1. Format check: regex [0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][A-Z0-9]Z[A-Z0-9]
  2. State code: first 2 digits must be a valid Indian state code (01–38, 97, 99)
  3. Checksum: Luhn mod-36 algorithm on first 14 characters, verifies 15th character
  4. 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.

typescript
// ✅ 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 → broken

BullMQ 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 ​

  1. Billing preference required before purchase. Customer must choose "individual" or "company". GST is mandatory only for company type.
  2. PAN is optional but must match GSTIN if provided (chars 3-12).
  3. Only collected once stored in BillingDetails, reused for all subsequent purchases. Can be changed anytime from Settings → Billing.
  4. 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.
  5. Invoice generation is non-blocking wrapped in try/catch; failure doesn't affect payment flow.
  6. Invoice is idempotent checks invoiceSentAt before regenerating (safe across webhook + verify race).
  7. No external GSTIN verification API offline checksum is sufficient for our use case and prevents latency + costs.
  8. Pricing is GST-exclusive amountINR = credits × pricePerUnit × 1.18. Frontend shows base price + GST separately; gateway charges full amount.
  9. Pending order auto-expire created orders expire inline after 2 min, payment_pending after 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 ​

bash
cd backend/client_backend
npm test        # Runs all Vitest tests (unit + property + integration)
npm run build   # TypeScript compile check

What's Tested ​

CategoryFilesWhat It Validates
Property tests (fast-check)src/__tests__/*.test.ts + src/services/*.test.tsMathematical correctness of billing, rate resolution, cascading
Integration testssrc/__tests__/*integration*.test.tsFull HTTP API endpoints + end-to-end billing flow
Unit testssrc/controllers/__tests__/, src/middleware/__tests__/, src/utils/__tests__/Controllers, middleware, utilities

Key Rules ​

  1. Always run npm test before pushing. CI will catch failures but it's faster to catch locally.
  2. If you modify billing/credit logic the property tests will catch regressions in the math.
  3. If you modify API routes/controllers the integration tests will catch response shape changes.
  4. 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

Last updated: