Build Chatwoot Cloud

KINDAreplaces $19/mosaves $228/yrback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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-daythe files for this build are in the project pack

ChannelsEmail and a website widget into one conversation modelAssignmentExplicit ownership with a single assignee at a timeIdentityContacts resolved across channels by verified identifiers onlyDeliveryOutbound email through a provider with reply threading preservedRuntimeTypeScript with PostgreSQL and a socket layer for the widget

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. 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

  2. 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

  3. 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

  4. 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

  5. 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.

VariableNeededExampleWhere the value comes from
DATABASE_URLsecretrequiredpostgres://localhost:5432/supportCustomer conversations, which contain personal data and often frustration.
INBOUND_DOMAINrequiredsupport.example.comWhere customer email arrives.
SMTP_URLsecretrequiredsmtp://user:pass@host:587Outbound replies, with threading headers preserved.
REQUIRE_ASSIGNEE_TO_REPLYrequiredalwaysPresent as a constant. A shared inbox with no owner is the failure this prevents.
AUTO_MERGE_CONTACTSrequiredverified_onlyCombining two customers' histories cannot be undone from the messages.
FIRST_RESPONSE_TARGET_MINUTESrequired240Breaches are surfaced while the conversation is still open.

The build, in order

  1. Email in and out without losing the thread

    A customer's reply lands on the conversation it belongs to.

    1. A reply that starts a new thread is a conversation split in half.

      Files src/email.ts

    2. Nobody reads six levels of quoting and you may still need it.

      Files src/quoting.ts

    3. A bounced reply looks identical to an ignored one otherwise.

      Files src/delivery.ts

    done when · tick each as it passes
    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.
  2. A widget that does not break the host page

    The chat widget loads fast, works offline-ish and survives a hostile page.

    1. Inheriting the host stylesheet breaks it differently on every site.

      Files widget/isolate.ts

    2. Visitors reload constantly and losing the thread loses the customer.

      Files widget/session.ts

    3. A silently absent widget is a support channel nobody knows is down.

      Files widget/embed.ts

    done when · tick each as it passes
    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.
  3. Ownership that is never ambiguous

    Every open conversation has exactly one person responsible.

    1. Or assign in the same action. Unowned replies are how threads get doubled.

      Files src/assignment.ts

    2. It is the queue that produces the two-day silences.

      Files src/views.ts

    3. Their conversations should surface rather than wait.

      Files src/availability.ts

    done when · tick each as it passes
    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.
  4. One customer, several channels

    A person who emails and then chats is recognised as one contact.

    1. Combining two customers' histories is not recoverable.

      Files src/identity.ts

    2. A matching name is a suggestion, not a fact.

      Files src/identity.ts

    3. You will get some wrong and the customer will notice.

      Files src/merge.tstests/merge-reversal.test.ts

    done when · tick each as it passes
    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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more customer support.