72 hours to find out if I could
A bespoke CRM for a health-tech startup, built solo in a 72-hour marathon. Half experiment in vibecoding, half real product. 23 tables, 8 state machines, and a stale-detection system that knows when you're neglecting a psychologist.
Hour 9. I'd just told a model to "add a contact sleep table" and watched it hand me back a migration that silently dropped lastManualContact (the one column the entire stale-detection system hangs on) because it had decided the schema read cleaner without it. I caught it in the diff. Barely. The cursor was already over the apply command.
That's the moment I understood what I'd actually signed up for.
The CRM was the excuse. The bet was the thing. Seventy-two hours, no team, no plan longer than a napkin: ship something a real startup would open on Monday and not flinch. 3C Labs needed it for real (that part wasn't theater), but I'd have found another excuse if they hadn't. I wanted to know if I could.
26 · mar 11(3d earlier) hour zero. one repo, two coffees, and a bet with myself I wasn't sure I'd win.
1. The berserker and the integrator
There are two ways to write code with a model in the loop, and on a team they're usually two different people who quietly resent each other.
The berserker opens the throttle. Generate, paste, run, generate again. Leaves forty files half-wired and a commit history that reads like a panic attack. Fast, fearless, and wrong about a third of the time, but the third that's right gets you a working endpoint before the integrator has finished naming the folder.
The integrator walks in behind. Reads the wreckage, deletes two thirds of it, finds the three places the berserker created an impossible state, and makes the rest hold weight. Slow, suspicious, allergic to anything clever.
I wanted to be both. Same skull, 72 hours, switching fast enough to feel the seam. That was the actual experiment. The CRM is just what the experiment happened to produce.
I'd been coding with geohot streams running on a second monitor, sound off: comma.ai's founder building things live, deleting more than he writes, openly contemptuous of abstraction. Some of his register leaked into mine over those three days. The blunter conclusions below are probably half his.
Here's the first one, and it set the whole architecture: a CRM is a state machine wearing a database as a disguise. Every enterprise tool is. They just charge you a seat license to not have to see it.
2. 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 deranged way to describe inviting someone to co-author a clinical validation study.
3C's relationship model has two distinct phases that no generic CRM expresses 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 because their specialization doesn't match the study. Separate tables, different schemas, different status enums, different side effects on transition.
In Salesforce you'd model this as two custom objects with workflows and enrollment triggers. In HubSpot you'd call enterprise sales and schedule a meeting with a "solutions architect." Here it's a first-class design decision baked into the schema: assessFits and prospects are different tables because they're different questions about the same person.
And this is where the cheese comes in.
HubSpot is a perfect wheel of cheese. Industrial, uniform, no holes, costs what it costs. What I built is a gruyère: full of holes, and every hole is a feature it will never have. No mobile app. No marketing automation. No permission system beyond is your email @3clabs.io.
But nobody chooses the cheese for the holes. You don't stand at the counter counting them. You buy it because it feeds the people at your table. What matters is the carbohydrates.
A cheese has almost no carbohydrates. I know. That's the joke: I picked the wrong nutrient on purpose, because everyone lining my 23 tables up against HubSpot's feature matrix is measuring the wrong nutrient too. The question was never does it have everything. The question was does it feed these two specific people. It does.
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 holes. The remaining 18 are also the product. Both things are true, which is the whole argument.
26 · mar 12(2d earlier) hour ~18. killed the stories table entirely. it was an abstraction that sounded smart on the whiteboard (narrative containers for groups of quests) and added exactly zero value once real data hit it. prospects now link straight to contacts. the berserker built it at hour 4; the integrator deleted it at hour 18. 9 commits to undo one good idea.
The speed argument is real. 30 schema migrations in 72 hours. One stretch shipped 14 in an afternoon: assess fits, contact sleep, task assignees, resource types, a shared component library, an entire deprecated view. The whole stack is one repo. Schema change goes schema.ts → drizzle-kit generate → wrangler d1 migrations apply in under a minute.
Try adding 14 custom objects to Salesforce in an afternoon. You can't even find the button in an afternoon.
3. Why everything is a state machine
I decided early (hour 2, maybe) that I'd model every entity as an explicit state machine with a written-down transition table, even the ones that "obviously" only needed a status string.
That decision was the integrator pre-empting the berserker.
Because here's what happens when you let a model generate CRUD at full tilt: it invents states. It'll happily write code that moves a task from done straight to prospecting, or marks a quest active that was never planned, because nothing told it those edges don't exist. A status string is just a label: it doesn't forbid anything. By hour 6 you have a database full of records in configurations that should be physically impossible, and you find out when a user clicks something and the UI does a thing nobody designed.
A transition table forbids. It's the cheapest possible leash. And it's the one form of structure I trusted the berserker not to route around, because it fails loud:
The dumb explicit thing beats the clever general thingA real backend engineer would have reached for XState or a proper state-chart library here. I wrote a Record<string, string[]> and a lookup function. No statecharts, no actors, no hierarchical machines. It's almost certainly wrong by their standards, and it never once broke in 72 hours. Abstractions are bets that you understand the problem. At hour 6 you understand nothing, so you write the dumb explicit thing, let it be ugly, and let it be checkable.
This CRM has 8 of these. 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 legal: you can't jump theorizing → done because you can't finish what hasn't started, and terminal states 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 reaches almost anything. done and killed are near-terminal: they reopen, they don't recategorize. A killed project can come back to planned or paused, but not straight to active: if you're raising the dead, you plan it first.
The cascade is the interesting part, and it's also where I lost a night. Quests are self-referencing trees; a quest can be a sub-quest of another. Mark a parent done or killed and the status propagates to every descendant quest and every task under each of them. A single change at the top can touch 15 entities, each getting its own timestamped statusTransitions entry so the audit trail survives the cascade.
In HindsightThe first version of the cascade was a naive recursive walk the berserker generated in about thirty seconds, and it was beautiful until hour 31, when I tested it against a quest tree that had (through a bad parent pointer an earlier migration left behind) a cycle in it. Quest A's parent was B, B's parent was A. The recursion didn't stop. Node's stack didn't survive. The wrangler tail just printed Maximum call stack size exceeded over and over while I sat there at 3am doing the math on how a tree had a cycle. The fix was two lines (a visited set), but the real fix was realizing the integrator should never have trusted a tree to actually be a tree. I added a parent-pointer validation on write the next morning. Should've been there at hour 4.
Both frontend and backend carry the same transition map 1. Click a state that shouldn't exist? The dropdown won't show it. Hand-craft the PATCH anyway? The API returns 400. The leash holds at both ends.
1Deliberate duplication. The frontend greys out invalid options so users never see a state they can't reach; the backend validates anyway because the frontend is a suggestion, not a contract. A shared validation package between a React SPA and a Hono API would buy a marginal DRY win for real complexity. At this scale I'll take the copy-paste.
The one with the side effects
Assess Fit has 5 states and deceptively boring transitions. The side effects are where the engineering actually lives.
When an assess fit moves new → contacted for the first time, the API silently sets lastManualContact on the contact. That single timestamp starts the stale-detection clock. Before that moment the contact can't be stale: you haven't engaged, so there's nothing to decay. After it, every day of silence counts.
Mark an assess fit lost and every active task aimed at that contact auto-pauses. If someone failed the general assessment, "send them the project brief" has no business sitting on your list. Reopen the lost fit back to new and the paused tasks auto-resume to planned. The system remembers what you were doing before you gave up on someone.
And then won. A single POST /assess-fits/:id/won fires 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 a quest participant
Ancestor quests in the tree also get the participant
Five side effects, one 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 the other machines. Win an assess fit and the contact silently slides from assessing to network. There's no updateCategory() anywhere. The category is a shadow the data casts, always current, never out of sync, because nobody writes to it.
It can't be wrong because there's nothing to get wrong. That's the only kind of code I fully trust at hour 40.
26 · mar 13(1d earlier) hour ~40. 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 once. pick one. the berserker keeps building things that want to be two things; the integrator's whole job this weekend is making them pick.
4. Detecting relationship decay
Relationships decay quietly. Nobody emails you "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. For a startup that lives on its network, that silence is the most expensive bug in the building.
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
Stale detection fuses three independent 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. The most recent interaction wins, whatever its source.
Done meetings count; scheduled ones don't. A calendar invite isn't a conversation. Only outbound email counts, 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 clock, but it surfaces in the Awaiting Reply panel, which creates the social pressure to respond. Reply, and the outbound email resets the clock. The system nudges without lying.
That clean three-line version is the third rewrite. It did not arrive clean.
In HindsightVersion one tracked meetings only, useless the second you remember most of this relationship lives in email. Version two bolted on Gmail but I let the model wire the snooze logic and it confidently produced an off-by-one that snoozed forward in time, so snoozing a stale contact made it more stale. I shipped that. It was live for about six hours before one of the two users asked, very politely, why the button made things worse. Version three is the fusion above, with the snooze arithmetic I should've sat down and done by hand the first time. A model will hand you something that runs and is wrong, and "runs" is the more dangerous half.
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 15 days (count=0, threshold=7), bumping the count by 1 sets the threshold to 14, still stale. The alert refuses to die. You'd have to snooze twice to clear it once, which makes the button feel broken. Increment by 2 and the threshold jumps to 21, clearing the alert in a single click. Tiny arithmetic, enormous UX difference. This is the kind of thing no model gets right by default, because it requires knowing how a button is supposed to feel.
Manual contact is the nuclear option: it resets lastManualContact to today and zeroes staleSnoozeCount. Clean slate. The clock restarts because you actually engaged, not because you postponed the reminder.
Immediate vs. tasked
When a contact is stale, it lands in one of two buckets:
Immediate: no active tasks. Nobody's doing anything about this. It's going cold. Highest urgency.
Tasked: has active tasks but hasn't met the threshold. Someone's on it, slowly. A yellow flag, not red.
The distinction matters because the response differs. Immediate means "drop everything, send an email." Tasked means "go check whether the person holding the open task is actually moving."
None of this runs on a cron. Stale is computed on demand: load the contact list and the API runs four batch queries, fuses them, returns the flag inline. No background workers, no cache that goes stale about staleness. D1 is SQLite on Cloudflare's edge; at ~100 contacts the queries are fast enough that the simplest architecture is the correct one. A DBA would wince at four batch queries on every list load. The DBA isn't here, and the page returns in under 80ms.
5. Things that broke
The narrative arc wants me to skip these. The honest version of a 72-hour build is mostly these.
The Drizzle relations API that didn't exist. Hour 14. The model wrote forty lines of beautifully confident relational query code against a Drizzle API surface that, as far as I can tell, has never existed in any version. It read so plausibly I trusted it and spent forty minutes debugging my schema before I thought to check whether the function it was calling was real. It was not. This is the berserker's signature failure: fluent, structured, total fiction.
--remote is doing a lot of work. I applied a migration to production D1 instead of local because the flag defaults the wrong way for how my brain works, and watched a half-baked column land on the live database the two users were, at that moment, using. Nothing was lost. It easily could have been. I now have the apply command aliased with the environment spelled out in English.
The undo stack that undid the wrong row. Global undo stores an async closure per destructive action; Ctrl+Z pops the latest. The first version captured id by reference inside a loop (the closure-over-loop-variable bug that every JavaScript developer meets exactly once and never forgets), so undoing a bulk delete restored the same record five times and left the other four gone. Classic, embarrassing, fixed with a const in the right scope.
runs and correct are different countries
I kept a tally, roughly, of where the time actually went. Maybe a fifth writing code. The rest split between reading what the model wrote closely enough to catch the lies, and the specific, un-Googleable mess of Cloudflare's edge stack: wrangler configs, D1 binding names, OAuth scopes for the Gmail integration that I re-did three times because the consent screen kept rejecting a scope I was sure I'd added.
That ratio is the berserker/integrator thing, measured in hours. The generation is cheap and fast. The integration (the reading, the distrust, the wiring) is the entire job.
6. Engineering notes
Things too good to leave out, too small for their own section.
AI-powered meeting debrief. Paste a transcript after a meeting's done; one call to Claude Sonnet 4 auto-creates outcomes, attendee notes, tasks, ponderings, interests, connections, and resources. The prompt carries the meeting context (prep notes, agenda, key questions) and enforces quality with BAD/GOOD examples. Thirty minutes of post-meeting data entry becomes a paste and a click. (Yes, I'm aware of the irony of using a model to clean up after the chaos a model helped me build. The integrator is allowed to be a hypocrite.)
Cycle resolution in tree drag-and-drop. Drag a parent quest onto its own descendant (which would create the same cycle that blew my stack at hour 31) and the system doesn't block the drop. It promotes the target to the dragged item's old parent, then nests the dragged item inside. Every drop succeeds, the tree stays valid. This is not how a frontend person would build a tree interaction. It works.
Zero TODO markers. grep -r "TODO\|HACK\|FIXME" across the whole codebase returns nothing. Not discipline: I just never believed future-me was coming to clean up. You don't have technical debt if you were never going to pay it back. You have a deadline and a shipped product. Those are different countries.
The CRM is live. Two people use it. It does exactly what they need and nothing they don't.
It doesn't scale to 10,000 users and never has to. There's no permission system past Cloudflare Zero Trust: @3clabs.io and you're in. No rate limiting, because two users aren't going to DDoS their own tool. Every decision is downstream of a specific constraint at a specific scale, and that specificity is the entire value. It's a gruyère. It has holes. You're here for the carbs.
And the bet (72 hours, both roles, one skull)? I won it, with the asterisk that the answer to "could I?" turned out to be less interesting than what I learned on the way to yes. The seam between berserker and integrator isn't a person. It's a mood. And you can change moods in the time a migration takes to apply.
26 · mar 14(day zero) hour 72. 178 commits. 30 migrations. 32,058 lines. it works. I have no idea what day it is and I'd do it again.