Skip to main content

CRM Pipeline

Internal CRM system for managing investor contacts, outreach campaigns, and pipeline tracking. Built on Railway PostgreSQL with agent-driven automation.


Table of Contents


Database Schema

crm_contacts

Primary contact table. Each record represents an investor, partner, or lead.

ColumnTypeDefaultDescription
idUUIDgen_random_uuid()Primary key
user_idUUIDrequiredOwner (FK to users)
tenant_idUUIDnullOptional tenant scope (FK to tenants)
nameVARCHAR(255)requiredContact full name
linkedin_urlVARCHAR(500)requiredLinkedIn profile URL
linkedin_member_idVARCHAR(255)nullLinkedIn member ID
emailVARCHAR(255)nullEmail address
phoneVARCHAR(50)nullPhone number
companyVARCHAR(255)nullCompany / fund name
titleVARCHAR(255)nullJob title
locationVARCHAR(255)nullGeographic location
industryVARCHAR(255)nullIndustry vertical
websiteVARCHAR(500)nullCompany website
notesTEXT''Free-text notes (appended by agents)
tagsJSONB[]Tag array (e.g., ["ai", "devtools", "vc"])
pipeline_stagecrm_pipeline_stage'new'Current pipeline position
scoreINTEGERnullContact quality score (0-100)
sourcecrm_contact_source'manual'How the contact was acquired
source_detailsVARCHAR(500)nullAdditional source context
connection_statuscrm_connection_status'not_connected'LinkedIn connection state
interaction_countINTEGER0Total interactions logged
last_contacted_atTIMESTAMPTZnullTimestamp of last outreach
next_follow_up_atTIMESTAMPTZnullScheduled follow-up date
created_atTIMESTAMPTZNOW()Record creation time
updated_atTIMESTAMPTZNOW()Last modification time

IR Validation Columns (added for the Investor Relations agent):

ColumnTypeDefaultDescription
investor_validatedBOOLEANFALSEWhether the org has been validated as a real investor
investor_validation_reasonTEXTnullWhy validation passed or failed
fund_activeBOOLEANTRUEWhether the fund is still actively investing
fund_active_reasonTEXTnullEvidence for fund activity status
fit_scoreINTEGERnullAINative fit score (1-100), set by IR agent
org_typeVARCHAR(50)nullClassification: confirmed_investor, likely_investor, non_investor, unknown
validated_atTIMESTAMPTZnullWhen validation was performed
portfolio_conflictsJSONB[]List of portfolio companies that are direct competitors

Indexes:

  • idx_crm_contacts_user_id -- owner lookup
  • idx_crm_contacts_tenant_id -- tenant scope
  • idx_crm_contacts_pipeline_stage -- pipeline filtering
  • idx_crm_contacts_company -- company search
  • idx_crm_contacts_next_follow_up -- partial index on non-null follow-ups
  • idx_crm_contacts_user_linkedin -- unique constraint on (user_id, linkedin_url)

Constraints:

  • chk_crm_contacts_score: score must be NULL or between 0 and 100

crm_campaigns

Outreach campaigns that group contacts for batch processing.

ColumnTypeDefaultDescription
idUUIDgen_random_uuid()Primary key
user_idUUIDrequiredCampaign owner (FK to users)
tenant_idUUIDnullOptional tenant scope
nameVARCHAR(255)requiredCampaign name
descriptionTEXT''Campaign description
statuscrm_campaign_status'draft'Current campaign state
channelcrm_campaign_channel'linkedin'Outreach channel
template_idUUIDnullMessage template reference
target_tagsJSONB[]Tags used to filter target contacts
daily_limitINTEGER20Max contacts per day (1-100)
start_dateTIMESTAMPTZnullCampaign start date
end_dateTIMESTAMPTZnullCampaign end date
paused_atTIMESTAMPTZnullWhen campaign was paused
created_atTIMESTAMPTZNOW()Record creation time
updated_atTIMESTAMPTZNOW()Last modification time

Constraints:

  • chk_crm_campaigns_daily_limit: daily_limit must be between 1 and 100

crm_campaign_contacts

Junction table linking campaigns to contacts with per-contact delivery status.

ColumnTypeDefaultDescription
idUUIDgen_random_uuid()Primary key
campaign_idUUIDrequiredFK to crm_campaigns (CASCADE delete)
contact_idUUIDrequiredFK to crm_contacts (CASCADE delete)
statuscrm_campaign_contact_status'pending'Delivery status for this contact
channelcrm_campaign_channel'linkedin'Channel used for this contact
messageTEXTnullActual message sent
sent_atTIMESTAMPTZnullWhen message was sent
opened_atTIMESTAMPTZnullWhen message was opened
replied_atTIMESTAMPTZnullWhen reply was received
created_atTIMESTAMPTZNOW()Record creation time

Constraints:

  • uq_crm_campaign_contacts: unique on (campaign_id, contact_id)

crm_activities

