System architecture
How the frontend, backend, database, schedulers, and providers fit together.
ReachConvert is two apps. The Next.js workspace runs in the operator's browser. The NestJS backend owns everything else: sign-in, encrypted provider keys, MongoDB, sending, scheduled jobs, signal polling and live phone calls. Almost all traffic is REST under /api. Live calls are the exception: the phone provider streams audio into the backend over a WebSocket, and the backend bridges it to Gemini Live.
The whole system
Product pages reach the backend through one client, src/lib/api.ts, which attaches the access token. On the backend, main.ts sets the /api prefix and hands the phone provider's WebSocket upgrades to the realtime gateway. Every other request passes the global AuthGuard into a feature module, and every module stores data through MongoService.
RUNNING and sends in the background, one recipient every 250 ms. Launching a calling campaign returns once each call is placed, and the conversations happen later on the gateway as contacts pick up. All of it runs inside the backend process. Deployed as a Vercel function, the backend still serves REST, but its WebSocket upgrade handler never receives traffic, so live calls need a long-running deploy such as Render or a local server.Who can call what
The guard is registered globally, so every route is private unless it opts out with @Public(). Opting out does not mean unchecked. Routes the phone provider calls carry their own proof instead of a user token.
Needs a Bearer access token
- Every route by default. The guard verifies the token, then loads the user, so a deleted account is refused.
- Settings, where the provider keys live. Responses always return those keys masked.
- Call recordings, which the backend proxies from the provider instead of linking to them.
Public, but signed per call
- The sign-in endpoints: register, login, refresh, forgot-password and reset-password.
- Twilio and Plivo answer, status and recording webhooks under
/api/calling-campaigns. - The
/twilio/streamand/plivo/streammedia sockets. - Every URL handed to the provider carries an HMAC of the call id. A webhook with a bad token gets a 403, and a socket with a bad token is closed at its start frame.
localAuth.ts keeps the access and refresh tokens in localStorage. When a request comes back 401, api.ts calls /auth/refresh once, saves the new pair and retries the original request. A second 401 surfaces as a session-expired error.Which module does what
Each feature is a NestJS module under AgentReach-backend/src. The kind says how work reaches it: a REST route, a provider webhook, a media socket or a cron job.
| Module | Kind | Owns | Calls out to | Collections |
|---|---|---|---|---|
| authauth/ | rest | Sign-up, login, token refresh, password reset, profile | none | User |
| settingssettings/ | rest | Provider keys, encrypted at rest, and a test button per provider | SES, Twilio, Plivo, Gemini (tests only) | SystemSettings |
| contactscontacts/ | rest | Contacts, directories, CSV parsing and import | none | Contact, ContactDirectory |
| templatestemplates/ | rest | Email templates, AI drafts, reference PDFs | Gemini text | Template |
| email-campaignsemail-campaigns/ | rest | Campaigns, recipients, schedule, launch, relaunch | AWS SES | EmailCampaign, EmailCampaignContact |
| ai-calling-botsbot/ | rest | Calling personas and their knowledge, from text or a PDF | none, embeddings are computed locally | AiCallingBot, AiCallingBotEmbedding |
| calling-campaignscalling-campaigns/ | restwebhook | Campaigns, dialing, provider callbacks, recordings | Twilio or Plivo, Gemini Live preflight | CallingCampaign, CallHistory |
| realtime-callingrealtime-calling/ | realtime | The media sockets and the live Gemini session | Gemini Live | CallHistory |
| signalssignals/ | restcron | Watches, collectors, playbooks, review queue | News RSS, EDGAR, job boards, Gemini text | CompanyWatch, Signal, SignalMatch, Playbook, TriggeredOutreach |
| schedulerscheduler/ | cron | Launches campaigns whose scheduled time has passed | none, it calls the campaign services | EmailCampaign, CallingCampaign |
| historyhistory/ | rest | Sent email and past calls | none | reads only |
| analyticsanalytics/ | rest | Dashboard metrics | none | reads only |
One outbound call, end to end
A calling campaign holds one CallHistory row per contact. Launching it dials each contact in turn. The conversation runs later, on the realtime gateway, which bridges the phone line to a Gemini Live session.
| Step | Kind | Runs in | Talks to | CallHistory after |
|---|---|---|---|---|
| 0 · Launchlaunch() | code | The launch request | none | PENDING; relaunch resets every row |
| 1 · PreflightpreflightGeminiLiveCall() | model | The launch request | Gemini Live, setup only | FAILED with the error, if setup fails |
| 2 · DialcreateTwilioCall() / createPlivoCall() | provider | The launch request | Twilio or Plivo REST | QUEUED, with the provider call id |
| 3 · AnswerhandleTwilioAnswer() | webhook | Public webhook, signed | Returns the stream XML | IN_PROGRESS |
| 4 · Live callRealtimeCallingGateway | realtime | The media socket | Gemini Live call session | Each turn appended to the transcript |
| end_callcall-tools.ts | tool | A Gemini tool call | none | endCallReason; hangs up after the last audio |
| fetch_contextcall-tools.ts | tool | A Gemini tool call | Bot knowledge, 2.5 s timeout | nothing |
| 5 · CompletecompleteCall() | code | The gateway | none | COMPLETED; the campaign completes when no call is left |
| Status, recordinghandleTwilioStatus() | webhook | Public webhook, signed | none | Provider status, duration, recording |
When a signal sends email on its own
Three collectors poll each active company watch every six hours: news RSS, SEC EDGAR and job boards. Operators can also add signals by hand or post SES bounces. Every signal then goes through IngestionService.ingest(), and only a narrow case sends email without a person approving it.
| Step | Kind | What happens | Stops here when |
|---|---|---|---|
| 1 · Dedup | code | Hashes the raw signal. | The same hash arrived in the last 14 days. |
| 2 · Classify | model | Gemini picks the signal type and extracts entities. A keyword heuristic answers when no key is set or the call fails. | Never. |
| 3 · Save | code | Stores the signal. A second source on the same company within 7 days raises its confidence one tier. | Never. |
| 4 · Match | code | Finds contacts by email domain, rated high, or by normalised company name, rated medium. | The type is news-other, or no contact matches. |
| 5 · Playbooks | code | Picks active playbooks for this signal type whose directories include the contact. | No playbook applies. The match is still kept for the feed. |
Fires on its own
- The playbook's mode is auto
- The match came from the contact's email domain, so its confidence is high
- The contact and company pairing has not been suppressed
- TriggerService's guardrails pass
Waits in the review queue
- The playbook's mode is review
- The match came from the company name only, even under an auto playbook
- Approving runs the same TriggerService; rejecting records the note
- Two rejections of the same contact and company suppress the pairing, so it stops coming back
{{signal.*}} values filled in, creates a one-contact email campaign, launches it through the normal email pipeline and records a TriggeredOutreach row for attribution.Where the data lives
MongoService wraps 16 Mongoose models in Prisma-style delegates such as findMany, findUnique, create and update, and maps Mongo's _id to id. Services read like a relational client while MongoDB stays the store. All accounts share one workspace, which is why sign-up is closed in production unless ALLOW_REGISTRATION is set.
| Collection | Written by | Read by |
|---|---|---|
| User | auth | AuthGuard, on every request |
| SystemSettings | settings, as one row with encrypted credentials | Every provider call |
| Contact, ContactDirectory | contacts | Campaigns, signal matching, playbooks |
| Template | templates; TriggerService saves signal copies | email-campaigns, analytics |
| EmailCampaign, EmailCampaignContact | email-campaigns, scheduler, TriggerService | History, analytics |
| AiCallingBot, AiCallingBotEmbedding | ai-calling-bots | calling-campaigns, the gateway |
| CallingCampaign, CallHistory | calling-campaigns, webhooks, the gateway | History, analytics, recordings |
| CompanyWatch | signals | The signal poller |
| Signal, SignalMatch | IngestionService, the review queue | The signals feed, the review queue |
| Playbook | signals | IngestionService, TriggerService |
| TriggeredOutreach | TriggerService | Signal stats, the guardrails |
Background work
Two cron jobs and two kinds of in-process work run outside any request. None of them is a separate worker; they share the backend process.
| Job | Runs | What it does | When it fails |
|---|---|---|---|
| Campaign schedulercampaign-scheduler.service.ts | Every minute | Launches email and calling campaigns whose scheduledAt has passed. | Logs the error and returns the campaign to DRAFT, so it is not retried every minute. |
| Signal pollersignals/scheduler.service.ts | Every 6 hours, or Run now | Runs each enabled collector for each active watch. | Logs and skips that collector. A poll that starts while one is running is dropped. |
| Email sendingrunBackgroundSending() | After a launch | Sends to each PENDING recipient, 250 ms apart. | A throttled send is retried once. A recipient that still fails is marked FAILED, and launching again retries only those. |
| Live callRealtimeCallingGateway | Per answered call | Holds the gateway session and its Gemini connection. | Ends at 10 minutes, after two silent 15 s checks, or past GEMINI_MAX_CALL_TOKENS, 600k by default. |
Why it is built this way
- Provider keys live in the database.
Settings encrypts them with AES-256-GCM before saving and always returns them masked. Operators change keys without a redeploy;
GEMINI_API_KEYin the environment is only a fallback. - Webhooks are public but signed.
Twilio and Plivo cannot send a user token, so each callback URL carries an HMAC of its call id, keyed with
JWT_SECRET. A forged URL gets a 403. - Gemini is checked before the phone rings.
The preflight session catches a bad key or model on the call row, so a contact never answers into silence.
- One gateway, two phone providers.
Both providers stream μ-law 8 kHz in the same event envelope. Only the outbound audio, clear and mark frames branch on the provider.
- Bot knowledge is embedded locally.
Chunks become 384-dimension hashed word vectors, so training and
fetch_contextmake no API call and stay fast enough for a live call. The trade-off is keyword recall rather than semantic search. - Signals only send on their own for the strongest match.
An email-domain match under an auto playbook fires. Anything weaker waits for a person, and repeated rejections suppress the pairing.
- Launching twice never double-sends.
A second launch retries only FAILED recipients. Re-sending to everyone, including those already SENT, is a separate relaunch the operator has to choose.
Run it yourself
Each app runs on its own. The backend needs MongoDB and a JWT secret; provider keys are entered later in Settings.
# Backend: REST on :3001/api, Swagger on :3001/docs cd AgentReach-backend npm install cp .env.example .env # set DATABASE_URL and JWT_SECRET npm run start:dev # Frontend: app on :3000, these docs on :3000/documentation cd AgentReach-frontend npm install # in .env.local: NEXT_PUBLIC_API_URL=http://localhost:3001/api npm run dev
PUBLIC_API_URL to a public HTTPS address, such as a tunnel to port 3001, and PUBLIC_WS_URL if the socket host differs. With Twilio or Plivo keys that contain “mock” or “test”, launches simulate the calls, so the rest of the flow can be exercised without a phone line.File map
| File | Role |
|---|---|
| Backend · AgentReach-backend/src | |
| main.ts | Boots Nest, sets the /api prefix, CORS and Swagger, and routes WebSocket upgrades. Exports a handler for Vercel and listens everywhere else. |
| app.module.ts | Registers Mongo, every feature module and both schedulers. |
| auth/auth.guard.ts | The global guard: Bearer token, user lookup, @Public() bypass. |
| auth/secrets.ts | JWT secret rules and the HMAC call tokens on provider callback URLs. |
| settings/credential-encryption.ts | AES-256-GCM for provider keys, and the masking used in every response. |
| mongo.service.ts | Prisma-style delegates over the 16 Mongoose models in schemas/. |
| email-campaigns/email-campaigns.service.ts | Launch checks, background SES sending, throttling retry. |
| calling-campaigns/calling-campaigns.service.ts | Launch, Gemini preflight, dialing, webhooks, recordings. |
| realtime-calling/realtime-calling.gateway.ts | The live call: sockets, Gemini session, noise gate, timers, transcript. |
| realtime-calling/audio-codec.ts | μ-law and PCM16 conversion and resampling. |
| realtime-calling/call-tools.ts | end_call and fetch_context. |
| realtime-calling/call-monitor.hub.ts | In-process pub/sub for watching a call live. It is registered, but nothing publishes to it yet. |
| bot/bot.service.ts | Knowledge chunking, local embeddings, cosine search. |
| signals/ingestion.service.ts | The signal pipeline, from dedup to playbooks. |
| signals/trigger.service.ts | Guardrails and the one-contact email campaign. |
| signals/scheduler.service.ts | The six-hourly collector poll. |
| scheduler/campaign-scheduler.service.ts | The every-minute campaign launcher. |
| Frontend · AgentReach-frontend | |
| src/lib/api.ts | The REST client: access token, refresh and retry, friendly errors. |
| src/lib/localAuth.ts | Tokens, profile and theme in localStorage. |
| next.config.ts | Rewrites /api/* to the backend. |
| src/components/docs/ | This page and its two diagrams. |
| src/lib/docs.ts | Content for every other documentation page. |