Skip to content

In-app notification center

Business applications tell their users things: your request was approved, a document needs you, stock ran low. Notifications deliver those over mail and webhooks, and every notification can carry a recipient and a per-user opt-out (account surface). The in-app inbox is the place users actually expect these to land: a bell in the shell with an unread badge, and an inbox page behind it. It is a third channel type — not a new pipeline — so everything the outbox already guarantees (at-least-once retries, dead letters, the enqueue-time opt-out, the notification coverage kind) applies to inbox messages unchanged.

End to end: an application declares one inbox channel and one recipient:-addressed notify:; the event shows up as an unread badge in the shell and a message in the inbox, reading clears it, and opting out silences it — zero app code beyond those declarations.

tesseraql:
notifications:
channels:
approvals:
type: inbox # beside mail and webhook
title: "Request [(${payload.requestId})] was [(${payload.decision})]"
body: "Decided by [(${payload.decidedBy})]."
userOptOut: true # optional, shows the per-user opt-out toggle

A notify: on an inbox channel must name its recipient: (the per-user opt-out expression resolving to a subject) — an inbox message without an addressee is meaningless, so a missing one is a lint error, not a runtime surprise. The resolved recipient rides the outbox envelope (an optional field; envelopes without it decode with it absent), and NotificationSink has an inbox case beside mail and webhook that renders the title/body templates against the payload (the mail channel’s inline-template mechanism) and inserts into the managed table.

InboxStore SPI in tesseraql-core, JdbcInboxStore in tesseraql-operations, over:

create table if not exists tql_user_notification (
event_id varchar(64) not null, -- the outbox event id: the dedupe key
tenant_id varchar(64) not null,
subject varchar(255) not null,
channel varchar(128) not null,
source varchar(256) not null,
title varchar(500) not null,
body varchar(2000),
created_at timestamp not null,
read_at timestamp,
primary key (event_id)
);
  • Dedupe by outbox event id: at-least-once delivery means a crash between insert and acknowledge redelivers — the second insert hits the primary key and reads as already-delivered. No message ever doubles.
  • Retention: delivery opportunistically prunes read messages older than tesseraql.inbox.retentionDays (default 90) — the session-store prune-on-create pattern; unread messages stay.
  • The table lives outside the Flyway component set (the tql_user_preference pattern): ensureSchema is its only owner, so the schema never has two competing owners.
  • The store binds only when an inbox channel is declared — no channel, no table, no bell.
  • The shared shell grows a bell between the header slot and the user menu, rendered from a reserved _inbox variable (beside _account/_theme): a link to /_tesseraql/inbox carrying the unread count as an hc-badge when it is non-zero. The count is read through a short-TTL cache (the preference-store pattern) so the per-page cost is a map lookup, not a query.
  • /_tesseraql/inbox joins the bundled account app (same mount, same kill switch): newest-first list — title, body, source, time, unread highlight — with per-message Mark read and a Mark all read action. Session-only, CSRF on writes, and the subject is always the session principal’s: the page cannot read another user’s inbox by construction, exactly like the preference store.
  • Message bodies are plain text rendered escaped — a notification never carries markup into the page.

The bell’s badge is pushed, not polled: the shell subscribes it to the framework’s per-session event stream with the Hypermedia Components sse-updates recipe on htmx’s bundled sse extension — the same SseRoutes transport that carries the copilot’s streaming replies.

  • GET /_tesseraql/events — browser-session-authenticated SSE, mounted exactly when an inbox channel is configured (like the bell) or any route declares emit:. Named events are the wire contract: inbox:badge carries the badge fragment, each live-view topic the page asks for is one named data-free event, and idle ping frames double as heartbeats — one connection serves every live surface.
  • One choke point — the runtime binds the inbox store wrapped in a notifying layer: outbox delivery, mark-read, and mark-all-read all signal the subject’s open streams automatically. Signals coalesce per stream; the caching layer sits underneath, and a local mutation invalidates it before the signal fires, so the pushed count is fresh.
  • One markup sourceInboxBadge.html renders the fragment for both the page’s initial _inbox.badge and the pushed payload; an all-read inbox pushes an empty payload, clearing the badge. A pushed update and a reload are byte-identical.
  • The count is the accessible name’s, too — the fragment carries the visual badge (aria-hidden) plus a visually hidden “(N)”, capped at 99+. The bell anchor has no aria-label: its name is the subtree — a hidden localized “Notifications” stem in the shell plus the fragment’s count — so what a screen reader hears changes together with what the badge shows (the kit’s unread-badge contract). The fragment itself stays locale-free, which is what lets one pushed payload serve every session.
  • Bounded by construction — subscriptions are capped per subject and globally (a new stream evicts the oldest), and each stream ends itself after a fixed lifetime; the browser’s EventSource reconnects at the server-set retry delay, which also covers evictions. Initial state always renders server-side — the stream only freshens it, so without JavaScript the badge simply updates on the next page load.
  • Lint TQL-YAML-1034: a notify: on an inbox-type channel declares no recipient:.
  • TQL-ACCOUNT-4806: marking a message that is not the caller’s (or unknown) as read.
  • Delivery failures throw, so the dispatcher’s retry/dead-letter policy applies — except the duplicate-key case, which reads as already-delivered success.

The inbox deliberately reuses existing framework machinery rather than introducing a new delivery pipeline:

Concern Answer
Delivery guarantees The notifications outbox: at-least-once, retries, dead letters
Addressing The recipient: expression (account surface)
Muting The per-user opt-out (userOptOut: true shows the toggle; the enqueue check silences)
Testing The existing notification coverage kind and notify: suite targets
Chrome The reserved-variable + bundled-app pattern (_account, /_tesseraql/account)