notification manager for bsky
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

bsky intermittently down, use blacksky appview

Add ATPROTO_APPVIEW_PROXY env var to route app.bsky.* calls through an
alternate appview via AT Protocol service proxying. Login still goes to
the user's PDS. Set to did:web:api.blacksky.community#bsky_appview to
use Blacksky's independent appview when bsky.social's is flaky.

Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>

+65 -38
+7 -1
.env.example
··· 2 2 ATPROTO_PASSWORD=your-app-password 3 3 ATPROTO_PDS=https://bsky.social 4 4 ANTHROPIC_API_KEY=your-anthropic-api-key 5 - ANTHROPIC_MODEL=claude-sonnet-4-5 5 + ANTHROPIC_MODEL=claude-sonnet-4-6 6 6 SQLITE_PATH=data/noti-ts.db 7 7 PORT=8000 8 + 9 + # optional: route app.bsky.* reads through an alternate appview when bsky.social is flaky 10 + # ATPROTO_APPVIEW_PROXY=did:web:api.blacksky.community#bsky_appview 11 + 12 + # optional: set to 1 for verbose recommendation/proposal debug logs 13 + # NOTI_DEBUG=1
+17 -19
README.md
··· 1 1 # noti 2 2 3 - `noti` is a Bluesky notification manager. It looks at the live inbox, suggests a few actions to reduce noise, and lets the user ask for a specific action in plain language. 3 + `noti` is a Bluesky notification manager: it looks at the live inbox, suggests a few actions to reduce noise, and lets the user ask for a specific action in plain language (ask noti) 4 4 5 - `noti` currently offers a small set of notification-management mutations against the live Bluesky API: 5 + `noti` currently offers a small set of notification-management actions against the live Bluesky API: 6 6 7 7 - mute or unmute an account 8 8 - turn post notifications on or off for a specific account 9 9 - turn reply notifications on or off for a specific account 10 10 - mark notifications seen 11 11 12 - The main design choice is code mode. The server gives the model live state plus a constrained SDK surface, the model writes the mutation code, the user can review it, and the server re-reads Bluesky state afterward before claiming success. 13 12 14 - When a direct request names someone outside the current inbox context, `noti` first tries to resolve that actor host-side. If the name is too ambiguous, it asks for a handle instead of guessing. 13 + to take configuration actions, noti employs a [code-mode](https://blog.cloudflare.com/code-mode/)-inspired approach. the server gives the model live state plus a constrained SDK surface, the model writes the mutation code, the user can review it, the code runs in a sandbox with allow-listed SDK methods injected, and the server re-reads appview or PDS state afterward before claiming success. 15 14 16 - ## running it 17 15 18 - ```bash 19 - cp .env.example .env 20 - just dev 21 - ``` 16 + ## running it 22 17 23 - Set these env vars in `.env`: 18 + set these env vars in `.env`: 24 19 25 20 - `ATPROTO_HANDLE` 26 21 - `ATPROTO_PASSWORD` 27 22 - `ANTHROPIC_API_KEY` 28 23 29 - `ATPROTO_PDS` is optional and defaults to `https://bsky.social`. 24 + if self-hosting your own PDS, set `ATPROTO_PDS` to your PDS URL. 30 25 31 - The app serves on `http://127.0.0.1:8000`. 26 + if bsky.social's appview is flaky, set `ATPROTO_APPVIEW_PROXY=did:web:api.blacksky.community#bsky_appview` to route `app.bsky.*` reads through Blacksky's appview 32 27 33 - Other useful commands: 28 + ```bash 29 + cp .env.example .env 30 + just dev 31 + ``` 34 32 35 - - `just start` 36 - - `just check` 37 - - `just lint` 33 + the app serves on `http://127.0.0.1:8000`. 38 34 39 35 ## why this scope 40 36 41 - The assignment is about helping a user manage notifications, not about rebuilding the full Bluesky client. I aimed for one thing done well: a reviewable action queue that can actually change live notification state and report the result honestly. 37 + the goal is to help a user manage notifications: a reviewable action queue that can change and verify live notification configuration state. 42 38 43 - That led to two constraints: 39 + this led to two constraints: 44 40 45 41 - keep the mutation surface small enough to verify cleanly 46 - - keep the UI centered on concrete actions instead of feed narration 42 + - keep the UI centered on concrete actions instead of summarization or narration 47 43 48 44 ## next 49 45 50 46 - expand from per-actor activity subscriptions into the broader global notification-preferences surface 51 47 - keep widening the code-mode SDK surface only when each added lever has a clear verification story 52 48 - keep tightening the UI around clarity, hierarchy, and reversible controls 49 + - add daemon ingest process to keep windowed actor occurrences fresh regardless of noti requests 50 + - make this a multi-tenant app with OAuth
+7
src/bluesky.ts
··· 53 53 identifier: requireEnv('ATPROTO_HANDLE'), 54 54 password: requireEnv('ATPROTO_PASSWORD'), 55 55 }) 56 + // Optional fallback: if bsky.social's appview is flaky, route app.bsky.* calls 57 + // to an alternate appview (e.g. Blacksky). Login still goes to the user's PDS. 58 + // Example: ATPROTO_APPVIEW_PROXY=did:web:api.blacksky.community#bsky_appview 59 + const proxy = process.env.ATPROTO_APPVIEW_PROXY 60 + if (proxy && proxy.includes('#')) { 61 + agent.configureProxy(proxy as `did:${string}:${string}#${string}`) 62 + } 56 63 return agent 57 64 } 58 65
+8 -3
src/recommend-prompt.ts
··· 30 30 - if an actor has subscriptionPosts or subscriptionReplies on, turn those off first 31 31 - turning off replies for subscriptions that are too chatty 32 32 - turning off posts and replies entirely for high-volume or low-value subscriptions 33 - - only mute if subscriptions are already off and the actor is still generating noise 33 + 34 + Muting is appropriate when subscriptions are already off but the actor is still actively interacting with the user. Use the \`reasons\` histogram to decide: 35 + - reasons like \`reply\`, \`mention\`, \`like\`, \`quote\`, \`repost\`, \`follow\` mean the actor is interacting with the user — these are ongoing, and muting would stop future volume 36 + - reasons like \`subscribed-post\` or \`subscribed-reply\` come from an activity subscription — if that subscription is now off, those unreads are historical and muting would not reduce future volume 37 + 38 + In user-facing text, describe the actor's behavior concretely (e.g. "they've been replying and liking your posts" or "they keep mentioning you"). Do not use internal vocabulary like "direct activity", "reasons histogram", or method names. 34 39 35 40 Do not recommend: 36 - - subscription changes for an actor whose subscriptions are already off — unless they are still generating notifications through replies to your posts or mentions 37 - - actions that would not reduce future notification volume — unread notifications may be historical artifacts from before a subscription was changed 41 + - subscription changes for an actor whose subscriptions are already off 42 + - muting an actor whose unreads are entirely \`subscribed-post\` / \`subscribed-reply\` reasons with subscriptions already off — those are historical 38 43 - if there is nothing useful to change, return an empty recommendations array 39 44 ` 40 45
+18 -10
src/recommend.ts
··· 20 20 21 21 const anthropic = new Anthropic({apiKey: process.env.ANTHROPIC_API_KEY}) 22 22 const model = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6' 23 + const debug = process.env.NOTI_DEBUG === '1' 24 + const dlog = (...args: unknown[]) => { if (debug) console.info(...args) } 23 25 24 26 type RecommendationToolResponse = { 25 27 recommendations?: Array<{ ··· 63 65 if (!block || block.type !== 'tool_use') { 64 66 throw new Error(`missing tool response for ${tool.name}`) 65 67 } 66 - console.info(`[toolResponse] ${tool.name} stop=${res.stop_reason} usage=${JSON.stringify(res.usage)}`) 68 + dlog(`[toolResponse] ${tool.name} stop=${res.stop_reason} usage=${JSON.stringify(res.usage)}`) 67 69 return block.input as TResponse 68 70 } 69 71 ··· 172 174 unread: NormalizedNotification[], 173 175 targets: ManagementTarget[], 174 176 ) { 175 - if (unread.length === 0 || targets.length === 0) return [] 176 - // Don't send targets with subscriptions already off to the queue — 177 - // their unreads are likely historical and mislead the model into recommending mutes. 178 - const actionableTargets = targets.filter(t => 179 - t.subscriptionPosts || t.subscriptionReplies || !t.currentUnreadCount, 180 - ) 181 - if (actionableTargets.length === 0) return [] 177 + dlog('[recommendations] start', {unread: unread.length, targets: targets.length}) 178 + if (unread.length === 0 || targets.length === 0) { 179 + dlog('[recommendations] skip: no unread or no targets') 180 + return [] 181 + } 182 + dlog('[recommendations] raw targets (first 10):') 183 + for (const t of targets.slice(0, 10)) { 184 + dlog(` - ${t.actorName} (@${t.actorHandle}) unread=${t.currentUnreadCount} subPosts=${t.subscriptionPosts} subReplies=${t.subscriptionReplies} muted=${t.muted}`) 185 + } 182 186 const notificationsByUri = new Map(unread.map(row => [row.uri, row])) 183 187 const targetsByDid = targetByDid(targets) 184 188 const payload = JSON.stringify({ 185 189 unreadCount: unread.length, 186 - targets: targetPayload(actionableTargets, 8), 190 + targets: targetPayload(targets, 8), 187 191 }) 188 192 const result = await toolResponse<RecommendationToolResponse>( 189 193 payload, ··· 191 195 recommendationToolChoice, 192 196 SYSTEM_QUEUE_SUGGESTIONS, 193 197 ) 198 + dlog('[recommendations] model returned:', Array.isArray(result.recommendations) ? `${result.recommendations.length} items` : typeof result.recommendations) 194 199 if (!Array.isArray(result.recommendations)) { 195 200 console.warn('[recommendations] model returned non-array:', result) 196 201 return [] ··· 279 284 examples, 280 285 } 281 286 } 282 - if (description && why && code && (sourceUris.length > 0 || requestedActorDids.length > 0)) { 287 + // Global actions (updateSeen) have no actor/source context by design. 288 + // Only require actorDids/sourceUris for per-actor actions. 289 + const isGlobalAction = code.includes('agent.app.bsky.notification.updateSeen') 290 + if (description && why && code && (isGlobalAction || sourceUris.length > 0 || requestedActorDids.length > 0)) { 283 291 try { 284 292 validateCode(code) 285 293 const actorDids = requestedActorDids.length
+8 -5
src/server.ts
··· 38 38 39 39 function placeholderFromTargets(targets: ManagementTarget[]) { 40 40 const top = targets[0] 41 - if (!top) return 'e.g. mute whoever is flooding my inbox' 41 + if (!top) return 'e.g. type @ to pick someone, then ask for a change' 42 + const handle = `@${top.actorHandle}` 42 43 if (top.subscriptionPosts && top.subscriptionReplies) { 43 - return `e.g. turn off replies from ${top.actorName}, or mute ${top.actorName}` 44 + return `e.g. turn off replies from ${handle}, or mute ${handle}` 44 45 } 45 46 if (top.subscriptionPosts || top.subscriptionReplies) { 46 - return `e.g. stop notifications from ${top.actorName}` 47 + return `e.g. stop notifications from ${handle}` 47 48 } 48 - return `e.g. mute ${top.actorName}` 49 + return `e.g. mute ${handle}` 49 50 } 50 51 51 52 type BaseState = { ··· 373 374 continue 374 375 } 375 376 376 - targets.push({ 377 + // Put selected actors at the FRONT so they always make it into the 378 + // targetPayload slice, even when the unread inbox has many actors. 379 + targets.unshift({ 377 380 actorDid: actor.did, 378 381 actorHandle: actor.handle, 379 382 actorName: actor.name,