Glean - Design Document #
1. Overview #
Glean is a social RSS reader built on the AT Protocol. It operates as an AppView for the at.glean.* lexicon namespace: it indexes records from Jetstream, serves XRPC query endpoints, and provides the web UI at glean.at.
Users store their RSS feed subscriptions as individual lexicon records on their PDS (one record per feed). Glean's AppView consumes Jetstream, indexes those records, fetches the referenced RSS feeds, and serves both the reader UI and public XRPC APIs for the at.glean.* namespace.
The core idea: your RSS subscriptions are a strong signal about your interests. When enough people expose theirs, you can discover both people (who reads the same things) and content (what similar readers follow that you don't).
2. Stack #
| Layer | Technology |
|---|---|
| Edge | Cloudflare Worker: SvelteKit SSR + routing to the container |
| Backend | Go, running as a single Cloudflare Container |
| Database | Cloudflare D1 (SQL + FTS5, via Worker binding proxy) + Vectorize |
| Frontend | SvelteKit (adapter-cloudflare, SSR) + TailwindCSS v4 |
| Auth | AT Protocol OAuth / DID resolution (configurable PLC directory) |
| AT Protocol role | AppView for at.glean.* lexicons |
| Data source | AT Protocol Jetstream → SQLite index |
3. AT Protocol Lexicons #
All user data lives on their PDS. The server does not own user data; it indexes and aggregates it.
3.1 at.glean.subscription #
A single RSS feed subscription. One record per feed per user. Created automatically when a user subscribes to a feed. During onboarding, existing subscriptions can be bulk-imported from an OPML file: the OPML is parsed and individual subscription records are created.
{
"lexicon": 1,
"id": "at.glean.subscription",
"defs": {
"main": {
"type": "record",
"key": "tid",
"description": "A single RSS feed subscription.",
"record": {
"type": "object",
"required": ["feedUrl"],
"properties": {
"createdAt": { "type": "string", "format": "datetime" },
"feedUrl": { "type": "string" },
"title": { "type": "string" },
"category": { "type": "string" }
}
}
}
}
}
OPML Import/Export #
OPML is not part of the lexicon. It is only used as a transport format:
- Import (onboarding): User uploads an OPML file. Glean parses it, validates each feed URL, creates individual
at.glean.subscriptionrecords on the user's PDS, and indexes them locally. - Export (offboarding): Glean reads the user's
at.glean.subscriptionrecords from their PDS and generates an OPML file for download. The user's repository remains the canonical source.
3.2 at.glean.annotation #
Reading notes on an article: quote a passage, tag it, rate it, or write a note. A user can have many annotations per article. These are public records on the PDS; users can always share an annotation on Bluesky if they want discussion.
{
"lexicon": 1,
"id": "at.glean.annotation",
"defs": {
"main": {
"type": "record",
"key": "tid",
"description": "Reading note on a specific RSS article.",
"record": {
"type": "object",
"required": ["feedUrl", "articleUrl"],
"properties": {
"createdAt": { "type": "string", "format": "datetime" },
"feedUrl": { "type": "string" },
"articleUrl": { "type": "string" },
"quote": { "type": "string", "maxGraphemes": 5000 },
"note": { "type": "string", "maxGraphemes": 500 },
"tags": {
"type": "array",
"items": { "type": "string", "maxGraphemes": 50 },
"maxLength": 10
},
"rating": { "type": "integer", "minimum": 1, "maximum": 5 }
}
}
}
}
}
3.3 at.glean.like #
A user likes an article. The liked feed surfaces popular articles and feeds into discovery. Likes also feed into the recommendation system.
{
"lexicon": 1,
"id": "at.glean.like",
"defs": {
"main": {
"type": "record",
"key": "tid",
"description": "Like an RSS article.",
"record": {
"type": "object",
"required": ["feedUrl", "articleUrl"],
"properties": {
"createdAt": { "type": "string", "format": "datetime" },
"feedUrl": { "type": "string" },
"articleUrl": { "type": "string" }
}
}
}
}
}
3.4 at.margin.note (External) #
Glean also indexes records from the at.margin.note lexicon (owned by margin.at). These are displayed in the UI as if they were at.glean.annotation records: margin notes appear alongside glean annotations on article detail pages.
Ingestion (margin.at → Glean) #
Margin notes are indexed from both Jetstream and PDS sync, same as glean records. The mapping from margin note to glean annotation:
| Margin note field | Annotation field | Notes |
|---|---|---|
target.source |
article_url |
W3C SpecificResource source URL |
body.value |
note |
Text content of the annotation |
target.selector.exact |
quote |
TextQuoteSelector exact text |
tags |
tags |
Direct mapping |
createdAt |
created_at |
Direct mapping |
| (looked up from articles DB) | feed_url |
Resolved by matching target.source against known article URLs |
When no matching article exists in the local DB, the annotation is stored with an empty feed_url.
Mirroring (Glean → margin.at) #
When a user creates an annotation through Glean, two records are written to the user's PDS:
at.glean.annotation: the primary record (canonical URI for the annotation)at.margin.note: mirror for interoperability with margin.at clients
The conversion from glean annotation to margin note uses NewMarginNoteRecord:
| Glean annotation field | Margin note field | Notes |
|---|---|---|
articleUrl |
target.source |
Direct mapping |
quote |
target.selector.exact |
Wrapped in a TextQuoteSelector |
note |
body.value |
body.format = "text/plain" |
tags |
tags |
Direct mapping |
| (constant) | motivation |
Always "commenting" |
The margin note mirror is fire-and-forget: if it fails, the glean annotation still succeeds. The Jetstream consumer will pick up the margin note create event, but handleMarginNote skips it via AnnotationExistsByContent (checks author_did + article_url + quote + note) to prevent duplicate annotations.
3.5 app.skyreader.feed.subscription (External) #
Glean also indexes records from the Skyreader lexicon (app.skyreader.feed.subscription). When a user has subscriptions in Skyreader, they are imported as Glean subscriptions during PDS sync and via Jetstream events. This lets users who previously used Skyreader seamlessly transition to Glean without re-subscribing to their feeds.
The mapping from Skyreader subscription to Glean subscription:
| Skyreader field | Glean field | Notes |
|---|---|---|
feedUrl |
feed_url |
Direct mapping |
title |
title |
Direct mapping |
siteUrl |
site_url |
Stored on the feed record |
createdAt |
added_at |
Direct mapping |
| (none) | category |
Empty (Skyreader has no categories) |
If a Glean subscription already exists for the same feed_url, the existing one is kept. If the existing subscription has no URI (was created locally without PDS sync), the Skyreader URI/CID is backfilled.
3.6 app.bsky.graph.follow (External) #
Follow relationships are tracked from Bluesky and Tangled follow records. The FollowRecord struct is validated against the lexicon at lexicons/app/bsky/graph/follow.json. The optional via field (a strong ref) is preserved as raw JSON but not used by Glean.
3.7 Lexicon Constants #
All collection NSIDs are defined as constants in lexicon.go and used throughout the codebase:
const (
CollectionSubscription = "at.glean.subscription"
CollectionAnnotation = "at.glean.annotation"
CollectionLike = "at.glean.like"
CollectionMarginNote = "at.margin.note"
CollectionSkyreaderSubscription = "app.skyreader.feed.subscription"
CollectionBskyFollow = "app.bsky.graph.follow"
CollectionTangledFollow = "sh.tangled.graph.follow"
)
3.8 AppView Query Lexicons #
As an AppView, Glean serves the following XRPC query endpoints. Other AT Protocol applications can call these to access indexed at.glean.* data without implementing their own indexer.
at.glean.listSubscriptions #
List subscriptions from a repo, with optional filtering.
Input:
repo: string (DID of the user)
category?: string
limit?: integer (default 50, max 100)
cursor?: string
Output:
cursor?: string
subscriptions: [{ uri, cid, value: at.glean.subscription#main, indexedAt }]
at.glean.listFeedLists #
List subscription lists from multiple repos, with optional filtering.
Input:
actors?: string[] (filter by DIDs)
limit?: integer (default 50, max 100)
cursor?: string
Output:
cursor?: string
feeds: [{ did, subscriptionCount, subscriptions: [{ feedUrl, title, category }] }]
at.glean.listAnnotations #
List annotations for an article, a feed, or by a user.
Input:
feedUrl?: string
articleUrl?: string
author?: string (DID)
limit?: integer (default 50, max 100)
cursor?: string
Output:
cursor?: string
annotations: [{ uri, cid, author: { did, handle }, value, indexedAt }]
at.glean.listLikes #
List liked articles, optionally filtered by user or feed.
Input:
author?: string (DID)
feedUrl?: string
limit?: integer (default 50, max 100)
cursor?: string
Output:
cursor?: string
likes: [{ uri, cid, author: { did, handle }, value: at.glean.like#main, indexedAt }]
at.glean.getTrending #
Articles with the most likes, forming the community feed.
Input:
limit?: integer (default 50, max 100)
cursor?: string
since?: string (datetime)
Output:
cursor?: string
articles: [{ feedUrl, articleUrl, title, likeCount, annotations: [...] }]
at.glean.getRecommendations #
Get feed recommendations for a user based on clustering.
Input:
repo: string (DID of the user, query parameter)
limit?: integer (default 20, max 50)
Output:
feeds: [{ feedUrl, title, siteUrl, description, subscriberCount, score }]
people: [{ did, handle, displayName, avatar, jaccard, commonFeeds, isFollowed }]
3.9 AppView Jetstream Consumption #
Glean subscribes to a Jetstream endpoint (GLEAN_JETSTREAM, default wss://jetstream1.eurosky.network):
SUBSCRIBE collections: ["at.glean.subscription", "at.glean.annotation", "at.glean.like", "app.bsky.graph.follow", "sh.tangled.graph.follow", "at.margin.note", "app.skyreader.feed.subscription", "site.standard.document"]
Every collection the handler processes is subscribed network-wide, including events by Glean's own users, which arrive through those same collections. No wantedDids filter is used: the users table holds every follow target (see 7.7), so a DID list would grow into megabytes of query string and a rejected WebSocket handshake. This keeps live sync with Skyreader subscriptions, margin.at notes, and the social graph.
The follow firehose is mainnet-scale, so follow edges are storage-gated to the slice the distance BFS actually traverses: edges with a reader user on either end. A users row exists only when a DID has a reader record (at.glean.*, skyreader.*, or margin.*: subscriptions, likes, and annotations), so a reader's out-edges resolve their 1-hop recommendations and edges into readers resolve 2-hop paths for whoever follows the follower. Every distance query joins follow_distances against subscriptions or likes, so distances to non-readers are never read. Everything else would mirror the network's graph without influencing any output; 3-hop coverage is partial, which its 0.1 signal weight absorbs. Articles for site.standard.publication feeds arrive via the feed scheduler (PDS polling through StandardSiteFetcher, atproto feeds, not RSS); the site.standard.document stream adds real-time create/update/delete for already-known publication feeds. Documents for unknown publications are dropped so the network-wide stream cannot mirror every publication into the article index. (site.standard.publication events are not subscribed: no handler ever consumed them.)
Records created while the consumer is offline are covered by PDS sync. The maintenance pass enforces the storage gates on already-stored rows.
On each event:
- create: Insert record into local SQLite, update materialized counts
- delete: Tombstone the record (soft delete to preserve foreign key integrity)
- update: Replace the record's CID and value
The AppView does not handle writes. Users write records to their own PDS. Glean only reads them from Jetstream.
4. RSS Reader #
Glean is first and foremost an RSS reader. It fetches, parses, and stores articles from RSS, Atom, and JSON feeds so users can read them in a clean interface.
4.1 Feed Fetching #
A background scheduler polls subscribed feeds on a configurable tick. Feeds are fetched at most once per cycle regardless of how many users share them.
4.2 Fetch Schedule #
The scheduler uses a configurable tick interval with in-flight deduplication:
- Tick interval: A Worker cron heartbeat (
*/15 * * * *inwrangler.jsonc) invokesPOST /internal/tick; feed fetching runs on every tick - Staleness threshold: Feeds not fetched in the last 30 minutes are eligible
- Subscriber filter: Only feeds with at least one subscription are fetched. Liked or annotated feeds are stored as passive anchors (protected from the purge, never fetched); their articles arrive via on-demand materialization (see 6.4)
- In-flight dedup: If a feed is already being fetched (e.g., manual refresh and background scheduler overlap), the second caller waits for the first to complete rather than fetching again
- Error tracking:
error_countincrements on failure, resets to 0 on success. Feeds with high error counts are surfaced as "dead feeds" to the user.
SELECT ... FROM feeds
WHERE EXISTS (SELECT 1 FROM subscriptions WHERE feed_url = feeds.feed_url)
AND (last_fetched_at IS NULL OR last_fetched_at <= :cutoff)
ORDER BY last_fetched_at ASC NULLS FIRST
4.3 Feed Parsing #
Go's encoding/xml for RSS and Atom. A simple encoding/json for JSON Feed.
Each parser returns a normalized Feed and a slice of Article structs:
type Article struct {
FeedURL string
GUID string
Title string
URL string
Author string
Content string
Summary string
Published time.Time
Updated time.Time
}
Articles are deduplicated by (feed_url, guid). On upsert, only the article metadata changes; reading state is preserved.
4.4 Article Content #
Glean stores article content locally so the reading experience is fast and consistent:
CREATE TABLE articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_url TEXT NOT NULL,
guid TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
url TEXT,
author TEXT,
summary TEXT,
content TEXT,
full_content TEXT,
published DATETIME,
updated DATETIME,
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
language TEXT NOT NULL DEFAULT '',
UNIQUE(feed_url, guid)
);
CREATE INDEX idx_articles_language ON articles(language);
4.5 Read State #
Read/unread state is tracked per user per article:
CREATE TABLE read_state (
user_did TEXT NOT NULL,
article_id INTEGER NOT NULL,
is_read BOOLEAN NOT NULL DEFAULT 0,
read_at DATETIME,
PRIMARY KEY (user_did, article_id)
);
CREATE INDEX idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0;
4.6 Reading Experience #
The /articles page is the main reading view:
- River of news: Chronological list of all unread articles across all subscriptions
- Feed filter: Narrow to a single feed or category
- Mark as read: Individual toggle or "Mark all read" (optionally scoped to one feed)
- Like: Endorse an article (public, synced to Bluesky PDS)
- Open original: Title links to the source article
- Share: Share to Bluesky
- Keyboard navigation: Single-letter route shortcuts (
gdashboard,aarticles,ffeeds,ttrending,llibrary) via<svelte:window>in the layout;Escapecloses dialogs - Expanded view: User setting that shows full article content inline on the articles page. Articles are automatically marked as read via
IntersectionObserverafter being visible for 3 seconds. YouTube videos are embedded inline. A duplicate like button appears at the bottom of each article. Configurable in profile settings. - Daily digest: An AI-generated summary of unread articles, shown on the dashboard when enabled in profile settings. The LLM receives the titles and summaries of up to 50 unread articles and produces a grouped overview with linked references. A "Read" button marks all digest articles as read. Digests are cached per user for 24 hours and generated via singleflight to avoid duplicate LLM calls.
4.7 Feed Discovery from Content #
Beyond the clustering system, Glean also discovers new feeds from article content:
- Auto-discovery: When fetching a feed, parse
<link rel="alternate" type="application/rss+xml">from the feed's site URL to discover related feeds - Feedfavicon: Fetch
favicon.icoor/apple-touch-icon.pngfrom the feed's site URL for display - Dead feed detection: If a feed fails for 7 consecutive fetches, mark it as dead. Notify the user and offer to remove it.
5. System Architecture #
Glean is a Worker-plus-container deployment. A Cloudflare Worker is the public origin: it server-renders the SvelteKit app and forwards /api/*, /xrpc/*, /metrics, and /ready to one named Cloudflare Container running the Go AppView. The Go process still fills two roles: AppView (indexing at.glean.* records from Jetstream, serving XRPC queries) and RSS reader (fetching and storing feed content).
The container is a singleton (getContainer(env.GLEAN, "glean"), max_instances: 1). The AppView is a shared index plus a Jetstream websocket, so it cannot be load-balanced across interchangeable instances. The process does not self-schedule: a single Worker cron heartbeat (every 15 minutes, see wrangler.jsonc) invokes the authenticated /internal/tick endpoint, which runs whatever is due (fetch every tick, cluster on the :00 tick hourly, sync on the :00 tick every 8 hours), and any request wakes a sleeping container (first request after a quiet period pays a few seconds of cold start). sleepAfter (5m) is the cost dial: the container sleeps when idle, Jetstream dies with the process, and the next wake resumes the stream from the cursor stored in D1.
The container's D1 and R2 traffic goes to the virtual host glean.proxy (GLEAN_D1_PROXY_URL): the platform intercepts requests to that host from inside the container's network namespace and runs them as Worker code next to the bindings, on the same machine. wrangler dev mirrors the arrangement with a sidecar in the same network namespace, so the same URL works locally.
flowchart TD
B[Browser] -->|"GET pages, __data.json"| W
B -->|"/api, /xrpc, /metrics, /ready"| W
subgraph Edge["Cloudflare edge (any colo)"]
W["Worker: glean, SvelteKit SSR + router"]
A[(Static assets)]
P["outbound host glean.proxy: auth'd /d1 + /r2 proxy"]
D1[("D1: SQL + FTS5")]
W --> A
P --> D1
end
subgraph Singleton["Singleton placement (one datacenter)"]
DO["Durable Object 'glean'
(wakes the container)"]
C["Container: Go AppView
chi API · feed scheduler · jetstream consumer · cluster cron · materializer"]
DO --> C
end
W -->|"backend paths + cron ticks, getContainer(env.GLEAN)"| DO
W -->|"SSR server loads, handleFetch + cookies"| DO
C -->|"SQL as JSON over /d1 (bearer token)"| P
C -->|"article bodies over /r2 (bearer token)"| P
P --> R2[("R2 archive")]
C -->|"embeddings + KNN, REST"| V[(Vectorize)]
C -->|"fetch RSS/Atom/JSON, scrape, sync PDS"| NET[Internet]
NET -.->|"Jetstream websocket"| C
C -->|"writes on user action"| PDS[User PDS]
AppView responsibilities:
- Subscribe to Jetstream network-wide (no wantedDids filter) for
at.glean.*,at.margin.note,app.skyreader.feed.subscription,site.standard.document, and follow records; follow edges are storage-gated to graph-relevant pairs - Index records into D1
- Convert
at.margin.noterecords to annotations (displayed alongside glean annotations), skip if duplicate glean annotation exists - Mirror glean annotations as
at.margin.noterecords on user PDS for interoperability - Import
app.skyreader.feed.subscriptionrecords as glean subscriptions - Serve XRPC query endpoints (
at.glean.listSubscriptions, etc.) - Write to user PDS on behalf of the user (when the user acts through the UI)
- Materialize liked/annotated articles whose row is missing by scraping the article URL
6. Database Schema #
All SQL runs against one database with flat table names: Cloudflare D1, reached through the Worker binding proxy in production and through wrangler dev's local D1 in development. D1 exposes a single main schema, so the exact same statements run everywhere. The schema initializes in one transaction so the D1 driver flushes it as a single proxy batch: cold starts pay one round trip, not one per statement. The Go test suites run the same schema and statements against in-process sqlite (modernc.org/sqlite); D1 is sqlite, so only the transport differs. Content KNN is Cloudflare Vectorize, not sqlite-vec.
Foreign key constraints are not used. Referential integrity is enforced by the application layer.
Transactions over D1 are emulated by the driver: writes buffer on the connection and flush as one D1 batch (a single transaction, chunked at 100 statements to respect D1's time budget). Reads inside a transaction run immediately against committed state; every such read in the codebase either precedes the transaction's writes or touches tables the writes do not affect.
6.1 Users #
Profile data (handle, display name, avatar) is resolved on-the-fly via AT Protocol identity resolution rather than stored locally.
CREATE TABLE users (
did TEXT PRIMARY KEY,
indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
follows_dirty BOOLEAN NOT NULL DEFAULT 1
);
CREATE TABLE user_settings (
did TEXT PRIMARY KEY,
languages TEXT,
expanded_view BOOLEAN NOT NULL DEFAULT 0,
digest_enabled BOOLEAN NOT NULL DEFAULT 0,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
6.2 Feed Subscriptions #
Indexed from at.glean.subscription records on user PDS.
CREATE TABLE subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_did TEXT NOT NULL,
feed_url TEXT NOT NULL,
title TEXT,
category TEXT,
added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
uri TEXT,
cid TEXT,
UNIQUE(user_did, feed_url)
);
6.3 Feeds #
Known feeds. Subscribed feeds are fetched; liked or annotated feeds are stored as passive join anchors and never fetched (their articles arrive via materialization, see 6.4).
CREATE TABLE feeds (
feed_url TEXT PRIMARY KEY,
title TEXT,
site_url TEXT,
description TEXT,
feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json', 'atproto')),
last_fetched_at DATETIME,
last_error TEXT,
subscriber_count INTEGER NOT NULL DEFAULT 0,
consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
error_count INTEGER NOT NULL DEFAULT 0,
favicon_url TEXT
);
6.4 Articles #
Fetched from RSS and atproto feeds that have at least one subscription network-wide.
CREATE TABLE articles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_url TEXT NOT NULL,
guid TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
url TEXT,
author TEXT,
summary TEXT,
content TEXT,
full_content TEXT,
published DATETIME,
updated DATETIME,
fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
language TEXT NOT NULL DEFAULT '',
UNIQUE(feed_url, guid)
);
CREATE INDEX idx_articles_language ON articles(language);
Article bodies are stored in two tiers. Fresh articles carry content
(feed-provided body) and full_content (scraped body) in the row. Once a row
is older than GLEAN_ARCHIVE_WINDOW days (default 90; the deployment sets 3),
the maintenance cycle uploads each body to R2 (articles/<id>/content,
articles/<id>/full) and NULLs the columns. Article reads hydrate NULL bodies
from R2 transparently, so lists, article pages, search results, and digests
behave the same across the tiers. If a body exists nowhere (a pre-R2 row, or
an offload that failed before the upload), the article page re-fetches the
body from the source URL once, stores it through the normal path, and serves
it; the next read hydrates from R2. A failed re-fetch is retried at most once
per hour per article, so dead sources do not add scrape latency to every
view. The columns stay NULL-safe: a body that cannot be recovered reads as
empty and the UI falls back to the summary plus the fetch-content action.
The archive exists because D1 caps a database at 10 GB and feed bodies are the bulk of the row; R2 is an order of magnitude cheaper per byte and article content should be kept forever, exactly as fetched (annotations quote the body, so a re-fetch of a since-edited page would break highlight alignment). Bodies were never searchable (the FTS index covers title, summary, and author only), so archiving an article changes nothing about search.
D1 size is bounded at every stage. A single fetch inserts at most the 50
newest items per feed (first fetches otherwise dump entire feed histories);
the article URL is the row identity (guid) whenever one exists (at:// record
URIs excepted), so feeds that regenerate their GUIDs cannot re-insert their
window every tick; articles already present are filtered out before any
INSERT is sent, and so are items whose publish date is already past the
retention window (maintenance purges those within the hour);
summaries are stored as a ~1000-character plain-text excerpt,
with the full text of description-only feeds moved into content; articles
older than GLEAN_ARTICLE_RETENTION_DAYS days (default 30, 0 disables) are
deleted unless a like or annotation references them, and undated articles
never age out (their publish date is unknown, so age-purging them resurrected
every item still in a feed's window as unread once per retention period; the
per-feed cap and the unreferenced-feed purge bound them instead); each feed
keeps at most 500 article rows; and unreferenced feeds are purged with their
articles.
Articles referenced by a like or annotation but absent locally (they already
rotated out of the feed's item window, or the feed is not subscribed) are
materialized on demand: the article_url is scraped and inserted through
the normal upsert path, using the article URL as GUID, the page title (or a
humanized URL slug) as title, and a plain-text excerpt as summary. Liked and
annotated feeds are passive, stored to anchor the joins and never fetched,
and their articles survive the retention purge as long as the like or
annotation exists. If the like is later deleted, the feed and articles become
unreferenced and the hourly purge removes them.
6.5 Read State #
CREATE TABLE read_state (
user_did TEXT NOT NULL,
article_id INTEGER NOT NULL,
is_read BOOLEAN NOT NULL DEFAULT 0,
read_at DATETIME,
PRIMARY KEY (user_did, article_id)
);
CREATE INDEX idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0;
6.6 Annotations, Likes #
Local mirror of AT Protocol lexicon records for fast querying.
CREATE TABLE annotations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uri TEXT NOT NULL UNIQUE,
author_did TEXT NOT NULL,
feed_url TEXT NOT NULL,
article_url TEXT NOT NULL,
quote TEXT,
note TEXT,
tags TEXT,
rating INTEGER,
created_at DATETIME NOT NULL,
cid TEXT
);
CREATE TABLE likes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
uri TEXT NOT NULL UNIQUE,
author_did TEXT NOT NULL,
feed_url TEXT NOT NULL,
article_url TEXT NOT NULL,
created_at DATETIME NOT NULL,
cid TEXT,
UNIQUE(author_did, feed_url, article_url)
);
6.7 Cluster Precomputation #
Stores precomputed similarity data to avoid recalculating on every request.
CREATE TABLE user_similarity (
user_a TEXT NOT NULL,
user_b TEXT NOT NULL,
jaccard REAL NOT NULL,
common_feeds INTEGER NOT NULL,
common_likes INTEGER NOT NULL DEFAULT 0,
common_tags INTEGER NOT NULL DEFAULT 0,
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_a, user_b),
CHECK(user_a < user_b)
);
6.8 Follows #
Tracks follow relationships between users (from app.bsky.graph.follow and sh.tangled.graph.follow records).
CREATE TABLE follows (
user_did TEXT NOT NULL,
target_did TEXT NOT NULL,
uri TEXT,
cid TEXT,
followed_at DATETIME,
PRIMARY KEY (user_did, target_did)
);
CREATE INDEX idx_follows_target ON follows(target_did);
CREATE INDEX idx_follows_uri ON follows(uri);
6.9 OAuth Storage #
Login attempts store their handshake state until the callback consumes it; sessions store the OAuth tokens per login. Maintenance deletes abandoned requests after 7 days and sessions untouched for 90 days, so both tables stay bounded.
CREATE TABLE oauth_auth_requests (
state TEXT PRIMARY KEY,
data TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE oauth_sessions (
account_did TEXT NOT NULL,
session_id TEXT NOT NULL,
data TEXT NOT NULL,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (account_did, session_id)
);
7. Recommendations #
Glean uses a multi-signal recommendation system that combines subscription overlap, like patterns, social graph distance, and user behavior feedback.
7.1 Signals #
| Signal | Source | Weight (default) | Description |
|---|---|---|---|
| Subscription | subscriptions |
1.0 | Jaccard over subscriber sets between similar users |
| Like | likes |
0.5 | Time-decayed like co-occurrence (30-day half-life) |
| Tag | annotations.tags |
0.3 | Jaccard over annotation tag sets |
| Social | follow_distances |
0.7 | Follow distance: 1-hop=1.0, 2-hop=0.3, 3-hop=0.1 |
| Popularity | feeds.subscriber_count |
0.2 | log(1 + subscribers) / log(1 + max) |
| Category | subscriptions.category |
0.4 | Boost feeds matching user's existing categories |
| Content | Vectorize glean-articles |
0.4 | Cosine similarity via embedding KNN (requires embedder) |
7.2 Feed Co-occurrence (Jaccard Similarity) #
For any two feeds, the similarity is the Jaccard index of their subscriber sets:
J(A, B) = |subscribers(A) ∩ subscribers(B)| / |subscribers(A) ∪ subscribers(B)|
Feed description similarity is also computed via embedding cosine similarity (requires embedder) and added as a boost.
7.3 User Similarity #
For any two users, compute Jaccard over their subscription sets, plus like co-occurrence (time-decayed) and tag overlap:
J(U1, U2) = jaccard_subscriptions + 0.3 * jaccard_likes + 0.2 * jaccard_tags + 0.5 * follow_boost
Like overlap uses exponential time decay: EXP(-0.023 * age_days) (30-day half-life).
7.4 Precomputed + On-Demand Scoring #
Recommendations are precomputed for all active users on every cluster cron cycle (hourly; see 7.10) and stored in the precomputed_recommendations table
Feed recommendation score (computed in SQL):
score = sub_signal * w_sub
+ like_signal * w_like
+ social_signal * w_social
+ pop_signal * w_pop
+ category_signal * w_category
Where:
sub_signal = SUM(jaccard(target, U))for similar users U subscribed to feedlike_signal = SUM(jaccard(target, U) * time_decay)for likes in that feed by similar userssocial_signal = SUM(distance_weight)from follow_distancespop_signal = log(1 + subscriber_count) / log(1 + max_subscribers)category_signal = 1if feed description matches user's top categories
Article recommendation score:
score = like_signal * w_like
+ social_signal * w_social
+ content_signal * w_content
+ recency_signal * 0.2
Content signal uses embedding vectors: the user's liked article embeddings are averaged into a single interest vector, then a KNN query against the glean-articles Vectorize index finds semantically similar articles. This requires an embedder to be configured; without it, the content signal is 0.
Language filtering: Users can set preferred languages on their profile (stored in the user_settings table as a JSON array of ISO 639-1 codes). When set, article recommendations are filtered to only include articles whose language column matches one of the selected codes or whose language is empty (not yet classified). This ensures users with language preferences still see all recommendations when the LLM hasn't run or hasn't classified certain articles yet. If no languages are set (empty/nil), all articles are shown regardless of language.
7.5 User Feedback (Dismiss) #
Users can dismiss recommendations they don't want to see again:
POST /feeds/dismiss: dismiss a feed recommendationPOST /articles/dismiss: dismiss an article recommendation- Dismissals are stored locally in
dismissed_recommendations(not on PDS) - Dismissed items are excluded from all future recommendation queries
- Auto-dismiss: items shown ≥5 times over >5 days without action are auto-dismissed
Recommended articles use the same card template as regular articles, with an additional "Hide" button that dismisses the recommendation. Dismissing only removes that specific article from recommendations; it does not affect the user's likes or signal weights, and similar articles may still be recommended.
7.6 Auto-Tuned Signal Weights #
Each user has a row in user_signal_weights with per-signal weights. When a user acts on a recommendation (subscribes, likes), the dominant signal that produced that recommendation is rewarded:
new_weight = MAX(0.1, MIN(3.0, old_weight * (1 + learning_rate * delta)))
learning_rate = 0.1,delta = +1for reward,-1for penalty- Only activates after
minActionsTune = 5positive actions - Defaults are used when no row exists for a user
7.7 Social Graph #
Follow distances (1-hop through 3-hop) are computed incrementally, for reader users only: a users row exists only when a DID has a reader record, and every query reads user_a = <requesting user> joined against subscriptions or likes. A follows_dirty column on users tracks whose follow graph changed since the last cron run. Only dirty users are reprocessed: their existing rows in follow_distances are deleted and recomputed via BFS, then the dirty flag is cleared. The follows table stores only the out-edges the BFS traverses: edges with a reader on either end (a reader's out-edges for 1-hop, edges into readers for 2-hop), so 3-hop coverage is partial. Maintenance purges distance rows whose user_a no longer has a users row.
- 1-hop: direct follows (weight 1.0)
- 2-hop: friends-of-friends (weight 0.3)
- 3-hop: third-degree connections (weight 0.1)
7.8 Diversity & Freshness #
After scoring, diversity filtering is applied in Go (not SQL):
- Domain diversity: max 2 feeds from the same domain in results
- Category diversity: max 3 feeds from the same category in results
- This prevents recommendation clustering on a single source
7.9 Cold Start #
New users with <5 subscriptions get a fallback strategy:
- Feeds from 1-hop followed users (70% weight)
- Globally popular feeds by subscriber count (30% weight)
7.10 Clustering Engine (Cron) #
The heartbeat's :00 tick (hourly) runs the cluster pass. Maintenance runs first, so the computation steps below run on the rows that survive instead of embedding and scoring articles that are about to expire:
- DB maintenance: Prune
recommendation_impressionsolder than 14 days; prune OAuth login attempts older than 7 days and sessions untouched for 90 days; purge follow edges that fail the storage gate (neither endpoint is a reader user, answered from an in-process known-users cache the jetstream gate also uses); purgefollow_distancesrows whoseuser_ahas nousersrow; delete articles older thanGLEAN_ARTICLE_RETENTION_DAYSdays unless a like or annotation references them (undated articles are exempt; the per-feed cap and the unreferenced-feed purge remove them); trim every feed to its 500 newest articles; delete feeds with no subscription, like, or annotation along with their articles; deleteread_staterows pointing at removed articles; then runPRAGMA incremental_vacuumon the local file (D1 manages its own storage). All deletes run in rowid-keyed chunks so maintenance never holds a long write transaction. - Offload article bodies: Articles older than
GLEAN_ARCHIVE_WINDOWdays (default 90; the deployment sets 3) getcontent/full_contentuploaded to R2 and the columns NULLed (see 6.4). Uploads precede the UPDATE so an interrupted pass is idempotent. - Compute feed embeddings: Embed new feed descriptions via embedding API into
feed_embeddingstable (skipped if no embedder configured) - Compute user similarity: Diff-update
user_similarity(subscription Jaccard + time-decayed likes + tags + follow boost; only rows whose value actually changed are written) - Compute article embeddings: Embed new articles (
title + summary + content, excludingfull_contentto stay within embedding model token limits) via embedding API into theglean-articlesVectorize index (skipped if no embedder configured) - Detect article languages: Batch-classify article languages via LLM, updating the
languagecolumn (skipped if no LLM configured) - Compute follow distances: Incremental BFS for dirty users (1-hop through 3-hop from
followstable), committed one user per transaction so a long run never holds the recs writer lock for the whole job, and a failure keeps completed users; each recompute writes only new, changed, or removed pairs - Compute signal profiles: Per-user category/tag/like summaries (diff-updated the same way)
- Auto-dismiss stale: Dismiss items shown >=5 times over >5 days without action
- Precompute recommendations: For each active user, compute feed, article, and people recommendations and store as JSON in the
precomputed_recommendationstable. This ensures instant load times for all recommendation sections. - Log purge stats: The cron logs how many follows, articles (unreferenced, expired, overage), feeds, and read_state rows maintenance removed, plus how many article bodies were offloaded
Jetstream ingestion and record indexing happen in a separate persistent goroutine (the Jetstream consumer), not in the cron.
7.11 User Interaction Tables #
Impressions feed the auto-dismiss pass and the per-user signal-weight tuning. Maintenance deletes impressions older than 14 days (the auto-dismiss window is 5 days).
CREATE TABLE dismissed_recommendations (
user_did TEXT NOT NULL,
target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article', 'person')),
target_id TEXT NOT NULL,
reason TEXT,
dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_did, target_type, target_id)
);
CREATE TABLE recommendation_impressions (
user_did TEXT NOT NULL,
target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article', 'person')),
target_id TEXT NOT NULL,
first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
shown_count INTEGER NOT NULL DEFAULT 1,
acted BOOLEAN NOT NULL DEFAULT 0,
PRIMARY KEY (user_did, target_type, target_id)
);
7.12 Computed Recommendation Tables #
Written exclusively by the cron. Read during recommendation requests (precomputed results served first, on-demand fallback if missing). The similarity tables are defined in 6.7.
CREATE TABLE follow_distances (
user_a TEXT NOT NULL,
user_b TEXT NOT NULL,
distance INTEGER NOT NULL CHECK(distance IN (1, 2, 3)),
PRIMARY KEY (user_a, user_b)
);
CREATE TABLE user_signal_weights (
user_did TEXT PRIMARY KEY,
w_sub REAL NOT NULL DEFAULT 1.0,
w_like REAL NOT NULL DEFAULT 0.5,
w_tag REAL NOT NULL DEFAULT 0.3,
w_social REAL NOT NULL DEFAULT 0.7,
w_pop REAL NOT NULL DEFAULT 0.2,
w_category REAL NOT NULL DEFAULT 0.4,
w_content REAL NOT NULL DEFAULT 0.4,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE user_signal_profiles (
user_did TEXT PRIMARY KEY,
total_likes INTEGER NOT NULL DEFAULT 0,
total_tags INTEGER NOT NULL DEFAULT 0,
top_categories TEXT,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE precomputed_recommendations (
user_did TEXT NOT NULL,
rec_type TEXT NOT NULL CHECK(rec_type IN ('feed', 'article', 'person')),
data TEXT NOT NULL,
computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (user_did, rec_type)
);
7.13 Embeddings (recommended) #
When GLEAN_EMBED_BASE_URL is configured, article text and feed descriptions are embedded and upserted into Vectorize (glean-articles, glean-feeds). SQL keeps article_embedding_ids and feed_embedding_meta so cron knows what is already indexed. Without embeddings, recommendations rely only on subscription overlap, like patterns, and social graph.
The embedding model must output exactly the Vectorize index dimensions (1536 in the setup commands): nothing reshapes or truncates vectors, so a mismatched model fails the upsert loudly in the cron logs.
The embedder uses the official github.com/openai/openai-go SDK with option.WithBaseURL(), so any OpenAI-compatible /v1/embeddings endpoint works (OpenAI, Gemini, Ollama, local inference servers).
7.14 LLM Client (optional) #
When GLEAN_LLM_BASE_URL is configured, an LLM client is available for text classification and summarization tasks. It uses the same github.com/openai/openai-go SDK pointed at any OpenAI-compatible /v1/chat/completions endpoint.
Language detection: The cron job calls DetectLanguages in batches of up to 100 articles per request. For each article, the title and summary (truncated to 500 characters) are sent with a prompt asking for ISO 639-1 codes. Results are written to articles.language. Articles that are already classified (non-empty language) are skipped. An empty response from the LLM defaults to 'unknown' rather than a specific language, ensuring unclassifiable articles aren't miscategorized.
Without an LLM, all articles remain at the default empty language value and language-based filtering is unavailable. The daily digest feature also requires an LLM to generate summaries; without it, the digest is unavailable.
SQL tracks which vectors exist:
CREATE TABLE feed_embedding_meta (
feed_url TEXT PRIMARY KEY,
source_text TEXT NOT NULL DEFAULT ''
);
CREATE TABLE article_embedding_ids (
article_id INTEGER PRIMARY KEY
);
During cron, ComputeArticleEmbeddings embeds new articles in batches using title + summary + content and upserts them to Vectorize. ComputeFeedEmbeddings embeds feed descriptions and re-embeds when source text changes. On-demand article recs average liked-article vectors and query Vectorize. Cold-start feed recs do the same with subscribed feeds.
8. HTTP API #
The Go server exposes a JSON API under /api/*. The SvelteKit frontend (see web/) owns all HTML page routes and consumes these endpoints via web/src/lib/api.ts. The Worker forwards browser /api/* to the container; SvelteKit server loads reach the container through handleFetch during server-side rendering (cookies forwarded), and subsequent client-side navigations fetch __data.json from the browser. All state-changing requests require a double-submit CSRF token (X-CSRF-Token header or csrf_token form field) matching the glean_csrf cookie.
8.1 JSON endpoints #
All endpoints below return JSON. Those marked 🔒 require authentication (session cookie). Form fields are sent application/x-www-form-urlencoded; file uploads are multipart/form-data.
| Route | Method | Auth | Description |
|---|---|---|---|
/api/me |
GET | Current user, CSRF token, feature flags (has_llm, client_id) |
|
/api/dashboard |
GET | 🔒 | Dashboard: article recs, unread articles, trending, digest flag |
/api/feeds |
GET | 🔒 | Subscriptions (paginated), categories, dead feeds |
/api/feeds/add |
POST | 🔒 | Add a feed URL (fields: feed_url, category) |
/api/feeds/edit |
POST | 🔒 | Edit a subscription category |
/api/feeds/opml/upload |
POST | 🔒 | Bulk-import subscriptions from OPML (field: opml file) |
/api/feeds/opml/download |
GET | 🔒 | Export subscriptions as OPML |
/api/feeds/refresh |
POST | 🔒 | Refresh all subscribed feeds |
/api/feeds/retry |
POST | 🔒 | Retry a failed feed |
/api/feeds/list |
GET | 🔒 | Flat subscription list (optional ?category=) |
/api/feeds/clear |
POST | 🔒 | Remove all subscriptions |
/api/articles |
GET | 🔒 | Read articles (paginated; ?feed=, ?status=, ?q=, ?sort=, ?category=) |
/api/articles/new-count |
GET | 🔒 | Count of articles newer than ?since= (unix seconds); banner polling |
/api/articles/{id} |
GET | 🔒 | Article detail + annotations (marks read as a side effect) |
/api/articles/{id}/read |
POST | 🔒 | Mark article read |
/api/articles/{id}/unread |
POST | 🔒 | Mark article unread |
/api/articles/{id}/like |
POST | 🔒 | Toggle like (writes/deletes on PDS) |
/api/articles/{id}/fetch-content |
POST | 🔒 | Scrape full article content from the source URL |
/api/articles/mark-all-read |
POST | 🔒 | Mark all (or ?feed=-scoped) articles read |
/api/trending |
GET | Trending articles (?scope=for-me requires auth) |
|
/api/profile/{did} |
GET | 🔒 | Public profile: feeds, annotations, languages (resolves handles) |
/api/library |
GET | 🔒 | Liked articles + annotations (paginated) |
/api/library/create |
POST | 🔒 | Create annotation (writes at.glean.annotation + at.margin.note mirror) |
/api/library/{id}/delete |
POST | 🔒 | Delete annotation + mirror |
/api/recs/articles |
GET | 🔒 | Article recommendations |
/api/recs/feeds |
GET | 🔒 | Feed recommendations |
/api/recs/people |
GET | 🔒 | People recommendations (followed + discover) |
/api/recs/dismiss-feed |
POST | 🔒 | Dismiss a feed recommendation |
/api/recs/dismiss-article |
POST | 🔒 | Dismiss an article recommendation |
/api/recs/dismiss-person |
POST | 🔒 | Dismiss a person recommendation |
/api/settings/languages/{code} |
POST | 🔒 | Toggle a preferred recommendation language |
/api/settings/expanded-view |
POST | 🔒 | Toggle expanded article view |
/api/settings/digest-enabled |
POST | 🔒 | Toggle daily digest |
/api/digest |
GET | 🔒 | Daily digest (LLM summary of unread articles); 204 when none/unavailable |
/api/digest/mark-read |
POST | 🔒 | Mark digest articles read (field: repeated ids) |
/api/auth/login |
GET | Whether OAuth is enabled (oauth_enabled) |
|
/api/auth/register |
GET | Start registration via Eurosky ({redirect}) |
|
/api/auth/actors |
GET | Handle typeahead (?q=) → {actors} |
|
/api/auth/start |
POST | 🔒 | Start OAuth flow (field: handle) → {redirect} |
/api/auth/callback |
GET | OAuth callback (browser redirect, not JSON) | |
/api/auth/logout |
POST | 🔒 | End session → {redirect} |
/api/oauth/client-metadata |
GET | OAuth client metadata document | |
/api/sitemap |
GET | Sitemap entries (JSON) | |
/api/stats |
GET | Prometheus metrics parsed into {metrics} |
|
/metrics |
GET | Raw Prometheus exposition | |
/ready |
GET | Container health check (204 if SQLite is reachable) |
XRPC query endpoints (public, no /api prefix):
| Route | Description |
|---|---|
/xrpc/at.glean.listSubscriptions |
List a user's subscriptions |
/xrpc/at.glean.listAnnotations |
List annotations |
/xrpc/at.glean.listLikes |
List likes |
/xrpc/at.glean.getTrending |
Trending articles |
/xrpc/at.glean.getRecommendations |
Recommendations |
/xrpc/at.glean.listFeedLists |
Feed lists |
8.2 Frontend routes #
All HTML page routes live in the SvelteKit app (web/src/routes/), not on the Go server. The app server-renders (ssr = true in the root +layout.ts): each route's +page.server.ts load runs in the Worker during SSR, calling the JSON API via endpointsFor(event.fetch) (routed to the container by handleFetch), and the browser hydrates the result; client-side navigations fetch __data.json. The layout (+layout.svelte) renders the shared chrome and mounts the new-articles banner.
| Path | Load source |
|---|---|
/ |
+page (landing) |
/dashboard |
/api/dashboard |
/articles |
/api/articles |
/articles/[id] |
/api/articles/{id} |
/feeds |
/api/feeds |
/library |
/api/library |
/trending |
/api/trending |
/profile/[did] |
/api/profile/{did} |
/stats |
/api/stats |
/terms |
(static content) |
/auth/login |
/api/auth/login |
/robots.txt |
(static, +server.ts) |
/sitemap.xml |
/api/sitemap |
9. Project Structure #
glean/
├── main.go # Entry point, wire everything
├── go.mod / go.sum
├── wrangler.jsonc # Worker + Container + cron + assets
├── worker/index.ts # Route /api /xrpc to the named Go container
├── Dockerfile # Go API, CGO-free
├── Makefile # Targets: dev, deploy, test, ...
├── lexicons/
│ └── at/
│ ├── glean/ # Glean lexicon JSON schemas (subscription, annotation, like)
│ └── margin/ # External: at.margin.note W3C Web Annotation schema
│ └── app/bsky/graph/ # External: app.bsky.graph.follow schema
├── internal/
│ ├── atproto/
│ │ ├── auth.go # DID resolution, OAuth flow
│ │ ├── client.go # XRPC client (write to user PDS)
│ │ ├── collectiondir.go # Collection directory backfill (startup)
│ │ ├── jetstream.go # Subscribe to Jetstream via official client
│ │ ├── stream_handler.go # Stream event → DB handler
│ │ ├── lexicon.go # Lexicon record types (at.glean.*, maintained by hand)
│ │ ├── lexicon_external.go # External lexicon record types (FollowRecord, MarginNoteRecord, SkyreaderSubscriptionRecord)
│ │ ├── lexicon_test.go # Test: Go structs match lexicon JSON schemas
│ │ ├── sync.go # PDS record reconciliation
│ │ └── xrpc.go # XRPC query handlers (AppView endpoints)
│ ├── blob/
│ │ └── blob.go # R2 client via the outbound /r2 proxy
│ ├── db/
│ │ ├── db.go # Store over D1 via the worker proxy; flat table names
│ │ ├── sqlite.go # In-process sqlite backend for the test suites (D1 is sqlite)
│ │ ├── d1.go # D1-over-HTTP driver (DSN from Cloudflare creds)
│ │ ├── migrations.go # Schema migrations
│ │ ├── blobs.go # Article-body R2 offload + hydration
│ │ ├── cursor_store.go # Jetstream cursor persistence
│ │ ├── user.go # User queries
│ │ ├── feed.go # Feed + subscription queries
│ │ ├── article.go # Article queries
│ │ ├── social.go # Like, annotation queries
│ │ ├── follow.go # Follow queries
│ │ ├── oauth_store.go # OAuth session storage
│ │ ├── user_settings.go # User settings queries
│ │ └── store.go # FeedStore adapter for scheduler
│ ├── feed/
│ │ ├── parser.go # RSS/Atom/RDF/JSON feed parser
│ │ ├── fetcher.go # Scheduler with dedup + Fetcher
│ │ ├── discover.go # Feed auto-discovery from URLs
│ │ └── opml.go # OPML import/export
│ ├── httpclient/
│ │ └── httpclient.go # Shared HTTP transport, retry logic, User-Agent
│ ├── scraper/
│ │ └── scraper.go # Full article content scraper
│ ├── metrics/
│ │ └── metrics.go # Prometheus metrics definitions
│ ├── ml/
│ │ ├── embed.go # Embedder interface + OpenAI-compatible implementation + vector helpers
│ │ ├── langdetect.go # Known-language table + detection
│ │ └── llm.go # TextModel interface + OpenAI-compatible LLM implementation
│ ├── cluster/
│ │ ├── jaccard.go # Jaccard similarity computation
│ │ ├── article.go # Article + feed embedding computation, Vectorize KNN content boost, language detection
│ │ ├── scoring.go # Feed + people + article recommendation queries (precomputed + on-demand fallback)
│ │ ├── precompute.go # Precompute all user recommendations (run by cron 4x/day)
│ │ ├── social.go # Incremental follow-distance computation (1-3 hop, dirty-flag)
│ │ ├── weights.go # Bandit-style signal weight auto-tuning
│ │ ├── diversity.go # Post-query domain/category diversity filtering
│ │ └── cron.go # Background recomputation scheduler
│ ├── feedback/
│ │ └── feedback.go # Dismiss + impression tracking service
│ ├── server/
│ │ ├── server.go # HTTP server, chi router setup (/api/* routes)
│ │ ├── api.go # JSON helpers (writeJSON), DTOs, null helpers
│ │ ├── api_helpers.go # URL validation helpers
│ │ ├── auth_handler.go # OAuth login/callback/register/logout
│ │ ├── feeds_handler.go # Feed management handlers
│ │ ├── articles_handler.go # Article reading handlers
│ │ ├── annotations_handler.go # Annotation + library handlers
│ │ ├── dashboard_handler.go # Dashboard + recommendation handlers
│ │ ├── recs_handler.go # Recommendation dismiss handlers
│ │ ├── trending_handler.go # Trending handler
│ │ ├── profile_handler.go # Public profile handler
│ │ ├── settings_handler.go # User settings (language preferences, digest/expanded toggle)
│ │ ├── digest_handler.go # Daily digest handler (LLM summary, mark-read)
│ │ ├── stats_handler.go # Stats handler (Prometheus metrics → JSON)
│ │ ├── sitemap_handler.go # Sitemap handler
│ │ ├── index_handler.go # /api/me + auth-login meta handler
│ │ ├── sync_handlers.go # Periodic sync + collection-dir backfill
│ │ ├── sanitize.go # HTML sanitization for article content
│ │ ├── pagination.go # Pagination helpers
│ │ ├── middleware.go # Auth, logging, CSRF, CORS middleware
│ │ └── session.go # Session management
├── web/ # SvelteKit frontend (SSR), adapter-cloudflare
│ ├── src/
│ │ ├── app.css # Tailwind v4 entry + design tokens (@theme, [data-theme], @utility)
│ │ ├── app.html # HTML shell
│ │ ├── app.d.ts # Locals types (user, csrfToken)
│ │ ├── hooks.server.ts # handleFetch: route server-load /api fetches to the container
│ │ ├── lib/
│ │ │ ├── api.ts # Typed endpoint client (endpoints / endpointsFor(fetch))
│ │ │ ├── types.ts # API response types
│ │ │ ├── format.ts # Date/HTML/youtube formatting helpers
│ │ │ ├── toast.svelte.ts # Global toast notifications ($state runes)
│ │ │ └── components/ # Svelte components (ArticleCard, FeedItem, Icon, ...)
│ │ └── routes/ # File-based routes (+page.server.ts load + +page.svelte)
│ │ ├── +layout.ts # ssr = true (server-rendered app)
│ │ ├── +layout.server.ts # /api/me load (user, CSRF token, feature flags)
│ │ ├── +layout.svelte # Chrome (nav, footer, dialogs, new-articles banner)
│ │ ├── articles/[id]/ # Article detail
│ │ ├── dashboard/ # Dashboard
│ │ ├── feeds/ # Feed management
│ │ ├── library/ # Liked + annotations
│ │ ├── trending/ # Trending
│ │ ├── profile/[did]/ # Public profile
│ │ ├── stats/ # Metrics
│ │ ├── auth/login/ # Login
│ │ ├── terms/ # Terms (static)
│ │ ├── robots.txt/ # robots (+server.ts)
│ │ └── sitemap.xml/ # Sitemap (+server.ts)
│ ├── static/ # Favicons, manifest, banner
│ ├── svelte.config.js # adapter-cloudflare
│ ├── vite.config.ts
│ └── package.json # bun; svelte, @sveltejs/kit, @tailwindcss/vite
└── docs/
├── specs.md # Technical specification (this document)
└── design.md # Design system
10. Auth Flow #
Session cookies are HMAC-signed using GLEAN_SESSION_KEY (required, must be set to a random string). The server refuses to start without it.
DID resolution uses a configurable PLC directory (GLEAN_PLC_URL, defaults to https://plc.eurosky.network). The identity directory is initialized once at startup via InitIdentity() with a caching layer (250k entries, 24h TTL).
- User visits
/, clicks "Sign in with Bluesky" (or any AT Proto PDS) - Server redirects to AT Protocol OAuth authorization endpoint
- User authorizes on their PDS
- PDS redirects back with authorization code
- Server exchanges code for access token + refresh token
- Server resolves DID and creates a session (cookie with HMAC-signed DID)
- On each request, middleware verifies and decodes the session, loads user from DB (or creates if new)
The server never stores the user's AT Protocol password. It stores the session tokens for XRPC calls to the user's PDS (to read/write their lexicon records).
11. Data Flow #
11.1 User Imports OPML #
Browser ──POST /api/feeds/opml/upload──► Server
│
├─► Parse OPML, extract feed URLs
├─► Fetch each feed, validate + store in `feeds` table
├─► For each feed, create an `at.glean.subscription` record
│ via XRPC write to user's PDS
├─► Insert subscriptions in local `subscriptions` table
└─◄ JSON response ({added}); client toasts and reloads
11.2 Reading the Feed #
Browser ──GET /articles──► SvelteKit route (server-rendered)
│
├─► Load: GET /api/articles (in the Worker via handleFetch)
│ └─► Query articles for user's subscriptions
└─◄ HTML with the list; browser hydrates
11.3 Recommendations #
Cron (every 1h) ──► Cluster Engine
│
├─► Compute user similarity
├─► Compute article embeddings (if embedder configured)
├─► Detect article languages (if LLM configured)
├─► Compute follow distances
├─► Compute signal profiles
└─► Auto-dismiss stale recommendations
Browser ──GET /dashboard──► Server
│
├─► Read precomputed recommendations from DB (instant)
│ (fallback to on-demand if missing)
├─► Fetch feed metadata
└─◄ JSON; client renders recommendation cards
12. Key Design Decisions #
12.1 Why use lexicon records for feed subscriptions? #
- User sovereignty: Each feed subscription lives as a record on the user's PDS. They can export them, move PDS, or revoke access at any time.
- Interoperability: Any AT Protocol app can read the lexicon and integrate with Glean data.
- No lock-in: If Glean shuts down, the user's data is intact on their PDS.
- OPML as transport only: OPML is a common interchange format used solely for import (onboarding from existing readers) and export (offboarding). The canonical representation is always the lexicon: individual
at.glean.subscriptionrecords, one per feed.
12.2 Storage #
- Relational recs and the AppView index live in D1 (FTS5 included). Content KNN lives in Vectorize. Article bodies archive to R2. The Go container is the Jetstream consumer and the cron, not the disk.
- D1 is the only storage backend, in production and local dev alike (
wrangler devserves the binding locally); the Go test suites run the same statements against in-process sqlite, since D1 is sqlite.
12.3 Prometheus Metrics #
Glean exposes a /metrics endpoint for monitoring. Key metrics:
glean_feeds_fetched_total: Total feed fetch attempts (counter)glean_feeds_fetched_last_timestamp_seconds: Unix timestamp of last feed fetch (gauge)glean_feed_fetch_duration_seconds: Histogram of feed fetch latencyglean_articles_upserted_total: Counter of articles stored from feedsglean_jetstream_events_total: Jetstream events labeled by collection and actionglean_jetstream_errors_total: Jetstream handler errorsglean_jetstream_reconnects_total: Jetstream reconnection countglean_http_requests_total: HTTP request counts labeled by method, path, and statusglean_http_request_duration_seconds: HTTP request duration labeled by method and pathglean_pds_sync_runs_total/glean_pds_sync_errors_total: PDS sync runs and errorsglean_cluster_runs_total/glean_cluster_duration_seconds: Recommendation engine runs and timing
12.4 Why a Worker in front of one Container? #
- Cloudflare Containers are reached only through a Worker. The Worker is the public origin, TLS, and asset host; the container never has a public IP.
- The Worker serves the SvelteKit app at the edge, server-rendered. The Go API cannot live at the edge: Jetstream and feed fetching need a long-lived Linux process.
- One named container instance is required because the Jetstream consumer and SQLite writer are process-global.
getRandom/ multi-instance routing would split the index. - Workers cannot keep a websocket open for hours, so Jetstream stays inside the container. The Worker cron only prevents idle sleep.
12.5 AppView Architecture #
Glean operates as an AT Protocol AppView. This means:
- Read path: All
at.glean.*data is consumed from Jetstream, not by polling individual PDS instances. The Jetstream consumer runs as a persistent goroutine, upserting records into D1 as they arrive. - Write path: Users write records to their own PDS (via standard AT Protocol
com.atproto.repo.createRecord/deleteRecord). Glean never stores user data directly; it only indexes what Jetstream delivers. - Query path: Other AT Protocol apps can query Glean's XRPC endpoints to access indexed data (subscriptions, annotations, likes, recommendations) without building their own indexer.
- Trade-off: Article content (fetched from RSS feeds) is local-only and not part of the AT Protocol layer. Only individual feed subscription records (
at.glean.subscription) live on the PDS.
12.6 Privacy Model #
All PDS records are public. There is no notion of private data on the AT Protocol: everything stored on the repo is visible. Users should be aware that annotations, subscriptions, and likes are all public records.
12.7 Cloudflare constraints this design accepts #
- D1 is the store, not the process. Jetstream still needs a long-lived Go container, and containers cannot use Worker bindings. The bridge is an outbound handler: the container POSTs statements to
http://glean.proxy/d1, the platform intercepts that host inside the container's network namespace, and the statements run through the D1 binding on the same machine. No request-count limit (the D1 REST API is capped account-wide at 1,200 requests / 5 min) and no public endpoint. - R2 through the same proxy. Article bodies read and write through
/r2on the same host via the R2 binding. The container holds no S3 credentials and does no request signing. - Vectors are Vectorize, not sqlite-vec. Recs KNN is
Index.Queryvia the cloudflare-go SDK. - Singleton process.
max_instances: 1. Two containers would each consume Jetstream. - Cold start. Cron pokes
/readyso sleepAfter does not drop the websocket.
12.8 Proxy security model #
/d1 and /r2 run as outbound handlers on the virtual host glean.proxy:
the runtime intercepts requests to that host from inside the container's
network namespace and runs the handler next to the bindings, so only the
container can reach them. /internal/tick is reachable only through the
Durable Object binding the Worker cron uses. Bearer tokens still gate every
endpoint as a second layer:
- Each endpoint derives its own token (
db.ProxyToken, SHA-256 ofGLEAN_SESSION_KEY+ purpose), so one leaked token grants only that endpoint's scope. - The tokens reveal nothing about
GLEAN_SESSION_KEYitself (one-way hash), which alone signs session cookies, so a leaked proxy token cannot forge sessions. - Bindings scope the blast radius: one D1 database, one R2 bucket, no
account-level API.
/r2exposes no listing. - Comparison is constant-time on both sides, and the tokens never leave the machine or the logs.
- Rotating
GLEAN_SESSION_KEYrotates every token and invalidates all user sessions in one step.
The precondition is a high-entropy GLEAN_SESSION_KEY (openssl rand -hex 32); a weak key makes every derived token brute-forceable.
13. Configuration #
All GLEAN_* vars are set as Worker vars (wrangler.jsonc, which carries the
deployment values) or Worker secrets; the Worker forwards them to the
container. The Go binary owns the defaults below (main.go).
| Variable | Default | What it does |
|---|---|---|
GLEAN_SESSION_KEY |
(required) | High-entropy secret (openssl rand -hex 32); signs session cookies and derives the /d1, /r2, /tick proxy tokens |
GLEAN_FRONTEND_URL |
(required) | Public origin; the OAuth callback is $GLEAN_FRONTEND_URL/api/auth/callback |
GLEAN_OAUTH_CLIENT_ID |
https://glean.at/api/oauth/client-metadata |
OAuth client-metadata URL; empty runs the localhost dev flow |
GLEAN_JETSTREAM |
wss://jetstream1.eurosky.network |
Jetstream WebSocket URL |
GLEAN_PLC_URL |
https://plc.eurosky.network |
PLC directory URL for DID resolution |
GLEAN_ARCHIVE_WINDOW |
90 |
Days article bodies stay in D1 before the offload pass moves them to R2 (0 disables) |
GLEAN_ARTICLE_RETENTION_DAYS |
30 |
Days article rows live before maintenance deletes them; liked, annotated, and undated articles survive |
GLEAN_COLLECTION_DIR_URL |
(empty) | Collection directory URL; a fresh database bootstraps from it once on boot |
GLEAN_BACKFILL_CONCURRENCY |
5 |
Max concurrent backfill workers |
GLEAN_EMBED_BASE_URL |
(empty) | Embeddings API base URL (recommended) |
GLEAN_EMBED_API_KEY |
(empty) | API key for the embeddings endpoint |
GLEAN_EMBED_MODEL |
text-embedding-3-small |
Embedding model name; must output exactly the Vectorize index dimensions (a mismatch fails upserts loudly) |
GLEAN_LLM_BASE_URL |
(empty) | LLM API base URL for language detection and digest summaries |
GLEAN_LLM_API_KEY |
(empty) | API key for the LLM endpoint |
GLEAN_LLM_MODEL |
gpt-4o-mini |
LLM model name |
GLEAN_CLOUDFLARE_ACCOUNT_ID |
(secret) | Account for Vectorize |
GLEAN_CLOUDFLARE_API_TOKEN |
(secret) | API token with Vectorize permissions |
GLEAN_D1_PROXY_URL |
http://glean.proxy (var) |
Outbound host the container POSTs SQL (/d1) and article bodies (/r2) to; also enables the body archive |
GLEAN_VECTORIZE_ARTICLES |
glean-articles |
Vectorize index for article embeddings |
GLEAN_VECTORIZE_FEEDS |
glean-feeds |
Vectorize index for feed embeddings |
GLEAN_ADDR |
:8080 |
Container listen address (set by the Worker) |
14. Future Considerations #
- Email digest: Periodic email with top articles from subscribed feeds
- Digest personalization: Feed the user's liked topics and reading patterns into the digest prompt for more targeted summaries