npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

chatwoot-router

v0.11.0

Published

Route Chatwoot conversations with TypeSafe Jev on Cloudflare Workers.

Readme

chatwoot-router

A native Chatwoot agent bot that uses TypeSafe's System One API to assign owners, add topic and kind labels, send canned responses, and resolve or snooze tickets. Runs on Cloudflare Workers independently of chatwoot-discord-relay. The Workers coordinate through Chatwoot status.

How it works

Chatwoot owns the lifecycle: a conversation is the bot's while it is pending, and people's otherwise. The router is each routed account's native agent bot, connected to the inboxes it should route.

  1. A new ticket in a connected inbox is pending, so Chatwoot sends its events to the router's /chatwoot/agent-bot webhook. Each conversation is routed by a Durable Object of its own, so a slow request for one conversation never delays another.
  2. In an inbox listed in routing.identifiedOnlyInboxes, a contact whose identifier is explicitly null or empty is labeled spam and resolved without Jev, a reply, an owner assignment, or a handoff to people. The usual before-handoff hook runs after the label and before resolution, then the bot is released. The rule applies again if a customer message reopens the ticket. Otherwise, the router reads the customer's first messages of this turn (redacted, at most three and 1,600 characters; the contact's email domain leads them) and asks TypeSafe's System One API (/v1/systemone, model routing.model, Jev by default; routing.endpoint may point to another deployment) for the owner, topic, kind, and whether there is a request yet.
  3. It applies the topic and kind labels, sends the kind's canned reply if it has one (at most once per conversation), then optionally calls the configured before-handoff service. The service can write context to the conversation before the turn ends: it returns success only after its write is complete. The router then ends the turn: the kind's resolved or snoozed status, the confident owner, or a handoff to people (status=open). A greeting with no request yet stays pending until the customer says more, for up to 30 minutes (an email that asks for nothing will not say more); then it is routed on. A resolved ticket that a customer message reopens keeps its owner: its kind may still end it (a thanks is resolved), otherwise it goes back to the owner.
  4. Anything uncertain hands off to people: an unclear owner, a failed or unconfirmed reply, a human replying, three failed attempts. Disconnecting an inbox's bot hands its pending tickets to people.
  5. A sweep every five minutes lists pending and open conversations, so a missed webhook is caught up and a bot assignment left on an open ticket is released.

Routing behavior has the precise rules and the Chatwoot contracts they rest on.

Deploy

Use Node 24 (24.15 or newer), npm 12.1.0, and the Cloudflare CLI cf. From this repository, run inside Docker:

npm ci
cd packages/chatwoot-router
npm run typecheck
npm test
npm run build
npx cf deploy --prebuilt --dry-run

Edit cloudflare.config.ts with your non-secret settings. Keep required secrets out of source control; .dev.vars.example lists placeholders. For local development copy it to .dev.vars and use npm run dev. A maintainer deploys with npx cf deploy --secrets-file <private-file>. The default Worker name is chatwoot-router; it exports one SQLite Durable Object, Router, bound as ROUTER. Keep the */5 * * * * cron trigger for reconciliation. GET /healthz returns 200 for a valid, readable configuration and 503 otherwise; it does not test upstream credentials or connectivity.

For a private deployment repository, depend on the public chatwoot-router npm package plus cf, Vite, and @cloudflare/vite-plugin. Its Worker entry is:

export { default, Router } from "chatwoot-router";

Use the same cloudflare.config.ts and vite.config.ts structure as this package. Pin compatible tooling versions from the workspace and root manifests. Declare Router with exports.durableObject({ storage: "sqlite" }), bind it as ROUTER, declare required secrets with bindings.secret(), and put CONFIG in bindings.json(...).

For a configuration larger than a Worker JSON binding, use immutable KV configuration instead:

npx cf kv namespaces create --title chatwoot-router-config
npx chatwoot-router-store-config config.jsonc --namespace-id <namespace-id>
import { storedConfig } from "chatwoot-router/stored-config";

Set CONFIG_STORE: bindings.kv({ id: "<namespace-id>" }) and CONFIG_KEY: bindings.text(storedConfig("config.jsonc").key) in the Worker's env, removing CONFIG. The CLI validates JSONC, uploads through the deployment project's cf, and prints the content-addressed key. Keep older keys for rollbacks. The JavaScript and type declarations in each npm tarball are self-contained; no unpublished shared package needs installing.

