Vibecode Crisp
track this build5 phases, 15 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Crisp, combine website chat and a shared inbox for one small support team. The hard boundary is multi-channel inbox, chat delivery, knowledge base, campaigns, and workspace pricing, plus channel integrations and support operations.
You are building a lean indie version of Crisp. 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 ===== # Crisp · indie build An embeddable chat widget for your site, a shared inbox where a small team answers from one place, message delivery that survives a dropped connection without losing or duplicating anything, email fallback when nobody is online, and searchable history with a retention policy. 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 | Node 22 with Fastify | the workload is many idle socket connections and short bursts of work, which is what this is good at | | Transport | WebSocket for live messages, with a cursor-based backfill over HTTP | sockets deliver, but the backfill is what makes a reconnect correct | | Database | PostgreSQL | conversations, ordering and full text search all want a real database | | Widget | A hand-written script under 15 KB, no framework | it loads on other people's sites and every kilobyte is theirs, not yours | | Email | A transactional provider with inbound parsing | the fallback when nobody is online, and the channel that keeps a conversation alive after the visitor closes the tab | ## 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 tooling. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free - Why: Holds conversations and provides the full text search in Phase 5. - Get it: Install locally with your package manager or run it in Docker, and create a database for the project. - Verify: psql connects and SELECT version() reports 16 or higher - [ ] **A transactional email provider with inbound parsing** · free tiers typically cover a few thousand messages a month; inbound parsing is sometimes on a paid tier, so check before choosing - Why: Phase 4 needs both directions. Plenty of providers send well and do not parse inbound mail, which would leave the fallback half built. - Get it: Choose a provider that explicitly supports inbound parsing or inbound routing, verify a sending domain with their DNS records, and point an MX record at their inbound host. Confirm inbound support before you commit, because it is the part that varies. - Verify: A test message to your inbound address arrives at your webhook - [ ] **A domain with DNS you control** · roughly 10 to 15 USD a year - Why: Needed for the sending domain, the inbound MX record and the certificate. - Get it: Any registrar, then add the DNS records your email provider specifies. Propagation can take a few hours, so do this first. - Verify: dig MX for your inbound subdomain returns the provider's host - [ ] **A page on a different origin to embed the widget** · free - Why: Same-origin testing hides exactly the CORS and origin problems Phase 1 exists to catch. - Get it: Any static host, or a second local server on a different port. A different port is a different origin, which is enough. - Verify: The widget loads and connects from that page ## Quick start ```sh pg_dump "$DATABASE_URL" | gzip > inbox-$(date +%F).sql.gz ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The multi-channel inbox covering social, SMS and marketplace messages. - The knowledge base, campaigns and the rest of the bundled suite. - Deliverability engineering as volume grows. - AI resolution trained on a large corpus of real support conversations. - Mobile applications for agents. - multi-channel inbox, chat delivery, knowledge base, campaigns, and workspace pricing - omnichannel integrations - large-scale deliverability - AI resolution tuning - enterprise routing and compliance If one of those is essential to you, that is the reason to keep paying for Crisp, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Crisp 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 shared inbox with a website chat widget for one small support team to replace Crisp. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify · the workload is many idle socket connections and short bursts of work, which is what this is good at - Transport: WebSocket for live messages, with a cursor-based backfill over HTTP · sockets deliver, but the backfill is what makes a reconnect correct - Database: PostgreSQL · conversations, ordering and full text search all want a real database - Widget: A hand-written script under 15 KB, no framework · it loads on other people's sites and every kilobyte is theirs, not yours - Email: A transactional provider with inbound parsing · the fallback when nobody is online, and the channel that keeps a conversation alive after the visitor closes the tab ### Data model (create this before Phase 1) - sites(id, name, origin_allowlist, widget_key) - contacts(id, site_id, external_id, email, name, first_seen_at, last_seen_at) - conversations(id, site_id, contact_id, state, assignee_id, subject, created_at, last_message_at) - messages(id, conversation_id, seq, author_kind, author_id, body, delivered_at, read_at) · seq is a per-conversation monotonic integer - agents(id, email, name, role) - canned_responses(id, site_id, shortcut, body) - The seq column is the backbone: it is what a reconnecting client asks for and what makes delivery verifiable. ### Phase 1 · Widget, origin control and one conversation Build: A small embeddable script that opens a socket and renders a chat panel. A per-site widget key that identifies the site but grants nothing beyond starting and continuing a conversation. Validate the Origin header against the site's allowlist on connect. Trap: The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. Done when: A page on a different origin loads the widget and starts a conversation. A connection from an origin not in the allowlist is refused before any message is accepted. Using a widget key to request another conversation's messages returns a refusal, not data. Do not build yet: No agent inbox, no reconnect logic, no email. ### Phase 2 · Delivery that survives a dropped connection Build: Assign every message a per-conversation sequence number at write time. The client stores the highest sequence it has. On reconnect it sends that number and receives everything after it over HTTP before the socket resumes live delivery. Trap: Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. Done when: Killing the socket, sending three messages server-side, then reconnecting delivers exactly those three, in order, with no duplicates. Sending the same client message twice with the same idempotency key stores one row. A message that arrives during the backfill window is not delivered twice. Do not build yet: No inbox and no email yet. ### Phase 3 · The shared inbox Build: An agent interface listing open conversations with assignment, states of open, snoozed and closed, and a visible indication when another agent is typing or already viewing a conversation. Assignment is a claim, not a suggestion. Trap: Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. Done when: Two browser sessions open the same conversation and each sees that the other is viewing it. Assigning to one agent updates the other's list within a second. A closed conversation reopens when the visitor replies rather than staying closed and unanswered. Do not build yet: No email fallback and no search yet. ### Phase 4 · Email fallback Build: Ask for an email address when nobody is online. Send agent replies by email when the visitor's socket is gone. Parse inbound replies back into the conversation using a token in the reply address rather than by matching the subject line. Trap: Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. Done when: Closing the browser tab, then having an agent reply, delivers an email. Replying to that email appends to the same conversation rather than creating a new one. Two separate conversations from the same email address stay separate. A reply to an expired or unknown token is rejected and reported rather than silently dropped. Do not build yet: No search or retention yet. ### Phase 5 · Search, canned replies and retention Build: PostgreSQL full text search across messages with the contact and date as filters. Canned responses with shortcuts. A retention policy that deletes conversations older than a chosen age, with a documented deletion path for a specific person on request. Trap: Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. Done when: Searching a phrase from a six month old conversation returns it in well under a second across a seeded dataset of tens of thousands of messages. Deleting a contact removes their messages, their attachments and their search results, verified by searching for a known phrase afterwards and finding nothing. Do not build yet: Production hardening is the last phase. ### Phase 6 · Running it for real Build: Rate limits per site and per visitor. Spam controls on conversation creation. TLS, monitoring on socket counts and message lag, nightly backups off the machine, and a restore you have performed. Trap: A widget embedded on a public page is an open endpoint. Without a per-visitor and per-site rate limit, one script can create a hundred thousand conversations overnight and your inbox is unusable in the morning. Done when: A burst of automated conversation creation is throttled and the existing conversations stay responsive. An uptime check alerts within minutes of the service stopping. A restore onto a clean server reproduces conversations and messages, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - The multi-channel inbox. Crisp pulls in email, social channels, SMS and more, and each one is an integration with its own delivery quirks and its own outages. - The knowledge base and the campaign tools, which are separate products bundled into the price. - Deliverability at scale. A transactional provider gets you started; keeping mail out of spam folders as volume grows is a discipline of its own. - AI resolution tuned on a large corpus of real support conversations, which is data you do not have. - The mobile applications agents use to answer while away from a desk. ### README must contain - That the widget key is public and grants only starting and continuing one conversation - How sequence numbers and cursor backfill guarantee exactly-once delivery on reconnect - That email threading uses a signed token in the reply address, not the subject line - The retention policy, and how to fulfil a deletion request for one person - The backup schedule and the date of the last restore drill ===== AGENTS.md ===== # Agent instructions · Crisp indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Fastify, WebSocket for live messages, with a cursor-based backfill over HTTP, PostgreSQL, A hand-written script under 15 KB, no framework, A transactional provider with inbound parsing. 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 - The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. - Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. - Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. - Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. - Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. ===== BUILD_PLAN.md ===== # Build plan · Crisp An embeddable chat widget for your site, a shared inbox where a small team answers from one place, message delivery that survives a dropped connection without losing or duplicating anything, email fallback when nobody is online, and searchable history with a retention policy. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Widget, origin control and one conversation A script on another origin can start a conversation, and an origin you did not authorise cannot. ### Steps 1. Build the widget as a single small script No framework, no build step for the widget itself. It creates a shadow root so the host page's styles cannot break it and yours cannot leak out. Files: `public/widget.js` 2. Validate origin on connect Check the Origin header against the site's allowlist during the WebSocket handshake and refuse there, before any message is processed. Files: `src/ws.ts` 3. Scope every read to the visitor's own conversation The visitor session identifies one conversation. Write a test that asks for another conversation's messages with a valid widget key and expects a refusal. Files: `src/auth.ts`, `test/scope.test.ts` ### Done when - [ ] The widget works when loaded from a different origin - [ ] A connection from an unlisted origin is refused at handshake - [ ] A widget key cannot read another conversation - [ ] The widget's shadow root is unaffected by aggressive host page CSS ### Watch out - The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. ## Phase 2 · Delivery that survives a dropped connection Messages sent while a client was offline arrive exactly once when it comes back. ### Steps 1. Allocate sequence numbers in the database A per-conversation counter updated in the same transaction as the insert, with a unique constraint on conversation and sequence. Files: `src/messages.ts`, `migrations/` 2. Implement cursor backfill An endpoint that returns messages after a sequence number, capped and paginated. The client calls it on reconnect before resuming live delivery. Files: `src/backfill.ts` 3. Handle the overlap window Buffer live messages during backfill, then deduplicate by sequence when resuming. This window is where duplicates come from and it needs a test. Files: `test/reconnect.test.ts` ### Done when - [ ] Three messages sent while offline arrive exactly once on reconnect - [ ] The same client message with one idempotency key stores one row - [ ] A duplicate sequence insert fails at the database - [ ] Messages arriving during backfill are not delivered twice ### Watch out - Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. ## Phase 3 · The shared inbox Two agents can work the same queue without both answering the same person. ### Steps 1. Build the queue and conversation view Sorted by last message, filtered by state and assignee. Keyboard shortcuts for assign, close and next, because this is a screen people live in. Files: `src/inbox/` 2. Broadcast presence and typing Ephemeral, in memory, never persisted. Presence that outlives a disconnect is worse than no presence. Files: `src/presence.ts` 3. Reopen on visitor reply A message from the visitor on a closed conversation sets it back to open and puts it in the queue. Test it, because it is easy to miss and it strands people. Files: `test/state.test.ts` ### Done when - [ ] Two agents viewing one conversation each see the other's presence - [ ] Assignment propagates to the other session within a second - [ ] A visitor reply reopens a closed conversation - [ ] Presence disappears when a socket drops rather than lingering ### Watch out - Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. ## Phase 4 · Email fallback A visitor who leaves gets the answer anyway, and their reply lands back in the same conversation. ### Steps 1. Capture an address when nobody is online Ask only when it is useful, and say why. An email field on every conversation is friction for the ones answered in thirty seconds. Files: `public/widget.js` 2. Send with a tokenised reply address A per-conversation token in the local part of the reply address. Sign it so a guessed token cannot inject into a conversation. Files: `src/email/send.ts` 3. Parse inbound and strip quoted history Route the provider's inbound webhook to the conversation named by the token, and trim quoted text and signatures so the thread stays readable. Files: `src/email/inbound.ts` ### Done when - [ ] An agent reply reaches the visitor by email after they close the tab - [ ] An email reply lands in the original conversation - [ ] Two conversations from one address remain separate - [ ] An unknown or tampered token is rejected and logged ### Watch out - Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. ## Phase 5 · Search, canned replies and retention Finding an old conversation is fast, and old data does not accumulate forever by default. ### Steps 1. Add full text search with an index A generated tsvector column with a GIN index. Seed tens of thousands of messages before deciding it is fast. Files: `migrations/` 2. Add canned responses Shortcut expansion in the agent composer. Small feature, large daily saving. Files: `src/canned.ts` 3. Implement deletion that covers every store One function that removes rows, attachments and index entries. Test by searching for the deleted phrase and expecting nothing. Files: `src/delete.ts`, `test/delete.test.ts` ### Done when - [ ] Search across tens of thousands of messages returns in well under a second - [ ] A deleted contact's phrase cannot be found by search afterwards - [ ] Attachments belonging to a deleted contact are gone from storage - [ ] Retention removes aged conversations on schedule and logs what it removed ### Watch out - Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. ## Not in this build - The multi-channel inbox covering social, SMS and marketplace messages. - The knowledge base, campaigns and the rest of the bundled suite. - Deliverability engineering as volume grows. - AI resolution trained on a large corpus of real support conversations. - Mobile applications for agents. ## After v1, if you want it - Business hours with an away message, which is small and changes the experience a lot - Conversation tagging and a weekly volume report - A public status page fed by the same monitoring ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/inbox # Required. Where the server listens. Put a reverse proxy in front for TLS. PORT=3000 # Required. Used to build widget URLs and reply addresses. Wrong here means email replies go nowhere. PUBLIC_URL=https://inbox.example.com # Required · secret. Your transactional provider's dashboard. EMAIL_API_KEY=re_... # Required. The subdomain whose MX record points at your provider's inbound host. INBOUND_DOMAIN=reply.example.com # Required · secret. Signs the per-conversation token in reply addresses. Rotating it invalidates outstanding reply addresses. REPLY_TOKEN_SECRET=generate-a-long-random-value # Required. Conversations older than this are deleted. Say this number in your privacy policy. RETENTION_DAYS=365
You are building a lean indie version of Crisp. 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 ===== # Crisp · indie build An embeddable chat widget for your site, a shared inbox where a small team answers from one place, message delivery that survives a dropped connection without losing or duplicating anything, email fallback when nobody is online, and searchable history with a retention policy. 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 | Node 22 with Fastify | the workload is many idle socket connections and short bursts of work, which is what this is good at | | Transport | WebSocket for live messages, with a cursor-based backfill over HTTP | sockets deliver, but the backfill is what makes a reconnect correct | | Database | PostgreSQL | conversations, ordering and full text search all want a real database | | Widget | A hand-written script under 15 KB, no framework | it loads on other people's sites and every kilobyte is theirs, not yours | | Email | A transactional provider with inbound parsing | the fallback when nobody is online, and the channel that keeps a conversation alive after the visitor closes the tab | ## 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 tooling. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free - Why: Holds conversations and provides the full text search in Phase 5. - Get it: Install locally with your package manager or run it in Docker, and create a database for the project. - Verify: psql connects and SELECT version() reports 16 or higher - [ ] **A transactional email provider with inbound parsing** · free tiers typically cover a few thousand messages a month; inbound parsing is sometimes on a paid tier, so check before choosing - Why: Phase 4 needs both directions. Plenty of providers send well and do not parse inbound mail, which would leave the fallback half built. - Get it: Choose a provider that explicitly supports inbound parsing or inbound routing, verify a sending domain with their DNS records, and point an MX record at their inbound host. Confirm inbound support before you commit, because it is the part that varies. - Verify: A test message to your inbound address arrives at your webhook - [ ] **A domain with DNS you control** · roughly 10 to 15 USD a year - Why: Needed for the sending domain, the inbound MX record and the certificate. - Get it: Any registrar, then add the DNS records your email provider specifies. Propagation can take a few hours, so do this first. - Verify: dig MX for your inbound subdomain returns the provider's host - [ ] **A page on a different origin to embed the widget** · free - Why: Same-origin testing hides exactly the CORS and origin problems Phase 1 exists to catch. - Get it: Any static host, or a second local server on a different port. A different port is a different origin, which is enough. - Verify: The widget loads and connects from that page ## Quick start ```sh pg_dump "$DATABASE_URL" | gzip > inbox-$(date +%F).sql.gz ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The multi-channel inbox covering social, SMS and marketplace messages. - The knowledge base, campaigns and the rest of the bundled suite. - Deliverability engineering as volume grows. - AI resolution trained on a large corpus of real support conversations. - Mobile applications for agents. - multi-channel inbox, chat delivery, knowledge base, campaigns, and workspace pricing - omnichannel integrations - large-scale deliverability - AI resolution tuning - enterprise routing and compliance If one of those is essential to you, that is the reason to keep paying for Crisp, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Crisp 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 shared inbox with a website chat widget for one small support team to replace Crisp. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify · the workload is many idle socket connections and short bursts of work, which is what this is good at - Transport: WebSocket for live messages, with a cursor-based backfill over HTTP · sockets deliver, but the backfill is what makes a reconnect correct - Database: PostgreSQL · conversations, ordering and full text search all want a real database - Widget: A hand-written script under 15 KB, no framework · it loads on other people's sites and every kilobyte is theirs, not yours - Email: A transactional provider with inbound parsing · the fallback when nobody is online, and the channel that keeps a conversation alive after the visitor closes the tab ### Data model (create this before Phase 1) - sites(id, name, origin_allowlist, widget_key) - contacts(id, site_id, external_id, email, name, first_seen_at, last_seen_at) - conversations(id, site_id, contact_id, state, assignee_id, subject, created_at, last_message_at) - messages(id, conversation_id, seq, author_kind, author_id, body, delivered_at, read_at) · seq is a per-conversation monotonic integer - agents(id, email, name, role) - canned_responses(id, site_id, shortcut, body) - The seq column is the backbone: it is what a reconnecting client asks for and what makes delivery verifiable. ### Phase 1 · Widget, origin control and one conversation Build: A small embeddable script that opens a socket and renders a chat panel. A per-site widget key that identifies the site but grants nothing beyond starting and continuing a conversation. Validate the Origin header against the site's allowlist on connect. Trap: The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. Done when: A page on a different origin loads the widget and starts a conversation. A connection from an origin not in the allowlist is refused before any message is accepted. Using a widget key to request another conversation's messages returns a refusal, not data. Do not build yet: No agent inbox, no reconnect logic, no email. ### Phase 2 · Delivery that survives a dropped connection Build: Assign every message a per-conversation sequence number at write time. The client stores the highest sequence it has. On reconnect it sends that number and receives everything after it over HTTP before the socket resumes live delivery. Trap: Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. Done when: Killing the socket, sending three messages server-side, then reconnecting delivers exactly those three, in order, with no duplicates. Sending the same client message twice with the same idempotency key stores one row. A message that arrives during the backfill window is not delivered twice. Do not build yet: No inbox and no email yet. ### Phase 3 · The shared inbox Build: An agent interface listing open conversations with assignment, states of open, snoozed and closed, and a visible indication when another agent is typing or already viewing a conversation. Assignment is a claim, not a suggestion. Trap: Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. Done when: Two browser sessions open the same conversation and each sees that the other is viewing it. Assigning to one agent updates the other's list within a second. A closed conversation reopens when the visitor replies rather than staying closed and unanswered. Do not build yet: No email fallback and no search yet. ### Phase 4 · Email fallback Build: Ask for an email address when nobody is online. Send agent replies by email when the visitor's socket is gone. Parse inbound replies back into the conversation using a token in the reply address rather than by matching the subject line. Trap: Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. Done when: Closing the browser tab, then having an agent reply, delivers an email. Replying to that email appends to the same conversation rather than creating a new one. Two separate conversations from the same email address stay separate. A reply to an expired or unknown token is rejected and reported rather than silently dropped. Do not build yet: No search or retention yet. ### Phase 5 · Search, canned replies and retention Build: PostgreSQL full text search across messages with the contact and date as filters. Canned responses with shortcuts. A retention policy that deletes conversations older than a chosen age, with a documented deletion path for a specific person on request. Trap: Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. Done when: Searching a phrase from a six month old conversation returns it in well under a second across a seeded dataset of tens of thousands of messages. Deleting a contact removes their messages, their attachments and their search results, verified by searching for a known phrase afterwards and finding nothing. Do not build yet: Production hardening is the last phase. ### Phase 6 · Running it for real Build: Rate limits per site and per visitor. Spam controls on conversation creation. TLS, monitoring on socket counts and message lag, nightly backups off the machine, and a restore you have performed. Trap: A widget embedded on a public page is an open endpoint. Without a per-visitor and per-site rate limit, one script can create a hundred thousand conversations overnight and your inbox is unusable in the morning. Done when: A burst of automated conversation creation is throttled and the existing conversations stay responsive. An uptime check alerts within minutes of the service stopping. A restore onto a clean server reproduces conversations and messages, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - The multi-channel inbox. Crisp pulls in email, social channels, SMS and more, and each one is an integration with its own delivery quirks and its own outages. - The knowledge base and the campaign tools, which are separate products bundled into the price. - Deliverability at scale. A transactional provider gets you started; keeping mail out of spam folders as volume grows is a discipline of its own. - AI resolution tuned on a large corpus of real support conversations, which is data you do not have. - The mobile applications agents use to answer while away from a desk. ### README must contain - That the widget key is public and grants only starting and continuing one conversation - How sequence numbers and cursor backfill guarantee exactly-once delivery on reconnect - That email threading uses a signed token in the reply address, not the subject line - The retention policy, and how to fulfil a deletion request for one person - The backup schedule and the date of the last restore drill ===== AGENTS.md ===== # Agent instructions · Crisp indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Fastify, WebSocket for live messages, with a cursor-based backfill over HTTP, PostgreSQL, A hand-written script under 15 KB, no framework, A transactional provider with inbound parsing. 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 - The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. - Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. - Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. - Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. - Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. ===== BUILD_PLAN.md ===== # Build plan · Crisp An embeddable chat widget for your site, a shared inbox where a small team answers from one place, message delivery that survives a dropped connection without losing or duplicating anything, email fallback when nobody is online, and searchable history with a retention policy. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Widget, origin control and one conversation A script on another origin can start a conversation, and an origin you did not authorise cannot. ### Steps 1. Build the widget as a single small script No framework, no build step for the widget itself. It creates a shadow root so the host page's styles cannot break it and yours cannot leak out. Files: `public/widget.js` 2. Validate origin on connect Check the Origin header against the site's allowlist during the WebSocket handshake and refuse there, before any message is processed. Files: `src/ws.ts` 3. Scope every read to the visitor's own conversation The visitor session identifies one conversation. Write a test that asks for another conversation's messages with a valid widget key and expects a refusal. Files: `src/auth.ts`, `test/scope.test.ts` ### Done when - [ ] The widget works when loaded from a different origin - [ ] A connection from an unlisted origin is refused at handshake - [ ] A widget key cannot read another conversation - [ ] The widget's shadow root is unaffected by aggressive host page CSS ### Watch out - The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. ## Phase 2 · Delivery that survives a dropped connection Messages sent while a client was offline arrive exactly once when it comes back. ### Steps 1. Allocate sequence numbers in the database A per-conversation counter updated in the same transaction as the insert, with a unique constraint on conversation and sequence. Files: `src/messages.ts`, `migrations/` 2. Implement cursor backfill An endpoint that returns messages after a sequence number, capped and paginated. The client calls it on reconnect before resuming live delivery. Files: `src/backfill.ts` 3. Handle the overlap window Buffer live messages during backfill, then deduplicate by sequence when resuming. This window is where duplicates come from and it needs a test. Files: `test/reconnect.test.ts` ### Done when - [ ] Three messages sent while offline arrive exactly once on reconnect - [ ] The same client message with one idempotency key stores one row - [ ] A duplicate sequence insert fails at the database - [ ] Messages arriving during backfill are not delivered twice ### Watch out - Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. ## Phase 3 · The shared inbox Two agents can work the same queue without both answering the same person. ### Steps 1. Build the queue and conversation view Sorted by last message, filtered by state and assignee. Keyboard shortcuts for assign, close and next, because this is a screen people live in. Files: `src/inbox/` 2. Broadcast presence and typing Ephemeral, in memory, never persisted. Presence that outlives a disconnect is worse than no presence. Files: `src/presence.ts` 3. Reopen on visitor reply A message from the visitor on a closed conversation sets it back to open and puts it in the queue. Test it, because it is easy to miss and it strands people. Files: `test/state.test.ts` ### Done when - [ ] Two agents viewing one conversation each see the other's presence - [ ] Assignment propagates to the other session within a second - [ ] A visitor reply reopens a closed conversation - [ ] Presence disappears when a socket drops rather than lingering ### Watch out - Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. ## Phase 4 · Email fallback A visitor who leaves gets the answer anyway, and their reply lands back in the same conversation. ### Steps 1. Capture an address when nobody is online Ask only when it is useful, and say why. An email field on every conversation is friction for the ones answered in thirty seconds. Files: `public/widget.js` 2. Send with a tokenised reply address A per-conversation token in the local part of the reply address. Sign it so a guessed token cannot inject into a conversation. Files: `src/email/send.ts` 3. Parse inbound and strip quoted history Route the provider's inbound webhook to the conversation named by the token, and trim quoted text and signatures so the thread stays readable. Files: `src/email/inbound.ts` ### Done when - [ ] An agent reply reaches the visitor by email after they close the tab - [ ] An email reply lands in the original conversation - [ ] Two conversations from one address remain separate - [ ] An unknown or tampered token is rejected and logged ### Watch out - Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. ## Phase 5 · Search, canned replies and retention Finding an old conversation is fast, and old data does not accumulate forever by default. ### Steps 1. Add full text search with an index A generated tsvector column with a GIN index. Seed tens of thousands of messages before deciding it is fast. Files: `migrations/` 2. Add canned responses Shortcut expansion in the agent composer. Small feature, large daily saving. Files: `src/canned.ts` 3. Implement deletion that covers every store One function that removes rows, attachments and index entries. Test by searching for the deleted phrase and expecting nothing. Files: `src/delete.ts`, `test/delete.test.ts` ### Done when - [ ] Search across tens of thousands of messages returns in well under a second - [ ] A deleted contact's phrase cannot be found by search afterwards - [ ] Attachments belonging to a deleted contact are gone from storage - [ ] Retention removes aged conversations on schedule and logs what it removed ### Watch out - Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. ## Not in this build - The multi-channel inbox covering social, SMS and marketplace messages. - The knowledge base, campaigns and the rest of the bundled suite. - Deliverability engineering as volume grows. - AI resolution trained on a large corpus of real support conversations. - Mobile applications for agents. ## After v1, if you want it - Business hours with an away message, which is small and changes the experience a lot - Conversation tagging and a weekly volume report - A public status page fed by the same monitoring ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/inbox # Required. Where the server listens. Put a reverse proxy in front for TLS. PORT=3000 # Required. Used to build widget URLs and reply addresses. Wrong here means email replies go nowhere. PUBLIC_URL=https://inbox.example.com # Required · secret. Your transactional provider's dashboard. EMAIL_API_KEY=re_... # Required. The subdomain whose MX record points at your provider's inbound host. INBOUND_DOMAIN=reply.example.com # Required · secret. Signs the per-conversation token in reply addresses. Rotating it invalidates outstanding reply addresses. REPLY_TOKEN_SECRET=generate-a-long-random-value # Required. Conversations older than this are deleted. Say this number in your privacy policy. RETENTION_DAYS=365
You are building a production product version of Crisp. 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 ===== # Crisp · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Crisp, combine website chat and a shared inbox for one small support team. The hard boundary is multi-channel inbox, chat delivery, knowledge base, campaigns, and workspace pricing, plus channel integrations and support operations. ## Product outcome A shared inbox a small team can actually work in, with delivery you can prove is exactly-once, email threading that survives real mail clients, deletion that covers every store, and a restore you have performed. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - public HTTPS deployment - email or chat channel credentials ## Explicit non-goals for v1 - The multi-channel inbox covering social, SMS and marketplace messages. - The knowledge base, campaigns and the rest of the bundled suite. - Deliverability engineering as volume grows. - AI resolution trained on a large corpus of real support conversations. - Mobile applications for agents. - multi-channel inbox, chat delivery, knowledge base, campaigns, and workspace pricing - omnichannel integrations - large-scale deliverability - AI resolution tuning - enterprise routing and compliance ## Success criteria - The reconnect test proves exactly-once delivery - An email reply threads into the original conversation on at least two different mail clients - Deleting a contact leaves nothing findable by search - A creation burst is throttled without degrading existing conversations - A restore has been performed on a clean server and dated ===== BRIEF.md ===== # Build brief · Crisp 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 shared inbox with a website chat widget for one small support team to replace Crisp. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify · the workload is many idle socket connections and short bursts of work, which is what this is good at - Transport: WebSocket for live messages, with a cursor-based backfill over HTTP · sockets deliver, but the backfill is what makes a reconnect correct - Database: PostgreSQL · conversations, ordering and full text search all want a real database - Widget: A hand-written script under 15 KB, no framework · it loads on other people's sites and every kilobyte is theirs, not yours - Email: A transactional provider with inbound parsing · the fallback when nobody is online, and the channel that keeps a conversation alive after the visitor closes the tab ### Data model (create this before Phase 1) - sites(id, name, origin_allowlist, widget_key) - contacts(id, site_id, external_id, email, name, first_seen_at, last_seen_at) - conversations(id, site_id, contact_id, state, assignee_id, subject, created_at, last_message_at) - messages(id, conversation_id, seq, author_kind, author_id, body, delivered_at, read_at) · seq is a per-conversation monotonic integer - agents(id, email, name, role) - canned_responses(id, site_id, shortcut, body) - The seq column is the backbone: it is what a reconnecting client asks for and what makes delivery verifiable. ### Phase 1 · Widget, origin control and one conversation Build: A small embeddable script that opens a socket and renders a chat panel. A per-site widget key that identifies the site but grants nothing beyond starting and continuing a conversation. Validate the Origin header against the site's allowlist on connect. Trap: The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. Done when: A page on a different origin loads the widget and starts a conversation. A connection from an origin not in the allowlist is refused before any message is accepted. Using a widget key to request another conversation's messages returns a refusal, not data. Do not build yet: No agent inbox, no reconnect logic, no email. ### Phase 2 · Delivery that survives a dropped connection Build: Assign every message a per-conversation sequence number at write time. The client stores the highest sequence it has. On reconnect it sends that number and receives everything after it over HTTP before the socket resumes live delivery. Trap: Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. Done when: Killing the socket, sending three messages server-side, then reconnecting delivers exactly those three, in order, with no duplicates. Sending the same client message twice with the same idempotency key stores one row. A message that arrives during the backfill window is not delivered twice. Do not build yet: No inbox and no email yet. ### Phase 3 · The shared inbox Build: An agent interface listing open conversations with assignment, states of open, snoozed and closed, and a visible indication when another agent is typing or already viewing a conversation. Assignment is a claim, not a suggestion. Trap: Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. Done when: Two browser sessions open the same conversation and each sees that the other is viewing it. Assigning to one agent updates the other's list within a second. A closed conversation reopens when the visitor replies rather than staying closed and unanswered. Do not build yet: No email fallback and no search yet. ### Phase 4 · Email fallback Build: Ask for an email address when nobody is online. Send agent replies by email when the visitor's socket is gone. Parse inbound replies back into the conversation using a token in the reply address rather than by matching the subject line. Trap: Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. Done when: Closing the browser tab, then having an agent reply, delivers an email. Replying to that email appends to the same conversation rather than creating a new one. Two separate conversations from the same email address stay separate. A reply to an expired or unknown token is rejected and reported rather than silently dropped. Do not build yet: No search or retention yet. ### Phase 5 · Search, canned replies and retention Build: PostgreSQL full text search across messages with the contact and date as filters. Canned responses with shortcuts. A retention policy that deletes conversations older than a chosen age, with a documented deletion path for a specific person on request. Trap: Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. Done when: Searching a phrase from a six month old conversation returns it in well under a second across a seeded dataset of tens of thousands of messages. Deleting a contact removes their messages, their attachments and their search results, verified by searching for a known phrase afterwards and finding nothing. Do not build yet: Production hardening is the last phase. ### Phase 6 · Running it for real Build: Rate limits per site and per visitor. Spam controls on conversation creation. TLS, monitoring on socket counts and message lag, nightly backups off the machine, and a restore you have performed. Trap: A widget embedded on a public page is an open endpoint. Without a per-visitor and per-site rate limit, one script can create a hundred thousand conversations overnight and your inbox is unusable in the morning. Done when: A burst of automated conversation creation is throttled and the existing conversations stay responsive. An uptime check alerts within minutes of the service stopping. A restore onto a clean server reproduces conversations and messages, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - The multi-channel inbox. Crisp pulls in email, social channels, SMS and more, and each one is an integration with its own delivery quirks and its own outages. - The knowledge base and the campaign tools, which are separate products bundled into the price. - Deliverability at scale. A transactional provider gets you started; keeping mail out of spam folders as volume grows is a discipline of its own. - AI resolution tuned on a large corpus of real support conversations, which is data you do not have. - The mobile applications agents use to answer while away from a desk. ### README must contain - That the widget key is public and grants only starting and continuing one conversation - How sequence numbers and cursor backfill guarantee exactly-once delivery on reconnect - That email threading uses a signed token in the reply address, not the subject line - The retention policy, and how to fulfil a deletion request for one person - The backup schedule and the date of the last restore drill ===== ARCHITECTURE.md ===== # Architecture · Crisp ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Fastify | the workload is many idle socket connections and short bursts of work, which is what this is good at | | Transport | WebSocket for live messages, with a cursor-based backfill over HTTP | sockets deliver, but the backfill is what makes a reconnect correct | | Database | PostgreSQL | conversations, ordering and full text search all want a real database | | Widget | A hand-written script under 15 KB, no framework | it loads on other people's sites and every kilobyte is theirs, not yours | | Email | A transactional provider with inbound parsing | the fallback when nobody is online, and the channel that keeps a conversation alive after the visitor closes the tab | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Transport | WebSocket delivery and reconnect backfill. | Server-sent events plus a POST endpoint is a valid simpler transport. The sequence number contract is what makes either correct, so keep it. | | Messages | Sequence allocation, idempotency and ordering. | Not swappable in practice. This is the core invariant and everything else assumes it. | | Inbox | Queue, assignment, states and presence. | Presence is ephemeral and can move to any pub/sub. Assignment is durable and stays in the database. | | Email | Outbound sending and inbound token routing. | Providers differ mostly in their inbound format. Keep parsing behind one adapter so a provider change is one file. | | Search | Full text query and the deletion path through the index. | An external search engine is the upgrade when PostgreSQL stops keeping up. The deletion-covers-every-store rule must survive that move. | ## 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 · Your PostgreSQL connection string. - `PORT` · required · Where the server listens. Put a reverse proxy in front for TLS. - `PUBLIC_URL` · required · Used to build widget URLs and reply addresses. Wrong here means email replies go nowhere. - `EMAIL_API_KEY` · required, secret · Your transactional provider's dashboard. - `INBOUND_DOMAIN` · required · The subdomain whose MX record points at your provider's inbound host. - `REPLY_TOKEN_SECRET` · required, secret · Signs the per-conversation token in reply addresses. Rotating it invalidates outstanding reply addresses. - `RETENTION_DAYS` · required · Conversations older than this are deleted. Say this number in your privacy policy. ## 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 · Crisp product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Fastify, WebSocket for live messages, with a cursor-based backfill over HTTP, PostgreSQL, A hand-written script under 15 KB, no framework, A transactional provider with inbound parsing. - 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 - The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. - Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. - Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. - Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. - Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. - A widget embedded on a public page is an open endpoint. Without a per-visitor and per-site rate limit, one script can create a hundred thousand conversations overnight and your inbox is unusable in the morning. ===== MILESTONES.md ===== # Delivery milestones · Crisp Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Widget, origin control and one conversation A script on another origin can start a conversation, and an origin you did not authorise cannot. ### Steps 1. Build the widget as a single small script No framework, no build step for the widget itself. It creates a shadow root so the host page's styles cannot break it and yours cannot leak out. Files: `public/widget.js` 2. Validate origin on connect Check the Origin header against the site's allowlist during the WebSocket handshake and refuse there, before any message is processed. Files: `src/ws.ts` 3. Scope every read to the visitor's own conversation The visitor session identifies one conversation. Write a test that asks for another conversation's messages with a valid widget key and expects a refusal. Files: `src/auth.ts`, `test/scope.test.ts` ### Done when - [ ] The widget works when loaded from a different origin - [ ] A connection from an unlisted origin is refused at handshake - [ ] A widget key cannot read another conversation - [ ] The widget's shadow root is unaffected by aggressive host page CSS ### Watch out - The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. ## M2 · Delivery that survives a dropped connection Messages sent while a client was offline arrive exactly once when it comes back. ### Steps 1. Allocate sequence numbers in the database A per-conversation counter updated in the same transaction as the insert, with a unique constraint on conversation and sequence. Files: `src/messages.ts`, `migrations/` 2. Implement cursor backfill An endpoint that returns messages after a sequence number, capped and paginated. The client calls it on reconnect before resuming live delivery. Files: `src/backfill.ts` 3. Handle the overlap window Buffer live messages during backfill, then deduplicate by sequence when resuming. This window is where duplicates come from and it needs a test. Files: `test/reconnect.test.ts` ### Done when - [ ] Three messages sent while offline arrive exactly once on reconnect - [ ] The same client message with one idempotency key stores one row - [ ] A duplicate sequence insert fails at the database - [ ] Messages arriving during backfill are not delivered twice ### Watch out - Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. ## M3 · The shared inbox Two agents can work the same queue without both answering the same person. ### Steps 1. Build the queue and conversation view Sorted by last message, filtered by state and assignee. Keyboard shortcuts for assign, close and next, because this is a screen people live in. Files: `src/inbox/` 2. Broadcast presence and typing Ephemeral, in memory, never persisted. Presence that outlives a disconnect is worse than no presence. Files: `src/presence.ts` 3. Reopen on visitor reply A message from the visitor on a closed conversation sets it back to open and puts it in the queue. Test it, because it is easy to miss and it strands people. Files: `test/state.test.ts` ### Done when - [ ] Two agents viewing one conversation each see the other's presence - [ ] Assignment propagates to the other session within a second - [ ] A visitor reply reopens a closed conversation - [ ] Presence disappears when a socket drops rather than lingering ### Watch out - Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. ## M4 · Email fallback A visitor who leaves gets the answer anyway, and their reply lands back in the same conversation. ### Steps 1. Capture an address when nobody is online Ask only when it is useful, and say why. An email field on every conversation is friction for the ones answered in thirty seconds. Files: `public/widget.js` 2. Send with a tokenised reply address A per-conversation token in the local part of the reply address. Sign it so a guessed token cannot inject into a conversation. Files: `src/email/send.ts` 3. Parse inbound and strip quoted history Route the provider's inbound webhook to the conversation named by the token, and trim quoted text and signatures so the thread stays readable. Files: `src/email/inbound.ts` ### Done when - [ ] An agent reply reaches the visitor by email after they close the tab - [ ] An email reply lands in the original conversation - [ ] Two conversations from one address remain separate - [ ] An unknown or tampered token is rejected and logged ### Watch out - Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. ## M5 · Search, canned replies and retention Finding an old conversation is fast, and old data does not accumulate forever by default. ### Steps 1. Add full text search with an index A generated tsvector column with a GIN index. Seed tens of thousands of messages before deciding it is fast. Files: `migrations/` 2. Add canned responses Shortcut expansion in the agent composer. Small feature, large daily saving. Files: `src/canned.ts` 3. Implement deletion that covers every store One function that removes rows, attachments and index entries. Test by searching for the deleted phrase and expecting nothing. Files: `src/delete.ts`, `test/delete.test.ts` ### Done when - [ ] Search across tens of thousands of messages returns in well under a second - [ ] A deleted contact's phrase cannot be found by search afterwards - [ ] Attachments belonging to a deleted contact are gone from storage - [ ] Retention removes aged conversations on schedule and logs what it removed ### Watch out - Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. ## M6 · Running it for real (production only) It stays up, it resists abuse, and you can restore it. ### Steps 1. Rate limit creation and messaging Per visitor and per site, with a low ceiling on new conversation creation specifically, because that is the expensive one. Files: `src/ratelimit.ts` 2. Monitor what actually breaks Open socket count, message delivery lag, database connection saturation and disk. Alert on lag rather than only on the process being alive. 3. Back up and perform the restore drill Nightly dump plus attachment storage, off the machine, then restore onto a clean server and record the date. Files: `README.md` ```sh pg_dump "$DATABASE_URL" | gzip > inbox-$(date +%F).sql.gz ``` ### Done when - [ ] A creation burst is throttled without affecting existing conversations - [ ] An alert fires within minutes when the service stops - [ ] Delivery lag is monitored, not just process liveness - [ ] A restore has been performed on a clean server and dated ### Watch out - A widget embedded on a public page is an open endpoint. Without a per-visitor and per-site rate limit, one script can create a hundred thousand conversations overnight and your inbox is unusable in the morning. ===== OPERATIONS.md ===== # Operations · Crisp ## Backup Nightly database dump plus attachment storage, both off the machine, several generations. The reply token secret belongs in your secret store, because rotating it after a restore breaks every outstanding reply address. ## Restore Fresh server, load the dump, restore attachments, set the same secrets, and verify by replying to a pre-existing conversation by email. Record the date. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Open socket count, message delivery lag, database connections and disk. Delivery lag is the metric that reflects the user experience; a process that is alive and slow looks fine to a simple uptime check. ## Incident checklist For a socket storm, the rate limiter is the first line and a connection cap is the second. For lost email threading, check the reply token secret first, because a rotation is the usual cause. For a deletion request, use the single deletion function and verify by search rather than by trusting it. 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 - [ ] The reconnect test proves exactly-once delivery - [ ] An email reply threads into the original conversation on at least two different mail clients - [ ] Deleting a contact leaves nothing findable by search - [ ] A creation burst is throttled without degrading existing conversations - [ ] A restore has been performed on a clean server and dated ## Launch constraint Do not market omitted Crisp 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. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/inbox # Required. Where the server listens. Put a reverse proxy in front for TLS. PORT=3000 # Required. Used to build widget URLs and reply addresses. Wrong here means email replies go nowhere. PUBLIC_URL=https://inbox.example.com # Required · secret. Your transactional provider's dashboard. EMAIL_API_KEY=re_... # Required. The subdomain whose MX record points at your provider's inbound host. INBOUND_DOMAIN=reply.example.com # Required · secret. Signs the per-conversation token in reply addresses. Rotating it invalidates outstanding reply addresses. REPLY_TOKEN_SECRET=generate-a-long-random-value # Required. Conversations older than this are deleted. Say this number in your privacy policy. RETENTION_DAYS=365
# Crisp · indie build An embeddable chat widget for your site, a shared inbox where a small team answers from one place, message delivery that survives a dropped connection without losing or duplicating anything, email fallback when nobody is online, and searchable history with a retention policy. 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 | Node 22 with Fastify | the workload is many idle socket connections and short bursts of work, which is what this is good at | | Transport | WebSocket for live messages, with a cursor-based backfill over HTTP | sockets deliver, but the backfill is what makes a reconnect correct | | Database | PostgreSQL | conversations, ordering and full text search all want a real database | | Widget | A hand-written script under 15 KB, no framework | it loads on other people's sites and every kilobyte is theirs, not yours | | Email | A transactional provider with inbound parsing | the fallback when nobody is online, and the channel that keeps a conversation alive after the visitor closes the tab | ## 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 tooling. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free - Why: Holds conversations and provides the full text search in Phase 5. - Get it: Install locally with your package manager or run it in Docker, and create a database for the project. - Verify: psql connects and SELECT version() reports 16 or higher - [ ] **A transactional email provider with inbound parsing** · free tiers typically cover a few thousand messages a month; inbound parsing is sometimes on a paid tier, so check before choosing - Why: Phase 4 needs both directions. Plenty of providers send well and do not parse inbound mail, which would leave the fallback half built. - Get it: Choose a provider that explicitly supports inbound parsing or inbound routing, verify a sending domain with their DNS records, and point an MX record at their inbound host. Confirm inbound support before you commit, because it is the part that varies. - Verify: A test message to your inbound address arrives at your webhook - [ ] **A domain with DNS you control** · roughly 10 to 15 USD a year - Why: Needed for the sending domain, the inbound MX record and the certificate. - Get it: Any registrar, then add the DNS records your email provider specifies. Propagation can take a few hours, so do this first. - Verify: dig MX for your inbound subdomain returns the provider's host - [ ] **A page on a different origin to embed the widget** · free - Why: Same-origin testing hides exactly the CORS and origin problems Phase 1 exists to catch. - Get it: Any static host, or a second local server on a different port. A different port is a different origin, which is enough. - Verify: The widget loads and connects from that page ## Quick start ```sh pg_dump "$DATABASE_URL" | gzip > inbox-$(date +%F).sql.gz ``` Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - The multi-channel inbox covering social, SMS and marketplace messages. - The knowledge base, campaigns and the rest of the bundled suite. - Deliverability engineering as volume grows. - AI resolution trained on a large corpus of real support conversations. - Mobile applications for agents. - multi-channel inbox, chat delivery, knowledge base, campaigns, and workspace pricing - omnichannel integrations - large-scale deliverability - AI resolution tuning - enterprise routing and compliance If one of those is essential to you, that is the reason to keep paying for Crisp, and the README should say so rather than pretend.
# Build brief · Crisp 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 shared inbox with a website chat widget for one small support team to replace Crisp. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify · the workload is many idle socket connections and short bursts of work, which is what this is good at - Transport: WebSocket for live messages, with a cursor-based backfill over HTTP · sockets deliver, but the backfill is what makes a reconnect correct - Database: PostgreSQL · conversations, ordering and full text search all want a real database - Widget: A hand-written script under 15 KB, no framework · it loads on other people's sites and every kilobyte is theirs, not yours - Email: A transactional provider with inbound parsing · the fallback when nobody is online, and the channel that keeps a conversation alive after the visitor closes the tab ### Data model (create this before Phase 1) - sites(id, name, origin_allowlist, widget_key) - contacts(id, site_id, external_id, email, name, first_seen_at, last_seen_at) - conversations(id, site_id, contact_id, state, assignee_id, subject, created_at, last_message_at) - messages(id, conversation_id, seq, author_kind, author_id, body, delivered_at, read_at) · seq is a per-conversation monotonic integer - agents(id, email, name, role) - canned_responses(id, site_id, shortcut, body) - The seq column is the backbone: it is what a reconnecting client asks for and what makes delivery verifiable. ### Phase 1 · Widget, origin control and one conversation Build: A small embeddable script that opens a socket and renders a chat panel. A per-site widget key that identifies the site but grants nothing beyond starting and continuing a conversation. Validate the Origin header against the site's allowlist on connect. Trap: The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. Done when: A page on a different origin loads the widget and starts a conversation. A connection from an origin not in the allowlist is refused before any message is accepted. Using a widget key to request another conversation's messages returns a refusal, not data. Do not build yet: No agent inbox, no reconnect logic, no email. ### Phase 2 · Delivery that survives a dropped connection Build: Assign every message a per-conversation sequence number at write time. The client stores the highest sequence it has. On reconnect it sends that number and receives everything after it over HTTP before the socket resumes live delivery. Trap: Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. Done when: Killing the socket, sending three messages server-side, then reconnecting delivers exactly those three, in order, with no duplicates. Sending the same client message twice with the same idempotency key stores one row. A message that arrives during the backfill window is not delivered twice. Do not build yet: No inbox and no email yet. ### Phase 3 · The shared inbox Build: An agent interface listing open conversations with assignment, states of open, snoozed and closed, and a visible indication when another agent is typing or already viewing a conversation. Assignment is a claim, not a suggestion. Trap: Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. Done when: Two browser sessions open the same conversation and each sees that the other is viewing it. Assigning to one agent updates the other's list within a second. A closed conversation reopens when the visitor replies rather than staying closed and unanswered. Do not build yet: No email fallback and no search yet. ### Phase 4 · Email fallback Build: Ask for an email address when nobody is online. Send agent replies by email when the visitor's socket is gone. Parse inbound replies back into the conversation using a token in the reply address rather than by matching the subject line. Trap: Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. Done when: Closing the browser tab, then having an agent reply, delivers an email. Replying to that email appends to the same conversation rather than creating a new one. Two separate conversations from the same email address stay separate. A reply to an expired or unknown token is rejected and reported rather than silently dropped. Do not build yet: No search or retention yet. ### Phase 5 · Search, canned replies and retention Build: PostgreSQL full text search across messages with the contact and date as filters. Canned responses with shortcuts. A retention policy that deletes conversations older than a chosen age, with a documented deletion path for a specific person on request. Trap: Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. Done when: Searching a phrase from a six month old conversation returns it in well under a second across a seeded dataset of tens of thousands of messages. Deleting a contact removes their messages, their attachments and their search results, verified by searching for a known phrase afterwards and finding nothing. Do not build yet: Production hardening is the last phase. ### Phase 6 · Running it for real Build: Rate limits per site and per visitor. Spam controls on conversation creation. TLS, monitoring on socket counts and message lag, nightly backups off the machine, and a restore you have performed. Trap: A widget embedded on a public page is an open endpoint. Without a per-visitor and per-site rate limit, one script can create a hundred thousand conversations overnight and your inbox is unusable in the morning. Done when: A burst of automated conversation creation is throttled and the existing conversations stay responsive. An uptime check alerts within minutes of the service stopping. A restore onto a clean server reproduces conversations and messages, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - The multi-channel inbox. Crisp pulls in email, social channels, SMS and more, and each one is an integration with its own delivery quirks and its own outages. - The knowledge base and the campaign tools, which are separate products bundled into the price. - Deliverability at scale. A transactional provider gets you started; keeping mail out of spam folders as volume grows is a discipline of its own. - AI resolution tuned on a large corpus of real support conversations, which is data you do not have. - The mobile applications agents use to answer while away from a desk. ### README must contain - That the widget key is public and grants only starting and continuing one conversation - How sequence numbers and cursor backfill guarantee exactly-once delivery on reconnect - That email threading uses a signed token in the reply address, not the subject line - The retention policy, and how to fulfil a deletion request for one person - The backup schedule and the date of the last restore drill
# Agent instructions · Crisp indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with Fastify, WebSocket for live messages, with a cursor-based backfill over HTTP, PostgreSQL, A hand-written script under 15 KB, no framework, A transactional provider with inbound parsing. 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 - The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. - Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. - Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. - Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. - Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards.
# Build plan · Crisp An embeddable chat widget for your site, a shared inbox where a small team answers from one place, message delivery that survives a dropped connection without losing or duplicating anything, email fallback when nobody is online, and searchable history with a retention policy. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Widget, origin control and one conversation A script on another origin can start a conversation, and an origin you did not authorise cannot. ### Steps 1. Build the widget as a single small script No framework, no build step for the widget itself. It creates a shadow root so the host page's styles cannot break it and yours cannot leak out. Files: `public/widget.js` 2. Validate origin on connect Check the Origin header against the site's allowlist during the WebSocket handshake and refuse there, before any message is processed. Files: `src/ws.ts` 3. Scope every read to the visitor's own conversation The visitor session identifies one conversation. Write a test that asks for another conversation's messages with a valid widget key and expects a refusal. Files: `src/auth.ts`, `test/scope.test.ts` ### Done when - [ ] The widget works when loaded from a different origin - [ ] A connection from an unlisted origin is refused at handshake - [ ] A widget key cannot read another conversation - [ ] The widget's shadow root is unaffected by aggressive host page CSS ### Watch out - The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. ## Phase 2 · Delivery that survives a dropped connection Messages sent while a client was offline arrive exactly once when it comes back. ### Steps 1. Allocate sequence numbers in the database A per-conversation counter updated in the same transaction as the insert, with a unique constraint on conversation and sequence. Files: `src/messages.ts`, `migrations/` 2. Implement cursor backfill An endpoint that returns messages after a sequence number, capped and paginated. The client calls it on reconnect before resuming live delivery. Files: `src/backfill.ts` 3. Handle the overlap window Buffer live messages during backfill, then deduplicate by sequence when resuming. This window is where duplicates come from and it needs a test. Files: `test/reconnect.test.ts` ### Done when - [ ] Three messages sent while offline arrive exactly once on reconnect - [ ] The same client message with one idempotency key stores one row - [ ] A duplicate sequence insert fails at the database - [ ] Messages arriving during backfill are not delivered twice ### Watch out - Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. ## Phase 3 · The shared inbox Two agents can work the same queue without both answering the same person. ### Steps 1. Build the queue and conversation view Sorted by last message, filtered by state and assignee. Keyboard shortcuts for assign, close and next, because this is a screen people live in. Files: `src/inbox/` 2. Broadcast presence and typing Ephemeral, in memory, never persisted. Presence that outlives a disconnect is worse than no presence. Files: `src/presence.ts` 3. Reopen on visitor reply A message from the visitor on a closed conversation sets it back to open and puts it in the queue. Test it, because it is easy to miss and it strands people. Files: `test/state.test.ts` ### Done when - [ ] Two agents viewing one conversation each see the other's presence - [ ] Assignment propagates to the other session within a second - [ ] A visitor reply reopens a closed conversation - [ ] Presence disappears when a socket drops rather than lingering ### Watch out - Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. ## Phase 4 · Email fallback A visitor who leaves gets the answer anyway, and their reply lands back in the same conversation. ### Steps 1. Capture an address when nobody is online Ask only when it is useful, and say why. An email field on every conversation is friction for the ones answered in thirty seconds. Files: `public/widget.js` 2. Send with a tokenised reply address A per-conversation token in the local part of the reply address. Sign it so a guessed token cannot inject into a conversation. Files: `src/email/send.ts` 3. Parse inbound and strip quoted history Route the provider's inbound webhook to the conversation named by the token, and trim quoted text and signatures so the thread stays readable. Files: `src/email/inbound.ts` ### Done when - [ ] An agent reply reaches the visitor by email after they close the tab - [ ] An email reply lands in the original conversation - [ ] Two conversations from one address remain separate - [ ] An unknown or tampered token is rejected and logged ### Watch out - Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. ## Phase 5 · Search, canned replies and retention Finding an old conversation is fast, and old data does not accumulate forever by default. ### Steps 1. Add full text search with an index A generated tsvector column with a GIN index. Seed tens of thousands of messages before deciding it is fast. Files: `migrations/` 2. Add canned responses Shortcut expansion in the agent composer. Small feature, large daily saving. Files: `src/canned.ts` 3. Implement deletion that covers every store One function that removes rows, attachments and index entries. Test by searching for the deleted phrase and expecting nothing. Files: `src/delete.ts`, `test/delete.test.ts` ### Done when - [ ] Search across tens of thousands of messages returns in well under a second - [ ] A deleted contact's phrase cannot be found by search afterwards - [ ] Attachments belonging to a deleted contact are gone from storage - [ ] Retention removes aged conversations on schedule and logs what it removed ### Watch out - Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. ## Not in this build - The multi-channel inbox covering social, SMS and marketplace messages. - The knowledge base, campaigns and the rest of the bundled suite. - Deliverability engineering as volume grows. - AI resolution trained on a large corpus of real support conversations. - Mobile applications for agents. ## After v1, if you want it - Business hours with an away message, which is small and changes the experience a lot - Conversation tagging and a weekly volume report - A public status page fed by the same monitoring
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/inbox # Required. Where the server listens. Put a reverse proxy in front for TLS. PORT=3000 # Required. Used to build widget URLs and reply addresses. Wrong here means email replies go nowhere. PUBLIC_URL=https://inbox.example.com # Required · secret. Your transactional provider's dashboard. EMAIL_API_KEY=re_... # Required. The subdomain whose MX record points at your provider's inbound host. INBOUND_DOMAIN=reply.example.com # Required · secret. Signs the per-conversation token in reply addresses. Rotating it invalidates outstanding reply addresses. REPLY_TOKEN_SECRET=generate-a-long-random-value # Required. Conversations older than this are deleted. Say this number in your privacy policy. RETENTION_DAYS=365
# Crisp · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Crisp, combine website chat and a shared inbox for one small support team. The hard boundary is multi-channel inbox, chat delivery, knowledge base, campaigns, and workspace pricing, plus channel integrations and support operations. ## Product outcome A shared inbox a small team can actually work in, with delivery you can prove is exactly-once, email threading that survives real mail clients, deletion that covers every store, and a restore you have performed. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - Docker - PostgreSQL - public HTTPS deployment - email or chat channel credentials ## Explicit non-goals for v1 - The multi-channel inbox covering social, SMS and marketplace messages. - The knowledge base, campaigns and the rest of the bundled suite. - Deliverability engineering as volume grows. - AI resolution trained on a large corpus of real support conversations. - Mobile applications for agents. - multi-channel inbox, chat delivery, knowledge base, campaigns, and workspace pricing - omnichannel integrations - large-scale deliverability - AI resolution tuning - enterprise routing and compliance ## Success criteria - The reconnect test proves exactly-once delivery - An email reply threads into the original conversation on at least two different mail clients - Deleting a contact leaves nothing findable by search - A creation burst is throttled without degrading existing conversations - A restore has been performed on a clean server and dated
# Build brief · Crisp 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 shared inbox with a website chat widget for one small support team to replace Crisp. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Stack (fixed, do not substitute) - Runtime: Node 22 with Fastify · the workload is many idle socket connections and short bursts of work, which is what this is good at - Transport: WebSocket for live messages, with a cursor-based backfill over HTTP · sockets deliver, but the backfill is what makes a reconnect correct - Database: PostgreSQL · conversations, ordering and full text search all want a real database - Widget: A hand-written script under 15 KB, no framework · it loads on other people's sites and every kilobyte is theirs, not yours - Email: A transactional provider with inbound parsing · the fallback when nobody is online, and the channel that keeps a conversation alive after the visitor closes the tab ### Data model (create this before Phase 1) - sites(id, name, origin_allowlist, widget_key) - contacts(id, site_id, external_id, email, name, first_seen_at, last_seen_at) - conversations(id, site_id, contact_id, state, assignee_id, subject, created_at, last_message_at) - messages(id, conversation_id, seq, author_kind, author_id, body, delivered_at, read_at) · seq is a per-conversation monotonic integer - agents(id, email, name, role) - canned_responses(id, site_id, shortcut, body) - The seq column is the backbone: it is what a reconnecting client asks for and what makes delivery verifiable. ### Phase 1 · Widget, origin control and one conversation Build: A small embeddable script that opens a socket and renders a chat panel. A per-site widget key that identifies the site but grants nothing beyond starting and continuing a conversation. Validate the Origin header against the site's allowlist on connect. Trap: The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. Done when: A page on a different origin loads the widget and starts a conversation. A connection from an origin not in the allowlist is refused before any message is accepted. Using a widget key to request another conversation's messages returns a refusal, not data. Do not build yet: No agent inbox, no reconnect logic, no email. ### Phase 2 · Delivery that survives a dropped connection Build: Assign every message a per-conversation sequence number at write time. The client stores the highest sequence it has. On reconnect it sends that number and receives everything after it over HTTP before the socket resumes live delivery. Trap: Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. Done when: Killing the socket, sending three messages server-side, then reconnecting delivers exactly those three, in order, with no duplicates. Sending the same client message twice with the same idempotency key stores one row. A message that arrives during the backfill window is not delivered twice. Do not build yet: No inbox and no email yet. ### Phase 3 · The shared inbox Build: An agent interface listing open conversations with assignment, states of open, snoozed and closed, and a visible indication when another agent is typing or already viewing a conversation. Assignment is a claim, not a suggestion. Trap: Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. Done when: Two browser sessions open the same conversation and each sees that the other is viewing it. Assigning to one agent updates the other's list within a second. A closed conversation reopens when the visitor replies rather than staying closed and unanswered. Do not build yet: No email fallback and no search yet. ### Phase 4 · Email fallback Build: Ask for an email address when nobody is online. Send agent replies by email when the visitor's socket is gone. Parse inbound replies back into the conversation using a token in the reply address rather than by matching the subject line. Trap: Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. Done when: Closing the browser tab, then having an agent reply, delivers an email. Replying to that email appends to the same conversation rather than creating a new one. Two separate conversations from the same email address stay separate. A reply to an expired or unknown token is rejected and reported rather than silently dropped. Do not build yet: No search or retention yet. ### Phase 5 · Search, canned replies and retention Build: PostgreSQL full text search across messages with the contact and date as filters. Canned responses with shortcuts. A retention policy that deletes conversations older than a chosen age, with a documented deletion path for a specific person on request. Trap: Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. Done when: Searching a phrase from a six month old conversation returns it in well under a second across a seeded dataset of tens of thousands of messages. Deleting a contact removes their messages, their attachments and their search results, verified by searching for a known phrase afterwards and finding nothing. Do not build yet: Production hardening is the last phase. ### Phase 6 · Running it for real Build: Rate limits per site and per visitor. Spam controls on conversation creation. TLS, monitoring on socket counts and message lag, nightly backups off the machine, and a restore you have performed. Trap: A widget embedded on a public page is an open endpoint. Without a per-visitor and per-site rate limit, one script can create a hundred thousand conversations overnight and your inbox is unusable in the morning. Done when: A burst of automated conversation creation is throttled and the existing conversations stay responsive. An uptime check alerts within minutes of the service stopping. A restore onto a clean server reproduces conversations and messages, and the date is recorded. Do not build yet: Nothing after this. ### Out of scope (and why) - The multi-channel inbox. Crisp pulls in email, social channels, SMS and more, and each one is an integration with its own delivery quirks and its own outages. - The knowledge base and the campaign tools, which are separate products bundled into the price. - Deliverability at scale. A transactional provider gets you started; keeping mail out of spam folders as volume grows is a discipline of its own. - AI resolution tuned on a large corpus of real support conversations, which is data you do not have. - The mobile applications agents use to answer while away from a desk. ### README must contain - That the widget key is public and grants only starting and continuing one conversation - How sequence numbers and cursor backfill guarantee exactly-once delivery on reconnect - That email threading uses a signed token in the reply address, not the subject line - The retention policy, and how to fulfil a deletion request for one person - The backup schedule and the date of the last restore drill
# Architecture · Crisp ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with Fastify | the workload is many idle socket connections and short bursts of work, which is what this is good at | | Transport | WebSocket for live messages, with a cursor-based backfill over HTTP | sockets deliver, but the backfill is what makes a reconnect correct | | Database | PostgreSQL | conversations, ordering and full text search all want a real database | | Widget | A hand-written script under 15 KB, no framework | it loads on other people's sites and every kilobyte is theirs, not yours | | Email | A transactional provider with inbound parsing | the fallback when nobody is online, and the channel that keeps a conversation alive after the visitor closes the tab | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Transport | WebSocket delivery and reconnect backfill. | Server-sent events plus a POST endpoint is a valid simpler transport. The sequence number contract is what makes either correct, so keep it. | | Messages | Sequence allocation, idempotency and ordering. | Not swappable in practice. This is the core invariant and everything else assumes it. | | Inbox | Queue, assignment, states and presence. | Presence is ephemeral and can move to any pub/sub. Assignment is durable and stays in the database. | | Email | Outbound sending and inbound token routing. | Providers differ mostly in their inbound format. Keep parsing behind one adapter so a provider change is one file. | | Search | Full text query and the deletion path through the index. | An external search engine is the upgrade when PostgreSQL stops keeping up. The deletion-covers-every-store rule must survive that move. | ## 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 · Your PostgreSQL connection string. - `PORT` · required · Where the server listens. Put a reverse proxy in front for TLS. - `PUBLIC_URL` · required · Used to build widget URLs and reply addresses. Wrong here means email replies go nowhere. - `EMAIL_API_KEY` · required, secret · Your transactional provider's dashboard. - `INBOUND_DOMAIN` · required · The subdomain whose MX record points at your provider's inbound host. - `REPLY_TOKEN_SECRET` · required, secret · Signs the per-conversation token in reply addresses. Rotating it invalidates outstanding reply addresses. - `RETENTION_DAYS` · required · Conversations older than this are deleted. Say this number in your privacy policy. ## 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 · Crisp product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with Fastify, WebSocket for live messages, with a cursor-based backfill over HTTP, PostgreSQL, A hand-written script under 15 KB, no framework, A transactional provider with inbound parsing. - 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 - The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. - Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. - Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. - Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. - Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. - A widget embedded on a public page is an open endpoint. Without a per-visitor and per-site rate limit, one script can create a hundred thousand conversations overnight and your inbox is unusable in the morning.
# Delivery milestones · Crisp Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Widget, origin control and one conversation A script on another origin can start a conversation, and an origin you did not authorise cannot. ### Steps 1. Build the widget as a single small script No framework, no build step for the widget itself. It creates a shadow root so the host page's styles cannot break it and yours cannot leak out. Files: `public/widget.js` 2. Validate origin on connect Check the Origin header against the site's allowlist during the WebSocket handshake and refuse there, before any message is processed. Files: `src/ws.ts` 3. Scope every read to the visitor's own conversation The visitor session identifies one conversation. Write a test that asks for another conversation's messages with a valid widget key and expects a refusal. Files: `src/auth.ts`, `test/scope.test.ts` ### Done when - [ ] The widget works when loaded from a different origin - [ ] A connection from an unlisted origin is refused at handshake - [ ] A widget key cannot read another conversation - [ ] The widget's shadow root is unaffected by aggressive host page CSS ### Watch out - The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations. ## M2 · Delivery that survives a dropped connection Messages sent while a client was offline arrive exactly once when it comes back. ### Steps 1. Allocate sequence numbers in the database A per-conversation counter updated in the same transaction as the insert, with a unique constraint on conversation and sequence. Files: `src/messages.ts`, `migrations/` 2. Implement cursor backfill An endpoint that returns messages after a sequence number, capped and paginated. The client calls it on reconnect before resuming live delivery. Files: `src/backfill.ts` 3. Handle the overlap window Buffer live messages during backfill, then deduplicate by sequence when resuming. This window is where duplicates come from and it needs a test. Files: `test/reconnect.test.ts` ### Done when - [ ] Three messages sent while offline arrive exactly once on reconnect - [ ] The same client message with one idempotency key stores one row - [ ] A duplicate sequence insert fails at the database - [ ] Messages arriving during backfill are not delivered twice ### Watch out - Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly. ## M3 · The shared inbox Two agents can work the same queue without both answering the same person. ### Steps 1. Build the queue and conversation view Sorted by last message, filtered by state and assignee. Keyboard shortcuts for assign, close and next, because this is a screen people live in. Files: `src/inbox/` 2. Broadcast presence and typing Ephemeral, in memory, never persisted. Presence that outlives a disconnect is worse than no presence. Files: `src/presence.ts` 3. Reopen on visitor reply A message from the visitor on a closed conversation sets it back to open and puts it in the queue. Test it, because it is easy to miss and it strands people. Files: `test/state.test.ts` ### Done when - [ ] Two agents viewing one conversation each see the other's presence - [ ] Assignment propagates to the other session within a second - [ ] A visitor reply reopens a closed conversation - [ ] Presence disappears when a socket drops rather than lingering ### Watch out - Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action. ## M4 · Email fallback A visitor who leaves gets the answer anyway, and their reply lands back in the same conversation. ### Steps 1. Capture an address when nobody is online Ask only when it is useful, and say why. An email field on every conversation is friction for the ones answered in thirty seconds. Files: `public/widget.js` 2. Send with a tokenised reply address A per-conversation token in the local part of the reply address. Sign it so a guessed token cannot inject into a conversation. Files: `src/email/send.ts` 3. Parse inbound and strip quoted history Route the provider's inbound webhook to the conversation named by the token, and trim quoted text and signatures so the thread stays readable. Files: `src/email/inbound.ts` ### Done when - [ ] An agent reply reaches the visitor by email after they close the tab - [ ] An email reply lands in the original conversation - [ ] Two conversations from one address remain separate - [ ] An unknown or tampered token is rejected and logged ### Watch out - Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous. ## M5 · Search, canned replies and retention Finding an old conversation is fast, and old data does not accumulate forever by default. ### Steps 1. Add full text search with an index A generated tsvector column with a GIN index. Seed tens of thousands of messages before deciding it is fast. Files: `migrations/` 2. Add canned responses Shortcut expansion in the agent composer. Small feature, large daily saving. Files: `src/canned.ts` 3. Implement deletion that covers every store One function that removes rows, attachments and index entries. Test by searching for the deleted phrase and expecting nothing. Files: `src/delete.ts`, `test/delete.test.ts` ### Done when - [ ] Search across tens of thousands of messages returns in well under a second - [ ] A deleted contact's phrase cannot be found by search afterwards - [ ] Attachments belonging to a deleted contact are gone from storage - [ ] Retention removes aged conversations on schedule and logs what it removed ### Watch out - Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards. ## M6 · Running it for real (production only) It stays up, it resists abuse, and you can restore it. ### Steps 1. Rate limit creation and messaging Per visitor and per site, with a low ceiling on new conversation creation specifically, because that is the expensive one. Files: `src/ratelimit.ts` 2. Monitor what actually breaks Open socket count, message delivery lag, database connection saturation and disk. Alert on lag rather than only on the process being alive. 3. Back up and perform the restore drill Nightly dump plus attachment storage, off the machine, then restore onto a clean server and record the date. Files: `README.md` ```sh pg_dump "$DATABASE_URL" | gzip > inbox-$(date +%F).sql.gz ``` ### Done when - [ ] A creation burst is throttled without affecting existing conversations - [ ] An alert fires within minutes when the service stops - [ ] Delivery lag is monitored, not just process liveness - [ ] A restore has been performed on a clean server and dated ### Watch out - A widget embedded on a public page is an open endpoint. Without a per-visitor and per-site rate limit, one script can create a hundred thousand conversations overnight and your inbox is unusable in the morning.
# Operations · Crisp ## Backup Nightly database dump plus attachment storage, both off the machine, several generations. The reply token secret belongs in your secret store, because rotating it after a restore breaks every outstanding reply address. ## Restore Fresh server, load the dump, restore attachments, set the same secrets, and verify by replying to a pre-existing conversation by email. Record the date. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Open socket count, message delivery lag, database connections and disk. Delivery lag is the metric that reflects the user experience; a process that is alive and slow looks fine to a simple uptime check. ## Incident checklist For a socket storm, the rate limiter is the first line and a connection cap is the second. For lost email threading, check the reply token secret first, because a rotation is the usual cause. For a deletion request, use the single deletion function and verify by search rather than by trusting it. 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 - [ ] The reconnect test proves exactly-once delivery - [ ] An email reply threads into the original conversation on at least two different mail clients - [ ] Deleting a contact leaves nothing findable by search - [ ] A creation burst is throttled without degrading existing conversations - [ ] A restore has been performed on a clean server and dated ## Launch constraint Do not market omitted Crisp 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. Your PostgreSQL connection string. DATABASE_URL=postgres://user:pass@localhost:5432/inbox # Required. Where the server listens. Put a reverse proxy in front for TLS. PORT=3000 # Required. Used to build widget URLs and reply addresses. Wrong here means email replies go nowhere. PUBLIC_URL=https://inbox.example.com # Required · secret. Your transactional provider's dashboard. EMAIL_API_KEY=re_... # Required. The subdomain whose MX record points at your provider's inbound host. INBOUND_DOMAIN=reply.example.com # Required · secret. Signs the per-conversation token in reply addresses. Rotating it invalidates outstanding reply addresses. REPLY_TOKEN_SECRET=generate-a-long-random-value # Required. Conversations older than this are deleted. Say this number in your privacy policy. RETENTION_DAYS=365
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Crisp because support software survives because missing or misrouting one customer message is expensive and every channel behaves differently. The recurring cost buys message delivery, webhooks, threading, identity, permissions, attachments, notifications, spam, search, uptime, and retention, not just the visible interface.
xmulti-channel inbox, chat delivery, knowledge base, campaigns, and workspace pricing
xomnichannel integrations
xlarge-scale deliverability
xAI resolution tuning
xenterprise routing and compliance
Don't feel like building it? These folks already made it free.
all 7 free alternatives to Crisp →· no votes, no pay-to-list · just what's real
Crisp pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| free | $0/workspace | $0/workspace | 2 agent seats, 100 customer profiles, unlimited conversations and 0 included AI credits |
| mini | $45/workspace | — | 4 seats, 5,000 customer profiles, unlimited conversations and $5 AI credits/month (about 90 automated conversations) |
| essentials | $95/workspace | — | 10 seats, 50,000 customer profiles, 2 sub-inboxes and $25 AI credits/month (about 450 automated conversations) |
| plus | $295/workspace | — | 20+ included seats, 200,000 customer profiles, 5 sub-inboxes and $75 AI credits/month (about 1,350 automated conversations) |
| enterprise | custom | — | Custom onboarding, SLA, rate limits, features and training |
free tier2 agent seats; 100 customer profiles; unlimited conversations; no included AI credits
billingmonthly + yearly payment available; no public yearly price or stated annual discount; 14-day all-feature trial
hidden costsAI overage is token-based pay-as-you-go after included credits; estimated $0.05-$0.10 per AI-handled conversation. Plus seats above the included 20+ cost $10/month; extra CRM contacts, agent capacity and other limits can cost extra; VAT may apply.
verified 2026-08-12 · source ↗
Vibecode Crisp
Kinda. The core of Crisp is buildable in a weekend with the prompt on this page, but there are real gaps: multi-channel inbox, chat delivery, knowledge base, campaigns, and workspace pricing, omnichannel integrations. Read the honest list above before committing.
How much does Crisp cost?
Crisp costs about $45/month (Mini, checked 2026-07-31), which is $540 per year.
What do I lose by replacing Crisp?
Honestly: multi-channel inbox, chat delivery, knowledge base, campaigns, and workspace pricing; omnichannel integrations; large-scale deliverability; AI resolution tuning; enterprise routing and compliance. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to Crisp?
Yes: Chatwoot (A proper support inbox with chat, email and a help center; free software, billable server chores.) LibreDesk (A modern email-and-live-chat desk in one compact stack, because apparently that was allowed.) Live Helper Chat (A battle-tested live-chat desk with routing, transcripts and canned replies; the interface has seen some things.) All 7 curated free alternatives are at vibecodeit.com/crisp/alternatives. The prompt is for when you want it exactly your way.