Vibecode Chatwoot Cloud
track this build4 phases, 12 steps, beginner friendly0%The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Chatwoot Cloud, use a managed shared inbox and website widget instead of operating Chatwoot yourself. The hard boundary is managed upgrades, backups, delivery infrastructure, support, and integrations, plus channel integrations and support operations.
You are building a lean indie version of Chatwoot Cloud. 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 ===== # Chatwoot Cloud · indie build A support platform whose real problem is that a shared inbox has no owner by default, so conversations sit unanswered while three people each assume someone else has it, and every mechanism here exists to make ownership unambiguous. 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 | | --- | --- | --- | | Channels | Email and a website widget into one conversation model | a customer who emails and then chats is one conversation, not two | | Assignment | Explicit ownership with a single assignee at a time | shared responsibility is nobody's responsibility | | Identity | Contacts resolved across channels by verified identifiers only | merging two customers is worse than failing to merge one | | Delivery | Outbound email through a provider with reply threading preserved | a reply that starts a new thread is a conversation lost | | Runtime | TypeScript with PostgreSQL and a socket layer for the widget | conversations are relational and the widget needs live updates | ## 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, the socket layer and the mail workers. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Conversations, identity resolution and assignment history. - Get it: Docker or your package manager. - Verify: psql connects and full text search returns a result - [ ] **Inbound and outbound email with a real domain** · free tiers cover testing - Why: Threading behaviour, bounces and quoting are all only observable against real mail clients, which disagree with each other. - Get it: A domain with receiving configured and a sending provider, plus at least three different mail clients to test replies from. - Verify: A reply from three different clients threads onto the original conversation - [ ] **A hostile host page for the widget** · free - Why: Widgets break on real pages with real stylesheets, and testing against a blank page demonstrates nothing. - Get it: A page with aggressive global styles, a restrictive content policy and other embedded scripts. - Verify: The widget renders correctly inside it - [ ] **Your response targets and what they exclude** · free - Why: The measurement is reported with its exclusions stated, and deciding those after seeing the numbers means choosing whichever exclusions flatter them. - Get it: Decide the first response target, the resolution target, and whether out of hours counts. Write down the reasoning. - Verify: A written note states both targets and every exclusion ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Automated replies and chatbots. - Channel breadth beyond email and a widget. - Knowledge base publishing. - Workforce scheduling. - managed upgrades, backups, delivery infrastructure, support, and integrations - 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 Chatwoot Cloud, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Chatwoot Cloud 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 and chat widget where no conversation falls between two people to replace Chatwoot Cloud. 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) - Channels: Email and a website widget into one conversation model · a customer who emails and then chats is one conversation, not two - Assignment: Explicit ownership with a single assignee at a time · shared responsibility is nobody's responsibility - Identity: Contacts resolved across channels by verified identifiers only · merging two customers is worse than failing to merge one - Delivery: Outbound email through a provider with reply threading preserved · a reply that starts a new thread is a conversation lost - Runtime: TypeScript with PostgreSQL and a socket layer for the widget · conversations are relational and the widget needs live updates ### Data model (create this before Phase 1) - contacts(id, display_name, created_at, merged_into_id) - identifiers(contact_id, kind, value_normalised, verified_at) · email, widget session or external id - conversations(id, contact_id, channel, subject, state, assignee_id, opened_at, first_response_at, resolved_at, snoozed_until) - messages(id, conversation_id, direction, author_id, body, sent_at, delivery_state, external_message_id, in_reply_to) - assignments(conversation_id, assignee_id, assigned_by, at, reason) · append only, so ownership history is visible - notes(conversation_id, author_id, body, at) · internal, never sent - A conversation has exactly one assignee at a time, and the assignment history is append only so nobody has to reconstruct who had it when. ### Phase 1 · Email in and out without losing the thread Build: Receive email, create or append to a conversation, and send replies that preserve threading headers. Handle a reply with the quoted history included, and one from a different address than the original. Trap: Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. Done when: A customer reply lands on the original conversation, verified with a real mail client. Quoted history is trimmed from the displayed message but retained in the stored original. A reply from a different address is proposed as the same contact rather than merged automatically. Outbound messages record their delivery state including bounces. Do not build yet: No widget, assignment or resolution. ### Phase 2 · A widget that does not break the host page Build: An embeddable widget with a small asynchronous script, isolated styling, and a socket connection for live messages. Handle a page with conflicting styles, a restrictive content policy, and a visitor who reloads mid-conversation. Trap: A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. Done when: The widget renders correctly on a page with aggressive global styles. It loads asynchronously without blocking the host page and its size is stated. A visitor reloading mid-conversation resumes with their history. A restrictive content policy produces a clear failure rather than a silent absence. Do not build yet: No assignment, identity resolution or reporting yet. ### Phase 3 · Ownership that is never ambiguous Build: Single assignee per conversation with an append-only assignment history. Unassigned conversations surfaced prominently. Handle an assignee who is away, and a conversation reopened after resolution. Trap: A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. Done when: Replying requires the conversation to be assigned, or assigns it in the same action. Unassigned conversations are the most prominent view. An assignee marked away has their conversations surfaced for reassignment rather than left. A reopened conversation returns to its previous assignee or to unassigned by a stated rule. Do not build yet: No identity resolution, reporting or automation yet. ### Phase 4 · One customer, several channels Build: Resolve contacts across channels on verified identifiers only. Propose merges for weaker signals rather than applying them. Make merges reversible with the decision recorded. Trap: Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. Done when: A contact using a verified email in the widget resolves to the existing email contact. Two contacts with the same name and no shared identifier are proposed rather than merged, tested with a crafted pair. A merge is reversible and its decision recorded. An unverified identifier never causes an automatic merge. Do not build yet: No reporting or automation yet. ### Phase 5 · Knowing whether anyone is being helped Build: Measure first response and resolution times in the customer's terms, excluding nothing silently. Report the distribution rather than an average. Surface conversations breaching a stated target while they are still open. Trap: Reporting a mean first response time hides the conversation that waited three days among a hundred that were answered in ten minutes, and the mean improves while the worst experience gets worse. Report the distribution and the tail, and surface breaches while they can still be fixed rather than in a monthly summary. Done when: First response and resolution times are reported as distributions with the tail visible rather than as means. Any exclusion from the measurement, such as out of hours, is stated beside the figure. Conversations breaching the target are surfaced while open. A snoozed conversation returns rather than disappearing from the measurement. Do not build yet: Nothing after this. ### Out of scope (and why) - Automated replies and chatbots, which is where most of this category's marketing sits and where a wrong answer costs more than a slow one. - Channel breadth beyond email and a widget, which is an integration each with its own threading and identity model. - Knowledge base and help centre publishing, which is a separate product for the same team. - Workforce scheduling and staffing forecasts, which is an operations problem rather than a support tool one. ### README must contain - That threading headers are preserved in both directions, or a customer's reply splits the conversation - That replying requires an assignee, because a shared inbox with no owner produces two-day silences - That contacts merge only on verified identifiers, since combining two customers is not recoverable - That the widget's styling is isolated and tested against a hostile host page - That response times are reported as distributions, because a mean hides the worst experience ===== AGENTS.md ===== # Agent instructions · Chatwoot Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Email and a website widget into one conversation model, Explicit ownership with a single assignee at a time, Contacts resolved across channels by verified identifiers only, Outbound email through a provider with reply threading preserved, TypeScript with PostgreSQL and a socket layer for the widget. 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 - Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. - A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. - A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. - Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. ===== BUILD_PLAN.md ===== # Build plan · Chatwoot Cloud A support platform whose real problem is that a shared inbox has no owner by default, so conversations sit unanswered while three people each assume someone else has it, and every mechanism here exists to make ownership unambiguous. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Email in and out without losing the thread A customer's reply lands on the conversation it belongs to. ### Steps 1. Preserve threading in both directions A reply that starts a new thread is a conversation split in half. Files: `src/email.ts` 2. Trim quoted history for display, keep the original Nobody reads six levels of quoting and you may still need it. Files: `src/quoting.ts` 3. Record delivery state including bounces A bounced reply looks identical to an ignored one otherwise. Files: `src/delivery.ts` ### Done when - [ ] A customer reply threads onto the original conversation - [ ] Quoted history is trimmed for display and retained - [ ] A different sending address is proposed, not merged - [ ] Delivery state including bounces is recorded ### Watch out - Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. ## Phase 2 · A widget that does not break the host page The chat widget loads fast, works offline-ish and survives a hostile page. ### Steps 1. Isolate the widget's styling Inheriting the host stylesheet breaks it differently on every site. Files: `widget/isolate.ts` 2. Resume a conversation after reload Visitors reload constantly and losing the thread loses the customer. Files: `widget/session.ts` 3. Fail visibly under a restrictive policy A silently absent widget is a support channel nobody knows is down. Files: `widget/embed.ts` ### Done when - [ ] The widget survives conflicting host styles - [ ] It loads asynchronously with a stated size - [ ] A reload resumes the conversation - [ ] A restrictive policy fails clearly ### Watch out - A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. ## Phase 3 · Ownership that is never ambiguous Every open conversation has exactly one person responsible. ### Steps 1. Require an assignee to reply Or assign in the same action. Unowned replies are how threads get doubled. Files: `src/assignment.ts` 2. Make the unassigned queue prominent It is the queue that produces the two-day silences. Files: `src/views.ts` 3. Handle away assignees Their conversations should surface rather than wait. Files: `src/availability.ts` ### Done when - [ ] Replying requires or performs assignment - [ ] The unassigned queue is the most prominent view - [ ] An away assignee's conversations are surfaced - [ ] Reopening follows a stated assignment rule ### Watch out - A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. ## Phase 4 · One customer, several channels A person who emails and then chats is recognised as one contact. ### Steps 1. Merge only on verified identifiers Combining two customers' histories is not recoverable. Files: `src/identity.ts` 2. Propose on weaker signals A matching name is a suggestion, not a fact. Files: `src/identity.ts` 3. Keep merges reversible You will get some wrong and the customer will notice. Files: `src/merge.ts`, `tests/merge-reversal.test.ts` ### Done when - [ ] A verified identifier resolves across channels - [ ] Same-named contacts are proposed, not merged - [ ] Merges are reversible with recorded decisions - [ ] Unverified identifiers never auto-merge ### Watch out - Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. ## Not in this build - Automated replies and chatbots. - Channel breadth beyond email and a widget. - Knowledge base publishing. - Workforce scheduling. ## After v1, if you want it - Canned replies with variable substitution, which is the first thing any team asks for - Conversation tagging for themes, which feeds whatever collects product feedback - Business hours awareness in targets, once the exclusions are already stated honestly ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Customer conversations, which contain personal data and often frustration. DATABASE_URL=postgres://localhost:5432/support # Required. Where customer email arrives. INBOUND_DOMAIN=support.example.com # Required · secret. Outbound replies, with threading headers preserved. SMTP_URL=smtp://user:pass@host:587 # Required. Present as a constant. A shared inbox with no owner is the failure this prevents. REQUIRE_ASSIGNEE_TO_REPLY=always # Required. Combining two customers' histories cannot be undone from the messages. AUTO_MERGE_CONTACTS=verified_only # Required. Breaches are surfaced while the conversation is still open. FIRST_RESPONSE_TARGET_MINUTES=240
You are building a lean indie version of Chatwoot Cloud. 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 ===== # Chatwoot Cloud · indie build A support platform whose real problem is that a shared inbox has no owner by default, so conversations sit unanswered while three people each assume someone else has it, and every mechanism here exists to make ownership unambiguous. 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 | | --- | --- | --- | | Channels | Email and a website widget into one conversation model | a customer who emails and then chats is one conversation, not two | | Assignment | Explicit ownership with a single assignee at a time | shared responsibility is nobody's responsibility | | Identity | Contacts resolved across channels by verified identifiers only | merging two customers is worse than failing to merge one | | Delivery | Outbound email through a provider with reply threading preserved | a reply that starts a new thread is a conversation lost | | Runtime | TypeScript with PostgreSQL and a socket layer for the widget | conversations are relational and the widget needs live updates | ## 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, the socket layer and the mail workers. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Conversations, identity resolution and assignment history. - Get it: Docker or your package manager. - Verify: psql connects and full text search returns a result - [ ] **Inbound and outbound email with a real domain** · free tiers cover testing - Why: Threading behaviour, bounces and quoting are all only observable against real mail clients, which disagree with each other. - Get it: A domain with receiving configured and a sending provider, plus at least three different mail clients to test replies from. - Verify: A reply from three different clients threads onto the original conversation - [ ] **A hostile host page for the widget** · free - Why: Widgets break on real pages with real stylesheets, and testing against a blank page demonstrates nothing. - Get it: A page with aggressive global styles, a restrictive content policy and other embedded scripts. - Verify: The widget renders correctly inside it - [ ] **Your response targets and what they exclude** · free - Why: The measurement is reported with its exclusions stated, and deciding those after seeing the numbers means choosing whichever exclusions flatter them. - Get it: Decide the first response target, the resolution target, and whether out of hours counts. Write down the reasoning. - Verify: A written note states both targets and every exclusion ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Automated replies and chatbots. - Channel breadth beyond email and a widget. - Knowledge base publishing. - Workforce scheduling. - managed upgrades, backups, delivery infrastructure, support, and integrations - 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 Chatwoot Cloud, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · Chatwoot Cloud 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 and chat widget where no conversation falls between two people to replace Chatwoot Cloud. 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) - Channels: Email and a website widget into one conversation model · a customer who emails and then chats is one conversation, not two - Assignment: Explicit ownership with a single assignee at a time · shared responsibility is nobody's responsibility - Identity: Contacts resolved across channels by verified identifiers only · merging two customers is worse than failing to merge one - Delivery: Outbound email through a provider with reply threading preserved · a reply that starts a new thread is a conversation lost - Runtime: TypeScript with PostgreSQL and a socket layer for the widget · conversations are relational and the widget needs live updates ### Data model (create this before Phase 1) - contacts(id, display_name, created_at, merged_into_id) - identifiers(contact_id, kind, value_normalised, verified_at) · email, widget session or external id - conversations(id, contact_id, channel, subject, state, assignee_id, opened_at, first_response_at, resolved_at, snoozed_until) - messages(id, conversation_id, direction, author_id, body, sent_at, delivery_state, external_message_id, in_reply_to) - assignments(conversation_id, assignee_id, assigned_by, at, reason) · append only, so ownership history is visible - notes(conversation_id, author_id, body, at) · internal, never sent - A conversation has exactly one assignee at a time, and the assignment history is append only so nobody has to reconstruct who had it when. ### Phase 1 · Email in and out without losing the thread Build: Receive email, create or append to a conversation, and send replies that preserve threading headers. Handle a reply with the quoted history included, and one from a different address than the original. Trap: Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. Done when: A customer reply lands on the original conversation, verified with a real mail client. Quoted history is trimmed from the displayed message but retained in the stored original. A reply from a different address is proposed as the same contact rather than merged automatically. Outbound messages record their delivery state including bounces. Do not build yet: No widget, assignment or resolution. ### Phase 2 · A widget that does not break the host page Build: An embeddable widget with a small asynchronous script, isolated styling, and a socket connection for live messages. Handle a page with conflicting styles, a restrictive content policy, and a visitor who reloads mid-conversation. Trap: A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. Done when: The widget renders correctly on a page with aggressive global styles. It loads asynchronously without blocking the host page and its size is stated. A visitor reloading mid-conversation resumes with their history. A restrictive content policy produces a clear failure rather than a silent absence. Do not build yet: No assignment, identity resolution or reporting yet. ### Phase 3 · Ownership that is never ambiguous Build: Single assignee per conversation with an append-only assignment history. Unassigned conversations surfaced prominently. Handle an assignee who is away, and a conversation reopened after resolution. Trap: A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. Done when: Replying requires the conversation to be assigned, or assigns it in the same action. Unassigned conversations are the most prominent view. An assignee marked away has their conversations surfaced for reassignment rather than left. A reopened conversation returns to its previous assignee or to unassigned by a stated rule. Do not build yet: No identity resolution, reporting or automation yet. ### Phase 4 · One customer, several channels Build: Resolve contacts across channels on verified identifiers only. Propose merges for weaker signals rather than applying them. Make merges reversible with the decision recorded. Trap: Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. Done when: A contact using a verified email in the widget resolves to the existing email contact. Two contacts with the same name and no shared identifier are proposed rather than merged, tested with a crafted pair. A merge is reversible and its decision recorded. An unverified identifier never causes an automatic merge. Do not build yet: No reporting or automation yet. ### Phase 5 · Knowing whether anyone is being helped Build: Measure first response and resolution times in the customer's terms, excluding nothing silently. Report the distribution rather than an average. Surface conversations breaching a stated target while they are still open. Trap: Reporting a mean first response time hides the conversation that waited three days among a hundred that were answered in ten minutes, and the mean improves while the worst experience gets worse. Report the distribution and the tail, and surface breaches while they can still be fixed rather than in a monthly summary. Done when: First response and resolution times are reported as distributions with the tail visible rather than as means. Any exclusion from the measurement, such as out of hours, is stated beside the figure. Conversations breaching the target are surfaced while open. A snoozed conversation returns rather than disappearing from the measurement. Do not build yet: Nothing after this. ### Out of scope (and why) - Automated replies and chatbots, which is where most of this category's marketing sits and where a wrong answer costs more than a slow one. - Channel breadth beyond email and a widget, which is an integration each with its own threading and identity model. - Knowledge base and help centre publishing, which is a separate product for the same team. - Workforce scheduling and staffing forecasts, which is an operations problem rather than a support tool one. ### README must contain - That threading headers are preserved in both directions, or a customer's reply splits the conversation - That replying requires an assignee, because a shared inbox with no owner produces two-day silences - That contacts merge only on verified identifiers, since combining two customers is not recoverable - That the widget's styling is isolated and tested against a hostile host page - That response times are reported as distributions, because a mean hides the worst experience ===== AGENTS.md ===== # Agent instructions · Chatwoot Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Email and a website widget into one conversation model, Explicit ownership with a single assignee at a time, Contacts resolved across channels by verified identifiers only, Outbound email through a provider with reply threading preserved, TypeScript with PostgreSQL and a socket layer for the widget. 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 - Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. - A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. - A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. - Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. ===== BUILD_PLAN.md ===== # Build plan · Chatwoot Cloud A support platform whose real problem is that a shared inbox has no owner by default, so conversations sit unanswered while three people each assume someone else has it, and every mechanism here exists to make ownership unambiguous. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Email in and out without losing the thread A customer's reply lands on the conversation it belongs to. ### Steps 1. Preserve threading in both directions A reply that starts a new thread is a conversation split in half. Files: `src/email.ts` 2. Trim quoted history for display, keep the original Nobody reads six levels of quoting and you may still need it. Files: `src/quoting.ts` 3. Record delivery state including bounces A bounced reply looks identical to an ignored one otherwise. Files: `src/delivery.ts` ### Done when - [ ] A customer reply threads onto the original conversation - [ ] Quoted history is trimmed for display and retained - [ ] A different sending address is proposed, not merged - [ ] Delivery state including bounces is recorded ### Watch out - Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. ## Phase 2 · A widget that does not break the host page The chat widget loads fast, works offline-ish and survives a hostile page. ### Steps 1. Isolate the widget's styling Inheriting the host stylesheet breaks it differently on every site. Files: `widget/isolate.ts` 2. Resume a conversation after reload Visitors reload constantly and losing the thread loses the customer. Files: `widget/session.ts` 3. Fail visibly under a restrictive policy A silently absent widget is a support channel nobody knows is down. Files: `widget/embed.ts` ### Done when - [ ] The widget survives conflicting host styles - [ ] It loads asynchronously with a stated size - [ ] A reload resumes the conversation - [ ] A restrictive policy fails clearly ### Watch out - A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. ## Phase 3 · Ownership that is never ambiguous Every open conversation has exactly one person responsible. ### Steps 1. Require an assignee to reply Or assign in the same action. Unowned replies are how threads get doubled. Files: `src/assignment.ts` 2. Make the unassigned queue prominent It is the queue that produces the two-day silences. Files: `src/views.ts` 3. Handle away assignees Their conversations should surface rather than wait. Files: `src/availability.ts` ### Done when - [ ] Replying requires or performs assignment - [ ] The unassigned queue is the most prominent view - [ ] An away assignee's conversations are surfaced - [ ] Reopening follows a stated assignment rule ### Watch out - A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. ## Phase 4 · One customer, several channels A person who emails and then chats is recognised as one contact. ### Steps 1. Merge only on verified identifiers Combining two customers' histories is not recoverable. Files: `src/identity.ts` 2. Propose on weaker signals A matching name is a suggestion, not a fact. Files: `src/identity.ts` 3. Keep merges reversible You will get some wrong and the customer will notice. Files: `src/merge.ts`, `tests/merge-reversal.test.ts` ### Done when - [ ] A verified identifier resolves across channels - [ ] Same-named contacts are proposed, not merged - [ ] Merges are reversible with recorded decisions - [ ] Unverified identifiers never auto-merge ### Watch out - Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. ## Not in this build - Automated replies and chatbots. - Channel breadth beyond email and a widget. - Knowledge base publishing. - Workforce scheduling. ## After v1, if you want it - Canned replies with variable substitution, which is the first thing any team asks for - Conversation tagging for themes, which feeds whatever collects product feedback - Business hours awareness in targets, once the exclusions are already stated honestly ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Customer conversations, which contain personal data and often frustration. DATABASE_URL=postgres://localhost:5432/support # Required. Where customer email arrives. INBOUND_DOMAIN=support.example.com # Required · secret. Outbound replies, with threading headers preserved. SMTP_URL=smtp://user:pass@host:587 # Required. Present as a constant. A shared inbox with no owner is the failure this prevents. REQUIRE_ASSIGNEE_TO_REPLY=always # Required. Combining two customers' histories cannot be undone from the messages. AUTO_MERGE_CONTACTS=verified_only # Required. Breaches are surfaced while the conversation is still open. FIRST_RESPONSE_TARGET_MINUTES=240
You are building a production product version of Chatwoot Cloud. 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 ===== # Chatwoot Cloud · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Chatwoot Cloud, use a managed shared inbox and website widget instead of operating Chatwoot yourself. The hard boundary is managed upgrades, backups, delivery infrastructure, support, and integrations, plus channel integrations and support operations. ## Product outcome A shared inbox where every open conversation has one owner, a customer's reply always finds its thread, and the response time report shows the person who waited three days. ## 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 - Automated replies and chatbots. - Channel breadth beyond email and a widget. - Knowledge base publishing. - Workforce scheduling. - managed upgrades, backups, delivery infrastructure, support, and integrations - omnichannel integrations - large-scale deliverability - AI resolution tuning - enterprise routing and compliance ## Success criteria - Replies from three mail clients are confirmed to thread correctly - The widget is confirmed to survive a hostile host page - Replying is confirmed to require or perform assignment - Same-named contacts are confirmed proposed rather than merged - Response times are confirmed reported as distributions ===== BRIEF.md ===== # Build brief · Chatwoot Cloud 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 and chat widget where no conversation falls between two people to replace Chatwoot Cloud. 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) - Channels: Email and a website widget into one conversation model · a customer who emails and then chats is one conversation, not two - Assignment: Explicit ownership with a single assignee at a time · shared responsibility is nobody's responsibility - Identity: Contacts resolved across channels by verified identifiers only · merging two customers is worse than failing to merge one - Delivery: Outbound email through a provider with reply threading preserved · a reply that starts a new thread is a conversation lost - Runtime: TypeScript with PostgreSQL and a socket layer for the widget · conversations are relational and the widget needs live updates ### Data model (create this before Phase 1) - contacts(id, display_name, created_at, merged_into_id) - identifiers(contact_id, kind, value_normalised, verified_at) · email, widget session or external id - conversations(id, contact_id, channel, subject, state, assignee_id, opened_at, first_response_at, resolved_at, snoozed_until) - messages(id, conversation_id, direction, author_id, body, sent_at, delivery_state, external_message_id, in_reply_to) - assignments(conversation_id, assignee_id, assigned_by, at, reason) · append only, so ownership history is visible - notes(conversation_id, author_id, body, at) · internal, never sent - A conversation has exactly one assignee at a time, and the assignment history is append only so nobody has to reconstruct who had it when. ### Phase 1 · Email in and out without losing the thread Build: Receive email, create or append to a conversation, and send replies that preserve threading headers. Handle a reply with the quoted history included, and one from a different address than the original. Trap: Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. Done when: A customer reply lands on the original conversation, verified with a real mail client. Quoted history is trimmed from the displayed message but retained in the stored original. A reply from a different address is proposed as the same contact rather than merged automatically. Outbound messages record their delivery state including bounces. Do not build yet: No widget, assignment or resolution. ### Phase 2 · A widget that does not break the host page Build: An embeddable widget with a small asynchronous script, isolated styling, and a socket connection for live messages. Handle a page with conflicting styles, a restrictive content policy, and a visitor who reloads mid-conversation. Trap: A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. Done when: The widget renders correctly on a page with aggressive global styles. It loads asynchronously without blocking the host page and its size is stated. A visitor reloading mid-conversation resumes with their history. A restrictive content policy produces a clear failure rather than a silent absence. Do not build yet: No assignment, identity resolution or reporting yet. ### Phase 3 · Ownership that is never ambiguous Build: Single assignee per conversation with an append-only assignment history. Unassigned conversations surfaced prominently. Handle an assignee who is away, and a conversation reopened after resolution. Trap: A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. Done when: Replying requires the conversation to be assigned, or assigns it in the same action. Unassigned conversations are the most prominent view. An assignee marked away has their conversations surfaced for reassignment rather than left. A reopened conversation returns to its previous assignee or to unassigned by a stated rule. Do not build yet: No identity resolution, reporting or automation yet. ### Phase 4 · One customer, several channels Build: Resolve contacts across channels on verified identifiers only. Propose merges for weaker signals rather than applying them. Make merges reversible with the decision recorded. Trap: Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. Done when: A contact using a verified email in the widget resolves to the existing email contact. Two contacts with the same name and no shared identifier are proposed rather than merged, tested with a crafted pair. A merge is reversible and its decision recorded. An unverified identifier never causes an automatic merge. Do not build yet: No reporting or automation yet. ### Phase 5 · Knowing whether anyone is being helped Build: Measure first response and resolution times in the customer's terms, excluding nothing silently. Report the distribution rather than an average. Surface conversations breaching a stated target while they are still open. Trap: Reporting a mean first response time hides the conversation that waited three days among a hundred that were answered in ten minutes, and the mean improves while the worst experience gets worse. Report the distribution and the tail, and surface breaches while they can still be fixed rather than in a monthly summary. Done when: First response and resolution times are reported as distributions with the tail visible rather than as means. Any exclusion from the measurement, such as out of hours, is stated beside the figure. Conversations breaching the target are surfaced while open. A snoozed conversation returns rather than disappearing from the measurement. Do not build yet: Nothing after this. ### Out of scope (and why) - Automated replies and chatbots, which is where most of this category's marketing sits and where a wrong answer costs more than a slow one. - Channel breadth beyond email and a widget, which is an integration each with its own threading and identity model. - Knowledge base and help centre publishing, which is a separate product for the same team. - Workforce scheduling and staffing forecasts, which is an operations problem rather than a support tool one. ### README must contain - That threading headers are preserved in both directions, or a customer's reply splits the conversation - That replying requires an assignee, because a shared inbox with no owner produces two-day silences - That contacts merge only on verified identifiers, since combining two customers is not recoverable - That the widget's styling is isolated and tested against a hostile host page - That response times are reported as distributions, because a mean hides the worst experience ===== ARCHITECTURE.md ===== # Architecture · Chatwoot Cloud ## Stack | Part | Choice | Why | | --- | --- | --- | | Channels | Email and a website widget into one conversation model | a customer who emails and then chats is one conversation, not two | | Assignment | Explicit ownership with a single assignee at a time | shared responsibility is nobody's responsibility | | Identity | Contacts resolved across channels by verified identifiers only | merging two customers is worse than failing to merge one | | Delivery | Outbound email through a provider with reply threading preserved | a reply that starts a new thread is a conversation lost | | Runtime | TypeScript with PostgreSQL and a socket layer for the widget | conversations are relational and the widget needs live updates | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Email | Threading, quoting and delivery state. | Any provider, provided threading headers survive in both directions. | | Widget | An isolated, resumable chat surface. | Any implementation, provided it survives a hostile host page. | | Assignment | Single ownership with append-only history. | Not swappable for shared responsibility, which is nobody's. | | Identity | Verified-only merging with proposals. | Any matcher, provided weak signals propose rather than merge. | | Metrics | Distributions with stated exclusions. | Any presentation, provided means do not replace the tail. | ## 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 · Customer conversations, which contain personal data and often frustration. - `INBOUND_DOMAIN` · required · Where customer email arrives. - `SMTP_URL` · required, secret · Outbound replies, with threading headers preserved. - `REQUIRE_ASSIGNEE_TO_REPLY` · required · Present as a constant. A shared inbox with no owner is the failure this prevents. - `AUTO_MERGE_CONTACTS` · required · Combining two customers' histories cannot be undone from the messages. - `FIRST_RESPONSE_TARGET_MINUTES` · required · Breaches are surfaced while the conversation is still open. ## 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 · Chatwoot Cloud product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Email and a website widget into one conversation model, Explicit ownership with a single assignee at a time, Contacts resolved across channels by verified identifiers only, Outbound email through a provider with reply threading preserved, TypeScript with PostgreSQL and a socket layer for the widget. - 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 - Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. - A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. - A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. - Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. - Reporting a mean first response time hides the conversation that waited three days among a hundred that were answered in ten minutes, and the mean improves while the worst experience gets worse. Report the distribution and the tail, and surface breaches while they can still be fixed rather than in a monthly summary. ===== MILESTONES.md ===== # Delivery milestones · Chatwoot Cloud Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Email in and out without losing the thread A customer's reply lands on the conversation it belongs to. ### Steps 1. Preserve threading in both directions A reply that starts a new thread is a conversation split in half. Files: `src/email.ts` 2. Trim quoted history for display, keep the original Nobody reads six levels of quoting and you may still need it. Files: `src/quoting.ts` 3. Record delivery state including bounces A bounced reply looks identical to an ignored one otherwise. Files: `src/delivery.ts` ### Done when - [ ] A customer reply threads onto the original conversation - [ ] Quoted history is trimmed for display and retained - [ ] A different sending address is proposed, not merged - [ ] Delivery state including bounces is recorded ### Watch out - Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. ## M2 · A widget that does not break the host page The chat widget loads fast, works offline-ish and survives a hostile page. ### Steps 1. Isolate the widget's styling Inheriting the host stylesheet breaks it differently on every site. Files: `widget/isolate.ts` 2. Resume a conversation after reload Visitors reload constantly and losing the thread loses the customer. Files: `widget/session.ts` 3. Fail visibly under a restrictive policy A silently absent widget is a support channel nobody knows is down. Files: `widget/embed.ts` ### Done when - [ ] The widget survives conflicting host styles - [ ] It loads asynchronously with a stated size - [ ] A reload resumes the conversation - [ ] A restrictive policy fails clearly ### Watch out - A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. ## M3 · Ownership that is never ambiguous Every open conversation has exactly one person responsible. ### Steps 1. Require an assignee to reply Or assign in the same action. Unowned replies are how threads get doubled. Files: `src/assignment.ts` 2. Make the unassigned queue prominent It is the queue that produces the two-day silences. Files: `src/views.ts` 3. Handle away assignees Their conversations should surface rather than wait. Files: `src/availability.ts` ### Done when - [ ] Replying requires or performs assignment - [ ] The unassigned queue is the most prominent view - [ ] An away assignee's conversations are surfaced - [ ] Reopening follows a stated assignment rule ### Watch out - A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. ## M4 · One customer, several channels A person who emails and then chats is recognised as one contact. ### Steps 1. Merge only on verified identifiers Combining two customers' histories is not recoverable. Files: `src/identity.ts` 2. Propose on weaker signals A matching name is a suggestion, not a fact. Files: `src/identity.ts` 3. Keep merges reversible You will get some wrong and the customer will notice. Files: `src/merge.ts`, `tests/merge-reversal.test.ts` ### Done when - [ ] A verified identifier resolves across channels - [ ] Same-named contacts are proposed, not merged - [ ] Merges are reversible with recorded decisions - [ ] Unverified identifiers never auto-merge ### Watch out - Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. ## M5 · Knowing whether anyone is being helped (production only) Response times reflect the customer's experience rather than the team's convenience. ### Steps 1. Report distributions, not means A mean hides the three day wait among a hundred fast replies. Files: `src/metrics.ts` 2. State every exclusion Out of hours handling changes the number and should be visible. Files: `src/metrics.ts` 3. Surface breaches while open A monthly report is too late to help the customer waiting. Files: `src/alerts.ts` ### Done when - [ ] Response times report as distributions with the tail - [ ] Exclusions are stated beside the figures - [ ] Open breaches are surfaced in time to act - [ ] Snoozed conversations return to measurement ### Watch out - Reporting a mean first response time hides the conversation that waited three days among a hundred that were answered in ten minutes, and the mean improves while the worst experience gets worse. Report the distribution and the tail, and surface breaches while they can still be fixed rather than in a monthly summary. ===== OPERATIONS.md ===== # Operations · Chatwoot Cloud ## Backup Conversations are customer records containing personal data and often complaints. Back them up encrypted with the stated retention applied to backups too, since a deletion request has to reach them. ## Restore Restore and confirm assignment history survived, since losing it makes it impossible to answer who was handling a conversation when something went wrong, which is exactly when the question gets asked. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Unassigned queue depth and oldest unanswered conversation. Both are leading indicators of the failure this product exists to prevent, and both are invisible in a resolved-count report. ## Incident checklist A customer waiting days with no reply is the incident, and it almost always means the conversation was unassigned rather than that anyone decided to ignore it. The unassigned queue being the most prominent view is the control. 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 - [ ] Replies from three mail clients are confirmed to thread correctly - [ ] The widget is confirmed to survive a hostile host page - [ ] Replying is confirmed to require or perform assignment - [ ] Same-named contacts are confirmed proposed rather than merged - [ ] Response times are confirmed reported as distributions ## Launch constraint Do not market omitted Chatwoot Cloud 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. Customer conversations, which contain personal data and often frustration. DATABASE_URL=postgres://localhost:5432/support # Required. Where customer email arrives. INBOUND_DOMAIN=support.example.com # Required · secret. Outbound replies, with threading headers preserved. SMTP_URL=smtp://user:pass@host:587 # Required. Present as a constant. A shared inbox with no owner is the failure this prevents. REQUIRE_ASSIGNEE_TO_REPLY=always # Required. Combining two customers' histories cannot be undone from the messages. AUTO_MERGE_CONTACTS=verified_only # Required. Breaches are surfaced while the conversation is still open. FIRST_RESPONSE_TARGET_MINUTES=240
# Chatwoot Cloud · indie build A support platform whose real problem is that a shared inbox has no owner by default, so conversations sit unanswered while three people each assume someone else has it, and every mechanism here exists to make ownership unambiguous. 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 | | --- | --- | --- | | Channels | Email and a website widget into one conversation model | a customer who emails and then chats is one conversation, not two | | Assignment | Explicit ownership with a single assignee at a time | shared responsibility is nobody's responsibility | | Identity | Contacts resolved across channels by verified identifiers only | merging two customers is worse than failing to merge one | | Delivery | Outbound email through a provider with reply threading preserved | a reply that starts a new thread is a conversation lost | | Runtime | TypeScript with PostgreSQL and a socket layer for the widget | conversations are relational and the widget needs live updates | ## 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, the socket layer and the mail workers. - Get it: nodejs.org, or nvm install 22. - Verify: node --version prints v22 or higher - [ ] **PostgreSQL 16 or newer** · free locally - Why: Conversations, identity resolution and assignment history. - Get it: Docker or your package manager. - Verify: psql connects and full text search returns a result - [ ] **Inbound and outbound email with a real domain** · free tiers cover testing - Why: Threading behaviour, bounces and quoting are all only observable against real mail clients, which disagree with each other. - Get it: A domain with receiving configured and a sending provider, plus at least three different mail clients to test replies from. - Verify: A reply from three different clients threads onto the original conversation - [ ] **A hostile host page for the widget** · free - Why: Widgets break on real pages with real stylesheets, and testing against a blank page demonstrates nothing. - Get it: A page with aggressive global styles, a restrictive content policy and other embedded scripts. - Verify: The widget renders correctly inside it - [ ] **Your response targets and what they exclude** · free - Why: The measurement is reported with its exclusions stated, and deciding those after seeing the numbers means choosing whichever exclusions flatter them. - Get it: Decide the first response target, the resolution target, and whether out of hours counts. Write down the reasoning. - Verify: A written note states both targets and every exclusion ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Automated replies and chatbots. - Channel breadth beyond email and a widget. - Knowledge base publishing. - Workforce scheduling. - managed upgrades, backups, delivery infrastructure, support, and integrations - 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 Chatwoot Cloud, and the README should say so rather than pretend.
# Build brief · Chatwoot Cloud 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 and chat widget where no conversation falls between two people to replace Chatwoot Cloud. 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) - Channels: Email and a website widget into one conversation model · a customer who emails and then chats is one conversation, not two - Assignment: Explicit ownership with a single assignee at a time · shared responsibility is nobody's responsibility - Identity: Contacts resolved across channels by verified identifiers only · merging two customers is worse than failing to merge one - Delivery: Outbound email through a provider with reply threading preserved · a reply that starts a new thread is a conversation lost - Runtime: TypeScript with PostgreSQL and a socket layer for the widget · conversations are relational and the widget needs live updates ### Data model (create this before Phase 1) - contacts(id, display_name, created_at, merged_into_id) - identifiers(contact_id, kind, value_normalised, verified_at) · email, widget session or external id - conversations(id, contact_id, channel, subject, state, assignee_id, opened_at, first_response_at, resolved_at, snoozed_until) - messages(id, conversation_id, direction, author_id, body, sent_at, delivery_state, external_message_id, in_reply_to) - assignments(conversation_id, assignee_id, assigned_by, at, reason) · append only, so ownership history is visible - notes(conversation_id, author_id, body, at) · internal, never sent - A conversation has exactly one assignee at a time, and the assignment history is append only so nobody has to reconstruct who had it when. ### Phase 1 · Email in and out without losing the thread Build: Receive email, create or append to a conversation, and send replies that preserve threading headers. Handle a reply with the quoted history included, and one from a different address than the original. Trap: Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. Done when: A customer reply lands on the original conversation, verified with a real mail client. Quoted history is trimmed from the displayed message but retained in the stored original. A reply from a different address is proposed as the same contact rather than merged automatically. Outbound messages record their delivery state including bounces. Do not build yet: No widget, assignment or resolution. ### Phase 2 · A widget that does not break the host page Build: An embeddable widget with a small asynchronous script, isolated styling, and a socket connection for live messages. Handle a page with conflicting styles, a restrictive content policy, and a visitor who reloads mid-conversation. Trap: A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. Done when: The widget renders correctly on a page with aggressive global styles. It loads asynchronously without blocking the host page and its size is stated. A visitor reloading mid-conversation resumes with their history. A restrictive content policy produces a clear failure rather than a silent absence. Do not build yet: No assignment, identity resolution or reporting yet. ### Phase 3 · Ownership that is never ambiguous Build: Single assignee per conversation with an append-only assignment history. Unassigned conversations surfaced prominently. Handle an assignee who is away, and a conversation reopened after resolution. Trap: A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. Done when: Replying requires the conversation to be assigned, or assigns it in the same action. Unassigned conversations are the most prominent view. An assignee marked away has their conversations surfaced for reassignment rather than left. A reopened conversation returns to its previous assignee or to unassigned by a stated rule. Do not build yet: No identity resolution, reporting or automation yet. ### Phase 4 · One customer, several channels Build: Resolve contacts across channels on verified identifiers only. Propose merges for weaker signals rather than applying them. Make merges reversible with the decision recorded. Trap: Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. Done when: A contact using a verified email in the widget resolves to the existing email contact. Two contacts with the same name and no shared identifier are proposed rather than merged, tested with a crafted pair. A merge is reversible and its decision recorded. An unverified identifier never causes an automatic merge. Do not build yet: No reporting or automation yet. ### Phase 5 · Knowing whether anyone is being helped Build: Measure first response and resolution times in the customer's terms, excluding nothing silently. Report the distribution rather than an average. Surface conversations breaching a stated target while they are still open. Trap: Reporting a mean first response time hides the conversation that waited three days among a hundred that were answered in ten minutes, and the mean improves while the worst experience gets worse. Report the distribution and the tail, and surface breaches while they can still be fixed rather than in a monthly summary. Done when: First response and resolution times are reported as distributions with the tail visible rather than as means. Any exclusion from the measurement, such as out of hours, is stated beside the figure. Conversations breaching the target are surfaced while open. A snoozed conversation returns rather than disappearing from the measurement. Do not build yet: Nothing after this. ### Out of scope (and why) - Automated replies and chatbots, which is where most of this category's marketing sits and where a wrong answer costs more than a slow one. - Channel breadth beyond email and a widget, which is an integration each with its own threading and identity model. - Knowledge base and help centre publishing, which is a separate product for the same team. - Workforce scheduling and staffing forecasts, which is an operations problem rather than a support tool one. ### README must contain - That threading headers are preserved in both directions, or a customer's reply splits the conversation - That replying requires an assignee, because a shared inbox with no owner produces two-day silences - That contacts merge only on verified identifiers, since combining two customers is not recoverable - That the widget's styling is isolated and tested against a hostile host page - That response times are reported as distributions, because a mean hides the worst experience
# Agent instructions · Chatwoot Cloud indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Email and a website widget into one conversation model, Explicit ownership with a single assignee at a time, Contacts resolved across channels by verified identifiers only, Outbound email through a provider with reply threading preserved, TypeScript with PostgreSQL and a socket layer for the widget. 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 - Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. - A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. - A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. - Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible.
# Build plan · Chatwoot Cloud A support platform whose real problem is that a shared inbox has no owner by default, so conversations sit unanswered while three people each assume someone else has it, and every mechanism here exists to make ownership unambiguous. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · Email in and out without losing the thread A customer's reply lands on the conversation it belongs to. ### Steps 1. Preserve threading in both directions A reply that starts a new thread is a conversation split in half. Files: `src/email.ts` 2. Trim quoted history for display, keep the original Nobody reads six levels of quoting and you may still need it. Files: `src/quoting.ts` 3. Record delivery state including bounces A bounced reply looks identical to an ignored one otherwise. Files: `src/delivery.ts` ### Done when - [ ] A customer reply threads onto the original conversation - [ ] Quoted history is trimmed for display and retained - [ ] A different sending address is proposed, not merged - [ ] Delivery state including bounces is recorded ### Watch out - Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. ## Phase 2 · A widget that does not break the host page The chat widget loads fast, works offline-ish and survives a hostile page. ### Steps 1. Isolate the widget's styling Inheriting the host stylesheet breaks it differently on every site. Files: `widget/isolate.ts` 2. Resume a conversation after reload Visitors reload constantly and losing the thread loses the customer. Files: `widget/session.ts` 3. Fail visibly under a restrictive policy A silently absent widget is a support channel nobody knows is down. Files: `widget/embed.ts` ### Done when - [ ] The widget survives conflicting host styles - [ ] It loads asynchronously with a stated size - [ ] A reload resumes the conversation - [ ] A restrictive policy fails clearly ### Watch out - A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. ## Phase 3 · Ownership that is never ambiguous Every open conversation has exactly one person responsible. ### Steps 1. Require an assignee to reply Or assign in the same action. Unowned replies are how threads get doubled. Files: `src/assignment.ts` 2. Make the unassigned queue prominent It is the queue that produces the two-day silences. Files: `src/views.ts` 3. Handle away assignees Their conversations should surface rather than wait. Files: `src/availability.ts` ### Done when - [ ] Replying requires or performs assignment - [ ] The unassigned queue is the most prominent view - [ ] An away assignee's conversations are surfaced - [ ] Reopening follows a stated assignment rule ### Watch out - A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. ## Phase 4 · One customer, several channels A person who emails and then chats is recognised as one contact. ### Steps 1. Merge only on verified identifiers Combining two customers' histories is not recoverable. Files: `src/identity.ts` 2. Propose on weaker signals A matching name is a suggestion, not a fact. Files: `src/identity.ts` 3. Keep merges reversible You will get some wrong and the customer will notice. Files: `src/merge.ts`, `tests/merge-reversal.test.ts` ### Done when - [ ] A verified identifier resolves across channels - [ ] Same-named contacts are proposed, not merged - [ ] Merges are reversible with recorded decisions - [ ] Unverified identifiers never auto-merge ### Watch out - Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. ## Not in this build - Automated replies and chatbots. - Channel breadth beyond email and a widget. - Knowledge base publishing. - Workforce scheduling. ## After v1, if you want it - Canned replies with variable substitution, which is the first thing any team asks for - Conversation tagging for themes, which feeds whatever collects product feedback - Business hours awareness in targets, once the exclusions are already stated honestly
# Copy to .env and fill in. Never commit .env; this file documents it. # Required · secret. Customer conversations, which contain personal data and often frustration. DATABASE_URL=postgres://localhost:5432/support # Required. Where customer email arrives. INBOUND_DOMAIN=support.example.com # Required · secret. Outbound replies, with threading headers preserved. SMTP_URL=smtp://user:pass@host:587 # Required. Present as a constant. A shared inbox with no owner is the failure this prevents. REQUIRE_ASSIGNEE_TO_REPLY=always # Required. Combining two customers' histories cannot be undone from the messages. AUTO_MERGE_CONTACTS=verified_only # Required. Breaches are surfaced while the conversation is still open. FIRST_RESPONSE_TARGET_MINUTES=240
# Chatwoot Cloud · product brief ## Problem The core loop is buildable, but a dependable replacement becomes a real weekend or multi-day project. For Chatwoot Cloud, use a managed shared inbox and website widget instead of operating Chatwoot yourself. The hard boundary is managed upgrades, backups, delivery infrastructure, support, and integrations, plus channel integrations and support operations. ## Product outcome A shared inbox where every open conversation has one owner, a customer's reply always finds its thread, and the response time report shows the person who waited three days. ## 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 - Automated replies and chatbots. - Channel breadth beyond email and a widget. - Knowledge base publishing. - Workforce scheduling. - managed upgrades, backups, delivery infrastructure, support, and integrations - omnichannel integrations - large-scale deliverability - AI resolution tuning - enterprise routing and compliance ## Success criteria - Replies from three mail clients are confirmed to thread correctly - The widget is confirmed to survive a hostile host page - Replying is confirmed to require or perform assignment - Same-named contacts are confirmed proposed rather than merged - Response times are confirmed reported as distributions
# Build brief · Chatwoot Cloud 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 and chat widget where no conversation falls between two people to replace Chatwoot Cloud. 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) - Channels: Email and a website widget into one conversation model · a customer who emails and then chats is one conversation, not two - Assignment: Explicit ownership with a single assignee at a time · shared responsibility is nobody's responsibility - Identity: Contacts resolved across channels by verified identifiers only · merging two customers is worse than failing to merge one - Delivery: Outbound email through a provider with reply threading preserved · a reply that starts a new thread is a conversation lost - Runtime: TypeScript with PostgreSQL and a socket layer for the widget · conversations are relational and the widget needs live updates ### Data model (create this before Phase 1) - contacts(id, display_name, created_at, merged_into_id) - identifiers(contact_id, kind, value_normalised, verified_at) · email, widget session or external id - conversations(id, contact_id, channel, subject, state, assignee_id, opened_at, first_response_at, resolved_at, snoozed_until) - messages(id, conversation_id, direction, author_id, body, sent_at, delivery_state, external_message_id, in_reply_to) - assignments(conversation_id, assignee_id, assigned_by, at, reason) · append only, so ownership history is visible - notes(conversation_id, author_id, body, at) · internal, never sent - A conversation has exactly one assignee at a time, and the assignment history is append only so nobody has to reconstruct who had it when. ### Phase 1 · Email in and out without losing the thread Build: Receive email, create or append to a conversation, and send replies that preserve threading headers. Handle a reply with the quoted history included, and one from a different address than the original. Trap: Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. Done when: A customer reply lands on the original conversation, verified with a real mail client. Quoted history is trimmed from the displayed message but retained in the stored original. A reply from a different address is proposed as the same contact rather than merged automatically. Outbound messages record their delivery state including bounces. Do not build yet: No widget, assignment or resolution. ### Phase 2 · A widget that does not break the host page Build: An embeddable widget with a small asynchronous script, isolated styling, and a socket connection for live messages. Handle a page with conflicting styles, a restrictive content policy, and a visitor who reloads mid-conversation. Trap: A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. Done when: The widget renders correctly on a page with aggressive global styles. It loads asynchronously without blocking the host page and its size is stated. A visitor reloading mid-conversation resumes with their history. A restrictive content policy produces a clear failure rather than a silent absence. Do not build yet: No assignment, identity resolution or reporting yet. ### Phase 3 · Ownership that is never ambiguous Build: Single assignee per conversation with an append-only assignment history. Unassigned conversations surfaced prominently. Handle an assignee who is away, and a conversation reopened after resolution. Trap: A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. Done when: Replying requires the conversation to be assigned, or assigns it in the same action. Unassigned conversations are the most prominent view. An assignee marked away has their conversations surfaced for reassignment rather than left. A reopened conversation returns to its previous assignee or to unassigned by a stated rule. Do not build yet: No identity resolution, reporting or automation yet. ### Phase 4 · One customer, several channels Build: Resolve contacts across channels on verified identifiers only. Propose merges for weaker signals rather than applying them. Make merges reversible with the decision recorded. Trap: Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. Done when: A contact using a verified email in the widget resolves to the existing email contact. Two contacts with the same name and no shared identifier are proposed rather than merged, tested with a crafted pair. A merge is reversible and its decision recorded. An unverified identifier never causes an automatic merge. Do not build yet: No reporting or automation yet. ### Phase 5 · Knowing whether anyone is being helped Build: Measure first response and resolution times in the customer's terms, excluding nothing silently. Report the distribution rather than an average. Surface conversations breaching a stated target while they are still open. Trap: Reporting a mean first response time hides the conversation that waited three days among a hundred that were answered in ten minutes, and the mean improves while the worst experience gets worse. Report the distribution and the tail, and surface breaches while they can still be fixed rather than in a monthly summary. Done when: First response and resolution times are reported as distributions with the tail visible rather than as means. Any exclusion from the measurement, such as out of hours, is stated beside the figure. Conversations breaching the target are surfaced while open. A snoozed conversation returns rather than disappearing from the measurement. Do not build yet: Nothing after this. ### Out of scope (and why) - Automated replies and chatbots, which is where most of this category's marketing sits and where a wrong answer costs more than a slow one. - Channel breadth beyond email and a widget, which is an integration each with its own threading and identity model. - Knowledge base and help centre publishing, which is a separate product for the same team. - Workforce scheduling and staffing forecasts, which is an operations problem rather than a support tool one. ### README must contain - That threading headers are preserved in both directions, or a customer's reply splits the conversation - That replying requires an assignee, because a shared inbox with no owner produces two-day silences - That contacts merge only on verified identifiers, since combining two customers is not recoverable - That the widget's styling is isolated and tested against a hostile host page - That response times are reported as distributions, because a mean hides the worst experience
# Architecture · Chatwoot Cloud ## Stack | Part | Choice | Why | | --- | --- | --- | | Channels | Email and a website widget into one conversation model | a customer who emails and then chats is one conversation, not two | | Assignment | Explicit ownership with a single assignee at a time | shared responsibility is nobody's responsibility | | Identity | Contacts resolved across channels by verified identifiers only | merging two customers is worse than failing to merge one | | Delivery | Outbound email through a provider with reply threading preserved | a reply that starts a new thread is a conversation lost | | Runtime | TypeScript with PostgreSQL and a socket layer for the widget | conversations are relational and the widget needs live updates | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Email | Threading, quoting and delivery state. | Any provider, provided threading headers survive in both directions. | | Widget | An isolated, resumable chat surface. | Any implementation, provided it survives a hostile host page. | | Assignment | Single ownership with append-only history. | Not swappable for shared responsibility, which is nobody's. | | Identity | Verified-only merging with proposals. | Any matcher, provided weak signals propose rather than merge. | | Metrics | Distributions with stated exclusions. | Any presentation, provided means do not replace the tail. | ## 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 · Customer conversations, which contain personal data and often frustration. - `INBOUND_DOMAIN` · required · Where customer email arrives. - `SMTP_URL` · required, secret · Outbound replies, with threading headers preserved. - `REQUIRE_ASSIGNEE_TO_REPLY` · required · Present as a constant. A shared inbox with no owner is the failure this prevents. - `AUTO_MERGE_CONTACTS` · required · Combining two customers' histories cannot be undone from the messages. - `FIRST_RESPONSE_TARGET_MINUTES` · required · Breaches are surfaced while the conversation is still open. ## 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 · Chatwoot Cloud product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Email and a website widget into one conversation model, Explicit ownership with a single assignee at a time, Contacts resolved across channels by verified identifiers only, Outbound email through a provider with reply threading preserved, TypeScript with PostgreSQL and a socket layer for the widget. - 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 - Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. - A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. - A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. - Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. - Reporting a mean first response time hides the conversation that waited three days among a hundred that were answered in ten minutes, and the mean improves while the worst experience gets worse. Report the distribution and the tail, and surface breaches while they can still be fixed rather than in a monthly summary.
# Delivery milestones · Chatwoot Cloud Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · Email in and out without losing the thread A customer's reply lands on the conversation it belongs to. ### Steps 1. Preserve threading in both directions A reply that starts a new thread is a conversation split in half. Files: `src/email.ts` 2. Trim quoted history for display, keep the original Nobody reads six levels of quoting and you may still need it. Files: `src/quoting.ts` 3. Record delivery state including bounces A bounced reply looks identical to an ignored one otherwise. Files: `src/delivery.ts` ### Done when - [ ] A customer reply threads onto the original conversation - [ ] Quoted history is trimmed for display and retained - [ ] A different sending address is proposed, not merged - [ ] Delivery state including bounces is recorded ### Watch out - Sending replies without preserving the threading headers means the customer's response starts a new conversation, so the history splits and whoever picks up the second half has no context. Preserve the message identifiers and reference chain in both directions. ## M2 · A widget that does not break the host page The chat widget loads fast, works offline-ish and survives a hostile page. ### Steps 1. Isolate the widget's styling Inheriting the host stylesheet breaks it differently on every site. Files: `widget/isolate.ts` 2. Resume a conversation after reload Visitors reload constantly and losing the thread loses the customer. Files: `widget/session.ts` 3. Fail visibly under a restrictive policy A silently absent widget is a support channel nobody knows is down. Files: `widget/embed.ts` ### Done when - [ ] The widget survives conflicting host styles - [ ] It loads asynchronously with a stated size - [ ] A reload resumes the conversation - [ ] A restrictive policy fails clearly ### Watch out - A widget that inherits the host page's stylesheet renders differently on every site and the customer only finds out when someone complains their support widget looks broken. Isolate the styling and test against a deliberately hostile page rather than a blank one. ## M3 · Ownership that is never ambiguous Every open conversation has exactly one person responsible. ### Steps 1. Require an assignee to reply Or assign in the same action. Unowned replies are how threads get doubled. Files: `src/assignment.ts` 2. Make the unassigned queue prominent It is the queue that produces the two-day silences. Files: `src/views.ts` 3. Handle away assignees Their conversations should surface rather than wait. Files: `src/availability.ts` ### Done when - [ ] Replying requires or performs assignment - [ ] The unassigned queue is the most prominent view - [ ] An away assignee's conversations are surfaced - [ ] Reopening follows a stated assignment rule ### Watch out - A shared inbox where anyone can reply and nobody is assigned produces conversations that sit for two days while three people each assume someone else has it, which is the failure the whole category exists to prevent. Require an assignee to reply, and make the unassigned queue the most visible thing in the interface. ## M4 · One customer, several channels A person who emails and then chats is recognised as one contact. ### Steps 1. Merge only on verified identifiers Combining two customers' histories is not recoverable. Files: `src/identity.ts` 2. Propose on weaker signals A matching name is a suggestion, not a fact. Files: `src/identity.ts` 3. Keep merges reversible You will get some wrong and the customer will notice. Files: `src/merge.ts`, `tests/merge-reversal.test.ts` ### Done when - [ ] A verified identifier resolves across channels - [ ] Same-named contacts are proposed, not merged - [ ] Merges are reversible with recorded decisions - [ ] Unverified identifiers never auto-merge ### Watch out - Merging contacts on a matching name combines two customers' conversation histories, and afterwards there is no way to separate them because which message belonged to whom is gone. Merge only on verified identifiers, propose everything else, and keep merges reversible. ## M5 · Knowing whether anyone is being helped (production only) Response times reflect the customer's experience rather than the team's convenience. ### Steps 1. Report distributions, not means A mean hides the three day wait among a hundred fast replies. Files: `src/metrics.ts` 2. State every exclusion Out of hours handling changes the number and should be visible. Files: `src/metrics.ts` 3. Surface breaches while open A monthly report is too late to help the customer waiting. Files: `src/alerts.ts` ### Done when - [ ] Response times report as distributions with the tail - [ ] Exclusions are stated beside the figures - [ ] Open breaches are surfaced in time to act - [ ] Snoozed conversations return to measurement ### Watch out - Reporting a mean first response time hides the conversation that waited three days among a hundred that were answered in ten minutes, and the mean improves while the worst experience gets worse. Report the distribution and the tail, and surface breaches while they can still be fixed rather than in a monthly summary.
# Operations · Chatwoot Cloud ## Backup Conversations are customer records containing personal data and often complaints. Back them up encrypted with the stated retention applied to backups too, since a deletion request has to reach them. ## Restore Restore and confirm assignment history survived, since losing it makes it impossible to answer who was handling a conversation when something went wrong, which is exactly when the question gets asked. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Unassigned queue depth and oldest unanswered conversation. Both are leading indicators of the failure this product exists to prevent, and both are invisible in a resolved-count report. ## Incident checklist A customer waiting days with no reply is the incident, and it almost always means the conversation was unassigned rather than that anyone decided to ignore it. The unassigned queue being the most prominent view is the control. 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 - [ ] Replies from three mail clients are confirmed to thread correctly - [ ] The widget is confirmed to survive a hostile host page - [ ] Replying is confirmed to require or perform assignment - [ ] Same-named contacts are confirmed proposed rather than merged - [ ] Response times are confirmed reported as distributions ## Launch constraint Do not market omitted Chatwoot Cloud 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. Customer conversations, which contain personal data and often frustration. DATABASE_URL=postgres://localhost:5432/support # Required. Where customer email arrives. INBOUND_DOMAIN=support.example.com # Required · secret. Outbound replies, with threading headers preserved. SMTP_URL=smtp://user:pass@host:587 # Required. Present as a constant. A shared inbox with no owner is the failure this prevents. REQUIRE_ASSIGNEE_TO_REPLY=always # Required. Combining two customers' histories cannot be undone from the messages. AUTO_MERGE_CONTACTS=verified_only # Required. Breaches are surfaced while the conversation is still open. FIRST_RESPONSE_TARGET_MINUTES=240
$ choose a build depth, inspect the files, then open the complete pack in your agent
People still pay for Chatwoot Cloud 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.
xmanaged upgrades, backups, delivery infrastructure, support, and integrations
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 5 free alternatives to Chatwoot Cloud →· no votes, no pay-to-list · just what's real
Chatwoot Cloud pricing
| plan | monthly | annual (per mo) | what you get |
|---|---|---|---|
| hacker | $0/user | $0/user | Up to 2 agents, 500 conversations/month, live-chat channel only and 30-day data retention |
| startups | — | $19/user | Unlimited conversations subject to fair use; 300 Captain AI credits/month; 1-year retention; all channels except voice |
| business | — | $39/user | Unlimited conversations subject to fair use; 500 AI credits/month; voice, teams and automations; 2-year retention |
| enterprise | — | $99/user | Unlimited conversations subject to fair use; 800 AI credits/month; SSO/SAML and audit logs; 3-year retention |
free tier2 agents; 500 conversations/month; live chat only; 30-day data retention
billingannual only on the published Cloud pricing page; 15-day trial
hidden costsExtra Captain AI credits cost $20 per 1,000 and are non-refundable. 'Unlimited' conversations are governed by fair use; video/voice support on Enterprise requires 20+ agents.
verified 2026-08-12 · source ↗
Vibecode Chatwoot Cloud
Kinda. The core of Chatwoot Cloud is buildable in a weekend with the prompt on this page, but there are real gaps: managed upgrades, backups, delivery infrastructure, support, and integrations, omnichannel integrations. Read the honest list above before committing.
How much does Chatwoot Cloud cost?
Chatwoot Cloud costs about $19/month (Startups, checked 2026-07-31), which is $228 per year.
What do I lose by replacing Chatwoot Cloud?
Honestly: managed upgrades, backups, delivery infrastructure, support, and integrations; 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 Chatwoot Cloud?
Yes: Chatwoot (A proper support inbox with chat, email and a help center; free software, billable server chores.) HubSpot Free CRM (A two-seat shared inbox and live chat bolted to a CRM; free until the limits become colleagues.) tawk.to (Live chat, tickets and a knowledge base for zero dollars; the little badge is how the arithmetic survives.) All 5 curated free alternatives are at vibecodeit.com/chatwoot-cloud/alternatives. The prompt is for when you want it exactly your way.