Activity log for all interactions with a contact, both manual and agent-driven.

ColumnTypeDefaultDescription
idUUIDgen_random_uuid()Primary key
contact_idUUIDrequiredFK to crm_contacts (CASCADE delete)
campaign_idUUIDnullFK to crm_campaigns (SET NULL on delete)
user_idUUIDrequiredWho performed the action (FK to users)
agent_registration_idUUIDnullWhich agent performed the action (FK to agent_registrations)
trace_idVARCHAR(255)nullTrace ID for agent observability
activity_typecrm_activity_typerequiredType of activity
descriptionTEXTrequiredHuman-readable description
metadataJSONB{}Arbitrary structured data
created_atTIMESTAMPTZNOW()When the activity occurred

crm_reminders

Follow-up reminders tied to specific contacts.

ColumnTypeDefaultDescription
idUUIDgen_random_uuid()Primary key
contact_idUUIDrequiredFK to crm_contacts (CASCADE delete)
user_idUUIDrequiredReminder owner (FK to users)
titleVARCHAR(500)requiredReminder description
due_atTIMESTAMPTZrequiredWhen the reminder is due
completedBOOLEANFALSEWhether the reminder has been completed
completed_atTIMESTAMPTZnullWhen it was completed
created_atTIMESTAMPTZNOW()Record creation time

Indexes:

  • idx_crm_reminders_due_at -- partial index where completed = FALSE

crm_message_templates

Reusable message templates for outreach campaigns.

ColumnTypeDefaultDescription
idUUIDgen_random_uuid()Primary key
user_idUUIDrequiredTemplate owner
tenant_idUUIDnullOptional tenant scope
nameVARCHAR(255)requiredTemplate name
subjectVARCHAR(500)nullEmail subject line
bodyTEXTrequiredMessage body (supports variable interpolation)
variablesJSONB[]List of template variables
categorycrm_template_category'custom'Template category
toneVARCHAR(50)'professional'Tone: professional, casual, etc.
times_usedINTEGER0Usage counter
avg_reply_rateFLOATnullHistorical reply rate
created_atTIMESTAMPTZNOW()Record creation time
updated_atTIMESTAMPTZNOW()Last modification time

Pipeline Stages

Contacts move through the pipeline in a defined progression. The IR agent and CoS agent automate most transitions.

new --> contacted --> replied --> meeting_scheduled --> negotiation --> won
| | |
| +---> nurture +---> lost
|
+---> lost (bounced)
StageDescriptionSet By
newFreshly imported, not yet contactedImport script
contactedOutreach email sentIR agent
repliedInvestor responded to outreachCoS inbox triage / IR agent
meeting_scheduledMeeting booked (Calendly or manual)Manual / agent
negotiationActive deal discussionManual
wonDeal closedManual
lostBounced email, rejected, or disqualifiedIR agent / CoS agent
nurtureNot ready now, keep warmManual / agent

Enum Types

All enum types are created idempotently via sync-production-schema.py.

crm_pipeline_stage

'new', 'contacted', 'replied', 'meeting_scheduled', 'negotiation', 'won', 'lost', 'nurture'

crm_contact_source

'manual', 'linkedin_api', 'agent_import', 'csv_import', 'mcp_tool'

crm_connection_status

'not_connected', 'pending', 'connected', 'following'

crm_campaign_status

'draft', 'active', 'paused', 'completed', 'cancelled'

crm_campaign_channel

'linkedin', 'email', 'multi'

crm_campaign_contact_status

'pending', 'sent', 'opened', 'replied', 'bounced', 'skipped'

crm_template_category

'connection_request', 'follow_up', 'introduction', 'meeting_request', 'thank_you', 'referral', 'custom'

crm_activity_type

'note_added', 'tag_changed', 'stage_changed', 'reminder_set',
'connection_sent', 'connection_accepted', 'message_sent',
'message_received', 'email_sent', 'email_opened', 'email_replied',
'meeting_scheduled', 'meeting_completed', 'deal_created', 'deal_updated'

Agent Integration

Investor Relations (IR) Agent

Script: scripts/investor_relations_agent.py

The IR agent runs on a cron schedule (4x daily) and processes contacts in small batches of 25.

Read phase -- pull unprocessed contacts:

SELECT id, name, email, company, title, website, linkedin_url,
location, tags, source_details
FROM crm_contacts
WHERE pipeline_stage = 'new'
AND email IS NOT NULL AND email != ''
ORDER BY created_at ASC
LIMIT 25

Validate + Score -- for each contact:

  1. Check if the firm is a known non-investor (hard-block list). If so, mark pipeline_stage = 'lost'.
  2. Look up the firm in a local validation cache (keyed by MD5 of lowercased company name). If cached, reuse the result.
  3. If not cached, perform a lightweight HTTP check on the firm's website looking for investment signals ("portfolio", "we invest", "fund size", etc.) and non-investor signals.
  4. Classify the contact as vc or angel based on title and company name patterns.
  5. Compute a fit score (base 50, +15 for confirmed investor, +10 for likely investor, +15 for priority firm, +10 for named email address, +5 for senior title).
  6. Skip contacts scoring below 50 (left as new for future re-scoring).

