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
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 - Browser extension in
extension/, built byscripts/build-extension.tsintoextension/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
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.
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.
atprotorepo:app.bsky.graph.blockrepo: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.listrepo:app.bsky.graph.listitemrpc: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_KEYas a Cloudflare secret - create the
noti-recommendationsCloudflare 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.