Skip to main content

CRM Pipeline

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.