chatwoot-router
v0.11.0
Published
Route Chatwoot conversations with TypeSafe Jev on Cloudflare Workers.
Maintainers
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.
- A new ticket in a connected inbox is pending, so Chatwoot sends its events to the router's
/chatwoot/agent-botwebhook. Each conversation is routed by a Durable Object of its own, so a slow request for one conversation never delays another. - In an inbox listed in
routing.identifiedOnlyInboxes, a contact whose identifier is explicitly null or empty is labeledspamand 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, modelrouting.model, Jev by default;routing.endpointmay point to another deployment) for the owner, topic, kind, and whether there is a request yet. - 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. - 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.
- 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-runEdit 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
- Give
CHATWOOT_TOKEN's user access to every routed inbox (membership or account administrator). - Create a brand agent bot in each account. Set its webhook URL to
https://<router>/chatwoot/agent-botand put its id inrouting.botIds, access token inCHATWOOT_AGENT_BOT_TOKENS, and bot webhook secret inCHATWOOT_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. - 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
spamlabel in each account with an inbox listed inrouting.identifiedOnlyInboxes, even if no spam kind is configured. - 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 inrouter.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/jsonFor 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.
