Architecture

Technical Architecture

Detailed technical architecture of the Project Noir AI outreach platform, including system diagrams, data flows, and key technical decisions.

System Diagram

The platform consists of three main services: the Next.js dashboard on Vercel, the Python voice agent on Railway, and the Python Telegram worker on Railway. All services share a Neon PostgreSQL database.

System Overview
+-----------------------------------------------------------------------------------+
|                              CLIENTS                                              |
|                                                                                   |
|   Browser (Landing)     Browser (Dashboard)      Twilio       Stripe/LiqPay      |
+--------+----------------+-----+------------------+---+--------+---+--------------+
         |                      |                      |              |
         v                      v                      v              v
+--------+----------------------+----------------------+--------------+-------------+
|                          VERCEL (Next.js 16)                                      |
|                                                                                   |
|  +----------------+  +-----------------------+  +------------------------------+  |
|  | Landing Page   |  | Dashboard Pages       |  | API Routes (37 endpoints)    |  |
|  | (public)       |  | (Clerk-protected)     |  |                              |  |
|  |                |  |                       |  | /api/dashboard/*  (Clerk)    |  |
|  | hero, pricing, |  | campaigns, leads,     |  | /api/webhook/*    (Signature)|  |
|  | features, FAQ  |  | scripts, voice, calls,|  | /api/demo/*       (Public)   |  |
|  |                |  | telegram, email,       |  |                              |  |
|  |                |  | analytics, billing,    |  |                              |  |
|  |                |  | settings, numbers      |  |                              |  |
|  +----------------+  +-----------------------+  +------+-----------+-----------+  |
|                                                        |           |              |
+--------------------------------------------------------+-----------+--------------+
                                                         |           |
            +--------------------------------------------+           |
            |                                                        |
            v                                                        v
+---------------------------+                          +---------------------------+
|   RAILWAY: Voice Agent    |                          | RAILWAY: Telegram Worker   |
|   (Python, port 7860)     |                          | (Python, port 7861)        |
|                           |                          |                            |
|   POST /api/call          |                          | POST /api/send             |
|   POST /api/config        |                          | POST /api/accounts/add     |
|   GET  /api/config        |                          | POST /api/accounts/:id/    |
|   GET  /api/health        |                          |      verify                |
|                           |                          | GET  /api/accounts         |
|   Pipecat Pipeline:       |                          | GET  /api/health           |
|   Audio -> Deepgram STT   |                          |                            |
|   -> GPT-4o (OpenRouter)  |                          | Telethon Client Pool       |
|   -> ElevenLabs TTS       |                          | Auto account selection     |
|   -> Audio Out            |                          | Rate limiting + delays     |
+-------------+-------------+                          +-------------+--------------+
              |                                                      |
              |          +---------------------------+               |
              +--------->|   NEON PostgreSQL          |<-------------+
                         |   (Serverless)             |
                         |   13 tables                |
                         +---------------------------+

Service Communication

Dashboard -> Voice AgentHTTP POST to /api/call (initiate calls), POST to /api/config (sync voice settings)
Dashboard -> Telegram WorkerHTTP POST to /api/send (send messages), GET /api/accounts (list accounts)
Dashboard -> Neon DBDirect connection via Drizzle ORM (serverless driver)
Voice Agent -> Neon DBDirect connection via psycopg2 (saves call transcripts)
Telegram Worker -> Neon DBDirect connection via psycopg2 (logs messages, manages accounts)
Twilio -> Voice AgentWebSocket connection for real-time audio streaming
Stripe/LiqPay -> DashboardWebhooks for payment events

Data Flow Diagrams

Voice Call Flow

When a campaign is executed, the voice channel processes leads with phone numbers through the Pipecat pipeline on Railway, streaming audio through Twilio.

Voice Call Sequence
User clicks "Execute Campaign"
         |
         v
Dashboard API: POST /api/dashboard/campaigns/[id]/execute
         |
         v
campaign-executor.ts: executeCampaign()
         |
         v
For each lead with a phone number:
         |
         v
campaign-executor.ts: sendVoiceCall()
    |
    |  HTTP POST /api/call
    v
Voice Agent (Railway):
    |
    |  Twilio REST API: client.calls.create()
    v
Twilio:
    |  Connects call, opens WebSocket to Voice Agent
    v
Voice Agent Pipeline:
    |  Audio In -> Silero VAD -> Deepgram STT
    |  -> LLM Context Aggregator -> GPT-4o (OpenRouter)
    |  -> TranscriptCollector -> ElevenLabs TTS -> Audio Out
    v
On call disconnect:
    |  TranscriptCollector.get_transcript()
    |  save_call_log() -> INSERT INTO call_logs
    v
Neon PostgreSQL: call_logs table updated

Telegram Message Flow

Telegram messages are sent through a pool of user accounts managed by the Telegram Worker on Railway, with rate limiting and delays to avoid bans.

Telegram Message Sequence
User clicks "Execute Campaign"
         |
         v
Dashboard API: POST /api/dashboard/campaigns/[id]/execute
         |
         v
campaign-executor.ts: executeCampaign()
         |
         v
For each lead with a telegram_username:
         |
         v
campaign-executor.ts: sendTelegram()
    |
    |  HTTP POST /api/send
    v
Telegram Worker (Railway):
    |  manager.get_available_account(orgId)
    |     -> Selects account with fewest daily sends
    |     -> Checks daily limit not exceeded
    v
    |  Random delay (30-90 seconds)
    v
    |  manager.send_message()
    |     -> client.get_entity(username)
    |     -> client.send_message(entity, message)
    |     -> db.increment_daily_sent()
    v
    |  db.log_message() -> INSERT INTO messages
    v
Neon PostgreSQL: messages + leads tables updated

Email Flow

Emails are sent directly from the Next.js dashboard through the Resend API, with AI-generated content powered by OpenRouter.

Email Send Sequence
User clicks "Execute Campaign"
         |
         v
campaign-executor.ts: executeCampaign()
         |
         v
For each lead with an email:
         |
         v
campaign-executor.ts: sendEmail()
    |  Load channel_configs for org (email sender settings)
    |  buildEmailPrompt() -> Generates subject + body template
    v
Resend API: resend.emails.send()
    |  from: configured sender (or noreply@projectnoir.xyz)
    |  to: lead's email address
    v
On success: INSERT INTO messages (status: 'sent')
On failure: INSERT INTO messages (status: 'failed')
    v
Neon PostgreSQL: messages table updated

Campaign Execution Flow

The campaign executor is the core engine that orchestrates outreach across all channels. It processes leads sequentially, trying channels in priority order with usage limit checks.

Detailed Campaign Execution
POST /api/dashboard/campaigns/[id]/execute
    |
    v
1. Load campaign (verify ownership via orgId)
2. Validate: not already active/completed, has assigned leads
    |
    v
3. Load campaign config:
   - channelPriority: ["telegram", "voice", "email"]
   - scriptId -> load script content + objection handlers
   - voiceConfigId -> load language + personality
    |
    v
4. Load all leads WHERE campaign_id = [id] AND org_id = [orgId]
    |
    v
5. UPDATE campaigns SET status = 'active'
    |
    v
6. FOR EACH lead:
   |
   |  a. checkUsageLimits(orgId)
   |     -> Count voice minutes, telegram msgs, emails this month
   |     -> Compare against plan limits
   |     -> If exceeded: pause campaign, skip remaining leads
   |
   |  b. FOR EACH channel in channelPriority:
   |     |  Check if lead has contact info for this channel
   |     |  If not available -> skip to next channel
   |     |  buildSystemPrompt() -> Channel-specific AI prompt
   |     |  Dispatch to service
   |     |  Log result to messages table
   |     |  If SUCCESS -> UPDATE leads SET status = 'contacted' -> BREAK
   |     |  If FAILED -> Try next channel in priority
   |
   |  c. Apply inter-lead delay:
   |     telegram: 60s, voice: 5s, email: 3s
    |
    v
7. UPDATE campaigns SET status = 'completed'
8. Return ExecutionProgress { total, processed, succeeded, failed }

Authentication Flow

Authentication is handled by Clerk with automatic user and organization provisioning on first API request.

Auth Flow
User visits /dashboard
    |
    v
Clerk Middleware (middleware.ts):
    |  isPublicRoute? -> "/" , "/sign-in", "/sign-up", "/api/webhook/*"
    |    YES -> Allow through
    |    NO  -> auth.protect() -> Redirect to /sign-in if not authenticated
    v
User signs in via Clerk (email, OAuth, etc.)
    |
    v
First API request to any /api/dashboard/* route:
    |  auth() -> Extract userId from Clerk session
    |  getOrgId(userId):
    |    1. Check if user exists in 'users' table
    |       NOT FOUND -> Create user from Clerk profile
    |    2. Check if org exists for this user
    |       NOT FOUND -> Create "Personal" org
    |    3. Return orgId
    v
All subsequent queries are scoped: WHERE org_id = [orgId]

Payment Flow

Stripe (International)

International payments are handled through Stripe Checkout with webhook-driven subscription lifecycle management.

Stripe Payment Flow
User selects plan on /dashboard/billing
    |
    v
POST /api/dashboard/billing/checkout { planId: "starter" }
    |  Find/create Stripe customer
    |  Create Checkout Session with price_id
    v
Redirect to Stripe Checkout -> User completes payment
    |
    v
Stripe webhook: checkout.session.completed
    |  POST /api/webhook/stripe
    |  Verify webhook signature
    |  UPDATE users SET plan = [planId], stripe_customer_id, stripe_subscription_id
    v
User redirected to /dashboard/billing?success=true

--- Subscription lifecycle ---
customer.subscription.updated  -> Update plan / downgrade if canceled
customer.subscription.deleted  -> Downgrade to free
invoice.payment_failed         -> Log error (Stripe retries)

LiqPay (Ukraine)

Ukrainian users can pay in UAH through LiqPay with signature-verified webhook callbacks.

LiqPay Payment Flow
User selects plan + LiqPay payment
    |
    v
POST /api/dashboard/billing/liqpay { planId: "starter" }
    |  Create order_id: "liqpay_{userId}_{planId}_{timestamp}"
    |  Build LiqPay payment form data + signature
    v
Client-side LiqPay form submission -> LiqPay processes payment
    |
    v
LiqPay webhook: POST /api/webhook/liqpay
    |  Verify signature: SHA1(private_key + data + private_key)
    |  Decode base64 data payload
    |  Parse order_id -> extract userId, planId
    |  status = "subscribed"/"success" -> UPDATE plan
    |  status = "reversed"/"unsubscribed" -> Downgrade to free
    v
User redirected to /dashboard/billing?success=true

Database Schema Relationships

The platform uses 13 tables in a Neon PostgreSQL database. All data is scoped to organizations for multi-tenant isolation.

Entity Relationships
users (PK: id = Clerk user ID)
  |
  |-- 1:N --> organizations (owner_id -> users.id)
  |              |
  |              |-- 1:N --> org_members (org_id, user_id)
  |              |-- 1:N --> scripts (org_id)
  |              |-- 1:N --> voice_configs (org_id)
  |              |-- 1:N --> phone_numbers (org_id)
  |              |-- 1:N --> campaigns (org_id)
  |              |     |
  |              |     |-- N:1 --> scripts (script_id)
  |              |     |-- N:1 --> voice_configs (voice_config_id)
  |              |     |-- 1:N --> leads (campaign_id)
  |              |     |-- 1:N --> call_logs (campaign_id)
  |              |     +-- 1:N --> messages (campaign_id)
  |              |
  |              |-- 1:N --> leads (org_id)
  |              |     |-- 1:N --> call_logs (lead_id)
  |              |     +-- 1:N --> messages (lead_id)
  |              |
  |              |-- 1:N --> call_logs (org_id)
  |              |-- 1:N --> messages (org_id)
  |              |-- 1:N --> telegram_accounts (assigned_org_id)
  |              |-- 1:N --> channel_configs (org_id)
  |              +-- 1:N --> api_keys (org_id)
TableDescription
usersUser accounts synced from Clerk. Stores plan tier, Stripe IDs, onboarding status.
organizationsMulti-tenant orgs. Each user auto-gets a 'Personal' org.
org_membersOrganization membership with roles: owner, admin, member.
scriptsSales scripts with content and objection handler arrays (JSONB).
voice_configsPer-org voice settings: voice ID, language, personality, speed.
phone_numbersTwilio phone numbers assigned to orgs.
campaignsOutreach campaigns with channel selection and priority ordering.
leadsContact database with phone, email, Telegram, company, metadata.
call_logsVoice call records with full transcript, duration, sentiment, score.
messagesUnified message log for all channels (voice/telegram/email).
telegram_accountsPool of Telegram accounts with session strings and rate tracking.
channel_configsPer-org, per-channel configuration (email sender, etc.).
api_keysSHA-256 hashed API keys with prefix and last4 for display.

Multi-Tenancy

All data is scoped to an organization via org_id. The getOrgId() helper in src/lib/auth.ts ensures:

  1. Every authenticated user has a row in the users table
  2. Every user has at least one organization ("Personal")
  3. All API queries filter by org_id to enforce data isolation

Key Technical Decisions

Why Neon PostgreSQL (Serverless)?

Zero cold-start connection pooling via @neondatabase/serverless. Compatible with Vercel's serverless function model. Drizzle ORM provides type-safe queries. All three services share the same database.

Why OpenRouter instead of direct OpenAI?

Single API key provides access to multiple LLM providers. Easy to switch between GPT-4o, Claude, Gemini without code changes. Built-in fallback and load balancing.

Why Pipecat for Voice?

Purpose-built framework for real-time voice AI pipelines. Native integration with Deepgram, ElevenLabs, and OpenAI. Handles audio streaming, VAD, and turn-taking automatically.

Why Telethon (user accounts) instead of Telegram Bot API?

Bot API cannot initiate DMs to users who haven't started the bot. User accounts can message anyone by username, which is critical for outreach. Account pooling distributes risk.

Why Dual Payment Processors?

Stripe for international customers (USD). LiqPay for Ukrainian customers (UAH) since Stripe has limited UA support. Both update the same users.plan field via webhooks.