Running the support desk
Working /admin/crm/support day to day — the queue and its five filters, what the four statuses actually mean, assignment, replying, the archive with the bulk verbs, and what every agent can see.
The support desk is the screen your team spends the day in. This page is the admin side of it: the ordering rule the queue uses, the status contract, who can see what, and which actions live on which of the three screens.
There are three, and the split matters:
| Screen | Path | Shape | What it is for |
|---|---|---|---|
| Customer Support | /admin/crm/support |
Three-pane console | Answering the work in front of you |
| All tickets | /admin/crm/support/tickets |
Data table | The archive: search everything, analytics, CSV, bulk verbs |
| One ticket | /admin/crm/support/{id} |
Full-page conversation | A single ticket on its own URL — where a link into the desk lands |
The first two are gated on access.support.ticket, deliberately the same key —
an operator who may work the queue may read the archive, and a separate key
would only mean one of the two silently 404s for half the desk after an upgrade.
The ticket page is gated on view.support.ticket. Every write is
edit.support.ticket; reads are view.support.ticket; the archive's bulk
delete is delete.support.ticket.
On the console, controls needing edit.support.ticket are hidden rather than
disabled for agents without it. A view-only agent gets the conversation and no
buttons, not a row of controls that 403. The single-ticket page does not do that
— it draws Status, Close and the composer for anyone who can open it, and the
refusal comes from the endpoint.
/admin/crm/support and /admin/crm/support/{id} are chromeless routes — no
site header, no footer. The way back to the rest of the admin panel is the arrow
in each one's own top bar, because removing the navbar removes every other one.
The archive at /admin/crm/support/tickets is not on that list: it is an
ordinary reading page and keeps the admin header.
The single-ticket page is where a link into the desk lands — the archive's
View on a row opens /admin/crm/support/{id}, and desk notifications point
straight at it. It carries one conversation full-bleed: the thread, its own
composer, Status and Close in the top bar, the same live WebSocket as the
console, and a details rail (customer and their ticket counts, the ticket's
fields, the assigned agent) behind the info toggle. What it does not carry is
assignment — it shows who holds the ticket; changing that is a console control.
The queue
The left pane is GET /api/admin/crm/support/inbox, and it is a queue, not
a page of a table.
It opens on the conversations waiting for you, oldest wait first:
- waiting on us — the customer spoke last, oldest wait first inside the band
- waiting on them — we spoke last
- closed — only when you ask for it
"Who spoke last, and when" is a fact about the last message a person sent, not
about updatedAt (which moves when a ticket is merely assigned) and not about
status (which an operator can set by hand). It used to be computed in the
browser from the messages blob, which meant the queue could only be ordered
after the rows had been fetched — so the fetch had to pick its rows by some
other key, and the longest-waiting tickets were the ones it dropped. Two columns
now carry it, written whenever a message is written, so the whole ordering
happens in the database and the first row really is the first row.
Each row carries the customer, the assigned agent, the last message preview, who wrote it, the wait in minutes, the message count and the status.
J and K move through it, and they turn the page at either end. They are
suppressed while a text field has focus so j still works in the composer and
the search box.
The five filters
| Filter | Shows |
|---|---|
| Waiting | The customer spoke last (or nobody has spoken yet) |
| Mine | Assigned to you |
| Free | Unassigned |
| Open | Everything the desk still owns. |
| Closed | Closed tickets |
Every filter is a database query and every one carries a live count — Closed included. The counts are computed over the whole desk rather than over the rows on screen, so the number on a tab is the number of conversations in it.
The search box matches subject, customer name, customer email, the assigned agent, and a ticket id pasted whole. It searches every page, not the one you are looking at.
Sorting and paging
The control under the search box changes the order, and the arrow beside it reverses the direction. The choice is saved in your browser.
| Sort | Opens on |
|---|---|
| Queue order | The ranking above. The default |
| Last message | The conversation that moved most recently |
| Ticket created | The newest ticket |
| Importance | HIGH first |
| Status, Subject, Customer | Grouped or alphabetical |
| Response time | Fastest first; never-answered tickets last |
| Rating | Best rated first; unrated last |
Rows per page — 25, 50 or 100 — is in the same menu, and the foot of the queue says which rows you are looking at and how many there are in total.
The queue is paged now rather than capped, so total is the real size of the
scope you picked. /admin/crm/support/tickets still owns analytics, export and
the bulk verbs.
The status contract
Four statuses, and all four answer one question: whose turn is it?
| Status | Means |
|---|---|
PENDING |
Waiting on you. Unassigned, or assignment was removed. |
OPEN |
Waiting on you. Assigned, or the customer has replied. |
REPLIED |
An agent has answered. Waiting on the customer. |
CLOSED |
Done. The composer refuses to send into it. |
Both customer-facing reply routes write OPEN, so a ticket the customer has
just spoken into really is in one of the first two.
The queue decides "waiting on us" by looking at who wrote the last message,
not at status. That is not a repair of the column — status is also set by hand
from the desk's own controls, so an operator who marks a ticket REPLIED while
the customer's message is the last one in it has put the column and the thread
into disagreement. When they disagree, the thread wins in the queue, and the
status column is what the rest of the platform reads.
System chips — the small "status changed" lines — are the interface speaking, not somebody waiting for an answer. They are filtered out before the last message is chosen, so they can neither own the last word nor count towards the message total.
Setting a status from the ticket pane offers PENDING, OPEN and REPLIED as
three buttons, with Close separated out because it is the change that ends
the conversation and stops the composer accepting anything. Priority is
LOW/MEDIUM/HIGH, on the same pane.
Assignment
Assignment is a single-holder model: agentId on the ticket, with agentName a
denormalised copy written at reply time. The live account is preferred and the
stored string is only a fallback — otherwise a renamed employee keeps their old
name on every ticket they ever touched. There is no team, no round-robin and no
queue-per-agent.
agentId: nullidsAssigning also writes the status: assigning sets OPEN, unassigning sets
PENDING. That is worth knowing before you unassign a ticket you have already
answered — it will read as waiting on you again.
The console shows one button and it depends on the ticket: Assign to me when nobody holds it, Take it anyway when a colleague does, and Unassign when you do. Taking a ticket from a colleague is not blocked; the pane simply warns that another agent has it.
responseTime is written from the first agent message in the thread, not
from assignment. That is deliberate: it used to be tied to assignment, so on a
desk that assigns before answering — the normal move, and the one the archive's
own "Assign to me" bulk action exists to make — every ticket had a null
first-response time and the average was computed over the exceptions.
Replying
The composer takes text and an optional image attachment (uploaded to
support-attachments, resized to 1200×900). Enter sends, Shift+Enter breaks the
line, and Cmd/Ctrl+Enter also sends. The thread updates live over a WebSocket;
the dot beside the assignment card is green when that connection is up and amber
while it is reconnecting.
Every admin message is written as type agent regardless of what the request
says, and the sender name is the administrator's own name, falling back to their
email. A closed ticket refuses replies with a 403 — reopen it first.
If the AI Support add-on is installed and has answered a ticket, its replies appear in this same thread rendered with an AI badge and a bot glyph. That is a disclosure requirement, not decoration.
Tickets cannot be created from the admin panel
There is no create route. /api/admin/crm/support/ticket has a list, a bulk
status update, a bulk assign and a bulk delete — no POST. A ticket exists
because a customer raised it, either through the support form (type: TICKET)
or by opening the live-chat widget (type: LIVE).
If you need to start a conversation with a customer, use a notification or email; the desk answers, it does not originate.
What each agent can see
Every operator holding view.support.ticket sees every ticket. There is no
per-agent scoping, no team ownership and no "only my tickets" server-side rule
— Mine is a client-side filter over the same fetched set as All.
crm/support/scope.ts is often mistaken for that rule. It is not. It is the one
definition of "a ticket the desk owns", shared by the queue, the archive and
the KPI endpoint so all three describe the same population, and what it actually
does is drop empty live-chat shells. GET /api/user/support/chat creates a
LIVE ticket the moment the widget opens, before a word is typed, so merely
opening the chat bubble and closing it again used to mint a row that counted as
Total, as Pending and as Unassigned with nothing in it to answer. The scope
keeps a LIVE ticket only once it has at least one message; TICKET rows are
always included.
It also fixed an asymmetry worth remembering: the list used to hide tickets raised by the admin who was looking, while the KPI counted them — so the header said "Total 4" over a table that could only ever show three, and the person best placed to notice a stuck ticket was the one person hidden from it.
The practical consequence for an operator: restricting what an agent can see
is a role decision, not a desk setting. The seeded Support role ships with
no permissions at all, like every role other than Super Admin, so you grant
access.support.ticket + view.support.ticket for a read-only agent and add
edit.support.ticket for one who answers. See
Roles and permissions.
The archive
/admin/crm/support/tickets is the whole desk as a data table, reachable from
the console's top bar. It is the only screen that reaches every ticket ever
raised, and the only one with bulk verbs.
Above the table, seven figures from GET /api/admin/crm/support/stat: total,
open, pending, closed, unassigned, average first response and average
satisfaction. The last two are genuinely nullable — "no response recorded yet"
is not the same as zero minutes, and the cards show a dash rather than a figure
when nothing has been measured.
Columns: customer (avatar, name, email), assigned agent, subject, importance,
status, type (LIVE / TICKET), message count and age. Importance, status and
type are select filters; the Overview/Analytics toggle sits above them.
Analytics
There are no analytics tabs. The table has two views — Overview, which is the table, and Analytics — and every figure below is on that one Analytics screen, computed server-side. It reads top to bottom in three bands, none of which carries a heading of its own:
- Open tickets, oldest open ticket, unassigned, high-priority open, average first response in minutes and SLA breach rate, with a busiest-agents bar chart beside them.
- Tickets over time: one line chart carrying created, answered and past-SLA.
- Tickets answered (first reply), SLA breaches and average satisfaction 1–5, with response time by agent ranked slowest first.
The two SLA figures on this view count tickets whose responseTime exceeded
60 minutes, and the breach rate is taken over tickets that actually got a
first reply rather than over everything created — which would flatter the desk
on a quiet week. That is a first-response quality measure and is a different
number from the 24-hour queue budget the inbox badge uses. Neither is
configurable.
Bulk actions
| Action | Endpoint | Asks for a reason |
|---|---|---|
| Close selected | PUT /api/admin/crm/support/ticket/status with status: CLOSED |
Yes, required |
| Reopen selected | the same, with status: OPEN |
Yes, required |
| Assign to me | PUT /api/admin/crm/support/ticket/assign |
No |
| Unassign selected | the same, agentId: null |
No |
| Delete | DELETE /api/admin/crm/support/ticket |
— |
This is not an internal audit note. The reason you type is appended to each selected ticket's own thread as a system message from you and broadcast on the socket the customer may still have open, so they are told why their ticket was closed rather than it simply vanishing. Write it as something a customer will read.
Assignment deliberately does not ask — handing work to a colleague is not a decision the customer needs explaining, and a mandatory field there is the noise that teaches operators to type "." to get past it.
The append is taken under a lock, because that bulk loop can run while an agent or the AI add-on is writing into the same ticket. Closing also resolves the AI Support session behind the ticket, when that add-on is installed, so a finished conversation stops showing as live in its console.
Bulk delete is a real delete and there is no reason field. Closing is almost always what you want instead — a deleted ticket takes its conversation, its first-response measurement and its evidence with it.
The inbox badge and the SLA
The support queue in the operations inbox in the admin header counts tickets
whose status is PENDING or OPEN — the two that mean waiting on you.
REPLIED is excluded because an agent has already answered. It links to
/admin/crm/support and is gated on access.support.ticket.
Its SLA budget is 24 hours, the tightest on the desk alongside disputes, and a ticket older than that counts as breached in both the inbox badge and the dashboard health card — both read the same constant, so they cannot disagree.
Automatic closing
There is no automatic ticket closing in the core platform. The
Close Tickets Nobody Replied To cron job (aiSupportAutoCloseResolved) is
registered by the AI Support add-on and is absent from the scheduler
entirely without it.
When it is installed, the job runs every 24 hours, closes tickets your team answered and the customer never replied to after the number of days you set, and posts an explanation into the thread before closing. It ships at zero days, which is off — nothing closes until you choose a number. See Scheduled jobs.
With the AI Support add-on installed
The console carries exactly one block that knows the add-on exists: a slot at the foot of the queue pane.
- Installed and enabled — it is a door to
/admin/ai/support/inbox, the Live Inbox, where the same conversations carry the assistant's retrieval evidence and a takeover button. - Not installed — it describes what the add-on does and links to its
extension page, and only for operators holding
access.extension. Desk agents who cannot buy add-ons are shown nothing rather than a locked door on every shift.
"Installed" means the extension is in the platform's enabled list, not merely present on disk — a disabled add-on has no inbox to send anyone to.
Everything else on this console is deliberately AI-free: no drafted replies, no confidence meters, no "teach from this ticket". Those need data this screen does not have, and half of them would 403 for an operator who has not bought the add-on.
Escalations do not get their own queue in the operations inbox, and that is
intentional rather than an omission: a session waiting for a human already has a
supportTicket in PENDING or OPEN, so the core support queue is already
counting it. A second row would count the same waiting customer twice.