Skip to main content
AgentSDR is one Next.js 16 App Router application (React 19, TypeScript, Tailwind) on one PostgreSQL database, accessed through Drizzle ORM and the postgres driver. It runs as a single Node/Bun process. There is no message broker and no separate worker deployment: background work runs inside the app process, plus a few HTTP endpoints that an external scheduler calls. The product covers: a lead database (People and Companies), enrichment tables, email, LinkedIn and WhatsApp outreach, WhatsApp calling, an AI-assisted CRM with a master inbox, and analytics. Every piece of business data belongs to one organization (a tenant).

Overview

Directory map

src/lib/<domain>

src/app/api

Route handlers are grouped by domain: auth (Better Auth), grid, leads, outreach, linkedin, whatsapp, calling, calls, call-recorder (the extension), crm, analytics, ai, settings, webhooks (Unipile), plus a few older ones (campaigns, categories, domains, qualify, targeted-domains, apps, export, nav, dev). Which of these are public is listed at the top of src/proxy.ts.

Request flow

  1. src/proxy.ts runs first. It lets through a short list of public paths (auth pages and endpoints, Unipile and Gmail webhooks, the call-recorder API, unsubscribe pages, static files). For /api/linkedin/jobs it accepts Authorization: Bearer $CRON_SECRET. Everything else needs a Better Auth session cookie; without one, pages redirect to /sign-in and APIs answer 401. This check is optimistic only: it does not validate the session. It also applies the migration kill switches.
  2. The route or page calls requireOrgContext(request) / withOrgContext(request, fn) (API) or requirePageOrgContext() (pages) from src/lib/auth/context.ts. This validates the session, reads the active organization, and re-reads the member’s role from the database on every call, so removing someone takes effect immediately. Failures become 401, 403 or 409 JSON.
  3. withOrgContext opens an AsyncLocalStorage scope with runInOrganization(orgId, fn). Code in src/lib/ never takes the session: it reads the organization from the scope (currentOrganizationId()), which is what lets workers and webhooks reuse it. Reading the scope outside one throws.
  4. Queries filter every scoped table with inOrg(table), and inserts set organizationId: currentOrganizationId(). An id from another organization is simply not found (404).
  5. Drizzle sends SQL through the one pool in src/lib/db.ts.
The full rules are in multi-tenancy/conventions.md.

Background work

Workers and webhooks have no session. They resolve the organization from the data (mailbox, Unipile account, call session, grid job, CRM job) and run each item inside runInOrganization. The outreach scheduler has no distributed lock, so the app must run as a single instance.

Auth and tenancy

Better Auth provides email and password (verified by email), optional Google sign-in, organizations and teams. Roles are owner, admin and member: members use the product; owners and admins also manage integrations, AI settings, members and teams. Better Auth tracks the active organization on the session; scoping data to it is done by this codebase (src/lib/tenancy), not by the library. Each table is classified in src/lib/tenancy/registry.ts as scoped (has its own organization_id), inherited (scoped through a NOT NULL parent), global, auth or legacy, and bun run db:check fails if a table is unclassified. See multi-tenancy/conventions.md and multi-tenancy/plan.md.

Integrations store

Platform integrations (Unipile, Google Workspace, R2) are one row each per organization in grid_providers (key platform-<key>), with the credentials encrypted in grid_provider_credentials using INTEGRATION_CREDENTIALS_KEY (AES-256-GCM). Reads are cached 30 seconds per process. OpenRouter and the enrichment providers use the same tables. Code that calls one of these services asks src/lib/platform/credentials.ts (getPlatformCredentials, requirePlatformCredentials, isPlatformConnected) inside the organization scope, and never reads keys from process.env. See integrations.md.

Data model overview

84 tables in db/schema.sql. By domain: people.id is the permanent identity shared by every channel. people and companies gain user-defined columns at runtime. Details and the rules for changing the schema are in database.md.

Other parts

  • WhatsApp call recorder. extensions/whatsapp-recorder/ is a Chrome extension that dials and records calls inside web.whatsapp.com. It shares src/lib/calls/contract.ts with the app and uploads to R2 through presigned URLs, authenticating each call with a bearer token.
  • Formula sandbox. Table formulas run in a QuickJS isolate (src/lib/grid/runners/sandbox.ts) with lodash, moment and formulajs loaded from node_modules at runtime (listed in next.config.ts outputFileTracingIncludes so the standalone build includes them).
  • Build output. next.config.ts sets output: "standalone"; the Docker image runs bun server.js from that output.