Build Crisp

KINDAreplaces $45/mosaves $540/yrback to the verdict

0%0 of 20 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.

An embeddable chat widget for your site, a shared inbox where a small team answers from one place, message delivery that survives a dropped connection without losing or duplicating anything, email fallback when nobody is online, and searchable history with a retention policy.

estimated effort multi-daythe files for this build are in the project pack

RuntimeNode 22 with FastifyTransportWebSocket for live messages, with a cursor-based backfill over HTTPDatabasePostgreSQLWidgetA hand-written script under 15 KB, no frameworkEmailA transactional provider with inbound parsing

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 and the tooling.

    Get it nodejs.org, or nvm install 22. open ↗

    Verify node --version prints v22 or higher

  2. installfree

    Why Holds conversations and provides the full text search in Phase 5.

    Get it Install locally with your package manager or run it in Docker, and create a database for the project. open ↗

    Verify psql connects and SELECT version() reports 16 or higher

  3. API keyfree tiers typically cover a few thousand messages a month; inbound parsing is sometimes on a paid tier, so check before choosing

    Why Phase 4 needs both directions. Plenty of providers send well and do not parse inbound mail, which would leave the fallback half built.

    Get it Choose a provider that explicitly supports inbound parsing or inbound routing, verify a sending domain with their DNS records, and point an MX record at their inbound host. Confirm inbound support before you commit, because it is the part that varies.

    Verify A test message to your inbound address arrives at your webhook

  4. have readyroughly 10 to 15 USD a year

    Why Needed for the sending domain, the inbound MX record and the certificate.

    Get it Any registrar, then add the DNS records your email provider specifies. Propagation can take a few hours, so do this first.

    Verify dig MX for your inbound subdomain returns the provider's host

  5. have readyfree

    Why Same-origin testing hides exactly the CORS and origin problems Phase 1 exists to catch.

    Get it Any static host, or a second local server on a different port. A different port is a different origin, which is enough.

    Verify The widget loads and connects from that page

Data model

Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.

- sites(id, name, origin_allowlist, widget_key)
- contacts(id, site_id, external_id, email, name, first_seen_at, last_seen_at)
- conversations(id, site_id, contact_id, state, assignee_id, subject, created_at, last_message_at)
- messages(id, conversation_id, seq, author_kind, author_id, body, delivered_at, read_at) · seq is a per-conversation monotonic integer
- agents(id, email, name, role)
- canned_responses(id, site_id, shortcut, body)
- The seq column is the backbone: it is what a reconnecting client asks for and what makes delivery verifiable.

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://user:pass@localhost:5432/inboxYour PostgreSQL connection string.
PORTrequired3000Where the server listens. Put a reverse proxy in front for TLS.
PUBLIC_URLrequiredhttps://inbox.example.comUsed to build widget URLs and reply addresses. Wrong here means email replies go nowhere.
EMAIL_API_KEYsecretrequiredre_...Your transactional provider's dashboard.
INBOUND_DOMAINrequiredreply.example.comThe subdomain whose MX record points at your provider's inbound host.
REPLY_TOKEN_SECRETsecretrequiredgenerate-a-long-random-valueSigns the per-conversation token in reply addresses. Rotating it invalidates outstanding reply addresses.
RETENTION_DAYSrequired365Conversations older than this are deleted. Say this number in your privacy policy.

The build, in order

  1. Widget, origin control and one conversation

    A script on another origin can start a conversation, and an origin you did not authorise cannot.

    1. No framework, no build step for the widget itself. It creates a shadow root so the host page's styles cannot break it and yours cannot leak out.

      Files public/widget.js

    2. Check the Origin header against the site's allowlist during the WebSocket handshake and refuse there, before any message is processed.

      Files src/ws.ts

    3. The visitor session identifies one conversation. Write a test that asks for another conversation's messages with a valid widget key and expects a refusal.

      Files src/auth.tstest/scope.test.ts

    done when · tick each as it passes
    watch out
    • The widget key ships in public HTML on your customers' sites, so it is an identifier and not a secret. Every authorisation decision must come from the server's own allowlist, and the key must never grant reading other conversations.
  2. Delivery that survives a dropped connection

    Messages sent while a client was offline arrive exactly once when it comes back.

    1. A per-conversation counter updated in the same transaction as the insert, with a unique constraint on conversation and sequence.

      Files src/messages.tsmigrations/

    2. An endpoint that returns messages after a sequence number, capped and paginated. The client calls it on reconnect before resuming live delivery.

      Files src/backfill.ts

    3. Buffer live messages during backfill, then deduplicate by sequence when resuming. This window is where duplicates come from and it needs a test.

      Files test/reconnect.test.ts

    done when · tick each as it passes
    watch out
    • Sequencing by timestamp breaks under concurrent writes and clock skew, and two messages in the same millisecond will silently reorder or collide. Use a monotonic integer per conversation allocated by the database, and make the pair of conversation and sequence unique so a duplicate insert fails loudly.
  3. The shared inbox

    Two agents can work the same queue without both answering the same person.

    1. Sorted by last message, filtered by state and assignee. Keyboard shortcuts for assign, close and next, because this is a screen people live in.

      Files src/inbox/

    2. Ephemeral, in memory, never persisted. Presence that outlives a disconnect is worse than no presence.

      Files src/presence.ts

    3. A message from the visitor on a closed conversation sets it back to open and puts it in the queue. Test it, because it is easy to miss and it strands people.

      Files test/state.test.ts

    done when · tick each as it passes
    watch out
    • Without a visible claim, two agents will answer the same conversation within the same minute, and the customer sees two different answers. Show who is viewing and make assignment an explicit, immediate, visible action.
  4. Email fallback

    A visitor who leaves gets the answer anyway, and their reply lands back in the same conversation.

    1. Ask only when it is useful, and say why. An email field on every conversation is friction for the ones answered in thirty seconds.

      Files public/widget.js

    2. A per-conversation token in the local part of the reply address. Sign it so a guessed token cannot inject into a conversation.

      Files src/email/send.ts

    3. Route the provider's inbound webhook to the conversation named by the token, and trim quoted text and signatures so the thread stays readable.

      Files src/email/inbound.ts

    done when · tick each as it passes
    watch out
    • Threading on subject lines breaks the moment a mail client rewrites one, and matching on the sender address merges separate conversations from the same person. Put an opaque token in the reply-to address, which survives every client and is unambiguous.
  5. Search, canned replies and retention

    Finding an old conversation is fast, and old data does not accumulate forever by default.

    1. A generated tsvector column with a GIN index. Seed tens of thousands of messages before deciding it is fast.

      Files migrations/

    2. Shortcut expansion in the agent composer. Small feature, large daily saving.

      Files src/canned.ts

    3. One function that removes rows, attachments and index entries. Test by searching for the deleted phrase and expecting nothing.

      Files src/delete.tstest/delete.test.ts

    done when · tick each as it passes
    watch out
    • Deleting a conversation row without deleting its messages, attachments and search index entries leaves personal data in three places and a deletion request unfulfilled. Make deletion one operation that covers every store, and test it by searching for the deleted content afterwards.
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.