Build Chatwoot Cloud
KINDAreplaces $19/mosaves $228/yrback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the server, the socket layer and the mail workers.
Get it nodejs.org, or nvm install 22. open ↗
Verify
node --version prints v22 or higher - installfree locally
Why Conversations, identity resolution and assignment history.
Get it Docker or your package manager. open ↗
Verify
psql connects and full text search returns a result - accountfree 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 - have readyfree
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 - decidefree
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
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- 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.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
DATABASE_URLsecret | required | postgres://localhost:5432/support | Customer conversations, which contain personal data and often frustration. |
INBOUND_DOMAIN | required | support.example.com | Where customer email arrives. |
SMTP_URLsecret | required | smtp://user:pass@host:587 | Outbound replies, with threading headers preserved. |
REQUIRE_ASSIGNEE_TO_REPLY | required | always | Present as a constant. A shared inbox with no owner is the failure this prevents. |
AUTO_MERGE_CONTACTS | required | verified_only | Combining two customers' histories cannot be undone from the messages. |
FIRST_RESPONSE_TARGET_MINUTES | required | 240 | Breaches are surfaced while the conversation is still open. |
The build, in order
Email in and out without losing the thread
A customer's reply lands on the conversation it belongs to.
A reply that starts a new thread is a conversation split in half.
Files
src/email.tsNobody reads six levels of quoting and you may still need it.
Files
src/quoting.tsA bounced reply looks identical to an ignored one otherwise.
Files
src/delivery.ts
done when · tick each as it passeswatch 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.
A widget that does not break the host page
The chat widget loads fast, works offline-ish and survives a hostile page.
Inheriting the host stylesheet breaks it differently on every site.
Files
widget/isolate.tsVisitors reload constantly and losing the thread loses the customer.
Files
widget/session.tsA silently absent widget is a support channel nobody knows is down.
Files
widget/embed.ts
done when · tick each as it passeswatch 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.
Ownership that is never ambiguous
Every open conversation has exactly one person responsible.
Or assign in the same action. Unowned replies are how threads get doubled.
Files
src/assignment.tsIt is the queue that produces the two-day silences.
Files
src/views.tsTheir conversations should surface rather than wait.
Files
src/availability.ts
done when · tick each as it passeswatch 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.
One customer, several channels
A person who emails and then chats is recognised as one contact.
Combining two customers' histories is not recoverable.
Files
src/identity.tsA matching name is a suggestion, not a fact.
Files
src/identity.tsYou will get some wrong and the customer will notice.
Files
src/merge.tstests/merge-reversal.test.ts
done when · tick each as it passeswatch 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.
Knowing whether anyone is being helpedproduct builder
Response times reflect the customer's experience rather than the team's convenience.
A mean hides the three day wait among a hundred fast replies.
Files
src/metrics.tsOut of hours handling changes the number and should be visible.
Files
src/metrics.tsA monthly report is too late to help the customer waiting.
Files
src/alerts.ts
done when · tick each as it passeswatch 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.
That is the whole plan for Chatwoot Cloud. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- 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
Need the files? The project pack on the verdict page hands your agent the whole brief · more customer support.