Headlessly
CRM

Contact

People -- developers, founders, VCs, partners -- with lifecycle stage tracking and lead scoring.

Schema

import { Noun } from 'digital-objects'

export const Contact = Noun('Contact', {
  name: 'string!',
  firstName: 'string',
  lastName: 'string',
  email: 'string##',
  phone: 'string',
  mobile: 'string',
  avatar: 'string',
  title: 'string',
  department: 'string',
  organization: '-> Organization.contacts',
  role: 'DecisionMaker | Influencer | Champion | Blocker | User',
  stage: 'Lead | Qualified | Customer | Churned | Partner',
  status: 'Active | Inactive | Bounced | Unsubscribed',
  source: 'string',
  leadScore: 'number',
  preferredChannel: 'Email | Phone | SMS | Chat',
  timezone: 'string',
  language: 'string',
  leads: '<- Lead.contact[]',
  activities: '<- Activity.contact[]',
  manager: '-> Contact.reports',
  reports: '<- Contact.manager[]',
  linkedinUrl: 'string',
  twitterHandle: 'string',
  marketingConsent: 'string',
  lastEngagement: 'datetime',
  qualify: 'Qualified',
  capture: 'Captured',
  assign: 'Assigned',
  merge: 'Merged',
  enrich: 'Enriched',
})

Fields

FieldTypeRequiredDescription
namestringYesFull display name
firstNamestringNoFirst name
lastNamestringNoLast name
emailstring (unique, indexed)NoPrimary email address
phonestringNoOffice phone number
mobilestringNoMobile phone number
avatarstringNoURL to avatar image
titlestringNoJob title
departmentstringNoDepartment within the organization
organization-> OrganizationNoOrganization this contact belongs to
roleenumNoDecisionMaker, Influencer, Champion, Blocker, or User
stageenumNoLead, Qualified, Customer, Churned, or Partner
statusenumNoActive, Inactive, Bounced, or Unsubscribed
sourcestringNoAcquisition source
leadScorenumberNoComputed lead score for prioritization
preferredChannelenumNoEmail, Phone, SMS, or Chat
timezonestringNoIANA timezone identifier
languagestringNoPreferred language code
leads<- Lead[]NoLeads associated with this contact
activities<- Activity[]NoAll activities involving this contact
manager-> ContactNoManager of this contact
reports<- Contact[]NoDirect reports
linkedinUrlstringNoLinkedIn profile URL
twitterHandlestringNoTwitter/X handle
marketingConsentstringNoMarketing consent status
lastEngagementdatetimeNoTimestamp of the last interaction

Relationships

FieldDirectionTargetDescription
organization->Organization.contactsThe company this contact works at
leads<-Lead.contact[]Leads generated by this contact
activities<-Activity.contact[]Calls, emails, meetings involving this contact
manager->Contact.reportsReporting manager (self-referencing hierarchy)
reports<-Contact.manager[]Direct reports under this contact

Verbs

VerbEventDescription
createCreatedCreate a new contact
updateUpdatedUpdate contact fields
deleteDeletedDelete a contact
qualifyQualifiedMove from Lead to Qualified stage
captureCapturedRecord a new lead capture with source
assignAssignedAssign the contact to an owner for follow-up
mergeMergedMerge duplicate contacts into one record
enrichEnrichedEnrich with external data (social profiles, job title)

Verb Lifecycle

import { Contact } from '@headlessly/crm'

// BEFORE hook -- validate prerequisites
Contact.qualifying(contact => {
  if (!contact.email) throw new Error('Email required for qualification')
  if (contact.leadScore < 40) throw new Error('Lead score too low')
})

// Execute -- qualify the contact
await Contact.qualify('contact_fX9bL5nRd')

// AFTER hook -- trigger downstream actions
Contact.qualified((contact, $) => {
  $.Deal.create({
    name: `${contact.name} - Inbound`,
    value: 12_000,
    contact: contact.$id,
    organization: contact.organization,
    stage: 'Prospecting',
  })
  $.Activity.create({
    subject: `Follow up with ${contact.name}`,
    type: 'Task',
    contact: contact.$id,
    status: 'Pending',
    priority: 'High',
  })
})

Status State Machine

Stage Progression

Lead --> Qualified --> Customer --> Churned
                          |
                          v
                        Partner
  • Lead: First contact, not yet evaluated
  • Qualified: Passed BANT or scoring threshold
  • Customer: Active paying customer
  • Churned: Former customer who left
  • Partner: Strategic or integration partner

Status Values

  • Active: Engaged, receiving communications
  • Inactive: No recent engagement
  • Bounced: Email delivery failed
  • Unsubscribed: Opted out of marketing

Cross-Domain Patterns

Contact is the central identity entity that connects CRM to every other domain:

  • Billing: When stage moves to Customer, a billing Customer record links this contact to Stripe. Contact.organization.subscriptions gives the full billing picture.
  • Marketing: Contacts belong to Segments and receive Campaigns. Contact.source and Contact.leads[].campaign track marketing attribution.
  • Support: Contact becomes the reporter on Tickets. Historical activities give support agents context.
  • Analytics: Every verb on a Contact (qualify, capture, assign) emits Events that feed Funnels.
  • Communication: Messages link to contacts via Message.sender and Message.recipient.
import { Contact } from '@headlessly/crm'

// When a contact churns, trigger a win-back campaign
Contact.updated((contact, $) => {
  if (contact.stage === 'Churned') {
    $.Campaign.create({
      name: `Win-back: ${contact.name}`,
      type: 'Email',
      segment: 'churned-customers',
    })
  }
})

Query Examples

SDK

import { Contact } from '@headlessly/crm'

// Find qualified leads at enterprise organizations
const qualified = await Contact.find({
  stage: 'Qualified',
  role: 'DecisionMaker',
})

// Get a contact with all related data
const contact = await Contact.get('contact_fX9bL5nRd', {
  include: ['organization', 'leads', 'activities'],
})

// Capture a new lead
await Contact.capture({
  name: 'Bob Rivera',
  email: 'bob@startup.io',
  source: 'demo-request-form',
  organization: 'org_Nw8rTxJv',
})

MCP

headless.ly/mcp#search
{
  "type": "Contact",
  "filter": { "stage": "Lead", "source": "website" },
  "sort": { "leadScore": "desc" },
  "limit": 50
}

REST

# List contacts filtered by stage
curl https://crm.headless.ly/~acme/contacts?stage=Qualified

# Get a specific contact
curl https://crm.headless.ly/~acme/contacts/contact_fX9bL5nRd

# Create a contact
curl -X POST https://crm.headless.ly/~acme/contacts \
  -H 'Content-Type: application/json' \
  -d '{"name": "Alice Chen", "email": "alice@acme.dev", "stage": "Lead"}'

# Qualify a contact
curl -X POST https://crm.headless.ly/~acme/contacts/contact_fX9bL5nRd/qualify

On this page