chatwoot-router 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -7,6 +7,77 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-03
11
+
12
+ ### Fixed
13
+
14
+ - End a kind's turn with resolved/snoozed only after its canned reply is confirmed to exist in Chatwoot. Failed,
15
+ unknown or malformed creation outcomes, incomplete history, and attempts without visible replies hand off to
16
+ people without sending again. A reply seen as failed/deleted in the final fresh read also hands off.
17
+ - Recognize historical public outgoing messages from the account's exact brand bot, including the former relay's
18
+ replies, without moving reply ledgers. Preserve the durable attempt/observed guard across turns and upgrades.
19
+ - Disconnecting an inbox hands its pending brand-bot leftovers to people with native bot `status=open`. The sweep
20
+ reads pending/open account conversations, including old disconnected inboxes, and retries failed handoffs.
21
+ - Release the brand bot immediately after a kind's resolved/snoozed status using its standard unassignment API;
22
+ durable retries finish failed releases. Preserve status, turn boundaries and other owners. The open sweep heals
23
+ Chatwoot webhook-failure fallbacks without scanning resolved/snoozed history.
24
+
25
+ ### Changed
26
+
27
+ - Partition routing by account and conversation, with a separate paged `Coordinator`. Each conversation owns its
28
+ queue, decisions and reply/turn guards; slow conversations no longer occupy another conversation's executor.
29
+ - Bound webhook admission and sweep child RPC waits. Persist scan progress independently of failed child
30
+ deliveries, so retries retain unavailable targets while later pages and healthy conversations continue.
31
+ - Reuse message pages only within one fresh phase, combine independent read-only preparation, and scope cached
32
+ decisions to actual redacted input and routing configuration/model. Preserve fresh side-effect checks and the
33
+ existing bounded history reads and 45-request budget; inbox-bot ownership is read fresh instead of cached.
34
+
35
+ ### Upgrade
36
+
37
+ - This minor release changes deployment bindings. Export and declare SQLite `Router` and `Coordinator`, bound as
38
+ `ROUTER` and `COORDINATOR`, and keep the five-minute reconciliation trigger. Conversation Router identities are
39
+ now `<account>:<conversation>`; account for any existing global Router guards before enabling
40
+ new owners. Keep Worker identities, namespaces/storage and immutable configuration keys.
41
+ - Deploy chatwoot-discord-relay 0.31.0 first, completing its separately authorized
42
+ [partition adoption](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-discord-relay/docs/adoption.md)
43
+ when upgrading an existing Hub. Verify the sole routing owner before connecting bots one account at a time;
44
+ health checks and webhook acknowledgements do not establish successful routing or channel delivery.
45
+ - To stop routing, disconnect bots but retain credentials and keep this router running until pending/open account
46
+ passes and durable ending retries clear all brand-bot ownership. Verify resolved/snoozed endings too, preserving
47
+ human and other-bot owners. Only then stop ingress, cron, queued alarms and in-flight work.
48
+ - Preserve permanent reply attempts, observed/deleted-reply records, unknown guards and turn boundaries. Empty
49
+ history cannot reconstruct lost guards or justify another send. The legacy relay's reply ledger is independent;
50
+ restoring old code/configuration does not transfer Router-only replies or unknown attempts. Once new effects
51
+ exist, use forward repair in the current namespaces; direct version rollback to relay 0.27 is not a safe handover
52
+ and Cloudflare rollback cannot cross Durable Object class/lifecycle changes.
53
+
54
+ ## [0.2.0] - 2026-10-03
55
+
56
+ ### Changed
57
+
58
+ - The router is each routed inbox's Chatwoot agent bot (the account's brand bot). It works on `pending` conversations:
59
+ it decides on the customer's first three messages of the bot's turn, adds the topic and kind labels, sends the kind's
60
+ canned reply as the bot, and ends the turn with the kind's status, the owner's assignment, or a handoff to people.
61
+ A person who takes, replies to, or reopens the ticket owns it; a resolved conversation the customer reopens comes
62
+ back to the bot and is decided on its new messages (a snoozed one reopens for people). After three failed attempts,
63
+ or when the kind's canned response does not exist, it hands the ticket to people. See "How it works" in the README.
64
+ - Replaces the `routing_*` conversation attributes, `startAfterConversationId`, the account webhook, `reconcile` (the
65
+ sweep lists every `pending` conversation), and `routing.snoozeUnclear`: a greeting stays with the bot until the
66
+ customer asks something or has sent three messages with text, then goes to people.
67
+
68
+ ### Added
69
+
70
+ - `routing.endpoint`: the URL of TypeSafe's System One API (default `https://api.typesafe.ai/v1/systemone`), for
71
+ example a proxy or gateway.
72
+
73
+ ### Upgrade
74
+
75
+ - Deploy chatwoot-discord-relay 0.30.0 first. Raise `subrequestBudget` to at least 45 (was 20), remove `reconcile`
76
+ and `routing.snoozeUnclear`, add `routing.botIds`, and replace `CHATWOOT_WEBHOOK_SECRETS` and
77
+ `CHATWOOT_BOT_TOKENS` with `CHATWOOT_AGENT_BOT_SECRETS` and `CHATWOOT_AGENT_BOT_TOKENS`. Then set each bot's webhook
78
+ URL to `/chatwoot/agent-bot` and connect it to the routed inboxes, one account at a time; existing `pending`
79
+ conversations become the bot's. Roll back by disconnecting the bots ([Upgrade and rollback](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/README.md#upgrade-and-rollback)).
80
+
10
81
  ## [0.1.0] - 2026-10-02
