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
| Field | Type | Required | Description |
|---|---|---|---|
name | string | Yes | Full display name |
firstName | string | No | First name |
lastName | string | No | Last name |
email | string (unique, indexed) | No | Primary email address |
phone | string | No | Office phone number |
mobile | string | No | Mobile phone number |
avatar | string | No | URL to avatar image |
title | string | No | Job title |
department | string | No | Department within the organization |
organization | -> Organization | No | Organization this contact belongs to |
role | enum | No | DecisionMaker, Influencer, Champion, Blocker, or User |
stage | enum | No | Lead, Qualified, Customer, Churned, or Partner |
status | enum | No | Active, Inactive, Bounced, or Unsubscribed |
source | string | No | Acquisition source |
leadScore | number | No | Computed lead score for prioritization |
preferredChannel | enum | No | Email, Phone, SMS, or Chat |
timezone | string | No | IANA timezone identifier |
language | string | No | Preferred language code |
leads | <- Lead[] | No | Leads associated with this contact |
activities | <- Activity[] | No | All activities involving this contact |
manager | -> Contact | No | Manager of this contact |
reports | <- Contact[] | No | Direct reports |
linkedinUrl | string | No | LinkedIn profile URL |
twitterHandle | string | No | Twitter/X handle |
marketingConsent | string | No | Marketing consent status |
lastEngagement | datetime | No | Timestamp of the last interaction |
Relationships
| Field | Direction | Target | Description |
|---|---|---|---|
organization | -> | Organization.contacts | The company this contact works at |
leads | <- | Lead.contact[] | Leads generated by this contact |
activities | <- | Activity.contact[] | Calls, emails, meetings involving this contact |
manager | -> | Contact.reports | Reporting manager (self-referencing hierarchy) |
reports | <- | Contact.manager[] | Direct reports under this contact |
Verbs
| Verb | Event | Description |
|---|---|---|
create | Created | Create a new contact |
update | Updated | Update contact fields |
delete | Deleted | Delete a contact |
qualify | Qualified | Move from Lead to Qualified stage |
capture | Captured | Record a new lead capture with source |
assign | Assigned | Assign the contact to an owner for follow-up |
merge | Merged | Merge duplicate contacts into one record |
enrich | Enriched | Enrich 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
stagemoves to Customer, a billing Customer record links this contact to Stripe.Contact.organization.subscriptionsgives the full billing picture. - Marketing: Contacts belong to Segments and receive Campaigns.
Contact.sourceandContact.leads[].campaigntrack 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.senderandMessage.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
{
"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