support_desk 0.2.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.
Files changed (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +998 -14
  4. data/app/assets/stylesheets/support_desk.css +10 -0
  5. data/app/controllers/support_desk/tickets_controller.rb +23 -1
  6. data/app/helpers/support_desk/engine_helper.rb +16 -0
  7. data/app/views/support_desk/console/tickets/_actions.html.erb +15 -0
  8. data/app/views/support_desk/console/tickets/_assignment.html.erb +16 -2
  9. data/app/views/support_desk/console/tickets/_composer.html.erb +27 -0
  10. data/app/views/support_desk/console/tickets/_context_card.html.erb +28 -0
  11. data/app/views/support_desk/console/tickets/_draft.html.erb +114 -0
  12. data/app/views/support_desk/console/tickets/_message.html.erb +34 -1
  13. data/app/views/support_desk/console/tickets/_ticket_row.html.erb +20 -0
  14. data/app/views/support_desk/console/tickets/_timeline.html.erb +68 -14
  15. data/app/views/support_desk/console/tickets/show.html.erb +5 -0
  16. data/app/views/support_desk/tickets/_human_door.html.erb +24 -0
  17. data/app/views/support_desk/tickets/_ticket_row.html.erb +13 -0
  18. data/config/locales/support_desk.console.en.yml +63 -0
  19. data/config/locales/support_desk.console.es.yml +65 -0
  20. data/config/locales/support_desk.en.yml +15 -0
  21. data/config/locales/support_desk.es.yml +23 -0
  22. data/config/routes.rb +7 -1
  23. data/lib/generators/support_desk/assistant_generator.rb +193 -0
  24. data/lib/generators/support_desk/install_generator.rb +12 -0
  25. data/lib/generators/support_desk/templates/add_assistants_to_support_desk.rb.erb +236 -0
  26. data/lib/generators/support_desk/templates/assistant/service.rb.erb +60 -0
  27. data/lib/generators/support_desk/templates/assistant/turn_job.rb.erb +72 -0
  28. data/lib/generators/support_desk/templates/assistant/turn_job_test.rb.erb +69 -0
  29. data/lib/generators/support_desk/templates/initializer.rb +33 -0
  30. data/lib/generators/support_desk/upgrade_generator.rb +12 -2
  31. data/lib/support_desk/assistant_policy.rb +213 -0
  32. data/lib/support_desk/brief.rb +283 -0
  33. data/lib/support_desk/configuration.rb +552 -2
  34. data/lib/support_desk/console.rb +290 -8
  35. data/lib/support_desk/context_card.rb +10 -1
  36. data/lib/support_desk/doctor.rb +144 -1
  37. data/lib/support_desk/engine.rb +10 -0
  38. data/lib/support_desk/errors.rb +37 -0
  39. data/lib/support_desk/events.rb +12 -5
  40. data/lib/support_desk/macros.rb +14 -1
  41. data/lib/support_desk/models/assistant.rb +153 -0
  42. data/lib/support_desk/models/concerns/requester.rb +18 -0
  43. data/lib/support_desk/models/desk.rb +26 -3
  44. data/lib/support_desk/models/draft.rb +266 -0
  45. data/lib/support_desk/models/event.rb +15 -1
  46. data/lib/support_desk/models/ticket/assistance.rb +675 -0
  47. data/lib/support_desk/models/ticket.rb +334 -37
  48. data/lib/support_desk/outcome.rb +38 -0
  49. data/lib/support_desk/queue.rb +41 -5
  50. data/lib/support_desk/test_helpers.rb +152 -0
  51. data/lib/support_desk/timeline.rb +40 -11
  52. data/lib/support_desk/topic.rb +22 -0
  53. data/lib/support_desk/topic_tree.rb +7 -1
  54. data/lib/support_desk/transcript.rb +237 -0
  55. data/lib/support_desk/version.rb +1 -1
  56. data/lib/support_desk.rb +108 -0
  57. data/lib/tasks/support_desk.rake +46 -0
  58. metadata +30 -8
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # 🎫 `support_desk` - Customer support for your Rails app, as conversations
1
+ # 🎫 `support_desk` - Customer support for your Rails app, as conversations — answered by your team and by AI agents on a leash
2
2
 
3
3
  [![Gem Version](https://badge.fury.io/rb/support_desk.svg)](https://badge.fury.io/rb/support_desk) [![Build Status](https://github.com/rameerez/support_desk/workflows/Tests/badge.svg)](https://github.com/rameerez/support_desk/actions)
4
4
 
@@ -7,6 +7,8 @@
7
7
 
8
8
  `support_desk` gives your Rails app a **support desk**: tickets that are real conversations. Somebody asks for help about something in your app (a ride, an order, a withdrawal) or about nothing in particular, your desk answers, humans sign the answers, and your team works a queue.
9
9
 
10
+ **AI agents are first-class citizens of that desk.** An assistant is an agent with a policy: a seat, a name, a turn budget and a level (`observe · draft · reply · resolve`) that says what she may *produce* on a case. At the default level she proposes and a person sends, signed by them; raise her level per topic and she answers customers herself, hands off when she is unsure, and can never touch a case a human holds, a money topic you capped, or a customer who asked for a person. The gem ships the guardrails — policy, drafts and review, the turn that makes a late model answer harmless, disclosure, the two exits, the sweep that catches a dead harness — and **no LLM**: bring any provider, any prompt, any retrieval, in a job of about ten lines. [→ Assistants](#-assistants)
11
+
10
12
  Here is the whole thing — the kind of support desk a DoorDash, an Uber Eats or a Grab needs — running on a made-up delivery app called Pepperbox. These are the **bundled views**, unmodified, themed by the host with a handful of CSS variables:
11
13
 
12
14
  | One row, every case | Pick a topic | Which order? |
@@ -19,7 +21,19 @@ Here is the whole thing — the kind of support desk a DoorDash, an Uber Eats or
19
21
 
20
22
  It is a product gem on the [`chats`](https://github.com/rameerez/chats) kernel: chats owns the transcript, realtime, attachments, read state and moderation; `support_desk` owns the case — topics, assignment, SLA clocks, events and the console API.
21
23
 
22
- Every app eventually needs a support inbox, and everyone rebuilds the same ticket table, the same "assigned to me" tab, the same "which order is this about?" picker and the same email bridge. `support_desk` is that whole rebuild, done once, done right, on top of the messaging you already have.
24
+ Every app eventually needs a support inbox, and everyone rebuilds the same ticket table, the same "assigned to me" tab, the same "which order is this about?" picker and the same email bridge — and, lately, the same "let the model answer, but not *that*" rules. `support_desk` is that whole rebuild, done once, done right, on top of the messaging you already have.
25
+
26
+ What "AI-native" means here, concretely:
27
+
28
+ - **One verb for the harness.** `ticket.respond!(text, by: rose, turn:)` — policy decides whether it is sent, drafted for a person, or withheld with the reason on the record. The job never encodes the rules.
29
+ - **Bounded authority, in the model.** Levels, topic caps, a per-case cap, pause, and floors for closed / human-held / human-requested cases — enforced inside every transition, not in a prompt or a button.
30
+ - **A turn, not a lock-free hope.** Every message and transition moves `assistant_turn`; every assistant action requires and consumes it, so a late, retried or redelivered job writes nothing.
31
+ - **Human in the loop by default.** Proposals a person sends verbatim or edited — as *their* message — or rejects with a reason you can raise her level on.
32
+ - **Two exits, always.** She escalates; the customer has a door to a person that never disappears.
33
+ - **Disclosure is your explicit choice**, and the record tells the truth in every mode.
34
+ - **Context as data.** `ticket.brief` and `ticket.transcript` — facts, roles, `may` / `may_not` — for any provider.
35
+
36
+ **Contents:** [Example](#-example) · [Quickstart](#quickstart) · [Configuration reference](#configuration-reference) · [Topics](#topics) · [Model macros](#the-model-macros) · [Tickets](#tickets) · [Queues and presenters](#queues-and-presenters) · [The requester experience](#the-requester-experience) · [The agent console](#the-agent-console) · [Writing first](#writing-first) · [Assistants](#-assistants) · [The wizard](#the-wizard) · [Events](#events) · [Errors](#errors) · [Locales](#locales) · [Doctor](#doctor) · [Compatibility](#compatibility) · [Testing](#testing) · [Module-level API](#module-level-api)
23
37
 
24
38
  ## 👨‍💻 Example
25
39
 
@@ -42,9 +56,14 @@ ticket.reply!("We're on it", by: lucia) # you answ
42
56
  ticket.close!(by: lucia)
43
57
 
44
58
  lucia.open_support_conversation_with!(alice, "We saw your refund bounced", about: order) # you write first
59
+
60
+ config.assistant(:rose) { |rose| rose.autonomy = :draft; rose.disclosure = :signature } # an AI agent, on a leash
61
+ rose = SupportDesk.assistant(:rose)
62
+ ticket.respond!(answer_from_your_model, by: rose, turn: ticket.assistant_turn) # she proposes; policy decides
63
+ ticket.pending_draft.send!(by: lucia, seen_turn: ticket.assistant_turn) # a person sends it, signed by them
45
64
  ```
46
65
 
47
- That's a ticket, a conversation, an assignment history, an append-only audit trail and four events your app can subscribe to.
66
+ That's a ticket, a conversation, an assignment history, an append-only audit trail, an AI agent whose every action is policy-checked under the row lock, and a dozen events your app can subscribe to.
48
67
 
49
68
  ## Quickstart
50
69
 
@@ -69,6 +88,9 @@ workers, and backfill before starting 0.2 traffic. This is not a rolling
69
88
  upgrade: old assignment writers cannot handle new support-opened cases.
70
89
  See the CHANGELOG for the complete cutover and rollback procedure.
71
90
 
91
+ Already on 0.2? The same generator copies the 0.3 assistants migration,
92
+ which is additive and needs no drain — see [Assistants](#-assistants).
93
+
72
94
  Three model lines and one route line:
73
95
 
74
96
  ```ruby
@@ -110,6 +132,129 @@ Check your work any time with `SupportDesk.doctor.print`.
110
132
 
111
133
  Desk records are memoised for the life of the process, so anything that has to change everywhere at once belongs in this initializer rather than in a desk's `settings` column.
112
134
 
135
+ ## Configuration reference
136
+
137
+ Everything lives in `config/initializers/support_desk.rb` (the install
138
+ generator writes an annotated one). Two rules, shared with the rest of the
139
+ gem ecosystem: class names are stored as **strings** and constantized lazily,
140
+ so the initializer can name app classes before they load and everything
141
+ survives reloads; and every setter **validates on assignment** and raises
142
+ `SupportDesk::ConfigurationError` with the fix in the message — a
143
+ configuration mistake is a boot failure, never a 3 a.m. `NoMethodError`.
144
+
145
+ ### Installation settings
146
+
147
+ | setting | default | what it decides |
148
+ |---|---|---|
149
+ | `requester_class` | `"User"` | the model with `has_support_tickets`; it must also be a chats messager |
150
+ | `parent_controller` | `"::ApplicationController"` | what the requester-facing engine inherits: your layout, auth, helpers, locale |
151
+ | `console_parent_controller` | your admin's base controller | what the mounted `ConsoleEngine` and the generated console inherit |
152
+ | `current_requester_method` | `:current_user` | how the engine finds the person asking |
153
+ | `current_agent_method` | `:current_user` | how the console finds the person answering (or define `current_agent` in your controller) |
154
+ | `authenticate_method` | `:authenticate_user!` | your own filter, run before every requester screen |
155
+ | `visible_desks_for` | `nil` (every desk) | `->(agent) { … }` returning the desks this agent may work; scopes the whole console |
156
+ | `authorize_console` | `nil` (allow) | `->(agent, ticket, action) { … }`; asked before every console action, `ticket` is nil for `index`, `new`, `next` and `open_conversation`; a hook that raises **denies** |
157
+ | `assistant(key) { … }` | — | declare an assistant (see [Assistants](#-assistants)); `config.assistants`, `config.assistant?(key)` read them back |
158
+ | `default_assistant` | the only one declared | which assistant the `:default` desk gets when more than one exists |
159
+ | `on(event) { … }` | — | subscribe to an event (see [Events](#events)); pass `key:` from code that reloads |
160
+
161
+ ### Desk settings
162
+
163
+ Top-level setters configure the `:default` desk. Every other desk inherits
164
+ whatever it does not state:
165
+
166
+ ```ruby
167
+ config.desk :billing do |desk|
168
+ desk.name = "Billing"
169
+ desk.reply_within = 8.hours
170
+ desk.topics { topic :invoice, about: Invoice; other }
171
+ end
172
+
173
+ SupportDesk.desk # the :default Desk record, memoised for the process
174
+ SupportDesk.desk(:billing) # another one (nil if nobody configured it)
175
+ ```
176
+
177
+ | setting | default | values / meaning |
178
+ |---|---|---|
179
+ | `name` | the key, humanized | what requesters see as the counterpart |
180
+ | `avatar` | `nil` | anything `image_tag` takes, or `->(desk) { … }` |
181
+ | `email` | `nil` | the address the email channel will answer from (channel lands in a later release) |
182
+ | `agents { … }` | — | a block or lambda returning the agent pool: notified while a case is unheld, offered in the assign picker |
183
+ | `assistant` | the default one | the key of the assistant that works this desk; an explicit `nil` **disables** her here rather than inheriting (see [Assistants](#-assistants)) |
184
+ | `topics do … end` | `other` only | the topic tree (see [Topics](#topics)) |
185
+ | `reply_policy` | `:anyone` | `:anyone` (a drop-in posts, signed; an unheld case is taken by whoever answers) · `:take_over` (replying reassigns) · `:assignee_only` (raises `NotAllowed`) |
186
+ | `announce_assignments` | `:first_only` | `:first_only` ("Lucía is taking care of your request" once) · `:always` (hand-offs too) · `:never` |
187
+ | `closed_tickets` | `:reopen_on_reply` | `:reopen_on_reply` (a requester writing reopens the case) · `:locked` (composer replaced by a notice) |
188
+ | `reply_within` | `24.hours` | the SLA breach threshold **and** the promise the requester reads |
189
+ | `at_risk_after` | `4.hours` | when a waiting case starts showing as at risk |
190
+ | `open_rate_limit` | `{ to: 5, within: 1.hour }` | how often one requester may **ask**; `nil` disables. Counts only cases the requester opened |
191
+ | `max_open_tickets` | `5` | how many cases one requester may have open; `nil` disables. Same rule |
192
+ | `inbox_entry` | `:always` | when the desk shows in an inbox with no cases yet: `:always` · `:when_tickets` · `:never` |
193
+ | `routing` | `:manual` | new cases are unassigned and the first "take" wins (`:round_robin` / `:least_loaded` are reserved and refused until they ship) |
194
+ | `mirror_replies_by_email` | `:when_away` | reserved for the email channel: `:always` · `:when_away` · `:never` |
195
+ | `auto_close_after` | `nil` | reserved for the sweep job: a duration after which an answered case closes itself |
196
+ | `opening_line` | `nil` | the system line a requester-opened thread starts with (see [Writing first](#writing-first)) |
197
+ | `opening_line_from_support` | the gem's I18n copy | the same line for a case the desk opened |
198
+ | `find_requester { \|query\| … }` | `nil` | how a console turns what an agent typed into a requester record |
199
+
200
+ `SupportDesk.doctor.print` checks all of it against a running app (see
201
+ [Doctor](#doctor)).
202
+
203
+ ## Topics
204
+
205
+ Topics are a **tree defined in code** and stored on the ticket as a stable
206
+ path (`"billing/invoice"`), so they can carry behaviour — which models they
207
+ attach to, which picker, which desk — and be reviewed and versioned like
208
+ everything else. Labels are I18n keys by default, so copy is a locale edit.
209
+
210
+ ```ruby
211
+ config.topics do
212
+ topic :order, about: Order, subject: :required, icon: "package"
213
+ topic :billing, priority: :high do
214
+ topic :invoice, about: Invoice, ask: "Which invoice?"
215
+ topic :refund, prefill: "Hi, I'd like a refund for ", desk: :billing
216
+ end
217
+ topic :beta, only: ->(requester) { requester.beta? }
218
+ topic :legacy, retired: true # old cases keep their label; nobody can open a new one
219
+ other # the free-form leaf every desk should have
220
+ end
221
+ ```
222
+
223
+ | option | meaning |
224
+ |---|---|
225
+ | `about:` | the `supportable` class(es) this topic is about; the wizard offers the requester's own records |
226
+ | `subject:` | `:required` (must pick one) · `:optional` (offers "none of these") · `:none` (free-form) |
227
+ | `candidates:` | `->(requester) { … }` overriding which records the picker shows |
228
+ | `ask:`, `placeholder:`, `prefill:`, `label:` | copy, when you'd rather not use the locale keys; `prefill:` may be a `->(subject) { … }` |
229
+ | `only:` | `->(requester) { … }` — who is offered this topic in the wizard (agents may still file onto it) |
230
+ | `priority:` | `:normal` · `:high` · `:urgent` — sorts the queue |
231
+ | `desk:` / `route_to:` | send cases on this topic to another desk |
232
+ | `retired:` | hidden from the wizard, still readable on old cases |
233
+ | `icon:` | a key your views may render; the gem never does |
234
+ | `assistant:` | the most an assistant may produce on this branch (see [Assistants](#-assistants)) |
235
+
236
+ `about`, `candidates`, `desk`, `route_to`, `priority`, `only` and `retired`
237
+ are inherited down the branch; copy never is. `assistant:` is the one option
238
+ that is neither inherited nor overridden: `Topic#assistant_cap` is the
239
+ **minimum** over the node and every ancestor, so a child can only tighten
240
+ what a parent allowed. Labels come from
241
+ `support_desk.topics.<path>.label` in your locale files (`ask` and `hint`
242
+ alongside).
243
+
244
+ ```ruby
245
+ ticket.topic # a SupportDesk::Topic value object
246
+ ticket.topic.path # "billing/invoice"
247
+ ticket.topic.label # "Invoice"
248
+ ticket.topic.full_label # "Billing › Invoice"
249
+ ticket.topic.under?(:billing) # true
250
+ ticket.topic.free_form? .subject_required? .retired? .priority .about .icon .assistant_cap
251
+ SupportDesk.find_topic("billing/invoice") # across every desk; nil, never a raise
252
+ ```
253
+
254
+ Agents can refile a case (`ticket.change_topic!(to: "billing/refund", by:)`)
255
+ and point a free-form one at the record it turned out to be about
256
+ (`ticket.attach_subject!(order, by:)`); both are events.
257
+
113
258
  ## The model macros
114
259
 
115
260
  ### `has_support_tickets(desk: :default, as: nil, if: nil)`
@@ -144,6 +289,8 @@ Makes a domain record something people can ask about. Every method has a working
144
289
 
145
290
  Makes someone able to answer. Agents are never chats participants — the desk sends, the agent *authors* — so this needs no messaging setup at all. It adds `support_agent?`, `support_agent_name`, `support_agent_avatar`, `on_duty?`, `support_capacity`, `support_queue` and exactly one verb — `open_support_conversation_with!`, the only agent action with no ticket yet (see [Writing first](#writing-first)). Everywhere else the ticket is the subject of the sentence.
146
291
 
292
+ `kind:` is `:human` or `:ai`, and anything else is a boot failure. Declaring one of your own models `kind: :ai` does **not** hand it an agent's authority: every support write by it, or to it, is refused with `SupportDesk::NotAnAssistant`, and `doctor` warns about it. The only machine that may act on a case is the desk's own `SupportDesk::Assistant` — see [Assistants](#-assistants).
293
+
147
294
  ## Tickets
148
295
 
149
296
  ```ruby
@@ -169,16 +316,51 @@ ticket.attach_subject!(order, by: lucia)
169
316
 
170
317
  Every transition takes `by:` (falling back to `SupportDesk::Current.actor`), runs under the ticket's row lock, writes exactly one event row, and emits its events after the transaction commits. Repeating one that already happened returns `self` and writes nothing.
171
318
 
319
+ More of what a ticket knows:
320
+
321
+ ```ruby
322
+ ticket.opened_by ticket.opened_by_requester? ticket.opened_by_support? # who wrote first
323
+ ticket.opened_via # :in_app | :email | :intercom | :api
324
+ ticket.channels # every channel the case can be answered through
325
+ ticket.channels_summary # "in app · email", in the reader's language
326
+ ticket.requester ticket.assignee ticket.desk ticket.conversation ticket.messages
327
+ ticket.assigned_to?(lucia) ticket.about?(order) ticket.reopened? ticket.unassigned?
328
+ ticket.assignments # the history of who held it; .open for the current seat
329
+ ticket.assignment_history # the same, oldest first
330
+ ticket.events ticket.notes # the append-only timeline, and just the internal notes
331
+ ticket.waiting_since ticket.first_agent_reply_at ticket.last_requester_message_at ticket.last_agent_message_at
332
+ ticket.export # a GDPR-friendly Hash: the requester's transcript and the events they saw, never notes
333
+ ticket.notification_title # "Support · new message" — safe for a lock screen
334
+ ticket.notification_body # the label — for an authenticated feed
335
+ ticket.register!(message) # fold a chats message into the clocks by hand (imports); idempotent, and what the chats subscriber calls
336
+ ticket.desk_config # this desk's slice of the configuration
337
+
338
+ SupportDesk::Ticket.find_by_reference!("t-ab12cd") # forgives case, the prefix and Crockford lookalikes (O→0, I/L→1)
339
+ SupportDesk::Ticket.for_conversation(conversation) # the case behind a chats conversation, or nil
340
+ SupportDesk::Current.actor = lucia # the fallback for every `by:` (the console sets it per request)
341
+ ```
342
+
343
+ An **assignment** row records `agent`, `assigned_by`, `reason` (`taken`
344
+ `assigned` `handed_off` `routed` `drop_in_takeover` `escalated` `reopened`
345
+ `opened`), `note`, `assigned_at`, `released_at` and `release_reason`
346
+ (`handed_off` `released` `shift_end` `closed` `escalated`). An **event** row
347
+ has a `kind` (`opened` `assigned` `handed_off` `released` `drop_in` `closed`
348
+ `reopened` `topic_changed` `subject_attached` `note` `escalated`
349
+ `human_requested` `assistant_paused` `assistant_resumed` `draft_sent`
350
+ `draft_rejected` `assistant_withheld` — plus kinds reserved for later
351
+ releases), an `actor` and a `payload`, and is read-only once written.
352
+
172
353
  ### Scopes
173
354
 
174
355
  ```ruby
175
356
  SupportDesk::Ticket
176
357
  .open .closed .not_closed .assigned .unassigned .assigned_to(lucia)
177
358
  .awaiting_reply .awaiting_requester
359
+ .opened_by_requester .opened_by_support
178
360
  .waiting_over(4.hours) .at_risk .overdue
179
361
  .about(order) .about_any(Order) .on_topic(:billing)
180
- .for_desk(:billing) .opened_via(:email) .opened_between(range)
181
- .most_urgent_first .recent_activity_first .newest_first
362
+ .for_desk(:billing) .opened_via(:email) .opened_between(range) .closed_between(range)
363
+ .most_urgent_first .recent_activity_first .newest_first .oldest_first
182
364
  .find_by_reference("T-AB12CD")
183
365
  ```
184
366
 
@@ -188,11 +370,12 @@ Bring your own UI on the agent side. Everything the console needs is plain Ruby:
188
370
 
189
371
  ```ruby
190
372
  q = lucia.support_queue
191
- q.mine q.unassigned q.awaiting q.open q.closed # relations
192
- q.counts # { awaiting: 4, mine: 2, … } in ONE query
193
- q.badge # the nav number, cached 30s per agent
194
- q.next # the most urgent thing this agent could pick up
195
- q.tabs # [[:awaiting, "Needs a reply", 4], …]
373
+ q.mine q.unassigned q.awaiting q.open q.closed q.needs_human # relations
374
+ q.counts # { awaiting: 4, mine: 2, … } in ONE query
375
+ q.badge # the nav number, cached 30s per agent
376
+ q.next # the most urgent thing this agent could pick up
377
+ q.tabs # [[:awaiting, "Needs a reply", 4], …]
378
+ q.visible_tabs # which ones this desk has any use for
196
379
 
197
380
  ticket.context_card # title, status, the host's own context pairs, the requester
198
381
  ticket.summary # one line for a list row, Slack, or a digest
@@ -200,6 +383,19 @@ ticket.timeline # messages ⨉ events merged by time; .print in a conso
200
383
  ticket.actions_for(lucia) # exactly the buttons to render
201
384
  ```
202
385
 
386
+ ```ruby
387
+ card = ticket.context_card
388
+ card.title card.status card.pairs card.subject_url card.topic_label
389
+ card.requester_name card.requester_avatar card.requester_since card.requester_open_tickets
390
+ card.opened_by_label # "Support · Lucía G." for a case the desk opened; "not recorded" for a 0.1 row awaiting backfill
391
+ card.to_h
392
+
393
+ ticket.summary.to_s # "T-AB12CD · Order SO1 · Alice · awaiting reply (12 minutes)"
394
+ ticket.summary.state # "awaiting reply" | "awaiting requester" | "closed" | "open", translated
395
+ ticket.timeline.entries # Timeline::Entry: .kind (:message or the event kind), .at, .actor, .body
396
+ ticket.timeline.print # in a console
397
+ ```
398
+
203
399
  ## The requester experience
204
400
 
205
401
  Mounting the engine is the whole user side: four screens, ejectable views,
@@ -258,6 +454,22 @@ first would lose to it.
258
454
  Both surfaces are pushed screens, never modals: every wizard step is a real
259
455
  URL, so the back gesture and cold-boot deep links work.
260
456
 
457
+
458
+ ### Every helper the requester views can use
459
+
460
+ | helper | what it renders |
461
+ |---|---|
462
+ | `link_to_support(about:, text:, **html)` | the door into the wizard (nothing when there is nobody to ask, or nothing they may ask about) |
463
+ | `support_unread_badge` | unread support messages, counted against the chats read horizon |
464
+ | `support_desk_styles` | the bundled stylesheet tag for your `<head>` |
465
+ | `support_reply_promise` | "We usually reply in under 24 hours", from `reply_within` |
466
+ | `support_ticket_state(ticket)` | "We're on it" · "We replied" · "We wrote to you" · "Closed" |
467
+ | `support_inbox_door?(viewer)` | whether the inbox should show the door instead of a row (`inbox_entry`) |
468
+ | `support_desk_record(key)`, `support_desk_avatar`, `support_desk_badge` | the desk as a counterpart: record, avatar (initials fallback), verified badge |
469
+ | `support_thread_path(ticket)` | where a case is read: its chats conversation |
470
+ | `open_support_ticket_about(record, requester)` | the open case about a record, or nil — what makes a door lead to the existing conversation |
471
+ | `support_human_door(ticket)` | the way to a person on a case an assistant is or was on, and the status line once one has been asked for (see [Assistants](#-assistants)) |
472
+
261
473
  ### Restyling
262
474
 
263
475
  The views ship with a small bundled stylesheet and semantic classes (the list
@@ -298,8 +510,11 @@ end
298
510
  ```
299
511
 
300
512
  That adds member `reply take assign hand_off release close reopen note
301
- change_topic` and collection `next` and `open_conversation`. It has to sit
302
- inside a `resources` block, since that is what those routes hang off.
513
+ change_topic send_draft reject_draft pause_assistant resume_assistant` and
514
+ collection `next` and `open_conversation`. The last four do nothing until an
515
+ assistant is configured, and the console never offers them before that. It
516
+ has to sit inside a `resources` block, since that is what those routes hang
517
+ off.
303
518
  `new` stays yours: add it to `only:` when you render the form behind
304
519
  `open_conversation` ("Write to someone").
305
520
 
@@ -483,6 +698,56 @@ screen, an order) and says so:
483
698
  config.find_requester { |query| User.find_by(email: query.to_s.strip.downcase) }
484
699
  ```
485
700
 
701
+ #### What the compose form posts, and what comes back
702
+
703
+ The form is yours (`new.html.erb` — the generated one is a fine start); the
704
+ concern owns the request. `new` fills the draft it renders, `open_conversation`
705
+ sends it. Every field is read by name:
706
+
707
+ | param | what it is |
708
+ |---|---|
709
+ | `requester` | a GlobalID from one of your own pages — **authoritative**: a bad one is a refusal, never a fallback to the typed query |
710
+ | `requester_query` | what an agent typed, resolved by `config.find_requester` when no `requester` was supplied |
711
+ | `about` | a GlobalID of a `supportable` record; the topic comes with it |
712
+ | `topic` | a free-form topic path when there is no subject |
713
+ | `body`, `files[]` | the message: text, uploads, or Active Storage signed blob ids — text **or** an attachment is enough |
714
+ | `desk` | the desk key the agent is working; an explicit key that isn't a visible desk is refused, never silently swapped for the default |
715
+
716
+ GlobalIDs resolve only inside the classes that declared themselves
717
+ (`has_support_tickets`, `supportable`), so a token is an identifier and never
718
+ permission to call `find` on whatever it names. The concern sets `@requester`,
719
+ `@about`, `@topic`, `@body`, `@files` and `@requester_query` **before** it
720
+ looks anything up, so every refusal re-renders your `new` as a **422 with the
721
+ draft still in it** (for Turbo too — a stream refresh would throw it away);
722
+ success is a **303** to the case, with the flash `message_sent` whether the
723
+ case was opened or the message joined one the person already had open. The
724
+ refusals are distinct and each has its own copy under
725
+ `support_desk.console.errors`: `unknown_requester`, `not_a_requester` (a
726
+ closed account), `invalid_requester`, `invalid_subject`, `blank_message`,
727
+ `invalid_input`, `no_requester_lookup`, `writing_to_yourself`, `off_duty`.
728
+
729
+ Two named seams, for hosts that look people up their own way (a multi-tenant
730
+ host scopes **both**, since `find_requester` only guards the typed path):
731
+
732
+ ```ruby
733
+ def support_conversation_requester # the person, or nil; raise SupportDesk::Console::InvalidInput, :invalid_requester to refuse
734
+ def support_conversation_subject # what it's about, or nil; the model re-checks supportable_by? at the write
735
+ ```
736
+
737
+ Helpers your `new` template can read: `support_conversation_topics` (the
738
+ free-form leaves), `support_conversation_offered?` (show the door at all: on
739
+ duty, and `config.authorize_console` says yes for `:open_conversation` with a
740
+ nil ticket), `support_conversation_sendable?` (render the send button), and
741
+ `support_desk_record` (the desk being written as). When the message turns out
742
+ to be a reply into an open case, `config.authorize_console(agent, ticket,
743
+ :reply)` and `ticket.actions_for(agent)` are asked again, **under that case's
744
+ row lock**, before anything is written.
745
+
746
+ The verbs live in one table the router reads:
747
+ `SupportDesk::Console::MEMBER_VERBS` (reply take assign hand_off release close
748
+ reopen note change_topic) and `SupportDesk::Console::COLLECTION_VERBS`
749
+ (`next: :get, open_conversation: :post`).
750
+
486
751
  ### Who can be written to
487
752
 
488
753
  `has_support_tickets if: :kept?` is the whole policy. When it turns false
@@ -491,6 +756,598 @@ the model, a direct chats write — and nothing is hidden or deleted: the
491
756
  transcript stays readable, the case stays in the queue, and agents can
492
757
  still take notes and close it.
493
758
 
759
+ ### Upgrading to 0.2
760
+
761
+ ```bash
762
+ rails generate support_desk:upgrade # copies the additive opened_by migration (the same file a fresh install runs)
763
+ rails db:migrate # backfills every existing case to its requester
764
+ rake support_desk:backfill_opened_by # the idempotent catch-up, once the old processes are gone
765
+ ```
766
+
767
+ **This is a drained cutover, not a rolling deploy.** 0.1 processes revalidate
768
+ `Assignment#reason` on `release!`/`close!`/`hand_off!` and reject the new
769
+ `opened` reason, so a case the desk opened must not exist while 0.1 code can
770
+ still touch it: migrate, pause support writes, stop and drain all old web
771
+ requests **and workers**, run the catch-up, verify
772
+ `SupportDesk::Ticket.where(opened_by_id: nil).count == 0` and
773
+ `SupportDesk.doctor` (its `provenance` check says exactly that), then start
774
+ only 0.2. A NULL `opened_by` is read as requester-opened in the meantime, so
775
+ quotas, labels and metrics stay right before the catch-up. Rolling back is a
776
+ code rollback, never a schema one; with staff-opened cases live, prefer
777
+ dropping `new`/`open_conversation` from your routes over downgrading. If you
778
+ would rather avoid the window, ship a `0.1.4` that only adds `opened` to
779
+ `Assignment::REASONS` first.
780
+
781
+ Requesters see one more state in their list — `support_desk.tickets.state
782
+ .opened_by_support` ("We wrote to you") — until they answer, and every error
783
+ the gem raises still inherits `SupportDesk::Error`; the new one is
784
+ `SupportDesk::NotARequester` (no `has_support_tickets`, or its `if:` said no).
785
+ `SupportDesk.humanize_duration` is where "1 day" / "4 horas" now comes from
786
+ (`Wizard.humanize_duration` still delegates to it).
787
+
788
+ ## 🤖 Assistants
789
+
790
+ An assistant is an agent that happens to be a machine. She has a seat, a
791
+ name, a turn budget and a **level** that says what she may produce on this
792
+ case — and at the default level that is a proposal a person reads and sends,
793
+ signed by them.
794
+
795
+ The gem does not call a model. It emits one event and accepts a handful of
796
+ verbs; which provider you use, what you put in a prompt and what you spend
797
+ are yours. What lives here is the part nobody should write twice: who may
798
+ say what to whom, what happens when the customer writes again mid-answer,
799
+ and how a person takes over.
800
+
801
+ **Nothing in this section is on until you turn it on.** Without
802
+ `config.assistant`, `support_desk` behaves exactly as it did in 0.2.
803
+
804
+ ### Two minutes
805
+
806
+ ```bash
807
+ rails g support_desk:assistant Rose --disclosure signature
808
+ ```
809
+
810
+ It writes `app/jobs/support/rose_turn_job.rb` and
811
+ `app/services/support/rose.rb`, and it **prints** the rest — it never edits
812
+ your initializer, because `config.assistant` is a policy decision and a
813
+ generator that wrote one would turn "let me look at this" into a live
814
+ assistant. Paste the stanza:
815
+
816
+ ```ruby
817
+ # config/initializers/support_desk.rb
818
+ config.assistant :rose do |rose|
819
+ rose.name = "Rose"
820
+ rose.autonomy = :draft # she proposes; a person sends
821
+ rose.disclosure = :signature # REQUIRED — see below
822
+ rose.max_turns = 6
823
+ rose.responds_within = 3.minutes
824
+ end
825
+ config.default_assistant = :rose
826
+ ```
827
+
828
+ Wire the job to the one event the gem emits:
829
+
830
+ ```ruby
831
+ SupportDesk.on(:assistant_turn, key: "support.rose.turn") do |ticket, assistant, _message, turn:|
832
+ Support::RoseTurnJob.set(wait: 20.seconds).perform_later(ticket.id, assistant.key, turn)
833
+ end
834
+ ```
835
+
836
+ And the job, which is the whole harness contract in nine lines:
837
+
838
+ ```ruby
839
+ def perform(ticket_id, assistant_key, turn)
840
+ ticket = SupportDesk::Ticket.find(ticket_id)
841
+ return unless ticket.assistant_turn == turn # the case moved on while we waited
842
+
843
+ rose = SupportDesk.assistant(assistant_key)
844
+ return unless ticket.assistant_policy(rose).may_observe?
845
+
846
+ answer = Support::Rose.answer(ticket.brief, ticket.transcript)
847
+ ticket.respond!(answer.text, by: rose, turn: turn, confidence: answer.confidence)
848
+ end
849
+ ```
850
+
851
+ What a person sees next: a card at the top of the case that says "Propuesta
852
+ de Rose", the text, a confidence pill, the sources she cited, and three
853
+ buttons — **Enviar**, **Editar**, **Descartar**. The sent message is
854
+ *theirs*, signed with their name. The customer sees an answer from a human,
855
+ because it is one.
856
+
857
+ ### Levels
858
+
859
+ `autonomy` is the ceiling she may ever work at. Every level is the one below
860
+ it plus its own verbs:
861
+
862
+ | level | she may | what it feels like |
863
+ |---|---|---|
864
+ | `:off` | nothing | configured, switched off |
865
+ | `:observe` | `note`, `escalate`, `release` | she reads and can leave a staff note or hand the case to a person; she never writes to the customer |
866
+ | `:draft` | the above + `draft` | **the default.** Every word goes through a person |
867
+ | `:reply` | the above + `reply`, `take` | she answers the customer and holds the case |
868
+ | `:resolve` | the above + `close` | she can close a case she holds once the customer has been answered |
869
+
870
+ ```ruby
871
+ ticket.assistant_policy.level # => :draft
872
+ ticket.assistant_policy.because # => "topic payments caps rose at draft"
873
+ ticket.assistant_policy.may_reply? # => false
874
+ ticket.assistant_policy.allowed_verbs # => [:note, :escalate, :release, :draft]
875
+ ```
876
+
877
+ ### Ceilings and floors
878
+
879
+ A **ceiling** lowers what she may ever produce here. The lowest one wins,
880
+ and `because` names the single rule that decided it — so any refusal traces
881
+ back to one line of configuration:
882
+
883
+ | ceiling | set by |
884
+ |---|---|
885
+ | her autonomy | `rose.autonomy = :reply` |
886
+ | the topic | `topic :payments, assistant: :draft` — the minimum over the node and every ancestor, so a child can only tighten |
887
+ | your block | `rose.cap { |ticket| :draft if ticket.requester.try(:vip?) }` |
888
+ | the case | what a reopen writes (below) |
889
+ | a pause | a human switched her off on this case |
890
+
891
+ A **floor** lowers the level because of the case's state, after the ceilings:
892
+ she is deactivated, the case is closed, a person has been asked for, or a
893
+ human holds it.
894
+
895
+ ```ruby
896
+ ticket.assistant_policy.ceilings # => { assistant: :reply } only the ones that apply
897
+ ticket.assistant_policy.floors # => [:held_by_human] Lucía took it: :reply becomes :draft
898
+ ticket.assistant_policy.to_h # all five ceiling slots, spelled out, for a log line
899
+ ```
900
+
901
+ Policy decides what she may **produce**. It never decides what a person can
902
+ see: the transcript, the case, the queue and the door to a human are the
903
+ same at every level, and a refusal is always a named reason on a record — an
904
+ `assistant_withheld` event, a policy stored in a proposal's metadata — never
905
+ silence.
906
+
907
+ ### `respond!` and its three outcomes
908
+
909
+ `respond!` is the verb a harness should call. It hands over an answer and
910
+ lets policy decide what that answer becomes, so the harness never encodes
911
+ rules that change per case:
912
+
913
+ ```ruby
914
+ outcome = ticket.respond!(text, by: rose, turn: turn, confidence: 0.82, sources: [{ title: "…", url: "https://…" }])
915
+
916
+ outcome.sent? # it went to the requester (level :reply or above, and it was her turn)
917
+ outcome.drafted? # a person will send it; outcome.draft is the row
918
+ outcome.withheld? # nothing was written; outcome.reason is :policy or :not_your_turn
919
+ outcome.escalated? # the budget ran out: the proposal is waiting AND so is a person
920
+ outcome.turn # the successor turn, for a second action in the same run
921
+ ```
922
+
923
+ `draft!` proposes regardless of level, for a host that has already decided it
924
+ wants a proposal. `reply!(by: rose, turn:)` and `note!(by: rose, turn:)` are
925
+ the ordinary verbs with a turn attached.
926
+
927
+ ### The turn
928
+
929
+ `ticket.assistant_turn` is an opaque string (`"t7-r12"`) over an integer
930
+ bumped by **every** registered message and **every** transition. Every
931
+ assistant action requires it and consumes it:
932
+
933
+ ```ruby
934
+ turn = ticket.assistant_turn # read it before you call a model
935
+ ticket.respond!(answer, by: rose, turn: turn)
936
+ ```
937
+
938
+ A model takes seconds and a customer can write again while it thinks. The
939
+ turn is what makes that safe: the action is compared against the case's
940
+ current revision **under its row lock**, and a late, retried or redelivered
941
+ one raises `SupportDesk::StaleTurn` and writes nothing. That one integer is
942
+ also why this gem has no idempotency keys, no claim rows and no leases —
943
+ "is this still the case you read?" is already answered.
944
+
945
+ Two consequences worth knowing:
946
+
947
+ - Check `ticket.assistant_turn == turn` in your job **before you spend
948
+ money**. A mismatch means the newer turn's job already exists.
949
+ - A retry after a committed action is a `StaleTurn`, and that is correct:
950
+ the work was done. Generated jobs `discard_on` it.
951
+
952
+ ### Proposals in the console
953
+
954
+ A pending proposal renders above the composer with its confidence, its
955
+ sources and its attachments, and goes stale visibly the moment anything on
956
+ the case moves.
957
+
958
+ ```ruby
959
+ draft = ticket.pending_draft
960
+ draft.send!(by: lucia, seen_turn: ticket.assistant_turn) # verbatim
961
+ draft.send!(by: lucia, seen_turn: ticket.assistant_turn, body: "Casi: …") # edited
962
+ draft.reject!(by: lucia, reason: "no es eso")
963
+ ```
964
+
965
+ `seen_turn` is the turn the reviewer's **page** was rendered with, and a
966
+ mismatch is a refusal, not a warning: approving a proposal from a page that
967
+ predates the customer's next message would send an answer into a
968
+ conversation that has moved on. There is no "send anyway" flag — the console
969
+ re-renders the case with the current turn and the reviewer's text still in
970
+ the box, and they submit again. One pending proposal per case, always: a
971
+ newer one, a takeover, a human reply or a pause supersedes the last.
972
+
973
+ A sent proposal is the **human's** message. `sent_body` keeps the edit, the
974
+ original body stays on the row, and `draft.edited?` / the `verbatim` and
975
+ `edited` scopes are the acceptance numbers you raise her level on.
976
+
977
+ ### The two exits
978
+
979
+ Either side can ask for a person, and both do the same write: her seat is
980
+ released, the reason is recorded, the priority goes up, and a line lands in
981
+ the thread.
982
+
983
+ ```ruby
984
+ ticket.escalate!(by: rose, turn: turn, reason: "refund_over_limit", summary: "Pidió el reembolso de …")
985
+ ticket.request_human!(by: alice) # the requester's own door
986
+ ```
987
+
988
+ Render the door in your thread — it is a partial in the requester engine,
989
+ and a helper:
990
+
991
+ ```erb
992
+ <%= render "support_desk/tickets/human_door", ticket: ticket %>
993
+ <%= support_human_door(ticket) %>
994
+ ```
995
+
996
+ It shows the button while an assistant is or has been in play on the case,
997
+ and the status line once a person has been asked for. **History counts**:
998
+ the door does not vanish because somebody edited an initializer after she
999
+ answered. Both exits reach the queue's `needs_human` tab, which appears only
1000
+ on desks that have an assistant or a non-zero count.
1001
+
1002
+ ### Disclosure
1003
+
1004
+ Required, with no default. Boot fails until you choose:
1005
+
1006
+ | mode | what the requester gets |
1007
+ |---|---|
1008
+ | `:signature_and_notice` | her messages are signed "Rose · asistente virtual" **and** the thread opens with a notice |
1009
+ | `:signature` | signed; no notice |
1010
+ | `:notice` | a notice; her messages are unsigned, from the desk |
1011
+ | `:none` | nothing is said and nothing is signed |
1012
+
1013
+ **Your legal process decides this, not us.** The gem refuses to pick a
1014
+ default because the right answer depends on a jurisdiction, a sector and a
1015
+ risk appetite it knows nothing about. What it does guarantee is that the
1016
+ choice is never invisible to *you*: whatever the mode, every machine-written
1017
+ message carries `metadata["support_desk"]` naming the assistant, the mode,
1018
+ the turn and the whole policy that allowed it, `ticket.export` labels it
1019
+ `from: "assistant"` in **every** mode including `:none`, and the console
1020
+ marks it for staff. What a customer is told is a product decision; what your
1021
+ records say a machine wrote is not.
1022
+
1023
+ ### Humans outrank
1024
+
1025
+ - A person may answer a case she holds under **every** `reply_policy`.
1026
+ `:assignee_only` exists so two people don't answer at once, and she is not
1027
+ one.
1028
+ - A human reply on her case takes it over and supersedes the pending
1029
+ proposal.
1030
+ - `ticket.agents_to_notify` and `desk.humans` return people only. Notifying a
1031
+ machine is notifying nobody. (`desk.agents` includes her, and is an Array.)
1032
+ - She can never send her own proposal, hand a case off, change a topic or
1033
+ attach a subject. She escalates instead.
1034
+
1035
+ ### Pause, resume, and what a reopen remembers
1036
+
1037
+ ```ruby
1038
+ ticket.pause_assistant!(by: lucia, reason: "cliente enfadado") # floors her at :off on THIS case
1039
+ ticket.resume_assistant!(by: lucia)
1040
+ ```
1041
+
1042
+ Pausing releases her seat and throws away her pending proposal. Resuming
1043
+ clears the pause and **nothing else** — a case cap and a request for a
1044
+ person are different decisions made by different people, and only an
1045
+ explicit hand-back (`assign!(to: rose, by: lucia)`) lifts those.
1046
+
1047
+ Reopening a case she closed leaves it unassigned and writes
1048
+ `assistant_cap = "draft"` for the rest of its life. A case that came back is
1049
+ a case where her answer was not the end of it.
1050
+
1051
+ ### When the harness is down
1052
+
1053
+ Two scheduled tasks, and they are the difference between a delay and a
1054
+ customer nobody answers:
1055
+
1056
+ ```yaml
1057
+ # config/recurring.yml
1058
+ support_desk_release_silent_assistants:
1059
+ command: "SupportDesk.release_silent_assistants!"
1060
+ schedule: every minute
1061
+ support_desk_redispatch_assistant_turns:
1062
+ command: "SupportDesk.redispatch_assistant_turns!"
1063
+ schedule: every 5 minutes
1064
+ ```
1065
+
1066
+ `release_silent_assistants` hands over every case she has sat on longer than
1067
+ her `responds_within` without answering — a worker that stopped, a provider
1068
+ that is down, a job that spent its last retry. It asks for a **person** on
1069
+ each one, not just her seat back: a case that waited that long deserves one
1070
+ whatever she would have said. `redispatch_assistant_turns` re-emits the turn
1071
+ for cases nobody acted on, which is safe precisely because the turn is
1072
+ consumed by the first action and every later one is a `StaleTurn`.
1073
+
1074
+ `rake support_desk:assistant_status` reads and writes nothing, and is the
1075
+ line to put in a deploy check. `SupportDesk.doctor` covers the same ground
1076
+ with verdicts.
1077
+
1078
+ ### What the model sees
1079
+
1080
+ > [!WARNING]
1081
+ > **`ticket.brief` and `ticket.transcript` are what your harness sends to a
1082
+ > third party.** Two fields in the brief are host data you chose:
1083
+ > `Supportable#support_context` (about the thing the case is about) and
1084
+ > `Requester#support_context` (about the person). `include_internal: true`
1085
+ > adds the desk's private reasoning — notes agents left each other, and
1086
+ > proposals a human rejected with the reason. Decide what belongs in a
1087
+ > prompt before you fill those in, not after.
1088
+
1089
+ ```ruby
1090
+ brief = ticket.brief(include_internal: false, transcript_limit: 50)
1091
+ brief.to_h # versioned data — schema_version, desk, assistant, case, requester, transcript
1092
+ brief.to_text # the same facts as sectioned plain text
1093
+ brief.policy # what she may do here, and why
1094
+
1095
+ ticket.transcript.to_text
1096
+ # [2026-09-18 10:02] Alice: No me han pagado
1097
+ # [2026-09-18 10:03] Rose: Lo estoy mirando ahora mismo
1098
+ # [2026-09-18 10:07] Lucía: Ya está resuelto [justificante.pdf]
1099
+ ```
1100
+
1101
+ A brief is **facts, never instructions**. There is not one imperative
1102
+ sentence in it and there never will be: what the assistant should *do* with
1103
+ a case is your prompt and your product. The one thing it states about
1104
+ behaviour is `may` / `may_not`, and that is not advice either — it is the
1105
+ authorization, straight off the policy, so a harness never has to re-derive
1106
+ the rules it is working under.
1107
+
1108
+ The transcript speaks four roles (`:requester`, `:human`, `:assistant`,
1109
+ `:system`) where `Ticket#role_of` speaks three: whose turn it is does not
1110
+ change because a machine wrote the desk's last word, but a reader cares.
1111
+ Deleted messages stay in it as tombstones, and an assistant's name follows
1112
+ your configuration — rename her and the whole transcript renames, drop her
1113
+ from the initializer and it keeps saying what the customer was actually
1114
+ shown.
1115
+
1116
+ ### Before you launch
1117
+
1118
+ - [ ] `SupportDesk.doctor.print` is green. It checks her bindings, that
1119
+ `Chats.display_name_for` answers for her, that no case she holds needs
1120
+ a person, that a turn subscriber exists, and that nothing has been
1121
+ idle longer than three times her `responds_within`.
1122
+ - [ ] **Moderation covers both shapes.** A signed message has her as its
1123
+ author; a `:notice` or `:none` message has no author at all and is the
1124
+ desk's. Whatever owns your moderation has to catch the nameless one
1125
+ too.
1126
+ - [ ] Notifiers subscribe to `draft_proposed` (a proposal is waiting),
1127
+ `ticket_escalated` and `human_requested` (somebody needs a person).
1128
+ - [ ] The `_human_door` partial renders in your thread.
1129
+ - [ ] Both rake tasks are scheduled, and you have watched them run once.
1130
+ - [ ] `autonomy` is `:draft`, and topic caps are on money and identity.
1131
+ Raise her only when the acceptance rate on reviewed proposals says so.
1132
+
1133
+ ### Reference
1134
+
1135
+ **Configuration**
1136
+
1137
+ | setting | type | default |
1138
+ |---|---|---|
1139
+ | `name` | String | the key, humanized |
1140
+ | `avatar` | String, or a callable given the assistant | nil |
1141
+ | `autonomy` | one of `AssistantPolicy::LEVELS` | `:draft` |
1142
+ | `disclosure` | **required**: `:signature_and_notice` · `:signature` · `:notice` · `:none` | — |
1143
+ | `max_turns` | positive Integer, or nil for unlimited (doctor warns) | 6 |
1144
+ | `responds_within` | a Duration, or nil to disable the silent sweep (doctor warns) | 3 minutes |
1145
+ | `may_open_conversations` | true / false | false |
1146
+ | `hand_off_line` · `human_requested_line` · `disclosure_line` | String (`%{name} %{desk} %{reply_within}`), I18n key, block, or nil | the gem's copy |
1147
+ | `hand_off_when` | block `(ticket, message)` → true / false / nil; anything else, or a raise, hands the case over | nil |
1148
+ | `cap` | block `(ticket)` → a level or nil | nil |
1149
+
1150
+ Predicates: `signs?`, `notice?`, `disclosed?`, `may_open_conversations?`,
1151
+ `line_for(setting, ticket)`. The four modes are
1152
+ `AssistantConfiguration::DISCLOSURE_MODES`.
1153
+
1154
+ ```ruby
1155
+ config.assistant :rose { |rose| … } config.assistant(:rose) config.assistants config.assistant?(:rose)
1156
+ config.default_assistant = :rose
1157
+ config.desk(:billing) { |desk| desk.assistant = :rose } # an explicit nil DISABLES that desk
1158
+ config.desk(:billing).assistant_key
1159
+ topic :payments, assistant: :draft # Topic#assistant_cap — tightens only
1160
+ acts_as_support_agent kind: :ai # validated; see the upgrade note
1161
+ ```
1162
+
1163
+ **Module**
1164
+
1165
+ ```ruby
1166
+ SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
1167
+ SupportDesk.reset_assistants!
1168
+ SupportDesk.ai_actor?(record)
1169
+ SupportDesk.release_silent_assistants! # → Integer
1170
+ SupportDesk.redispatch_assistant_turns!(older_than: 1.minute) # → Integer
1171
+ ```
1172
+
1173
+ **`SupportDesk::Assistant`**
1174
+
1175
+ | | |
1176
+ |---|---|
1177
+ | `SupportDesk::Assistant.for(key)` / `.active` | found or created; the on-duty scope |
1178
+ | `config` / `configured?` | her slice of the configuration; whether anything still declares her |
1179
+ | `name` `avatar` `autonomy` `disclosure` `max_turns` `responds_within` `may_open_conversations?` | read through the configuration, with safe answers when it is gone |
1180
+ | `disclosed?` `signs?` `notice?` | the mode, as predicates |
1181
+ | `disclosed_name` `display_name` `to_s` `support_agent_name` `support_agent_avatar` | what a requester sees |
1182
+ | `on_duty?` `support_capacity` | the agent contract |
1183
+ | `deactivate!(by:, reason:)` / `activate!(by:)` | the cross-process kill switch; rows are never destroyed |
1184
+ | `desks` / `held_tickets` | where she works, and what she is sitting on |
1185
+
1186
+ **`SupportDesk::AssistantPolicy`**
1187
+
1188
+ `LEVELS` · `RANK` · `VERBS_BY_LEVEL` · `ALL_VERBS` ·
1189
+ `AssistantPolicy.for(ticket, assistant, hand_back: false)` · `level` ·
1190
+ `because` · `ceilings` · `floors` · `assistant` · `ticket` ·
1191
+ `at_least?(level)` · `may?(verb)` · `may_observe?` · `may_draft?` ·
1192
+ `may_reply?` · `may_hold?` · `may_close?` · `allowed_verbs` ·
1193
+ `forbidden_verbs` · `to_h` · `to_s` · `null?` (and `AssistantPolicy::Null`,
1194
+ whose `because` says which of "no assistant here" and "not this desk's
1195
+ assistant" it was).
1196
+
1197
+ **`SupportDesk::Ticket`**
1198
+
1199
+ ```ruby
1200
+ # verbs
1201
+ respond!(body, by:, turn:, files:, confidence:, sources:, metadata:, request:) # → Outcome
1202
+ draft!(body, by:, turn:, …) # → Draft
1203
+ escalate!(by:, reason:, summary:, turn:, request:)
1204
+ request_human!(by:, request:) # by: must be the requester
1205
+ pause_assistant!(by:, reason:) / resume_assistant!(by:)
1206
+
1207
+ # readers
1208
+ assistant assistant_policy(assistant = self.assistant, hand_back: false) assistant_turn
1209
+ assistant_in_play? held_by_assistant? human_required? assistant_paused? assistant_turns_left
1210
+ assistant_message?(message) transcript(limit: nil) brief(include_internal:, transcript_limit:)
1211
+ drafts pending_draft notes
1212
+
1213
+ # scopes
1214
+ held_by_assistants held_by_humans needs_human assistant_paused assistant_capped
1215
+ resolved_by_assistant with_pending_draft assistant_idle_since(time)
1216
+ ```
1217
+
1218
+ Columns: `assistant_revision`, `last_requester_message_id`,
1219
+ `assistant_turns_count`, `assistant_acted_at`, `assistant_paused_at`,
1220
+ `assistant_paused_reason`, `assistant_cap`, `human_required_at`,
1221
+ `human_required_reason`.
1222
+
1223
+ **`SupportDesk::Draft`**
1224
+
1225
+ `STATUSES` · `MAX_SOURCES` (20) · `MAX_SOURCE_TITLE` (500) ·
1226
+ `MAX_SOURCE_URL` (2048) · `send!(by:, seen_turn:, body:, request:)` ·
1227
+ `reject!(by:, reason:, request:)` · `pending?` `sent?` `rejected?`
1228
+ `superseded?` `expired?` · `stale?` · `edited?` · `final_body` ·
1229
+ `confidence_percent` · `author_key` · scopes `pending` `sent` `rejected`
1230
+ `superseded` `expired` `reviewed` `verbatim` `edited` `by(author)`
1231
+ `chronological` `newest_first`.
1232
+
1233
+ **`SupportDesk::Outcome`** — `action` `message` `draft` `policy` `reason`
1234
+ `turn` · `sent?` `drafted?` `withheld?` `escalated?` · `to_h`.
1235
+
1236
+ **`SupportDesk::Transcript`** — `Turn(role:, name:, body:, at:,
1237
+ attachments:, assisted:, message:)` with `requester?` `human?` `assistant?`
1238
+ `system?` `assisted?` and `to_line`; `to_a` `to_h` `to_text` `last(n)`
1239
+ `since(message)` `size`, Enumerable, and `limit:`. `to_h` carries
1240
+ `truncated`, because a reader seeing the last 50 of 300 turns has to know.
1241
+
1242
+ **`SupportDesk::Brief`** — `SCHEMA_VERSION` · `to_h` · `to_text` · `policy`
1243
+ · `include_internal?` · `transcript`.
1244
+
1245
+ **`Requester#support_context`** — overridable, `{}` by default, the same
1246
+ meaning as `Supportable#support_context`. `ContextCard#requester_pairs`
1247
+ renders it next to the subject's own `pairs`, and `to_h` carries it under
1248
+ `requester.context`.
1249
+
1250
+ **`SupportDesk::Desk`** — `assistant` · `assistant?` · `humans` (people
1251
+ only) · `agents` (people plus her, an Array).
1252
+
1253
+ **Events**
1254
+
1255
+ | event | arguments |
1256
+ |---|---|
1257
+ | `assistant_turn` | `ticket, assistant, message, turn:` — the only one a harness subscribes to |
1258
+ | `draft_proposed` | `ticket, draft` |
1259
+ | `draft_sent` | `ticket, draft, message, by:` |
1260
+ | `draft_rejected` | `ticket, draft, by:, reason:` |
1261
+ | `assistant_withheld` | `ticket, assistant, reason:, policy:` |
1262
+ | `ticket_escalated` | `ticket, from:, reason:, by:` |
1263
+ | `human_requested` | `ticket, by:, reason:` |
1264
+ | `assistant_paused` / `assistant_resumed` | `ticket, by:` |
1265
+
1266
+ New `Event::KINDS`: `human_requested` `assistant_paused` `assistant_resumed`
1267
+ `draft_sent` `draft_rejected` `assistant_withheld` — the last five join
1268
+ `note` and `drop_in` in `Event::INTERNAL_KINDS`, which is what
1269
+ `Event.requester_visible` excludes. An export says a person was asked for;
1270
+ it never says a machine's proposal was discarded. `Event#summary` reads the
1271
+ paragraph an escalation left.
1272
+
1273
+ **Errors** — `SupportDesk::NotAnAssistant` (an AI-kind actor that is not
1274
+ this desk's assistant; a subclass of `NotAnAgent`),
1275
+ `SupportDesk::AssistantNotAllowed` (carries `policy` and `verb`; a subclass
1276
+ of `NotAllowed`), `SupportDesk::StaleTurn` (a subclass of
1277
+ `InvalidTransition`, so `rescue InvalidTransition` still catches it).
1278
+
1279
+ **Console** — `MEMBER_VERBS` gains `send_draft` `reject_draft`
1280
+ `pause_assistant` `resume_assistant`; helpers `support_pending_draft` and
1281
+ `support_assistant`; `unavailable_reason` gains `"no_pending_draft"`. Picker
1282
+ values are `SupportDesk.actor_key(agent)`. `Queue::TABS` gains
1283
+ `:needs_human`, hidden by `visible_tabs` unless the desk has an assistant or
1284
+ the count is non-zero.
1285
+
1286
+ **Requester engine** — `POST /tickets/:id/request_human`, and
1287
+ `support_desk/tickets/_human_door` (local: `ticket`) behind the
1288
+ `support_human_door(ticket)` helper.
1289
+
1290
+ **Rake** — `support_desk:release_silent_assistants` ·
1291
+ `support_desk:redispatch_assistant_turns` (`OLDER_THAN=60`) ·
1292
+ `support_desk:assistant_status`.
1293
+
1294
+ **Doctor** — `assistants (config)` · `assistant turn subscriber` ·
1295
+ `assistant authorship` · `assistant silence` · `assistant seats` ·
1296
+ `assistant idle turns` · `drafts` · `ai agents without policy`.
1297
+
1298
+ **Test helpers**
1299
+
1300
+ | helper | |
1301
+ |---|---|
1302
+ | `support_assistant(key = nil)` | the record |
1303
+ | `respond_as(assistant, ticket, body, turn:, **options)` | → `Outcome` |
1304
+ | `draft_as(assistant, ticket, body, turn:, **options)` | → `Draft` |
1305
+ | `assert_pending_draft(ticket, body:)` / `refute_pending_draft` | `body:` takes a String (substring) or a Regexp |
1306
+ | `assert_needs_human(ticket, reason:)` / `refute_needs_human` | |
1307
+ | `assert_held_by_assistant(ticket, assistant = nil)` | |
1308
+ | `refute_assistant_spoke(ticket)` | no machine has said anything to the requester |
1309
+ | `assert_assistant_policy(ticket, level, because:)` | the level **and** the sentence |
1310
+ | `with_assistant_config(key = nil, **overrides) { … }` | |
1311
+ | `with_topic_assistant_cap(path, level) { … }` | rebuilds the frozen tree with one cap |
1312
+
1313
+ ### Upgrading to 0.3
1314
+
1315
+ ```bash
1316
+ rails generate support_desk:upgrade # copies the additive assistants migration
1317
+ rails db:migrate
1318
+ ```
1319
+
1320
+ Additive and rolling-safe — unlike 0.2, no drain. Deploy **every** process to
1321
+ 0.3 before you add `config.assistant`: a 0.2 worker cannot honour a turn it
1322
+ does not know about.
1323
+
1324
+ Four things change whether or not you configure an assistant:
1325
+
1326
+ - **A host model declared `acts_as_support_agent kind: :ai` is now refused
1327
+ for every support write, by it or to it** (`NotAnAssistant`). It used to
1328
+ be treated as a human. `kind:` is validated at declaration, and `doctor`
1329
+ warns about such classes. Only the desk's own `SupportDesk::Assistant` has
1330
+ machine authority.
1331
+ - **Console picker values are actor keys**, not bare ids — an assistant and
1332
+ a user can share an integer id. A bare id is still accepted for one
1333
+ release, and only when exactly one pool member matches it.
1334
+ - `Queue::TABS` gains `:needs_human`, hidden unless it is relevant.
1335
+ - **Every registered message and every transition writes
1336
+ `assistant_revision`**, on every case, with or without an assistant. It is
1337
+ one nullable integer, it is what the turn is made of, and a 0.2 process
1338
+ reading those rows is unaffected — which is why step 3 matters in the
1339
+ other direction: a 0.2 *writer* leaves the counter behind.
1340
+
1341
+ And when you do configure one: `desk.agents` becomes an Array of humans plus
1342
+ her and `desk.humans` is the human-only pool, `announce_assignments` never
1343
+ announces her, a human reply takes her case over under every `reply_policy`
1344
+ and supersedes the pending proposal, `close!` expires proposals, a reopen
1345
+ after her close caps her at `:draft`, and `reply!` / `post_agent_message!`
1346
+ accept `metadata:` and `turn:`.
1347
+
1348
+ `rose.deactivate!(by: owner)` is the kill switch: cross-process, within one
1349
+ transition, no deploy.
1350
+
494
1351
  ## The wizard
495
1352
 
496
1353
  "What do you need help with?" is a plain object, not a controller, so a host
@@ -527,12 +1384,97 @@ Pass `key:` from anywhere that runs more than once (a `to_prepare` block, an eng
527
1384
  Keep push titles and bodies generic — `ticket.notification_title` is safe for previews.
528
1385
  `ticket.notification_body` contains case details for an authenticated feed, not a lock screen.
529
1386
 
1387
+
1388
+
1389
+ The whole catalogue, with the arguments each subscriber receives:
1390
+
1391
+ | event | arguments | when |
1392
+ |---|---|---|
1393
+ | `ticket_opened` | `ticket` | a case was **inserted** — by a requester or by the desk; check `ticket.opened_by_support?` before paging your team about their own message |
1394
+ | `requester_replied` | `ticket, message` | a requester message registered, other than the one that opened the case |
1395
+ | `agent_replied` | `ticket, message` | a desk message registered, other than the desk's own opening message |
1396
+ | `ticket_assigned` | `ticket, assignment` | take, assign, or (later) routing — not the silent seat a desk-opened case starts with |
1397
+ | `ticket_handed_off` | `ticket, assignment, from:, note:` | |
1398
+ | `ticket_released` | `ticket, from:, reason:` | |
1399
+ | `ticket_closed` / `ticket_reopened` | `ticket, by:` | `by:` is the requester when their own reply reopened it |
1400
+ | `ticket_topic_changed` | `ticket, from:, to:, by:` | |
1401
+ | `subject_attached` | `ticket, subject, by:` | |
1402
+ | `note_added` | `ticket, event` | internal notes never reach the conversation |
1403
+ | `assistant_turn` | `ticket, assistant, message, turn:` | there is something for an assistant to answer — the only event a harness subscribes to (see [Assistants](#-assistants)) |
1404
+ | `draft_proposed` | `ticket, draft` | a proposal is waiting for a person |
1405
+ | `draft_sent` | `ticket, draft, message, by:` | |
1406
+ | `draft_rejected` | `ticket, draft, by:, reason:` | |
1407
+ | `assistant_withheld` | `ticket, assistant, reason:, policy:` | `respond!` wrote nothing, and the policy that refused is in the payload |
1408
+ | `ticket_escalated` | `ticket, from:, reason:, by:` | a case was handed to a person |
1409
+ | `human_requested` | `ticket, by:, reason:` | the requester pressed the door, or a `hand_off_when` phrase fired |
1410
+ | `assistant_paused` / `assistant_resumed` | `ticket, by:` | |
1411
+ | `ticket_transitioned` | `ticket, kind, by:, request:, payload:` | once per event row — the audit-log hook |
1412
+
1413
+ `ticket.agents_to_notify` is the assignee, or the whole on-duty pool while
1414
+ nobody holds the case — the gem computes it so every host gets "assignee or
1415
+ everyone" right. System messages (opening lines, closure notices) never emit
1416
+ anything and never move the clocks.
1417
+
1418
+ ## Errors
1419
+
1420
+ Every error inherits `SupportDesk::Error`, so `rescue SupportDesk::Error`
1421
+ catches anything support-specific; the console turns each into a translated
1422
+ flash. `ConfigurationError` (boot) · `ActorMissing` (a transition with no
1423
+ `by:` and no `Current.actor`) · `NotAnAgent` · `NotARequester` (no
1424
+ `has_support_tickets`, or its `if:` said no) · `NotTheAssignee` (a hand-off
1425
+ by somebody who doesn't hold the case) · `NotAllowed` (policy: a drop-in
1426
+ under `:assignee_only`, somebody else's record, a hidden topic, an agent
1427
+ writing to themselves) · `InvalidTransition` and its subclass `Locked` (a
1428
+ closed case on a desk that locks them, or a requester who can no longer be
1429
+ written to) · `UnknownTopic` · `NotSupportable` · `RateLimited` ·
1430
+ `TooManyOpenTickets`. With an assistant configured, three more:
1431
+ `NotAnAssistant` (an AI-kind actor that isn't this desk's own assistant — a
1432
+ subclass of `NotAnAgent`), `AssistantNotAllowed` (her policy forbids the
1433
+ verb; it carries the `policy` and the `verb`, so you can log the rule
1434
+ instead of parsing the sentence) and `StaleTurn` (the case changed since she
1435
+ read it — a subclass of `InvalidTransition`, so anything rescuing that
1436
+ still catches it).
1437
+
1438
+ ## Locales
1439
+
1440
+ `es` and `en` ship with the gem, under `support_desk.*` (requester screens,
1441
+ system lines, notifications, queue tabs, statuses, channels) and
1442
+ `support_desk.console.*` (queue, case page, compose form, flashes, errors).
1443
+ Your own locale files **outrank** the gem's — Rails loads every engine's
1444
+ locales first and the app's last — so override any key in your `es.yml` and
1445
+ the gem's copy loses. The ones hosts usually touch: `support_desk.topics.<path>.label`
1446
+ (and `.ask`, `.hint`), `support_desk.queue.tabs.*`,
1447
+ `support_desk.system.assigned`, `support_desk.thread.*`,
1448
+ `support_desk.console.flashes.*` and `support_desk.console.errors.*`. The
1449
+ suite asserts both languages carry exactly the same keys.
1450
+
1451
+ ## Doctor
1452
+
1453
+ ```ruby
1454
+ SupportDesk.doctor.print # or .ok? in CI
1455
+ ```
1456
+
1457
+ Configuration: `requester_class`, `agents` (the block resolves to records),
1458
+ `topics` (a tree with a way out), `opening lines` (every static line
1459
+ interpolates, every I18n key exists), `supportables` (every `about:` class is
1460
+ supportable), `find_requester` (callable, one argument), `engine mount`,
1461
+ `parent controllers`. Chats seams: `chats subscribers`, `chats authorship`,
1462
+ `desk messager`. Data invariants: `conversations` (every ticket has one),
1463
+ `assignments` (at most one open per case), `assignee pointers`, `provenance`
1464
+ (no half-NULL `opened_by`; warns on legacy NULL rows and names the backfill
1465
+ task), `awaiting` (agrees with the transcript), `references` (unique).
1466
+ Assistants (only where one is configured): `assistants (config)`,
1467
+ `assistant turn subscriber`, `assistant authorship`, `assistant silence`,
1468
+ `assistant seats`, `assistant idle turns`, `drafts`, and `ai agents without
1469
+ policy` — which warns about any host class declared `kind: :ai`, since every
1470
+ support write by it is refused.
1471
+
530
1472
  ## Compatibility
531
1473
 
532
1474
  Rails 7.2, 8.0 and 8.1; Ruby >= 3.2; PostgreSQL, SQLite and MySQL; bigint or UUID primary keys (the migration follows your app's `primary_key_type`).
533
1475
 
534
- PostgreSQL and SQLite enforce unique new-case creation and one open assignment per ticket
535
- with **partial unique indexes**. New submissions reuse an existing open case. Reopening
1476
+ PostgreSQL and SQLite enforce unique new-case creation, one open assignment per ticket and
1477
+ one pending proposal per ticket with **partial unique indexes**. New submissions reuse an existing open case. Reopening
536
1478
  historical cases is deliberately exempt from new-case deduplication: if a newer case
537
1479
  already exists, both histories remain open and support is notified of the reply. No
538
1480
  conversation is silently merged, closed or discarded. MySQL has no partial indexes,
@@ -558,6 +1500,7 @@ bundle exec appraisal rails-8.1 rake test
558
1500
  include SupportDesk::TestHelpers
559
1501
 
560
1502
  ticket = open_support_ticket(for: users(:alice), about: orders(:one), message: "…")
1503
+ written = open_support_ticket(for: users(:alice), by: users(:lucia), message: "…") # the desk writes first
561
1504
  reply_as users(:lucia), ticket, "…"
562
1505
  assert_awaiting_requester ticket
563
1506
  assert_ticket_event ticket, :handed_off, from: users(:lucia), to: users(:pedro)
@@ -565,6 +1508,47 @@ assert_ticket_event ticket, :handed_off, from: users(:lucia), to: users(:pedro)
565
1508
  with_support_config(reply_policy: :assignee_only) { … }
566
1509
  ```
567
1510
 
1511
+ Everything the module gives you:
1512
+
1513
+ | helper | |
1514
+ |---|---|
1515
+ | `open_support_ticket(for:, message:, about:, topic:, by:)` | a case, the way a requester opens one — or the desk, with `by:` |
1516
+ | `reply_as(agent, ticket, body, files:)` | answer; returns the `Chats::Message` |
1517
+ | `ask_again(ticket, body)` | the requester writes again |
1518
+ | `assert_awaiting_reply` / `assert_awaiting_requester` | who owes the next word (these reload the ticket) |
1519
+ | `assert_ticket_open` / `assert_ticket_closed` | status |
1520
+ | `assert_assigned_to(ticket, agent)` / `assert_unassigned` | the seat |
1521
+ | `assert_ticket_event(ticket, kind, from:, to:, by:)` / `refute_ticket_event` | the timeline |
1522
+ | `with_support_config(desk = :default, **overrides) { … }` | different desk settings for one block, put back afterwards |
1523
+ | `capture_support_events(*names) { … }` | `[[name, args, kwargs], …]` of what the block emitted; unsubscribes on the way out |
1524
+
1525
+ And, with an assistant configured, the ones its own suite uses — `support_assistant`, `respond_as`, `draft_as`, `assert_pending_draft` / `refute_pending_draft`, `assert_needs_human` / `refute_needs_human`, `assert_held_by_assistant`, `refute_assistant_spoke`, `assert_assistant_policy`, `with_assistant_config` and `with_topic_assistant_cap`. They are documented in [Assistants](#-assistants).
1526
+
1527
+ Between examples, `SupportDesk.reset!` clears configuration, subscribers,
1528
+ desks and registries; `SupportDesk.reset_desks!` only forgets the memoised
1529
+ desk records.
1530
+
1531
+ ## Module-level API
1532
+
1533
+ ```ruby
1534
+ SupportDesk.configure { |config| … } SupportDesk.config SupportDesk.configured?
1535
+ SupportDesk.desk(key = :default) # the Desk record, found or created, memoised
1536
+ SupportDesk.assistant(key = nil) # the Assistant record, memoised; nil when none is configured
1537
+ SupportDesk.reset_assistants! SupportDesk.ai_actor?(record)
1538
+ SupportDesk.release_silent_assistants! # the net under a dead harness (schedule it)
1539
+ SupportDesk.redispatch_assistant_turns!(older_than: 1.minute)
1540
+ SupportDesk.find_topic("billing/invoice")
1541
+ SupportDesk.on(event, key: nil) { … } SupportDesk.off(event, key)
1542
+ SupportDesk.doctor
1543
+ SupportDesk.native_path_rules(mount:, title:) # Hotwire Native path rules for the requester screens
1544
+ SupportDesk.humanize_duration(24.hours) # "1 day", in the reader's language
1545
+ SupportDesk.actor_key(record) # a stable key for an actor (GlobalID param); what event payloads store
1546
+ SupportDesk.requester_class?(klass) .supportable_class?(klass) .agent_class?(klass)
1547
+ SupportDesk.eligible?(record, condition) # how both macros read their `if:`
1548
+ SupportDesk.subscribe_to_chats! # the `:message_created` listener; idempotent
1549
+ SupportDesk::VERSION
1550
+ ```
1551
+
568
1552
  ## Development
569
1553
 
570
1554
  After checking out the repo, run `bundle install`, then `bundle exec rake ci`. The dummy app lives in `test/dummy` and mounts all three surfaces the way a real host does: the requester engine at `/messages/support`, `chats` at `/messages`, and the turnkey console at `/admin/support` — plus the same console again inside a host-owned `madmin` namespace, because "the console uses only the public API" is a claim that needs a second implementation to be worth anything.