RetroFed Forum
Your home away from modern life.
How To Build Our Own Bluesky Custom Feed
Post Reply
AuthorMessage
♠️ Critical Nexus ✊🏿 reddthat.com PumpkinDrama@reddthat.com
Joined: 2023-09-23
Quote

How To Build Our Own Bluesky Custom Feed

A technical walkthrough of running a real, programmatic Bluesky feed generator from scratch — the same setup that serves thousands of scored posts to a curated community.


What This Is

Bluesky lets third parties publish custom feeds. A feed generator is just a web service that answers a few HTTP endpoints and returns ranked lists of post URIs. The ranking logic is entirely up to you.

This is the story of how we built ours: a stdlib-only Python service, running on a home server behind a Cloudflare Tunnel, serving 8 different engagement-ranked feeds from a curated list of accounts.

If you want to build something similar — for your own community, your own interests, your own rules — this is the guide.


Why Not Skyfeed?

Skyfeed is a no-code web tool for building feeds. It's fine for simple rules (posts containing X, from author Y). But it can't score posts by engagement, filter by repost ratios, rank by velocity, or maintain state across refreshes.

If you want a feed that understands "popular" the way a human curator does — weighting likes, reposts, quotes, replies, age, and the ratio between them — you need to write code.


Architecture at a Glance

Bluesky App → https://your-feed.com/xrpc/app.bsky.feed.getFeedSkeleton
                     ↓
             Cloudflare Tunnel (HTTPS, no open ports)
                     ↓
             Your server: Python feed generator on port 8004
                     ↓
             Periodically fetches posts from Bluesky API
                     ↓
             Scores, filters, dedups, caches → serves on request

The service exposes four endpoints:

  • GET /.well-known/did.json — tells Bluesky who the feed is
  • GET /xrpc/app.bsky.feed.describeFeedGenerator — lists available feeds
  • GET /xrpc/app.bsky.feed.getFeedSkeleton — returns ranked post URIs
  • GET / — human-readable status page

Everything else is scoring logic.


Prerequisites

  • A Bluesky account and an app password (create one in Settings → Privacy & Security → App Passwords)
  • A domain you control (for the public HTTPS URL)
  • A server that can stay on — a VPS, a home machine, a Raspberry Pi
  • Cloudflare Tunnel (or any way to expose HTTPS without opening ports)

Step 1: The Feed Generator Core

The entire service is a single Python file using only the standard library. No frameworks, no databases, no dependencies.

The main loop:

  1. Refresh on a timer (every 10 minutes)
  2. Fetch recent posts from all accounts in your source list
  3. Score each post by engagement (likes × 1 + reposts × 3 + quotes × 2 + replies × 0.5)
  4. Filter by your rules (age window, minimum likes, repost ratio)
  5. Dedup — skip posts already served in this feed
  6. Cache the ranked results
  7. Serve on HTTP requests

Scoring

Each feed can have its own scoring weights:

score = (likes * w_likes
       + reposts * w_reposts
       + quotes * w_quotes
       + replies * w_replies)

For a "trending" feed, we divide by age in hours to surface fast-rising posts:

if ranking == "trending" and age_h > 0:
    score = score / age_h

Filtering

Each feed defines its own thresholds:

{
    "rkey": "leftist-24h",
    "max_age_hours": 24,     # only posts from last 24h
    "min_likes": 10,          # at least 10 likes
    "min_reposts_share": 0.33, # at least 1/3 of likers also reposted
    "max_per_author": 3,      # diversity cap
    "max_posts": 100
}

The repost-share rule is the key differentiator: it ensures posts are not just liked but actively shared, which dramatically improves feed quality.

Dedup State

A JSON file (feedgen-state.json) tracks which posts have already been served in each feed. On each refresh, already-seen posts are skipped. This means a post appears once and never clutters the feed again.

The state file is written next to the script and persists across restarts.


Step 2: Configuration

All settings live in a separate feedgen.json:

{
  "hostname": "your-feed.com",
  "publisher_did": "did:plc:...",
  "port": 8004,
  "refresh_secs": 600,
  "source": {
    "list_uri": "at://did:plc:.../app.bsky.graph.list/your-list",
    "pds_host": "https://bsky.social",
    "appview_host": "https://public.api.bsky.app",
    "member_page_limit": 100,
    "author_feed_limit": 20,
    "max_workers": 10
  },
  "scoring": {"w_likes": 1.0, "w_reposts": 3.0, "w_quotes": 2.0, "w_replies": 0.5},
  "owner": {"did": "did:plc:...", "handle": "you.bsky.social", "always_include": true},
  "feeds": [...]
}

Secrets (handle + app password) go in .env, never in the config.


Step 3: Running as a Service

A systemd user unit keeps it alive:

[Unit]
Description=Bluesky feed generator
After=network-online.target

[Service]
WorkingDirectory=/home/you/feedgen
ExecStart=/usr/bin/python3 /home/you/feedgen/feedgen.py
Restart=always
RestartSec=10

[Install]
WantedBy=default.target

The WorkingDirectory matters — the state file is written relative to it.


Step 4: Exposing to Bluesky

Bluesky needs to reach your feed over HTTPS. Cloudflare Tunnel is the easiest path:

  1. Install cloudflared on your server
  2. In the Zero Trust dashboard, add a public hostname pointing to http://localhost:8004
  3. Your feed is now at https://your-feed.com

No ports opened, no SSL certificates to manage.


Step 5: Publishing

Once the endpoints work publicly, you register the feed with Bluesky using com.atproto.repo.createRecord (collection app.bsky.feed.generator). After that, it appears in your Bluesky app under "My Feeds".


What We Learned

The repost-share ratio is everything

A post with 100 likes and 2 reposts is not "popular" — it's agreeable. A post with 50 likes and 20 reposts is being actively shared. The ratio between them is the signal that separates noise from signal.

We require reposts ≥ 1/3 of likes. This is strict — it means our 4h feed might only have 15-20 posts on a quiet day. That's intentional. The longer windows (week, month, 4 months) fill up because the threshold has more data to work with.

Dedup is feed-level, not user-level

Bluesky's getFeedSkeleton doesn't tell us which user is requesting. So we can't do per-user "hide after I liked this" — only per-feed "hide after this appeared". This is a platform limitation, not ours.

For a single-curator feed (where everyone wants the same view), this works fine. For true per-user dedup, Bluesky would need to pass the requesting user's identity to the feed generator.

State persistence is critical

Without a state file, every restart would re-serve the same top posts. The JSON state file (one array of URIs per feed) is simple, inspectable, and survives restarts. Ours is ~90KB for 1200 tracked posts.

Member enumeration is the bottleneck

We fetch 700+ members, then for each one fetch their recent posts. This is ~700 API calls per refresh. We use a thread pool (10 workers) to parallelize, but it still takes ~60 seconds. The Bluesky AppView API handles it fine at our scale (every 10 minutes), but if your list has thousands of members, you'll need to be smarter about which accounts to scan.


The Code

The entire feed generator is ~360 lines of Python. No dependencies. No database. No framework.

You can read the full source at [link to your repo].


Adapting This for Your Own Feed

The pattern generalizes:

  1. Pick a source — a Bluesky list, your follows, a set of hashtags
  2. Define scoring — what makes a post "good" for your community?
  3. Set thresholds — how good is good enough?
  4. Add time windows — one source can power many feeds (4h, 24h, week, month...)
  5. Dedup — don't show the same post twice
  6. Serve — expose the endpoints, tunnel, publish

The scoring function is where your feed's personality lives. Spend your time there.

3   Original
Post Reply
Back to top