The notify service is how people hear about what happens on the network when they are not looking. It follows a daemon's activity feed, turns new comments and mentions into notifications, keeps an inbox and read state for each account, and sends email for mentions, replies and discussions. The web app, the desktop app, the vault and the mobile app all read their notification inbox from it. The hosted instance is https://notify.seed.hyper.media.
Notifications are centralized on purpose, for now. The desktop app no longer discovers its own notifications over peer-to-peer sync. The notify server is the one source, and read state syncs only when a client is online. The team treats this as temporary. The plan is private peer-to-peer sync of subscriptions and read state.
Where the code is
frontend/apps/notify, package @shm/notify: Remix 2.17 on Node with Vite, better-sqlite3, nodemailer, Sentry. The email templates are a separate package, frontend/apps/emails (@shm/emails, react-email and MJML).
Path | What it holds |
|---|---|
| Boot: loads |
| The SQLite schema, migrations and statements. |
| The loops that read the activity feed, classify events, write inbox rows and send email. |
| The unified state API: snapshot and actions. |
| Request signatures and agent-capability checks. |
| The SMTP sender. |
| Every HTTP route, listed below. |
| The client transport, payload shape, read-state logic and comment classifier shared with every client. |
Read frontend/apps/notify/README.md first, then app/NOTIFICATIONS_SERVICE_ARCHITECTURE.md for the service. The client halves are in frontend/apps/web/app/NOTIFICATIONS_WEB_ARCHITECTURE.md and frontend/apps/desktop/src/NOTIFICATIONS_DESKTOP_ARCHITECTURE.md. NOTIFICATIONS_REVIEW.md is a restructuring memo, docs/notifications/local-first-read-state-edge-cases.md covers offline read state, and email-notification-signing-notes.md at the repository root is historical: it describes an earlier per-feature API that no longer matches the code.
How it talks to the daemon
The service holds one gRPC-web client to DAEMON_HTTP_URL, the daemon's HTTP port. It pages through ActivityFeed.ListEvents for new events, loads comments, documents and accounts through the same typed API handlers the web app uses, and asks AccessControl.ListCapabilitiesForDelegate whether a signing key holds an AGENT capability for the account it claims. The hosted service reads from the hyper.media gateway daemon, so it sees what that node has synced.
The notifier
At startup the service processes once, then runs two loops.
Loop | Every | Produces |
|---|---|---|
Immediate | 15 seconds |
|
Batch | 30 seconds, sending at most every 4 hours (6 minutes in development) |
|
Each loop keeps a cursor in the notifier_status table. Event ids are derived from the blob, blob-<cid> or mention-<cid>-<type>-<target>, and the same id is the inbox row's key and the read-state key. A page loop is capped at 100 pages per pass.
Some pieces exist only in name. site-doc-update exists in the payload schema and the notifyOwnedDocChange flag exists in the settings, but the notifier has a TODO where document-update delivery should be, so nobody gets document-change email today. Batch-only notifications are not clearly written to the inbox.
Storage
One SQLite file, web-db.sqlite, in DATA_DIR or the working directory. The tables that matter:
Table | Holds |
|---|---|
| Persisted notification payloads per account. |
| A "read everything before this time" watermark per account, plus explicit reads above it. |
| An account's notification email and its verification. |
| Accounts registered for an inbox, used by the notifier and the vault. |
| Legacy public site subscriptions and one-click unsubscribe tokens. |
| Loop cursors and the last batch send time. |
The watermark design keeps "mark all as read" to one row and makes unread toggling reversible.
The HTTP API
All routes live under /hm/api and /hm on the notify host. The signed routes take a POST body of DAG-CBOR: the client builds {action, signer, time, accountUid?}, signs the CBOR encoding, and sends it with sig. The service re-encodes the unsigned payload to verify the signature. If accountUid differs from the signer, the signer must hold an AGENT capability for that account, which is how a browser session key acts for the account held in the vault. The service accepts Ed25519 and compressed P-256 signers.
Route | Kind | Used by |
|---|---|---|
| signed: | web, desktop, mobile; returns inbox, email config and read state in one snapshot |
| signed | the vault: inbox registration, email config, trusted email prevalidation |
| signed | compatibility only; no first-party client calls it |
| unsigned | legacy site email subscriptions |
| token | the settings page linked from emails |
| token | one-click unsubscribe, RFC 8058 |
| link | email verification |
| link | marks an email's notification read, then redirects to the comment |
Email prevalidation. A vault that has already verified a person's email signs {email, signer, host} with its daemon's key. The notify service accepts that instead of sending a verification email only when the host is in NOTIFY_TRUSTED_PREVALIDATORS, and only after fetching the host's /hm/api/config and checking that signerAccountUid matches the signer.
Configuration
Variable | Meaning |
|---|---|
| The daemon to read. |
| Listen port; the Docker image uses 3000, the hosted deployment 3560. |
| Where |
| The public site used to build links in emails. |
| This service's own public URL, used in email links. |
| Outgoing mail. Without host, user and password, emails are logged and dropped. |
| Vault origins whose email prevalidation is trusted. |
| Error reporting. |
Running it
pnpm notify # Remix dev server on :3060, against the desktop dev daemon on 58001
pnpm notify:standalone # its own daemon on 54000-54002 and notify on :3061
pnpm --filter @shm/notify test./dev up starts it as the notify pane and points the vault pane at it. Images are seedhypermedia/notify:dev from main and seedhypermedia/notify:latest for releases. The hosted services are notify.seed.hyper.media and notify-dev.seed.hyper.media.
Working with it
In the Seed app
The desktop app keeps a local optimistic notification store and syncs it with /hm/api/notifications, signing each request through the daemon's SignData RPC. The notify host comes from VITE_NOTIFY_SERVICE_HOST, https://notify.seed.hyper.media in release builds. Account settings can override it, and the override is saved in the synced vault state.
Web API
A site advertises its notify service as notifyServiceHost in /hm/api/config (see The web API), set from NOTIFY_SERVICE_HOST on the web app. The web app's /hm/notifications page signs requests in the browser with the session key and stores the notify host it learned at sign-in. The mobile app reads the same field and falls back to the hosted service.
SDK
The SDK has no notify client. The transport lives in @shm/shared (models/notification-service.ts). A third party can reproduce it with the SDK's CBOR and signing helpers.
Agents
Seed Agents do not use the notify service. They watch the network themselves through the activity feed. See triggers.
Where this is going
As of September 2026, the team plans three changes. None of them is built.
Move subscriptions and read state out of this central server into private peer-to-peer sync once private documents mature.
Restore the link between an email and the account that subscribes. It was dropped in 2025 and later called a mistake.
Replace the legacy site subscriptions with per-site options for document changes, discussions, comments and mentions.
Do you like what you are reading? Subscribe to receive updates.
Unsubscribe anytime