Vibecode Bigin
track this build4 phases, 12 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Bigin, run a small-business pipeline with contacts, activities, and simple workflows. The hard boundary is zoho ecosystem, telephony, forms, integrations, and mobile apps, plus integrations, data quality, and workflow lock-in.
You are building a lean indie version of Bigin. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Bigin · indie build A small CRM whose hard problems are importing a messy contact list without creating five of everybody, merging duplicates in a way that can be reversed, and recording stage changes as events so that time in stage is a fact rather than a guess. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One web application and one worker, both in the same repository | imports and exports are the only long jobs and they do not deserve a queue service | | Storage | PostgreSQL, with every state change written as a row in an event table | a CRM is bought for its memory, and a column that is overwritten has none | | Deployment | Docker Compose with the database as a named volume and a documented dump command | the restore has to be a command someone can run at nine on a Monday | | Matching | Normalised keys computed on write for email, phone and company name | matching raw strings finds nothing and matching loosely merges two real people | | Export | Every object and every attachment, as files, on demand | the whole point of owning it is being able to leave | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Docker and Docker Compose** · free - Why: Runs the application and the database together so the first run is one command and the restore is reproducible. - Get it: docker.com, or your package manager. - Verify: docker compose version prints a version - [ ] **Node.js 22 or newer** · free - Why: Runs the web application and the import worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A maintained phone number parsing library** · free - Why: Normalising a phone number to the international format needs the current numbering plan for each country, and hand-rolled stripping of spaces and brackets matches the wrong records. - Get it: Any well-maintained port of the widely used phone number library for your runtime. - Verify: Three national formats of one number parse to the same international value - [ ] **A real contact export to import** · free - Why: Every interesting failure comes from real data: duplicate rows, a person at two companies, an empty required column, a number stored as text. - Get it: Export contacts from whatever you use now, or take a messy spreadsheet you already have. - Verify: The file imports with a dry run report you can read - [ ] **Your matching, stale and erasure rules** · free - Why: Each becomes a test, and deciding them after seeing the results means choosing whatever the code already did. - Get it: Write down which fields make a duplicate, how many days without activity makes a deal stale, and exactly what an erasure removes. - Verify: All three appear in configuration and are named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Automatic mailbox and calendar ingestion. - Commercial enrichment data. - Telephony and call recording. - Enterprise forecasting and quota management. - Zoho ecosystem, telephony, forms, integrations, and mobile apps - automatic email and calendar capture - data enrichment - large integration ecosystem - enterprise reporting If one of those is essential to you, that is the reason to keep paying for Bigin, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Bigin The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a small-business pipeline whose contact history is append-only and whose merges can be undone to replace Bigin. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: One web application and one worker, both in the same repository · imports and exports are the only long jobs and they do not deserve a queue service - Storage: PostgreSQL, with every state change written as a row in an event table · a CRM is bought for its memory, and a column that is overwritten has none - Deployment: Docker Compose with the database as a named volume and a documented dump command · the restore has to be a command someone can run at nine on a Monday - Matching: Normalised keys computed on write for email, phone and company name · matching raw strings finds nothing and matching loosely merges two real people - Export: Every object and every attachment, as files, on demand · the whole point of owning it is being able to leave ### Data model (create this before Phase 1) - people(id, given_name, family_name, email, email_key, phone_e164, company_id, owner, created_at, updated_at, merged_into_id) - companies(id, name, name_key, domain, domain_key, created_at, updated_at, merged_into_id) - deals(id, title, person_id, company_id, pipeline_id, stage_id, amount_minor, currency, expected_close, state, created_at, closed_at) - stage_events(id, deal_id, from_stage_id, to_stage_id, at, actor, note) · append-only, and the only source of time in stage - interactions(id, subject_type, subject_id, kind, at, summary, body, created_by) · calls, meetings and emails logged by hand - tasks(id, subject_type, subject_id, title, due_at, done_at, owner) · the next action, which is the reason anyone opens a CRM - merges(id, kind, survivor_id, absorbed_id, field_choices_json, at, undone_at) · enough to put both records back - import_rows(id, batch_id, source_key, raw_json, decision, matched_id, error) · every row of every import, including the rejected ones - Nothing is updated in place that a report later depends on. Stage changes, merges and imports are all rows you can read back. ### Phase 1 · People, companies and a history that is only appended Build: Contacts, companies, deals, notes and tasks with a timeline per record. Stage changes write to stage_events rather than only updating the deal. Editing a note keeps the previous body. The timeline reads from the event rows, so it can never disagree with them. Trap: Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. Done when: Moving a deal between stages writes an event with both stage identifiers and a timestamp, and the deal's own column is derived from the latest event, tested by replaying events. Time in stage for a deal that moved forwards, backwards and forwards again is computed from the events and matches a hand-calculated figure in a test. Editing a note preserves the previous body and both are visible. The timeline for a person shows interactions, tasks and deal movements in one ordered list. Do not build yet: No import, deduplication, merging or export yet. ### Phase 2 · Importing a real contact list Build: A mapping screen that shows the file's columns beside your fields with the first rows as a preview, then a dry run that reports what would be created, matched, changed and rejected. Normalise on write: lower-case the email domain, keep phone numbers in the international format with a country supplied per import, and reduce company names to a comparison key. Store every row of the file with its decision so a bad import can be explained afterwards. Trap: Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. Done when: Importing the same file twice creates records once and reports the second run as matched, tested with a file containing a row that changed between runs. A dry run reports counts for created, matched, changed and rejected, and importing after it produces exactly those counts. A phone number written in three different national formats normalises to one international value, tested. Every source row is retrievable with its decision, including rejected rows with the reason. Do not build yet: No merging, roles or export yet. ### Phase 3 · Merging duplicates without losing either of them Build: Find candidate duplicates by normalised email, normalised phone and company key plus a name similarity, and score them. Review pairs side by side, choose the surviving value field by field, and record the choices. Keep the absorbed record with a pointer to the survivor instead of deleting it. Provide an unmerge that restores both, including their interactions. Trap: Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. Done when: Merging two people moves every interaction, task and deal to the survivor and leaves the absorbed record retrievable by its old identifier, tested by fetching it. Unmerging restores both records with their original field values and reattaches each interaction to the record it came from, tested. Candidate scoring finds a pair differing by a middle name and does not propose two people who share only a company. A merge and an unmerge both appear in the audit log with the actor and the timestamp. Do not build yet: No roles, dashboard or export yet. ### Phase 4 · The next action, and honest pipeline numbers Build: A dashboard of overdue tasks, deals with no activity for a stated number of days, and deals closing this month. Pipeline value is the sum of open deal amounts with the currency shown, not a weighted forecast. If you show a probability it comes from your own closed history per stage and the sample size is displayed beside it. Trap: Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. Done when: Every dashboard figure links to the list of rows it was computed from, checked for each tile. A stale deal is defined by a configured number of days since the last interaction and the definition appears next to the number. Any probability shown carries the number of closed deals it was derived from, and disappears below a stated minimum. Amounts in more than one currency are never summed into a single total without a stated conversion date. Do not build yet: No roles, export or backup yet. ### Phase 5 · Leaving, and being asked to forget someone Build: Export every object as CSV plus attachments as files, in one archive, from a single command and from the interface. Test a restore of the database dump into an empty instance. Add two roles, one that can see everything and one that cannot export or delete. Implement an erasure that removes a person's identifying fields and their interaction bodies while leaving the event rows and their timestamps in place, and record the erasure itself. Trap: Handling a deletion request by removing the person's rows outright takes their deals and stage events with them, so the pipeline history changes retrospectively and last quarter's numbers no longer reproduce. Erase the identifying fields and the free text, keep the skeleton rows and their timestamps, and log that the erasure happened and when. Done when: The export archive restored into an empty instance reproduces people, companies, deals, events and attachments, compared by count and by spot check. A database dump taken by the documented command restores into an empty container and the application starts against it, rehearsed once and written down. The restricted role cannot export or delete, tested by attempting both. Erasing a person removes their name, contact details and interaction bodies, leaves stage events and their timestamps intact, and appears in the audit log. Do not build yet: Nothing after this. ### Out of scope (and why) - Automatic mailbox and calendar ingestion, which means holding a mail credential and a sync engine, and is a larger project than the CRM. - Commercial enrichment data, which is a licence you cannot self-host and a data protection question you would then own. - Telephony and call recording, which carry consent requirements per jurisdiction and a per-minute bill. - Anything resembling enterprise forecasting, since a small pipeline does not contain enough closed deals to support it. ### README must contain - That stage changes are stored as events, and that every reported figure is computed from them - The exact fields used for duplicate matching and how each is normalised before comparison - That merges keep the absorbed record and can be reversed, with the command or screen to do it - The dump and restore commands, and the date they were last rehearsed - What an erasure removes and what it deliberately leaves behind, and why ===== AGENTS.md ===== # Agent instructions · Bigin indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One web application and one worker, both in the same repository, PostgreSQL, with every state change written as a row in an event table, Docker Compose with the database as a named volume and a documented dump command, Normalised keys computed on write for email, phone and company name, Every object and every attachment, as files, on demand. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. - Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. - Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. - Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. ===== BUILD_PLAN.md ===== # Build plan · Bigin A small CRM whose hard problems are importing a messy contact list without creating five of everybody, merging duplicates in a way that can be reversed, and recording stage changes as events so that time in stage is a fact rather than a guess. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · People, companies and a history that is only appended Every interaction and every stage change is a row, and nothing that a report reads is overwritten. ### Steps 1. Write a stage event on every move The column can be derived from events; events cannot be derived from a column. Files: `src/deals.ts`, `src/events.ts` 2. Compute time in stage from events in a test Against a hand-worked example including a deal that moved backwards. Files: `tests/time-in-stage.test.ts` 3. Build one timeline that reads the event rows Two sources of history is one source of arguments. Files: `src/timeline.ts` ### Done when - [ ] Every stage move writes an event with both stages - [ ] Time in stage matches a hand-worked example - [ ] A note edit keeps the previous body - [ ] One timeline reads from the event rows ### Watch out - Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. ## Phase 2 · Importing a real contact list A messy CSV imported twice produces the same records, not twice as many. ### Steps 1. Give every import row a source key It is what makes a second run an update instead of a duplicate. Files: `src/import/rows.ts` 2. Dry run before writing anything The counts from the dry run are the contract the real run must meet. Files: `src/import/dryrun.ts`, `tests/import.test.ts` 3. Normalise email, phone and company on write Matching happens on the stored key, never on the raw string. Files: `src/normalise.ts`, `tests/normalise.test.ts` ### Done when - [ ] Importing a file twice creates records once - [ ] Dry run counts match the real run exactly - [ ] Three phone formats normalise to one value - [ ] Every source row is retrievable with its decision ### Watch out - Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. ## Phase 3 · Merging duplicates without losing either of them A merge is a reversible decision, and an over-eager merge can be put back. ### Steps 1. Never delete the absorbed record It holds the notes somebody will look for. Files: `src/merge.ts` 2. Record the field choices Unmerge is only possible if you know what was chosen. Files: `src/merge.ts`, `tests/unmerge.test.ts` 3. Score candidates rather than auto-merging An automatic merge on a shared company is how two customers become one. Files: `src/dedupe.ts`, `tests/dedupe.test.ts` ### Done when - [ ] The absorbed record stays retrievable - [ ] Unmerge restores both records and their interactions - [ ] Scoring catches a middle-name duplicate - [ ] Merges and unmerges are in the audit log ### Watch out - Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. ## Phase 4 · The next action, and honest pipeline numbers Opening the app shows what to do today, and every figure on the dashboard can be traced to rows. ### Steps 1. Link every figure to its rows A dashboard tile that cannot be drilled into is decoration. Files: `src/dashboard.ts` 2. Derive probability from your own history or omit it With the sample size beside it, so four deals looks like four deals. Files: `src/forecast.ts`, `tests/forecast.test.ts` 3. Define stale in configuration, and show the definition Otherwise the number means whatever the reader assumes. Files: `src/dashboard.ts` ### Done when - [ ] Each dashboard figure drills through to rows - [ ] The stale definition is shown beside the number - [ ] Probabilities carry their sample size or are absent - [ ] Mixed currencies are never silently summed ### Watch out - Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. ## Not in this build - Automatic mailbox and calendar ingestion. - Commercial enrichment data. - Telephony and call recording. - Enterprise forecasting and quota management. ## After v1, if you want it - A shared read-only view of one pipeline, which needs a token rather than a permission model - Email logging by forwarding to an address the app watches, which is far simpler than mailbox sync - A weekly digest of stale deals written to a file, so nobody has to remember to look ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The whole CRM. Backed up by the documented dump command. DATABASE_URL=postgres://crm:crm@db:5432/crm # Required. Files attached to records. Included in the export archive and in the backup. ATTACHMENT_DIR=./data/attachments # Required. Used when a phone number in an import has no country code. DEFAULT_COUNTRY=GB # Required. Days without an interaction before a deal is called stale. Shown beside the number. STALE_DEAL_DAYS=21 # Required. Below this many closed deals in a stage, no probability is shown at all. FORECAST_MIN_SAMPLE=20 # Required. Where the export archive is written. Clear it after downloading. EXPORT_DIR=./data/exports
You are building a lean indie version of Bigin. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # Bigin · indie build A small CRM whose hard problems are importing a messy contact list without creating five of everybody, merging duplicates in a way that can be reversed, and recording stage changes as events so that time in stage is a fact rather than a guess. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One web application and one worker, both in the same repository | imports and exports are the only long jobs and they do not deserve a queue service | | Storage | PostgreSQL, with every state change written as a row in an event table | a CRM is bought for its memory, and a column that is overwritten has none | | Deployment | Docker Compose with the database as a named volume and a documented dump command | the restore has to be a command someone can run at nine on a Monday | | Matching | Normalised keys computed on write for email, phone and company name | matching raw strings finds nothing and matching loosely merges two real people | | Export | Every object and every attachment, as files, on demand | the whole point of owning it is being able to leave | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Docker and Docker Compose** · free - Why: Runs the application and the database together so the first run is one command and the restore is reproducible. - Get it: docker.com, or your package manager. - Verify: docker compose version prints a version - [ ] **Node.js 22 or newer** · free - Why: Runs the web application and the import worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A maintained phone number parsing library** · free - Why: Normalising a phone number to the international format needs the current numbering plan for each country, and hand-rolled stripping of spaces and brackets matches the wrong records. - Get it: Any well-maintained port of the widely used phone number library for your runtime. - Verify: Three national formats of one number parse to the same international value - [ ] **A real contact export to import** · free - Why: Every interesting failure comes from real data: duplicate rows, a person at two companies, an empty required column, a number stored as text. - Get it: Export contacts from whatever you use now, or take a messy spreadsheet you already have. - Verify: The file imports with a dry run report you can read - [ ] **Your matching, stale and erasure rules** · free - Why: Each becomes a test, and deciding them after seeing the results means choosing whatever the code already did. - Get it: Write down which fields make a duplicate, how many days without activity makes a deal stale, and exactly what an erasure removes. - Verify: All three appear in configuration and are named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Automatic mailbox and calendar ingestion. - Commercial enrichment data. - Telephony and call recording. - Enterprise forecasting and quota management. - Zoho ecosystem, telephony, forms, integrations, and mobile apps - automatic email and calendar capture - data enrichment - large integration ecosystem - enterprise reporting If one of those is essential to you, that is the reason to keep paying for Bigin, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Bigin The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a small-business pipeline whose contact history is append-only and whose merges can be undone to replace Bigin. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: One web application and one worker, both in the same repository · imports and exports are the only long jobs and they do not deserve a queue service - Storage: PostgreSQL, with every state change written as a row in an event table · a CRM is bought for its memory, and a column that is overwritten has none - Deployment: Docker Compose with the database as a named volume and a documented dump command · the restore has to be a command someone can run at nine on a Monday - Matching: Normalised keys computed on write for email, phone and company name · matching raw strings finds nothing and matching loosely merges two real people - Export: Every object and every attachment, as files, on demand · the whole point of owning it is being able to leave ### Data model (create this before Phase 1) - people(id, given_name, family_name, email, email_key, phone_e164, company_id, owner, created_at, updated_at, merged_into_id) - companies(id, name, name_key, domain, domain_key, created_at, updated_at, merged_into_id) - deals(id, title, person_id, company_id, pipeline_id, stage_id, amount_minor, currency, expected_close, state, created_at, closed_at) - stage_events(id, deal_id, from_stage_id, to_stage_id, at, actor, note) · append-only, and the only source of time in stage - interactions(id, subject_type, subject_id, kind, at, summary, body, created_by) · calls, meetings and emails logged by hand - tasks(id, subject_type, subject_id, title, due_at, done_at, owner) · the next action, which is the reason anyone opens a CRM - merges(id, kind, survivor_id, absorbed_id, field_choices_json, at, undone_at) · enough to put both records back - import_rows(id, batch_id, source_key, raw_json, decision, matched_id, error) · every row of every import, including the rejected ones - Nothing is updated in place that a report later depends on. Stage changes, merges and imports are all rows you can read back. ### Phase 1 · People, companies and a history that is only appended Build: Contacts, companies, deals, notes and tasks with a timeline per record. Stage changes write to stage_events rather than only updating the deal. Editing a note keeps the previous body. The timeline reads from the event rows, so it can never disagree with them. Trap: Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. Done when: Moving a deal between stages writes an event with both stage identifiers and a timestamp, and the deal's own column is derived from the latest event, tested by replaying events. Time in stage for a deal that moved forwards, backwards and forwards again is computed from the events and matches a hand-calculated figure in a test. Editing a note preserves the previous body and both are visible. The timeline for a person shows interactions, tasks and deal movements in one ordered list. Do not build yet: No import, deduplication, merging or export yet. ### Phase 2 · Importing a real contact list Build: A mapping screen that shows the file's columns beside your fields with the first rows as a preview, then a dry run that reports what would be created, matched, changed and rejected. Normalise on write: lower-case the email domain, keep phone numbers in the international format with a country supplied per import, and reduce company names to a comparison key. Store every row of the file with its decision so a bad import can be explained afterwards. Trap: Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. Done when: Importing the same file twice creates records once and reports the second run as matched, tested with a file containing a row that changed between runs. A dry run reports counts for created, matched, changed and rejected, and importing after it produces exactly those counts. A phone number written in three different national formats normalises to one international value, tested. Every source row is retrievable with its decision, including rejected rows with the reason. Do not build yet: No merging, roles or export yet. ### Phase 3 · Merging duplicates without losing either of them Build: Find candidate duplicates by normalised email, normalised phone and company key plus a name similarity, and score them. Review pairs side by side, choose the surviving value field by field, and record the choices. Keep the absorbed record with a pointer to the survivor instead of deleting it. Provide an unmerge that restores both, including their interactions. Trap: Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. Done when: Merging two people moves every interaction, task and deal to the survivor and leaves the absorbed record retrievable by its old identifier, tested by fetching it. Unmerging restores both records with their original field values and reattaches each interaction to the record it came from, tested. Candidate scoring finds a pair differing by a middle name and does not propose two people who share only a company. A merge and an unmerge both appear in the audit log with the actor and the timestamp. Do not build yet: No roles, dashboard or export yet. ### Phase 4 · The next action, and honest pipeline numbers Build: A dashboard of overdue tasks, deals with no activity for a stated number of days, and deals closing this month. Pipeline value is the sum of open deal amounts with the currency shown, not a weighted forecast. If you show a probability it comes from your own closed history per stage and the sample size is displayed beside it. Trap: Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. Done when: Every dashboard figure links to the list of rows it was computed from, checked for each tile. A stale deal is defined by a configured number of days since the last interaction and the definition appears next to the number. Any probability shown carries the number of closed deals it was derived from, and disappears below a stated minimum. Amounts in more than one currency are never summed into a single total without a stated conversion date. Do not build yet: No roles, export or backup yet. ### Phase 5 · Leaving, and being asked to forget someone Build: Export every object as CSV plus attachments as files, in one archive, from a single command and from the interface. Test a restore of the database dump into an empty instance. Add two roles, one that can see everything and one that cannot export or delete. Implement an erasure that removes a person's identifying fields and their interaction bodies while leaving the event rows and their timestamps in place, and record the erasure itself. Trap: Handling a deletion request by removing the person's rows outright takes their deals and stage events with them, so the pipeline history changes retrospectively and last quarter's numbers no longer reproduce. Erase the identifying fields and the free text, keep the skeleton rows and their timestamps, and log that the erasure happened and when. Done when: The export archive restored into an empty instance reproduces people, companies, deals, events and attachments, compared by count and by spot check. A database dump taken by the documented command restores into an empty container and the application starts against it, rehearsed once and written down. The restricted role cannot export or delete, tested by attempting both. Erasing a person removes their name, contact details and interaction bodies, leaves stage events and their timestamps intact, and appears in the audit log. Do not build yet: Nothing after this. ### Out of scope (and why) - Automatic mailbox and calendar ingestion, which means holding a mail credential and a sync engine, and is a larger project than the CRM. - Commercial enrichment data, which is a licence you cannot self-host and a data protection question you would then own. - Telephony and call recording, which carry consent requirements per jurisdiction and a per-minute bill. - Anything resembling enterprise forecasting, since a small pipeline does not contain enough closed deals to support it. ### README must contain - That stage changes are stored as events, and that every reported figure is computed from them - The exact fields used for duplicate matching and how each is normalised before comparison - That merges keep the absorbed record and can be reversed, with the command or screen to do it - The dump and restore commands, and the date they were last rehearsed - What an erasure removes and what it deliberately leaves behind, and why ===== AGENTS.md ===== # Agent instructions · Bigin indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One web application and one worker, both in the same repository, PostgreSQL, with every state change written as a row in an event table, Docker Compose with the database as a named volume and a documented dump command, Normalised keys computed on write for email, phone and company name, Every object and every attachment, as files, on demand. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. - Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. - Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. - Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. ===== BUILD_PLAN.md ===== # Build plan · Bigin A small CRM whose hard problems are importing a messy contact list without creating five of everybody, merging duplicates in a way that can be reversed, and recording stage changes as events so that time in stage is a fact rather than a guess. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · People, companies and a history that is only appended Every interaction and every stage change is a row, and nothing that a report reads is overwritten. ### Steps 1. Write a stage event on every move The column can be derived from events; events cannot be derived from a column. Files: `src/deals.ts`, `src/events.ts` 2. Compute time in stage from events in a test Against a hand-worked example including a deal that moved backwards. Files: `tests/time-in-stage.test.ts` 3. Build one timeline that reads the event rows Two sources of history is one source of arguments. Files: `src/timeline.ts` ### Done when - [ ] Every stage move writes an event with both stages - [ ] Time in stage matches a hand-worked example - [ ] A note edit keeps the previous body - [ ] One timeline reads from the event rows ### Watch out - Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. ## Phase 2 · Importing a real contact list A messy CSV imported twice produces the same records, not twice as many. ### Steps 1. Give every import row a source key It is what makes a second run an update instead of a duplicate. Files: `src/import/rows.ts` 2. Dry run before writing anything The counts from the dry run are the contract the real run must meet. Files: `src/import/dryrun.ts`, `tests/import.test.ts` 3. Normalise email, phone and company on write Matching happens on the stored key, never on the raw string. Files: `src/normalise.ts`, `tests/normalise.test.ts` ### Done when - [ ] Importing a file twice creates records once - [ ] Dry run counts match the real run exactly - [ ] Three phone formats normalise to one value - [ ] Every source row is retrievable with its decision ### Watch out - Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. ## Phase 3 · Merging duplicates without losing either of them A merge is a reversible decision, and an over-eager merge can be put back. ### Steps 1. Never delete the absorbed record It holds the notes somebody will look for. Files: `src/merge.ts` 2. Record the field choices Unmerge is only possible if you know what was chosen. Files: `src/merge.ts`, `tests/unmerge.test.ts` 3. Score candidates rather than auto-merging An automatic merge on a shared company is how two customers become one. Files: `src/dedupe.ts`, `tests/dedupe.test.ts` ### Done when - [ ] The absorbed record stays retrievable - [ ] Unmerge restores both records and their interactions - [ ] Scoring catches a middle-name duplicate - [ ] Merges and unmerges are in the audit log ### Watch out - Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. ## Phase 4 · The next action, and honest pipeline numbers Opening the app shows what to do today, and every figure on the dashboard can be traced to rows. ### Steps 1. Link every figure to its rows A dashboard tile that cannot be drilled into is decoration. Files: `src/dashboard.ts` 2. Derive probability from your own history or omit it With the sample size beside it, so four deals looks like four deals. Files: `src/forecast.ts`, `tests/forecast.test.ts` 3. Define stale in configuration, and show the definition Otherwise the number means whatever the reader assumes. Files: `src/dashboard.ts` ### Done when - [ ] Each dashboard figure drills through to rows - [ ] The stale definition is shown beside the number - [ ] Probabilities carry their sample size or are absent - [ ] Mixed currencies are never silently summed ### Watch out - Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. ## Not in this build - Automatic mailbox and calendar ingestion. - Commercial enrichment data. - Telephony and call recording. - Enterprise forecasting and quota management. ## After v1, if you want it - A shared read-only view of one pipeline, which needs a token rather than a permission model - Email logging by forwarding to an address the app watches, which is far simpler than mailbox sync - A weekly digest of stale deals written to a file, so nobody has to remember to look ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The whole CRM. Backed up by the documented dump command. DATABASE_URL=postgres://crm:crm@db:5432/crm # Required. Files attached to records. Included in the export archive and in the backup. ATTACHMENT_DIR=./data/attachments # Required. Used when a phone number in an import has no country code. DEFAULT_COUNTRY=GB # Required. Days without an interaction before a deal is called stale. Shown beside the number. STALE_DEAL_DAYS=21 # Required. Below this many closed deals in a stage, no probability is shown at all. FORECAST_MIN_SAMPLE=20 # Required. Where the export archive is written. Clear it after downloading. EXPORT_DIR=./data/exports
You are building a production product version of Bigin. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # Bigin · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Bigin, run a small-business pipeline with contacts, activities, and simple workflows. The hard boundary is zoho ecosystem, telephony, forms, integrations, and mobile apps, plus integrations, data quality, and workflow lock-in. ## Product outcome A CRM whose history is append-only, whose imports are repeatable, whose merges can be undone, and whose dashboard figures can each be traced back to the rows that produced them. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - self-hosted server - optional email API credentials ## Explicit non-goals for v1 - Automatic mailbox and calendar ingestion. - Commercial enrichment data. - Telephony and call recording. - Enterprise forecasting and quota management. - Zoho ecosystem, telephony, forms, integrations, and mobile apps - automatic email and calendar capture - data enrichment - large integration ecosystem - enterprise reporting ## Success criteria - Time in stage is confirmed against a hand-worked example - A file imported twice is confirmed to create records once - An unmerge is confirmed to restore both records and their interactions - Every dashboard figure is confirmed to drill through to rows - The dump and restore are confirmed rehearsed and written down ===== BRIEF.md ===== # Build brief · Bigin The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a small-business pipeline whose contact history is append-only and whose merges can be undone to replace Bigin. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: One web application and one worker, both in the same repository · imports and exports are the only long jobs and they do not deserve a queue service - Storage: PostgreSQL, with every state change written as a row in an event table · a CRM is bought for its memory, and a column that is overwritten has none - Deployment: Docker Compose with the database as a named volume and a documented dump command · the restore has to be a command someone can run at nine on a Monday - Matching: Normalised keys computed on write for email, phone and company name · matching raw strings finds nothing and matching loosely merges two real people - Export: Every object and every attachment, as files, on demand · the whole point of owning it is being able to leave ### Data model (create this before Phase 1) - people(id, given_name, family_name, email, email_key, phone_e164, company_id, owner, created_at, updated_at, merged_into_id) - companies(id, name, name_key, domain, domain_key, created_at, updated_at, merged_into_id) - deals(id, title, person_id, company_id, pipeline_id, stage_id, amount_minor, currency, expected_close, state, created_at, closed_at) - stage_events(id, deal_id, from_stage_id, to_stage_id, at, actor, note) · append-only, and the only source of time in stage - interactions(id, subject_type, subject_id, kind, at, summary, body, created_by) · calls, meetings and emails logged by hand - tasks(id, subject_type, subject_id, title, due_at, done_at, owner) · the next action, which is the reason anyone opens a CRM - merges(id, kind, survivor_id, absorbed_id, field_choices_json, at, undone_at) · enough to put both records back - import_rows(id, batch_id, source_key, raw_json, decision, matched_id, error) · every row of every import, including the rejected ones - Nothing is updated in place that a report later depends on. Stage changes, merges and imports are all rows you can read back. ### Phase 1 · People, companies and a history that is only appended Build: Contacts, companies, deals, notes and tasks with a timeline per record. Stage changes write to stage_events rather than only updating the deal. Editing a note keeps the previous body. The timeline reads from the event rows, so it can never disagree with them. Trap: Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. Done when: Moving a deal between stages writes an event with both stage identifiers and a timestamp, and the deal's own column is derived from the latest event, tested by replaying events. Time in stage for a deal that moved forwards, backwards and forwards again is computed from the events and matches a hand-calculated figure in a test. Editing a note preserves the previous body and both are visible. The timeline for a person shows interactions, tasks and deal movements in one ordered list. Do not build yet: No import, deduplication, merging or export yet. ### Phase 2 · Importing a real contact list Build: A mapping screen that shows the file's columns beside your fields with the first rows as a preview, then a dry run that reports what would be created, matched, changed and rejected. Normalise on write: lower-case the email domain, keep phone numbers in the international format with a country supplied per import, and reduce company names to a comparison key. Store every row of the file with its decision so a bad import can be explained afterwards. Trap: Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. Done when: Importing the same file twice creates records once and reports the second run as matched, tested with a file containing a row that changed between runs. A dry run reports counts for created, matched, changed and rejected, and importing after it produces exactly those counts. A phone number written in three different national formats normalises to one international value, tested. Every source row is retrievable with its decision, including rejected rows with the reason. Do not build yet: No merging, roles or export yet. ### Phase 3 · Merging duplicates without losing either of them Build: Find candidate duplicates by normalised email, normalised phone and company key plus a name similarity, and score them. Review pairs side by side, choose the surviving value field by field, and record the choices. Keep the absorbed record with a pointer to the survivor instead of deleting it. Provide an unmerge that restores both, including their interactions. Trap: Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. Done when: Merging two people moves every interaction, task and deal to the survivor and leaves the absorbed record retrievable by its old identifier, tested by fetching it. Unmerging restores both records with their original field values and reattaches each interaction to the record it came from, tested. Candidate scoring finds a pair differing by a middle name and does not propose two people who share only a company. A merge and an unmerge both appear in the audit log with the actor and the timestamp. Do not build yet: No roles, dashboard or export yet. ### Phase 4 · The next action, and honest pipeline numbers Build: A dashboard of overdue tasks, deals with no activity for a stated number of days, and deals closing this month. Pipeline value is the sum of open deal amounts with the currency shown, not a weighted forecast. If you show a probability it comes from your own closed history per stage and the sample size is displayed beside it. Trap: Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. Done when: Every dashboard figure links to the list of rows it was computed from, checked for each tile. A stale deal is defined by a configured number of days since the last interaction and the definition appears next to the number. Any probability shown carries the number of closed deals it was derived from, and disappears below a stated minimum. Amounts in more than one currency are never summed into a single total without a stated conversion date. Do not build yet: No roles, export or backup yet. ### Phase 5 · Leaving, and being asked to forget someone Build: Export every object as CSV plus attachments as files, in one archive, from a single command and from the interface. Test a restore of the database dump into an empty instance. Add two roles, one that can see everything and one that cannot export or delete. Implement an erasure that removes a person's identifying fields and their interaction bodies while leaving the event rows and their timestamps in place, and record the erasure itself. Trap: Handling a deletion request by removing the person's rows outright takes their deals and stage events with them, so the pipeline history changes retrospectively and last quarter's numbers no longer reproduce. Erase the identifying fields and the free text, keep the skeleton rows and their timestamps, and log that the erasure happened and when. Done when: The export archive restored into an empty instance reproduces people, companies, deals, events and attachments, compared by count and by spot check. A database dump taken by the documented command restores into an empty container and the application starts against it, rehearsed once and written down. The restricted role cannot export or delete, tested by attempting both. Erasing a person removes their name, contact details and interaction bodies, leaves stage events and their timestamps intact, and appears in the audit log. Do not build yet: Nothing after this. ### Out of scope (and why) - Automatic mailbox and calendar ingestion, which means holding a mail credential and a sync engine, and is a larger project than the CRM. - Commercial enrichment data, which is a licence you cannot self-host and a data protection question you would then own. - Telephony and call recording, which carry consent requirements per jurisdiction and a per-minute bill. - Anything resembling enterprise forecasting, since a small pipeline does not contain enough closed deals to support it. ### README must contain - That stage changes are stored as events, and that every reported figure is computed from them - The exact fields used for duplicate matching and how each is normalised before comparison - That merges keep the absorbed record and can be reversed, with the command or screen to do it - The dump and restore commands, and the date they were last rehearsed - What an erasure removes and what it deliberately leaves behind, and why ===== ARCHITECTURE.md ===== # Architecture · Bigin ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One web application and one worker, both in the same repository | imports and exports are the only long jobs and they do not deserve a queue service | | Storage | PostgreSQL, with every state change written as a row in an event table | a CRM is bought for its memory, and a column that is overwritten has none | | Deployment | Docker Compose with the database as a named volume and a documented dump command | the restore has to be a command someone can run at nine on a Monday | | Matching | Normalised keys computed on write for email, phone and company name | matching raw strings finds nothing and matching loosely merges two real people | | Export | Every object and every attachment, as files, on demand | the whole point of owning it is being able to leave | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Records | People, companies, deals and their timelines. | Any store, provided stage changes stay events. | | Import | Mapping, dry run, normalisation and source keys. | Any parser, provided a second run updates rather than inserts. | | Dedupe | Candidate scoring and reversible merges. | Not swappable for automatic merging, which loses records silently. | | Reporting | Figures computed from events with drill-through. | Any presentation, provided every figure links to its rows. | | Export | The archive, the dump and the erasure. | Any format, provided a restore has been rehearsed. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · The whole CRM. Backed up by the documented dump command. - `ATTACHMENT_DIR` · required · Files attached to records. Included in the export archive and in the backup. - `DEFAULT_COUNTRY` · required · Used when a phone number in an import has no country code. - `STALE_DEAL_DAYS` · required · Days without an interaction before a deal is called stale. Shown beside the number. - `FORECAST_MIN_SAMPLE` · required · Below this many closed deals in a stage, no probability is shown at all. - `EXPORT_DIR` · required · Where the export archive is written. Clear it after downloading. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · Bigin product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One web application and one worker, both in the same repository, PostgreSQL, with every state change written as a row in an event table, Docker Compose with the database as a named volume and a documented dump command, Normalised keys computed on write for email, phone and company name, Every object and every attachment, as files, on demand. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. - Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. - Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. - Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. - Handling a deletion request by removing the person's rows outright takes their deals and stage events with them, so the pipeline history changes retrospectively and last quarter's numbers no longer reproduce. Erase the identifying fields and the free text, keep the skeleton rows and their timestamps, and log that the erasure happened and when. ===== MILESTONES.md ===== # Delivery milestones · Bigin Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · People, companies and a history that is only appended Every interaction and every stage change is a row, and nothing that a report reads is overwritten. ### Steps 1. Write a stage event on every move The column can be derived from events; events cannot be derived from a column. Files: `src/deals.ts`, `src/events.ts` 2. Compute time in stage from events in a test Against a hand-worked example including a deal that moved backwards. Files: `tests/time-in-stage.test.ts` 3. Build one timeline that reads the event rows Two sources of history is one source of arguments. Files: `src/timeline.ts` ### Done when - [ ] Every stage move writes an event with both stages - [ ] Time in stage matches a hand-worked example - [ ] A note edit keeps the previous body - [ ] One timeline reads from the event rows ### Watch out - Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. ## M2 · Importing a real contact list A messy CSV imported twice produces the same records, not twice as many. ### Steps 1. Give every import row a source key It is what makes a second run an update instead of a duplicate. Files: `src/import/rows.ts` 2. Dry run before writing anything The counts from the dry run are the contract the real run must meet. Files: `src/import/dryrun.ts`, `tests/import.test.ts` 3. Normalise email, phone and company on write Matching happens on the stored key, never on the raw string. Files: `src/normalise.ts`, `tests/normalise.test.ts` ### Done when - [ ] Importing a file twice creates records once - [ ] Dry run counts match the real run exactly - [ ] Three phone formats normalise to one value - [ ] Every source row is retrievable with its decision ### Watch out - Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. ## M3 · Merging duplicates without losing either of them A merge is a reversible decision, and an over-eager merge can be put back. ### Steps 1. Never delete the absorbed record It holds the notes somebody will look for. Files: `src/merge.ts` 2. Record the field choices Unmerge is only possible if you know what was chosen. Files: `src/merge.ts`, `tests/unmerge.test.ts` 3. Score candidates rather than auto-merging An automatic merge on a shared company is how two customers become one. Files: `src/dedupe.ts`, `tests/dedupe.test.ts` ### Done when - [ ] The absorbed record stays retrievable - [ ] Unmerge restores both records and their interactions - [ ] Scoring catches a middle-name duplicate - [ ] Merges and unmerges are in the audit log ### Watch out - Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. ## M4 · The next action, and honest pipeline numbers Opening the app shows what to do today, and every figure on the dashboard can be traced to rows. ### Steps 1. Link every figure to its rows A dashboard tile that cannot be drilled into is decoration. Files: `src/dashboard.ts` 2. Derive probability from your own history or omit it With the sample size beside it, so four deals looks like four deals. Files: `src/forecast.ts`, `tests/forecast.test.ts` 3. Define stale in configuration, and show the definition Otherwise the number means whatever the reader assumes. Files: `src/dashboard.ts` ### Done when - [ ] Each dashboard figure drills through to rows - [ ] The stale definition is shown beside the number - [ ] Probabilities carry their sample size or are absent - [ ] Mixed currencies are never silently summed ### Watch out - Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. ## M5 · Leaving, and being asked to forget someone (production only) Everything can be exported, restored, and one person can be erased without destroying the audit trail. ### Steps 1. Make export one command and one button An export that requires database access is not an export. Files: `src/export.ts`, `scripts/export.sh` 2. Rehearse the restore into an empty container Write down the commands that worked, not the ones you meant to run. Files: `docs/restore.md` 3. Erase fields, keep the skeleton So that last quarter still reproduces after a deletion request. Files: `src/erasure.ts`, `tests/erasure.test.ts` ### Done when - [ ] The export archive restores into an empty instance - [ ] The documented dump and restore have actually been run - [ ] The restricted role cannot export or delete - [ ] Erasure keeps event rows and is itself logged ### Watch out - Handling a deletion request by removing the person's rows outright takes their deals and stage events with them, so the pipeline history changes retrospectively and last quarter's numbers no longer reproduce. Erase the identifying fields and the free text, keep the skeleton rows and their timestamps, and log that the erasure happened and when. ===== OPERATIONS.md ===== # Operations · Bigin ## Backup A nightly database dump plus the attachment directory, kept for a stated number of days with one copy off the machine. The dump command belongs in the README, not in somebody's shell history. ## Restore Restore into an empty container, start the application against it, and check a known deal's stage history and one attachment. Do this once before you trust the backup and once a year after. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Failed import batches, rows rejected per import, merge and unmerge counts, and the age of the last successful dump. A silent import failure is the one that costs a week of data entry. ## Incident checklist A missing contact is usually a merge, and the merges table names the survivor and allows the reversal. A wrong pipeline number is usually a definition rather than a bug, which is why each figure drills through to its rows. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Time in stage is confirmed against a hand-worked example - [ ] A file imported twice is confirmed to create records once - [ ] An unmerge is confirmed to restore both records and their interactions - [ ] Every dashboard figure is confirmed to drill through to rows - [ ] The dump and restore are confirmed rehearsed and written down ## Launch constraint Do not market omitted Bigin capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The whole CRM. Backed up by the documented dump command. DATABASE_URL=postgres://crm:crm@db:5432/crm # Required. Files attached to records. Included in the export archive and in the backup. ATTACHMENT_DIR=./data/attachments # Required. Used when a phone number in an import has no country code. DEFAULT_COUNTRY=GB # Required. Days without an interaction before a deal is called stale. Shown beside the number. STALE_DEAL_DAYS=21 # Required. Below this many closed deals in a stage, no probability is shown at all. FORECAST_MIN_SAMPLE=20 # Required. Where the export archive is written. Clear it after downloading. EXPORT_DIR=./data/exports
# Bigin · indie build A small CRM whose hard problems are importing a messy contact list without creating five of everybody, merging duplicates in a way that can be reversed, and recording stage changes as events so that time in stage is a fact rather than a guess. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One web application and one worker, both in the same repository | imports and exports are the only long jobs and they do not deserve a queue service | | Storage | PostgreSQL, with every state change written as a row in an event table | a CRM is bought for its memory, and a column that is overwritten has none | | Deployment | Docker Compose with the database as a named volume and a documented dump command | the restore has to be a command someone can run at nine on a Monday | | Matching | Normalised keys computed on write for email, phone and company name | matching raw strings finds nothing and matching loosely merges two real people | | Export | Every object and every attachment, as files, on demand | the whole point of owning it is being able to leave | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Docker and Docker Compose** · free - Why: Runs the application and the database together so the first run is one command and the restore is reproducible. - Get it: docker.com, or your package manager. - Verify: docker compose version prints a version - [ ] **Node.js 22 or newer** · free - Why: Runs the web application and the import worker. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A maintained phone number parsing library** · free - Why: Normalising a phone number to the international format needs the current numbering plan for each country, and hand-rolled stripping of spaces and brackets matches the wrong records. - Get it: Any well-maintained port of the widely used phone number library for your runtime. - Verify: Three national formats of one number parse to the same international value - [ ] **A real contact export to import** · free - Why: Every interesting failure comes from real data: duplicate rows, a person at two companies, an empty required column, a number stored as text. - Get it: Export contacts from whatever you use now, or take a messy spreadsheet you already have. - Verify: The file imports with a dry run report you can read - [ ] **Your matching, stale and erasure rules** · free - Why: Each becomes a test, and deciding them after seeing the results means choosing whatever the code already did. - Get it: Write down which fields make a duplicate, how many days without activity makes a deal stale, and exactly what an erasure removes. - Verify: All three appear in configuration and are named in tests ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Automatic mailbox and calendar ingestion. - Commercial enrichment data. - Telephony and call recording. - Enterprise forecasting and quota management. - Zoho ecosystem, telephony, forms, integrations, and mobile apps - automatic email and calendar capture - data enrichment - large integration ecosystem - enterprise reporting If one of those is essential to you, that is the reason to keep paying for Bigin, and the README should say so rather than pretend.
# Build brief · Bigin The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a small-business pipeline whose contact history is append-only and whose merges can be undone to replace Bigin. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: One web application and one worker, both in the same repository · imports and exports are the only long jobs and they do not deserve a queue service - Storage: PostgreSQL, with every state change written as a row in an event table · a CRM is bought for its memory, and a column that is overwritten has none - Deployment: Docker Compose with the database as a named volume and a documented dump command · the restore has to be a command someone can run at nine on a Monday - Matching: Normalised keys computed on write for email, phone and company name · matching raw strings finds nothing and matching loosely merges two real people - Export: Every object and every attachment, as files, on demand · the whole point of owning it is being able to leave ### Data model (create this before Phase 1) - people(id, given_name, family_name, email, email_key, phone_e164, company_id, owner, created_at, updated_at, merged_into_id) - companies(id, name, name_key, domain, domain_key, created_at, updated_at, merged_into_id) - deals(id, title, person_id, company_id, pipeline_id, stage_id, amount_minor, currency, expected_close, state, created_at, closed_at) - stage_events(id, deal_id, from_stage_id, to_stage_id, at, actor, note) · append-only, and the only source of time in stage - interactions(id, subject_type, subject_id, kind, at, summary, body, created_by) · calls, meetings and emails logged by hand - tasks(id, subject_type, subject_id, title, due_at, done_at, owner) · the next action, which is the reason anyone opens a CRM - merges(id, kind, survivor_id, absorbed_id, field_choices_json, at, undone_at) · enough to put both records back - import_rows(id, batch_id, source_key, raw_json, decision, matched_id, error) · every row of every import, including the rejected ones - Nothing is updated in place that a report later depends on. Stage changes, merges and imports are all rows you can read back. ### Phase 1 · People, companies and a history that is only appended Build: Contacts, companies, deals, notes and tasks with a timeline per record. Stage changes write to stage_events rather than only updating the deal. Editing a note keeps the previous body. The timeline reads from the event rows, so it can never disagree with them. Trap: Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. Done when: Moving a deal between stages writes an event with both stage identifiers and a timestamp, and the deal's own column is derived from the latest event, tested by replaying events. Time in stage for a deal that moved forwards, backwards and forwards again is computed from the events and matches a hand-calculated figure in a test. Editing a note preserves the previous body and both are visible. The timeline for a person shows interactions, tasks and deal movements in one ordered list. Do not build yet: No import, deduplication, merging or export yet. ### Phase 2 · Importing a real contact list Build: A mapping screen that shows the file's columns beside your fields with the first rows as a preview, then a dry run that reports what would be created, matched, changed and rejected. Normalise on write: lower-case the email domain, keep phone numbers in the international format with a country supplied per import, and reduce company names to a comparison key. Store every row of the file with its decision so a bad import can be explained afterwards. Trap: Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. Done when: Importing the same file twice creates records once and reports the second run as matched, tested with a file containing a row that changed between runs. A dry run reports counts for created, matched, changed and rejected, and importing after it produces exactly those counts. A phone number written in three different national formats normalises to one international value, tested. Every source row is retrievable with its decision, including rejected rows with the reason. Do not build yet: No merging, roles or export yet. ### Phase 3 · Merging duplicates without losing either of them Build: Find candidate duplicates by normalised email, normalised phone and company key plus a name similarity, and score them. Review pairs side by side, choose the surviving value field by field, and record the choices. Keep the absorbed record with a pointer to the survivor instead of deleting it. Provide an unmerge that restores both, including their interactions. Trap: Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. Done when: Merging two people moves every interaction, task and deal to the survivor and leaves the absorbed record retrievable by its old identifier, tested by fetching it. Unmerging restores both records with their original field values and reattaches each interaction to the record it came from, tested. Candidate scoring finds a pair differing by a middle name and does not propose two people who share only a company. A merge and an unmerge both appear in the audit log with the actor and the timestamp. Do not build yet: No roles, dashboard or export yet. ### Phase 4 · The next action, and honest pipeline numbers Build: A dashboard of overdue tasks, deals with no activity for a stated number of days, and deals closing this month. Pipeline value is the sum of open deal amounts with the currency shown, not a weighted forecast. If you show a probability it comes from your own closed history per stage and the sample size is displayed beside it. Trap: Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. Done when: Every dashboard figure links to the list of rows it was computed from, checked for each tile. A stale deal is defined by a configured number of days since the last interaction and the definition appears next to the number. Any probability shown carries the number of closed deals it was derived from, and disappears below a stated minimum. Amounts in more than one currency are never summed into a single total without a stated conversion date. Do not build yet: No roles, export or backup yet. ### Phase 5 · Leaving, and being asked to forget someone Build: Export every object as CSV plus attachments as files, in one archive, from a single command and from the interface. Test a restore of the database dump into an empty instance. Add two roles, one that can see everything and one that cannot export or delete. Implement an erasure that removes a person's identifying fields and their interaction bodies while leaving the event rows and their timestamps in place, and record the erasure itself. Trap: Handling a deletion request by removing the person's rows outright takes their deals and stage events with them, so the pipeline history changes retrospectively and last quarter's numbers no longer reproduce. Erase the identifying fields and the free text, keep the skeleton rows and their timestamps, and log that the erasure happened and when. Done when: The export archive restored into an empty instance reproduces people, companies, deals, events and attachments, compared by count and by spot check. A database dump taken by the documented command restores into an empty container and the application starts against it, rehearsed once and written down. The restricted role cannot export or delete, tested by attempting both. Erasing a person removes their name, contact details and interaction bodies, leaves stage events and their timestamps intact, and appears in the audit log. Do not build yet: Nothing after this. ### Out of scope (and why) - Automatic mailbox and calendar ingestion, which means holding a mail credential and a sync engine, and is a larger project than the CRM. - Commercial enrichment data, which is a licence you cannot self-host and a data protection question you would then own. - Telephony and call recording, which carry consent requirements per jurisdiction and a per-minute bill. - Anything resembling enterprise forecasting, since a small pipeline does not contain enough closed deals to support it. ### README must contain - That stage changes are stored as events, and that every reported figure is computed from them - The exact fields used for duplicate matching and how each is normalised before comparison - That merges keep the absorbed record and can be reversed, with the command or screen to do it - The dump and restore commands, and the date they were last rehearsed - What an erasure removes and what it deliberately leaves behind, and why
# Agent instructions · Bigin indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: One web application and one worker, both in the same repository, PostgreSQL, with every state change written as a row in an event table, Docker Compose with the database as a named volume and a documented dump command, Normalised keys computed on write for email, phone and company name, Every object and every attachment, as files, on demand. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. - Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. - Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. - Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one.
# Build plan · Bigin A small CRM whose hard problems are importing a messy contact list without creating five of everybody, merging duplicates in a way that can be reversed, and recording stage changes as events so that time in stage is a fact rather than a guess. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · People, companies and a history that is only appended Every interaction and every stage change is a row, and nothing that a report reads is overwritten. ### Steps 1. Write a stage event on every move The column can be derived from events; events cannot be derived from a column. Files: `src/deals.ts`, `src/events.ts` 2. Compute time in stage from events in a test Against a hand-worked example including a deal that moved backwards. Files: `tests/time-in-stage.test.ts` 3. Build one timeline that reads the event rows Two sources of history is one source of arguments. Files: `src/timeline.ts` ### Done when - [ ] Every stage move writes an event with both stages - [ ] Time in stage matches a hand-worked example - [ ] A note edit keeps the previous body - [ ] One timeline reads from the event rows ### Watch out - Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. ## Phase 2 · Importing a real contact list A messy CSV imported twice produces the same records, not twice as many. ### Steps 1. Give every import row a source key It is what makes a second run an update instead of a duplicate. Files: `src/import/rows.ts` 2. Dry run before writing anything The counts from the dry run are the contract the real run must meet. Files: `src/import/dryrun.ts`, `tests/import.test.ts` 3. Normalise email, phone and company on write Matching happens on the stored key, never on the raw string. Files: `src/normalise.ts`, `tests/normalise.test.ts` ### Done when - [ ] Importing a file twice creates records once - [ ] Dry run counts match the real run exactly - [ ] Three phone formats normalise to one value - [ ] Every source row is retrievable with its decision ### Watch out - Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. ## Phase 3 · Merging duplicates without losing either of them A merge is a reversible decision, and an over-eager merge can be put back. ### Steps 1. Never delete the absorbed record It holds the notes somebody will look for. Files: `src/merge.ts` 2. Record the field choices Unmerge is only possible if you know what was chosen. Files: `src/merge.ts`, `tests/unmerge.test.ts` 3. Score candidates rather than auto-merging An automatic merge on a shared company is how two customers become one. Files: `src/dedupe.ts`, `tests/dedupe.test.ts` ### Done when - [ ] The absorbed record stays retrievable - [ ] Unmerge restores both records and their interactions - [ ] Scoring catches a middle-name duplicate - [ ] Merges and unmerges are in the audit log ### Watch out - Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. ## Phase 4 · The next action, and honest pipeline numbers Opening the app shows what to do today, and every figure on the dashboard can be traced to rows. ### Steps 1. Link every figure to its rows A dashboard tile that cannot be drilled into is decoration. Files: `src/dashboard.ts` 2. Derive probability from your own history or omit it With the sample size beside it, so four deals looks like four deals. Files: `src/forecast.ts`, `tests/forecast.test.ts` 3. Define stale in configuration, and show the definition Otherwise the number means whatever the reader assumes. Files: `src/dashboard.ts` ### Done when - [ ] Each dashboard figure drills through to rows - [ ] The stale definition is shown beside the number - [ ] Probabilities carry their sample size or are absent - [ ] Mixed currencies are never silently summed ### Watch out - Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. ## Not in this build - Automatic mailbox and calendar ingestion. - Commercial enrichment data. - Telephony and call recording. - Enterprise forecasting and quota management. ## After v1, if you want it - A shared read-only view of one pipeline, which needs a token rather than a permission model - Email logging by forwarding to an address the app watches, which is far simpler than mailbox sync - A weekly digest of stale deals written to a file, so nobody has to remember to look
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The whole CRM. Backed up by the documented dump command. DATABASE_URL=postgres://crm:crm@db:5432/crm # Required. Files attached to records. Included in the export archive and in the backup. ATTACHMENT_DIR=./data/attachments # Required. Used when a phone number in an import has no country code. DEFAULT_COUNTRY=GB # Required. Days without an interaction before a deal is called stale. Shown beside the number. STALE_DEAL_DAYS=21 # Required. Below this many closed deals in a stage, no probability is shown at all. FORECAST_MIN_SAMPLE=20 # Required. Where the export archive is written. Clear it after downloading. EXPORT_DIR=./data/exports
# Bigin · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Bigin, run a small-business pipeline with contacts, activities, and simple workflows. The hard boundary is zoho ecosystem, telephony, forms, integrations, and mobile apps, plus integrations, data quality, and workflow lock-in. ## Product outcome A CRM whose history is append-only, whose imports are repeatable, whose merges can be undone, and whose dashboard figures can each be traced back to the rows that produced them. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - self-hosted server - optional email API credentials ## Explicit non-goals for v1 - Automatic mailbox and calendar ingestion. - Commercial enrichment data. - Telephony and call recording. - Enterprise forecasting and quota management. - Zoho ecosystem, telephony, forms, integrations, and mobile apps - automatic email and calendar capture - data enrichment - large integration ecosystem - enterprise reporting ## Success criteria - Time in stage is confirmed against a hand-worked example - A file imported twice is confirmed to create records once - An unmerge is confirmed to restore both records and their interactions - Every dashboard figure is confirmed to drill through to rows - The dump and restore are confirmed rehearsed and written down
# Build brief · Bigin The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a small-business pipeline whose contact history is append-only and whose merges can be undone to replace Bigin. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: One web application and one worker, both in the same repository · imports and exports are the only long jobs and they do not deserve a queue service - Storage: PostgreSQL, with every state change written as a row in an event table · a CRM is bought for its memory, and a column that is overwritten has none - Deployment: Docker Compose with the database as a named volume and a documented dump command · the restore has to be a command someone can run at nine on a Monday - Matching: Normalised keys computed on write for email, phone and company name · matching raw strings finds nothing and matching loosely merges two real people - Export: Every object and every attachment, as files, on demand · the whole point of owning it is being able to leave ### Data model (create this before Phase 1) - people(id, given_name, family_name, email, email_key, phone_e164, company_id, owner, created_at, updated_at, merged_into_id) - companies(id, name, name_key, domain, domain_key, created_at, updated_at, merged_into_id) - deals(id, title, person_id, company_id, pipeline_id, stage_id, amount_minor, currency, expected_close, state, created_at, closed_at) - stage_events(id, deal_id, from_stage_id, to_stage_id, at, actor, note) · append-only, and the only source of time in stage - interactions(id, subject_type, subject_id, kind, at, summary, body, created_by) · calls, meetings and emails logged by hand - tasks(id, subject_type, subject_id, title, due_at, done_at, owner) · the next action, which is the reason anyone opens a CRM - merges(id, kind, survivor_id, absorbed_id, field_choices_json, at, undone_at) · enough to put both records back - import_rows(id, batch_id, source_key, raw_json, decision, matched_id, error) · every row of every import, including the rejected ones - Nothing is updated in place that a report later depends on. Stage changes, merges and imports are all rows you can read back. ### Phase 1 · People, companies and a history that is only appended Build: Contacts, companies, deals, notes and tasks with a timeline per record. Stage changes write to stage_events rather than only updating the deal. Editing a note keeps the previous body. The timeline reads from the event rows, so it can never disagree with them. Trap: Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. Done when: Moving a deal between stages writes an event with both stage identifiers and a timestamp, and the deal's own column is derived from the latest event, tested by replaying events. Time in stage for a deal that moved forwards, backwards and forwards again is computed from the events and matches a hand-calculated figure in a test. Editing a note preserves the previous body and both are visible. The timeline for a person shows interactions, tasks and deal movements in one ordered list. Do not build yet: No import, deduplication, merging or export yet. ### Phase 2 · Importing a real contact list Build: A mapping screen that shows the file's columns beside your fields with the first rows as a preview, then a dry run that reports what would be created, matched, changed and rejected. Normalise on write: lower-case the email domain, keep phone numbers in the international format with a country supplied per import, and reduce company names to a comparison key. Store every row of the file with its decision so a bad import can be explained afterwards. Trap: Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. Done when: Importing the same file twice creates records once and reports the second run as matched, tested with a file containing a row that changed between runs. A dry run reports counts for created, matched, changed and rejected, and importing after it produces exactly those counts. A phone number written in three different national formats normalises to one international value, tested. Every source row is retrievable with its decision, including rejected rows with the reason. Do not build yet: No merging, roles or export yet. ### Phase 3 · Merging duplicates without losing either of them Build: Find candidate duplicates by normalised email, normalised phone and company key plus a name similarity, and score them. Review pairs side by side, choose the surviving value field by field, and record the choices. Keep the absorbed record with a pointer to the survivor instead of deleting it. Provide an unmerge that restores both, including their interactions. Trap: Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. Done when: Merging two people moves every interaction, task and deal to the survivor and leaves the absorbed record retrievable by its old identifier, tested by fetching it. Unmerging restores both records with their original field values and reattaches each interaction to the record it came from, tested. Candidate scoring finds a pair differing by a middle name and does not propose two people who share only a company. A merge and an unmerge both appear in the audit log with the actor and the timestamp. Do not build yet: No roles, dashboard or export yet. ### Phase 4 · The next action, and honest pipeline numbers Build: A dashboard of overdue tasks, deals with no activity for a stated number of days, and deals closing this month. Pipeline value is the sum of open deal amounts with the currency shown, not a weighted forecast. If you show a probability it comes from your own closed history per stage and the sample size is displayed beside it. Trap: Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. Done when: Every dashboard figure links to the list of rows it was computed from, checked for each tile. A stale deal is defined by a configured number of days since the last interaction and the definition appears next to the number. Any probability shown carries the number of closed deals it was derived from, and disappears below a stated minimum. Amounts in more than one currency are never summed into a single total without a stated conversion date. Do not build yet: No roles, export or backup yet. ### Phase 5 · Leaving, and being asked to forget someone Build: Export every object as CSV plus attachments as files, in one archive, from a single command and from the interface. Test a restore of the database dump into an empty instance. Add two roles, one that can see everything and one that cannot export or delete. Implement an erasure that removes a person's identifying fields and their interaction bodies while leaving the event rows and their timestamps in place, and record the erasure itself. Trap: Handling a deletion request by removing the person's rows outright takes their deals and stage events with them, so the pipeline history changes retrospectively and last quarter's numbers no longer reproduce. Erase the identifying fields and the free text, keep the skeleton rows and their timestamps, and log that the erasure happened and when. Done when: The export archive restored into an empty instance reproduces people, companies, deals, events and attachments, compared by count and by spot check. A database dump taken by the documented command restores into an empty container and the application starts against it, rehearsed once and written down. The restricted role cannot export or delete, tested by attempting both. Erasing a person removes their name, contact details and interaction bodies, leaves stage events and their timestamps intact, and appears in the audit log. Do not build yet: Nothing after this. ### Out of scope (and why) - Automatic mailbox and calendar ingestion, which means holding a mail credential and a sync engine, and is a larger project than the CRM. - Commercial enrichment data, which is a licence you cannot self-host and a data protection question you would then own. - Telephony and call recording, which carry consent requirements per jurisdiction and a per-minute bill. - Anything resembling enterprise forecasting, since a small pipeline does not contain enough closed deals to support it. ### README must contain - That stage changes are stored as events, and that every reported figure is computed from them - The exact fields used for duplicate matching and how each is normalised before comparison - That merges keep the absorbed record and can be reversed, with the command or screen to do it - The dump and restore commands, and the date they were last rehearsed - What an erasure removes and what it deliberately leaves behind, and why
# Architecture · Bigin ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | One web application and one worker, both in the same repository | imports and exports are the only long jobs and they do not deserve a queue service | | Storage | PostgreSQL, with every state change written as a row in an event table | a CRM is bought for its memory, and a column that is overwritten has none | | Deployment | Docker Compose with the database as a named volume and a documented dump command | the restore has to be a command someone can run at nine on a Monday | | Matching | Normalised keys computed on write for email, phone and company name | matching raw strings finds nothing and matching loosely merges two real people | | Export | Every object and every attachment, as files, on demand | the whole point of owning it is being able to leave | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Records | People, companies, deals and their timelines. | Any store, provided stage changes stay events. | | Import | Mapping, dry run, normalisation and source keys. | Any parser, provided a second run updates rather than inserts. | | Dedupe | Candidate scoring and reversible merges. | Not swappable for automatic merging, which loses records silently. | | Reporting | Figures computed from events with drill-through. | Any presentation, provided every figure links to its rows. | | Export | The archive, the dump and the erasure. | Any format, provided a restore has been rehearsed. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `DATABASE_URL` · required, secret · The whole CRM. Backed up by the documented dump command. - `ATTACHMENT_DIR` · required · Files attached to records. Included in the export archive and in the backup. - `DEFAULT_COUNTRY` · required · Used when a phone number in an import has no country code. - `STALE_DEAL_DAYS` · required · Days without an interaction before a deal is called stale. Shown beside the number. - `FORECAST_MIN_SAMPLE` · required · Below this many closed deals in a stage, no probability is shown at all. - `EXPORT_DIR` · required · Where the export archive is written. Clear it after downloading. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · Bigin product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: One web application and one worker, both in the same repository, PostgreSQL, with every state change written as a row in an event table, Docker Compose with the database as a named volume and a documented dump command, Normalised keys computed on write for email, phone and company name, Every object and every attachment, as files, on demand. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. - Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. - Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. - Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. - Handling a deletion request by removing the person's rows outright takes their deals and stage events with them, so the pipeline history changes retrospectively and last quarter's numbers no longer reproduce. Erase the identifying fields and the free text, keep the skeleton rows and their timestamps, and log that the erasure happened and when.
# Delivery milestones · Bigin Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · People, companies and a history that is only appended Every interaction and every stage change is a row, and nothing that a report reads is overwritten. ### Steps 1. Write a stage event on every move The column can be derived from events; events cannot be derived from a column. Files: `src/deals.ts`, `src/events.ts` 2. Compute time in stage from events in a test Against a hand-worked example including a deal that moved backwards. Files: `tests/time-in-stage.test.ts` 3. Build one timeline that reads the event rows Two sources of history is one source of arguments. Files: `src/timeline.ts` ### Done when - [ ] Every stage move writes an event with both stages - [ ] Time in stage matches a hand-worked example - [ ] A note edit keeps the previous body - [ ] One timeline reads from the event rows ### Watch out - Keeping only a current stage column and adding a reporting screen later means average days in stage is computed from data that was never recorded, so the first pipeline report is fiction that looks plausible, and nobody notices because the numbers are the right shape. Write the event on the first day, before there is anything to report. ## M2 · Importing a real contact list A messy CSV imported twice produces the same records, not twice as many. ### Steps 1. Give every import row a source key It is what makes a second run an update instead of a duplicate. Files: `src/import/rows.ts` 2. Dry run before writing anything The counts from the dry run are the contract the real run must meet. Files: `src/import/dryrun.ts`, `tests/import.test.ts` 3. Normalise email, phone and company on write Matching happens on the stored key, never on the raw string. Files: `src/normalise.ts`, `tests/normalise.test.ts` ### Done when - [ ] Importing a file twice creates records once - [ ] Dry run counts match the real run exactly - [ ] Three phone formats normalise to one value - [ ] Every source row is retrievable with its decision ### Watch out - Re-running an import because the first attempt half failed creates a second copy of every row that succeeded, and since the copies differ slightly the deduplication screen then offers hundreds of pairs nobody will work through. Give each row a stable source key from the file, record it, and make a second run update the matched record rather than insert beside it. ## M3 · Merging duplicates without losing either of them A merge is a reversible decision, and an over-eager merge can be put back. ### Steps 1. Never delete the absorbed record It holds the notes somebody will look for. Files: `src/merge.ts` 2. Record the field choices Unmerge is only possible if you know what was chosen. Files: `src/merge.ts`, `tests/unmerge.test.ts` 3. Score candidates rather than auto-merging An automatic merge on a shared company is how two customers become one. Files: `src/dedupe.ts`, `tests/dedupe.test.ts` ### Done when - [ ] The absorbed record stays retrievable - [ ] Unmerge restores both records and their interactions - [ ] Scoring catches a middle-name duplicate - [ ] Merges and unmerges are in the audit log ### Watch out - Merging by copying the loser's fields into the winner and deleting the row destroys the one thing the CRM is for, because the deleted record carried the notes from the call that actually closed the deal, and the person who noticed is looking at a timeline with a gap. Keep the absorbed row, repoint its interactions, and record the field choices so the whole operation can be reversed. ## M4 · The next action, and honest pipeline numbers Opening the app shows what to do today, and every figure on the dashboard can be traced to rows. ### Steps 1. Link every figure to its rows A dashboard tile that cannot be drilled into is decoration. Files: `src/dashboard.ts` 2. Derive probability from your own history or omit it With the sample size beside it, so four deals looks like four deals. Files: `src/forecast.ts`, `tests/forecast.test.ts` 3. Define stale in configuration, and show the definition Otherwise the number means whatever the reader assumes. Files: `src/dashboard.ts` ### Done when - [ ] Each dashboard figure drills through to rows - [ ] The stale definition is shown beside the number - [ ] Probabilities carry their sample size or are absent - [ ] Mixed currencies are never silently summed ### Watch out - Multiplying deal value by a stage probability copied from a blog post produces a forecast number that people quote in meetings and plan against, and it has no relationship to this business at all. Either omit the weighting, or compute it from your own closed deals per stage and show the count it was computed from so a figure based on four deals looks like one. ## M5 · Leaving, and being asked to forget someone (production only) Everything can be exported, restored, and one person can be erased without destroying the audit trail. ### Steps 1. Make export one command and one button An export that requires database access is not an export. Files: `src/export.ts`, `scripts/export.sh` 2. Rehearse the restore into an empty container Write down the commands that worked, not the ones you meant to run. Files: `docs/restore.md` 3. Erase fields, keep the skeleton So that last quarter still reproduces after a deletion request. Files: `src/erasure.ts`, `tests/erasure.test.ts` ### Done when - [ ] The export archive restores into an empty instance - [ ] The documented dump and restore have actually been run - [ ] The restricted role cannot export or delete - [ ] Erasure keeps event rows and is itself logged ### Watch out - Handling a deletion request by removing the person's rows outright takes their deals and stage events with them, so the pipeline history changes retrospectively and last quarter's numbers no longer reproduce. Erase the identifying fields and the free text, keep the skeleton rows and their timestamps, and log that the erasure happened and when.
# Operations · Bigin ## Backup A nightly database dump plus the attachment directory, kept for a stated number of days with one copy off the machine. The dump command belongs in the README, not in somebody's shell history. ## Restore Restore into an empty container, start the application against it, and check a known deal's stage history and one attachment. Do this once before you trust the backup and once a year after. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Failed import batches, rows rejected per import, merge and unmerge counts, and the age of the last successful dump. A silent import failure is the one that costs a week of data entry. ## Incident checklist A missing contact is usually a merge, and the merges table names the survivor and allows the reversal. A wrong pipeline number is usually a definition rather than a bug, which is why each figure drills through to its rows. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] Time in stage is confirmed against a hand-worked example - [ ] A file imported twice is confirmed to create records once - [ ] An unmerge is confirmed to restore both records and their interactions - [ ] Every dashboard figure is confirmed to drill through to rows - [ ] The dump and restore are confirmed rehearsed and written down ## Launch constraint Do not market omitted Bigin capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. The whole CRM. Backed up by the documented dump command. DATABASE_URL=postgres://crm:crm@db:5432/crm # Required. Files attached to records. Included in the export archive and in the backup. ATTACHMENT_DIR=./data/attachments # Required. Used when a phone number in an import has no country code. DEFAULT_COUNTRY=GB # Required. Days without an interaction before a deal is called stale. Shown beside the number. STALE_DEAL_DAYS=21 # Required. Below this many closed deals in a stage, no probability is shown at all. FORECAST_MIN_SAMPLE=20 # Required. Where the export archive is written. Clear it after downloading. EXPORT_DIR=./data/exports
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Bigin because CRM value compounds as the system becomes the shared memory of every customer interaction and embeds itself in the rest of the stack. The recurring cost buys deduplication, permissions, imports, sync, enrichment, email capture, automations, backups, and audit history, not just the visible interface.
xZoho ecosystem, telephony, forms, integrations, and mobile apps
xautomatic email and calendar capture
xdata enrichment
xlarge integration ecosystem
xenterprise reporting
Don't feel like building it? These folks already made it free.
all 8 free alternatives to Bigin →· no votes, no pay-to-list · just what's real
Bigin pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/user | $0/user | 1 user and 500 total records |
| express | $9/user | $7/user | 50,000 records; no user-count limit (each user requires a license) |
| premier | $15/user | $12/user | 100,000 records; no user-count limit (each user requires a license) |
| bigin 360 | $21/user | $18/user | 1,000,000 records; no user-count limit (each user requires a license) |
free tier1 user and 500 records total (contacts, companies, pipelines, activities, products and notes all count as records)
billingmonthly + annual
hidden costsLocal taxes may be added despite contradictory wording on the page; paid plans charge for every user. Add-ons are sold separately in the Bigin Store. Annual downgrades can create account credits rather than cash refunds.
verified 2026-08-12 · source ↗
Vibecode Bigin
Kinda. The core of Bigin is buildable in a weekend with the prompt on this page, but there are real gaps: Zoho ecosystem, telephony, forms, integrations, and mobile apps, automatic email and calendar capture. Read the honest list above before committing.
How much does Bigin cost?
Bigin costs about $9/month (Express, checked 2026-07-31), which is $108 per year.
What do I lose by replacing Bigin?
Honestly: Zoho ecosystem, telephony, forms, integrations, and mobile apps; automatic email and calendar capture; data enrichment; large integration ecosystem; enterprise reporting. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Bigin?
Yes: Twenty (A modern pipeline CRM with custom objects, views and mailbox sync; the subscription becomes a Compose file.) Krayin CRM (A Laravel sales pipeline with leads, contacts and email; glossy enough, but it still wants a PHP stack.) EspoCRM (A traditional CRM that actually lets you change the data model; polished enough, but still your server.) All 8 curated free alternatives are at vibecodeit.com/bigin/alternatives. The prompt is for when you want it exactly your way.