Chatwoot setup

  1. Give CHATWOOT_TOKEN's user access to every routed inbox (membership or account administrator).
  2. Create a brand agent bot in each account. Set its webhook URL to https://<router>/chatwoot/agent-bot and put its id in routing.botIds, access token in CHATWOOT_AGENT_BOT_TOKENS, and bot webhook secret in CHATWOOT_AGENT_BOT_SECRETS. All three maps must name exactly the routed accounts. Account webhook secrets are different credentials. Bot signatures use HMAC-SHA256 of <timestamp>.<raw body>, a ±5-minute window, and account matching. Valid irrelevant events are acknowledged without actions.
  3. Create topic and kind labels and any canned responses in Chatwoot. Canned responses use their short codes and are read at send time; customers see the brand bot's name. Chatwoot expands its normal message variables. Create the spam label in each account with an inbox listed in routing.identifiedOnlyInboxes, even if no spam kind is configured.
  4. After deploying both Workers, connect each bot to the inboxes it routes. No routing_* custom attributes or definitions are needed. For the relay's Manage card, list every kind name in router.keepLabels.

Configuration reference

CONFIG accepts a JSON object or JSON string, or use CONFIG_STORE + CONFIG_KEY instead. Do not set both.

{
  "chatwoot": { "baseUrl": "https://chatwoot.example.com" },
  "routing": {
    "botIds": { "1": 1 },
    "accounts": {
      "1": {
        "support": { "assignee": 6, "covers": "Product support, billing, and account access." },
        "sales": { "assignee": 7, "covers": "Sales questions and purchase enquiries." }
      }
    },
    "topics": { "billing": "Invoices and payments.", "technical": "Product troubleshooting." },
    "minConfidence": 0.7,
    "beforeHandoff": { "url": "https://service.example.com/router/before-handoff", "timeoutSeconds": 20 },
    "kinds": { "1": { "spam": { "covers": "Unsolicited advertising.", "status": "resolved" } } }
  }
}

| Setting | Type | Default | Meaning | | --- | --- | --- | --- | | chatwoot.baseUrl | HTTP(S) URL | required | Final Chatwoot API URL; redirects are refused. | | subrequestBudget | integer 45–1000 | 45 | Per-alarm outbound budget; reserve 45 for a bounded turn read and all actions. | | routing.endpoint | HTTPS URL | https://api.typesafe.ai/v1/systemone | TypeSafe's System One API endpoint; may point to another deployment of that API, for example a proxy or gateway. Redirects are refused. | | routing.model | non-empty string | jev-1.13.0 | Model sent to TypeSafe's System One API; Jev by default. | | routing.minConfidence | number 0.5–1 | 0.7 | Probability an answer needs before it is applied. | | routing.identifiedOnlyInboxes | array of positive safe integer inbox ids | unset | Instance-wide Chatwoot inbox ids that require an identified contact, e.g. [2, 3, 5] for signed-in-only widgets. An explicit null or empty contact identifier is labeled spam and resolved before Jev, without a reply or assignment; this also applies after a customer reopens the ticket. Unset or []: unchanged routing. | | routing.noRequest | 1–1000 characters | a greeting, a test, contact details alone, or a few words that ask for nothing | Jev's criterion for a ticket with no request yet, which waits for the customer to say more (How it works). | | routing.beforeHandoff | object | unset | Optional service call completed before the router ends a pending bot turn. | | routing.beforeHandoff.url | HTTPS URL | required when configured | Endpoint that writes any context needed before handoff. | | routing.beforeHandoff.timeoutSeconds | positive integer | 20 | Maximum time for the endpoint request, including its response body. | | routing.botIds | object: account id → positive safe integer | required | Brand bot id for every routed account, and no others. | | routing.accounts | object: account id → (owner name → owner) | required | Routed accounts and the owners Jev chooses from. Owner names are 1–40 lower-case letters, digits, or _; unclear is reserved. | | routing.accounts.<id>.<name>.assignee | integer > 0 | required | Chatwoot user id to assign. | | routing.accounts.<id>.<name>.covers | 1–1000 characters | required | What the owner handles: Jev's criterion for choosing them. | | routing.topics | object: label → what it covers | unset | Topic labels (Chatwoot label names, lower case) Jev chooses from; one is added when a ticket has no label other than its kinds (an automation rule's label is kept alone). The separate relay can display them using its forumTags configuration. Unset: no topic. | | routing.kinds | object: account id → (kind name → kind) | unset | Kinds of ticket Jev recognizes in routed accounts, added as labels beside the topic, and what is done once when it does (How it works). Kind names are the account's label names (1–40 lower-case letters, digits, _, or -); none is reserved. Unset: none. | | routing.kinds.<id>.<name>.covers | 1–1000 characters | required | What the kind is: Jev's criterion for recognizing it. | | routing.kinds.<id>.<name>.cannedResponse | short code | unset | The account's Chatwoot canned response sent to the customer once, by the account's agent bot (CHATWOOT_AGENT_BOT_TOKENS); handoff if it is missing. | | routing.kinds.<id>.<name>.status | resolved or snoozed | unset | Set instead of routing the ticket (snoozed: until the customer's next message), after the reply of a cannedResponse; a new customer message reopens it. | | routing.kinds.<id>.<name>.label | boolean | true | Whether the ticket gets the kind's label; false for a kind that is not a kind of ticket, such as a thank-you that is only resolved. |

