Notifications and the live console
What lands in an agent's inbox, and how the console stays current.
Each agent has an in-app inbox per workspace: the bell in the console header. It points at work. It is not a record of it, and it sends no email by default.
What lands in the inbox
| Kind | When | Who is told | Opens |
|---|---|---|---|
MENTION | A teammate @mentions them in an internal note | Each person mentioned | The note |
ASSIGNED | A teammate hands them a ticket | The new assignee | The ticket |
NEW_TICKET | A customer opens a ticket by email or through the portal, including a follow-up to a closed ticket | The ticket's owner, or every active member of the workspace while the ticket is nobody's | The ticket |
CUSTOMER_REPLY | The requester, or someone they copied in, writes on a ticket, by email or through the portal | The ticket's owner, or every active member while it is nobody's | That message |
Why these recipients:
- New tickets go to everyone. Every new request is unassigned, and until someone takes it, nobody else is going to look. A follow-up to a closed ticket goes to the owner it inherits, if there is one.
- Replies go to the owner. They are the one waiting for the answer. An owner who has left the workspace or been blocked owns nothing, so the reply goes to the whole team, as the queue would show it.
- Nobody hears about machine mail: auto-replies, bounces, or anything with list headers. Nobody hears about mail in an agent's name either (see below), or about anything an agent does in the console. Opening a ticket on a customer's behalf is the agent's own doing.
- A reply notification that is still unread is moved up to the customer's newest reply rather than joined by another, so a customer who writes five times fills one line of the bell. Once it has been read, the next reply writes a new one.
Every notification is written in the same transaction as what it is about. It reaches only active members of the ticket's workspace (src/lib/notifications/record.ts). A broadcast reaches at most 200 members, the longest-standing first. That bounds what one inbound mail writes while it holds the workspace lock.
Limits:
- The badge counts to 100 and shows "99+" past it.
- The popover lists the newest 30.
- Notifications are kept for 90 days, read or not, and the hourly retention sweep removes older ones.
- Opening a ticket marks that ticket's notifications read, up to the newest the page was drawn with.
Email is unchanged. Mentions still send their courtesy email. NOTIFY_ASSIGNEE_ON_REPLY (default off) still emails the assignee only when a reply moves a Pending ticket back to Open. The new kinds are in-app only.
Keeping the console current
Nothing is pushed.
- The bell asks for its count when the console comes back into view (the tab is shown again, or its window is focused), and once a minute while it stays in view. It does not ask while its popover is open.
- The queue asks for the workspace's stamp in the same moments, and every 30 seconds while it stays in view. The stamp is the last ticket number handed out plus the newest activity, both read from an index. When the stamp moves, the queue redraws with
router.refresh(). That keeps the page's own state, including a half-typed search, and replaces only the rows and counts. - A redraw waits while the agent is in the middle of something: the pointer resting on the rows (on devices with a real pointer), the focus on a row, a menu, select or dialog open, text selected, or a navigation under way. It is tried again every 3 seconds until none of those holds.
- Nothing is asked while the page is hidden, and a question is never asked again while the previous one is unanswered. A question still unanswered when the page goes away is called off.
The activity route
Both ask one Route Handler, GET /api/console/<workspace>/activity (src/app/api/console/[ws]/activity/route.ts). It answers {"unread": 3, "stamp": "12:1790236800000"}: the bell's count, capped as the badge is, and the queue's stamp. It is not a Server Function because Next sends a client's Server Functions one at a time, so a background question would wait behind a reply being posted, or hold it up.
- The guard is the console's: a session, and membership of the workspace. A blocked person's session counts as signed out.
- It never redirects. Someone signed out gets
401 {"error": "unauthenticated"}. A workspace that is not theirs gets404 {"error": "not-found"}, whether it exists or not. A check nobody asked for must not take the page away from a reply being written, so the page keeps what it shows, and the agent's next real action meets the usual guard. - Nothing keeps an answer. Every response is
Cache-Control: no-store, and the console asks withcache: "no-store", at low priority. - Only the installation's own hostname serves it. On
APP_URL's host the proxy passes/api/*through without the console's sign-in redirect. On a brand's hostname it refuses it with a 404, as it refuses everything outside the customer surface. - Answers can arrive out of order with the page's own marking, which is still made through Server Functions. The bell takes an answer only if nothing has set the count since it asked: marking one notification or all of them, the popover's list, opening a ticket, or the layout rendering again.
Mail from an agent's own address
The receiving API gives no trusted sender verdict, so a From that names an agent proves nothing.
- Opening a ticket, an agent's address is a requester like any customer. The ticket gets an ordinary requester message and the usual confirmation email, and the team hears of it. This is the owner's natural first test: email the support address from their own mailbox. It also counts toward the requester's hourly burst limits, trusted or not.
- Writing on a ticket it opened, it is still that ticket's requester, and its reply is a customer's reply.
- Writing on anyone else's ticket, it would speak for the help desk: the mail would go on to that ticket's requester in the agent's name. Unless the workspace has turned on Allow unverified agent replies by email, it is kept as an internal note that says why, nothing is sent, and nobody is notified.
Migration
20260924130000_customer_notifications adds NEW_TICKET and CUSTOMER_REPLY to NotificationKind. It is additive, and nothing existing is read or rewritten.