Send phase -- generate and send personalized email:

  • VC contacts receive a fund-focused pitch referencing their firm.
  • Angel contacts receive a founder-focused pitch with smaller check language.
  • Unknown contacts receive a generic introduction.
  • Emails are sent directly via gmail-dwd.py send (no drafts, no A2P queue).

Write phase -- update CRM after send:

UPDATE crm_contacts
SET pipeline_stage = 'contacted',
last_contacted_at = NOW(),
interaction_count = COALESCE(interaction_count, 0) + 1,
fit_score = <score>,
org_type = <investor_type>,
updated_at = NOW()
WHERE id = <contact_id>

Bounce + Reply detection (runs at the start of every batch):

  • Bounces: Queries Gmail for from:mailer-daemon@googlemail.com newer_than:3d, extracts bounced email addresses from snippets, and sets pipeline_stage = 'lost' with a [BOUNCED] note.
  • Replies: Queries Gmail for inbox messages matching AINative subject lines, extracts the sender address, and sets pipeline_stage = 'replied' for any contact currently in contacted stage.

CoS handoff: The IR agent reads ~/.openclaw/workspace/memory/ir-handoff.md before each run. Any emails listed under a "DO NOT EMAIL" section are excluded from the batch.

Chief of Staff (CoS) Inbox Triage

Script: scripts/cos_inbox_triage.py

The CoS agent classifies incoming emails into tiers:

TierDescription
HUMAN_ACTIONReal person, needs response
HUMAN_FYIReal person, informational only
PARTNERVendor/partner notification
NEWSLETTEROpted-in digests
TRANSACTIONALReceipts, invoices
COLD_OUTREACHMass cold sales email
SPAMJunk

When a reply from an investor is detected (classified as HUMAN_ACTION), the CoS agent can update the CRM contact from contacted to replied. This feeds back into the pipeline so the IR agent does not re-contact them.

Pipeline Stats

The IR agent exposes a --check mode that prints current pipeline counts:

python3 scripts/investor_relations_agent.py --check

This runs:

SELECT pipeline_stage, COUNT(*) FROM crm_contacts
GROUP BY pipeline_stage ORDER BY count DESC

Import Workflow

Script: scripts/import_vcdb_to_crm.py

CSV Sources

The import script reads from multiple CSV files and deduplicates on email address:

  1. Fondo VCDB SF contacts
  2. Fondo VCDB global contacts
  3. vc_public_contacts.csv (scraped public VC contact data)

Field Mapping

CSV FieldCRM Column
Contact Name or Contactname
Emailemail
Fund Name or Fundcompany
Titletitle
Websitewebsite
LinkedIn or LinkedIn Linklinkedin_url
Locationlocation
Fund focusUsed for tag generation
Fund stageStored in metadata
Fund typeUsed for tag generation
Fund Descriptionnotes (first 500 chars)

Tag Generation

Tags are automatically derived from the fund focus and fund type fields:

  • "artificial intelligence" or "ai/ml" in focus --> ai tag
  • "developer tools" in focus --> devtools tag
  • "saas" in focus --> saas tag
  • "fintech" in focus --> fintech tag
  • "accelerator" in fund type --> accelerator tag
  • "venture" in fund type --> vc tag

Import Defaults

All imported contacts are created with:

  • pipeline_stage = 'new'
  • source = 'csv_import'
  • connection_status = 'not_connected'
  • interaction_count = 0

Post-Import: Mark Sent Contacts

After importing, the script cross-references the A2P review queue (~/.openclaw/workspace/memory/a2p-review-queue.jsonl) to find contacts that have already been emailed. These are updated to pipeline_stage = 'contacted'.

It also checks Gmail for bounced emails (from:mailer-daemon@googlemail.com) and marks those contacts as pipeline_stage = 'lost'.

Usage

# Full import: load CSVs + mark already-sent contacts
python3 scripts/import_vcdb_to_crm.py

# Preview what would be imported (no writes)
python3 scripts/import_vcdb_to_crm.py --dry-run

# Only update pipeline_stage for already-sent contacts (skip CSV import)
python3 scripts/import_vcdb_to_crm.py --mark-sent

Entity Relationship Diagram

users
|
+--< crm_contacts >--< crm_campaign_contacts >--< crm_campaigns
| | |
| +--< crm_activities >--------------------------+
| |
| +--< crm_reminders
|
+--< crm_campaigns
|
+--< crm_message_templates
  • A user owns contacts, campaigns, templates, and reminders.
  • Contacts belong to campaigns through the crm_campaign_contacts junction.
  • Activities reference a contact and optionally a campaign and agent registration.
  • Reminders are scoped to a single contact.