Architecture & Conventions
Stack
A TypeScript monorepo built with pnpm workspaces + Turborepo on Node 22+:
apps/api— NestJS backend + background jobs. The single source of truth for all business operations.apps/web— Next.js 16 (App Router) + React 19, styled with Tailwind 4 and shadcn/ui primitives. Consumes the API; holds no business logic.packages/— shared TypeScript (API types shared web↔api, value objects, enums).- Auth: passwordless magic-link (tokens hashed at rest, single-use, 15-min TTL); API access via tokens.
- Tailwind 4 for styling; shadcn/ui as the component kit.
- Jest or Vitest (unit/integration) + Playwright (e2e); ESLint + Prettier;
tsc --noEmitfor type-checking.pnpm testruns the suite. - PostgreSQL with Prisma (or Drizzle) as ORM — recommended, still [open] (see the
decision list in the root
CLAUDE.md). - Redis + BullMQ for the queue-driven research/enrichment + outreach/connect pipeline.
Stack choices marked [open] aren't final yet — flag before implementing, don't silently default. See
docs/technical-requirements.mdfor the full mapping and status table.
High-level shape
Campaign run (scheduler; respects daily + per-run + budget limits)
└─ ResearchJob(postal_code, industry) → Serper.dev (Google Maps/Places, primary ID)
└─ per Target found (a prospect company):
├─ Firecrawl.dev crawl → contacts, emails (on demand)
├─ Meta Ad Library → paid-ads signal (free, parallel)
├─ Wayback Machine → website-age / staleness signal (free, parallel)
├─ jobsuche.api.bund.dev → hiring-activity signal (free, parallel)
└─ (on completion) benchmark scoring → 0–100 across 5 weighted dimensions
Targets (+ benchmark)
→ Campaign assignment → threshold rule OR LLM-prompt rule routes each Target into 0..1 campaign
→ Outreach → VirtualCompany / VirtualEmployee senders → Microsoft Graph (Exchange Online)
→ Bandit picks the StepVersion at send time → reply webhook → PipelineEvent
→ Connect → Shifts + call Packages (by benchmark) → CloudTalk → call webhook → PipelineEvent
→ Closing → round-robin pipeline → SignRequest (contract) → GoCardless (payment)
→ Controlling ← reads all of the above
Everything downstream of a Target's research is derived and recomputable. The immutable
audit trail plus the append-only pipeline_events stream form the spine that every KPI
dashboard reads.
Core principles
- One score, computed once, flows everywhere. The benchmark is the single sort key for outreach ordering, call prioritization, and shift assignment. It is versioned so re-scoring never silently rewrites history. Computed once per target (no auto-refresh except a re-research when a >60-day-stale target is assigned to a campaign).
- A Target IS the prospect company; a Target belongs to 0..1 Campaign. There is no separate deduplicated company master reused across many campaigns. A Target may be "unassigned"; re-assignment into a different campaign is supported. Per-campaign state and score live on the Target itself.
- Every integration sits behind an interface. Each external service is its own NestJS module behind an interface so it can be mocked in tests (never called for real in CI). The platform builds its own sender layer (VirtualCompany / VirtualEmployee identities over Microsoft Graph) rather than delegating to a third-party outreach provider. Integration clients live in the Infrastructure layer, wrapped with retries, backoff, and timeouts.
- The pipeline is budget-governed and idempotent. Every paid external call is budget-pre-checked against the campaign budget and aborts if it would exceed; actual cost is recorded after the call. Jobs carry a unique job key for idempotency; per-source rate limits keep us inside provider quotas. At 80% budget → warning email; at 100% → auto-pause + escalation.
- State transitions are the source of truth for reporting. Lifecycle transitions are modeled
as an explicit state machine in the domain layer (enums + guarded transitions — illegal
transitions are impossible), and recorded in the immutable
audit_logstable. Controlling derives KPIs from these rather than bespoke bookkeeping. - Single-tenant build now; fork-per-tenant later. Single-tenant by design — no
tenant_idcolumns, no per-row scoping. Multi-tenancy is achieved by forking the instance, not by tenant isolation inside one deployment (see Tenancy).
Backend layering & feature modules
API-first. The Nest API is the single source of truth; the web app consumes it and never duplicates business logic. Business logic lives in services, never in controllers or React components — controllers and components are thin and call services.
Backend layering:
HTTP (Nest controllers, DTO validation)
→ Application (services / use-cases)
→ Domain (entities, value objects, enums, state machines)
→ Infrastructure (queue processors, integration clients, data layer)
→ Persistence (PostgreSQL, Redis)
The backend is organized into NestJS feature modules under apps/api/src/*, each owning its
controllers, services, domain types, and processors:
apps/api/src/
research/ # ResearchJob orchestration, integration clients, budget guard, provenance
benchmark/ # scoring strategy, 5-dimension weighted score, benchmark versions
campaigns/ # Campaign, Product, AssignmentRule, target auto-routing + re-assignment
outreach/ # OutreachVariant/Step/StepVersion, sequence orchestration, reply processing
bandit/ # Thompson-Sampling variant picker, bandit_events, learning from outcomes
connect/ # ConnectVariant/Step, Shift, Package, call cockpit, KPI + commission calc
closing/ # closing pipeline, appointments, contracts, no-show, warm handoff
virtual-senders/# VirtualCompany, VirtualEmployee, mailbox lifecycle, warmup
controlling/ # read-model aggregates, budget allocation optimization
integrations/ # one module per external service, each behind a mockable interface
auth/ # magic-link auth, sessions/tokens
users/ # User, Role, CampaignAccess, permission layer (CASL)
settings/ # key/value config; third-party secrets stored encrypted
Module boundaries are enforced by review/CI discipline (Domain must not reach into HTTP or
Infrastructure, etc.). Shared React components (shadcn/ui based) live in apps/web and
packages/, kept presentational — they orchestrate API calls, not business rules.
Tenancy
Decision: single-tenant codebase now; multi-tenancy by forking the instance. ⚠️ Needs client sign-off before Phase 2.
- Build as if there is one organization — no
tenant_idcolumns, no per-row scoping. - If it becomes multi-org, the model is fork-per-tenant: stand up a separate instance + database per customer rather than retrofitting per-row isolation.
- Why decide before Phase 2: while there's no production data, the shape is easy to confirm. After real targets land, a schema retrofit is expensive.
Authorization (RBAC)
A role/permission layer (e.g. CASL) with four roles enforced by per-resource checks:
- Admin — everything, including integration credentials, virtual companies, and budgets.
- Supervisor — campaigns, assignment rules, connect agents, shift scheduling, releases, controlling.
- Connect-Agent — the cockpit: sees only released campaigns (nothing without release); assigned shift's targets, scripts, disposition; own KPIs/commission.
- Closing-Agent — the closing pipeline: sees only their round-robin-assigned targets; appointments, contracts; own commission.
Integration contracts
Each integration is a NestJS module behind an interface with a concrete client; all outbound
calls run with retries, exponential backoff, a configurable timeout (default 30s), secret-redacted
logging, and per-call cost recording. Every external call is logged append-only in
research_api_calls. Inbound events arrive on signed webhook endpoints (or WebSocket where
offered). Webhook payloads never trigger business logic before the signature is verified.
| Integration | Interface (module) | Direction | What we send / receive |
|---|---|---|---|
| Anthropic Claude | LlmProvider |
push | mail generation + inbound-reply classification; schema-validated responses; sequential + token-budgeted |
| Serper.dev | PlacesSource |
pull | primary ID source — query by industry + postal code; parallel |
| Firecrawl.dev | CrawlSource |
pull | website crawl on demand → contacts, emails |
| Wayback Machine | EnrichmentSource |
pull | website first-seen / last-updated → staleness signal (free, parallel) |
| Meta Ad Library | EnrichmentSource |
pull | does the company run paid ads → benchmark signal (free, parallel) |
| jobsuche.api.bund.dev | JobSource |
pull | job postings (primary, free, parallel) → hiring signal |
| SerpAPI Google Jobs | JobSource |
pull | fallback only if bund.dev returns nothing |
| TheirStack | EnrichmentSource |
pull | hiring-manager ID, only if not found elsewhere |
| handelsregister.ai | RegisterSource |
pull | company-register data, only if an HR entry is likely |
| INWX | DomainProvider |
push | domain purchases on demand |
| Microsoft Graph | MailProvider |
push + pull | send/receive outreach mail via Exchange Online mailboxes |
| Trulyinbox | WarmupProvider |
background | inbox warmup for virtual-employee mailboxes |
| CloudTalk | DialerProvider |
push + pull | power-dialer call lists per shift; call outcome, recording URL, duration via WebSocket/webhook |
| SignRequest | SignatureProvider |
push + pull | send contract for e-signature; receive signed event |
| GoCardless | PaymentProvider |
push + pull | collect payment after contract close |
Design rule: the interface makes each provider swappable and, crucially, mockable — the test suite runs entirely offline.
Queue & pipeline
- Redis + BullMQ. Named queues per concern:
default,research,outreach-mail,connect-sync,webhooks,notifications. Sized for 30k targets / 20k mails per month; scale by adding workers. - Budget guard on paid jobs: pre-check against the campaign budget before any paid call and abort if it would exceed; record actual cost after. At 80% → warning email; at 100% → auto-pause the campaign + escalation email. This path must be 100% test-covered.
- Rate limiting per source keeps us inside provider quotas.
- Idempotency: each job carries a unique job key; jobs are idempotent where it matters and log start/end with an id.
- Scheduled jobs: research cron triggers (hourly); follow-ups (daily); randomize sick days / plan vacations for virtual employees (daily); enforce campaign budgets (hourly); rotate unassigned targets (hourly); refresh stale research (daily).
Realtime
Socket.IO (or a hosted realtime service — [open]) powers the live call cockpit
(CloudTalk event → UI in < 500ms), pipeline updates across users, and per-user notifications.
Channels: per-user (user.{id}), per-shift (shift.{id}), per-campaign
(campaign.{id}.pipeline). Inbound CloudTalk arrives via WebSocket if offered, else via signed
webhooks that re-broadcast internally.
UI
Decision: shadcn/ui as the component kit on Next.js App Router — compose bespoke screens (agent cockpit, benchmark target list, closing pipeline, controlling dashboards). This is a product with custom UX, not a generic CRUD admin.
- React components are presentational + orchestrate API calls; keep business logic out of components — it belongs in Nest services.
- Drag-drop (closing pipeline, shift board) via a React DnD library.
- Charts use a React charting library for the controlling trend/funnel charts.
- Tailwind 4 design tokens for theming; consistent shadcn/ui components over hand-rolled markup.
- Shared React (shadcn/ui) components so the
/docsdesign showcase and the real app render the same markup (see 04-design-workflow).
Data integrity & testing
- Illegal lifecycle transitions are impossible (enforced by the domain state machine — enums + guarded transitions).
- Benchmark scores are versioned; re-scoring writes a new version, never mutates history.
- Jest/Vitest service + integration tests per module. External integrations are tested against mocked clients (the interface makes this trivial) — no live API calls in the suite. Playwright e2e covers login, pipeline transitions, and the live-call cockpit.
- Webhook endpoints verify signatures; tests cover valid + forged payloads.
- Coverage: ≥ 70% overall; ≥ 90% on services / integrations; 100% on auth, permissions, and budget enforcement.
- ESLint + Prettier +
tsc --noEmitrun green before any module is "done".
Database
Decision: PostgreSQL (recommended, [open]), with Prisma (or Drizzle) as the ORM.
- Why Postgres: JSON/JSONB for the research payloads + benchmark breakdown,
NUMERICfor money, window functions for controlling aggregates, strong constraints. - Provenance is first-class: every stored fact (
research_data_points) carriessource,retrieved_at, andconfidence. - Append-only tables never UPDATE/DELETE:
research_api_calls,bandit_events,audit_logs.
Portability rules
- No raw vendor SQL in business logic — isolate any unavoidable report SQL behind a thin, swappable query layer.
- Use the ORM's column types / migrations (never hand-written raw
CREATE TABLE) so schema stays consistent and reviewable. - Use upserts for dedup/imports.
Settings & config
- App-wide settings live in the
settingstable (key/value, DB-backed). - Integration credentials (CloudTalk, Microsoft Graph, Serper.dev, Firecrawl, Meta, job portal,
SignRequest, GoCardless, …) are stored encrypted in
settings, set via the UI — never in env — except the Anthropic key, treated as an infrastructure credential. - The
/docsdesign showcase is gated by an env-gated route (see 04-design-workflow).
Deferred / later
- Multi-tenancy (fork-per-tenant) — pending client sign-off.
- Budget allocation optimization across sources/campaigns by observed yield (later phase) — a separate concern from the outreach-content Bandit.
- Benchmark weighting A/B testing (later phase).
- Waterfall contact-enrichment fallback (only if Serper + crawl isn't enough).