···11+# design
22+33+## thesis
44+55+`noti` is an agentic inbox for Bluesky.
66+77+The core move is simple: do not make the user mentally group a chronological notification pile every time they look at it. Poll the unread batch, compress it into a few higher-level feed items, and let the user jump back to Bluesky when they want the original thread or profile.
88+99+## architecture
1010+1111+```mermaid
1212+flowchart LR
1313+ subgraph Bluesky
1414+ API["notification + record APIs"]
1515+ end
1616+1717+ subgraph noti
1818+ POLL["Poller + normalizer"]
1919+ PREFS["Preferences store"]
2020+ GEN["Feed generator"]
2121+ RENDER["Server-rendered UI"]
2222+ end
2323+2424+ USER["Browser"]
2525+ LLM["Claude"]
2626+2727+ API --> POLL
2828+ PREFS --> POLL
2929+ POLL -->|"unread notifications"| GEN
3030+ PREFS --> GEN
3131+ GEN -->|"0-4 feed items + briefing"| RENDER
3232+ USER -->|"view + edit preferences"| RENDER
3333+ RENDER --> USER
3434+ GEN --> LLM
3535+ LLM --> GEN
3636+ USER -->|"open in Bluesky"| API
3737+```
3838+3939+## runtime flow
4040+4141+1. `poller.py` logs into Bluesky and polls notifications on an interval.
4242+2. Notifications are normalized into plain dicts with actor info, text, URLs, thread keys, and resolved subject context for likes/reposts.
4343+3. Preferences are loaded from disk and included in the regeneration key, so preference changes force a new feed.
4444+4. `agent.py` sends the unread batch, preferences, and active-user context to Claude.
4545+5. Claude returns a briefing and a small set of feed items.
4646+6. The server validates the result, constrains links to real source URLs, derives avatar stacks, and renders the cards.
4747+4848+## what the model sees
4949+5050+Each unread notification includes:
5151+5252+- reason metadata like `reply`, `mention`, `like`, or `follow`
5353+- actor identity and profile URL
5454+- post text when present
5555+- subject text and subject URL for resolved likes/reposts
5656+- thread structure via `reply_root_uri`, `reply_parent_uri`, and `thread_key`
5757+- candidate URLs that are safe to link back to
5858+5959+That gives the model enough structure to do more than just collapse identical events. It can turn a burst of related activity into one situation, or decide that one prolific poster should become a single “posting streak” item.
6060+6161+## responsibilities
6262+6363+The model is responsible for:
6464+6565+- grouping related notifications into a few meaningful situations
6666+- omitting low-value or explicitly unwanted activity
6767+- writing the top briefing
6868+- choosing internal priority for ordering
6969+- scaling the abstraction level to the size of the unread batch
7070+7171+The server is responsible for:
7272+7373+- polling and caching live Bluesky data
7474+- subject resolution and thread metadata
7575+- durable preferences
7676+- constraining links to real candidate URLs from the source notifications
7777+- limiting the UI to a small number of items
7878+- rendering the final feed
7979+- advancing the seen cursor when the user chooses `mark all read`
8080+- gating executable actions against Bluesky state before they are rendered
8181+8282+## current product boundaries
8383+8484+- single-user: the deployed app is still backed by one account
8585+- generated inbox, not full client: history and detailed browsing stay in Bluesky
8686+- mostly read-oriented: `mark all read` advances the seen cursor
8787+- limited executable actions: account mute and activity unsubscribe are live because
8888+ Bluesky exposes readable state for them; thread mute is intentionally not offered
8989+ because there is no corresponding list/get endpoint for muted threads
9090+- no long-term semantic memory: each generation mostly reasons from the current unread batch
9191+9292+## why this shape
9393+9494+Bluesky’s notification UI is chronological. That works at low volume, but once related activity piles up, the user has to do the grouping and prioritization mentally every time.
9595+9696+`noti` tries to solve exactly one problem: turn the unread pile into something interpretable at a glance.
9797+9898+That is also why the app does not try to recreate the whole Bluesky client. The system is better when it stays narrow: ingest, compress, hand off, and offer one clear inbox-clearing action.
9999+100100+## known weak spots
101101+102102+- latency is still visible on cold generations and preference-triggered refreshes
103103+- canonical link selection is imperfect when one item spans several distinct posts
104104+- the current model can still be too literal under very noisy conditions
105105+- grouped items that genuinely span many destinations still do not have a great affordance
106106+107107+## obvious extensions
108108+109109+1. execute more suggested actions with explicit confirmation
110110+2. improve grouping and link selection for very high-volume batches
111111+3. add a lightweight sense of continuity between refreshes
···11+# [noti.fly.dev](https://noti.fly.dev)
22+33+`noti` is an agentic Bluesky inbox. Instead of rendering raw notification rows, it compresses the unread pile into `0..4` higher-level feed items.
44+55+## what i built
66+77+- a live Bluesky notification app that polls unread notifications
88+- a feed generator that turns them into a few situation-level cards
99+- freeform preferences that steer what gets surfaced
1010+- one real inbox action: `mark all read`
1111+1212+## how to run it
1313+1414+install `uv`:
1515+1616+```bash
1717+curl -LsSf https://astral.sh/uv/install.sh | sh
1818+```
1919+2020+then:
2121+2222+```bash
2323+uv sync
2424+cp .env.example .env
2525+just dev
2626+```
2727+2828+set these env vars in `.env`:
2929+- `ATPROTO_HANDLE`
3030+- `ATPROTO_PASSWORD`
3131+- `ANTHROPIC_API_KEY`
3232+3333+`ATPROTO_PDS` is optional and defaults to `https://bsky.social`.
3434+3535+## deploy on fly
3636+3737+create the app and persistent volume:
3838+3939+```bash
4040+fly apps create noti
4141+fly volumes create data --region ord --size 1 -a noti
4242+```
4343+4444+set the required secrets:
4545+4646+```bash
4747+fly secrets set \
4848+ ATPROTO_HANDLE=... \
4949+ ATPROTO_PASSWORD=... \
5050+ ANTHROPIC_API_KEY=... \
5151+ -a noti
5252+```
5353+5454+then deploy:
5555+5656+```bash
5757+fly deploy
5858+```
5959+6060+the volume is required because preferences are persisted at `/data/preferences.json`.
6161+6262+## why i scoped it this way
6363+6464+i focused on one problem: making a noisy Bluesky inbox interpretable at a glance. the app stays narrow on purpose: poll, compress, suggest a next step, and hand the user back to Bluesky when they want the original thread or profile.
6565+6666+## what i would do next
6767+6868+- execute more suggested actions with explicit confirmation
6969+- improve grouping and link selection for very high-volume bursts
7070+- add a lightweight sense of continuity between refreshes
···11+#!/usr/bin/env python
22+"""Manage Bluesky activity-subscription fixtures for demo prep.
33+44+Examples:
55+ uv run scripts/activity_fixtures.py status --file scripts/demo-accounts.txt
66+ uv run scripts/activity_fixtures.py status --all
77+ uv run scripts/activity_fixtures.py subscribe --file scripts/demo-accounts.txt --mode both
88+ uv run scripts/activity_fixtures.py subscribe --handles "alice.example,bob.example" --mode replies
99+ uv run scripts/activity_fixtures.py unsubscribe --file scripts/demo-accounts.txt
1010+"""
1111+1212+from __future__ import annotations
1313+1414+import argparse
1515+from dataclasses import dataclass
1616+from pathlib import Path
1717+1818+from atproto import Client
1919+2020+from noti.config import settings
2121+2222+2323+DEFAULT_FIXTURE_FILE = Path("scripts/demo-accounts.txt")
2424+2525+2626+@dataclass
2727+class ActivitySubscription:
2828+ did: str
2929+ handle: str
3030+ display_name: str | None
3131+ post: bool | None
3232+ reply: bool | None
3333+3434+3535+def _normalize_handle(value: str) -> str:
3636+ return value.strip().lstrip("@")
3737+3838+3939+def _load_handles(args: argparse.Namespace) -> list[str]:
4040+ handles: list[str] = []
4141+ if args.handles:
4242+ handles.extend(_normalize_handle(part) for part in args.handles.split(","))
4343+ if args.file:
4444+ path = Path(args.file)
4545+ if not path.exists():
4646+ raise SystemExit(f"fixture file not found: {path}")
4747+ for line in path.read_text().splitlines():
4848+ stripped = line.strip()
4949+ if not stripped or stripped.startswith("#"):
5050+ continue
5151+ handles.append(_normalize_handle(stripped))
5252+ unique: list[str] = []
5353+ seen: set[str] = set()
5454+ for handle in handles:
5555+ if handle and handle not in seen:
5656+ seen.add(handle)
5757+ unique.append(handle)
5858+ return unique
5959+6060+6161+def _build_client() -> Client:
6262+ client = Client(base_url=settings.atproto_pds)
6363+ client.login(settings.atproto_handle, settings.atproto_password)
6464+ return client
6565+6666+6767+def _resolve_did(client: Client, handle_or_did: str) -> str:
6868+ if handle_or_did.startswith("did:"):
6969+ return handle_or_did
7070+ resolved = client.resolve_handle(handle_or_did)
7171+ return resolved.did
7272+7373+7474+def _parse_subscription(profile: object) -> ActivitySubscription:
7575+ subscription = (
7676+ getattr(profile, "activity_subscription", None)
7777+ or getattr(profile, "activitySubscription", None)
7878+ or {}
7979+ )
8080+ post = getattr(subscription, "post", None)
8181+ reply = getattr(subscription, "reply", None)
8282+ if isinstance(subscription, dict):
8383+ post = subscription.get("post")
8484+ reply = subscription.get("reply")
8585+ return ActivitySubscription(
8686+ did=getattr(profile, "did", ""),
8787+ handle=getattr(profile, "handle", ""),
8888+ display_name=getattr(profile, "display_name", None)
8989+ or getattr(profile, "displayName", None),
9090+ post=post,
9191+ reply=reply,
9292+ )
9393+9494+9595+def _list_subscriptions(client: Client, *, limit: int | None = None) -> list[ActivitySubscription]:
9696+ subscriptions: list[ActivitySubscription] = []
9797+ cursor: str | None = None
9898+ while True:
9999+ params: dict[str, object] = {"limit": min(limit or 100, 100)}
100100+ if cursor:
101101+ params["cursor"] = cursor
102102+ response = client.app.bsky.notification.list_activity_subscriptions(params=params)
103103+ subscriptions.extend(_parse_subscription(profile) for profile in response.subscriptions)
104104+ if limit is not None and len(subscriptions) >= limit:
105105+ return subscriptions[:limit]
106106+ cursor = getattr(response, "cursor", None)
107107+ if not cursor:
108108+ return subscriptions
109109+110110+111111+def _mode_flags(mode: str) -> tuple[bool, bool]:
112112+ if mode == "both":
113113+ return True, True
114114+ if mode == "posts":
115115+ return True, False
116116+ if mode == "replies":
117117+ return False, True
118118+ raise SystemExit(f"unsupported mode: {mode}")
119119+120120+121121+def _print_rows(rows: list[ActivitySubscription], *, selected_dids: set[str] | None = None) -> None:
122122+ if not rows:
123123+ print("no matching activity subscriptions")
124124+ return
125125+ for row in rows:
126126+ marker = ""
127127+ if selected_dids is not None and row.did in selected_dids:
128128+ marker = "* "
129129+ mode = (
130130+ "posts+replies"
131131+ if row.post and row.reply
132132+ else "posts"
133133+ if row.post
134134+ else "replies"
135135+ if row.reply
136136+ else "off"
137137+ )
138138+ label = row.display_name or row.handle or row.did
139139+ suffix = f" (@{row.handle})" if row.display_name and row.handle else ""
140140+ print(f"{marker}{label}{suffix} [{row.did}] -> {mode}")
141141+142142+143143+def _is_active(row: ActivitySubscription) -> bool:
144144+ return bool(row.post or row.reply)
145145+146146+147147+def cmd_status(args: argparse.Namespace) -> int:
148148+ client = _build_client()
149149+ rows = _list_subscriptions(client, limit=args.limit if args.all else None)
150150+ if not args.include_off:
151151+ rows = [row for row in rows if _is_active(row)]
152152+ if args.all:
153153+ _print_rows(rows)
154154+ return 0
155155+156156+ handles = _load_handles(args)
157157+ if not handles:
158158+ raise SystemExit("no handles selected; pass --handles, --file, or use --all")
159159+ selected_dids = {_resolve_did(client, handle) for handle in handles}
160160+ filtered = [row for row in rows if row.did in selected_dids]
161161+ _print_rows(filtered, selected_dids=selected_dids)
162162+ missing = selected_dids - {row.did for row in filtered}
163163+ if missing:
164164+ print(f"\n{len(missing)} selected account(s) have no active activity subscription")
165165+ return 0
166166+167167+168168+def cmd_subscribe(args: argparse.Namespace) -> int:
169169+ client = _build_client()
170170+ handles = _load_handles(args)
171171+ if not handles:
172172+ raise SystemExit("no handles selected; pass --handles or --file")
173173+ post, reply = _mode_flags(args.mode)
174174+ for handle in handles:
175175+ did = _resolve_did(client, handle)
176176+ client.app.bsky.notification.put_activity_subscription(
177177+ {
178178+ "subject": did,
179179+ "activitySubscription": {"post": post, "reply": reply},
180180+ }
181181+ )
182182+ mode = "posts+replies" if post and reply else "posts" if post else "replies"
183183+ print(f"subscribed @{handle} [{did}] -> {mode}")
184184+ print("\ncurrent status:")
185185+ return cmd_status(
186186+ argparse.Namespace(
187187+ handles=",".join(handles),
188188+ file=None,
189189+ all=False,
190190+ limit=None,
191191+ )
192192+ )
193193+194194+195195+def cmd_unsubscribe(args: argparse.Namespace) -> int:
196196+ client = _build_client()
197197+ handles = _load_handles(args)
198198+ if not handles:
199199+ raise SystemExit("no handles selected; pass --handles or --file")
200200+ for handle in handles:
201201+ did = _resolve_did(client, handle)
202202+ client.app.bsky.notification.put_activity_subscription(
203203+ {
204204+ "subject": did,
205205+ "activitySubscription": {"post": False, "reply": False},
206206+ }
207207+ )
208208+ print(f"unsubscribed @{handle} [{did}]")
209209+ print("\ncurrent status:")
210210+ return cmd_status(
211211+ argparse.Namespace(
212212+ handles=",".join(handles),
213213+ file=None,
214214+ all=False,
215215+ limit=None,
216216+ )
217217+ )
218218+219219+220220+def build_parser() -> argparse.ArgumentParser:
221221+ parser = argparse.ArgumentParser(description=__doc__)
222222+ subparsers = parser.add_subparsers(dest="command", required=True)
223223+224224+ def add_handle_args(subparser: argparse.ArgumentParser) -> None:
225225+ subparser.add_argument(
226226+ "--handles",
227227+ help="comma-separated handles or DIDs",
228228+ )
229229+ subparser.add_argument(
230230+ "--file",
231231+ default=str(DEFAULT_FIXTURE_FILE),
232232+ help=f"fixture file with one handle per line (default: {DEFAULT_FIXTURE_FILE})",
233233+ )
234234+235235+ status = subparsers.add_parser("status", help="show current activity-subscription state")
236236+ add_handle_args(status)
237237+ status.add_argument("--all", action="store_true", help="show all current subscriptions")
238238+ status.add_argument(
239239+ "--include-off",
240240+ action="store_true",
241241+ help="include disabled/off subscription records in the output",
242242+ )
243243+ status.add_argument(
244244+ "--limit",
245245+ type=int,
246246+ default=50,
247247+ help="max rows when using --all (default: 50)",
248248+ )
249249+ status.set_defaults(func=cmd_status)
250250+251251+ subscribe = subparsers.add_parser("subscribe", help="enable subscriptions for a fixture set")
252252+ add_handle_args(subscribe)
253253+ subscribe.add_argument(
254254+ "--mode",
255255+ choices=("both", "posts", "replies"),
256256+ default="both",
257257+ help="subscription mode to apply (default: both)",
258258+ )
259259+ subscribe.set_defaults(func=cmd_subscribe)
260260+261261+ unsubscribe = subparsers.add_parser("unsubscribe", help="disable subscriptions for a fixture set")
262262+ add_handle_args(unsubscribe)
263263+ unsubscribe.set_defaults(func=cmd_unsubscribe)
264264+265265+ return parser
266266+267267+268268+def main() -> int:
269269+ parser = build_parser()
270270+ args = parser.parse_args()
271271+ return int(args.func(args))
272272+273273+274274+if __name__ == "__main__":
275275+ raise SystemExit(main())
+6
scripts/demo-accounts.txt
···11+# one handle per line, no leading @ required.
22+# keep this list to prolific or reliably active accounts you want for demo prep.
33+#
44+# examples:
55+# jcsalterego.bsky.social
66+# segyges.bsky.social
+86
scripts/replay.py
···11+#!/usr/bin/env python
22+"""replay feed generation against a saved fixture.
33+44+usage:
55+ uv run scripts/replay.py # run all fixtures
66+ uv run scripts/replay.py fixtures/unread-001.json # run one fixture
77+"""
88+99+import asyncio
1010+import json
1111+import sys
1212+from pathlib import Path
1313+1414+import os
1515+1616+from pydantic_settings import BaseSettings, SettingsConfigDict
1717+1818+1919+class ReplaySettings(BaseSettings):
2020+ model_config = SettingsConfigDict(
2121+ env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore"
2222+ )
2323+ anthropic_api_key: str
2424+2525+2626+_settings = ReplaySettings() # type: ignore
2727+os.environ["ANTHROPIC_API_KEY"] = _settings.anthropic_api_key
2828+2929+from noti.agent import ActiveUser, generate_feed # noqa: E402
3030+from noti.preferences import load_preferences # noqa: E402
3131+3232+3333+async def replay(fixture_path: Path):
3434+ notifications = []
3535+ for raw in json.loads(fixture_path.read_text()):
3636+ sanitized = dict(raw)
3737+ sanitized.pop("priority", None)
3838+ sanitized.pop("summary", None)
3939+ sanitized.pop("candidate_urls", None)
4040+ notifications.append(sanitized)
4141+ preferences = load_preferences()
4242+ print(f"\n{'=' * 60}")
4343+ print(f"fixture: {fixture_path.name} ({len(notifications)} notifications)")
4444+ print(f"{'=' * 60}")
4545+ print(f"want to see: {preferences.want_to_see or '(none)'}")
4646+ print(f"dont want to see: {preferences.dont_want_to_see or '(none)'}")
4747+4848+ result = await generate_feed(
4949+ notifications,
5050+ preferences,
5151+ ActiveUser(handle="zzstoatzz.io", display_name="nate"),
5252+ )
5353+ if not result:
5454+ print("feed generation failed!")
5555+ return
5656+5757+ print(f"\nbriefing: {result.briefing}\n")
5858+ for item in result.items:
5959+ print(f" [{item.priority:6}] {item.title} :: {item.count_label}")
6060+ print(f" {item.summary}")
6161+ if item.target_url:
6262+ print(f" -> {item.target_label}: {item.target_url}")
6363+ if item.suggested_actions:
6464+ print(f" actions: {', '.join(item.suggested_actions)}")
6565+ print()
6666+6767+6868+async def main():
6969+ fixtures_dir = Path("fixtures")
7070+7171+ if len(sys.argv) > 1:
7272+ paths = [Path(p) for p in sys.argv[1:]]
7373+ else:
7474+ paths = sorted(fixtures_dir.glob("*.json"))
7575+7676+ if not paths:
7777+ print("no fixtures found. save one with:")
7878+ print(" curl -sf https://noti.fly.dev/api/notifications > fixtures/unread-001.json")
7979+ return
8080+8181+ for path in paths:
8282+ await replay(path)
8383+8484+8585+if __name__ == "__main__":
8686+ asyncio.run(main())
src/noti/__init__.py
This is a binary file and will not be displayed.
+66
src/noti/actions.py
···11+"""Finite feed action registry backed by real Bluesky mutations."""
22+33+from typing import Literal
44+55+ActionId = Literal["mute_account", "unsubscribe_activity"]
66+77+ACTION_LABELS: dict[ActionId, str] = {
88+ "mute_account": "mute account",
99+ "unsubscribe_activity": "unsubscribe",
1010+}
1111+1212+ACTION_CONFIRMATIONS: dict[ActionId, str] = {
1313+ "mute_account": "Mute this account in Bluesky?",
1414+ "unsubscribe_activity": "Unsubscribe from this account's posts and replies?",
1515+}
1616+1717+ACTION_SUCCESS_MESSAGES: dict[ActionId, str] = {
1818+ "mute_account": "account muted.",
1919+ "unsubscribe_activity": "unsubscribed from activity.",
2020+}
2121+2222+2323+def _display_actor(notifications: list[dict]) -> str | None:
2424+ names = {
2525+ n.get("author_name") or n.get("author_handle")
2626+ for n in notifications
2727+ if n.get("author_name") or n.get("author_handle")
2828+ }
2929+ if len(names) == 1:
3030+ return next(iter(names))
3131+ return None
3232+3333+3434+def action_label(action_id: ActionId, notifications: list[dict]) -> str:
3535+ if action_id == "unsubscribe_activity":
3636+ return "unsubscribe posts & replies"
3737+ if action_id == "mute_account":
3838+ actor = _display_actor(notifications)
3939+ return f"mute {actor}" if actor else ACTION_LABELS[action_id]
4040+ return ACTION_LABELS[action_id]
4141+4242+4343+def action_confirmation(action_id: ActionId, notifications: list[dict]) -> str:
4444+ actor = _display_actor(notifications)
4545+ if action_id == "unsubscribe_activity":
4646+ if actor:
4747+ return f"Stop notifications for {actor}'s posts and replies?"
4848+ return ACTION_CONFIRMATIONS[action_id]
4949+ if action_id == "mute_account":
5050+ if actor:
5151+ return f"Mute {actor} in Bluesky?"
5252+ return ACTION_CONFIRMATIONS[action_id]
5353+ return ACTION_CONFIRMATIONS[action_id]
5454+5555+5656+def candidate_action_ids(notifications: list[dict]) -> set[ActionId]:
5757+ candidate_ids: set[ActionId] = set()
5858+ author_dids = {n.get("author_did") for n in notifications if n.get("author_did")}
5959+ reasons = {n.get("reason") for n in notifications}
6060+6161+ if len(author_dids) == 1:
6262+ candidate_ids.add("mute_account")
6363+ if reasons == {"subscribed-post"} and len(author_dids) == 1:
6464+ candidate_ids.add("unsubscribe_activity")
6565+6666+ return candidate_ids
+531
src/noti/agent.py
···11+"""notification feed agent — synthesize unread notifications into feed items."""
22+33+from __future__ import annotations
44+55+import json
66+import logging
77+import re
88+from typing import Literal
99+1010+from pydantic import BaseModel, Field
1111+from pydantic_ai import Agent
1212+1313+from noti.actions import ActionId, candidate_action_ids
1414+from noti.config import settings
1515+from noti.preferences import UserPreferences
1616+1717+logger = logging.getLogger("noti")
1818+1919+2020+class FeedActor(BaseModel):
2121+ """A visible actor associated with a synthesized feed item."""
2222+2323+ name: str
2424+ profile_url: str | None = None
2525+ avatar_url: str | None = None
2626+2727+2828+class FeedItem(BaseModel):
2929+ """A synthesized unread feed item backed by one or more notifications."""
3030+3131+ source_uris: list[str] = Field(
3232+ description="notification AT-URIs included in this item, each used exactly once"
3333+ )
3434+ priority: Literal["high", "medium", "low"] = Field(
3535+ description="high, medium, or low. internal ordering only, never user-facing"
3636+ )
3737+ title: str = Field(description="short headline, under 8 words")
3838+ count_label: str = Field(
3939+ description="very short metadata, like '3 replies' or '8 likes, 2 replies'"
4040+ )
4141+ summary: str = Field(
4242+ description="1-2 concise sentences explaining what happened and why it matters"
4343+ )
4444+ target_url: str | None = Field(
4545+ default=None,
4646+ description="best URL to open for this item, chosen from the notification data",
4747+ )
4848+ target_label: str = Field(
4949+ default="open",
5050+ description="short button label like 'open thread' or 'view profile'",
5151+ )
5252+ suggested_actions: list[ActionId] = Field(
5353+ default_factory=list,
5454+ description="0-3 action ids chosen only from: mute_account, unsubscribe_activity",
5555+ )
5656+ actors: list[FeedActor] = Field(default_factory=list)
5757+ actor_count: int = Field(default=0)
5858+5959+6060+class FeedPlan(BaseModel):
6161+ """Structured unread feed."""
6262+6363+ briefing: str = Field(
6464+ description="1-2 sentences about the overall unread situation right now"
6565+ )
6666+ items: list[FeedItem]
6767+6868+6969+class FeedResult(BaseModel):
7070+ """Validated feed result used by the UI."""
7171+7272+ briefing: str
7373+ items: list[FeedItem]
7474+7575+7676+class ActiveUser(BaseModel):
7777+ """The logged-in Bluesky account this feed is being generated for."""
7878+7979+ handle: str
8080+ display_name: str | None = None
8181+8282+8383+FEED_PROMPT = """\
8484+you are generating an inbox feed for a bluesky user from their unread notifications.
8585+8686+your job is not to repeat the raw notification list. instead, synthesize a smaller set \
8787+of feed items representing the actual things happening.
8888+8989+rules:
9090+- group notifications when a human would consider them part of the same thing
9191+- the feed must contain between 0 and 4 items total, never more
9292+- if there are 5 or more unread notifications, strongly prefer 3 main items or fewer
9393+- at high volume, use broad situation reports like posting streaks, ongoing discussions,
9494+ bursts of engagement, or a catch-all bucket instead of itemizing individual records
9595+- if needed, the last item may be a catch-all like "more activity" or "assorted posting"
9696+- when there are only 1-4 unread notifications, stay concrete and close to the source material
9797+- at low volume, a card should be at least as informative as the native Bluesky notification
9898+- do not use vague phrases like "another discussion", "separate thread", "a thread", or
9999+ "a conversation" when there are only a few unread items and you have the actual text
100100+- common good collapses:
101101+ - many likes/reposts on the same post
102102+ - multiple replies/mentions around the same post or thread
103103+ - follow bursts or obvious spammy engagement clusters
104104+- if notifications share the same thread_key or reply_root_uri, prefer one discussion item unless
105105+ there is a strong reason to keep them separate
106106+- if a mention, reply, and subscribed-post activity are clearly part of the same thread, prefer one
107107+ discussion item with one best target_url
108108+- keep unrelated things separate
109109+- prefer fewer, more meaningful items over a long list of tiny ones
110110+- use the user's preferences to decide what deserves emphasis and what can be \
111111+ omitted entirely
112112+- it is valid to omit notifications that are low-value, repetitive, or explicitly unwanted
113113+- do not create "hidden", "suppressed", or "filtered" items
114114+- do not mention omitted notifications in the briefing or in visible item summaries
115115+- the briefing should summarize only the items you actually surfaced
116116+- do not mention the total unread count in the briefing
117117+- do not inventory the batch like a report; summarize the surfaced situations in natural language
118118+- if subject_text or subject metadata is available, use it
119119+- only include target_url when the item has one clear canonical destination
120120+- if a grouped item spans multiple distinct posts and there is no single canonical destination,
121121+ leave target_url empty instead of inventing a catch-all link
122122+- priority is only for internal ordering. it will not be shown to the user
123123+- suggested_actions must only use these exact action ids:
124124+ - mute_account
125125+ - unsubscribe_activity
126126+- never invent new actions, synonyms, or freeform labels
127127+- only suggest an action when the item clearly maps to a real Bluesky mutation
128128+- for subscribed-post activity from one account, prefer unsubscribe_activity
129129+- for one clear actor, mute_account is valid
130130+- the input includes active_user. when you need to refer to whose post, thread, or account is involved,
131131+ use the active user's real handle or display name from that object instead of generic placeholders
132132+- when an item is backed by a single reply or mention and notification text is available,
133133+ the title and summary must include the actual topic or wording of that post
134134+- avoid generic summaries like "left a comment on a thread", "replied in a thread",
135135+ "posted in a discussion", or "was active in a conversation"
136136+- for a single reply, prefer paraphrasing or briefly quoting the actual reply text
137137+- if an item is too small or too vague to be meaningfully better than the native notification,
138138+ keep it very close to the actual post rather than abstracting it
139139+140140+when referring to accounts, use the display name or full handle exactly as given.
141141+"""
142142+143143+_feed_agent: Agent | None = None
144144+145145+146146+def _priority_rank(priority: str | None) -> int:
147147+ return {"high": 0, "medium": 1, "low": 2}.get(priority or "", 3)
148148+149149+150150+def _normalize_text(value: str) -> str:
151151+ value = value.lower().replace("ϕ", "phi").replace("φ", "phi")
152152+ return re.sub(r"[^a-z0-9]+", " ", value).strip()
153153+154154+155155+def _preference_tokens(preferences: UserPreferences) -> set[str]:
156156+ text = _normalize_text(preferences.dont_want_to_see)
157157+ return {token for token in text.split() if len(token) >= 3}
158158+159159+160160+def _notification_actor_tokens(notification: dict) -> set[str]:
161161+ values = [
162162+ notification.get("author_name", ""),
163163+ notification.get("author_handle", ""),
164164+ notification.get("author", ""),
165165+ ]
166166+ tokens: set[str] = set()
167167+ for value in values:
168168+ normalized = _normalize_text(str(value))
169169+ if not normalized:
170170+ continue
171171+ tokens.update(token for token in normalized.split() if len(token) >= 3)
172172+ return tokens
173173+174174+175175+def _filter_notifications_for_preferences(
176176+ notifications: list[dict], preferences: UserPreferences
177177+) -> list[dict]:
178178+ muted_tokens = _preference_tokens(preferences)
179179+ if not muted_tokens:
180180+ return notifications
181181+182182+ filtered: list[dict] = []
183183+ for notification in notifications:
184184+ actor_tokens = _notification_actor_tokens(notification)
185185+ if actor_tokens and actor_tokens.intersection(muted_tokens):
186186+ continue
187187+ filtered.append(notification)
188188+ return filtered
189189+190190+191191+def _get_feed_agent() -> Agent:
192192+ global _feed_agent
193193+ if _feed_agent is None:
194194+ _feed_agent = Agent(
195195+ model=settings.feed_model,
196196+ system_prompt=FEED_PROMPT,
197197+ output_type=FeedPlan,
198198+ )
199199+ return _feed_agent
200200+201201+202202+def _resolve_target_label(target_url: str | None, notifications: list[dict]) -> str:
203203+ if not target_url:
204204+ return ""
205205+206206+ thread_urls = {n.get("reply_root_url") for n in notifications if n.get("reply_root_url")}
207207+ subject_urls = {n.get("subject_url") for n in notifications if n.get("subject_url")}
208208+ notification_urls = {
209209+ n.get("notification_url") for n in notifications if n.get("notification_url")
210210+ }
211211+ profile_urls = {n.get("profile_url") for n in notifications if n.get("profile_url")}
212212+213213+ if target_url in thread_urls:
214214+ return "open thread"
215215+ if target_url in subject_urls or target_url in notification_urls:
216216+ return "open post"
217217+ if target_url in profile_urls:
218218+ return "view profile"
219219+ return "open"
220220+221221+222222+def _resolve_group_target(
223223+ source_uris: list[str], notifications_by_uri: dict[str, dict], requested_url: str | None
224224+) -> str | None:
225225+ notifications = [notifications_by_uri[uri] for uri in source_uris if uri in notifications_by_uri]
226226+ if not notifications:
227227+ return None
228228+229229+ thread_urls = {n.get("reply_root_url") for n in notifications if n.get("reply_root_url")}
230230+ if len(thread_urls) == 1:
231231+232232+ if (shared_thread := next(iter(thread_urls))):
233233+ return shared_thread
234234+235235+ subject_urls = {n.get("subject_url") for n in notifications if n.get("subject_url")}
236236+ if len(subject_urls) == 1:
237237+238238+ if (shared_subject := next(iter(subject_urls))):
239239+ return shared_subject
240240+241241+ notification_urls = {
242242+ n.get("notification_url") for n in notifications if n.get("notification_url")
243243+ }
244244+ if len(notification_urls) == 1:
245245+ if (shared_notification := next(iter(notification_urls))):
246246+ return shared_notification
247247+248248+ profile_urls = {n.get("profile_url") for n in notifications if n.get("profile_url")}
249249+ if len(profile_urls) == 1 and len(notifications) == 1:
250250+ return next(iter(profile_urls))
251251+252252+ if requested_url:
253253+ candidate_sets = [
254254+ {
255255+ candidate
256256+ for candidate in notification.get("candidate_urls", [])
257257+ if candidate
258258+ }
259259+ for notification in notifications
260260+ ]
261261+ if candidate_sets:
262262+ shared_candidates = set.intersection(*candidate_sets)
263263+ if requested_url in shared_candidates:
264264+ return requested_url
265265+266266+ if len(notifications) == 1:
267267+ return (
268268+ notifications[0].get("subject_url")
269269+ or notifications[0].get("notification_url")
270270+ or notifications[0].get("profile_url")
271271+ )
272272+273273+ return None
274274+275275+276276+def _reason_bucket(notification: dict) -> str:
277277+ reason = notification.get("reason")
278278+ if reason == "like":
279279+ return "likes"
280280+ if reason == "follow":
281281+ return "follows"
282282+ if reason == "mention":
283283+ return "mentions"
284284+ if notification.get("is_reply"):
285285+ return "replies"
286286+ return "posts"
287287+288288+289289+def _count_label_from_notifications(notifications: list[dict]) -> str:
290290+ counts: dict[str, int] = {}
291291+ for notification in notifications:
292292+ bucket = _reason_bucket(notification)
293293+ counts[bucket] = counts.get(bucket, 0) + 1
294294+295295+ ordered = ["posts", "replies", "likes", "mentions", "follows"]
296296+ parts = [f"{counts[name]} {name}" for name in ordered if counts.get(name)]
297297+ return ", ".join(parts[:3]) if parts else f"{len(notifications)} notifications"
298298+299299+300300+def _catch_all_summary(notifications: list[dict]) -> str:
301301+ actor_names: list[str] = []
302302+ seen: set[str] = set()
303303+ for notification in sorted(
304304+ notifications,
305305+ key=lambda notification: notification.get("indexed_at", ""),
306306+ reverse=True,
307307+ ):
308308+ key = (
309309+ notification.get("profile_url")
310310+ or notification.get("author_handle")
311311+ or notification.get("author_name")
312312+ or notification.get("uri")
313313+ )
314314+ if key in seen:
315315+ continue
316316+ seen.add(key)
317317+ actor_names.append(
318318+ notification.get("author_name")
319319+ or notification.get("author_handle")
320320+ or "someone"
321321+ )
322322+ if len(actor_names) >= 3:
323323+ break
324324+325325+ if not actor_names:
326326+ return "There is additional lower-signal activity in your unread pile."
327327+328328+ if len(actor_names) == 1:
329329+ actors_text = actor_names[0]
330330+ elif len(actor_names) == 2:
331331+ actors_text = f"{actor_names[0]} and {actor_names[1]}"
332332+ else:
333333+ actors_text = f"{actor_names[0]}, {actor_names[1]}, and {actor_names[2]}"
334334+335335+ return f"{actors_text} are active, along with additional lower-signal posting and discussion."
336336+337337+338338+def _collapse_overflow_items(
339339+ items: list[FeedItem], notifications_by_uri: dict[str, dict]
340340+) -> list[FeedItem]:
341341+ if len(items) <= 4:
342342+ return items
343343+344344+ head = items[:3]
345345+ tail = items[3:]
346346+ overflow_uris: list[str] = []
347347+ for item in tail:
348348+ overflow_uris.extend(item.source_uris)
349349+350350+ overflow_notifications = [
351351+ notifications_by_uri[uri] for uri in overflow_uris if uri in notifications_by_uri
352352+ ]
353353+ catch_all = FeedItem(
354354+ source_uris=overflow_uris,
355355+ priority="low",
356356+ title="More activity",
357357+ count_label=_count_label_from_notifications(overflow_notifications),
358358+ summary=_catch_all_summary(overflow_notifications),
359359+ target_url=None,
360360+ target_label="",
361361+ suggested_actions=[],
362362+ actors=_derive_actors(overflow_uris, notifications_by_uri),
363363+ actor_count=_count_actors(overflow_uris, notifications_by_uri),
364364+ )
365365+ return head + [catch_all]
366366+367367+368368+def _sort_items(items: list[FeedItem], notifications_by_uri: dict[str, dict]) -> list[FeedItem]:
369369+ def newest_indexed_at(item: FeedItem) -> str:
370370+ timestamps = [
371371+ notifications_by_uri[uri].get("indexed_at", "")
372372+ for uri in item.source_uris
373373+ if uri in notifications_by_uri
374374+ ]
375375+ return max(timestamps, default="")
376376+377377+ items = sorted(items, key=newest_indexed_at, reverse=True)
378378+ items = sorted(items, key=lambda item: _priority_rank(item.priority))
379379+ return items
380380+381381+382382+def _fallback_briefing(item_count: int, total_unread: int) -> str:
383383+ if total_unread == 0:
384384+ return "You are all caught up."
385385+ if item_count == 0:
386386+ return "Nothing in your unread pile looks worth surfacing right now."
387387+ return "Here is what looks worth your attention right now."
388388+389389+390390+def _finalize_briefing(
391391+ model_briefing: str | None, items: list[FeedItem], total_unread: int
392392+) -> str:
393393+ briefing = (model_briefing or "").strip()
394394+ if briefing:
395395+ return briefing
396396+ return _fallback_briefing(len(items), total_unread)
397397+398398+399399+def _derive_actors(
400400+ source_uris: list[str], notifications_by_uri: dict[str, dict], *, limit: int = 4
401401+) -> list[FeedActor]:
402402+ deduped: list[FeedActor] = []
403403+ seen: set[str] = set()
404404+ notifications = sorted(
405405+ (notifications_by_uri[uri] for uri in source_uris if uri in notifications_by_uri),
406406+ key=lambda notification: notification.get("indexed_at", ""),
407407+ reverse=True,
408408+ )
409409+ for notification in notifications:
410410+ key = (
411411+ notification.get("profile_url")
412412+ or notification.get("author_handle")
413413+ or notification.get("author_name")
414414+ or notification.get("uri")
415415+ )
416416+ if key in seen:
417417+ continue
418418+ seen.add(key)
419419+ deduped.append(
420420+ FeedActor(
421421+ name=notification.get("author_name")
422422+ or notification.get("author")
423423+ or "someone",
424424+ profile_url=notification.get("profile_url"),
425425+ avatar_url=notification.get("avatar"),
426426+ )
427427+ )
428428+ if len(deduped) >= limit:
429429+ break
430430+ return deduped
431431+432432+433433+def _count_actors(source_uris: list[str], notifications_by_uri: dict[str, dict]) -> int:
434434+ seen: set[str] = set()
435435+ for uri in source_uris:
436436+ notification = notifications_by_uri.get(uri)
437437+ if not notification:
438438+ continue
439439+ key = (
440440+ notification.get("profile_url")
441441+ or notification.get("author_handle")
442442+ or notification.get("author_name")
443443+ or notification.get("uri")
444444+ )
445445+ seen.add(key)
446446+ return len(seen)
447447+448448+449449+async def generate_feed(
450450+ notifications: list[dict],
451451+ preferences: UserPreferences,
452452+ active_user: ActiveUser,
453453+) -> FeedResult | None:
454454+ """Synthesize unread notifications into feed items."""
455455+ try:
456456+ visible_notifications = _filter_notifications_for_preferences(
457457+ notifications, preferences
458458+ )
459459+ if not visible_notifications:
460460+ return FeedResult(
461461+ briefing=_finalize_briefing(None, [], len(notifications)),
462462+ items=[],
463463+ )
464464+465465+ feed_input = json.dumps(
466466+ {
467467+ "preferences": {
468468+ "want_to_see": preferences.want_to_see,
469469+ "dont_want_to_see": preferences.dont_want_to_see,
470470+ },
471471+ "active_user": active_user.model_dump(),
472472+ "notifications": visible_notifications,
473473+ }
474474+ )
475475+ result = await _get_feed_agent().run(feed_input)
476476+ plan = result.output
477477+ notifications_by_uri = {n["uri"]: n for n in visible_notifications}
478478+ used_source_uris: set[str] = set()
479479+ validated_items: list[FeedItem] = []
480480+481481+ for item in plan.items:
482482+ source_uris = []
483483+ for uri in item.source_uris:
484484+ if uri in notifications_by_uri and uri not in used_source_uris:
485485+ source_uris.append(uri)
486486+ used_source_uris.add(uri)
487487+ if not source_uris:
488488+ continue
489489+490490+ target_url = _resolve_group_target(
491491+ source_uris, notifications_by_uri, item.target_url
492492+ )
493493+ notifications = [
494494+ notifications_by_uri[uri] for uri in source_uris if uri in notifications_by_uri
495495+ ]
496496+ target_label = _resolve_target_label(target_url, notifications)
497497+498498+ validated_items.append(
499499+ item.model_copy(
500500+ update={
501501+ "source_uris": source_uris,
502502+ "target_url": target_url,
503503+ "target_label": target_label,
504504+ "suggested_actions": [
505505+ action_id
506506+ for action_id in item.suggested_actions[:3]
507507+ if action_id in candidate_action_ids(notifications)
508508+ ],
509509+ "actors": _derive_actors(source_uris, notifications_by_uri),
510510+ "actor_count": _count_actors(source_uris, notifications_by_uri),
511511+ }
512512+ )
513513+ )
514514+515515+ validated_items = _sort_items(validated_items, notifications_by_uri)
516516+ validated_items = _collapse_overflow_items(validated_items, notifications_by_uri)
517517+ logger.info(
518518+ "feed generation: %s notifications -> %s items",
519519+ len(visible_notifications),
520520+ len(validated_items),
521521+ )
522522+ return FeedResult(
523523+ briefing=_finalize_briefing(
524524+ plan.briefing, validated_items, len(visible_notifications)
525525+ ),
526526+ items=validated_items,
527527+ )
528528+529529+ except Exception as e:
530530+ logger.error(f"feed generation failed: {e}")
531531+ return None