The identified-only check uses meta.sender.identifier from the live conversation the router already fetches. Missing or malformed identity data is logged and follows normal routing; a failed conversation fetch keeps the existing retry without changing the ticket. The rule does not require a configured spam kind and always adds spam and resolves, without using any kind's canned response. Enable it only for inboxes whose legitimate contacts are always identified; it checks the identifier, not HMAC verification.

Secrets

| Secret | Required | Meaning | | --- | --- | --- | | CHATWOOT_TOKEN | Yes | User token for messages, canned responses, inbox discovery and sweep. Must see every routed inbox. | | CHATWOOT_AGENT_BOT_TOKENS | Yes | JSON object of bot access tokens by account id, e.g. {"1":"<token>"}. All mutations use the bot token. | | CHATWOOT_AGENT_BOT_SECRETS | Yes | JSON object of bot webhook secrets by account id, e.g. {"1":"<secret>"}. | | TYPESAFE_API_KEY | Yes | TypeSafe API key for System One API requests. | | BEFORE_HANDOFF_SECRET | When routing.beforeHandoff.url is set | At least 32 characters; signs requests to that endpoint. |

Both bot-secret maps require nonempty values and exactly the routing.accounts keys. Missing/invalid entries fail startup; /healthz returns 503 without credentials in its response. All secrets use bindings.secret().

Before-handoff hook

When routing.beforeHandoff is configured, the router calls its HTTPS endpoint once before each successful pending-turn release. This covers a handoff to people (status=open), a kind's resolved or snoozed status, an owner assignment, and the open mutation used when the inbox bot is disconnected. A person or another bot taking over a ticket, and the unassignment of a bot from an already non-pending conversation, do not end a pending bot turn and do not call the hook.

The request is a POST with this JSON body. turnId is the status-activity boundary returned by the router's turn read; it is 0 for the first turn, remains stable when that turn is retried, and changes when a conversation is reopened. channel and hmacVerified are null when Chatwoot has no value; hmacVerified otherwise carries Chatwoot's boolean contact-inbox verification result.

{
  "accountId": 42,
  "conversationId": 9001,
  "turnId": 17,
  "contact": { "email": "[email protected]", "blocked": false },
  "channel": "Channel::WebWidget",
  "hmacVerified": true
}

The exact UTF-8 body bytes are signed with HMAC-SHA256 using BEFORE_HANDOFF_SECRET and the Unix timestamp: HMAC-SHA256(secret, "<x-timestamp>.<raw body>"). Headers are:

x-timestamp: 1760000000
x-signature: sha256=<64 lowercase hexadecimal characters>
content-type: application/json

For example, a receiver can verify and handle a request with the same raw body before returning success:

body='{"accountId":42,"conversationId":9001,"turnId":17,"contact":{"email":"[email protected]","blocked":false},"channel":"Channel::WebWidget","hmacVerified":true}'
timestamp="$(date +%s)"
signature="$(printf '%s.%s' "$timestamp" "$body" | openssl dgst -sha256 -hmac "$BEFORE_HANDOFF_SECRET" -hex | sed 's/^.* //')"
curl --fail-with-body -X POST "https://service.example.com/router/before-handoff" \
  -H "content-type: application/json" -H "x-timestamp: $timestamp" -H "x-signature: sha256=$signature" \
  --data "$body"

The receiver must be idempotent for (accountId, conversationId, turnId) because retries can repeat a request after a timeout or network failure. It should return a 2xx response only after all context it adds for that turn is written (or when nothing is due). A non-2xx response, timeout, or network error is logged without credentials or request body and is raised by the router; the pending ticket then remains pending for the existing Durable Object retry and sweep.

Upgrade and rollback

Keep Worker names, Durable Object namespaces/storage and old KV configuration keys. The Router DO holds permanent reply attempts/observed records and turn guards as well as jobs and decisions. Message history cannot rebuild an unknown attempt, an observed reply since deleted, or a missing turn boundary. Do not delete or recreate the Router DO during rollback. If its state is lost or restored to an older recovery point, keep bots disconnected and isolate conversations with uncertain attempts/turns for owner review; a currently empty history does not prove another send is safe. Existing pending conversations are reconciled; non-pending conversations stay with people. The user token must see all relevant inboxes.

Redaction is best effort, not anonymization: other personal information can still reach TypeSafe, and the contact's email domain is sent on purpose (never the address or name). Logs contain ids and outcomes, never message bodies or credentials. See SECURITY.md, CONTRIBUTING.md and CHANGELOG.md. Licensed under MIT.