# Notifications

> Use when designing notification surfaces: toasts, badges, push, inbox, frequency and batching, priority, quiet hours, and transactional email layout.

- Skill: `agentsorg/notifications` (Agent Skill, multi-file: 3 files)
- Install (CLI): `npx skillmds@latest add agentsorg/notifications`
- Raw SKILL.md: https://api.skillmd.com/api/skills/agentsorg/notifications/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Productivity
- Author: agentsorg (https://skillmd.com/u/agentsorg)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/agentsorg/notifications

---



# Designing a Notification System

Notifications are a shared budget spent against one person's attention, and the default is **ambient**: it lands in an inbox, it raises a count, and the user finds it when they look. An interrupt — a toast, a push, an email in the middle of the day — has to be argued for, per event, in writing. Design the system before the surface: a toast component built without a priority tier and a dedupe key becomes a firehose the moment a second feature starts emitting. **Boundary with `motion`:** how a toast enters, stacks, and swipes away is `motion`'s (see `motion/references/patterns.md`); how often it fires, what tier it carries, and whether it coalesces is this skill's. **Boundary with `ui-states`:** an error rendered where the data would have been is `ui-states`; an error thrown at the user somewhere else is this skill. Push-permission *timing* belongs to `onboarding`.

**Find the emitters before writing any component.** Grep for what already sends: a toast library (`sonner`, `react-hot-toast`, `@radix-ui/react-toast`), a push service worker and `Notification` calls, a mail sender (`resend`, `postmark`, `sendgrid`, `nodemailer`), an existing `notifications` table, a job queue, and any per-user preference or timezone column. Most products already have three uncoordinated emitters and no shared tier, and the real work is routing them through one emit path — not adding a fourth. Match the project's existing template and styling systems for both in-app surfaces and email.

## Quick Reference

| Topic | Where |
| --- | --- |
| Once an event's tier is known and you need to route it to concrete surfaces — which channels fire, what batches, what quiet hours do, what the user can turn off | [priority-matrix.md](references/priority-matrix.md) |
| Before writing or reviewing any transactional email markup — client constraints, table layout, inline CSS, dark mode, images, width, preheader, unsubscribe headers | [html-email.md](references/html-email.md) |

## Decision Framework: interrupt or ambient?

An event may interrupt only if **both** are true:

1. The user has to act on it **within this session**, and
2. not seeing it now causes a loss they cannot recover later.

One yes is not enough. "Their teammate replied" fails (2). "Your export finished" fails (1) — that is a badge. "Your payment failed and service stops in 24 hours" passes both. Everything failing the test is ambient: inbox row, badge count, or an entry in a digest.

Then place the surface by tier:

| Tier | In-app | Push / email | Persists? |
| --- | --- | --- | --- |
| `critical` | Blocking or persistent banner | Both, bypasses quiet hours | Until resolved, not until seen |
| `actionable` | Toast with an action, plus inbox row | Push if the user opted in; batched email | Inbox row until acted on |
| `informational` | Inbox row, badge count | Digest only | Until read |
| `ambient` | Count only | Never | Auto-expires |

## Core Principles

1. **Assign the tier at emit and store it on the event.** Routing, batching, quiet hours, digesting, and the user's preference screen all key off one field; deciding tier inside the toast component means every channel re-derives it differently and they drift within a quarter. Tier is one of `critical | actionable | informational | ambient`. *Exception:* a user preference may **demote** a tier; nothing but the system may promote anything to `critical`.

2. **One event produces one notification across all channels.** Fan out from a single emit carrying a shared `dedupe_key`, so the same comment cannot arrive as a toast *and* a push *and* an email *and* an inbox row. If the user was looking at the screen when it happened, the other channels are suppressed. *Exception:* `critical` intentionally duplicates across channels, because the cost of a miss exceeds the cost of the annoyance.

3. **Coalesce on `(actor, verb, object)` inside a window; render counts, never repeats.** Five comments on one document is one row reading "Sam and 4 others commented on Roadmap", not five rows — an inbox that repeats is an inbox people stop opening. Newest activity refreshes the row's timestamp and un-reads it. *Exception:* `critical` never coalesces; two failed payments are two problems.

4. **Compute toast duration from word count at `200–250 wpm`; never ship a flat value for every message.** A nine-word warning and a two-word confirmation cannot share a timer — one is unreadable and the other overstays. Pause the timer while the toast is hovered or focused, and while the tab is hidden, so a message emitted in a background tab is still there on return. *Exception:* a burst of identical confirmations collapses into one slot at the shortest computed duration rather than stacking.

5. **A toast carrying an action or an error does not auto-dismiss.** An `Undo` that expires while the user is still reading it is a trap, and an error that vanishes leaves someone with no way to find out what happened. Give both an explicit close. *Exception:* an undo whose window is genuinely time-bounded by the backend may auto-dismiss when that window closes — and it must show the window, not hide it.

6. **A badge counts only things the user can act on, and it clears on view, not on visit.** The count is `unread AND actionable`, nothing else — a number padded with marketing announcements teaches people the number is a lie, after which it is decoration. Entering the surface is not reading; clear per item as each is seen. *Exception:* an unbounded or expensive-to-count set gets a dot with no number rather than a wrong number.

7. **Read and dismissed state is per-user, server-side, and syncs across devices.** Store `read_at` and `dismissed_at` on the `(user, notification)` row; dismissing on a laptop and finding it unread on a phone is the most common trust failure in this surface. *Exception:* transient toasts, which are the one notification class that legitimately dies with the tab — anything that must survive a reload was never a toast.

8. **Never notify a user about their own foreground action.** The UI changing *is* the confirmation; a `Saved` toast on top of a visibly saved document is noise, and it is how a product ends up with three toasts per interaction. *Exception:* actions whose result is invisible or arrives later (a background export, an async invite), and any action with an undo window.

9. **Quiet hours defer; they never drop.** Compute from the user's stored timezone, not the server's, and release the queue at the boundary as a batch rather than a burst. A dropped notification is a bug the user reports as data loss. *Exception:* `critical` bypasses quiet hours, and that is the entire reason the tier exists.

10. **Every channel is independently controllable, and bulk mail is unsubscribable in one click.** Ship `List-Unsubscribe` with `List-Unsubscribe-Post: List-Unsubscribe=One-Click` (RFC 8058) on bulk sends, and keep transactional and marketing on separate streams and separate preference toggles — unsubscribing from a newsletter must never stop a password-reset email. *Exception:* none. A transactional message that cannot be turned off must genuinely be transactional.

## Smell / Fix

| Smell | Fix |
| --- | --- |
| Toast, push, email and inbox row for one comment | One emit, one dedupe key, suppress the rest |
| `duration: 3000` on every toast | Derive from word count at `200–250 wpm` |
| Undo toast that auto-dismisses | Actions and errors never auto-dismiss |
| Badge that counts announcements | Count only actionable items; clear per item on view |
| Read state in `localStorage` | Per-user, server-side, synced |
| Five rows for five comments on one doc | Coalesce on `(actor, verb, object)` |
| "Saved" toast over a visibly saved document | Delete it; the UI change is the confirmation |
| Quiet hours implemented as a drop | Defer and release at the boundary |
| Tier decided inside the toast component | Assign at emit, store on the event |
| Quiet hours computed in server time | Use the user's stored timezone |
| One "notifications" on/off switch | Per-channel and per-tier controls |
| Inline error thrown as a toast | Render it where the data was — `ui-states` |

## Output format

Spec a notification system as one row per event type, before any component is written:

| Event | Tier | Channels | Dedupe key | Coalesce on / window | Dismiss | Quiet hours |
| --- | --- | --- | --- | --- | --- | --- |

`Channels` lists only what actually fires after suppression. `Dismiss` is one of `auto (computed)`, `explicit`, or `on resolve`. `Quiet hours` is `defer` or `bypass` — and `bypass` requires the tier to be `critical`.

## Checklist

- [ ] Every event has a tier assigned at emit and stored on the record
- [ ] Interrupt-vs-ambient test applied per event, both conditions required
- [ ] One event, one notification; dedupe key present; in-session suppression works
- [ ] Coalescing key and window defined for every repeatable event
- [ ] Toast durations computed from word count; timer pauses on hover, focus, and hidden tab
- [ ] Toasts with actions or errors do not auto-dismiss
- [ ] Badge counts actionable items only and clears per item on view
- [ ] Read/dismissed state per-user and server-side
- [ ] Quiet hours defer, computed in the user's timezone; only `critical` bypasses
- [ ] Transactional and marketing streams separated; `List-Unsubscribe-Post` on bulk mail
- [ ] Toast motion handed to `motion`; permission timing handed to `onboarding`

