Click any node to see details

Signal-First CRM — System Architecture

Sources → Processing → Storage → Surfaces → Outbound · Feedback loop via sent_* signals
Signal
Unit of truth · Append-only event log · Score-bearing
Contact
One per person per workspace · gtm_company_id FK
Company
First-class · Dedup waterfall · Parent/child supported
Outreach log
Append-only send history · fingerprint_version_id
Persona
Buyer archetype · First-match-wins · Strict matching
Style fingerprint
Voice profile · 3 scopes · Versioned + attributed
📡 Sources
Signals arrive · Each is a webhook handler
Teamfluence
LinkedIn engagement · likes · comments · follows
Workspace UUID
Dripify
LinkedIn outbound · sent / accepted / DM
x-dripify-secret
Unipile
DM replies · AI-classified · DNC on "not interested"
HMAC-SHA-256
Calendly
Meeting booked · Triggers MQL stage transition
HMAC-SHA-256
Resend
Email lifecycle · opened / clicked / bounced
Svix signed
HubSpot
CRM → us · Contact updates · Lifecycle changes
HMAC v3
Stripe
Revenue events · subscriptions · invoices
Stripe-Signature
Attribution app
Click tracking · Separate Vercel deploy · UTM lookup
apps/attribution
MVPR coverage
PR platform API · Optional per-workspace integration
Bearer + baseUrl
Wizard / CSV
One-time onboarding import · Dedup-waterfall applied
First-run only
⚙️ Processing — derive meaning
Pure functions · No I/O of their own · Tested in isolation
Score derivation
signal_score = Σ score_delta · Per-workspace verb weights · Threshold-driven funnel stage
Persona match
First-match-wins on matchPatterns · STRICT on country / employee band
Fingerprint resolution
3 scopes resolved least-to-most-specific · corporate → channel → channel_persona
Stage transitions
Meeting booked → Discovery Call · Don't-regress guard · ADR-012
DNC / exclusion
DNC = temporal · Exclude = permanent · Bounce / complaint / manual
Dedup waterfall
linkedin_url → domain → canonical_name · Race-safe partial unique indexes
💾 Storage — 3 layers
Upstash Redis
workspace:{id}:config · Encrypted secrets · enc: prefix
AES-256-GCM
Postgres
The projection · Indexed on workspace_id · Append-only
Source of truth
HubSpot mirror
Secondary projection · Postgres → CRM · Best-effort
Lossy by design
📤 Outbound
Drafted by LLM · Sent by humans · Logged for attribution
Drafter
LLM + persona + fingerprint + signals · Anthropic-powered · Records fingerprint_version_id
claude-sonnet
LinkedIn DM
Direct send · Invite queue cron · 80/day cap respected
Unipile out
Email
send-outbound.ts · Resend wrapped · Per-role sender resolution
Resend out
Newsletter
Broadcast pipeline · Scheduled send · Tracked links
campaign-attached
Outbound Calls
Log-only · NOT a fingerprint channel · ADR-005
Manual log
🔍 Enrichment — vendors fill in missing contact + company data
📇
Surfe
Email lookup by LinkedIn URL · Job title verification · Company match
🎯
Apollo
Alt contact enrichment · Same shape as Surfe · Workspaces pick one or both
🧪
Clay
Multi-step enrichment workflows · For more complex lookups
📊
Moz
Domain authority lookup · Company-level signal weight
🕷️
Apify
Web scraping · LinkedIn company employee data · Async with poll
⏰ Cron jobs — operational rhythm (vercel.json)
📇
enrichment-poll
Daily 10:00 · Pulls completed enrichment results from queued jobs
🔗
linkedin-invite-queue
Every 15 min · Spreads connection requests under LinkedIn's daily cap
🤝
linkedin-connected-sweep
Daily 02:30 · Detects newly-connected contacts missed by webhooks
✍️
refit-fingerprints
Daily 03:00 · Re-fits style fingerprints from recent send + reply data
✉️
email-freshness
Daily 11:00 · Flags stale email addresses for re-verification
💰
allocate-platform-cost
Daily 04:00 · Aggregates usage_log entries into per-workspace cost totals
💳
stripe-reconcile
Daily 04:45 · Reconciles Stripe subscription state with local projection
📰
mvpr-coverage-sync
Every 6h · Pulls PR coverage + announcements from MVPR API (optional)
🖥️ Surfaces — humans use these
📊
SDR action list
Score-sorted feed · Persona chips · Inline draft + send
🏢
Companies view
Account rollup · Prospect-type chips · Unfurl contacts
📤
Delivery
Channels + campaigns · Templates · Attribution
📈
Reports
PR coverage · Costs · Funnel · Click attribution
⚙️
Settings
Integrations · Webhooks · Personas · Scoring · DNC
📚 Knowledge base — read these BEFORE customising
🧭
Design tenets · What would break the system
📖
Canonical product terms · Worth knowing the words
🏗️
Storage layers · Lifecycle of a signal · Boundaries
🤖
Operating manual · Auto-loads in Claude Code
📜
13 ADRs · One per non-obvious decision
🍳 Cookbook — drill into specifics
🪝
Per-provider auth + idempotency + verbs
📣
Campaign + delivery flow · Click attribution
👥
5 paths people enter · Dedup behaviour
✍️
LLM prompt layering · Footguns · Attribution
🚀
Step-by-step from zip → live dashboard
Core entities
Sources (webhooks)
Processing
Storage
Surfaces
Outbound
Enrichment
Cron jobs
Neutral / log-only

