The support queue is backing up

How the desk console orders work, why "waiting" is computed from the conversation and not the status column, and the order to drain a backlog in.

8 min readUpdated 6 August 2026support, tickets, queue, sla, runbook

Support carries the tightest target on the platform — 24 hours, against seven days for a withdrawal — and it is the only core queue whose health check fires on priority rather than on age. If the Operations inbox is amber on Support, or the system health card says "N high priority tickets open", this is the page.

The work happens on Users → Customer Support (/admin/crm/support). Everything else — search across the whole desk, export, bulk verbs, closed tickets — is on the archive at /admin/crm/support/tickets, which has no menu entry of its own and is reached from the console's bar.

The console

/admin/crm/support is a console, not a page. It has no site header and no footer (the route is on the admin layout's chromeless list), and it fills exactly one viewport with three panes:

Pane What it holds
Left — the queue One row per conversation: subject, customer, the last thing said, a HIGH-priority chip, the age, the message count, and who owns it
Centre — the conversation The whole thread, customer messages on one side and staff on the other, with a composer at the foot
Right — the case Ownership first, then status, priority, assignment and the customer's details. Below xl it is a slide-over instead of a column

::kbd[J] moves down the queue and ::kbd[K] moves up, bound to the rows you can currently see — so they agree with the filter you have on rather than jumping to something off screen. Both are suppressed while a text field has focus, so j is still a letter in the composer.

The queue polls every 20 seconds; the open conversation holds a websocket, so a customer's reply lands without a refresh. The ticket id is written into the URL as ?ticket=<id>, which makes a conversation pasteable to a colleague.

The composer only appears for edit.support.ticket. The route itself is gated on access.support.ticket, so a read-only agent gets the queue and the thread and no buttons — deliberately, rather than a row of controls that 403.

There is no create action on the desk, none on the archive table, and no POST route behind either. Tickets are raised by customers, through the help centre or the live-chat widget. If you need a conversation with a customer that they have not started, use the notification and email tooling, not this desk.

"Waiting" is not a status

This is the one idea the whole screen is built on, and it is the thing that makes the counters look like they disagree.

Waiting on us means the customer spoke last. The queue route reads each ticket's message thread, drops the system chips, looks at who wrote the last real message, and orders the desk by that:

  1. Waiting on us — the customer spoke last, oldest wait first
  2. Waiting on them — we spoke last
  3. Closed — only when you ask for it

A ticket whose thread is empty also counts as waiting on us: a ticket raised through the form carries its body on the ticket rather than as a message, and nobody has answered it.

The status column is a separate axis with four values:

Status Means
PENDING No agent has ever replied
OPEN The customer spoke last
REPLIED An agent spoke last
CLOSED Done

Those normally agree with the thread — the customer's reply route writes OPEN, the agent reply route writes REPLIED. They can still disagree, because status is also writable by hand from the case pane's status control and from the archive's bulk actions. Set a ticket to REPLIED while the customer's message is the last one in it and the column says "answered" while the thread says "waiting". The thread wins on the console; the column drives every counter.

PUT /api/admin/crm/support/ticket/assign writes status: OPEN on every ticket it assigns (and PENDING on every one it unassigns), across rows currently PENDING, OPEN or REPLIED. CLOSED rows are left alone.

So bulk-assigning a batch that includes answered tickets moves them to OPEN. The console's Waiting filter will not move — it reads the thread — but the Operations inbox counts PENDING + OPEN, and the system health check counts OPEN, so both climb. Select the tickets you mean, not the page.

If you are looking at an install that was upgraded rather than freshly seeded, note that REPLIED used to be written by both reply routes, for two opposite events. The migration 20260803000001-support-ticket-status-repair re-derives every non-closed ticket's status from its own thread (OPEN if the customer spoke last, REPLIED if an agent did, PENDING if nothing was said) and normalises the double-encoded messages blobs it finds on the way. It never touches CLOSED.

The five filters

The tab strip above the queue is the filter. Four of them are views of the same fetched set; Closed is a different query.

Filter Shows Count on the tab
Waiting The customer spoke last, oldest first Yes — amber while non-zero
Mine Assigned to your account Yes
Free No agent assigned Yes
All Everything the queue fetched Yes
Closed status = CLOSED, fetched separately No — the closed set is not in memory until you ask for it, so any number here would be a guess

The search box beside them matches subject, customer name, customer email and the message preview, on the rows already loaded.

Empty live-chat shells are excluded from all of it. The widget mints a LIVE ticket the moment a customer opens the bubble, before a word is typed; the desk's scope requires a LIVE ticket to have at least one message before it counts.

The badge in the header counts differently

The Operations inbox in the admin header polls once a minute and its Support queue counts status IN ('PENDING', 'OPEN') — no agent has replied yet, or the customer spoke last. REPLIED means an agent has already answered, so it is not on your desk.

The age it shows is the age of the oldest row in that set, measured against SLA_HOURS.support = 24:

  • Amber at half the budget — 12 hours.
  • Red once anything is past 24 hours, with an overdue count beside it.

The Age column on the archive table is computed from the same number and only tints rows in PENDING or OPEN, so a settled ticket from last year is never painted red.

The health check fires on priority, not age

GET /api/admin/system/health/batch has a Support Queue entry, and it is unusual:

Condition Result
Any ticket with status = OPEN and importance = HIGH warning — "N high priority tickets open"
Otherwise up — "N open tickets"

There is no age term in it at all. One high-priority ticket opened a minute ago turns the card amber, and a hundred ordinary tickets three days old do not. It is also critical: false, so it never turns the overall health verdict red.

Treat it as a triage signal: it is telling you that something someone marked HIGH is unanswered. Priority is set on the case pane (LOW, MEDIUM, HIGH), so it is a claim a human made about the ticket, and it is worth checking that the claim is right before you reorder your day around it.

Draining a backlog

Work it in this order. Assignment first, because the queue puts the oldest waiting ticket at the top of every agent's screen — without owners, two agents answer the same customer and one of them wastes the reply.

  1. Take stock on the archive, not the console. Open /admin/crm/support/tickets. The KPI strip there describes the whole desk: total, awaiting response (pending + open), unassigned, resolved, and the average first-response time. The console's queue is capped, so its counts describe a page, not the desk.

  2. Hand out the unowned work. Still on the archive, filter to the tickets you want, select them, and use Assign to me or Unassign selected from the bulk menu. Assignment deliberately takes no reason — handing work to a colleague is not a decision the customer needs explained. Remember it rewrites status; see the warning above.

  3. Work the console on Waiting, top down. Back on /admin/crm/support, choose Waiting. The top row is the customer who has been waiting longest. Answer, set the status, ::kbd[J] to the next one. The screen never changes.

  4. Sweep Free afterwards. Anything left under Free is work nobody has claimed. If it is still there at the end of a shift, it is the queue that will breach first.

  5. Close in bulk from the archive, with a reason. Close selected and Reopen selected both route through the decision dialog and both require a reason, because the endpoint appends it to each ticket's own conversation — the customer is told why their ticket was closed instead of it silently vanishing.

The console is capped; the archive is not

GET /api/admin/crm/support/inbox returns 60 rows by default and 200 at most, selected newest-activity-first before the waiting-order is applied. That keeps the console on the live end of the desk. Its response carries a capped flag, and its total is the size of the page it returned — not the size of your desk.

/admin/crm/support/tickets is the only screen that reaches every ticket ever raised. It is a paged table over the same population with per-column filters, search across the whole desk, CSV export, the analytics tabs, and the four bulk verbs. It opens filtered to status in (PENDING, OPEN) sorted oldest-first, so its default view is the same work the console shows — widen the status filter to find anything older.

Rows link through to the single-ticket page at /admin/crm/support/{id}.

Where the AI Support add-on fits

The desk console carries no AI: no drafted replies, no retrieval evidence, no confidence meters. That is deliberate — those need data this route does not have, and half of them would 403 for an operator who has not bought the add-on.

The one slot that knows the add-on exists sits at the foot of the queue pane, and it has two states:

  • Installed and enabled — a link to /admin/ai/support/inbox, the Live Inbox, where the same tickets carry the assistant's reasoning, its citations and a takeover button. That screen is gated on view.support.ticket, the key your desk agents already hold, so it appears on upgrade with no grant step.
  • Not installed — a description of what the add-on does and a link to its extension page. It is hidden entirely from operators without access.extension, since a locked door on every shift is noise they cannot act on.

Either way, if the assistant has answered a ticket, its replies appear in this console's thread drawn with the AI badge and a bot glyph. That is a disclosure requirement, not a feature.