noti#
noti pools notifications from a linked group of ATProto accounts and recommends account-scoped notification actions from recent inbox state and preferences.
It logs in with atproto OAuth, reads the live Bluesky notification inbox, and can:
- mute or unmute an account
- block or unblock an account
- turn post notifications on or off for an account
- turn reply notifications on or off for an account
- mark notifications seen
One allowlisted account owns a persistent account group. Additional accounts can be linked with their own OAuth grants, switched in the UI, and observed together in a shared unread-notification pool. The cursor API retains the full observed event history. Mutations always remain scoped to one active account.
Mutations are code-mode: model-written compositions over a restricted atproto SDK surface, executed in a Cloudflare Worker isolate and verified against live state before success is reported.
architecture#
- Cloudflare Worker app in
src/worker.ts - D1-backed OAuth state, persistent account groups, browser sessions, and user guidance in
src/cloudflare/* - One
UserGraphDurable Object per account DID for account-local history and features - One
GroupInboxDurable Object per account group for an ordered, replayable notification event log - Worker Loader sandboxing in
src/cloudflare/code-mode.ts - Background recommendations via Cloudflare Queues with D1 job/result storage
- At-least-once outbound delivery via a dedicated Cloudflare Queue, with encrypted destination credentials and a D1 delivery ledger
- D1 allowlist and LLM usage tracking/limits in
src/cloudflare/usage.ts - Anthropic prompt caching on the stable code-mode system prompt
- Local app bundle from
scripts/build-client.ts, including pinnedhtmx.organdsrc/client.ts - Login typeahead backed by
https://typeahead.waow.tech
shared notification API#
The browser-authenticated event surface is:
GET /api/groups/current/notifications?after=0&limit=100&account=did:plc:...
- Omit
afterto receive the latest events in newest-first order. - Send
after=0to replay from the oldest retained event, or pass a returned cursor to receive newer events in sequence order. accountis optional and must identify an account linked to the current group.- Events are idempotent by
(account DID, notification URI)and retain a stable group sequence.
The five-minute cron syncs the notification bus for linked accounts. Every fifteen minutes it also performs the full account snapshot and recommendation refresh. Initial linking backfills up to 500 recent notifications; subsequent polls stop when they reach an event already present in the group log.
outbound delivery#
Account-group owners can connect a Discord webhook from settings. noti validates the destination against Discord's webhook URL shape, sends a test message before saving it, and encrypts the credential with AES-GCM. A strict connection/resume watermark rejects notifications indexed before the integration became active, even if an inbox backfill first observes them later.
The default delivery strategy includes direct interactions (mentions, replies, and quotes). Owners can independently enable follows, subscribed activity, and noisy likes/reposts. Each eligible shared-pool sequence becomes one idempotent D1 delivery record. A dedicated, single-consumer Cloudflare Queue performs at-least-once delivery with bounded retries and groups bursts into Discord messages of up to ten events; permanent failures remain visible in the integration status. Discord is the first provider adapter—the shared event log and delivery queue are provider-independent.
OAuth scopes#
noti requests granular atproto OAuth scopes:
atprotorepo:app.bsky.graph.blockrpc:app.bsky.notification.listNotifications?aud=*rpc:app.bsky.notification.listActivitySubscriptions?aud=*rpc:app.bsky.notification.putActivitySubscription?aud=*rpc:app.bsky.notification.updateSeen?aud=*rpc:app.bsky.graph.getMutes?aud=*rpc:app.bsky.graph.getBlocks?aud=*rpc:app.bsky.graph.muteActor?aud=*rpc:app.bsky.graph.unmuteActor?aud=*rpc:app.bsky.actor.getProfile?aud=*rpc:app.bsky.actor.getProfiles?aud=*rpc:app.bsky.feed.getPosts?aud=*
local dev#
Create .dev.vars:
ANTHROPIC_API_KEY=...
WEBHOOK_ENCRYPTION_KEY=... # 32 random bytes encoded as base64url
Generate the webhook encryption key with:
bun -e 'console.log(Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString("base64url"))'
Run:
bun install
bunx wrangler d1 migrations apply noti --local
bun run dev -- --ip 127.0.0.1 --port 8787
Open http://127.0.0.1:8787/oauth/login.
Allow a DID locally:
bunx wrangler d1 execute noti --local --command "INSERT INTO invite_allowlist (user_did, note, created_at) VALUES ('did:plc:...', 'local dev', datetime('now'))"
checks#
bun run check
bun test
bun run lint
bun run check:dry-run
deploy#
Before deploying:
- set
ANTHROPIC_API_KEYas a Cloudflare secret - create the
noti-recommendationsCloudflare Queue - apply D1 migrations to
noti - add allowed user DIDs to
invite_allowlist
Then:
bun run deploy
Current deployment: https://noti.n8-3e9.workers.dev