Lifecycle of a signal

Follow a single signal from arrival to outbound + back. The dashed feedback arrow back into Processing is the system's most important property: every outbound action becomes an inbound signal. That's what makes the audit trail honest.

1.A signal arrives · 📡 Sources

A prospect likes a post on LinkedIn. Teamfluence detects it, fires a webhook at /api/webhooks/[workspaceId]/teamfluence. The handler verifies the request, extracts actor / verb / object / timestamp, looks up the contact by LinkedIn URL (creating one if not found), and resolves the company through the Dedup waterfall (LinkedIn URL → domain → canonical name).

2.Processing the event · ⚙️ Processing

Score derivation applies the per-workspace weight for liked_post (e.g. +3 points). Total signal_score updates. The contact's funnel stage is recomputed against WorkspaceConfig.scoring.thresholds. DNC / exclusion is checked — if set, outbound paths skip them but the signal still records (you want the engagement history even for unreachable contacts).

3.Persistence · 💾 Storage

A new signals row is appended in Postgres (append-only). The contact's signal_score, signal_count, last_signal_at are updated in the same transaction. For Teamfluence signals, a HubSpot timeline event is also written (best-effort — if HubSpot is down, Postgres still succeeded). Dripify signals stop at Postgres by design.

4.Surfacing · 🖥️ Surfaces

On the SDR action list, the contact's row reorders by last_signal_at DESC and floats to the top. The Companies view aggregates company-level score. The funnel stage label updates (e.g. "Engaged" → "Highly Engaged" via the display-label override).

5.Outbound · 📤 Outbound (when the seller acts)

The seller clicks "Draft DM." The Drafter pulls the contact + recent signals, matches a persona via pickPersona(), resolves the fingerprint through three scopes, renders the prompt with persona + fingerprint + signal context, hits Anthropic, returns the draft. Seller reviews + sends.

6.Feedback loop · 📤 → 📡

The send appends a new signals row with verb sent_dm AND an outreach_log row recording fingerprint_version_id — critical for outcome attribution. When the prospect replies, Unipile fires replied_dm_initial. If they book a meeting, Calendly fires booked_meeting which triggers the Discovery Call stage transition. The funnel keeps moving; the score stays recomputable.

Companion to MAP.md · When the diagram drifts from the code, update both in lockstep
README · SCAFFOLD · PHILOSOPHY · CLAUDE · LICENSE