Building a custom CRM for a startup
A bespoke CRM for a health-tech startup, built from scratch in 5 days. 23 tables, 8 state machines, and a stale detection system that knows when you're neglecting a psychologist.
I should probably explain.
3C Labs is a health-tech startup. Two people. They recruit psychologists, advisors, and research fellows into collaborative projects — clinical validation studies, regulatory advisory boards, co-authored papers. Their CRM was a Notion table with colored tags. It worked until it didn't, which was around the time they needed to track not just who someone was, but whether that person fit the organization, whether they fit a specific project, when someone last talked to them, and whether anyone was actually going to follow up.
It's that moment where the overhead finally hits a tipping point. You start calculating the time it would take to script a custom solution versus just paying the convenience tax.
1. The problem with off-the-shelf
The issue isn't that Salesforce can't track contacts. It can. The issue is semantic: a psychologist isn't a "lead," a research paper isn't an "opportunity," and "closing a deal" is a weird way to describe inviting someone to co-author a clinical validation study.
3C's relationship model has two distinct phases that no generic CRM can express natively:
The Two-Phase LifecyclePhase 1 — Assess Fit. General evaluation. Does this person belong in the 3C network at all? States: new → contacted → evaluating → won / lost.
Phase 2 — Prospect. Project-specific. Does this person fit this particular quest? States: new → contacted → in_conversation → opportunity → won / lost.
A contact can win the general assessment — great psychologist, solid fit for the network — and lose a specific quest prospect because their specialization doesn't match the study. These are separate tables with different schemas, different status enums, and different side effects on transition.
In Salesforce, you'd model this as two custom objects with custom workflows and enrollment triggers. In HubSpot, you'd call enterprise sales and schedule a "solutions architect" meeting. Here it's a first-class design decision baked into the schema: assessFits and prospects are different tables because they represent different questions about the same person.
But the lifecycle is just the beginning. The full data model has 23 tables. Here's what the off-the-shelf world can and can't cover:
Table What it does Off-the-shelf?
contacts People ✓
organisations Orgs + discovery metadata ✓
contactAffiliations Rich person-org links — role, type, strength, temporal data, academic metadata. 10 fields. 1 field
contactInterests Per-item interests, each with source and meeting provenance. Starrable for serendipity flagging. 1 flat field
contactConnections Who they know. Mutual auto-creation — add "Alice knows Bob" and Bob's profile updates too. ✗
assessFits Pre-quest general evaluation pipeline ✗
prospects Quest-specific prospect pipeline Different semantics
quests Self-referencing project tree with 7-state lifecycle and participant tracking ✗
questParticipants Contact ↔ Quest many-to-many with ancestor chain propagation ✗
tasks 7-state lifecycle, multi-assignee, linked to contacts and quests 3 states, 1 assignee
meetings Full lifecycle: prep → notes → debrief. Completeness scoring. No prep, no debrief
meetingAttendeeNotes Per-person structured observations from each meeting ✗
ponderings Polymorphic notes that attach to any entity via refType + refId Notes on contacts only
statusTransitions Full audit trail — every status change, timestamped, across all entities ✗
contactSleepHistory Hibernation log with reasons and wake triggers ✗
resources Papers, tools, links with provenance and quest associations ✗
HubSpot covers about 5 of these directly. The remaining 18 are the product.
26 · mar 11(3d earlier) Day 2. Removed the stories table entirely. It was an abstraction that sounded smart on the whiteboard — narrative containers for groups of quests — but added zero value in practice. Prospects now link directly to contacts. Should have seen it sooner. 9 commits, but the data model restructure was effectively a v2.
The speed argument matters. 30 schema migrations in 5 days. Day 3 alone shipped 14 — added assess fits, contact sleep, task assignees, resource types, built a shared component library, and deprecated an entire view. The whole stack is one repo. Schema changes go from schema.ts → drizzle-kit generate → wrangler d1 migrations apply in under a minute.
Try adding 14 custom objects to Salesforce in an afternoon.
2. State machines all the way down
Every CRM is a state machine pretending to be a database. You don't notice until you build one from scratch, and then suddenly every entity has a lifecycle, every lifecycle has transitions, and every transition has side effects that cascade into other lifecycles.
This CRM has 8 of them. I'll show three.
the one with the cascade
Quests and tasks share a unified 7-state lifecycle: theorizing → planned → prospecting → active → paused → done → killed. Not every transition is valid — you can't jump from theorizing to done because you can't finish what hasn't started, and terminal states can only reopen to specific re-entry points.
TYPESCRIPT Copy
const STATUS_TRANSITIONS: Record<string, string[]> = { theorizing: ['planned', 'prospecting', 'killed'], planned: ['theorizing', 'prospecting', 'active', 'paused', 'killed'], prospecting: ['planned', 'active', 'paused', 'killed'], active: ['planned', 'prospecting', 'paused', 'done', 'killed'], paused: ['theorizing', 'planned', 'prospecting', 'active', 'done', 'killed'], done: ['active'], killed: ['planned', 'paused'], }
paused is the escape hatch — it can reach almost any state. done and killed are near-terminal: they can only reopen, not recategorize. This isn't arbitrary. A killed project can be resurrected into planned or paused, but it can't jump to active — because if you're bringing something back from the dead, you should plan it first.
The cascade is the interesting part. Quests are self-referencing trees — a quest can be a sub-quest of another. When a parent quest is marked done or killed, the status propagates to every descendant quest and every task under each of them. A single status change on a top-level quest can touch 15 entities. Each one gets its own statusTransitions entry with a timestamp, so the audit trail stays clean even when the cascade runs deep.
Both frontend and backend carry the same transition map 1. Click a status that shouldn't exist? The dropdown won't show it. Craft a PATCH request manually? The API returns 400.
1Deliberate duplication. The frontend disables invalid options in the dropdown so users never see a state they can't reach. The backend validates anyway because the frontend is a suggestion, not a contract. The alternative — a shared validation package between a React SPA and a Hono API — would add complexity for a marginal DRY benefit. At this scale, I'll take the copy-paste.
the one with the side effects
Assess Fit has 5 states and deceptively simple transitions. But the side effects are where the engineering lives.
When an assess fit moves from new to contacted for the first time, the API silently sets lastManualContact on the contact. That single timestamp starts the stale detection clock. Before this moment, the contact can't be stale — you haven't engaged yet, so there's nothing to decay. After it, every day of silence counts.
When an assess fit is marked lost, all active tasks directed at that contact auto-pause. If someone failed the general assessment, there's no point keeping "send them the project brief" on your to-do list. When you reopen a lost assess fit back to new, the paused tasks auto-resume to planned. The system remembers what you were doing before you gave up.
And then there's won. A single POST /assess-fits/:id/won triggers up to five things:
Assess fit status → won
Roles merged onto the contact's networkRoles
Optionally: a prospect created for a specific quest
Optionally: contact added as quest participant
Ancestor quests in the tree also get the participant
Five side effects, one API call. In HubSpot that's a custom workflow with enrollment triggers and a prayer that nobody renames a property.
the one that isn't stored
The most important state machine in the system doesn't have a database column.
Contact category — team, assessing, network, lost, other — is computed on every API response from the underlying data:
TYPESCRIPT Copy
if (contact.isTeamMember) category = 'team' else if (afRows.some(af => af.status !== 'won' && af.status !== 'lost')) category = 'assessing' else if (afRows.some(af => af.status === 'won') || networkRoles.length > 0) category = 'network' else if (afRows.some(af => af.status === 'lost')) category = 'lost' // else: 'other'
The transitions happen as side effects of other state machines. Win an assess fit and the contact silently shifts from assessing to network. Mark yourself as a team member and you jump to team. There's no updateCategory() function. The category is a shadow cast by the data — always current, never stale, never out of sync.
It can't be wrong because nobody writes to it.
26 · mar 12(2d earlier) Day 3. 38 commits. 14 migrations. Added assess fits, contact sleep, task assignees, resource types. Built the shared component library. Deprecated and removed FollowUpView — it was trying to be a dashboard and a follow-up tool at the same time. Pick one.
3. Detecting relationship decay
Relationships decay silently. Nobody sends you an email saying "hey, you're losing me." The connection just goes cold — one missed follow-up, then two, then three weeks pass and reaching out feels awkward. In a startup that lives on its network, that silence is the most expensive bug.
3C tracks around 100 contacts across active assessments and network relationships. At that scale, you can't hold the state of every relationship in your head. You need the system to tap you on the shoulder.
three sources, one clock
The stale detection fuses three independent data sources into a single lastAction timestamp:
TYPESCRIPT Copy
const lastMeeting = lastMeetingMap.get(contact.id) // MAX(date) WHERE status='done' const lastEmail = lastEmailMap.get(contact.id) // MAX(date) WHERE to_address matches const lastManual = contact.lastManualContact // set via /contacts/:id/contacted const lastAction = [lastMeeting, lastEmail, lastManual] .filter(Boolean).sort().pop()
Three lines of code. The most recent interaction wins, regardless of source.
Done meetings count. Scheduled ones don't — having a calendar invite doesn't mean you've talked. Only outbound emails count. Receiving an email from someone doesn't reset their stale clock because staleness measures your engagement, not theirs 1. Manual contact covers everything the system can't see — phone calls, WhatsApp, bumping into someone at a conference.
1There's a feedback loop hiding here. An inbound email doesn't reset the stale clock — but it does appear in the Awaiting Reply panel, which creates social pressure to respond. When you reply, that outbound email resets the clock. The system nudges without lying.
the escalating threshold
A contact is stale when daysSinceAction > 7 × (snoozeCount + 1).
Snooze count Threshold Meaning
0 7 days Default. One week of silence.
2 21 days "We're waiting." First snooze.
4 35 days Still waiting. Second snooze.
6 49 days Deliberately slow-burning. Third snooze.
Why +2 per snooze, not +1If a contact has been stale for 15 days (count=0, threshold=7), incrementing the count by 1 sets the threshold to 14 — still stale. The alert stays. You'd have to snooze twice to clear it once, which makes the button feel broken. Incrementing by 2 sets the threshold to 21, which guarantees the alert clears in a single click. Small arithmetic, big UX difference.
Manual contact is the nuclear option. It resets lastManualContact to today and resets staleSnoozeCount to 0. Clean slate. The clock restarts because you've actually engaged — not just postponed the reminder.
immediate vs. tasked
When a contact is stale, it falls into one of two categories:
Immediate — no active tasks. Nobody is doing anything about this relationship. It's going cold. Highest urgency.
Tasked — has active tasks but hasn't met the interaction threshold. Someone is working on it, but slowly. Lower urgency — a yellow flag, not a red one.
The distinction matters because the response is different. Immediate means "drop everything and send an email." Tasked means "check if the person with the open task is making progress."
None of this runs on a cron job. Stale is computed on demand — every time you load the contact list, the API runs four batch queries, fuses them, and returns the stale flag inline. No background workers, no scheduled tasks, no cache that's stale about staleness. D1 is SQLite on Cloudflare's edge. At startup scale with ~100 contacts, the queries are fast enough that the simplest architecture is the right one.
26 · mar 13(1d earlier) Day 4. Stale logic reworked three times. First version tracked meetings only — useless if you communicate by email. Second version added Gmail but didn't handle snooze correctly. Third version fuses all three sources and has the +2 snooze increment. 24 commits. Also unified all contact affiliations into a single table (migration 0024) — replaced three separate data sources that had drifted out of sync.
4. Engineering notes
Things that don't fit the narrative arc but are too interesting to leave out.
AI-powered meeting debrief. Upload a transcript after a meeting is done. A single API call sends it to Claude Sonnet 4, which auto-creates outcomes, attendee notes, tasks, ponderings, interests, projects, connections, and resources. The prompt includes meeting context — prep notes, agenda, key questions — and enforces quality with BAD/GOOD examples. What used to be 30 minutes of post-meeting data entry is now a paste and a click.
Cycle resolution in tree drag-and-drop. Drag a parent quest onto its own descendant — which would create a circular reference — and the system doesn't block the drop. It promotes the target node to the dragged item's old parent, then nests the dragged item inside. Every drop succeeds. The tree stays valid. UX over defensive programming.
Global undo with distributed closures. Every destructive action stores an async closure on a global stack. Ctrl+Z pops and executes the most recent one. No centralized command pattern — each component writes its own reversal logic. Toast with inline "Undo" button, 5-second window, 15-minute TTL, max 30 entries. Smart detection skips Ctrl+Z inside text inputs and contentEditable elements.
Zero TODO markers. grep -r "TODO\|HACK\|FIXME" across the entire codebase returns nothing. Not because there's no technical debt — because every compromise was accepted at implementation time rather than deferred. The code documents what was consciously traded off, not what was left half-done.
The CRM has been running for four days. Two people use it. It does exactly what they need.
It doesn't scale to 10,000 users. It doesn't need to. There's no permission system beyond Cloudflare Zero Trust — if your email ends in @3clabs.io, you're in. The API has no rate limiting because two users aren't going to DDoS their own tool. Every architectural decision reflects a specific constraint at a specific scale, and that specificity is the point.
When the domain is yours and the stack is simple, building custom isn't the reckless choice. Configuring an enterprise CRM to do something it wasn't designed for — forcing psychologists into "Leads," research papers into "Opportunities," relationship decay into a field that doesn't exist — that's reckless. Eighteen tables that HubSpot can't model aren't a feature request. They're a different product.
26 · mar 14(day zero) 178 commits. 30 migrations. 32,058 lines. It works.