The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Folk, organize relationship lists, enrich context manually, and run lightweight outreach. The hard boundary is collaborative contact enrichment, integrations, messaging, and polished relationship workflows, plus integrations, data quality, and workflow lock-in.
You are building a lean indie version of Folk. 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 ===== # Folk · indie build A relationship manager built around the fact that the useful context about a person is mostly things they told you, not things you looked up. Manual notes and interaction history are the core, enrichment is a bounded feature that carries an obligation, and every field knows where it came from. 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 | TypeScript with Fastify and PostgreSQL | contacts, interactions and provenance are relational | | Provenance | Every field records its source and when it arrived | this is what makes the notification obligation trackable rather than theoretical | | Identity | Deterministic contact matching with manual merge | automatic merging of two people who share a name is a data loss event | | Interactions | Email and calendar metadata by opt-in per account | the timeline is the value, and reading someone's mailbox is a serious permission | | Enrichment | Optional, per contact, with the source recorded and a notification due date | the feature that makes this legally interesting | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the mail metadata sync. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Fields with sources, identities and suppression records want real constraints and indexes. - Get it: Docker or your package manager. - Verify: psql connects and a non-nullable foreign key can be enforced - [ ] **A mail account with metadata scopes available** (optional) · free - Why: Phase 3 depends on what scopes the provider actually offers, and some only offer broad ones, which changes what you should store. - Get it: A test account with the provider your contacts use, and read the scope documentation before building the consent screen. - Verify: A token with the narrowest useful scope returns message metadata - [ ] **Your lawful basis and notification text** · free to draft; an hour of advice if this is commercial - Why: Article 14 requires naming the actual source and the basis, and writing that after you have enriched three hundred contacts is writing it too late. - Get it: Decide your basis, draft the notification naming each provider you will use, and write down your position on the disproportionate-effort exemption if you intend to rely on it. - Verify: A written notification text exists naming providers and the lawful basis - [ ] **Two hundred of your own contacts** · free - Why: Duplicate matching, identity normalisation and timeline usefulness only become real at a few hundred contacts with messy overlapping data. - Get it: Export from wherever you keep them now, including the duplicates and the entries with only a first name. - Verify: Two hundred contacts import, including at least five genuine duplicates ## 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: - The enrichment data itself. - Sales pipeline management with forecasting. - Bulk outreach and sequences. - Legal advice. - collaborative contact enrichment, integrations, messaging, and polished relationship workflows - 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 Folk, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Folk 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 relationship manager that keeps context on people without collecting it behind their backs to replace Folk. 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. ### Rule zero The feature that defines this category is enrichment: you type an email address and the tool fills in a job title, a company, a photo and a social profile from somewhere else. That is processing personal data obtained from a source other than the person themselves, and under the GDPR it triggers Article 14. Article 14 requires you to tell the person: within a reasonable period and at the latest within one month of obtaining the data, or at your first communication with them if that comes sooner. Article 14(2)(f) requires naming the actual source rather than saying marketing partners. The disproportionate-effort exemption in 14(5)(b) needs a documented assessment per batch, not a blanket claim. For a personal relationship manager with a few hundred contacts, that is entirely achievable and mostly ignored. So this build makes it structural: every enriched field records where it came from, the notification obligation is tracked with a due date, and a contact you have not notified is visibly marked. If that seems like too much friction, the honest response is to type the job title in yourself, which is what the manual path is for. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · contacts, interactions and provenance are relational - Provenance: Every field records its source and when it arrived · this is what makes the notification obligation trackable rather than theoretical - Identity: Deterministic contact matching with manual merge · automatic merging of two people who share a name is a data loss event - Interactions: Email and calendar metadata by opt-in per account · the timeline is the value, and reading someone's mailbox is a serious permission - Enrichment: Optional, per contact, with the source recorded and a notification due date · the feature that makes this legally interesting ### Data model (create this before Phase 1) - contacts(id, display_name, created_at, notification_due_at, notified_at) - fields(id, contact_id, key, value, source, source_detail, obtained_at) · source is manual, imported, enriched or from_them - identities(contact_id, kind, value_normalised, verified) · emails, handles and phone numbers used for matching - interactions(id, contact_id, kind, at, subject, direction, account_id) · metadata only, never message bodies - notes(id, contact_id, body, at, author) · the part that actually matters - enrichments(id, contact_id, provider, requested_at, fields_json, notification_required) - merges(id, kept_contact_id, merged_contact_id, at, reversible_until) · because merging is destructive and sometimes wrong ### Phase 1 · Contacts, with every field knowing where it came from Build: Contacts as a set of fields, each recording its source and when it arrived. Manual entry, file import and a place for notes. No field may be stored without a source. Trap: A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. Done when: Every field has a source that cannot be null, enforced by the schema. A field entered manually and one that arrived by import are distinguishable. Notes are free text and are the primary surface rather than a secondary tab. Importing a file records the file as the source detail. Do not build yet: No enrichment, interactions or merging. ### Phase 2 · Matching people without merging strangers Build: Normalise identities and match on them deterministically. Propose duplicates by shared identity, never by name similarity alone. Make merges reversible for a period, keeping both records until then. Trap: Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. Done when: Two contacts sharing an email address are proposed as duplicates. Two people with the same common name and no shared identity are not proposed, tested with a crafted pair. A merge is reversible for a stated period with both records retained. After the reversal window, the merge record still says what happened. Do not build yet: No enrichment or interactions yet. ### Phase 3 · The timeline, from metadata you were given access to Build: Connect a mail and calendar account with the narrowest scope that gives message metadata and event attendance. Store subjects, times and directions, never bodies. Let a contact or an account be excluded entirely. Trap: Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. Done when: The timeline shows last contact and frequency from metadata alone, with no message bodies stored anywhere, verified by inspecting the database. The narrowest available scope is requested and documented. An excluded account or contact produces no interaction rows. Revoking access stops collection and retains what was already collected. Do not build yet: No enrichment or export yet. ### Phase 4 · Enrichment, and the obligation that comes with it Build: Optional per-contact enrichment recording the provider and the exact fields obtained. Set a notification due date on the contact. Mark contacts not yet notified. Provide the notification text naming the actual source and the lawful basis. Trap: Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. Done when: Enriching a contact records the provider and the specific fields, and sets a notification due date. Contacts past their due date without notification are visibly marked and counted. The notification text names the actual provider rather than a generic phrase. Bulk enrichment on import is not offered, verified by looking for it. Do not build yet: No export or deletion yet. ### Phase 5 · Their rights, which are not a feature request Build: Produce a complete record for one contact including every field with its source, all interactions and all enrichments. Support correction and deletion, including deletion that survives a future import re-adding them. Trap: Deleting a contact without recording the deletion means the next import from the same source silently recreates them, which is not deletion and is exactly what the person asked you not to do. Keep a suppression record keyed on the identity so a re-import is blocked rather than welcomed. Done when: A complete record for one contact is produced in minutes including field sources. Deletion records a suppression entry and a subsequent import does not recreate the contact, tested by deleting and re-importing. Correction updates the field and records who changed it. Export of the whole database is complete and re-importable. Do not build yet: Nothing after this. ### Out of scope (and why) - Enrichment data itself, which is the product being sold by the providers and cannot be replicated. - Sales pipeline management with forecasting, which is a different product for a different job. - Bulk outreach and sequences, which turns a relationship tool into a mailing tool with a different set of rules. - Legal advice. This encodes one regulation's requirements and yours may differ. ### README must contain - That every field records its source and a field without one cannot exist - That enrichment creates a notification obligation with a tracked due date - That the notification must name the actual provider rather than a generic phrase - That contacts are never merged on name similarity and merges are reversible - That deletion creates a suppression record so a re-import cannot undo it ===== AGENTS.md ===== # Agent instructions · Folk indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Every field records its source and when it arrived, Deterministic contact matching with manual merge, Email and calendar metadata by opt-in per account, Optional, per contact, with the source recorded and a notification due date. 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 - A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. - Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. - Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. - Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. ===== BUILD_PLAN.md ===== # Build plan · Folk A relationship manager built around the fact that the useful context about a person is mostly things they told you, not things you looked up. Manual notes and interaction history are the core, enrichment is a bounded feature that carries an obligation, and every field knows where it came from. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Contacts, with every field knowing where it came from You can always answer where a piece of information about someone came from. ### Steps 1. Model fields as rows with sources More work than columns and the reason the rest of the build is possible. Files: `src/schema.sql` 2. Make source non-nullable In the database, not in the application. A field without provenance should not exist. Files: `src/fields.ts` 3. Put notes first What someone told you is the valuable context. Enrichment is a garnish. Files: `src/contacts.ts` ### Done when - [ ] No field can be stored without a source - [ ] Manual and imported fields are distinguishable - [ ] Notes are the primary surface - [ ] Import records the file as source detail ### Watch out - A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. ## Phase 2 · Matching people without merging strangers Duplicates are found and merged deliberately, never automatically. ### Steps 1. Normalise identities before matching Case, dots, plus addressing and phone formats. All of them cause false negatives. Files: `src/identity.ts`, `src/identity.test.ts` 2. Never merge on name similarity Propose at most. Combining two people is not recoverable from notes alone. Files: `src/duplicates.ts` 3. Keep merges reversible for a window Both records retained. Reversal is the only defence against a wrong merge. Files: `src/merge.ts` ### Done when - [ ] A shared email proposes a duplicate - [ ] A shared common name alone does not - [ ] A merge is reversible with both records kept - [ ] The merge record survives the reversal window ### Watch out - Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. ## Phase 3 · The timeline, from metadata you were given access to You can see when you last spoke to someone without reading their mail. ### Steps 1. Request the narrowest scope available And document why. A broad scope is a liability even when unused. Files: `src/oauth.ts` 2. Store metadata only Subject, time, direction. Never bodies, and assert that in the suite. Files: `src/interactions.ts`, `tests/no-bodies.test.ts` 3. Support exclusions at both levels Per account and per contact. Some correspondence should not be in a CRM. Files: `src/exclusions.ts` ### Done when - [ ] The timeline works from metadata alone - [ ] No message bodies exist in the database - [ ] The narrowest scope is requested and documented - [ ] Exclusions produce no interaction rows ### Watch out - Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. ## Phase 4 · Enrichment, and the obligation that comes with it Looking someone up creates a notification you are tracked against. ### Steps 1. Record provider and fields per enrichment Naming the actual source is required. A generic phrase is not sufficient. Files: `src/enrich.ts` 2. Set and track a notification due date One month from obtaining, or first contact, whichever comes first. Files: `src/notifications.ts` 3. Do not offer bulk enrichment It creates hundreds of obligations in one click. The absence is the design. Files: `src/enrich.ts` ### Done when - [ ] Enrichment records provider and specific fields - [ ] A notification due date is set and tracked - [ ] Overdue contacts are marked and counted - [ ] Bulk enrichment on import is not available ### Watch out - Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. ## Not in this build - The enrichment data itself. - Sales pipeline management with forecasting. - Bulk outreach and sequences. - Legal advice. ## After v1, if you want it - Reminders to be in touch, which is what a relationship manager is actually for - Shared contacts between a small team, which adds a permissions model - A weekly digest of who you have not spoken to, built from the timeline ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Personal data about identified people, most of whom did not give it to you directly. DATABASE_URL=postgres://localhost:5432/folk # Required. Named because the notification must name it. None is a valid and simpler configuration. ENRICHMENT_PROVIDER=none # Optional · secret. Only if enrichment is enabled. Absent means the manual path, which has no obligation. ENRICHMENT_API_KEY=a provider key # Required. From obtaining the data, or first contact if sooner. Thirty is the outer limit, not a target. NOTIFICATION_DUE_DAYS=30 # Required. How long a merge can be undone. A wrong merge is usually noticed weeks later. MERGE_REVERSAL_DAYS=30 # Required. Present as a constant so the answer is visible in configuration. There is no other value. STORE_MESSAGE_BODIES=never
You are building a lean indie version of Folk. 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 ===== # Folk · indie build A relationship manager built around the fact that the useful context about a person is mostly things they told you, not things you looked up. Manual notes and interaction history are the core, enrichment is a bounded feature that carries an obligation, and every field knows where it came from. 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 | TypeScript with Fastify and PostgreSQL | contacts, interactions and provenance are relational | | Provenance | Every field records its source and when it arrived | this is what makes the notification obligation trackable rather than theoretical | | Identity | Deterministic contact matching with manual merge | automatic merging of two people who share a name is a data loss event | | Interactions | Email and calendar metadata by opt-in per account | the timeline is the value, and reading someone's mailbox is a serious permission | | Enrichment | Optional, per contact, with the source recorded and a notification due date | the feature that makes this legally interesting | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the mail metadata sync. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Fields with sources, identities and suppression records want real constraints and indexes. - Get it: Docker or your package manager. - Verify: psql connects and a non-nullable foreign key can be enforced - [ ] **A mail account with metadata scopes available** (optional) · free - Why: Phase 3 depends on what scopes the provider actually offers, and some only offer broad ones, which changes what you should store. - Get it: A test account with the provider your contacts use, and read the scope documentation before building the consent screen. - Verify: A token with the narrowest useful scope returns message metadata - [ ] **Your lawful basis and notification text** · free to draft; an hour of advice if this is commercial - Why: Article 14 requires naming the actual source and the basis, and writing that after you have enriched three hundred contacts is writing it too late. - Get it: Decide your basis, draft the notification naming each provider you will use, and write down your position on the disproportionate-effort exemption if you intend to rely on it. - Verify: A written notification text exists naming providers and the lawful basis - [ ] **Two hundred of your own contacts** · free - Why: Duplicate matching, identity normalisation and timeline usefulness only become real at a few hundred contacts with messy overlapping data. - Get it: Export from wherever you keep them now, including the duplicates and the entries with only a first name. - Verify: Two hundred contacts import, including at least five genuine duplicates ## 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: - The enrichment data itself. - Sales pipeline management with forecasting. - Bulk outreach and sequences. - Legal advice. - collaborative contact enrichment, integrations, messaging, and polished relationship workflows - 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 Folk, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Folk 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 relationship manager that keeps context on people without collecting it behind their backs to replace Folk. 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. ### Rule zero The feature that defines this category is enrichment: you type an email address and the tool fills in a job title, a company, a photo and a social profile from somewhere else. That is processing personal data obtained from a source other than the person themselves, and under the GDPR it triggers Article 14. Article 14 requires you to tell the person: within a reasonable period and at the latest within one month of obtaining the data, or at your first communication with them if that comes sooner. Article 14(2)(f) requires naming the actual source rather than saying marketing partners. The disproportionate-effort exemption in 14(5)(b) needs a documented assessment per batch, not a blanket claim. For a personal relationship manager with a few hundred contacts, that is entirely achievable and mostly ignored. So this build makes it structural: every enriched field records where it came from, the notification obligation is tracked with a due date, and a contact you have not notified is visibly marked. If that seems like too much friction, the honest response is to type the job title in yourself, which is what the manual path is for. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · contacts, interactions and provenance are relational - Provenance: Every field records its source and when it arrived · this is what makes the notification obligation trackable rather than theoretical - Identity: Deterministic contact matching with manual merge · automatic merging of two people who share a name is a data loss event - Interactions: Email and calendar metadata by opt-in per account · the timeline is the value, and reading someone's mailbox is a serious permission - Enrichment: Optional, per contact, with the source recorded and a notification due date · the feature that makes this legally interesting ### Data model (create this before Phase 1) - contacts(id, display_name, created_at, notification_due_at, notified_at) - fields(id, contact_id, key, value, source, source_detail, obtained_at) · source is manual, imported, enriched or from_them - identities(contact_id, kind, value_normalised, verified) · emails, handles and phone numbers used for matching - interactions(id, contact_id, kind, at, subject, direction, account_id) · metadata only, never message bodies - notes(id, contact_id, body, at, author) · the part that actually matters - enrichments(id, contact_id, provider, requested_at, fields_json, notification_required) - merges(id, kept_contact_id, merged_contact_id, at, reversible_until) · because merging is destructive and sometimes wrong ### Phase 1 · Contacts, with every field knowing where it came from Build: Contacts as a set of fields, each recording its source and when it arrived. Manual entry, file import and a place for notes. No field may be stored without a source. Trap: A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. Done when: Every field has a source that cannot be null, enforced by the schema. A field entered manually and one that arrived by import are distinguishable. Notes are free text and are the primary surface rather than a secondary tab. Importing a file records the file as the source detail. Do not build yet: No enrichment, interactions or merging. ### Phase 2 · Matching people without merging strangers Build: Normalise identities and match on them deterministically. Propose duplicates by shared identity, never by name similarity alone. Make merges reversible for a period, keeping both records until then. Trap: Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. Done when: Two contacts sharing an email address are proposed as duplicates. Two people with the same common name and no shared identity are not proposed, tested with a crafted pair. A merge is reversible for a stated period with both records retained. After the reversal window, the merge record still says what happened. Do not build yet: No enrichment or interactions yet. ### Phase 3 · The timeline, from metadata you were given access to Build: Connect a mail and calendar account with the narrowest scope that gives message metadata and event attendance. Store subjects, times and directions, never bodies. Let a contact or an account be excluded entirely. Trap: Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. Done when: The timeline shows last contact and frequency from metadata alone, with no message bodies stored anywhere, verified by inspecting the database. The narrowest available scope is requested and documented. An excluded account or contact produces no interaction rows. Revoking access stops collection and retains what was already collected. Do not build yet: No enrichment or export yet. ### Phase 4 · Enrichment, and the obligation that comes with it Build: Optional per-contact enrichment recording the provider and the exact fields obtained. Set a notification due date on the contact. Mark contacts not yet notified. Provide the notification text naming the actual source and the lawful basis. Trap: Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. Done when: Enriching a contact records the provider and the specific fields, and sets a notification due date. Contacts past their due date without notification are visibly marked and counted. The notification text names the actual provider rather than a generic phrase. Bulk enrichment on import is not offered, verified by looking for it. Do not build yet: No export or deletion yet. ### Phase 5 · Their rights, which are not a feature request Build: Produce a complete record for one contact including every field with its source, all interactions and all enrichments. Support correction and deletion, including deletion that survives a future import re-adding them. Trap: Deleting a contact without recording the deletion means the next import from the same source silently recreates them, which is not deletion and is exactly what the person asked you not to do. Keep a suppression record keyed on the identity so a re-import is blocked rather than welcomed. Done when: A complete record for one contact is produced in minutes including field sources. Deletion records a suppression entry and a subsequent import does not recreate the contact, tested by deleting and re-importing. Correction updates the field and records who changed it. Export of the whole database is complete and re-importable. Do not build yet: Nothing after this. ### Out of scope (and why) - Enrichment data itself, which is the product being sold by the providers and cannot be replicated. - Sales pipeline management with forecasting, which is a different product for a different job. - Bulk outreach and sequences, which turns a relationship tool into a mailing tool with a different set of rules. - Legal advice. This encodes one regulation's requirements and yours may differ. ### README must contain - That every field records its source and a field without one cannot exist - That enrichment creates a notification obligation with a tracked due date - That the notification must name the actual provider rather than a generic phrase - That contacts are never merged on name similarity and merges are reversible - That deletion creates a suppression record so a re-import cannot undo it ===== AGENTS.md ===== # Agent instructions · Folk indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Every field records its source and when it arrived, Deterministic contact matching with manual merge, Email and calendar metadata by opt-in per account, Optional, per contact, with the source recorded and a notification due date. 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 - A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. - Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. - Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. - Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. ===== BUILD_PLAN.md ===== # Build plan · Folk A relationship manager built around the fact that the useful context about a person is mostly things they told you, not things you looked up. Manual notes and interaction history are the core, enrichment is a bounded feature that carries an obligation, and every field knows where it came from. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Contacts, with every field knowing where it came from You can always answer where a piece of information about someone came from. ### Steps 1. Model fields as rows with sources More work than columns and the reason the rest of the build is possible. Files: `src/schema.sql` 2. Make source non-nullable In the database, not in the application. A field without provenance should not exist. Files: `src/fields.ts` 3. Put notes first What someone told you is the valuable context. Enrichment is a garnish. Files: `src/contacts.ts` ### Done when - [ ] No field can be stored without a source - [ ] Manual and imported fields are distinguishable - [ ] Notes are the primary surface - [ ] Import records the file as source detail ### Watch out - A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. ## Phase 2 · Matching people without merging strangers Duplicates are found and merged deliberately, never automatically. ### Steps 1. Normalise identities before matching Case, dots, plus addressing and phone formats. All of them cause false negatives. Files: `src/identity.ts`, `src/identity.test.ts` 2. Never merge on name similarity Propose at most. Combining two people is not recoverable from notes alone. Files: `src/duplicates.ts` 3. Keep merges reversible for a window Both records retained. Reversal is the only defence against a wrong merge. Files: `src/merge.ts` ### Done when - [ ] A shared email proposes a duplicate - [ ] A shared common name alone does not - [ ] A merge is reversible with both records kept - [ ] The merge record survives the reversal window ### Watch out - Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. ## Phase 3 · The timeline, from metadata you were given access to You can see when you last spoke to someone without reading their mail. ### Steps 1. Request the narrowest scope available And document why. A broad scope is a liability even when unused. Files: `src/oauth.ts` 2. Store metadata only Subject, time, direction. Never bodies, and assert that in the suite. Files: `src/interactions.ts`, `tests/no-bodies.test.ts` 3. Support exclusions at both levels Per account and per contact. Some correspondence should not be in a CRM. Files: `src/exclusions.ts` ### Done when - [ ] The timeline works from metadata alone - [ ] No message bodies exist in the database - [ ] The narrowest scope is requested and documented - [ ] Exclusions produce no interaction rows ### Watch out - Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. ## Phase 4 · Enrichment, and the obligation that comes with it Looking someone up creates a notification you are tracked against. ### Steps 1. Record provider and fields per enrichment Naming the actual source is required. A generic phrase is not sufficient. Files: `src/enrich.ts` 2. Set and track a notification due date One month from obtaining, or first contact, whichever comes first. Files: `src/notifications.ts` 3. Do not offer bulk enrichment It creates hundreds of obligations in one click. The absence is the design. Files: `src/enrich.ts` ### Done when - [ ] Enrichment records provider and specific fields - [ ] A notification due date is set and tracked - [ ] Overdue contacts are marked and counted - [ ] Bulk enrichment on import is not available ### Watch out - Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. ## Not in this build - The enrichment data itself. - Sales pipeline management with forecasting. - Bulk outreach and sequences. - Legal advice. ## After v1, if you want it - Reminders to be in touch, which is what a relationship manager is actually for - Shared contacts between a small team, which adds a permissions model - A weekly digest of who you have not spoken to, built from the timeline ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Personal data about identified people, most of whom did not give it to you directly. DATABASE_URL=postgres://localhost:5432/folk # Required. Named because the notification must name it. None is a valid and simpler configuration. ENRICHMENT_PROVIDER=none # Optional · secret. Only if enrichment is enabled. Absent means the manual path, which has no obligation. ENRICHMENT_API_KEY=a provider key # Required. From obtaining the data, or first contact if sooner. Thirty is the outer limit, not a target. NOTIFICATION_DUE_DAYS=30 # Required. How long a merge can be undone. A wrong merge is usually noticed weeks later. MERGE_REVERSAL_DAYS=30 # Required. Present as a constant so the answer is visible in configuration. There is no other value. STORE_MESSAGE_BODIES=never
You are building a production product version of Folk. 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 ===== # Folk · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Folk, organize relationship lists, enrich context manually, and run lightweight outreach. The hard boundary is collaborative contact enrichment, integrations, messaging, and polished relationship workflows, plus integrations, data quality, and workflow lock-in. ## Product outcome A relationship manager where the valuable context is what people told you, every field can say where it came from, and looking someone up creates an obligation the tool holds you to. ## 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 - The enrichment data itself. - Sales pipeline management with forecasting. - Bulk outreach and sequences. - Legal advice. - collaborative contact enrichment, integrations, messaging, and polished relationship workflows - automatic email and calendar capture - data enrichment - large integration ecosystem - enterprise reporting ## Success criteria - A field without a source is confirmed impossible - Name-only similarity is confirmed not to propose a merge - No message bodies are confirmed present in the database - An enrichment is confirmed to set a tracked notification due date - Deletion is confirmed to survive a re-import ===== BRIEF.md ===== # Build brief · Folk 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 relationship manager that keeps context on people without collecting it behind their backs to replace Folk. 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. ### Rule zero The feature that defines this category is enrichment: you type an email address and the tool fills in a job title, a company, a photo and a social profile from somewhere else. That is processing personal data obtained from a source other than the person themselves, and under the GDPR it triggers Article 14. Article 14 requires you to tell the person: within a reasonable period and at the latest within one month of obtaining the data, or at your first communication with them if that comes sooner. Article 14(2)(f) requires naming the actual source rather than saying marketing partners. The disproportionate-effort exemption in 14(5)(b) needs a documented assessment per batch, not a blanket claim. For a personal relationship manager with a few hundred contacts, that is entirely achievable and mostly ignored. So this build makes it structural: every enriched field records where it came from, the notification obligation is tracked with a due date, and a contact you have not notified is visibly marked. If that seems like too much friction, the honest response is to type the job title in yourself, which is what the manual path is for. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · contacts, interactions and provenance are relational - Provenance: Every field records its source and when it arrived · this is what makes the notification obligation trackable rather than theoretical - Identity: Deterministic contact matching with manual merge · automatic merging of two people who share a name is a data loss event - Interactions: Email and calendar metadata by opt-in per account · the timeline is the value, and reading someone's mailbox is a serious permission - Enrichment: Optional, per contact, with the source recorded and a notification due date · the feature that makes this legally interesting ### Data model (create this before Phase 1) - contacts(id, display_name, created_at, notification_due_at, notified_at) - fields(id, contact_id, key, value, source, source_detail, obtained_at) · source is manual, imported, enriched or from_them - identities(contact_id, kind, value_normalised, verified) · emails, handles and phone numbers used for matching - interactions(id, contact_id, kind, at, subject, direction, account_id) · metadata only, never message bodies - notes(id, contact_id, body, at, author) · the part that actually matters - enrichments(id, contact_id, provider, requested_at, fields_json, notification_required) - merges(id, kept_contact_id, merged_contact_id, at, reversible_until) · because merging is destructive and sometimes wrong ### Phase 1 · Contacts, with every field knowing where it came from Build: Contacts as a set of fields, each recording its source and when it arrived. Manual entry, file import and a place for notes. No field may be stored without a source. Trap: A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. Done when: Every field has a source that cannot be null, enforced by the schema. A field entered manually and one that arrived by import are distinguishable. Notes are free text and are the primary surface rather than a secondary tab. Importing a file records the file as the source detail. Do not build yet: No enrichment, interactions or merging. ### Phase 2 · Matching people without merging strangers Build: Normalise identities and match on them deterministically. Propose duplicates by shared identity, never by name similarity alone. Make merges reversible for a period, keeping both records until then. Trap: Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. Done when: Two contacts sharing an email address are proposed as duplicates. Two people with the same common name and no shared identity are not proposed, tested with a crafted pair. A merge is reversible for a stated period with both records retained. After the reversal window, the merge record still says what happened. Do not build yet: No enrichment or interactions yet. ### Phase 3 · The timeline, from metadata you were given access to Build: Connect a mail and calendar account with the narrowest scope that gives message metadata and event attendance. Store subjects, times and directions, never bodies. Let a contact or an account be excluded entirely. Trap: Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. Done when: The timeline shows last contact and frequency from metadata alone, with no message bodies stored anywhere, verified by inspecting the database. The narrowest available scope is requested and documented. An excluded account or contact produces no interaction rows. Revoking access stops collection and retains what was already collected. Do not build yet: No enrichment or export yet. ### Phase 4 · Enrichment, and the obligation that comes with it Build: Optional per-contact enrichment recording the provider and the exact fields obtained. Set a notification due date on the contact. Mark contacts not yet notified. Provide the notification text naming the actual source and the lawful basis. Trap: Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. Done when: Enriching a contact records the provider and the specific fields, and sets a notification due date. Contacts past their due date without notification are visibly marked and counted. The notification text names the actual provider rather than a generic phrase. Bulk enrichment on import is not offered, verified by looking for it. Do not build yet: No export or deletion yet. ### Phase 5 · Their rights, which are not a feature request Build: Produce a complete record for one contact including every field with its source, all interactions and all enrichments. Support correction and deletion, including deletion that survives a future import re-adding them. Trap: Deleting a contact without recording the deletion means the next import from the same source silently recreates them, which is not deletion and is exactly what the person asked you not to do. Keep a suppression record keyed on the identity so a re-import is blocked rather than welcomed. Done when: A complete record for one contact is produced in minutes including field sources. Deletion records a suppression entry and a subsequent import does not recreate the contact, tested by deleting and re-importing. Correction updates the field and records who changed it. Export of the whole database is complete and re-importable. Do not build yet: Nothing after this. ### Out of scope (and why) - Enrichment data itself, which is the product being sold by the providers and cannot be replicated. - Sales pipeline management with forecasting, which is a different product for a different job. - Bulk outreach and sequences, which turns a relationship tool into a mailing tool with a different set of rules. - Legal advice. This encodes one regulation's requirements and yours may differ. ### README must contain - That every field records its source and a field without one cannot exist - That enrichment creates a notification obligation with a tracked due date - That the notification must name the actual provider rather than a generic phrase - That contacts are never merged on name similarity and merges are reversible - That deletion creates a suppression record so a re-import cannot undo it ===== ARCHITECTURE.md ===== # Architecture · Folk ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | contacts, interactions and provenance are relational | | Provenance | Every field records its source and when it arrived | this is what makes the notification obligation trackable rather than theoretical | | Identity | Deterministic contact matching with manual merge | automatic merging of two people who share a name is a data loss event | | Interactions | Email and calendar metadata by opt-in per account | the timeline is the value, and reading someone's mailbox is a serious permission | | Enrichment | Optional, per contact, with the source recorded and a notification due date | the feature that makes this legally interesting | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Fields | Values with mandatory provenance. | Not swappable for flat columns, which lose the distinction the rest depends on. | | Identity | Normalisation and duplicate proposal. | Any matcher, provided it never merges on name similarity. | | Interactions | Metadata-only timeline. | Any provider, provided the narrowest scope is used and bodies are never stored. | | Enrichment | Lookups and their notification obligations. | Any provider, provided it is named and the obligation is tracked. | | Rights | Access, correction, deletion and suppression. | Any implementation, provided deletion survives a re-import. | ## 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 · Personal data about identified people, most of whom did not give it to you directly. - `ENRICHMENT_PROVIDER` · required · Named because the notification must name it. None is a valid and simpler configuration. - `ENRICHMENT_API_KEY` · optional, secret · Only if enrichment is enabled. Absent means the manual path, which has no obligation. - `NOTIFICATION_DUE_DAYS` · required · From obtaining the data, or first contact if sooner. Thirty is the outer limit, not a target. - `MERGE_REVERSAL_DAYS` · required · How long a merge can be undone. A wrong merge is usually noticed weeks later. - `STORE_MESSAGE_BODIES` · required · Present as a constant so the answer is visible in configuration. There is no other value. ## 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 · Folk product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Every field records its source and when it arrived, Deterministic contact matching with manual merge, Email and calendar metadata by opt-in per account, Optional, per contact, with the source recorded and a notification due date. - 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 - A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. - Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. - Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. - Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. - Deleting a contact without recording the deletion means the next import from the same source silently recreates them, which is not deletion and is exactly what the person asked you not to do. Keep a suppression record keyed on the identity so a re-import is blocked rather than welcomed. ===== MILESTONES.md ===== # Delivery milestones · Folk Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Contacts, with every field knowing where it came from You can always answer where a piece of information about someone came from. ### Steps 1. Model fields as rows with sources More work than columns and the reason the rest of the build is possible. Files: `src/schema.sql` 2. Make source non-nullable In the database, not in the application. A field without provenance should not exist. Files: `src/fields.ts` 3. Put notes first What someone told you is the valuable context. Enrichment is a garnish. Files: `src/contacts.ts` ### Done when - [ ] No field can be stored without a source - [ ] Manual and imported fields are distinguishable - [ ] Notes are the primary surface - [ ] Import records the file as source detail ### Watch out - A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. ## M2 · Matching people without merging strangers Duplicates are found and merged deliberately, never automatically. ### Steps 1. Normalise identities before matching Case, dots, plus addressing and phone formats. All of them cause false negatives. Files: `src/identity.ts`, `src/identity.test.ts` 2. Never merge on name similarity Propose at most. Combining two people is not recoverable from notes alone. Files: `src/duplicates.ts` 3. Keep merges reversible for a window Both records retained. Reversal is the only defence against a wrong merge. Files: `src/merge.ts` ### Done when - [ ] A shared email proposes a duplicate - [ ] A shared common name alone does not - [ ] A merge is reversible with both records kept - [ ] The merge record survives the reversal window ### Watch out - Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. ## M3 · The timeline, from metadata you were given access to You can see when you last spoke to someone without reading their mail. ### Steps 1. Request the narrowest scope available And document why. A broad scope is a liability even when unused. Files: `src/oauth.ts` 2. Store metadata only Subject, time, direction. Never bodies, and assert that in the suite. Files: `src/interactions.ts`, `tests/no-bodies.test.ts` 3. Support exclusions at both levels Per account and per contact. Some correspondence should not be in a CRM. Files: `src/exclusions.ts` ### Done when - [ ] The timeline works from metadata alone - [ ] No message bodies exist in the database - [ ] The narrowest scope is requested and documented - [ ] Exclusions produce no interaction rows ### Watch out - Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. ## M4 · Enrichment, and the obligation that comes with it Looking someone up creates a notification you are tracked against. ### Steps 1. Record provider and fields per enrichment Naming the actual source is required. A generic phrase is not sufficient. Files: `src/enrich.ts` 2. Set and track a notification due date One month from obtaining, or first contact, whichever comes first. Files: `src/notifications.ts` 3. Do not offer bulk enrichment It creates hundreds of obligations in one click. The absence is the design. Files: `src/enrich.ts` ### Done when - [ ] Enrichment records provider and specific fields - [ ] A notification due date is set and tracked - [ ] Overdue contacts are marked and counted - [ ] Bulk enrichment on import is not available ### Watch out - Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. ## M5 · Their rights, which are not a feature request (production only) A person asking what you hold about them can be answered in minutes. ### Steps 1. Produce a per-contact record on demand Fields with sources, interactions, enrichments. This is what the question actually asks. Files: `src/subject-access.ts` 2. Suppress on deletion Keyed on identity, so a re-import cannot undo it. Files: `src/deletion.ts`, `tests/suppression.test.ts` 3. Record corrections What changed, when and by whom. A correction is not an edit. Files: `src/corrections.ts` ### Done when - [ ] A per-contact record is produced in minutes - [ ] Deletion survives a subsequent re-import - [ ] Corrections are recorded with who and when - [ ] Full export is complete and re-importable ### Watch out - Deleting a contact without recording the deletion means the next import from the same source silently recreates them, which is not deletion and is exactly what the person asked you not to do. Keep a suppression record keyed on the identity so a re-import is blocked rather than welcomed. ===== OPERATIONS.md ===== # Operations · Folk ## Backup This is personal data about people who mostly did not choose to be in it. Encrypt backups, apply your retention to them, and make sure a deletion request reaches the backups rather than only the live database. ## Restore Restore and confirm suppression records survived, since a restore that loses them will recreate contacts who asked to be deleted the next time an import runs. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Outstanding notification count is the number to watch, because it only ever grows quietly. A rising count means enrichment is being used faster than the obligations are being met. ## Incident checklist A contact recreated after deletion means the suppression record was missing or not consulted by the import path. That is the failure most likely to reach the person themselves, since they will notice they are back. 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 - [ ] A field without a source is confirmed impossible - [ ] Name-only similarity is confirmed not to propose a merge - [ ] No message bodies are confirmed present in the database - [ ] An enrichment is confirmed to set a tracked notification due date - [ ] Deletion is confirmed to survive a re-import ## Launch constraint Do not market omitted Folk 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. Personal data about identified people, most of whom did not give it to you directly. DATABASE_URL=postgres://localhost:5432/folk # Required. Named because the notification must name it. None is a valid and simpler configuration. ENRICHMENT_PROVIDER=none # Optional · secret. Only if enrichment is enabled. Absent means the manual path, which has no obligation. ENRICHMENT_API_KEY=a provider key # Required. From obtaining the data, or first contact if sooner. Thirty is the outer limit, not a target. NOTIFICATION_DUE_DAYS=30 # Required. How long a merge can be undone. A wrong merge is usually noticed weeks later. MERGE_REVERSAL_DAYS=30 # Required. Present as a constant so the answer is visible in configuration. There is no other value. STORE_MESSAGE_BODIES=never
# Folk · indie build A relationship manager built around the fact that the useful context about a person is mostly things they told you, not things you looked up. Manual notes and interaction history are the core, enrichment is a bounded feature that carries an obligation, and every field knows where it came from. 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 | TypeScript with Fastify and PostgreSQL | contacts, interactions and provenance are relational | | Provenance | Every field records its source and when it arrived | this is what makes the notification obligation trackable rather than theoretical | | Identity | Deterministic contact matching with manual merge | automatic merging of two people who share a name is a data loss event | | Interactions | Email and calendar metadata by opt-in per account | the timeline is the value, and reading someone's mailbox is a serious permission | | Enrichment | Optional, per contact, with the source recorded and a notification due date | the feature that makes this legally interesting | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the server and the mail metadata sync. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Fields with sources, identities and suppression records want real constraints and indexes. - Get it: Docker or your package manager. - Verify: psql connects and a non-nullable foreign key can be enforced - [ ] **A mail account with metadata scopes available** (optional) · free - Why: Phase 3 depends on what scopes the provider actually offers, and some only offer broad ones, which changes what you should store. - Get it: A test account with the provider your contacts use, and read the scope documentation before building the consent screen. - Verify: A token with the narrowest useful scope returns message metadata - [ ] **Your lawful basis and notification text** · free to draft; an hour of advice if this is commercial - Why: Article 14 requires naming the actual source and the basis, and writing that after you have enriched three hundred contacts is writing it too late. - Get it: Decide your basis, draft the notification naming each provider you will use, and write down your position on the disproportionate-effort exemption if you intend to rely on it. - Verify: A written notification text exists naming providers and the lawful basis - [ ] **Two hundred of your own contacts** · free - Why: Duplicate matching, identity normalisation and timeline usefulness only become real at a few hundred contacts with messy overlapping data. - Get it: Export from wherever you keep them now, including the duplicates and the entries with only a first name. - Verify: Two hundred contacts import, including at least five genuine duplicates ## 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: - The enrichment data itself. - Sales pipeline management with forecasting. - Bulk outreach and sequences. - Legal advice. - collaborative contact enrichment, integrations, messaging, and polished relationship workflows - 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 Folk, and the README should say so rather than pretend.
# Build brief · Folk 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 relationship manager that keeps context on people without collecting it behind their backs to replace Folk. 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. ### Rule zero The feature that defines this category is enrichment: you type an email address and the tool fills in a job title, a company, a photo and a social profile from somewhere else. That is processing personal data obtained from a source other than the person themselves, and under the GDPR it triggers Article 14. Article 14 requires you to tell the person: within a reasonable period and at the latest within one month of obtaining the data, or at your first communication with them if that comes sooner. Article 14(2)(f) requires naming the actual source rather than saying marketing partners. The disproportionate-effort exemption in 14(5)(b) needs a documented assessment per batch, not a blanket claim. For a personal relationship manager with a few hundred contacts, that is entirely achievable and mostly ignored. So this build makes it structural: every enriched field records where it came from, the notification obligation is tracked with a due date, and a contact you have not notified is visibly marked. If that seems like too much friction, the honest response is to type the job title in yourself, which is what the manual path is for. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · contacts, interactions and provenance are relational - Provenance: Every field records its source and when it arrived · this is what makes the notification obligation trackable rather than theoretical - Identity: Deterministic contact matching with manual merge · automatic merging of two people who share a name is a data loss event - Interactions: Email and calendar metadata by opt-in per account · the timeline is the value, and reading someone's mailbox is a serious permission - Enrichment: Optional, per contact, with the source recorded and a notification due date · the feature that makes this legally interesting ### Data model (create this before Phase 1) - contacts(id, display_name, created_at, notification_due_at, notified_at) - fields(id, contact_id, key, value, source, source_detail, obtained_at) · source is manual, imported, enriched or from_them - identities(contact_id, kind, value_normalised, verified) · emails, handles and phone numbers used for matching - interactions(id, contact_id, kind, at, subject, direction, account_id) · metadata only, never message bodies - notes(id, contact_id, body, at, author) · the part that actually matters - enrichments(id, contact_id, provider, requested_at, fields_json, notification_required) - merges(id, kept_contact_id, merged_contact_id, at, reversible_until) · because merging is destructive and sometimes wrong ### Phase 1 · Contacts, with every field knowing where it came from Build: Contacts as a set of fields, each recording its source and when it arrived. Manual entry, file import and a place for notes. No field may be stored without a source. Trap: A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. Done when: Every field has a source that cannot be null, enforced by the schema. A field entered manually and one that arrived by import are distinguishable. Notes are free text and are the primary surface rather than a secondary tab. Importing a file records the file as the source detail. Do not build yet: No enrichment, interactions or merging. ### Phase 2 · Matching people without merging strangers Build: Normalise identities and match on them deterministically. Propose duplicates by shared identity, never by name similarity alone. Make merges reversible for a period, keeping both records until then. Trap: Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. Done when: Two contacts sharing an email address are proposed as duplicates. Two people with the same common name and no shared identity are not proposed, tested with a crafted pair. A merge is reversible for a stated period with both records retained. After the reversal window, the merge record still says what happened. Do not build yet: No enrichment or interactions yet. ### Phase 3 · The timeline, from metadata you were given access to Build: Connect a mail and calendar account with the narrowest scope that gives message metadata and event attendance. Store subjects, times and directions, never bodies. Let a contact or an account be excluded entirely. Trap: Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. Done when: The timeline shows last contact and frequency from metadata alone, with no message bodies stored anywhere, verified by inspecting the database. The narrowest available scope is requested and documented. An excluded account or contact produces no interaction rows. Revoking access stops collection and retains what was already collected. Do not build yet: No enrichment or export yet. ### Phase 4 · Enrichment, and the obligation that comes with it Build: Optional per-contact enrichment recording the provider and the exact fields obtained. Set a notification due date on the contact. Mark contacts not yet notified. Provide the notification text naming the actual source and the lawful basis. Trap: Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. Done when: Enriching a contact records the provider and the specific fields, and sets a notification due date. Contacts past their due date without notification are visibly marked and counted. The notification text names the actual provider rather than a generic phrase. Bulk enrichment on import is not offered, verified by looking for it. Do not build yet: No export or deletion yet. ### Phase 5 · Their rights, which are not a feature request Build: Produce a complete record for one contact including every field with its source, all interactions and all enrichments. Support correction and deletion, including deletion that survives a future import re-adding them. Trap: Deleting a contact without recording the deletion means the next import from the same source silently recreates them, which is not deletion and is exactly what the person asked you not to do. Keep a suppression record keyed on the identity so a re-import is blocked rather than welcomed. Done when: A complete record for one contact is produced in minutes including field sources. Deletion records a suppression entry and a subsequent import does not recreate the contact, tested by deleting and re-importing. Correction updates the field and records who changed it. Export of the whole database is complete and re-importable. Do not build yet: Nothing after this. ### Out of scope (and why) - Enrichment data itself, which is the product being sold by the providers and cannot be replicated. - Sales pipeline management with forecasting, which is a different product for a different job. - Bulk outreach and sequences, which turns a relationship tool into a mailing tool with a different set of rules. - Legal advice. This encodes one regulation's requirements and yours may differ. ### README must contain - That every field records its source and a field without one cannot exist - That enrichment creates a notification obligation with a tracked due date - That the notification must name the actual provider rather than a generic phrase - That contacts are never merged on name similarity and merges are reversible - That deletion creates a suppression record so a re-import cannot undo it
# Agent instructions · Folk indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Every field records its source and when it arrived, Deterministic contact matching with manual merge, Email and calendar metadata by opt-in per account, Optional, per contact, with the source recorded and a notification due date. 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 - A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. - Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. - Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. - Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly.
# Build plan · Folk A relationship manager built around the fact that the useful context about a person is mostly things they told you, not things you looked up. Manual notes and interaction history are the core, enrichment is a bounded feature that carries an obligation, and every field knows where it came from. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Contacts, with every field knowing where it came from You can always answer where a piece of information about someone came from. ### Steps 1. Model fields as rows with sources More work than columns and the reason the rest of the build is possible. Files: `src/schema.sql` 2. Make source non-nullable In the database, not in the application. A field without provenance should not exist. Files: `src/fields.ts` 3. Put notes first What someone told you is the valuable context. Enrichment is a garnish. Files: `src/contacts.ts` ### Done when - [ ] No field can be stored without a source - [ ] Manual and imported fields are distinguishable - [ ] Notes are the primary surface - [ ] Import records the file as source detail ### Watch out - A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. ## Phase 2 · Matching people without merging strangers Duplicates are found and merged deliberately, never automatically. ### Steps 1. Normalise identities before matching Case, dots, plus addressing and phone formats. All of them cause false negatives. Files: `src/identity.ts`, `src/identity.test.ts` 2. Never merge on name similarity Propose at most. Combining two people is not recoverable from notes alone. Files: `src/duplicates.ts` 3. Keep merges reversible for a window Both records retained. Reversal is the only defence against a wrong merge. Files: `src/merge.ts` ### Done when - [ ] A shared email proposes a duplicate - [ ] A shared common name alone does not - [ ] A merge is reversible with both records kept - [ ] The merge record survives the reversal window ### Watch out - Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. ## Phase 3 · The timeline, from metadata you were given access to You can see when you last spoke to someone without reading their mail. ### Steps 1. Request the narrowest scope available And document why. A broad scope is a liability even when unused. Files: `src/oauth.ts` 2. Store metadata only Subject, time, direction. Never bodies, and assert that in the suite. Files: `src/interactions.ts`, `tests/no-bodies.test.ts` 3. Support exclusions at both levels Per account and per contact. Some correspondence should not be in a CRM. Files: `src/exclusions.ts` ### Done when - [ ] The timeline works from metadata alone - [ ] No message bodies exist in the database - [ ] The narrowest scope is requested and documented - [ ] Exclusions produce no interaction rows ### Watch out - Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. ## Phase 4 · Enrichment, and the obligation that comes with it Looking someone up creates a notification you are tracked against. ### Steps 1. Record provider and fields per enrichment Naming the actual source is required. A generic phrase is not sufficient. Files: `src/enrich.ts` 2. Set and track a notification due date One month from obtaining, or first contact, whichever comes first. Files: `src/notifications.ts` 3. Do not offer bulk enrichment It creates hundreds of obligations in one click. The absence is the design. Files: `src/enrich.ts` ### Done when - [ ] Enrichment records provider and specific fields - [ ] A notification due date is set and tracked - [ ] Overdue contacts are marked and counted - [ ] Bulk enrichment on import is not available ### Watch out - Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. ## Not in this build - The enrichment data itself. - Sales pipeline management with forecasting. - Bulk outreach and sequences. - Legal advice. ## After v1, if you want it - Reminders to be in touch, which is what a relationship manager is actually for - Shared contacts between a small team, which adds a permissions model - A weekly digest of who you have not spoken to, built from the timeline
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Personal data about identified people, most of whom did not give it to you directly. DATABASE_URL=postgres://localhost:5432/folk # Required. Named because the notification must name it. None is a valid and simpler configuration. ENRICHMENT_PROVIDER=none # Optional · secret. Only if enrichment is enabled. Absent means the manual path, which has no obligation. ENRICHMENT_API_KEY=a provider key # Required. From obtaining the data, or first contact if sooner. Thirty is the outer limit, not a target. NOTIFICATION_DUE_DAYS=30 # Required. How long a merge can be undone. A wrong merge is usually noticed weeks later. MERGE_REVERSAL_DAYS=30 # Required. Present as a constant so the answer is visible in configuration. There is no other value. STORE_MESSAGE_BODIES=never
# Folk · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Folk, organize relationship lists, enrich context manually, and run lightweight outreach. The hard boundary is collaborative contact enrichment, integrations, messaging, and polished relationship workflows, plus integrations, data quality, and workflow lock-in. ## Product outcome A relationship manager where the valuable context is what people told you, every field can say where it came from, and looking someone up creates an obligation the tool holds you to. ## 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 - The enrichment data itself. - Sales pipeline management with forecasting. - Bulk outreach and sequences. - Legal advice. - collaborative contact enrichment, integrations, messaging, and polished relationship workflows - automatic email and calendar capture - data enrichment - large integration ecosystem - enterprise reporting ## Success criteria - A field without a source is confirmed impossible - Name-only similarity is confirmed not to propose a merge - No message bodies are confirmed present in the database - An enrichment is confirmed to set a tracked notification due date - Deletion is confirmed to survive a re-import
# Build brief · Folk 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 relationship manager that keeps context on people without collecting it behind their backs to replace Folk. 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. ### Rule zero The feature that defines this category is enrichment: you type an email address and the tool fills in a job title, a company, a photo and a social profile from somewhere else. That is processing personal data obtained from a source other than the person themselves, and under the GDPR it triggers Article 14. Article 14 requires you to tell the person: within a reasonable period and at the latest within one month of obtaining the data, or at your first communication with them if that comes sooner. Article 14(2)(f) requires naming the actual source rather than saying marketing partners. The disproportionate-effort exemption in 14(5)(b) needs a documented assessment per batch, not a blanket claim. For a personal relationship manager with a few hundred contacts, that is entirely achievable and mostly ignored. So this build makes it structural: every enriched field records where it came from, the notification obligation is tracked with a due date, and a contact you have not notified is visibly marked. If that seems like too much friction, the honest response is to type the job title in yourself, which is what the manual path is for. ### Stack (fixed, do not substitute) - Runtime: TypeScript with Fastify and PostgreSQL · contacts, interactions and provenance are relational - Provenance: Every field records its source and when it arrived · this is what makes the notification obligation trackable rather than theoretical - Identity: Deterministic contact matching with manual merge · automatic merging of two people who share a name is a data loss event - Interactions: Email and calendar metadata by opt-in per account · the timeline is the value, and reading someone's mailbox is a serious permission - Enrichment: Optional, per contact, with the source recorded and a notification due date · the feature that makes this legally interesting ### Data model (create this before Phase 1) - contacts(id, display_name, created_at, notification_due_at, notified_at) - fields(id, contact_id, key, value, source, source_detail, obtained_at) · source is manual, imported, enriched or from_them - identities(contact_id, kind, value_normalised, verified) · emails, handles and phone numbers used for matching - interactions(id, contact_id, kind, at, subject, direction, account_id) · metadata only, never message bodies - notes(id, contact_id, body, at, author) · the part that actually matters - enrichments(id, contact_id, provider, requested_at, fields_json, notification_required) - merges(id, kept_contact_id, merged_contact_id, at, reversible_until) · because merging is destructive and sometimes wrong ### Phase 1 · Contacts, with every field knowing where it came from Build: Contacts as a set of fields, each recording its source and when it arrived. Manual entry, file import and a place for notes. No field may be stored without a source. Trap: A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. Done when: Every field has a source that cannot be null, enforced by the schema. A field entered manually and one that arrived by import are distinguishable. Notes are free text and are the primary surface rather than a secondary tab. Importing a file records the file as the source detail. Do not build yet: No enrichment, interactions or merging. ### Phase 2 · Matching people without merging strangers Build: Normalise identities and match on them deterministically. Propose duplicates by shared identity, never by name similarity alone. Make merges reversible for a period, keeping both records until then. Trap: Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. Done when: Two contacts sharing an email address are proposed as duplicates. Two people with the same common name and no shared identity are not proposed, tested with a crafted pair. A merge is reversible for a stated period with both records retained. After the reversal window, the merge record still says what happened. Do not build yet: No enrichment or interactions yet. ### Phase 3 · The timeline, from metadata you were given access to Build: Connect a mail and calendar account with the narrowest scope that gives message metadata and event attendance. Store subjects, times and directions, never bodies. Let a contact or an account be excluded entirely. Trap: Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. Done when: The timeline shows last contact and frequency from metadata alone, with no message bodies stored anywhere, verified by inspecting the database. The narrowest available scope is requested and documented. An excluded account or contact produces no interaction rows. Revoking access stops collection and retains what was already collected. Do not build yet: No enrichment or export yet. ### Phase 4 · Enrichment, and the obligation that comes with it Build: Optional per-contact enrichment recording the provider and the exact fields obtained. Set a notification due date on the contact. Mark contacts not yet notified. Provide the notification text naming the actual source and the lawful basis. Trap: Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. Done when: Enriching a contact records the provider and the specific fields, and sets a notification due date. Contacts past their due date without notification are visibly marked and counted. The notification text names the actual provider rather than a generic phrase. Bulk enrichment on import is not offered, verified by looking for it. Do not build yet: No export or deletion yet. ### Phase 5 · Their rights, which are not a feature request Build: Produce a complete record for one contact including every field with its source, all interactions and all enrichments. Support correction and deletion, including deletion that survives a future import re-adding them. Trap: Deleting a contact without recording the deletion means the next import from the same source silently recreates them, which is not deletion and is exactly what the person asked you not to do. Keep a suppression record keyed on the identity so a re-import is blocked rather than welcomed. Done when: A complete record for one contact is produced in minutes including field sources. Deletion records a suppression entry and a subsequent import does not recreate the contact, tested by deleting and re-importing. Correction updates the field and records who changed it. Export of the whole database is complete and re-importable. Do not build yet: Nothing after this. ### Out of scope (and why) - Enrichment data itself, which is the product being sold by the providers and cannot be replicated. - Sales pipeline management with forecasting, which is a different product for a different job. - Bulk outreach and sequences, which turns a relationship tool into a mailing tool with a different set of rules. - Legal advice. This encodes one regulation's requirements and yours may differ. ### README must contain - That every field records its source and a field without one cannot exist - That enrichment creates a notification obligation with a tracked due date - That the notification must name the actual provider rather than a generic phrase - That contacts are never merged on name similarity and merges are reversible - That deletion creates a suppression record so a re-import cannot undo it
# Architecture · Folk ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | TypeScript with Fastify and PostgreSQL | contacts, interactions and provenance are relational | | Provenance | Every field records its source and when it arrived | this is what makes the notification obligation trackable rather than theoretical | | Identity | Deterministic contact matching with manual merge | automatic merging of two people who share a name is a data loss event | | Interactions | Email and calendar metadata by opt-in per account | the timeline is the value, and reading someone's mailbox is a serious permission | | Enrichment | Optional, per contact, with the source recorded and a notification due date | the feature that makes this legally interesting | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Fields | Values with mandatory provenance. | Not swappable for flat columns, which lose the distinction the rest depends on. | | Identity | Normalisation and duplicate proposal. | Any matcher, provided it never merges on name similarity. | | Interactions | Metadata-only timeline. | Any provider, provided the narrowest scope is used and bodies are never stored. | | Enrichment | Lookups and their notification obligations. | Any provider, provided it is named and the obligation is tracked. | | Rights | Access, correction, deletion and suppression. | Any implementation, provided deletion survives a re-import. | ## 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 · Personal data about identified people, most of whom did not give it to you directly. - `ENRICHMENT_PROVIDER` · required · Named because the notification must name it. None is a valid and simpler configuration. - `ENRICHMENT_API_KEY` · optional, secret · Only if enrichment is enabled. Absent means the manual path, which has no obligation. - `NOTIFICATION_DUE_DAYS` · required · From obtaining the data, or first contact if sooner. Thirty is the outer limit, not a target. - `MERGE_REVERSAL_DAYS` · required · How long a merge can be undone. A wrong merge is usually noticed weeks later. - `STORE_MESSAGE_BODIES` · required · Present as a constant so the answer is visible in configuration. There is no other value. ## 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 · Folk product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: TypeScript with Fastify and PostgreSQL, Every field records its source and when it arrived, Deterministic contact matching with manual merge, Email and calendar metadata by opt-in per account, Optional, per contact, with the source recorded and a notification due date. - 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 - A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. - Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. - Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. - Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. - Deleting a contact without recording the deletion means the next import from the same source silently recreates them, which is not deletion and is exactly what the person asked you not to do. Keep a suppression record keyed on the identity so a re-import is blocked rather than welcomed.
# Delivery milestones · Folk Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Contacts, with every field knowing where it came from You can always answer where a piece of information about someone came from. ### Steps 1. Model fields as rows with sources More work than columns and the reason the rest of the build is possible. Files: `src/schema.sql` 2. Make source non-nullable In the database, not in the application. A field without provenance should not exist. Files: `src/fields.ts` 3. Put notes first What someone told you is the valuable context. Enrichment is a garnish. Files: `src/contacts.ts` ### Done when - [ ] No field can be stored without a source - [ ] Manual and imported fields are distinguishable - [ ] Notes are the primary surface - [ ] Import records the file as source detail ### Watch out - A contact record as a flat set of columns loses the distinction between what someone told you and what you found out about them, and that distinction is the entire basis for deciding what you are allowed to do with it. Model fields as rows with a source, even though it is more work than columns. ## M2 · Matching people without merging strangers Duplicates are found and merged deliberately, never automatically. ### Steps 1. Normalise identities before matching Case, dots, plus addressing and phone formats. All of them cause false negatives. Files: `src/identity.ts`, `src/identity.test.ts` 2. Never merge on name similarity Propose at most. Combining two people is not recoverable from notes alone. Files: `src/duplicates.ts` 3. Keep merges reversible for a window Both records retained. Reversal is the only defence against a wrong merge. Files: `src/merge.ts` ### Done when - [ ] A shared email proposes a duplicate - [ ] A shared common name alone does not - [ ] A merge is reversible with both records kept - [ ] The merge record survives the reversal window ### Watch out - Merging two contacts because they share a common name silently combines two different people's notes and history, and there is no way to separate them afterwards because the information about which note belonged to whom is gone. Match on identities, propose on anything weaker, and keep merges reversible. ## M3 · The timeline, from metadata you were given access to You can see when you last spoke to someone without reading their mail. ### Steps 1. Request the narrowest scope available And document why. A broad scope is a liability even when unused. Files: `src/oauth.ts` 2. Store metadata only Subject, time, direction. Never bodies, and assert that in the suite. Files: `src/interactions.ts`, `tests/no-bodies.test.ts` 3. Support exclusions at both levels Per account and per contact. Some correspondence should not be in a CRM. Files: `src/exclusions.ts` ### Done when - [ ] The timeline works from metadata alone - [ ] No message bodies exist in the database - [ ] The narrowest scope is requested and documented - [ ] Exclusions produce no interaction rows ### Watch out - Requesting a scope that grants full mailbox read access because it is the easiest one to get means your application holds the ability to read every message the user has, which is a liability you did not need and a permission prompt that will lose you users. Request metadata scopes, and if the platform only offers broad ones, store less than you were given and say so. ## M4 · Enrichment, and the obligation that comes with it Looking someone up creates a notification you are tracked against. ### Steps 1. Record provider and fields per enrichment Naming the actual source is required. A generic phrase is not sufficient. Files: `src/enrich.ts` 2. Set and track a notification due date One month from obtaining, or first contact, whichever comes first. Files: `src/notifications.ts` 3. Do not offer bulk enrichment It creates hundreds of obligations in one click. The absence is the design. Files: `src/enrich.ts` ### Done when - [ ] Enrichment records provider and specific fields - [ ] A notification due date is set and tracked - [ ] Overdue contacts are marked and counted - [ ] Bulk enrichment on import is not available ### Watch out - Enriching every contact automatically on import creates hundreds of notification obligations in one action and no realistic way to meet them, which converts a convenience feature into a compliance problem you did not notice creating. Enrich per contact deliberately, and show the count of outstanding notifications so it cannot accumulate invisibly. ## M5 · Their rights, which are not a feature request (production only) A person asking what you hold about them can be answered in minutes. ### Steps 1. Produce a per-contact record on demand Fields with sources, interactions, enrichments. This is what the question actually asks. Files: `src/subject-access.ts` 2. Suppress on deletion Keyed on identity, so a re-import cannot undo it. Files: `src/deletion.ts`, `tests/suppression.test.ts` 3. Record corrections What changed, when and by whom. A correction is not an edit. Files: `src/corrections.ts` ### Done when - [ ] A per-contact record is produced in minutes - [ ] Deletion survives a subsequent re-import - [ ] Corrections are recorded with who and when - [ ] Full export is complete and re-importable ### Watch out - Deleting a contact without recording the deletion means the next import from the same source silently recreates them, which is not deletion and is exactly what the person asked you not to do. Keep a suppression record keyed on the identity so a re-import is blocked rather than welcomed.
# Operations · Folk ## Backup This is personal data about people who mostly did not choose to be in it. Encrypt backups, apply your retention to them, and make sure a deletion request reaches the backups rather than only the live database. ## Restore Restore and confirm suppression records survived, since a restore that loses them will recreate contacts who asked to be deleted the next time an import runs. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Outstanding notification count is the number to watch, because it only ever grows quietly. A rising count means enrichment is being used faster than the obligations are being met. ## Incident checklist A contact recreated after deletion means the suppression record was missing or not consulted by the import path. That is the failure most likely to reach the person themselves, since they will notice they are back. 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 - [ ] A field without a source is confirmed impossible - [ ] Name-only similarity is confirmed not to propose a merge - [ ] No message bodies are confirmed present in the database - [ ] An enrichment is confirmed to set a tracked notification due date - [ ] Deletion is confirmed to survive a re-import ## Launch constraint Do not market omitted Folk 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. Personal data about identified people, most of whom did not give it to you directly. DATABASE_URL=postgres://localhost:5432/folk # Required. Named because the notification must name it. None is a valid and simpler configuration. ENRICHMENT_PROVIDER=none # Optional · secret. Only if enrichment is enabled. Absent means the manual path, which has no obligation. ENRICHMENT_API_KEY=a provider key # Required. From obtaining the data, or first contact if sooner. Thirty is the outer limit, not a target. NOTIFICATION_DUE_DAYS=30 # Required. How long a merge can be undone. A wrong merge is usually noticed weeks later. MERGE_REVERSAL_DAYS=30 # Required. Present as a constant so the answer is visible in configuration. There is no other value. STORE_MESSAGE_BODIES=never
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Folk 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.
xcollaborative contact enrichment, integrations, messaging, and polished relationship workflows
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 7 free alternatives to Folk →· no votes, no pay-to-list · just what's real
Folk pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| standard | $30/user | $24/user | 1 synced account/member; 500 email-finding enrichments/month, 2,000 AI-field actions/month, 200 research actions/month, 1,500 workflow actions/month, 2,000 messages/member/month, 1 custom domain |
| premium | $60/user | $48/user | 5 synced accounts/member; 1,000 enrichments/month, 5,000 AI-field actions/month, 200 research actions/month, 10,000 workflow actions/month, 5,000 messages/member/month, 3 custom domains |
| enterprise | $100/user | $80/user | Custom usage limits, synced accounts and domains; dedicated account manager and custom integrations |
free tierno free tier (14-day Premium-feature trial; account is blocked after the trial unless extended or upgraded)
billingmonthly + annual (20% off)
hidden costsBilling is per member and separately per workspace; member additions/removals are prorated. Enrichment, AI, workflow and message allowances are capped; Enterprise prices are starting amounts.
verified 2026-08-12 · source ↗
Vibecode Folk
Kinda. The core of Folk is buildable in a weekend with the prompt on this page, but there are real gaps: collaborative contact enrichment, integrations, messaging, and polished relationship workflows, automatic email and calendar capture. Read the honest list above before committing.
How much does Folk cost?
Folk costs about $30/month (Premium, checked 2026-07-31), which is $360 per year.
What do I lose by replacing Folk?
Honestly: collaborative contact enrichment, integrations, messaging, and polished relationship workflows; 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 Folk?
Yes: Twenty (A modern pipeline CRM with custom objects, views and mailbox sync; the subscription becomes a Compose file.) EspoCRM (A traditional CRM that actually lets you change the data model; polished enough, but still your server.) Frappe CRM (A clean leads-and-deals board with notes, tasks and email; Frappe underneath means a real server, not a toy.) All 7 curated free alternatives are at vibecodeit.com/folk/alternatives. The prompt is for when you want it exactly your way.