11
82
 
12
83
  ### Added
@@ -19,5 +90,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
19
90
  `routing_kind`.
20
91
  - The `chatwoot-router-store-config` command and `chatwoot-router/stored-config`, for a configuration in KV.
21
92
 
22
- [Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.1.0...HEAD
93
+ [Unreleased]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.3.0...HEAD
94
+ [0.3.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.2.0...chatwoot-router@0.3.0
95
+ [0.2.0]: https://github.com/Phala-Network/chatwoot-workers/compare/chatwoot-router@0.1.0...chatwoot-router@0.2.0
23
96
  [0.1.0]: https://github.com/Phala-Network/chatwoot-workers/releases/tag/chatwoot-router@0.1.0
package/README.md CHANGED
@@ -1,45 +1,111 @@
1
1
  # chatwoot-router
2
2
 
3
- Route Chatwoot support tickets with [TypeSafe Jev](https://docs.typesafe.ai) on Cloudflare Workers:
4
- assign owners, add topic and kind labels, and optionally send a canned response or resolve/snooze tickets.
5
- Runs independently of [chatwoot-discord-relay](https://github.com/Phala-Network/chatwoot-workers/tree/main/packages/chatwoot-discord-relay).
6
- Neither Worker calls the other: Chatwoot is their only coordination surface.
3
+ A native Chatwoot agent bot that uses [TypeSafe's System One API](https://docs.typesafe.ai) to assign owners, add topic and
4
+ kind labels, send canned responses, and resolve or snooze tickets. Runs on Cloudflare Workers independently
5
+ of [chatwoot-discord-relay](https://github.com/Phala-Network/chatwoot-workers/tree/main/packages/chatwoot-discord-relay). The Workers coordinate through Chatwoot status.
7
6
 
8
7
  ## How it works
9
8
 
10
- The router is a level-triggered reconciler running one-time automation per input version.
11
- People own assignment, labels, and status after those effects, just as with a Chatwoot automation rule.
12
-
13
- - Webhooks and the all-status, newest-activity-first sweep enqueue the same deduplicated
14
- `route:<account>:<conversation>` job. Every run reads Chatwoot; event payloads are not routing inputs.
15
- The SQLite queue retries failed runs with backoff. Sweep pages resume within a window anchored to
16
- the previous pass's start, using the same pass model as the relay.
17
- - Inputs are the first three customer messages with usable, redacted text, capped at 1,600 characters.
18
- Their message ids form the memoization key. An unchanged key reuses Jev's answer; a new message
19
- entering this window gets a new answer. There are no pending/waiting/done decision states.
20
- Internal messages are filtered out; public replies are scanned within a bounded three-page read.
21
- If that read cannot fill or finish the window, no automatic status action is allowed.
22
- - Immediately before effects, a fresh read recomputes the input key. If it changed during Jev,
23
- nothing from the old plan is applied or acknowledged; the existing queue reruns the job.
24
- The answer and the fresh conversation determine applicable labels, owner, reply, and status.
25
- Non-open, blocked, pre-cutover, or differently assigned conversations receive no routing actions.
26
- An assignment to this decision's owner is eligible on retry. Empty inputs never call Jev or snooze.
27
- - Every action has an effects-ledger key: `<effect>:<account>:<conversation>:<input key>` for
28
- `assign`, `labels` (topic and kind together), and `status`; `reply:<account>:<conversation>` remains
29
- once per ticket. Recorded effects never run again for that version, even if a person unassigns or
30
- clears labels. Assignment and labels are recorded after confirmed success, or without a request when
31
- already satisfied. Failed idempotent actions retry. Reply and status attempts are recorded before
32
- sending; failed or uncertain attempts are not repeated. Confirmed kind actions carry their handled
33
- input id in that ledger entry. There is no dispatcher.
34
- - `routing_seen` projects a durable, monotonic per-conversation checkpoint: the highest customer
35
- message id observed after applicable effects complete. Persist it before synchronizing attributes,
36
- retaining greater existing values. Later bounded reads cannot lower it. `routing_handled` and
37
- `routing_kind` derive from the effects ledger, not current labels or the bounded message view.
38
- Each run writes only differences; this repairs lost attributes for any status without a repair job.
39
- Memo lookups use exact keys; ledger history uses indexed key ranges, never a whole-cache scan.
40
- - A run completes after applicable actions and attribute synchronization succeed, or after acknowledging
41
- an out-of-scope conversation. Errors retry; deleted conversations are dropped. Events during a run
42
- keep its job queued for another read. The sweep provides the same reconciliation if a webhook is lost.
9
+ Chatwoot owns the lifecycle: a conversation is the bot's while pending, and people's otherwise.
10
+
11
+ System One is TypeSafe's class of decision models, and Jev is its first model. The router calls TypeSafe's
12
+ System One API (`/v1/systemone`) with the model in `routing.model` (Jev by default). `routing.endpoint`
13
+ may point to another deployment of that API, for example a proxy or gateway.
14
+
15
+ - Each routed account has a configured brand bot. Routing is limited to inboxes linked to that exact account's
16
+ bot, discovered through `GET inboxes/{id}/agent_bot`. The wrapped `agent_bot.id` and `account_id` must match;
17
+ `agent_bot: null` or `agent_bot: {}` means unlinked. Foreign-account and system bots are rejected. The user token
18
+ must see every routed inbox. Disconnecting an inbox stops classification/replies and hands any pending ticket still assigned to this bot to people. The API does not expose whether the
19
+ association is inactive; disconnect to disable it. `routing.botIds` and both bot credential maps require
20
+ exactly the routed account keys (see [Configuration reference](#configuration-reference)).
21
+ - Each account+conversation has its own SQLite `Router` Durable Object: its queue, memo, reply records and turn
22
+ guards have one writer. A slow conversation cannot occupy another conversation’s alarm. A separate `Coordinator`
23
+ Durable Object pages the account lists and durably enqueues each conversation in its own object.
24
+ - Signed bot webhooks at `/chatwoot/agent-bot` enqueue a deduplicated conversation job. The five-minute sweep
25
+ lists only **pending and open conversations in the account**, including disconnected inboxes, without an age
26
+ cutoff. Pending tickets route or hand off; open tickets still assigned to the brand bot release that assignment
27
+ with `POST assignments {assignee_id: null}`, preserving status after a fresh ownership read. Resolved/snoozed
28
+ history is not scanned. Each status has its own persistent page cursor within the request budget. Failed pages
29
+ retry. An empty page ends that status's pass; the next starts at page 1 and catches
30
+ conversations skipped by changing pages. Neither event payloads nor sweep rows are decision inputs.
31
+ - Read messages newest-first, unfiltered (including activities), with `before` paging, at most five pages of 20.
32
+ The latest `conversation_status_changed` activity begins the turn; without one or evidence it is missing, use
33
+ the conversation's start. Take the first three usable customer texts after the boundary, oldest first. Include
34
+ email subjects and reply text without quoted history; omit automatic email, deleted messages and private notes.
35
+ Redact identifiers and contact names, then cap the input at 1,600 characters. Memoize Jev's decision by account, conversation, input ids, actual redacted input and routing configuration/model.
36
+ - Status activity is asynchronous. The queue's boundary guard remembers an observed/expected transition and the
37
+ last boundary needed to reject stale handoff work. An activity read before its first status webhook can match
38
+ that webhook's status and time only if no existing expectation requires a newer activity. Merging a webhook
39
+ never lowers or removes an expected boundary id. Once matched, a different transition requires an activity
40
+ **after that boundary id**, even when the old activity has the same status and second-level timestamp. Repeated
41
+ delivery of the same transition timestamp does not start another expectation. Only a status webhook supplies a transition time;
42
+ a read observing pending end requires a newer activity without inferring a time from `updated_at`. The last
43
+ pending observation survives retries, so a retry confirming resolution after a lost response retains that boundary.
44
+ Other non-pending snapshots only release a remaining brand-bot assignment: metadata updates change `updated_at` without creating a status activity.
45
+ An expected activity not yet present retries normally; an incomplete read or a deleted known boundary hands off.
46
+ Permanently missing activity uses the same three-attempt limit. A new boundary clears the previous turn's failure
47
+ count and handoff. Chatwoot exposes no turn API: a lost webhook plus permanently removed, never-observed activity
48
+ cannot be reconstructed. Chatwoot writes activity only for a changed status with activity content; no-op status
49
+ changes create none. Activities use job execution time, and customer/scheduled reopen usually creates none.
50
+ A late activity ordered after new text does not license reading old text across the boundary; empty input hands off.
51
+ - Jev chooses owner, topic, kind and whether there is a request. A confident kind takes precedence; otherwise a
52
+ confident greeting/no-request stays pending while fewer than three texts exist. Empty or identifier-only input,
53
+ three greetings, an unclear owner with an actual request, or a public human reply hands off. Blocked contacts
54
+ and person-assigned conversations are untouched during routing; disconnect handoff also clears blocked bot leftovers.
55
+ Bot and user assignees are distinguished by `assignee_type`.
56
+ - Before **each** action, re-read the inbox link, pending status, assignee, turn boundary, inputs and public human
57
+ replies. A changed input defers the job to decide again. Apply topic/kind labels, then the kind's canned reply,
58
+ then end the turn: set the kind's resolved/snoozed status and immediately release the bot, assign a confident
59
+ owner, or use explicit bot `status=open` handoff. Chatwoot keeps the bot on resolve/snooze, so release is part of
60
+ that same ending, with a fresh ownership/status read. A failed release keeps the durable job for retry, even
61
+ though the sweep excludes closed history. Clearing only the bot creates no assignment/status activity and does
62
+ not move the turn boundary; Chatwoot still dispatches assignment/update events. Preserve human topic labels and
63
+ validate that an owner still belongs to the account.
64
+ Successful person assignment ends the turn without a bot-handoff reporting event; nothing follows it.
65
+ Reopened resolved conversations can lack a bot assignee, in which case assignment would not open
66
+ pending: use native handoff instead. Chatwoot has no atomic compare-and-write API for a change racing a mutation.
67
+ - Only canned replies have an action record, `reply:<account>:<conversation>`, kept across turns and upgrades.
68
+ Before sending, read up to five unfiltered history pages (100 messages), past turn boundaries, for a public outgoing
69
+ message (`message_type=1`, `private=false`, `sender.type=agent_bot`) from this account's exact configured bot id.
70
+ A confirmed, non-deleted, non-failed reply counts as replied, including replies the old relay sent; preserve an
71
+ observed record so later removal cannot permit a resend. A complete read to the beginning with none permits the
72
+ first attempt; an incomplete/failed/ambiguous read or an existing attempt without a visible reply hands off.
73
+ Record the attempt **before sending**. A failed POST, lost response, malformed receipt or failed/deleted reply
74
+ persists handoff and never resolves/snoozes or resends. Only a valid creation receipt for this conversation or
75
+ confirmed history permits the kind's ending status. This confirms creation in Chatwoot, not channel delivery;
76
+ a delivery failure seen in the final fresh read also hands off. Labels, assignment and status use
77
+ current Chatwoot state, without an effects ledger or custom coordination attributes. A missing canned response
78
+ hands off immediately. After three processing failures (initial, +5s, +10s), persist handoff mode. Further retries
79
+ only revalidate the turn and hand off, with backoff capped at 30 minutes. Handoff failures keep the job; deleted
80
+ conversations end it. Disconnected brand-bot pending tickets use native bot `status=open` handoff without Jev
81
+ or a turn-history read; failures retry until the handoff succeeds. Other bots and people are untouched. Credentials/service failures need repair before handoff can succeed.
82
+ - A Worker 2xx means the job is durable, not that routing succeeded. Chatwoot's own webhook failure fallback cannot
83
+ cover later alarm failures. Its fallback opens pending on failed `message_created`/`message_updated` delivery unless
84
+ `keep_pending_on_bot_failure` is enabled; it may leave a bot assignee on open. The router releases that assignment on its next job or sweep.
85
+ Chatwoot makes three delivery attempts for 429/500 responses. Irrelevant valid bot events are acknowledged.
86
+ There is no router account-webhook endpoint.
87
+
88
+ After handoff, later customer messages belong to people. Resolved tickets reopen pending in an active bot inbox
89
+ and are decided on their **new turn's** messages. Snoozed tickets reopen open and go to people. A canned reply
90
+ following a customer message counts as answering it in the relay, even if that message was outside Jev's window.
91
+
92
+ The API contracts were checked against Chatwoot v4.18.0:
93
+ [assignment service](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/services/conversations/assignment_service.rb)
94
+ and [controller](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/controllers/api/v1/accounts/conversations/assignments_controller.rb),
95
+ [status changes](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/controllers/api/v1/accounts/conversations_controller.rb),
96
+ [activity creation](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/models/concerns/activity_message_handler.rb)
97
+ and its [job](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/jobs/conversations/activity_message_job.rb),
98
+ [message paging](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/finders/message_finder.rb),
99
+ [message creation](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/builders/messages/message_builder.rb),
100
+ [message JSON](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/views/api/v1/models/_message.json.jbuilder),
101
+ [bot sender](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/models/agent_bot.rb),
102
+ [account access](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/controllers/concerns/ensure_current_account_helper.rb),
103
+ [conversation access](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/policies/conversation_policy.rb),
104
+ [inbox bot response](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/views/api/v1/accounts/inboxes/agent_bot.json.jbuilder),
105
+ [assignees/timestamps](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/presenters/conversations/event_data_presenter.rb),
106
+ [token permissions](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/controllers/concerns/access_token_auth_helper.rb),
107
+ [bot events](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/app/listeners/agent_bot_listener.rb), and
108
+ [webhook fallback](https://raw.githubusercontent.com/chatwoot/chatwoot/v4.18.0/lib/webhooks/trigger.rb).
43
109
 
44
110
  ## Deploy
45
111
 
@@ -57,7 +123,7 @@ npx cf deploy --prebuilt --dry-run
57
123
  Edit `cloudflare.config.ts` with your non-secret settings. Keep required secrets out of source control;
58
124
  `.dev.vars.example` lists placeholders. For local development copy it to `.dev.vars` and use `npm run dev`.
59
125
  A maintainer deploys with `npx cf deploy --secrets-file <private-file>`.
60
- The default Worker name is `chatwoot-router`; it exports one SQLite Durable Object, `Router`, bound as `ROUTER`.
126
+ The default Worker name is `chatwoot-router`; it exports SQLite Durable Objects `Router` (bound as `ROUTER`) and `Coordinator` (bound as `COORDINATOR`).
61
127
  Keep the `*/5 * * * *` cron trigger for reconciliation. `GET /healthz` returns 200 for a valid, readable
62
128
  configuration and 503 otherwise; it does not test upstream credentials or connectivity.
63
129
 
@@ -65,12 +131,12 @@ For a private deployment repository, depend on the public `chatwoot-router` npm
65
131
  `@cloudflare/vite-plugin`. Its Worker entry is:
66
132
 
67
133
  ```ts
68
- export { default, Router } from "chatwoot-router";
134
+ export { default, Router, Coordinator } from "chatwoot-router";
69
135
  ```
70
136
 
71
137
  Use the same `cloudflare.config.ts` and `vite.config.ts` structure as this package. Pin compatible tooling versions
72
- from the workspace and root manifests. Declare `Router` with `exports.durableObject({ storage: "sqlite" })`, bind it as
73
- `ROUTER`, declare required secrets with `bindings.secret()`, and put `CONFIG` in `bindings.json(...)`.
138
+ from the workspace and root manifests. Declare `Router` and `Coordinator` with `exports.durableObject({ storage: "sqlite" })`, bind it as
139
+ `ROUTER` and `COORDINATOR` respectively, declare required secrets with `bindings.secret()`, and put `CONFIG` in `bindings.json(...)`.
74
140
 
75
141
  For a configuration larger than a Worker JSON binding, use immutable KV configuration instead:
76
142
 
@@ -91,38 +157,16 @@ no unpublished shared package needs installing.
91
157
 
92
158
  ## Chatwoot setup
93
159
 
94
- - `CHATWOOT_TOKEN` belongs to an agent in every routed inbox or an administrator.
95
- - In each routed account, add a signed webhook pointing to `POST /chatwoot/webhook`. Subscribe to
96
- `message_created`, `conversation_created`, `conversation_updated`, and `conversation_status_changed`.
97
- - Put that webhook's secret in `CHATWOOT_WEBHOOK_SECRETS`, keyed by account id. Webhooks use HMAC-SHA256 of
98
- `<timestamp>.<raw body>`, a ±5-minute timestamp window, and account matching. Only public incoming messages
99
- whose sender is a contact, and the relevant conversation events, queue routing; the API is re-read before acting.
100
- - Create the topic and kind labels in Chatwoot. Create the following **conversation** custom attribute definitions:
101
-
102
- | Fixed name | Type | Meaning |
103
- | --- | --- | --- |
104
- | `routing_seen` | Number | Latest customer message id observed by a completed run, including skipped tickets, written after applicable actions. The relay's sole completion signal. |
105
- | `routing_handled` | Number | Latest customer message id handled by a kind's successful reply or set-aside action. |
106
- | `routing_kind` | Text | Kind label added by the router, preserved by the relay's Manage card. |
107
-
108
- Every public incoming contact `message_created` in a routed account queues reconciliation, even for an
109
- assigned, closed, pre-cutover, or already-routed ticket. Skipped messages and no-owner decisions also advance
110
- `routing_seen`; only eligible tickets ask Jev. When a kind handles messages, `routing_handled`
111
- and `routing_kind` are included in the same update as `routing_seen`, after its reply/status/label actions.
112
-
113
- Attributes are updated through Chatwoot's `custom_attributes` API with `merge: true`. Chatwoot v4.18 implements
114
- this as read-merge-save, not an atomic merge: concurrent relay/router writes can still lose keys. Every run
115
- projects its durable `routing_seen` checkpoint and effects ledger again. This repairs assigned, resolved, and
116
- snoozed tickets too, without a separate repair job. The checkpoint retains the greatest observed customer id
117
- and existing `routing_seen` value, even after that message leaves the bounded read window. The handled id and
118
- kind label come from ledger history, independently of current labels or visible inputs. Greater existing
119
- watermarks are retained in Chatwoot; unchanged attributes are not written again.
120
- The relay similarly repairs its missing or different post URL on sync. Recovery is eventual, not a cross-Worker
121
- transaction. Failed reads and writes retry without reclassifying unchanged inputs or repeating irreversible actions.
122
- With the relay, set `router.accounts` there; its default wait is 30 seconds from each customer's message time.
123
- Assignment and status changes never release that wait; only `routing_seen` or the timeout does.
124
- The three attribute names are fixed. The relay's link attribute must not use any of them.
125
- Watermarks accept non-negative safe integers stored as numbers or numeric strings; other values count as absent.
160
+ 1. Give `CHATWOOT_TOKEN`'s user access to every routed inbox (membership or account administrator).
161
+ 2. Create a brand agent bot in each account. Set its webhook URL to `https://<router>/chatwoot/agent-bot` and put its
162
+ id in `routing.botIds`, access token in `CHATWOOT_AGENT_BOT_TOKENS`, and bot webhook secret in
163
+ `CHATWOOT_AGENT_BOT_SECRETS`. All three maps must name exactly the routed accounts. Account webhook secrets
164
+ are different credentials. Bot signatures use HMAC-SHA256 of `<timestamp>.<raw body>`, a ±5-minute window,
165
+ and account matching. Valid irrelevant events are acknowledged without actions.
166
+ 3. Create topic and kind labels and any canned responses in Chatwoot. Canned responses use their short codes and
167
+ are read at send time; customers see the brand bot's name. Chatwoot expands its normal message variables.
168
+ 4. After deploying both Workers, connect each bot to the inboxes it routes. No `routing_*` custom attributes or
169
+ definitions are needed. For the relay's Manage card, list every kind name in `router.keepLabels`.
126
170
 
127
171
  ## Configuration reference
128
172
 
@@ -132,6 +176,7 @@ Watermarks accept non-negative safe integers stored as numbers or numeric string
132
176
  {
133
177
  "chatwoot": { "baseUrl": "https://chatwoot.example.com" },
134
178
  "routing": {
179
+ "botIds": { "1": 1 },
135
180
  "accounts": {
136
181
  "1": {
137
182
  "support": { "assignee": 6, "covers": "Product support, billing, and account access." },
@@ -140,157 +185,82 @@ Watermarks accept non-negative safe integers stored as numbers or numeric string
140
185
  },
141
186
  "topics": { "billing": "Invoices and payments.", "technical": "Product troubleshooting." },
142
187
  "minConfidence": 0.7,
143
- "snoozeUnclear": true,
144
188
  "kinds": { "1": { "spam": { "covers": "Unsolicited advertising.", "status": "resolved" } } }
145
- },
146
- "startAfterConversationId": { "1": 0 }
189
+ }
147
190
  }
148
191
  ```
149
192
 
150
193
  | Setting | Type | Default | Meaning |
151
194
  | --- | --- | --- | --- |
152
195
  | `chatwoot.baseUrl` | HTTP(S) URL | required | Final Chatwoot API URL; redirects are refused. |
153
- | `startAfterConversationId` | object: routed account id → integer ≥ 0 | `{}` | Each account's conversation display id at cutover; omitted accounts default to `0`. At or below it, routing actions and Jev are skipped, but customer messages still advance `routing_seen`. |
154
- | `reconcile.lookbackSeconds` | integer ≥ 60 | `3600` | Minimum activity window for the five-minute sweep. |
155
- | `reconcile.maxCatchUpSeconds` | integer ≥ 60 | `604800` | Maximum activity window after downtime. |
156
- | `subrequestBudget` | integer 20–1000 | `45` | Per-alarm outbound budget; keep under your Workers plan's limit. |
157
- | `routing` | object | required | TypeSafe Jev owner, topic, and kind configuration. |
158
- | `routing.model` | non-empty string | `jev-1.13.0` | TypeSafe model. |
196
+ | `subrequestBudget` | integer 45–1000 | `45` | Per-alarm outbound budget; reserve 45 for a bounded turn read and all actions. |
197
+ | `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. |
198
+ | `routing.model` | non-empty string | `jev-1.13.0` | Model sent to TypeSafe's System One API; Jev by default. |
159
199
  | `routing.minConfidence` | number 0.5–1 | `0.7` | Probability an answer needs before it is applied. |
160
- | `routing.snoozeUnclear` | boolean | `false` | Snooze a ticket with no clear owner in which the customer asked for nothing yet (a greeting, a test) until their next message. |
200
+ | `routing.botIds` | object: account id → positive safe integer | required | Brand bot id for every routed account, and no others. |
161
201
  | `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. |
162
202
  | `routing.accounts.<id>.<name>.assignee` | integer > 0 | required | Chatwoot user id to assign. |
163
203
  | `routing.accounts.<id>.<name>.covers` | 1–1000 characters | required | What the owner handles: Jev's criterion for choosing them. |
164
204
  | `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. |
165
- | `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 ([Routing behavior](#routing-behavior)). Kind names are the account's label names (1–40 lower-case letters, digits, `_`, or `-`); `none` is reserved. Unset: none. |
205
+ | `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](#how-it-works)). Kind names are the account's label names (1–40 lower-case letters, digits, `_`, or `-`); `none` is reserved. Unset: none. |
166
206
  | `routing.kinds.<id>.<name>.covers` | 1–1000 characters | required | What the kind is: Jev's criterion for recognizing it. |
167
- | `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_BOT_TOKENS`); none while it does not exist. |
207
+ | `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. |
168
208
  | `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. |
169
209
 
170
210
  ### Secrets
171
211
 
172
212
  | Secret | Required | Meaning |
173
213
  | --- | --- | --- |
174
- | `CHATWOOT_TOKEN` | Yes | Chatwoot agent/admin access token for reading and applying decisions. |
175
- | `CHATWOOT_WEBHOOK_SECRETS` | Yes | JSON object with a webhook secret for every routed account id. Missing entries fail configuration validation and `/healthz` returns 503. |
176
- | `TYPESAFE_API_KEY` | Yes | TypeSafe Jev API key. |
177
- | `CHATWOOT_BOT_TOKENS` | For kinds that reply | JSON object of Chatwoot agent bot tokens by account id; default `{}`. |
178
-
179
- Required secrets are declared with `bindings.secret()`. Optional bot tokens are declared only in development
180
- so deployment without canned replies does not require one. Do not connect a reply-only agent bot to an inbox.
181
-
182
- ## Routing behavior
183
-
184
- The [reconciliation model](#how-it-works) defines the flow. Jev answers multiple-choice questions about the
185
- account's owner (or `unclear`), topic (with `topics`), kind (with `kinds`), and whether the customer asks for
186
- anything yet (with `snoozeUnclear`). Inputs include email subjects and the first three customer messages with
187
- usable text. Before they leave the Worker, emails, URLs, hex and base58 addresses, long tokens, phone numbers,
188
- IP addresses (and four-part version numbers, which read as one), @handles, and the contact's name (each word
189
- of two characters or more) are replaced with `[REDACTED]`. Identifier patterns run before name replacement
190
- so a name cannot split an identifier. This is best-effort redaction, not anonymization: other personal details
191
- can still reach TypeSafe. Attachment-only and redacted-only messages are acknowledged but do not consume
192
- the text window, call Jev, or cause snoozing.
193
-
194
- An owner at `minConfidence` or above is assigned unless the kind sets the ticket aside. A confident topic is
195
- added when there are no labels other than kinds; labels set by an automation rule or a person are kept.
196
- New messages entering the three-text-message window get a new memoized answer, even after an earlier
197
- assignment; later messages outside that window do not change it. When no owner fits, the ticket is left for
198
- a person. With `snoozeUnclear`, a confident `no request` answer can snooze an unassigned ticket until a
199
- customer writes again, but not once all three inputs have been used. Reopening with the same inputs never
200
- repeats that status action. A new window can apply a new status action. Before effects, the run re-reads the
201
- input window and compares its key. If a new message entered, the existing queue job is deferred immediately,
202
- without actions or acknowledgment of the old plan. The next run decides the new inputs. Messages outside a
203
- full window do not change the key. Chatwoot provides no atomic snapshot across message reads and actions.
204
-
205
- Router message reads set `filter_internal_messages=true`: Chatwoot v4.18's controller passes it to
206
- `MessageFinder`, which filters private notes and activity lines before paging. The generated OpenAPI schema
207
- omits it, so the shared typed client extends the query type; relay reads are unchanged. The forward scan is
208
- bounded to 300 public messages, including agent replies. If it cannot fill or finish the input window, labels,
209
- assignment, and a kind reply can still apply, but no status action does; the ticket stays for a person.
210
-
211
- Immediately before actions a fresh read must show an open ticket, with an unblocked contact, unassigned or
212
- assigned to this answer's owner. Otherwise only coordination attributes are synchronized. Routing resumes
213
- after reopening using memoized inputs and the effects ledger, not a saved decision state. Recorded assignment,
214
- label, and status effects are never repeated for that input version: a person unassigning or clearing labels
215
- via Manage has the final say. A new input version permits new effects. Actions run in order: assignment/labels,
216
- reply, status, then the checkpoint and attributes. Lost assignment/label responses are recovered by observing
217
- Chatwoot and recording an already-satisfied effect without another request. Routing uses `CHATWOOT_TOKEN`,
218
- whose user must be an agent in the routed inboxes; Chatwoot records the assignment as made by that user.
219
-
220
-
221
- With `kinds`, Jev is also asked which of the account's kinds the ticket is (or `none`). Kinds are
222
- labels of a second family: a ticket has one topic label, the category, and a kind Jev is confident
223
- about is added beside it (create each kind as a label in its account; it needs no forum tag, and
224
- the card shows it). It also acts with the decision: a kind with a `status` (spam, for example) sets the
225
- ticket aside, resolved or snoozed until the customer's next message, instead of routing it; the
226
- contact is not blocked, so a new message reopens the ticket as usual. A kind with a `cannedResponse` sends that
227
- Chatwoot canned response (Settings → Canned Responses, by its short code) to the customer, for
228
- example to acknowledge an application or point a security report to its process; a kind with both
229
- replies, then sets the ticket aside (a templated security report: acknowledged, then resolved). It is read when
230
- it is sent, so it is edited in Chatwoot, can use Chatwoot's variables such as `{{contact.name}}`,
231
- and a missing response leaves the job retrying without recording a send attempt. It is sent as the account's
232
- Chatwoot agent bot (`CHATWOOT_BOT_TOKENS`): customers see the bot's name, such as "Acme Support"; a
233
- bot's message assigns nobody and is no human first reply, and Chatwoot then counts the customer as
234
- answered (no longer waiting) until they write again. Create the bot in the account (Settings →
235
- Bots), without connecting it to an inbox. A reply goes out at most once per ticket: it is recorded
236
- before it is sent, so a failed send is not retried. Once a kind replied or set the ticket aside, the
237
- customer messages it handled (those Jev was given) are relayed without calling the triage bot, with a
238
- note, through the coordination attributes below. The relay waits up to its configured deadline;
239
- if routing is unavailable or slower than that, the message proceeds with its usual mention. Rules that need no judgement of the text (by inbox, sender, or
240
- subject) are Chatwoot's automation rules.
241
-
242
- ```jsonc
243
- "routing": {
244
- "accounts": {
245
- "1": {
246
- "cloud": { "assignee": 6, "covers": "Cloud support and billing: deployments, invoices, account access." },
247
- "sales": { "assignee": 7, "covers": "Sales and partnerships: pricing, capacity, volume deals." }
248
- }
249
- },
250
- "topics": { "technical-support": "Something does not work.", "billing": "Payments, invoices, refunds." },
251
- "kinds": {
252
- "1": {
253
- "spam": { "covers": "Unsolicited promotion or scams.", "status": "resolved" },
254
- "startup-program": { "covers": "A Startup Program application.", "cannedResponse": "startup-program" }
255
- }
256
- }
257
- }
258
- ```
259
-
260
-
261
- ## Reliability and cutover
262
-
263
- The Worker acknowledges after a SQLite job is queued. Alarms serialize decisions, preserve work across failures,
264
- and retry with exponential backoff capped at 30 minutes. The subrequest budget reserves 15 requests for the
265
- worst-case route and yields to a fresh invocation before starting work it cannot finish.
266
- Chatwoot's conversation-not-found response (JSON HTTP 404) is logged and the job is dropped, including a deletion
267
- during a message read or write. Other failures, including proxy errors and account-level API failures, keep their backoff.
268
- A sweep every five minutes pages **all statuses**, newest activity first, using the same shared Chatwoot client
269
- and pass-window/cursor logic as the relay. It queues the same route job for every listed conversation,
270
- regardless of status or assignment; reconciliation includes attribute repair.
271
- Closing a ticket does not remove it from this list. Activity can move it to the front, so it may be seen again;
272
- the next window starts from the previous pass's **start**, with a 60-second overlap, bounded by
273
- `reconcile.lookbackSeconds` and `reconcile.maxCatchUpSeconds`.
274
- The persisted page cursor survives budget yields and failed-page backoff. Route jobs run between
275
- pages; a failed sweep does not hold due jobs in its own account or any other account.
276
- Memoized answers, effects-ledger entries, and checkpoints remain in the Router's own Durable Object without expiry.
277
-
278
- When splitting an existing relay deployment, **deploy the new relay first**, removing its old `routing` setting and
279
- routing secrets and adding `router.accounts`. Keep its existing Worker `name` and `Hub` export to preserve state.
280
- Then find the newest Chatwoot conversation display id **in each routed account** at cutover and record the map in
281
- `startAfterConversationId`, for example `{ "1": 1200, "2": 85 }`. Deploy the router with the old routing configuration and its secrets, then add its
282
- Chatwoot webhook. Do not start the router while the old embedded routing is active. The cutover watermark
283
- intentionally leaves pre-cutover conversations to humans; old relay routing decisions are not imported. New
284
- customer messages on those tickets are still acknowledged with `routing_seen`. Set the relay's `router.keepLabels`
285
- (for example `["spam", "security", "beg-bounty"]`) to keep legacy kind labels even without `routing_kind`.
286
-
287
- Chatwoot conversation display ids are account-local: never use one account's id as another account's cutoff.
288
- The map accepts only accounts in `routing.accounts`, with non-negative integer ids. An omitted account defaults
289
- to `0`, so include every account whose pre-cutover history must be left alone. In the example, account 1 starts
290
- at conversation 1201 and account 2 at 86, independently of the other account's counter.
291
-
292
- Redaction is best effort, not anonymization: other personal information can still reach TypeSafe. Review its
293
- policy before enabling routing. Logs contain ids and outcomes, never message bodies or credentials.
294
- See [SECURITY.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/SECURITY.md) to report vulnerabilities,
295
- [CONTRIBUTING.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/CONTRIBUTING.md) for development,
296
- and [CHANGELOG.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/CHANGELOG.md) for releases. Licensed under [MIT](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/LICENSE).
214
+ | `CHATWOOT_TOKEN` | Yes | User token for messages, canned responses, inbox discovery and sweep. Must see every routed inbox. |
215
+ | `CHATWOOT_AGENT_BOT_TOKENS` | Yes | JSON object of bot access tokens by account id, e.g. `{"1":"<token>"}`. All mutations use the bot token. |
216
+ | `CHATWOOT_AGENT_BOT_SECRETS` | Yes | JSON object of bot webhook secrets by account id, e.g. `{"1":"<secret>"}`. |
217
+ | `TYPESAFE_API_KEY` | Yes | TypeSafe API key for System One API requests. |
218
+
219
+ Both bot-secret maps require nonempty values and exactly the `routing.accounts` keys. Missing/invalid entries
220
+ fail startup; `/healthz` returns 503 without credentials in its response. All secrets use `bindings.secret()`.
221
+
222
+ ## Upgrade and rollback
223
+
224
+ 0.2.x was never deployed, so the conversation partition and coordinator require no state migration.
225
+
226
+ Keep Worker names, Durable Object namespaces/storage and old KV configuration keys. The Router DO holds
227
+ permanent reply attempts/observed records and turn guards as well as jobs and decisions. Message history cannot
228
+ rebuild an unknown attempt, an observed reply since deleted, or a missing turn boundary. Do not delete or recreate
229
+ any conversation’s Router DO during rollback. If its state is lost or restored to an older recovery point, keep bots disconnected
230
+ and isolate conversations with uncertain attempts/turns for owner review; a currently empty history does not
231
+ prove another send is safe. Existing pending conversations are reconciled; non-pending conversations stay with people. The user token must see all relevant inboxes.
232
+
233
+ For the first move from the relay's built-in routing (production baseline 0.27.0):
234
+
235
+ 1. Record the live relay deployment/version id and CONFIG_KEY before rollout; retain its Hub and old secrets.
236
+ Disconnect all brand bots and prevent competing old deployments. Verify bot identities, owner membership,
237
+ labels, canned responses and inbox visibility. Historical confirmed brand-bot replies need no ledger migration.
238
+ 2. Deploy and verify relay 0.30 first. Remove `routing`, `router.accounts` and `router.waitSeconds`; keep every kind
239
+ in `router.keepLabels`. Verify the actual version and that built-in routing/in-flight old work stopped.
240
+ 3. Bootstrap this Worker by hand with all four secrets in a protected secrets file while bots remain disconnected.
241
+ Verify KV/domain, upstream permissions and test tickets; health/2xx alone do not prove routing success.
242
+ If using an infra PR whose push deploys both Workers independently, merge only after both hand deployments pass.
243
+ 4. Connect bots one account at a time and verify real routing, handoff, failed/unknown reply handling, historical
244
+ reply deduplication and Discord/triage behavior, including at least a complete sweep.
245
+ 5. To roll back, disconnect bots first and **keep this router running until no pending ticket remains assigned to
246
+ a disconnected brand bot and no non-pending ticket remains assigned to that bot**. Complete pending/open
247
+ account passes and let failed ending releases finish through their durable retries: pending uses native bot
248
+ handoff, open leftovers use explicit unassignment, and kind endings release resolved/snoozed tickets in the
249
+ same turn. Confirm cleared bot ownership across statuses; preserve human/other-bot owners. Then stop
250
+ webhook entry, cron and queued/in-flight router work; stopping cron alone does not cancel DO alarms. Preserve DO
251
+ namespace and storage, including reply and turn guards. Restore the recorded live 0.27 version **and its
252
+ CONFIG_KEY**, without overlapping old/new routing. Relay 0.27 uses its original Hub's
253
+ `kind-reply:<account>:<conversation>` records; the Router uses separate `reply:<account>:<conversation>`
254
+ records. Restoring code/config does not copy Router attempts into the Hub or reset either ledger. Only replies
255
+ already recorded by 0.27 retain its own reply-once protection; Router-only replies/unknown attempts can be
256
+ attempted again by 0.27. Keep both DOs and review/isolate those tickets before restoring old routing. Rollback
257
+ does not guarantee reply-once across this switch or undo sent messages or other mutations.
258
+
259
+ Current router upgrades need no custom coordination attributes, compatibility effects or one-time lifecycle
260
+ cleanup. Older unreadable job payloads are dropped; live snapshots and the account sweep reconstruct queued work, not durable reply/turn guards.
261
+ If handoff credentials fail, repair them or let a real owner take the tickets; do not bulk-open with the integration
262
+ user token, which can assign every ticket to that user. Keep old attributes through the owner's rollback window.
263
+
264
+ Redaction is best effort, not anonymization: other personal information can still reach TypeSafe. Logs contain
265
+ ids and outcomes, never message bodies or credentials. See [SECURITY.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/SECURITY.md),
266
+ [CONTRIBUTING.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/CONTRIBUTING.md) and [CHANGELOG.md](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/CHANGELOG.md). Licensed under [MIT](https://github.com/Phala-Network/chatwoot-workers/blob/main/packages/chatwoot-router/LICENSE).