notification manager for bsky noti.waow.tech
TypeScript 100%
Just <1%

README.md

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
  • change account-level notification filtering per reason (likes from follows only, and so on)
  • create a curation list, and add or remove accounts from one
  • mark notifications seen

Everything but list curation is available both as a background recommendation and on request. Lists are request-only: membership is a public record and curation is the user's own act, so noti will organise a list when asked and never on its own initiative. The same asymmetry already governs blocking.

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.

asking for a change #

ask noti takes a request in plain language and answers with a proposal you apply, a question, an explanation, or a refusal. When a request is answerable but under-specified — two accounts with similar names, several lists that might fit — noti asks rather than guessing, and offers the possible answers as one-click follow-ups. The exchange persists across turns and page loads in the account's Durable Object, so a bare answer like "the first one" resolves against the question that preceded it. Applying a proposal ends the exchange; "start over" ends it without applying anything.

what noti knows #

/me shows the evidence noti has joined about the accounts that notify you: totals over the whole history, the people it has noticed, the lists you keep, and how fresh its copy of your public relationship records is. /me?advanced=1 answers a different question — why noti believes a thing. Every stored assertion appears with the source that made it, when it was observed, its ttl, and what asserted it, including rows that lost to a higher-precedence source. Records link to pdsls.

browser extension #

The same actions as a glass pill in the top-left of your bluesky client — bsky.app and the other social-app forks in the shared client registry. GET /panel renders it; its forms post to the routes the app already has. Nobody hosts the extension: it is built from this repo against your own deployment (bun run build:extension reads PUBLIC_URL from wrangler.jsonc) and loaded from extension/dist. The host page decides which account you are looking at — the extension reads one DID from bsky.app's persisted session and noti switches to it when it is linked — and noti's own cookie decides what it may do; the host page's tokens are never read. See extension/README.md for loading it and docs/browser-extension.md for the design.

architecture #

  • Cloudflare Worker app in src/worker.ts
  • D1-backed OAuth state, persistent account groups, browser sessions, and user guidance in src/cloudflare/*
  • One UserGraph Durable Object per account DID for account-local history and features
  • One GroupInbox Durable 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 pinned htmx.org and src/client.ts
  • Browser extension in extension/, built by scripts/build-extension.ts into extension/dist
  • 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 after to receive the latest events in newest-first order.
  • Send after=0 to replay from the oldest retained event, or pass a returned cursor to receive newer events in sequence order.
  • account is 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.

Discovery #

Friends-of-friends discovery is an opt-in addition to the existing recommended actions. Set DISCOVERY_ACCOUNT_DID to one account DID for a trial; an empty value disables collection and serving. Scheduled collection is capped at one run per 24 hours, with up to 13 public graph reads and two authenticated profile batches, and no LLM calls. At most one of the three cards is a discovery suggestion. Follow rechecks relationships; Not interested hides the account for 90 days. See the implementation and cost report.

After bun run check, run bun run check:discovery for local Workers persistence, budget, and browser checks (requires Playwright Chromium). These use synthetic local accounts; bun scripts/discovery-sample.ts <did> separately samples public follows and writes its results only to ignored data/.

OAuth scopes #

noti requests granular atproto OAuth scopes. Except for the optional discovery-follow permission, the scopes below are required for a session: hasRequiredScopes gates session validity, so a grant issued before a scope was added is treated as stale and the next request sends that account through /oauth/login?reason=scopes. That is how a new capability reaches existing users — one sign-in, with account links, features and history all keyed by DID and therefore untouched.

The background does not stop while they get around to it. The cron sweep gates on SYNC_OAUTH_SCOPES — the reads it actually performs — not on the full list, so an account whose grant predates a new capability keeps syncing notifications, snapshots and webhook delivery, and simply lacks the newest actions until its owner signs in. Anything the sweep touches that is not a sync scope has to degrade on its own; listUserLists returns [] for exactly this reason.

  • atproto
  • repo:app.bsky.graph.block
  • repo:app.bsky.graph.follow — optional for existing sessions; reconnect to enable Follow on discovery suggestions. Its absence does not stop notification syncing.
  • repo:app.bsky.graph.list
  • repo:app.bsky.graph.listitem
  • rpc: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.notification.getUnreadCount?aud=*
  • rpc:app.bsky.notification.getPreferences?aud=*
  • rpc:app.bsky.notification.putPreferencesV2?aud=*
  • rpc:app.bsky.graph.getMutes?aud=*
  • rpc:app.bsky.graph.getBlocks?aud=*
  • rpc:app.bsky.graph.getLists?aud=*
  • rpc:app.bsky.graph.getList?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.actor.getPreferences?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          # builds the client and extension bundles, typechecks both
bun test
bun run lint
bun run check:dry-run

deploy #

Before deploying:

  • set ANTHROPIC_API_KEY as a Cloudflare secret
  • create the noti-recommendations Cloudflare Queue
  • apply D1 migrations to noti
  • add allowed user DIDs to invite_allowlist

Every push to main runs .tangled/workflows/: ci.yml checks, deploy.yml deploys with the CLOUDFLARE_API_TOKEN spindle secret (an account-scoped Workers/D1/Queues token; see the secrets store for its rotation answer). To deploy by hand:

bun run deploy

Current deployment: https://noti.waow.tech

evals #

Two kinds of test, and the distinction matters.

bun test is regression testing: pure functions, render output, and the pipeline with the model injected. It is free, deterministic, and runs on every change. It catches plumbing — a dropped field, a bad URL, a stale snapshot shape — which is most of what actually breaks.

bun run eval:propose is an eval: it calls the real model, because the product is "you ask in words and get the right action" and no injected response can test that. Cases live in scripts/propose-eval.ts against synthetic accounts in test/fixtures/account.ts — invented handles, committed, unlike test/snapshots/, which holds real inboxes and stays gitignored.

Determinism comes from the assertions, not the model: Sonnet 5 rejects non-default sampling parameters, so temperature cannot be pinned. Cases assert invariants that hold across samplings — which method was called, which actor was named, which kind came back — never expected text. A borderline case is sampled N times against a threshold; a case that cannot clear its threshold is a finding about the prompt, not a flake.

bun run eval:propose                 # 7 cases × 3 samples, ~$0.10, ~90s
bun run eval:propose create-list     # one case
bun run eval:propose --samples 5     # more samples on a borderline case

Exits non-zero on failure, so it can gate a deploy. bun run eval (the evidence layer, live network, no model) and bun run rec:baseline (recommendation cost/quality on real snapshots) are the other two harnesses.