skrift-notifications
Skrift real-time notification system — SSE delivery, pluggable backends (InMemory, Redis, PgNotify), group keys, and dismiss patterns.
SKILL.md.claude/skills/skrift-notifications/SKILL.md
GitHubSkrift Notifications
Real-time notification system delivered via Server-Sent Events (SSE). Notifications appear as toast popups and persist until dismissed.
Delivery Scopes
from skrift.notifications import notify_session, notify_user, notify_broadcast, ensure_nid
# Session-scoped — stored, replayed on reconnect
nid = ensure_nid(request)
await notify_session(nid, "generic", title="Saved", message="Your draft was saved.")
# User-scoped — stored, delivered to all sessions of a user
await notify_user(str(user.id), "generic", title="New reply", message="Someone replied.")
# Broadcast — ephemeral, not stored, all active connections
await notify_broadcast("new_tweet", tweet_id="...", content_html="...")
| Function | Stored? | Target | Use case |
|---|---|---|---|
await notify_session(nid, type, *, group=None, **payload) |
Yes | Single session | Transient feedback (saves, errors) |
await notify_user(user_id, type, *, group=None, **payload) |
Yes | All sessions of user | Cross-device (replies, likes) |
await notify_broadcast(type, *, group=None, **payload) |
No | All connections | Feed updates (new posts) |
Stored notifications replay on reconnect. Broadcast notifications are ephemeral.
Group Key — Replace-in-Place
All three functions accept an optional group keyword. A new notification with the same group key automatically dismisses the previous one:
# Progress updates — each replaces the previous toast
nid = ensure_nid(request)
await notify_session(nid, "generic", group="deploy", title="Deploying…", message="Step 1/3")
await notify_session(nid, "generic", group="deploy", title="Deploying…", message="Step 2/3")
await notify_session(nid, "generic", group="deploy", title="Deployed!", message="Done")
# User-scoped status update
await notify_user(str(user.id), "generic", group="upload-status", title="Uploading…", message="50%")
await notify_user(str(user.id), "generic", group="upload-status", title="Upload complete", message="100%")
# Broadcast
await notify_broadcast("generic", group="maintenance", title="Maintenance", message="Starting soon…")
Dismissing by Group Key (Backend)
from skrift.notifications import dismiss_session_group, dismiss_user_group
# Dismiss the active "deploy" notification without knowing its UUID
await dismiss_session_group(nid, "deploy")
# Dismiss from a user's queue (pushes dismissed event to all their sessions)
await dismiss_user_group(str(user.id), "upload-status")
Notification Types
"generic"— rendered as a toast withtitle+message(built-in UI)"dismissed"— internal, triggers client-side removal- Custom types — dispatched via
sk:notificationCustomEvent for app-specific handling
Controller Pattern — Notify on Action
from skrift.notifications import notify_user
@post("/{item_id:uuid}/comment", guards=[auth_guard])
async def comment(self, request: Request, db_session: AsyncSession, item_id: UUID) -> Redirect:
user = await self._get_user(request, db_session)
comment = await comment_service.create(db_session, user.id, item_id, form.data.content)
item = await item_service.get_by_id(db_session, item_id)
if item and str(item.user_id) != str(user.id):
await notify_user(
str(item.user_id),
"generic",
title=f"{user.name} commented on your post",
message=form.data.content[:100],
)
return Redirect(path=f"/items/{item_id}")
Backend Architecture
The notification service uses a pluggable backend system for storage and cross-replica fanout.
Backend Protocol
class NotificationBackend(Protocol):
async def start(self) -> None: ...
async def stop(self) -> None: ...
async def store(self, scope: str, scope_id: str, notification: Notification) -> None: ...
async def remove(self, notification_id: UUID) -> None: ...
async def remove_by_group(self, scope: str, scope_id: str, group: str) -> UUID | None: ...
async def get_queued(self, scope: str, scope_id: str) -> list[Notification]: ...
async def publish(self, message: dict) -> None: ...
def on_remote_message(self, callback: Callable[[dict], Any]) -> None: ...
InMemoryBackend (default)
Dict-based storage, no cross-replica fanout. Suitable for single-process deployments.
- No configuration needed — used automatically when
notifications.backendis unset - Notifications stored in Python dicts, lost on restart
RedisBackend
Database storage + Redis pub/sub for cross-replica fanout.
- Notifications persisted in
stored_notificationstable - Pub/sub via a Redis channel for real-time fanout across replicas
- Requires
skrift[redis]extra (pip install 'skrift[redis]')
PgNotifyBackend
Database storage + PostgreSQL LISTEN/NOTIFY for cross-replica fanout.
- Notifications persisted in
stored_notificationstable - Uses PostgreSQL's native
LISTEN/NOTIFY— no extra infrastructure - Automatic reconnect with exponential backoff
- Requires
asyncpg(already a Skrift dependency)
_DatabaseStorageMixin
Shared by RedisBackend and PgNotifyBackend. Provides:
- DB-backed
store(),remove(),remove_by_group(),get_queued() - Background cleanup task: deletes notifications older than 24 hours (runs every 10 minutes)
Backend Configuration
Configure in app.yaml:
# InMemory (default — no config needed)
# notifications:
# backend: ""
# Redis
redis:
url: $REDIS_URL
prefix: "myapp" # optional key prefix
notifications:
backend: "skrift.lib.notification_backends:RedisBackend"
# PgNotify (uses existing db.url)
notifications:
backend: "skrift.lib.notification_backends:PgNotifyBackend"
Config classes in skrift/config.py:
class RedisConfig(BaseModel):
url: str = ""
prefix: str = "" # e.g. "myapp" → keys like "myapp:skrift:notifications"
class NotificationsConfig(BaseModel):
backend: str = "" # empty = InMemoryBackend; or "module:ClassName"
Backend is instantiated in skrift/asgi.py during app creation and started/stopped via ASGI lifespan hooks.
StoredNotification Model
Used by RedisBackend and PgNotifyBackend for persistent storage:
class StoredNotification(Base):
__tablename__ = "stored_notifications"
scope: Mapped[str] # "session" or "user"
scope_id: Mapped[str] # nid or user_id
type: Mapped[str] # notification type
payload_json: Mapped[str] # JSON-encoded payload
group_key: Mapped[str | None]
notified_at: Mapped[datetime]
Indexes: (scope, scope_id), (scope, scope_id, group_key), (notified_at).
Cleanup: rows older than 24 hours are deleted automatically by the backend's cleanup loop.
SSE Protocol (Three-Phase)
- Flush: Server sends all queued notifications for the session/user
- Sync: Server sends
event: sync— client reconciles (removes dismissed-elsewhere items) - Live: Server pushes new notifications as they arrive; 30s keepalive comments prevent proxy timeouts
During the Live phase, group-based replacement sends a "dismissed" event for the old notification followed by the new one.
Client Behavior
- Auto-connects on page load and
window.focus, disconnects onwindow.blur - Reconnects after 5s on error
- Deduplicates via
_displayedIdsSet - Max visible toasts: 3 (desktop) / 2 (mobile); excess queued
- Dispatches
sk:notificationCustomEvent (cancelable) for every notification - Only renders
"generic"type as toast; custom types handled via event listeners - Global instance:
window.__skriftNotifications(exposes.statusgetter)
Custom Event Handling (Client-Side)
document.addEventListener('sk:notification', (e) => {
const data = e.detail; // { type, id, ...payload }
if (data.type === 'my_custom_type') {
// Handle custom notification — build your own UI
e.preventDefault(); // Prevents default generic toast
}
});
Connection Status Events
document.addEventListener('sk:notification-status', (e) => {
console.log(e.detail.status); // "connecting" | "connected" | "disconnected" | "reconnecting"
});
Dismiss Flow
- User clicks dismiss → client adds
.sk-notification-exit(slide-out animation) - Client sends
DELETE /notifications/{id} - Server removes from queues, broadcasts
"dismissed"event to user's other sessions - Other sessions remove the toast via
_removeDismissed()
Backend dismiss by group: dismiss_session_group(nid, group) and dismiss_user_group(user_id, group).
Hook Integration
Two hook constants defined in skrift/lib/hooks.py:
NOTIFICATION_SENT— action fired after a notification is sentNOTIFICATION_DISMISSED— action fired after a notification is dismissed
Key Files
| File | Purpose |
|---|---|
skrift/lib/notifications.py |
NotificationService singleton, convenience functions |
skrift/lib/notification_backends.py |
InMemoryBackend, RedisBackend, PgNotifyBackend |
skrift/db/models/notification.py |
StoredNotification model |
skrift/controllers/notifications.py |
SSE stream + dismiss HTTP endpoints |
skrift/static/js/notifications.js |
SkriftNotifications client class |
skrift/static/css/skrift.css |
.sk-notification* toast styles |
skrift/config.py |
RedisConfig, NotificationsConfig classes |
Endpoints
| Method | Path | Purpose |
|---|---|---|
GET |
/notifications/stream |
SSE stream (auto-connected by notifications.js) |
DELETE |
/notifications/{id} |
Dismiss by notification UUID |
DELETE |
/notifications/group/{group} |
Dismiss by group key |