Org Status: π’ Active Cloudflare: N/A Last Audited: 2026-04-28
Every notification system faces the same fundamental tension: sources want attention, users want peace. iOS solves this with interruption levels and Focus modes. Android solves it with notification channels and importance levels. Linux solves it with D-Bus urgency hints. Car dashboards solve it with NHTSA-compliant priority preemption. Smart displays solve it with proximity-aware rendering.
But what happens when youβre building a tiny ambient display β a macOS notch HUD where multiple AI agents, system monitors, and user scripts all compete for a single line of text? None of these systems map directly. You need to understand all of them, extract the patterns that work, and design something new.
What youβll learn:
- How iOS, macOS, Android, and Linux each handle notification priority, grouping, and filtering
- The design patterns behind Dynamic Island, Focus modes, and notification channels
- How smart displays, car dashboards, and smartwatches manage competing information
- Conflict resolution strategies: preemption, queuing, time-sharing, summarization
- A complete TypeScript API design for an intelligent notification HUD
- Policy engine architecture with rules for what shows, when, and for how long
- How to handle RSVP interruption, simultaneous senders, and quiet hours
You have a programmable status bar embedded in a macOS notch. The display area is roughly 600 pixels wide and 28 pixels tall β enough for one line of text, maybe a small icon. Multiple sources want to use it:
- AI agents (Athena, Jane) reporting system status, task completion, anomalies
- System monitors tracking CPU, memory, network, battery
- User scripts showing timers, reminders, weather, music info
- Cloud services sending webhook notifications
- RSVP reader cycling through text at 300+ words per minute
These sources donβt coordinate with each other. They donβt know what else is trying to display. They each think their content is important. Without a policy layer, the display becomes a seizure-inducing mess of flickering text.
The existing approaches all fall short for this use case:
| Approach | Why it fails for a tiny HUD |
|---|---|
| iOS notifications | Designed for a large screen with a notification drawer; assumes notifications stack vertically |
| Android channels | Assumes the user configures per-app settings through a full settings UI |
| Linux D-Bus | Fire-and-forget; no concept of display ownership or preemption policy |
| Dashboard tools | Designed for multi-widget layouts with ample screen space |
| Alert systems (PagerDuty) | Designed for human escalation, not ambient display management |
What changes if you get this right:
- The notch becomes a calm technology β information at a glance without cognitive load
- AI agents can communicate status without interrupting focused work
- Urgent alerts break through immediately while ambient data flows smoothly
- Users retain control over their attention without needing to configure dozens of settings
1. Interruption Levels
Every notification system, regardless of platform, implicitly or explicitly defines interruption levels. The concept determines whether a notification can break through the userβs current context.
/**
* Universal interruption level taxonomy derived from iOS, Android,
* and Linux notification systems.
*
* iOS: passive | active | timeSensitive | critical
* Android: MIN | LOW | DEFAULT | HIGH (maps to importance)
* Linux: low | normal | critical
* Pushover: -2 | -1 | 0 | 1 | 2
*/
enum InterruptionLevel {
/** Silent. No visual change. Logged only.
* iOS: passive. Android: IMPORTANCE_MIN. Linux: low. */
SILENT = 0,
/** Ambient. Updates display if nothing else is showing.
* iOS: passive. Android: IMPORTANCE_LOW. Linux: low. */
AMBIENT = 1,
/** Standard. Shown at next opportunity (after current content expires).
* iOS: active. Android: IMPORTANCE_DEFAULT. Linux: normal. */
STANDARD = 2,
/** Elevated. Preempts ambient/standard content within 2 seconds.
* iOS: active (with sound). Android: IMPORTANCE_HIGH. Linux: normal. */
ELEVATED = 3,
/** Urgent. Immediately preempts everything except critical.
* iOS: timeSensitive. Android: IMPORTANCE_HIGH (heads-up). Linux: critical. */
URGENT = 4,
/** Critical. Bypasses all filters, quiet hours, focus modes.
* iOS: critical (requires Apple entitlement). Android: foreground service.
* Linux: critical. Pushover: emergency (2). */
CRITICAL = 5,
}
Key insight: iOS nailed the taxonomy with four levels, but for an ambient display you need six. The gap between βpassiveβ and βactiveβ is too wide β you need AMBIENT (update if idle) and STANDARD (show at next opportunity) as distinct behaviors.
2. Notification Channels
Android introduced notification channels in API 26 (Oreo) as a way to give users per-category control over notification behavior. The concept is powerful: instead of per-app settings, you have per-purpose settings.
/**
* A channel defines a category of notifications with shared behavior.
* Users can override the default importance of any channel.
*
* Android requires channels since API 26.
* iOS achieves similar grouping via notification categories.
* Our HUD uses channels to partition display time.
*/
interface NotificationChannel {
/** Unique identifier. Once created, the ID cannot change. */
id: string;
/** Human-readable name shown in settings UI. */
name: string;
/** Description of what this channel carries. */
description: string;
/** Default interruption level for notifications in this channel. */
defaultLevel: InterruptionLevel;
/** User override. If set, takes precedence over defaultLevel. */
userOverride?: InterruptionLevel;
/** Whether the user has muted this channel entirely. */
muted: boolean;
/** Maximum notifications per minute from this channel. */
rateLimit: number;
/** How to handle multiple notifications: replace, queue, or summarize. */
collapseStrategy: "replace" | "queue" | "summarize";
/** Display duration in milliseconds. null = until displaced. */
defaultTTL: number | null;
/** Visual style hint. */
style?: "text" | "metric" | "progress" | "alert";
}
Key insight: Androidβs fatal design flaw is that channel importance cannot be changed programmatically after creation. This is a feature for user trust β it prevents apps from escalating their own importance. Our HUD should adopt this: sources declare channels, but only the user (or the policy engine) can change their effective importance.
3. Notification Identity and Lifecycle
Every notification needs a lifecycle β itβs created, displayed, potentially updated, and eventually expires or is dismissed. The freedesktop.org D-Bus specification handles this elegantly with replaces_id.
/**
* A notification is a discrete unit of information with an identity
* and a lifecycle.
*
* Mirrors the freedesktop.org Notify method signature:
* UINT32 Notify(app_name, replaces_id, icon, summary, body, actions, hints, expire_timeout)
*
* And Firebase Cloud Messaging's collapse_key concept:
* Messages with the same collapse_key replace each other.
*/
interface Notification {
/** Unique ID assigned by the notification system. */
id: string;
/** Source identifier (app, agent, service). */
source: string;
/** Channel this notification belongs to. */
channel: string;
/** If set, this notification replaces an existing one with this ID.
* Equivalent to freedesktop replaces_id or FCM collapse_key. */
replacesId?: string;
/** The content to display. */
content: NotificationContent;
/** Interruption level. Defaults to channel's default if not set. */
level?: InterruptionLevel;
/** When this notification was created. */
createdAt: number;
/** When this notification expires. null = no expiry. */
expiresAt: number | null;
/** Current state in the lifecycle. */
state: "pending" | "active" | "displayed" | "expired" | "dismissed";
/** Number of times this notification has been updated via replacesId. */
updateCount: number;
/** Relevance score for summary ordering (0-1).
* iOS uses this for notification summary ranking. */
relevanceScore?: number;
}
interface NotificationContent {
/** Short text for the status bar (max ~60 chars). */
summary: string;
/** Longer text for hover/expanded view. */
body?: string;
/** Structured data for metric display. */
metric?: { label: string; value: string; trend?: "up" | "down" | "flat" };
/** Icon identifier or emoji. */
icon?: string;
/** Severity coloring. */
severity?: "green" | "yellow" | "red";
/** Actions the user can take (like iOS actionable notifications). */
actions?: NotificationAction[];
}
interface NotificationAction {
/** Unique action identifier. */
id: string;
/** Display label. */
label: string;
/** URL or callback identifier to invoke. */
handler: string;
/** Whether this action is destructive (shown in red). */
destructive?: boolean;
}
Key insight: The
replacesIdpattern from freedesktop.org is essential for an ambient display. Without it, a CPU monitor sending updates every second would flood the queue. With it, each update replaces the previous one β the display always shows the latest value. FCM calls this βcollapsible messagesβ and limits you to 4 simultaneous collapse keys per device.
4. Focus Modes
iOS 15 introduced Focus modes as a generalization of Do Not Disturb. Android has a simpler DND with priority exceptions. Both recognize that notification filtering should be contextual β different rules for different situations.
/**
* A focus mode defines a notification filtering policy that activates
* based on time, location, or manual toggle.
*
* iOS Focus modes: Work, Personal, Sleep, Driving, custom
* Android: Do Not Disturb with priority exceptions
* Grafana: Mute timings (recurring) vs silences (one-time)
*/
interface FocusMode {
/** Unique identifier. */
id: string;
/** Human-readable name. */
name: string;
/** Whether this focus mode is currently active. */
active: boolean;
/** Channels that are allowed through during this focus mode. */
allowedChannels: string[];
/** Sources that are allowed through regardless of channel. */
allowedSources: string[];
/** Minimum interruption level that can break through.
* Everything below this level is suppressed. */
minimumLevel: InterruptionLevel;
/** Schedule for automatic activation. */
schedule?: FocusModeSchedule;
/** What to show instead of suppressed notifications. */
fallback: "nothing" | "count" | "summary";
}
interface FocusModeSchedule {
/** Days of the week (0=Sunday). */
days: number[];
/** Start time in HH:MM format. */
startTime: string;
/** End time in HH:MM format. */
endTime: string;
/** Timezone. */
timezone: string;
}
Key insight: Grafanaβs distinction between βmute timingsβ (recurring, like weekends) and βsilencesβ (one-time, like a maintenance window) is more useful than iOSβs Focus model for a HUD. You want both: scheduled quiet hours AND the ability to say βmute everything for 30 minutes while I present.β
Pattern 1: Priority Queue with Preemption
The most fundamental pattern. Every notification enters a priority queue. The display always shows the highest-priority item. When a higher-priority notification arrives, it preempts whatβs currently showing.
When to use it: As the base layer of any notification display system. Every other pattern builds on this.
/**
* Priority queue with preemption for a single-line display.
*
* Implements the core display scheduling algorithm:
* 1. Notifications enter the queue sorted by priority
* 2. The display always shows the head of the queue
* 3. Higher-priority items preempt lower-priority ones
* 4. Items expire based on TTL and are removed
* 5. When the current item expires, the next one shows
*
* This mirrors how car dashboards work: navigation directions
* preempt music info, incoming calls preempt navigation,
* collision warnings preempt everything.
*/
class NotificationPriorityQueue {
private queue: QueueEntry[] = [];
private currentDisplay: QueueEntry | null = null;
private displayCallback: (entry: QueueEntry | null) => void;
private expiryTimers: Map<string, ReturnType<typeof setTimeout>> = new Map();
constructor(onDisplayChange: (entry: QueueEntry | null) => void) {
this.displayCallback = onDisplayChange;
}
/**
* Submit a notification to the queue.
* Returns the assigned queue position.
*/
submit(notification: Notification): number {
const entry: QueueEntry = {
notification,
effectivePriority: this.calculatePriority(notification),
enqueuedAt: Date.now(),
displayedAt: null,
};
// Check if this replaces an existing notification
if (notification.replacesId) {
const existingIndex = this.queue.findIndex(
(e) => e.notification.id === notification.replacesId
);
if (existingIndex !== -1) {
const existing = this.queue[existingIndex];
entry.notification.updateCount = existing.notification.updateCount + 1;
this.queue.splice(existingIndex, 1);
this.clearExpiryTimer(notification.replacesId);
}
}
// Insert in priority order (highest first)
const insertIndex = this.queue.findIndex(
(e) => e.effectivePriority < entry.effectivePriority
);
if (insertIndex === -1) {
this.queue.push(entry);
} else {
this.queue.splice(insertIndex, 0, entry);
}
// Set expiry timer
if (notification.expiresAt) {
const delay = notification.expiresAt - Date.now();
if (delay > 0) {
const timer = setTimeout(() => {
this.expire(notification.id);
}, delay);
this.expiryTimers.set(notification.id, timer);
} else {
// Already expired, don't even queue it
return -1;
}
}
// Check if this should preempt the current display
this.evaluateDisplay();
return this.queue.findIndex(
(e) => e.notification.id === notification.id
);
}
/**
* Calculate effective priority from notification properties.
*
* Priority is a composite score:
* - Base: interruption level (0-5) * 1000
* - Boost: relevance score * 100
* - Decay: time-based decay for aging notifications
* - Escalation: +500 if notification has been waiting too long
*/
private calculatePriority(notification: Notification): number {
const level = notification.level ?? InterruptionLevel.STANDARD;
let priority = level * 1000;
// Relevance score boost (0-100 points)
if (notification.relevanceScore !== undefined) {
priority += notification.relevanceScore * 100;
}
return priority;
}
/**
* Evaluate whether the current display should change.
* Called after every queue mutation.
*/
private evaluateDisplay(): void {
if (this.queue.length === 0) {
if (this.currentDisplay !== null) {
this.currentDisplay = null;
this.displayCallback(null);
}
return;
}
const topEntry = this.queue[0];
// Nothing currently displayed β show the top item
if (!this.currentDisplay) {
this.showEntry(topEntry);
return;
}
// Something is displayed β check if the top item should preempt it
const currentPriority = this.currentDisplay.effectivePriority;
const newPriority = topEntry.effectivePriority;
if (topEntry.notification.id === this.currentDisplay.notification.id) {
// Same notification (possibly updated) β refresh display
this.showEntry(topEntry);
return;
}
if (newPriority > currentPriority) {
// Higher priority β preempt
this.showEntry(topEntry);
return;
}
// Lower or equal priority β wait for current to expire
}
private showEntry(entry: QueueEntry): void {
entry.displayedAt = Date.now();
entry.notification.state = "displayed";
this.currentDisplay = entry;
this.displayCallback(entry);
}
/**
* Remove an expired notification from the queue.
*/
private expire(notificationId: string): void {
const index = this.queue.findIndex(
(e) => e.notification.id === notificationId
);
if (index !== -1) {
this.queue[index].notification.state = "expired";
this.queue.splice(index, 1);
}
this.clearExpiryTimer(notificationId);
// If the expired item was being displayed, show the next one
if (this.currentDisplay?.notification.id === notificationId) {
this.currentDisplay = null;
this.evaluateDisplay();
}
}
private clearExpiryTimer(notificationId: string): void {
const timer = this.expiryTimers.get(notificationId);
if (timer) {
clearTimeout(timer);
this.expiryTimers.delete(notificationId);
}
}
/**
* Dismiss a notification (user action).
*/
dismiss(notificationId: string): void {
const index = this.queue.findIndex(
(e) => e.notification.id === notificationId
);
if (index !== -1) {
this.queue[index].notification.state = "dismissed";
this.queue.splice(index, 1);
}
this.clearExpiryTimer(notificationId);
if (this.currentDisplay?.notification.id === notificationId) {
this.currentDisplay = null;
this.evaluateDisplay();
}
}
/** Get queue state for debugging/UI. */
getState(): { current: QueueEntry | null; queued: QueueEntry[] } {
return {
current: this.currentDisplay,
queued: [...this.queue],
};
}
}
interface QueueEntry {
notification: Notification;
effectivePriority: number;
enqueuedAt: number;
displayedAt: number | null;
}
Gotchas:
- Priority inversion: a long-TTL medium-priority notification can block lower-priority items indefinitely. Solution: add a maximum display time per priority level.
- Starvation: if high-priority sources keep sending, low-priority content never shows. Solution: time-sharing (Pattern 3).
- Thundering herd: if 10 agents all send ELEVATED notifications at once, which one wins? Solution: use relevance score as a tiebreaker, then FIFO within the same level.
Connection to other patterns: This is the foundation. Pattern 2 (Rate Limiting) prevents queue flooding. Pattern 3 (Time-Sharing) prevents starvation. Pattern 4 (Channel Policies) maps source behavior to queue priorities.
Pattern 2: Rate Limiting and Notification Budget
iOS throttles notifications from noisy apps. Android rate-limits notification updates. Firebase Cloud Messaging has a per-device notification budget. Pushover limits emergency retries. Every system needs rate limiting to prevent abuse.
When to use it: Always. Without rate limiting, a single misbehaving source can monopolize the display.
/**
* Rate limiter for notification sources.
*
* Implements three strategies:
* 1. Token bucket: allows bursts but limits sustained rate
* 2. Sliding window: hard limit per time window
* 3. Adaptive: adjusts limits based on user engagement
*
* iOS budget system: undocumented per-device budget, progressive
* throttling for background execution.
* Android: automatically groups when an app sends 4+ notifications.
* FCM: can store max 4 collapsible messages per device per collapse key.
* Pushover emergency: retry every 30+ seconds, expire after N seconds.
*/
class NotificationRateLimiter {
private buckets: Map<string, TokenBucket> = new Map();
private windowCounters: Map<string, SlidingWindowCounter> = new Map();
private channelLimits: Map<string, ChannelRateConfig> = new Map();
constructor(private defaultConfig: RateLimitConfig) {}
/**
* Register rate limit configuration for a channel.
*/
configureChannel(channelId: string, config: ChannelRateConfig): void {
this.channelLimits.set(channelId, config);
}
/**
* Check if a notification from a source/channel is allowed.
* Returns { allowed, retryAfterMs, reason }.
*/
check(
source: string,
channel: string,
level: InterruptionLevel
): RateLimitResult {
// Critical notifications bypass rate limiting entirely
// This mirrors iOS critical alerts that bypass all controls
if (level >= InterruptionLevel.CRITICAL) {
return { allowed: true };
}
const channelConfig = this.channelLimits.get(channel);
const config = channelConfig ?? this.defaultConfig;
// Check source-level rate limit (token bucket)
const sourceKey = `${source}:${channel}`;
let bucket = this.buckets.get(sourceKey);
if (!bucket) {
bucket = new TokenBucket(
config.maxBurst,
config.refillRate,
config.refillIntervalMs
);
this.buckets.set(sourceKey, bucket);
}
if (!bucket.tryConsume()) {
return {
allowed: false,
retryAfterMs: bucket.msUntilNextToken(),
reason: `Source ${source} exceeded burst limit for channel ${channel}`,
};
}
// Check global per-source limit (sliding window)
let counter = this.windowCounters.get(source);
if (!counter) {
counter = new SlidingWindowCounter(
config.windowMs,
config.maxPerWindow
);
this.windowCounters.set(source, counter);
}
if (!counter.tryIncrement()) {
return {
allowed: false,
retryAfterMs: counter.msUntilWindowSlides(),
reason: `Source ${source} exceeded ${config.maxPerWindow} notifications per ${config.windowMs}ms`,
};
}
return { allowed: true };
}
}
/**
* Token bucket algorithm for burst-tolerant rate limiting.
* Allows short bursts while limiting sustained throughput.
*/
class TokenBucket {
private tokens: number;
private lastRefill: number;
constructor(
private maxTokens: number,
private refillRate: number,
private refillIntervalMs: number
) {
this.tokens = maxTokens;
this.lastRefill = Date.now();
}
tryConsume(): boolean {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return true;
}
return false;
}
msUntilNextToken(): number {
this.refill();
if (this.tokens >= 1) return 0;
return this.refillIntervalMs;
}
private refill(): void {
const now = Date.now();
const elapsed = now - this.lastRefill;
const tokensToAdd = Math.floor(
(elapsed / this.refillIntervalMs) * this.refillRate
);
if (tokensToAdd > 0) {
this.tokens = Math.min(this.maxTokens, this.tokens + tokensToAdd);
this.lastRefill = now;
}
}
}
/**
* Sliding window counter for hard rate limits.
*/
class SlidingWindowCounter {
private timestamps: number[] = [];
constructor(
private windowMs: number,
private maxCount: number
) {}
tryIncrement(): boolean {
const now = Date.now();
const windowStart = now - this.windowMs;
// Remove expired timestamps
this.timestamps = this.timestamps.filter((t) => t > windowStart);
if (this.timestamps.length >= this.maxCount) {
return false;
}
this.timestamps.push(now);
return true;
}
msUntilWindowSlides(): number {
if (this.timestamps.length === 0) return 0;
const oldest = this.timestamps[0];
return Math.max(0, oldest + this.windowMs - Date.now());
}
}
interface RateLimitConfig {
/** Maximum burst size (tokens). */
maxBurst: number;
/** Tokens refilled per interval. */
refillRate: number;
/** Interval for token refill in ms. */
refillIntervalMs: number;
/** Sliding window size in ms. */
windowMs: number;
/** Max notifications per window. */
maxPerWindow: number;
}
interface ChannelRateConfig extends RateLimitConfig {
/** Whether to auto-summarize when rate-limited. */
summarizeOnLimit: boolean;
}
interface RateLimitResult {
allowed: boolean;
retryAfterMs?: number;
reason?: string;
}
Gotchas:
- Rate limiting should apply before queueing, not after. A rate-limited notification should never enter the priority queue.
- Different channels need different limits. A system metrics channel updating every second is normal; a social notification channel sending once per second is abuse.
- Emergency/critical notifications must bypass rate limits, or youβll suppress fire alarms.
- FCM limits collapsible messages to 4 per collapse key per device. This is a good design constraint β if you have more than 4 types of collapsible update, your channel taxonomy is wrong.
Pattern 3: Time-Sharing Rotation
When multiple notifications have the same priority and all deserve display time, rotate between them. This is how smartwatch complications work β each complication gets a slot on the face, and the watch rotates through them when the user taps.
When to use it: When the display is idle (no urgent content) and multiple ambient sources want visibility.
/**
* Time-sharing rotation for ambient content.
*
* When the display has no urgent notifications, it rotates
* through ambient content sources on a configurable interval.
*
* Inspired by:
* - Apple Watch complications: each gets a fixed position,
* data rotates on interaction
* - Google Nest Hub: ambient mode cycles through photos,
* weather, calendar
* - Car instrument clusters: info display cycles between
* odometer, range, average MPG
* - RSVP reading: words rotate at a fixed interval
*/
class TimeShareRotation {
private sources: RotationSource[] = [];
private currentIndex: number = 0;
private timer: ReturnType<typeof setInterval> | null = null;
private paused: boolean = false;
private displayCallback: (content: NotificationContent | null) => void;
constructor(
private intervalMs: number,
onContentChange: (content: NotificationContent | null) => void
) {
this.displayCallback = onContentChange;
}
/**
* Register a content source for rotation.
* The source provides a function that returns current content.
*/
addSource(source: RotationSource): void {
this.sources.push(source);
if (this.sources.length === 1 && !this.paused) {
this.start();
}
}
removeSource(sourceId: string): void {
this.sources = this.sources.filter((s) => s.id !== sourceId);
if (this.currentIndex >= this.sources.length) {
this.currentIndex = 0;
}
if (this.sources.length === 0) {
this.stop();
}
}
/**
* Start rotation. Called when the priority queue is empty
* and the display enters ambient mode.
*/
start(): void {
if (this.timer) return;
this.paused = false;
// Show first source immediately
this.showCurrent();
this.timer = setInterval(() => {
this.advance();
}, this.intervalMs);
}
/**
* Pause rotation. Called when a priority notification
* preempts the rotation.
*/
pause(): void {
this.paused = true;
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
/**
* Resume rotation from where it left off.
*/
resume(): void {
if (!this.paused) return;
this.paused = false;
this.start();
}
private advance(): void {
if (this.sources.length === 0) return;
// Find the next source with content
let attempts = 0;
do {
this.currentIndex = (this.currentIndex + 1) % this.sources.length;
attempts++;
} while (
!this.sources[this.currentIndex].hasContent() &&
attempts < this.sources.length
);
this.showCurrent();
}
private showCurrent(): void {
if (this.sources.length === 0) {
this.displayCallback(null);
return;
}
const source = this.sources[this.currentIndex];
const content = source.getContent();
this.displayCallback(content);
}
/** Update rotation interval dynamically. */
setInterval(ms: number): void {
this.intervalMs = ms;
if (this.timer) {
this.stop();
this.start();
}
}
}
interface RotationSource {
/** Unique identifier for this source. */
id: string;
/** Human-readable name. */
name: string;
/** Weight for weighted rotation (higher = more frequent). */
weight: number;
/** Whether this source currently has content to display. */
hasContent(): boolean;
/** Get the current content to display. */
getContent(): NotificationContent;
}
Gotchas:
- Rotation speed matters enormously. Too fast (< 3 seconds) and the display is distracting. Too slow (> 30 seconds) and sources feel ignored. 5-10 seconds is the sweet spot for ambient content.
- When a priority notification preempts rotation, you need to decide: resume from where you left off, or restart from the beginning? Resume is less jarring.
- Weighted rotation is better than round-robin. A weather source that updates hourly doesnβt need the same display time as a system monitor updating every second.
Pattern 4: Channel Policy Engine
This is where it all comes together. A policy engine that evaluates incoming notifications against channels, focus modes, rate limits, and the current display state to make a decision: display, queue, suppress, or summarize.
When to use it: This is the brain of the notification system. Every notification passes through it.
/**
* The policy engine evaluates every incoming notification and decides
* what to do with it.
*
* Decision flow:
* 1. Validate the notification (required fields, known channel)
* 2. Check rate limits
* 3. Check focus mode filters
* 4. Apply channel policies (collapse, summarize)
* 5. Calculate effective priority
* 6. Submit to the priority queue or rotation
*
* This mirrors the layered policy approach used by:
* - iOS: interruption level β Focus mode β notification summary
* - Grafana: alert rules β notification policies β silences β mute timings
* - PagerDuty: event rules β services β escalation policies β user notification rules
*/
class PolicyEngine {
private channels: Map<string, NotificationChannel> = new Map();
private focusModes: Map<string, FocusMode> = new Map();
private rateLimiter: NotificationRateLimiter;
private queue: NotificationPriorityQueue;
private rotation: TimeShareRotation;
private history: NotificationHistoryLog;
private suppressionRules: SuppressionRule[] = [];
constructor(config: PolicyEngineConfig) {
this.rateLimiter = new NotificationRateLimiter(config.defaultRateLimit);
this.queue = new NotificationPriorityQueue((entry) => {
if (entry) {
// Priority notification: pause rotation and show it
this.rotation.pause();
this.onDisplay(entry);
} else {
// Queue empty: resume rotation
this.rotation.resume();
}
});
this.rotation = new TimeShareRotation(
config.rotationIntervalMs,
(content) => {
this.onRotationDisplay(content);
}
);
this.history = new NotificationHistoryLog(config.historySize);
}
/**
* Register a notification channel.
*/
registerChannel(channel: NotificationChannel): void {
this.channels.set(channel.id, channel);
this.rateLimiter.configureChannel(channel.id, {
maxBurst: channel.rateLimit,
refillRate: 1,
refillIntervalMs: 60_000 / channel.rateLimit,
windowMs: 60_000,
maxPerWindow: channel.rateLimit,
summarizeOnLimit: channel.collapseStrategy === "summarize",
});
}
/**
* Set a focus mode. Only one can be active at a time.
*/
activateFocusMode(modeId: string): void {
// Deactivate all others
for (const mode of this.focusModes.values()) {
mode.active = false;
}
const mode = this.focusModes.get(modeId);
if (mode) {
mode.active = true;
}
}
/**
* Process an incoming notification.
* Returns the decision made by the policy engine.
*/
process(notification: Notification): PolicyDecision {
// Step 1: Validate
const channel = this.channels.get(notification.channel);
if (!channel) {
return {
action: "reject",
reason: `Unknown channel: ${notification.channel}`,
};
}
// Apply channel defaults
if (notification.level === undefined) {
notification.level = channel.userOverride ?? channel.defaultLevel;
}
// Step 2: Check suppression rules
for (const rule of this.suppressionRules) {
if (rule.matches(notification)) {
this.history.log(notification, "suppressed", rule.reason);
return {
action: "suppress",
reason: rule.reason,
};
}
}
// Step 3: Check if channel is muted
if (
channel.muted &&
notification.level! < InterruptionLevel.CRITICAL
) {
this.history.log(notification, "muted", "Channel is muted");
return {
action: "suppress",
reason: `Channel ${channel.id} is muted`,
};
}
// Step 4: Check focus mode
const activeFocus = this.getActiveFocusMode();
if (activeFocus) {
const allowed = this.checkFocusMode(activeFocus, notification);
if (!allowed) {
this.history.log(
notification,
"filtered",
`Blocked by focus mode: ${activeFocus.name}`
);
// Still log for summary display if configured
if (activeFocus.fallback === "summary") {
return {
action: "defer",
reason: `Deferred by focus mode: ${activeFocus.name}`,
};
}
return {
action: "suppress",
reason: `Blocked by focus mode: ${activeFocus.name}`,
};
}
}
// Step 5: Check rate limits
const rateResult = this.rateLimiter.check(
notification.source,
notification.channel,
notification.level!
);
if (!rateResult.allowed) {
this.history.log(
notification,
"rate-limited",
rateResult.reason!
);
return {
action: "rate-limited",
reason: rateResult.reason!,
retryAfterMs: rateResult.retryAfterMs,
};
}
// Step 6: Apply collapse strategy
if (channel.collapseStrategy === "replace" && notification.replacesId) {
// The queue handles replacement via replacesId
} else if (channel.collapseStrategy === "summarize") {
// Check if we should summarize instead of showing individually
const pendingCount = this.countPendingForChannel(
notification.channel
);
if (pendingCount >= 3) {
return this.summarize(notification);
}
}
// Step 7: Set TTL from channel default if not specified
if (notification.expiresAt === null && channel.defaultTTL !== null) {
notification.expiresAt = Date.now() + channel.defaultTTL;
}
// Step 8: Submit to priority queue
const position = this.queue.submit(notification);
this.history.log(notification, "queued", `Position: ${position}`);
return {
action: "queued",
position,
};
}
private checkFocusMode(
mode: FocusMode,
notification: Notification
): boolean {
// Critical always breaks through (iOS behavior)
if (notification.level! >= InterruptionLevel.CRITICAL) {
return true;
}
// Check minimum level
if (notification.level! < mode.minimumLevel) {
return false;
}
// Check allowed channels
if (mode.allowedChannels.includes(notification.channel)) {
return true;
}
// Check allowed sources
if (mode.allowedSources.includes(notification.source)) {
return true;
}
return false;
}
private getActiveFocusMode(): FocusMode | null {
for (const mode of this.focusModes.values()) {
if (mode.active) return mode;
}
// Check scheduled modes
const now = new Date();
for (const mode of this.focusModes.values()) {
if (mode.schedule && this.isScheduleActive(mode.schedule, now)) {
mode.active = true;
return mode;
}
}
return null;
}
private isScheduleActive(
schedule: FocusModeSchedule,
now: Date
): boolean {
const day = now.getDay();
if (!schedule.days.includes(day)) return false;
const timeStr = now.toLocaleTimeString("en-US", {
hour12: false,
hour: "2-digit",
minute: "2-digit",
timeZone: schedule.timezone,
});
return timeStr >= schedule.startTime && timeStr <= schedule.endTime;
}
private countPendingForChannel(channelId: string): number {
return this.queue
.getState()
.queued.filter(
(e) => e.notification.channel === channelId
).length;
}
private summarize(notification: Notification): PolicyDecision {
// Aggregate pending notifications for this channel into a summary
const pending = this.queue
.getState()
.queued.filter(
(e) => e.notification.channel === notification.channel
);
const summaryContent: NotificationContent = {
summary: `${pending.length + 1} ${notification.channel} notifications`,
body: pending
.map((e) => e.notification.content.summary)
.concat(notification.content.summary)
.join("\n"),
icon: "stack",
};
// Replace all pending with a single summary
for (const entry of pending) {
this.queue.dismiss(entry.notification.id);
}
const summaryNotification: Notification = {
...notification,
id: `summary:${notification.channel}:${Date.now()}`,
content: summaryContent,
relevanceScore: 0.5,
};
this.queue.submit(summaryNotification);
return {
action: "summarized",
reason: `Aggregated with ${pending.length} pending notifications`,
};
}
private onDisplay(entry: QueueEntry): void {
// Hook for external display rendering
}
private onRotationDisplay(content: NotificationContent | null): void {
// Hook for ambient rotation rendering
}
}
interface PolicyDecision {
action:
| "queued"
| "reject"
| "suppress"
| "rate-limited"
| "defer"
| "summarized";
reason?: string;
position?: number;
retryAfterMs?: number;
}
interface PolicyEngineConfig {
defaultRateLimit: RateLimitConfig;
rotationIntervalMs: number;
historySize: number;
}
interface SuppressionRule {
reason: string;
matches(notification: Notification): boolean;
}
Gotchas:
- The order of policy evaluation matters. Rate limiting should come before focus mode checks β you donβt want a rate-limited but urgent notification to be counted against the rate limit and then also blocked by focus mode.
- Summarization changes the notification identity. The original notifications are gone; only the summary exists. Make sure the history log captures the originals.
- Focus mode transitions need careful handling. When a focus mode deactivates, should deferred notifications flood the display? No β they should be shown as a summary.
Pattern 5: RSVP Interruption Protocol
Rapid Serial Visual Presentation (RSVP) displays words one at a time at high speed. This is a special case because the display is βoccupiedβ β showing content that the user is actively reading. Interrupting RSVP is like interrupting someone mid-sentence.
When to use it: When the HUD has a speed-reading mode that cycles through text word-by-word.
/**
* RSVP-aware notification delivery.
*
* When the display is in RSVP mode (showing words at 300+ WPM),
* interruptions must be handled carefully:
*
* - SILENT/AMBIENT: queue silently, show after RSVP completes
* - STANDARD: queue, show subtle indicator (dot color change)
* - ELEVATED: pause RSVP, show notification, resume RSVP
* - URGENT: immediately stop RSVP, show notification
* - CRITICAL: immediately stop RSVP, show notification, cannot dismiss
*
* This mirrors how car dashboards handle navigation + calls:
* - Music info: hidden during turn-by-turn
* - Phone call: pauses navigation, shows caller
* - Collision warning: overrides everything, cannot be dismissed
*/
class RSVPInterruptionHandler {
private rsvpState: RSVPState | null = null;
private pendingDuringRSVP: Notification[] = [];
constructor(
private policyEngine: PolicyEngine,
private displayController: DisplayController
) {}
/**
* Called when RSVP mode starts.
*/
startRSVP(text: string, wpm: number): void {
this.rsvpState = {
text,
words: text.split(/\s+/),
currentWordIndex: 0,
wpm,
startedAt: Date.now(),
pausedAt: null,
intervalMs: 60_000 / wpm,
};
this.pendingDuringRSVP = [];
this.displayController.setMode("rsvp");
this.advanceRSVP();
}
/**
* Handle a notification arriving during RSVP.
*/
handleDuringRSVP(notification: Notification): RSVPInterruptAction {
if (!this.rsvpState) {
// Not in RSVP mode β process normally
return { action: "normal" };
}
const level = notification.level ?? InterruptionLevel.STANDARD;
switch (level) {
case InterruptionLevel.SILENT:
case InterruptionLevel.AMBIENT:
// Queue silently β don't disturb the reader
this.pendingDuringRSVP.push(notification);
return {
action: "queued-silent",
resumeAfterRSVP: true,
};
case InterruptionLevel.STANDARD:
// Queue but show subtle indicator
this.pendingDuringRSVP.push(notification);
this.displayController.showIndicator("pending", {
count: this.pendingDuringRSVP.length,
});
return {
action: "queued-indicated",
resumeAfterRSVP: true,
};
case InterruptionLevel.ELEVATED:
// Pause RSVP, show notification, then resume
this.pauseRSVP();
this.displayController.showNotification(notification);
// Auto-resume after notification TTL or 5 seconds
const resumeDelay = notification.expiresAt
? notification.expiresAt - Date.now()
: 5_000;
setTimeout(() => {
this.resumeRSVP();
}, resumeDelay);
return {
action: "pause-show-resume",
resumeAfterMs: resumeDelay,
};
case InterruptionLevel.URGENT:
case InterruptionLevel.CRITICAL:
// Stop RSVP entirely
this.stopRSVP();
this.displayController.showNotification(notification);
return {
action: "stop-rsvp",
rsvpCancelled: true,
};
default:
return { action: "normal" };
}
}
private pauseRSVP(): void {
if (!this.rsvpState) return;
this.rsvpState.pausedAt = Date.now();
this.displayController.setMode("notification");
}
private resumeRSVP(): void {
if (!this.rsvpState || !this.rsvpState.pausedAt) return;
this.rsvpState.pausedAt = null;
this.displayController.setMode("rsvp");
this.advanceRSVP();
}
private stopRSVP(): void {
this.rsvpState = null;
// Process any notifications that accumulated during RSVP
for (const pending of this.pendingDuringRSVP) {
this.policyEngine.process(pending);
}
this.pendingDuringRSVP = [];
}
private advanceRSVP(): void {
if (!this.rsvpState || this.rsvpState.pausedAt) return;
const state = this.rsvpState;
if (state.currentWordIndex >= state.words.length) {
// RSVP complete
this.stopRSVP();
return;
}
const word = state.words[state.currentWordIndex];
this.displayController.showWord(word, state.currentWordIndex, state.words.length);
state.currentWordIndex++;
setTimeout(() => {
this.advanceRSVP();
}, state.intervalMs);
}
}
interface RSVPState {
text: string;
words: string[];
currentWordIndex: number;
wpm: number;
startedAt: number;
pausedAt: number | null;
intervalMs: number;
}
interface RSVPInterruptAction {
action:
| "normal"
| "queued-silent"
| "queued-indicated"
| "pause-show-resume"
| "stop-rsvp";
resumeAfterRSVP?: boolean;
resumeAfterMs?: number;
rsvpCancelled?: boolean;
}
interface DisplayController {
setMode(mode: "rsvp" | "notification" | "ambient" | "idle"): void;
showWord(word: string, index: number, total: number): void;
showNotification(notification: Notification): void;
showIndicator(
type: string,
data: Record<string, unknown>
): void;
}
Gotchas:
- Resuming RSVP after an interruption is disorienting. Consider backing up 2-3 words so the reader can re-find their place.
- RSVP at high WPM (500+) means even a 2-second pause for a notification breaks the reading flow completely. At those speeds, only URGENT and CRITICAL should interrupt.
- The pending notification indicator should be minimal β a dot color change, not text. Anything more disrupts RSVP.
Pattern 6: Escalation and Acknowledgment
Some notifications become more important over time. PagerDutyβs entire business model is built on escalation: if the first responder doesnβt acknowledge in N minutes, escalate to the next level. Pushoverβs emergency priority retries every 30+ seconds until acknowledged.
When to use it: For notifications that require human awareness β not just display, but confirmation that the human saw it.
/**
* Escalation engine for notifications that require acknowledgment.
*
* Implements PagerDuty-style escalation:
* 1. Show notification at initial level
* 2. If not acknowledged within timeout, escalate to next level
* 3. Repeat until acknowledged or escalation chain exhausted
* 4. If chain exhausted, mark as unacknowledged and log
*
* Also implements Pushover-style retry:
* - Retry display every N seconds
* - Expire after M seconds
* - Cancel retries on acknowledgment
*
* Grafana uses similar concepts: group_wait, group_interval, repeat_interval.
*/
class EscalationEngine {
private escalations: Map<string, EscalationState> = new Map();
private policyEngine: PolicyEngine;
constructor(policyEngine: PolicyEngine) {
this.policyEngine = policyEngine;
}
/**
* Submit a notification with an escalation policy.
*/
submitWithEscalation(
notification: Notification,
policy: EscalationPolicy
): void {
const state: EscalationState = {
notification,
policy,
currentStep: 0,
startedAt: Date.now(),
acknowledged: false,
retryCount: 0,
timers: [],
};
this.escalations.set(notification.id, state);
// Submit at initial level
this.policyEngine.process(notification);
// Set up escalation timer
this.scheduleEscalation(state);
// Set up retry timer if configured
if (policy.retryIntervalMs) {
this.scheduleRetry(state);
}
// Set up expiry timer
if (policy.expireAfterMs) {
const expiryTimer = setTimeout(() => {
this.expire(notification.id);
}, policy.expireAfterMs);
state.timers.push(expiryTimer);
}
}
/**
* Acknowledge a notification, stopping escalation and retries.
*/
acknowledge(notificationId: string): AcknowledgmentResult {
const state = this.escalations.get(notificationId);
if (!state) {
return { success: false, reason: "Notification not found" };
}
state.acknowledged = true;
// Clear all timers
for (const timer of state.timers) {
clearTimeout(timer);
}
state.timers = [];
const elapsed = Date.now() - state.startedAt;
return {
success: true,
acknowledgedAfterMs: elapsed,
escalationLevel: state.currentStep,
retryCount: state.retryCount,
};
}
private scheduleEscalation(state: EscalationState): void {
const step = state.policy.steps[state.currentStep];
if (!step) return;
const timer = setTimeout(() => {
if (state.acknowledged) return;
// Move to next escalation step
state.currentStep++;
const nextStep = state.policy.steps[state.currentStep];
if (nextStep) {
// Re-submit at higher level
const escalated: Notification = {
...state.notification,
level: nextStep.level,
content: {
...state.notification.content,
summary: `[ESCALATED] ${state.notification.content.summary}`,
},
replacesId: state.notification.id,
};
this.policyEngine.process(escalated);
// Execute escalation action (e.g., send to additional channels)
if (nextStep.action) {
nextStep.action(state.notification);
}
// Schedule next escalation
this.scheduleEscalation(state);
} else {
// Escalation chain exhausted
this.onEscalationExhausted(state);
}
}, step.timeoutMs);
state.timers.push(timer);
}
private scheduleRetry(state: EscalationState): void {
if (!state.policy.retryIntervalMs) return;
const timer = setTimeout(() => {
if (state.acknowledged) return;
state.retryCount++;
// Re-display the notification
const retry: Notification = {
...state.notification,
id: `${state.notification.id}:retry:${state.retryCount}`,
replacesId: state.notification.id,
content: {
...state.notification.content,
summary: `[${state.retryCount}x] ${state.notification.content.summary}`,
},
};
this.policyEngine.process(retry);
// Schedule next retry
this.scheduleRetry(state);
}, state.policy.retryIntervalMs);
state.timers.push(timer);
}
private expire(notificationId: string): void {
const state = this.escalations.get(notificationId);
if (!state || state.acknowledged) return;
// Clear all timers
for (const timer of state.timers) {
clearTimeout(timer);
}
this.escalations.delete(notificationId);
}
private onEscalationExhausted(state: EscalationState): void {
// All escalation steps exhausted without acknowledgment
// This is the "nobody is responding" case
const finalNotification: Notification = {
...state.notification,
level: InterruptionLevel.CRITICAL,
content: {
...state.notification.content,
summary: `[UNACK] ${state.notification.content.summary}`,
severity: "red",
},
replacesId: state.notification.id,
};
this.policyEngine.process(finalNotification);
}
}
interface EscalationPolicy {
/** Ordered escalation steps. */
steps: EscalationStep[];
/** Retry interval in ms (Pushover-style). Min 30000. */
retryIntervalMs?: number;
/** Total time before escalation expires in ms. */
expireAfterMs?: number;
}
interface EscalationStep {
/** Interruption level at this step. */
level: InterruptionLevel;
/** Time to wait before escalating to next step (ms). */
timeoutMs: number;
/** Optional action to execute at this step. */
action?: (notification: Notification) => void;
}
interface EscalationState {
notification: Notification;
policy: EscalationPolicy;
currentStep: number;
startedAt: number;
acknowledged: boolean;
retryCount: number;
timers: ReturnType<typeof setTimeout>[];
}
interface AcknowledgmentResult {
success: boolean;
reason?: string;
acknowledgedAfterMs?: number;
escalationLevel?: number;
retryCount?: number;
}
Example 1: Registering Channels for a HUD
const engine = new PolicyEngine({
defaultRateLimit: {
maxBurst: 5,
refillRate: 1,
refillIntervalMs: 10_000,
windowMs: 60_000,
maxPerWindow: 30,
},
rotationIntervalMs: 8_000,
historySize: 500,
});
// AI agent status channel β replace old status with new
engine.registerChannel({
id: "agent-status",
name: "Agent Status",
description: "Athena and Jane operational status",
defaultLevel: InterruptionLevel.AMBIENT,
muted: false,
rateLimit: 12, // max 12/minute (one every 5 seconds)
collapseStrategy: "replace",
defaultTTL: 30_000, // 30 seconds
style: "metric",
});
// System alerts β queue multiple, don't replace
engine.registerChannel({
id: "system-alerts",
name: "System Alerts",
description: "CPU, memory, disk, network anomalies",
defaultLevel: InterruptionLevel.ELEVATED,
muted: false,
rateLimit: 6,
collapseStrategy: "summarize",
defaultTTL: 60_000,
style: "alert",
});
// User timers β high priority, replace on update
engine.registerChannel({
id: "timers",
name: "Timers & Reminders",
description: "User-set timers, calendar reminders",
defaultLevel: InterruptionLevel.URGENT,
muted: false,
rateLimit: 10,
collapseStrategy: "replace",
defaultTTL: null, // persist until dismissed
style: "text",
});
// Weather β ambient, very low rate
engine.registerChannel({
id: "weather",
name: "Weather",
description: "Current conditions and alerts",
defaultLevel: InterruptionLevel.SILENT,
muted: false,
rateLimit: 2, // max 2/minute
collapseStrategy: "replace",
defaultTTL: 300_000, // 5 minutes
style: "metric",
});
Example 2: Sending a Notification from an AI Agent
function agentStatusUpdate(
agentName: string,
status: string,
severity: "green" | "yellow" | "red"
): Notification {
const level =
severity === "red"
? InterruptionLevel.URGENT
: severity === "yellow"
? InterruptionLevel.STANDARD
: InterruptionLevel.AMBIENT;
return {
id: `agent:${agentName}:${Date.now()}`,
source: agentName,
channel: "agent-status",
replacesId: `agent:${agentName}:latest`,
content: {
summary: `${agentName}: ${status}`,
severity,
icon: agentName === "athena" ? "owl" : "sparkle",
metric: severity === "yellow" || severity === "red"
? { label: agentName, value: status, trend: "down" }
: undefined,
},
level,
createdAt: Date.now(),
expiresAt: Date.now() + 30_000,
state: "pending",
updateCount: 0,
relevanceScore: severity === "red" ? 1.0 : severity === "yellow" ? 0.7 : 0.3,
};
}
// Normal operation
engine.process(agentStatusUpdate("athena", "nominal", "green"));
// Something unusual
engine.process(agentStatusUpdate("athena", "traffic spike 3x", "yellow"));
// System down
engine.process(agentStatusUpdate("athena", "UNREACHABLE", "red"));
Example 3: Focus Mode for Deep Work
const deepWorkMode: FocusMode = {
id: "deep-work",
name: "Deep Work",
active: false,
// Only agent alerts and timers break through
allowedChannels: ["timers"],
// Only Athena (the system) can interrupt, not individual agents
allowedSources: ["athena"],
// Only URGENT and above can break through
minimumLevel: InterruptionLevel.URGENT,
schedule: {
days: [1, 2, 3, 4, 5], // weekdays
startTime: "09:00",
endTime: "12:00",
timezone: "America/Chicago",
},
fallback: "summary", // show count of suppressed notifications
};
// Manual activation
engine.activateFocusMode("deep-work");
Example 4: System Monitor as a Rotation Source
class CPUMonitorSource implements RotationSource {
id = "cpu-monitor";
name = "CPU Usage";
weight = 1;
private lastReading: number = 0;
hasContent(): boolean {
return true; // always has something to show
}
getContent(): NotificationContent {
const usage = this.lastReading;
const trend =
usage > 80 ? "up" : usage < 20 ? "down" : ("flat" as const);
return {
summary: `CPU ${usage}%`,
metric: {
label: "CPU",
value: `${usage}%`,
trend,
},
severity: usage > 90 ? "red" : usage > 70 ? "yellow" : "green",
icon: "cpu",
};
}
update(usage: number): void {
this.lastReading = usage;
// If CPU is critically high, send as a priority notification
// instead of waiting for rotation
if (usage > 95) {
// This would be submitted to the policy engine directly
}
}
}
class MemoryMonitorSource implements RotationSource {
id = "memory-monitor";
name = "Memory Usage";
weight = 1;
private usedGB: number = 0;
private totalGB: number = 16;
hasContent(): boolean {
return true;
}
getContent(): NotificationContent {
const pct = Math.round((this.usedGB / this.totalGB) * 100);
return {
summary: `RAM ${this.usedGB.toFixed(1)}/${this.totalGB}GB`,
metric: {
label: "RAM",
value: `${pct}%`,
trend: pct > 80 ? "up" : "flat",
},
severity: pct > 90 ? "red" : pct > 75 ? "yellow" : "green",
icon: "memory",
};
}
update(usedGB: number): void {
this.usedGB = usedGB;
}
}
Example 5: Quiet Hours with Grafana-Style Mute Timing
/**
* Mute timing: suppress notifications during configured periods.
* Modeled after Grafana's mute timings, which are distinct from silences.
*
* Mute timing = recurring (every weeknight, every weekend)
* Silence = one-time (mute for next 2 hours during this deploy)
*/
class MuteTimingManager {
private muteTimings: MuteTiming[] = [];
private silences: Silence[] = [];
addMuteTiming(timing: MuteTiming): void {
this.muteTimings.push(timing);
}
addSilence(silence: Silence): void {
this.silences.push(silence);
}
isCurrentlyMuted(channelId?: string, source?: string): MuteResult {
const now = new Date();
// Check one-time silences first
for (const silence of this.silences) {
if (now >= silence.startsAt && now <= silence.endsAt) {
if (!silence.channelFilter || silence.channelFilter.includes(channelId ?? "")) {
return { muted: true, reason: silence.reason, type: "silence" };
}
}
}
// Check recurring mute timings
for (const timing of this.muteTimings) {
if (this.isTimingActive(timing, now)) {
return { muted: true, reason: timing.name, type: "mute-timing" };
}
}
return { muted: false };
}
private isTimingActive(timing: MuteTiming, now: Date): boolean {
const day = now.getDay();
if (!timing.days.includes(day)) return false;
const hour = now.getHours();
const minute = now.getMinutes();
const currentMinutes = hour * 60 + minute;
const [startH, startM] = timing.startTime.split(":").map(Number);
const [endH, endM] = timing.endTime.split(":").map(Number);
const startMinutes = startH * 60 + startM;
const endMinutes = endH * 60 + endM;
// Handle overnight ranges (e.g., 22:00 - 07:00)
if (endMinutes < startMinutes) {
return currentMinutes >= startMinutes || currentMinutes <= endMinutes;
}
return currentMinutes >= startMinutes && currentMinutes <= endMinutes;
}
}
interface MuteTiming {
name: string;
days: number[];
startTime: string; // HH:MM
endTime: string; // HH:MM
}
interface Silence {
reason: string;
startsAt: Date;
endsAt: Date;
channelFilter?: string[];
}
interface MuteResult {
muted: boolean;
reason?: string;
type?: "silence" | "mute-timing";
}
// Usage
const muteManager = new MuteTimingManager();
// Quiet hours every night
muteManager.addMuteTiming({
name: "Sleep hours",
days: [0, 1, 2, 3, 4, 5, 6],
startTime: "22:00",
endTime: "07:00",
});
// Weekend mornings are quieter
muteManager.addMuteTiming({
name: "Weekend mornings",
days: [0, 6],
startTime: "07:00",
endTime: "10:00",
});
// One-time silence for a presentation
muteManager.addSilence({
reason: "Presenting to client",
startsAt: new Date("2026-03-28T14:00:00"),
endsAt: new Date("2026-03-28T15:30:00"),
});
Example 6: Notification History Log
/**
* Ring buffer log of all notification decisions.
* Critical for debugging "why didn't I see that notification?"
*
* Every notification system needs this β iOS has Notification Center,
* Android has the notification log (Settings > Notifications > History),
* dunst has notification history accessible via keyboard shortcut.
*/
class NotificationHistoryLog {
private entries: HistoryEntry[] = [];
private maxSize: number;
constructor(maxSize: number = 500) {
this.maxSize = maxSize;
}
log(
notification: Notification,
decision: string,
reason: string
): void {
const entry: HistoryEntry = {
timestamp: Date.now(),
notificationId: notification.id,
source: notification.source,
channel: notification.channel,
level: notification.level ?? InterruptionLevel.STANDARD,
summary: notification.content.summary,
decision,
reason,
};
this.entries.push(entry);
// Ring buffer: drop oldest when full
if (this.entries.length > this.maxSize) {
this.entries.shift();
}
}
/**
* Query history by source, channel, or time range.
*/
query(filter: HistoryFilter): HistoryEntry[] {
return this.entries.filter((entry) => {
if (filter.source && entry.source !== filter.source) return false;
if (filter.channel && entry.channel !== filter.channel) return false;
if (filter.decision && entry.decision !== filter.decision)
return false;
if (filter.since && entry.timestamp < filter.since) return false;
if (filter.until && entry.timestamp > filter.until) return false;
return true;
});
}
/** Get suppressed notifications β the ones the user didn't see. */
getSuppressed(since?: number): HistoryEntry[] {
return this.query({
decision: "suppressed",
since,
}).concat(
this.query({ decision: "rate-limited", since }),
this.query({ decision: "filtered", since }),
this.query({ decision: "muted", since })
);
}
/** Export as JSON for debugging. */
export(): string {
return JSON.stringify(this.entries, null, 2);
}
}
interface HistoryEntry {
timestamp: number;
notificationId: string;
source: string;
channel: string;
level: InterruptionLevel;
summary: string;
decision: string;
reason: string;
}
interface HistoryFilter {
source?: string;
channel?: string;
decision?: string;
since?: number;
until?: number;
}
Example 7: SSE-Based Notification Server
/**
* Server-Sent Events endpoint for real-time notification delivery.
*
* SSE is preferred over WebSocket for notifications because:
* - Notifications are unidirectional (server β client)
* - SSE auto-reconnects with Last-Event-ID
* - SSE works through HTTP proxies without special configuration
* - SSE is simpler to implement and debug
*
* WebSocket is only needed if the client needs to send data back
* (e.g., acknowledgments, dismiss actions). In that case, use a
* hybrid: SSE for notification delivery, REST for actions.
*/
// Hono server example (runs on Cloudflare Workers or Node)
import { Hono } from "hono";
import { streamSSE } from "hono/streaming";
const app = new Hono();
// In-memory notification bus (replace with Redis/D1 in production)
const subscribers: Map<string, WritableStreamDefaultWriter> = new Map();
app.get("/notifications/stream", async (c) => {
const lastEventId = c.req.header("Last-Event-ID");
return streamSSE(c, async (stream) => {
const clientId = crypto.randomUUID();
// Send any missed notifications since lastEventId
if (lastEventId) {
const missed = getMissedNotifications(lastEventId);
for (const notification of missed) {
await stream.writeSSE({
event: "notification",
data: JSON.stringify(notification),
id: notification.id,
});
}
}
// Keep connection alive with heartbeat
const heartbeat = setInterval(async () => {
await stream.writeSSE({
event: "heartbeat",
data: JSON.stringify({ timestamp: Date.now() }),
});
}, 30_000);
// Listen for new notifications
const handler = async (notification: Notification) => {
await stream.writeSSE({
event: "notification",
data: JSON.stringify(notification),
id: notification.id,
});
};
subscribe(clientId, handler);
stream.onAbort(() => {
clearInterval(heartbeat);
unsubscribe(clientId);
});
});
});
// REST endpoint for submitting notifications
app.post("/notifications", async (c) => {
const body = await c.req.json();
const notification: Notification = {
id: crypto.randomUUID(),
source: body.source,
channel: body.channel,
replacesId: body.replacesId,
content: body.content,
level: body.level,
createdAt: Date.now(),
expiresAt: body.ttl ? Date.now() + body.ttl * 1000 : null,
state: "pending",
updateCount: 0,
relevanceScore: body.relevanceScore,
};
// Process through policy engine
const decision = engine.process(notification);
// Broadcast to all connected clients
broadcast(notification);
return c.json({ id: notification.id, decision });
});
// REST endpoint for acknowledging notifications
app.post("/notifications/:id/acknowledge", async (c) => {
const id = c.req.param("id");
const result = escalationEngine.acknowledge(id);
return c.json(result);
});
// REST endpoint for dismissing notifications
app.post("/notifications/:id/dismiss", async (c) => {
const id = c.req.param("id");
queue.dismiss(id);
return c.json({ dismissed: true });
});
function getMissedNotifications(sinceId: string): Notification[] {
// Implementation: query history log for notifications after sinceId
return [];
}
function subscribe(
clientId: string,
handler: (n: Notification) => Promise<void>
): void {
// Implementation: add to subscriber list
}
function unsubscribe(clientId: string): void {
// Implementation: remove from subscriber list
}
function broadcast(notification: Notification): void {
// Implementation: send to all subscribers
}
Example 8: File-Based Status Protocol (Local HUD)
/**
* For a local macOS HUD that reads from a file, the simplest
* protocol is a JSON status file watched via FSEvents.
*
* This is what our Atlas HUD uses: ~/.atlas/status.json
* Any process can write to it. The HUD reads it.
*
* Advantages over a server:
* - No daemon needed β any script can write a JSON file
* - No network β works offline
* - No authentication β filesystem permissions are sufficient
* - Simple debugging β cat the file
*
* Disadvantages:
* - No queuing β last writer wins
* - No history β overwritten data is lost
* - No multi-client β only one reader
* - Race conditions with multiple writers
*/
import { watch } from "fs";
import { readFile, writeFile } from "fs/promises";
const STATUS_PATH = `${process.env.HOME}/.atlas/status.json`;
interface StatusFile {
/** Current entries, keyed by source */
entries: Record<string, StatusEntry>;
/** Timestamp of last modification */
lastModified: string;
}
interface StatusEntry {
source: string;
severity: "green" | "yellow" | "red";
message: string;
channel: string;
level: InterruptionLevel;
updated: string;
ttl?: number;
replacesId?: string;
}
/**
* Write a status entry. Uses read-modify-write to support
* multiple sources without clobbering each other.
*/
async function writeStatus(entry: StatusEntry): Promise<void> {
let current: StatusFile;
try {
const raw = await readFile(STATUS_PATH, "utf-8");
current = JSON.parse(raw);
} catch {
current = { entries: {}, lastModified: new Date().toISOString() };
}
current.entries[entry.source] = entry;
current.lastModified = new Date().toISOString();
// Clean expired entries
const now = Date.now();
for (const [key, e] of Object.entries(current.entries)) {
if (e.ttl) {
const entryTime = new Date(e.updated).getTime();
if (now - entryTime > e.ttl * 1000) {
delete current.entries[key];
}
}
}
await writeFile(STATUS_PATH, JSON.stringify(current, null, 2));
}
/**
* Watch the status file and feed changes into the policy engine.
*/
function watchStatus(engine: PolicyEngine): void {
let lastContent = "";
watch(STATUS_PATH, async () => {
try {
const raw = await readFile(STATUS_PATH, "utf-8");
if (raw === lastContent) return;
lastContent = raw;
const status: StatusFile = JSON.parse(raw);
// Convert each entry to a notification and process
for (const entry of Object.values(status.entries)) {
const notification: Notification = {
id: `file:${entry.source}:${Date.now()}`,
source: entry.source,
channel: entry.channel,
replacesId: entry.replacesId ?? `file:${entry.source}:latest`,
content: {
summary: entry.message,
severity: entry.severity,
},
level: entry.level,
createdAt: Date.now(),
expiresAt: entry.ttl ? Date.now() + entry.ttl * 1000 : null,
state: "pending",
updateCount: 0,
};
engine.process(notification);
}
} catch (err) {
// File might be partially written β retry on next change
}
});
}
Example 9: Notification with Escalation Chain
// An agent detects a critical issue and submits it with escalation
const criticalAlert: Notification = {
id: `athena:unreachable:${Date.now()}`,
source: "jane",
channel: "system-alerts",
content: {
summary: "Athena unreachable for 2 cycles",
body: "Jane cannot reach Athena. Last heartbeat was 10 minutes ago. Checking DO state via API.",
severity: "red",
icon: "alert",
actions: [
{ id: "ack", label: "Acknowledge", handler: "ack://jane:unreachable" },
{ id: "restart", label: "Restart Athena", handler: "action://athena:restart", destructive: true },
],
},
level: InterruptionLevel.ELEVATED,
createdAt: Date.now(),
expiresAt: null,
state: "pending",
updateCount: 0,
relevanceScore: 1.0,
};
escalationEngine.submitWithEscalation(criticalAlert, {
steps: [
// Step 1: Show as ELEVATED for 2 minutes
{
level: InterruptionLevel.ELEVATED,
timeoutMs: 120_000,
},
// Step 2: Escalate to URGENT after 2 minutes
{
level: InterruptionLevel.URGENT,
timeoutMs: 300_000,
action: (n) => {
// Also send to Telegram
sendTelegram(`URGENT: ${n.content.summary}`);
},
},
// Step 3: Escalate to CRITICAL after 5 more minutes
{
level: InterruptionLevel.CRITICAL,
timeoutMs: 600_000,
action: (n) => {
// Play sound, send SMS
playSound("alarm");
sendSMS(`CRITICAL: ${n.content.summary}`);
},
},
],
retryIntervalMs: 60_000, // Retry display every minute
expireAfterMs: 1_800_000, // Give up after 30 minutes
});
declare function sendTelegram(msg: string): void;
declare function playSound(name: string): void;
declare function sendSMS(msg: string): void;
Example 10: Complete HUD Initialization
/**
* Full initialization of the HUD notification system.
* This wires together all the patterns.
*/
function initializeHUD(): {
engine: PolicyEngine;
escalation: EscalationEngine;
rsvp: RSVPInterruptionHandler;
mute: MuteTimingManager;
} {
// 1. Create the policy engine
const engine = new PolicyEngine({
defaultRateLimit: {
maxBurst: 5,
refillRate: 1,
refillIntervalMs: 12_000,
windowMs: 60_000,
maxPerWindow: 30,
},
rotationIntervalMs: 8_000,
historySize: 1000,
});
// 2. Register all channels
const channels: NotificationChannel[] = [
{
id: "agent-status",
name: "Agent Status",
description: "AI agent operational status",
defaultLevel: InterruptionLevel.AMBIENT,
muted: false,
rateLimit: 12,
collapseStrategy: "replace",
defaultTTL: 30_000,
style: "metric",
},
{
id: "system-alerts",
name: "System Alerts",
description: "Infrastructure and service alerts",
defaultLevel: InterruptionLevel.ELEVATED,
muted: false,
rateLimit: 6,
collapseStrategy: "summarize",
defaultTTL: 120_000,
style: "alert",
},
{
id: "system-metrics",
name: "System Metrics",
description: "CPU, memory, disk, network",
defaultLevel: InterruptionLevel.SILENT,
muted: false,
rateLimit: 60,
collapseStrategy: "replace",
defaultTTL: 5_000,
style: "metric",
},
{
id: "user-messages",
name: "User Messages",
description: "Chat messages, emails, social",
defaultLevel: InterruptionLevel.STANDARD,
muted: false,
rateLimit: 10,
collapseStrategy: "queue",
defaultTTL: 60_000,
style: "text",
},
{
id: "timers",
name: "Timers & Reminders",
description: "Countdown timers, calendar alerts",
defaultLevel: InterruptionLevel.URGENT,
muted: false,
rateLimit: 10,
collapseStrategy: "replace",
defaultTTL: null,
style: "text",
},
{
id: "ambient",
name: "Ambient Info",
description: "Weather, stocks, news headlines",
defaultLevel: InterruptionLevel.SILENT,
muted: false,
rateLimit: 2,
collapseStrategy: "replace",
defaultTTL: 300_000,
style: "text",
},
];
for (const channel of channels) {
engine.registerChannel(channel);
}
// 3. Set up focus modes
// (registered on the engine β see Pattern 4)
// 4. Create escalation engine
const escalation = new EscalationEngine(engine);
// 5. Create RSVP handler
const displayController: DisplayController = {
setMode: (mode) => {
// Bridge to SwiftUI notch renderer
},
showWord: (word, index, total) => {
// Render single word in notch
},
showNotification: (notification) => {
// Render notification in notch
},
showIndicator: (type, data) => {
// Show subtle indicator dot
},
};
const rsvp = new RSVPInterruptionHandler(engine, displayController);
// 6. Create mute timing manager
const mute = new MuteTimingManager();
mute.addMuteTiming({
name: "Sleep",
days: [0, 1, 2, 3, 4, 5, 6],
startTime: "22:00",
endTime: "07:00",
});
// 7. Start watching the status file
watchStatus(engine);
return { engine, escalation, rsvp, mute };
}
Notification Systems Across Platforms
| Feature | iOS | macOS | Android | Linux (freedesktop) | Our HUD |
|---|---|---|---|---|---|
| Priority levels | 4 (passive, active, time-sensitive, critical) | 2 (banner, alert) | 5 (MIN through URGENT) | 3 (low, normal, critical) | 6 (SILENT through CRITICAL) |
| Channels/categories | Categories with actions | Categories | Channels (required since API 26) | None (app-level only) | Channels with collapse strategies |
| Grouping | Thread identifier + automatic | By app | Group key + automatic (4+) | None (daemon-specific) | Channel-based + summarization |
| Focus/DND | Focus modes (Work, Personal, Sleep, custom) | Focus (synced with iOS) | DND with priority exceptions | None (daemon-specific) | Focus modes + mute timings + silences |
| Rate limiting | Undocumented per-device budget | None documented | Auto-groups at 4+ | None | Token bucket + sliding window per channel |
| Replacement | Via notification identifier | Via identifier | Via notification ID + tag | replaces_id | replacesId per notification |
| TTL/expiry | Badge persists, banner auto-dismisses | Auto-dismiss (banner) or persist (alert) | Per-channel settings | expire_timeout parameter | Per-notification + per-channel default |
| Actions | Category-defined actions (up to 4) | Same as iOS categories | Up to 3 action buttons | Actions array (key/label pairs) | Action array with handler URIs |
| History | Notification Center | Notification Center | Notification log (Settings) | Daemon-dependent (dunst: ctrl+shift+`) | Ring buffer with query API |
| Live/persistent | Live Activities + Dynamic Island | None | Foreground service notifications | None | Rotation sources + ambient mode |
| Escalation | None built-in | None | None | None | Escalation engine with retry/expire |
| Display constraint | Full screen available | Full screen available | Full screen available | Desktop-dependent | Single line, 600px wide |
Notification Delivery Technologies
| Technology | Direction | Reconnection | Browser limit | Complexity | Best for |
|---|---|---|---|---|---|
| Server-Sent Events (SSE) | Server to client | Automatic (Last-Event-ID) | 6 per domain (HTTP/1.1) | Low | Notifications, logs, metrics |
| WebSocket | Bidirectional | Manual | No hard limit | Medium | Chat, gaming, collaborative editing |
| Long polling | Server to client | Manual | 6 per domain | Low | Fallback when SSE unavailable |
| Firebase Cloud Messaging | Server to device | Managed by Google | N/A (push) | Medium | Mobile push notifications |
| APNs | Server to device | Managed by Apple | N/A (push) | Medium | iOS/macOS push notifications |
| File watching (FSEvents) | Writer to reader | Automatic | N/A (local) | Very low | Local single-machine HUD |
| D-Bus | App to daemon | Session bus | N/A (local) | Low | Linux desktop notifications |
| GraphQL subscriptions | Server to client | Library-dependent | 1 WebSocket | High | Apps already using GraphQL |
Alert Management Platforms
| Platform | Priority model | Escalation | Acknowledgment | Routing | Rate limiting |
|---|---|---|---|---|---|
| PagerDuty | Severity (critical/warning/error/info) mapped to urgency (high/low) | Multi-step with timeout | Required for high-urgency | Event rules + service routing | Undocumented |
| OpsGenie | P1-P5 priorities | Team-based with schedules | Required for P1-P3 | Alert policies + routing rules | API rate limits |
| Grafana Alerting | Labels + notification policies | group_wait/group_interval/repeat_interval | Via silences | Label-based policy tree | Mute timings + silences |
| Pushover | -2 to +2 (5 levels) | Emergency retry/expire | Receipt-based for emergency | User/group targeting | 7,500 messages/month free |
| Firebase Cloud Messaging | Normal/High | None built-in | None | Topic-based + device targeting | Per-device budget (undocumented) |
| Our HUD | 6 levels (SILENT-CRITICAL) | Multi-step with escalation actions | In-display + multi-channel | Channel + focus mode + mute timing | Token bucket + sliding window |
Ambient Display Approaches
| System | Display area | Priority handling | Multiple sources | User control |
|---|---|---|---|---|
| Apple Watch complications | 4-8 fixed slots on watch face | Timeline-based relevance | Each complication is a slot | Choose which complications to show |
| Dynamic Island | 2 pill-shaped areas flanking notch | System-determined; calls > timers > music | Split into 2 mini-islands | Tap to expand/interact |
| Google Nest Hub | Full 7-10β screen | Proximity-based (ultrasound) | Ambient mode cycles through sources | Settings per notification type |
| Car instrument cluster | 2-4 info areas around speedometer | NHTSA guidelines: safety > navigation > infotainment | Fixed zones per function | Steering wheel buttons |
| dunst | Desktop corner, stacked | Urgency-based sorting, configurable per-rule | Stack up to N notifications | Per-app rules in config |
| Our notch HUD | Single line, 600px | 6-level priority with preemption | Time-sharing rotation for ambient; queue for priority | Focus modes, mute timings, per-channel config |
| Donβt | Do Instead | Why |
|---|---|---|
| Let sources set their own display duration | Channel policy sets TTL; sources only suggest | A misbehaving source can hold the display forever by setting TTL to infinity |
| Use a single priority level for all notifications | Map every notification to one of 6 interruption levels via channel defaults | Without differentiation, everything gets the same treatment and nothing feels important |
| Skip rate limiting because βmy sources are well-behavedβ | Always rate-limit, even trusted sources | A bug in a monitoring script can send 1000 notifications per second. Ask me how I know. |
| Block critical notifications during Focus/DND | Critical (level 5) always breaks through all filters | iOS requires Apple entitlement for critical alerts precisely because they bypass everything β thatβs the point |
| Replace notifications without tracking history | Log every notification decision (displayed, suppressed, rate-limited) | When the user asks βdid Athena alert me about that?β you need to answer definitively |
| Show notification content during RSVP without pausing | Pause RSVP, show notification, then resume with backtrack | Overlapping content makes both unreadable |
| Use WebSocket when data flows one direction | Use SSE for server-to-client notification delivery | SSE auto-reconnects, works through proxies, and is simpler. WebSocket is overkill for push-only. |
| Hard-code notification importance in the source | Sources declare a channel; channel config determines importance | Importance should be controlled by the display policy, not the sender. This is Androidβs key insight. |
| Treat all time periods the same | Implement mute timings (recurring) and silences (one-time) separately | Grafanaβs distinction is important: weeknight quiet is recurring, deploy freeze is one-time |
| Escalate immediately to the highest level | Use multi-step escalation with increasing timeouts | PagerDutyβs whole value is in the escalation chain. Jumping to CRITICAL for everything desensitizes the user. |
| Show a counter like β12 notificationsβ without any detail | Show the most important suppressed notificationβs summary with a count | A number without context creates anxiety. βAthena: nominal (+11 more)β is better than β12 notificationsβ |
| Let sources specify pixel coordinates or layout zones | Sources declare content and priority; the layout engine decides placement | Decoupling content from geometry is the fundamental principle. See the HUD layout engine design. |
| Use polling to check for new notifications | Use FSEvents (local) or SSE (network) for push-based delivery | Polling wastes CPU and adds latency. FSEvents and SSE are event-driven. |
| Implement notification channels without letting users override importance | Users must be able to mute, boost, or change importance of any channel | Android gets this right: once a channel is created, only the user can change its importance level |
Pulling together all the patterns, here is the complete API surface for a notch HUD notification system.
API Endpoint Summary
POST /notifications Submit a notification
GET /notifications/stream SSE stream of notification events
POST /notifications/:id/acknowledge Acknowledge (stop escalation)
POST /notifications/:id/dismiss Dismiss from display
GET /notifications/history Query notification history
POST /channels Register a notification channel
PUT /channels/:id Update channel configuration
GET /channels List all channels
POST /focus-modes Create a focus mode
PUT /focus-modes/:id/activate Activate a focus mode
DELETE /focus-modes/:id/activate Deactivate a focus mode
POST /mute-timings Create a recurring mute timing
POST /silences Create a one-time silence
DELETE /silences/:id Cancel a silence
GET /status Current display state + queue
Submit Notification Request
/**
* POST /notifications
*
* This is what sources call to send a notification to the HUD.
* The design is influenced by:
* - freedesktop.org Notify method (replaces_id, actions, hints, expire_timeout)
* - FCM message format (collapse_key, priority, ttl, topic)
* - Pushover API (priority, sound, retry, expire)
* - iOS UNNotificationContent (interruptionLevel, relevanceScore, threadIdentifier)
*/
interface SubmitNotificationRequest {
/** Source identifier. Required. */
source: string;
/** Channel ID. Must be pre-registered. */
channel: string;
/** Content to display. */
content: {
/** Short text for the status bar. Max 80 chars. */
summary: string;
/** Longer text for hover/expanded view. */
body?: string;
/** Structured metric for dashboard-style display. */
metric?: {
label: string;
value: string;
unit?: string;
trend?: "up" | "down" | "flat";
};
/** Severity affects visual styling (traffic light colors). */
severity?: "green" | "yellow" | "red";
/** Icon identifier or emoji. */
icon?: string;
/** Actions the user can take. */
actions?: Array<{
id: string;
label: string;
handler: string;
destructive?: boolean;
}>;
};
/** Override the channel's default interruption level. */
level?: InterruptionLevel;
/** If set, replaces the notification with this ID.
* Like freedesktop replaces_id or FCM collapse_key. */
replacesId?: string;
/** Time-to-live in seconds. Overrides channel default.
* 0 = display immediately, discard if display is busy (FCM TTL 0).
* null = persist until dismissed. */
ttl?: number | null;
/** Relevance score (0-1) for ranking in summaries.
* iOS uses this for notification summary ordering. */
relevanceScore?: number;
/** Escalation policy. If set, the notification will escalate
* through the defined steps until acknowledged. */
escalation?: {
steps: Array<{
level: InterruptionLevel;
timeoutMs: number;
/** Additional channels to notify at this step. */
notifyChannels?: string[];
}>;
retryIntervalMs?: number;
expireAfterMs?: number;
};
/** Thread identifier for grouping related notifications.
* iOS threadIdentifier / Android group key equivalent. */
threadId?: string;
/** Idempotency key. If the same key is submitted twice,
* the second submission is silently ignored. */
idempotencyKey?: string;
}
interface SubmitNotificationResponse {
/** Assigned notification ID. */
id: string;
/** Policy engine decision. */
decision: {
action: "queued" | "displayed" | "replaced" | "summarized" | "suppressed" | "rate-limited" | "deferred";
reason?: string;
position?: number;
retryAfterMs?: number;
};
/** Current display state after this notification was processed. */
displayState: {
currentNotificationId: string | null;
queueDepth: number;
mode: "idle" | "notification" | "rotation" | "rsvp";
};
}
Notification Event Stream
/**
* GET /notifications/stream
* Accept: text/event-stream
*
* SSE stream of notification lifecycle events.
* The HUD client connects to this and renders accordingly.
*
* Events:
* - notification:display β show this notification
* - notification:expire β remove expired notification
* - notification:dismiss β user dismissed
* - notification:escalate β notification escalated to higher level
* - rotation:show β ambient rotation content
* - mode:change β display mode changed
* - heartbeat β keep-alive
*/
type NotificationEvent =
| {
event: "notification:display";
data: {
notification: Notification;
preempted: string | null; // ID of preempted notification
};
}
| {
event: "notification:expire";
data: {
notificationId: string;
nextNotificationId: string | null;
};
}
| {
event: "notification:dismiss";
data: {
notificationId: string;
nextNotificationId: string | null;
};
}
| {
event: "notification:escalate";
data: {
notificationId: string;
fromLevel: InterruptionLevel;
toLevel: InterruptionLevel;
step: number;
};
}
| {
event: "rotation:show";
data: {
sourceId: string;
content: NotificationContent;
};
}
| {
event: "mode:change";
data: {
from: string;
to: string;
reason: string;
};
}
| {
event: "heartbeat";
data: {
timestamp: number;
queueDepth: number;
activeMode: string;
activeFocusMode: string | null;
};
};
Configuration Schema
/**
* Complete configuration for the notification system.
* Stored at ~/.atlas/notification-config.json
*
* Three-layer config (like the HUD layout engine):
* 1. System defaults (ships with app)
* 2. User configuration (this file)
* 3. Runtime overrides (focus modes, silences)
*/
interface NotificationSystemConfig {
/** Global settings. */
global: {
/** Maximum notifications in the priority queue. */
maxQueueSize: number;
/** History log size (ring buffer). */
historySize: number;
/** Default TTL for notifications without one (ms). */
defaultTTLMs: number;
/** Ambient rotation interval (ms). */
rotationIntervalMs: number;
/** Whether to show suppressed notification count. */
showSuppressedCount: boolean;
};
/** Default rate limit for channels without specific config. */
defaultRateLimit: {
maxBurst: number;
refillRate: number;
refillIntervalMs: number;
windowMs: number;
maxPerWindow: number;
};
/** Channel definitions. */
channels: NotificationChannel[];
/** Focus mode definitions. */
focusModes: FocusMode[];
/** Recurring mute timings. */
muteTimings: MuteTiming[];
/** Source trust levels. */
sources: Array<{
/** Source identifier or glob pattern. */
pattern: string;
/** Whether this source is trusted (can use URGENT+). */
trusted: boolean;
/** Maximum interruption level this source can use. */
maxLevel: InterruptionLevel;
/** Override rate limit for this source. */
rateLimit?: Partial<RateLimitConfig>;
}>;
/** RSVP interruption settings. */
rsvp: {
/** Minimum level to pause RSVP. */
pauseLevel: InterruptionLevel;
/** Minimum level to stop RSVP entirely. */
stopLevel: InterruptionLevel;
/** Words to backtrack when resuming after pause. */
backtrackWords: number;
};
}
Default Configuration
{
"global": {
"maxQueueSize": 100,
"historySize": 1000,
"defaultTTLMs": 30000,
"rotationIntervalMs": 8000,
"showSuppressedCount": true
},
"defaultRateLimit": {
"maxBurst": 5,
"refillRate": 1,
"refillIntervalMs": 12000,
"windowMs": 60000,
"maxPerWindow": 30
},
"channels": [
{
"id": "agent-status",
"name": "Agent Status",
"description": "AI agent operational status",
"defaultLevel": 1,
"muted": false,
"rateLimit": 12,
"collapseStrategy": "replace",
"defaultTTL": 30000,
"style": "metric"
},
{
"id": "system-alerts",
"name": "System Alerts",
"description": "Infrastructure and service alerts",
"defaultLevel": 3,
"muted": false,
"rateLimit": 6,
"collapseStrategy": "summarize",
"defaultTTL": 120000,
"style": "alert"
},
{
"id": "system-metrics",
"name": "System Metrics",
"description": "CPU, memory, disk, network",
"defaultLevel": 0,
"muted": false,
"rateLimit": 60,
"collapseStrategy": "replace",
"defaultTTL": 5000,
"style": "metric"
},
{
"id": "timers",
"name": "Timers & Reminders",
"description": "Countdown timers, calendar alerts",
"defaultLevel": 4,
"muted": false,
"rateLimit": 10,
"collapseStrategy": "replace",
"defaultTTL": null,
"style": "text"
},
{
"id": "ambient",
"name": "Ambient Info",
"description": "Weather, stocks, news, learning cards",
"defaultLevel": 0,
"muted": false,
"rateLimit": 2,
"collapseStrategy": "replace",
"defaultTTL": 300000,
"style": "text"
}
],
"focusModes": [
{
"id": "deep-work",
"name": "Deep Work",
"active": false,
"allowedChannels": ["timers", "system-alerts"],
"allowedSources": ["athena", "jane"],
"minimumLevel": 4,
"schedule": {
"days": [1, 2, 3, 4, 5],
"startTime": "09:00",
"endTime": "12:00",
"timezone": "America/Chicago"
},
"fallback": "summary"
},
{
"id": "sleep",
"name": "Sleep",
"active": false,
"allowedChannels": [],
"allowedSources": [],
"minimumLevel": 5,
"schedule": {
"days": [0, 1, 2, 3, 4, 5, 6],
"startTime": "22:00",
"endTime": "07:00",
"timezone": "America/Chicago"
},
"fallback": "nothing"
}
],
"muteTimings": [],
"sources": [
{
"pattern": "athena",
"trusted": true,
"maxLevel": 5
},
{
"pattern": "jane",
"trusted": true,
"maxLevel": 5
},
{
"pattern": "user:*",
"trusted": true,
"maxLevel": 4
},
{
"pattern": "*",
"trusted": false,
"maxLevel": 3
}
],
"rsvp": {
"pauseLevel": 3,
"stopLevel": 4,
"backtrackWords": 3
}
}
iOS: The Most Sophisticated Consumer Notification System
Appleβs User Notifications framework is the most thoughtfully designed consumer notification system. Key design decisions:
Interruption Levels (iOS 15+)
The UNNotificationInterruptionLevel enum defines four levels:
-
Passive: Silently added to Notification Center. No sound, no screen wake, no banner. Used for information that can wait (e.g., news updates, promotional content).
-
Active (default): Standard delivery. Sound plays, screen lights up, banner appears. This is what most notifications use.
-
Time Sensitive: Breaks through Focus mode and Scheduled Summary. The system shows these immediately even when the user has explicitly asked not to be disturbed. Apps must declare the Time Sensitive capability in their entitlements. Abuse gets your app rejected.
-
Critical: Bypasses the mute switch. The userβs phone will make noise even if the physical switch is set to silent. Requires explicit entitlement from Apple β only granted for health, safety, security, and public safety apps. Think flood warnings and medical alerts.
Focus Modes (iOS 15+)
Focus modes are the evolution of Do Not Disturb. Each Focus mode defines:
- Which apps can send notifications
- Which people can reach you
- Which notification categories are allowed
- Time-sensitive notifications always break through (unless the user explicitly disables them per-app)
- Focus modes sync across devices via iCloud
Notification Summaries (iOS 15+)
Instead of delivering every notification immediately, iOS can batch them into scheduled summaries. The system uses machine learning to determine which notifications are most relevant and places those at the top of the summary. Apps influence ranking with the relevanceScore property (0.0 to 1.0).
Notification Grouping
iOS groups notifications using threadIdentifier. Notifications with the same thread ID are grouped together in Notification Center. When the user taps a group, it expands to show individual notifications. Apps provide a summaryArgument to customize the group summary text (e.g., β3 messages from Aliceβ).
Live Activities and Dynamic Island
Live Activities are a fundamentally different concept from notifications. They represent ongoing activities (sports scores, delivery tracking, timers) that update in real-time on the Lock Screen and Dynamic Island.
The Dynamic Island handles competing Live Activities by splitting into two smaller areas, one on each side of the camera cutout. Each responds independently. The system uses relevanceScore to determine which activity gets the prominent position. Active calls typically claim more visual real estate than background music.
ActivityKit imposes a notification budget that restricts the number of push updates per hour. Updates use the apns-priority header (priority 10 for immediate delivery).
Notification Categories
Apps register UNNotificationCategory objects that define available actions. Each category can have up to 4 action buttons. Actions can be typed (text input), destructive (shown in red), or require authentication (unlock to execute).
macOS: iOS Notification System, Desktop Edition
macOS uses the same UNUserNotificationCenter framework as iOS. Key differences:
Notification Styles
- Banners: Appear briefly in the top-right corner, then slide away. Equivalent to iOS active.
- Alerts: Persist on screen until the user dismisses them. No iOS equivalent β closest is time-sensitive with a sticky banner.
Users choose banner vs. alert per app in System Settings > Notifications.
Notification Center The macOS Notification Center (top-right corner, accessed via click on date/time) shows all recent notifications grouped by app. Notifications expire and are removed after a period (not well-documented, appears to be ~24 hours for most apps).
Focus Integration macOS Focus modes sync with iOS. When you set Focus on your iPhone, your Mac follows suit. This is relevant for our HUD: the HUD should query the system Focus state and respect it.
Android: Channels Are the Key Innovation
Androidβs notification system went through a major redesign in Android 8.0 (Oreo, API 26) with the introduction of notification channels. This is arguably the most influential notification design decision in the industry.
Notification Channels
Every notification must belong to a channel. Channels define:
- Importance: IMPORTANCE_NONE (0), IMPORTANCE_MIN (1), IMPORTANCE_LOW (2), IMPORTANCE_DEFAULT (3), IMPORTANCE_HIGH (4). Each level determines visual behavior:
- MIN: Shade only, no sound, no status bar icon
- LOW: Shade, no sound
- DEFAULT: Shade, sound
- HIGH: Heads-up notification (banner), sound
- Sound: Custom or default
- Vibration: Pattern
- Light: LED color
- Badge: Show badge on app icon
The critical design decision: once a channel is created, the app cannot change its importance. Only the user can modify it through Settings. This prevents apps from sneaking higher priority over time.
Notification Grouping
Android supports notification groups via setGroup(). When an app sends 4 or more notifications without specifying a group key, the system automatically groups them. Android 16 will force-group all app notifications by default.
Groups require a summary notification (the βparentβ that appears when the group is collapsed). This is the notification the user sees first β it should meaningfully summarize the group contents.
Priority Conversations (Android 12+)
Users can mark specific conversations as βpriority.β Priority conversation notifications get their own section at the top of the notification shade and can break through DND.
Bubbles (Android 11+)
Bubbles are floating circular icons for ongoing conversations. They overlap other apps and persist until dismissed. This is Androidβs answer to the βpersistent but not intrusiveβ problem.
Foreground Service Notifications
Android requires apps with foreground services (background work visible to the user) to show a persistent, non-dismissable notification. This ensures users know whatβs running in the background. Music players, navigation apps, and VPN apps all use this.
Linux: The Minimal Standard
The freedesktop.org Desktop Notifications Specification defines a D-Bus protocol that any notification daemon can implement.
The Notify Method
The core of the protocol is a single D-Bus method:
UINT32 org.freedesktop.Notifications.Notify(
STRING app_name,
UINT32 replaces_id,
STRING app_icon,
STRING summary,
STRING body,
ARRAY actions,
DICT hints,
INT32 expire_timeout
)
Parameters:
app_name: Identifier for the sending applicationreplaces_id: If non-zero, replaces the notification with this ID. This enables atomic updates β show a new notification in the same βslotβ as the old one.app_icon: Path or themed icon namesummary: Single-line titlebody: Multi-line body, supports basic HTML markup (<b>,<i>,<a>,<img>)actions: Array of key/label pairs. Keys are identifiers; labels are localized display strings. The special action key βdefaultβ is invoked when the user clicks the notification body.hints: Dictionary of hints. Standard hints includeurgency(byte: 0=low, 1=normal, 2=critical),category(string),desktop-entry(string),image-data(raw pixel data),sound-file(path),suppress-sound(boolean),transient(boolean),resident(boolean).expire_timeout: Milliseconds until auto-dismiss. -1 = server default. 0 = never expire (critical).
Urgency Levels
Three levels, passed as a byte in the hints dictionary:
- 0 (Low): βJoe Bob signed onβ β can be skipped if the daemon is busy
- 1 (Normal): βYou have new mailβ β standard delivery
- 2 (Critical): βYour computer is on fireβ β should not auto-expire, must be manually dismissed
Notification Daemons
The specification only defines the protocol. The actual behavior (queuing, stacking, styling, animations) is up to the daemon:
- dunst: The most popular lightweight daemon. Supports per-app rules, urgency-based styling, configurable stack height, keyboard shortcuts for history navigation, and script execution on notification arrival.
- mako: Wayland-native. Similar rule system to dunst, supports sorting by time or priority.
- GNOME Notifications: Full-featured, integrated with GNOME Shellβs notification tray.
- KDE Plasma: Notification Center with history, grouping, and inline reply.
Key insight for our HUD: The freedesktop.org
replaces_idpattern is essential. Our HUD operates like a notification daemon β it receives notifications from multiple sources and decides how to display them. Thereplaces_idconcept (calledreplacesIdin our API) allows sources to update in place without flooding the queue.
Google Nest Hub
The Google Nest Hub manages a 7-inch screen that shows ambient information (photos, weather, calendar) and responds to notifications from Google services.
Key patterns:
- Proximity-based rendering: Using ultrasound sensing, the Nest Hub detects how close you are and adjusts text size accordingly. Far away = larger text. Close up = detailed info. This is a brilliant ambient display pattern.
- Face-based personalization: Face Match recognizes who is looking at the display and shows their relevant notifications, not someone elseβs.
- Ambient mode rotation: When not actively in use, the display cycles through a configurable set of sources: Google Photos, weather, news, calendar.
- Notification priority: Developers set a priority value in the notification object when using the Google Home Cloud-to-cloud API. Higher priority notifications are more prominent.
Car Dashboards
Automotive HMI (Human-Machine Interface) design is governed by NHTSA Visual-Manual Driver Distraction Guidelines. Key principles:
- Glance time budget: Any visual interaction should require no more than 2 seconds of eyes-off-road time per glance, with a maximum of 12 seconds total task time.
- Priority hierarchy: Safety warnings (collision, lane departure) > Navigation (turn-by-turn) > Communication (phone calls) > Infotainment (music, podcasts)
- Non-lockout: Safety-critical alerts cannot be dismissed or locked out. They persist until the condition clears.
- Context sensitivity: Navigation instructions get more prominent when a turn is imminent. Phone call notifications show more detail when the vehicle is stopped.
This maps directly to our HUD:
- CRITICAL notifications = collision warnings (cannot be dismissed)
- URGENT notifications = navigation (preempts everything except critical)
- STANDARD/ELEVATED = communication (shown at next opportunity)
- AMBIENT/SILENT = infotainment (shown during idle rotation)
Apple Watch Complications
The Apple Watch complication system is the gold standard for ambient data display on a tiny screen.
Key concepts:
- Timeline model: Complications provide past, present, and future data. ClockKit renders the appropriate entry based on the current time. This means complications can show βwhat happenedβ (past), βwhatβs happeningβ (present), and βwhatβs comingβ (future) without live updates.
- Complication families: Different visual layouts for different positions on the watch face β Graphic Corner, Graphic Circular, Graphic Bezel, Modular Small/Large, Utilitarian Small/Flat. Each family has strict size constraints.
- Budget: Complications can update a limited number of times per day. This forces developers to think about what information actually changes and when.
- Always-On Display: The Retina display shows a dimmed, tinted version of complications when the wrist is down. Sensitive information should be redacted in this mode.
Key insight: The Apple Watch timeline model is highly relevant to our HUD. Instead of every update being a βnotification,β sources could provide a timeline of data points. The HUD displays the current entry and can show past/future entries on interaction (hover or click).
Scenario 1: RSVP Interrupted by Urgent Alert
The user is reading text via RSVP at 350 WPM. An urgent alert arrives from Athena.
// The RSVP handler receives the interrupt
const action = rsvpHandler.handleDuringRSVP({
id: "athena:spike:1234",
source: "athena",
channel: "system-alerts",
content: {
summary: "Traffic spike: 5x normal on scalable-media",
severity: "yellow",
actions: [
{ id: "ack", label: "OK", handler: "ack://athena:spike" },
{ id: "investigate", label: "Details", handler: "open://dashboard" },
],
},
level: InterruptionLevel.URGENT,
createdAt: Date.now(),
expiresAt: Date.now() + 30_000,
state: "pending",
updateCount: 0,
relevanceScore: 0.9,
});
// Result: { action: "stop-rsvp", rsvpCancelled: true }
// RSVP stops, alert is shown, pending RSVP notifications are flushed to queue
The RSVP configuration determines the threshold:
pauseLevel: 3(ELEVATED) β pause RSVP, show notification, resumestopLevel: 4(URGENT) β stop RSVP entirely
If the alert were ELEVATED instead of URGENT, RSVP would pause for 5 seconds (the notificationβs TTL or default), show the alert, then resume reading from 3 words back.
Scenario 2: Multiple Agents Sending Simultaneously
Three agents all report status within 1 second of each other.
// All three arrive within ~1 second
engine.process(agentStatusUpdate("athena", "nominal", "green"));
engine.process(agentStatusUpdate("jane", "watching", "green"));
engine.process(agentStatusUpdate("coo", "queue clear", "green"));
Because all three use channel agent-status with collapseStrategy: "replace", and all use replacesId: "agent:<name>:latest", each agentβs notification replaces its own previous one. The priority queue now has 3 entries at AMBIENT level.
Since no notification is currently displayed (or the current one has a lower/equal priority), the queue shows the first one that arrived. But all three are AMBIENT β they donβt preempt anything. They enter the time-sharing rotation instead.
The rotation cycles through them every 8 seconds:
- 0s: βathena: nominalβ (green)
- 8s: βjane: watchingβ (green)
- 16s: βcoo: queue clearβ (green)
- 24s: back to βathena: nominalβ
If one of them is YELLOW severity:
engine.process(agentStatusUpdate("athena", "traffic spike 3x", "yellow"));
This notification is now STANDARD level (because severity=yellow maps to STANDARD in our agent status function). It preempts the ambient rotation and shows immediately. The rotation pauses until this notification expires (30 seconds).
Scenario 3: User Wants Quiet Hours
The user wants complete quiet from 10 PM to 7 AM, except for critical alerts from Athena.
// Option 1: Focus mode (recurring, with source exception)
const sleepMode: FocusMode = {
id: "sleep",
name: "Sleep",
active: false,
allowedChannels: [],
allowedSources: ["athena"], // only Athena can break through
minimumLevel: InterruptionLevel.CRITICAL, // only critical from Athena
schedule: {
days: [0, 1, 2, 3, 4, 5, 6],
startTime: "22:00",
endTime: "07:00",
timezone: "America/Chicago",
},
fallback: "nothing", // don't even show a count
};
// Option 2: Mute timing (recurring, broader)
muteManager.addMuteTiming({
name: "Sleep hours",
days: [0, 1, 2, 3, 4, 5, 6],
startTime: "22:00",
endTime: "07:00",
});
// Plus: ensure Athena's CRITICAL notifications bypass mute timings
// (which they do by default β CRITICAL bypasses everything)
// Option 3: One-time silence for tonight only
muteManager.addSilence({
reason: "Going to bed early",
startsAt: new Date("2026-03-28T21:00:00"),
endsAt: new Date("2026-03-29T08:00:00"),
});
The layered approach means:
- Mute timings are checked first (recurring quiet hours)
- Silences override mute timings (one-time exceptions)
- Focus modes provide source-level filtering within quiet hours
- CRITICAL notifications always break through everything
Scenario 4: Thundering Herd After Focus Mode Ends
The user has been in Deep Work mode for 3 hours. During that time, 47 notifications were suppressed. When Deep Work ends, what happens?
Bad approach: flood the display with 47 queued notifications.
Good approach (what our system does):
// When focus mode deactivates, check the deferred queue
function onFocusModeDeactivated(modeId: string): void {
const deferred = history.query({
decision: "filtered",
since: focusModeActivatedAt,
});
if (deferred.length === 0) return;
if (deferred.length <= 3) {
// Small number: show them one by one with standard TTL
for (const entry of deferred) {
engine.process(reconstructNotification(entry));
}
} else {
// Many notifications: show a summary
const bySeverity = groupBy(deferred, (e) =>
e.level >= InterruptionLevel.ELEVATED ? "important" : "routine"
);
const important = bySeverity.important ?? [];
const routine = bySeverity.routine ?? [];
if (important.length > 0) {
engine.process({
id: `summary:focus-end:important:${Date.now()}`,
source: "system",
channel: "system-alerts",
content: {
summary: `${important.length} important alerts while in ${modeId}`,
body: important.map((e) => `- ${e.summary}`).join("\n"),
severity: "yellow",
},
level: InterruptionLevel.STANDARD,
createdAt: Date.now(),
expiresAt: Date.now() + 60_000,
state: "pending",
updateCount: 0,
});
}
if (routine.length > 0) {
// Just show a count for routine notifications
engine.process({
id: `summary:focus-end:routine:${Date.now()}`,
source: "system",
channel: "ambient",
content: {
summary: `${routine.length} notifications while in ${modeId}`,
severity: "green",
},
level: InterruptionLevel.AMBIENT,
createdAt: Date.now(),
expiresAt: Date.now() + 30_000,
state: "pending",
updateCount: 0,
});
}
}
}
function groupBy<T>(
array: T[],
keyFn: (item: T) => string
): Record<string, T[]> {
return array.reduce(
(groups, item) => {
const key = keyFn(item);
(groups[key] ??= []).push(item);
return groups;
},
{} as Record<string, T[]>
);
}
function reconstructNotification(entry: HistoryEntry): Notification {
return {
id: `deferred:${entry.notificationId}`,
source: entry.source,
channel: entry.channel,
content: { summary: entry.summary },
level: entry.level,
createdAt: Date.now(),
expiresAt: Date.now() + 30_000,
state: "pending",
updateCount: 0,
};
}
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β NOTIFICATION SOURCES β
ββββββββββββ¬ββββββββββββ¬βββββββββββ¬ββββββββββββ¬ββββββββββββββββββββ€
β Athena β Jane β System β User β Cloud Services β
β (agent) β (agent) β Monitor β Scripts β (webhooks) β
ββββββ¬ββββββ΄ββββββ¬ββββββ΄βββββ¬ββββββ΄ββββββ¬ββββββ΄βββββββββ¬βββββββββββ
β β β β β
βΌ βΌ βΌ βΌ βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β NOTIFICATION INGRESS β
β ββββββββββββββββ ββββββββββββββββ ββββββββββββββββββββββ β
β β REST API β β Status File β β SSE Webhooks β β
β β POST /notify β β ~/.atlas/ β β (inbound) β β
β β β β status.json β β β β
β ββββββββ¬ββββββββ ββββββββ¬ββββββββ βββββββββββ¬βββββββββββ β
β β β β β
β ββββββββββ¬βββββββββββββββββββββββββββββββ β
β βΌ β
β ββββββββββββββββββββββββββββββββββββββββββββββββ β
β β POLICY ENGINE β β
β β β β
β β 1. Validate (known channel, required fields) β β
β β 2. Source trust check (max level) β β
β β 3. Rate limit (token bucket + sliding window)β β
β β 4. Mute timing / silence check β β
β β 5. Focus mode filter β β
β β 6. Channel collapse strategy β β
β β 7. Calculate effective priority β β
β β 8. Route to queue or rotation β β
β β β β
β β βββββββββββββββββββββββββββ β β
β β β History Log β β β
β β β (every decision logged) β β β
β β βββββββββββββββββββββββββββ β β
β ββββββββββββββββ¬ββββββββββββββββββββββββββββββββ β
βββββββββββββββββββΌββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββ΄ββββββββββ
βΌ βΌ
ββββββββββββββββ ββββββββββββββββββββ
β PRIORITY β β TIME-SHARING β
β QUEUE β β ROTATION β
β β β β
β ββββββββββ β β ββββββββββββ β
β βCRITICALβ β β β Agent β β
β ββββββββββ€ β β β Status β β
β βURGENT β β β ββββββββββββ€ β
β ββββββββββ€ β β β CPU/RAM β β
β βELEVATEDβ β β ββββββββββββ€ β
β ββββββββββ€ β β β Weather β β
β βSTANDARDβ β β ββββββββββββ€ β
β ββββββββββ β β β Calendar β β
β β β ββββββββββββ β
β Preemption β β Round-robin β
β by priority β β every 8s β
ββββββββ¬ββββββββ ββββββββββ¬βββββββββ
β β
ββββββββββ¬ββββββββββββ
βΌ
ββββββββββββββββββββββββββββββββββββ
β DISPLAY SCHEDULER β
β β
β If queue non-empty: β
β β Show highest priority item β
β β Pause rotation β
β β
β If queue empty: β
β β Resume rotation β
β β Cycle through ambient β
β β
β If RSVP active: β
β β Apply RSVP interruption β
β protocol β
β β
β Escalation engine runs in β
β parallel, re-submitting at β
β higher levels on timeout. β
ββββββββββββββββ¬ββββββββββββββββββββ
βΌ
ββββββββββββββββββββββββββββββββββββ
β DISPLAY OUTPUT β
β β
β ββββββββββββββββββββββββββββββ β
β β SSE Stream (to HUD client) β β
β β event: notification:displayβ β
β β event: rotation:show β β
β β event: mode:change β β
β ββββββββββββββββββββββββββββββ β
β β
β ββββββββββββββββββββββββββββββ β
β β Status File β β
β β ~/.atlas/status.json β β
β β (for local HUD) β β
β ββββββββββββββββββββββββββββββ β
β β
β ββββββββββββββββββββββββββββββ β
β β Escalation Side-channels β β
β β - Telegram β β
β β - Sound β β
β β - SMS (critical only) β β
β ββββββββββββββββββββββββββββββ β
ββββββββββββββββββββββββββββββββββββ
Start with the File-Based Protocol
For a single-machine macOS HUD, the file-based protocol (~/.atlas/status.json) is the right starting point:
- Zero infrastructure: No server, no daemon, no database. Any process that can write a JSON file can send notifications.
- FSEvents is fast: macOS FSEvents triggers within milliseconds of a file change.
- Race conditions are manageable: Read-modify-write with a file lock (or accept last-writer-wins for non-critical data).
- Easy debugging:
cat ~/.atlas/status.json | jqtells you exactly what the HUD is seeing.
Graduate to the SSE API when you need:
- Multiple display clients (HUD + dashboard + mobile)
- Remote notification sources (cloud webhooks)
- Guaranteed ordering and delivery
- Full notification lifecycle tracking
Channel Taxonomy
Start with 5-6 channels. You can always add more, but you cannot easily remove them (Android learned this: channels persist even if the app removes them in code).
Recommended starter channels:
- agent-status β AI agent heartbeats and status updates
- system-alerts β Infrastructure anomalies and failures
- system-metrics β CPU/RAM/disk/network readings (ambient rotation only)
- timers β User timers and calendar reminders
- ambient β Weather, learning cards, news, quotes
- user-messages β Chat, email, social (if you want the HUD to show these)
Priority Level Mapping
Map your sources to levels conservatively. Most notifications should be AMBIENT or STANDARD. Reserve URGENT for things that genuinely need immediate attention. Reserve CRITICAL for βthe building is on fire.β
A practical rule of thumb:
- SILENT: Data that might be interesting later but never needs to be shown (pure logging)
- AMBIENT: Data that should be visible when nothing else is showing (rotation)
- STANDARD: Events worth seeing at the next opportunity (queued, shown after current expires)
- ELEVATED: Events worth interrupting ambient content for (preempts rotation within 2s)
- URGENT: Events worth interrupting whatever the user is doing (preempts RSVP)
- CRITICAL: βThis needs attention RIGHT NOW regardless of any settingsβ (bypasses everything)
If more than 10% of your notifications are URGENT or above, your priority levels are miscalibrated. Recalibrate.
Testing Your Policy Engine
Write tests for these specific scenarios:
- Higher priority preempts lower priority
- Same priority respects FIFO
- Rate-limited notifications return appropriate retry-after
- Focus mode blocks the right notifications and passes the right ones
- CRITICAL always breaks through everything (Focus, mute timing, rate limit)
- Mute timing suppresses during configured hours
- Silence suppresses during configured window and then stops
- RSVP interruption protocol works at each level boundary
- Escalation fires at configured timeouts
- Acknowledgment stops escalation and retry
- History log records every decision
- Queue doesnβt grow unbounded (expired items are cleaned up)
- Rotation pauses when priority notification arrives and resumes when queue empties
- Focus mode end doesnβt flood the display
- replacesId correctly updates in-place
Official Documentation
- UNNotificationInterruptionLevel β Apple Developer β iOS interruption level enum: passive, active, timeSensitive, critical
- Managing Notifications β Apple Human Interface Guidelines β Appleβs design guidance for notification behavior
- threadIdentifier β Apple Developer β iOS notification grouping via thread identifiers
- UNUserNotificationCenter β Apple Developer β macOS/iOS notification center API
- Send communication and Time Sensitive notifications β WWDC21 β Appleβs introduction of interruption levels and Focus integration
- Complications β Apple Developer β watchOS complication design guidelines
- Creating complications for your watchOS app β Apple Developer β ClockKit timeline model for ambient data
- DynamicIsland β Apple Developer β Dynamic Island API for competing Live Activities
- Create and manage notification channels β Android Developers β Android notification channel creation and importance levels
- About notifications β Android Developers β Android notification system overview
- Create a group of notifications β Android Developers β Android notification bundling and grouping
- Desktop Notifications Specification β freedesktop.org β Complete D-Bus notification protocol spec
- D-BUS Protocol β freedesktop.org β Notify method signature and parameters
- Basic Design β Desktop Notifications Specification β Urgency levels and design philosophy
Alert Management Platforms
- Escalation Policy Basics β PagerDuty β Multi-step escalation with timeouts
- Dynamic Notifications β PagerDuty β Severity-to-urgency mapping
- Configurable Service Settings β PagerDuty β Service-level notification configuration
- Configure notification policies β Grafana β Label-based notification routing tree
- Configure mute timings β Grafana β Recurring mute timing configuration
- Mute timing vs silences β Grafana Blog β When to use mute timings vs one-time silences
- Notifications β Grafana β Group wait, group interval, repeat interval
- Pushover API β 5 priority levels, sounds, TTL
- Pushover Receipts API β Emergency notification acknowledgment and retry
Cloud Messaging APIs
- Set and manage Android message priority β Firebase β Normal vs high priority FCM messages
- Non-collapsible and collapsible messages β Firebase β collapse_key mechanics, 4-key limit
- Understanding FCM Message Delivery on Android β Firebase Blog β Per-device notification budget details
Smart Displays and Automotive
- Turn Nest displays notifications on or off β Google β Nest Hub notification management
- Notifications for smart home Actions β Google Home Developers β Smart home notification priority API
- NHTSA Visual-Manual Driver Distraction Guidelines β 2-second glance rule, priority hierarchy for automotive displays
- Design of Automotive HMI β MDPI Applied Sciences β HMI challenges: safety, UX, security
Linux Notification Daemons
- dunst β GitHub β Lightweight notification daemon with rule-based configuration
- Dunst β ArchWiki β Configuration guide: urgency levels, stack height, per-app rules
- Desktop notifications β ArchWiki β Overview of notification systems on Linux
Notification Delivery Patterns
- WebSockets vs SSE vs Long-Polling β RxDB β Comprehensive comparison of real-time delivery technologies
- WebSockets vs Server-Sent Events β Ably β When to use SSE vs WebSocket
- SSE vs WebSockets for SaaS Notifications β Notilayer β SSE advantages for notification-specific use cases
- WebSocket Notifications β websocket.org β WebSocket notification delivery patterns
iOS Notification Deep Dives
- iOS Focus modes and interruption levels β OneSignal β Practical guide to interruption level payload configuration
- Whatβs new in notifications in iOS 12 β mackuba.eu β Thread identifiers and grouping introduction
- How to group user notifications β Hacking with Swift β threadIdentifier and summaryArgument code examples
- Apple Intelligence Impact on Push Notifications β Batch β iOS 18 notification summary with AI ranking
Internal References
- Jane + Atlas HUD Design β Channel policy (notch, Telegram, sound, terminal), traffic light severity, status file contract
- HUD Layout Engine Design β Content bus protocol, severity-driven layout, zone system, panel columns
- LCD Display Protocols β Display protocol foundations for the virtual display renderer