# ATProto Feed Generator: building a custom Bluesky feed algorithm

> bluesky-social/feed-generator is a TypeScript starter kit that handles the AT Protocol boilerplate for a custom feed, leaving the indexing and ranking logic for you to implement. It ships with SQLite, an Express server on port 3000 and one example algorithm, and deploying to Bluesky requires a public HTTPS hostname and a registered Bluesky account.

**bluesky-social/feed-generator** — ATProto Feed Generator Starter Kit

- Repository: https://github.com/bluesky-social/feed-generator
- Stars: 2,069 · Forks: 713
- Language: TypeScript
- License: MIT
- Published: 2026-10-09 · Updated: 2026-10-09 · Language: en
- Canonical page: https://hysenlabs.com/projects/bluesky-social-feed-generator

## What a feed generator does inside the AT Protocol request chain

A Feed Generator service sits between a user's Personal Data Server and the content they want to see. When a user subscribes to a custom feed, their PDS sends a getFeedSkeleton request to the service endpoint listed in the feed generator's DID document. The generator returns a skeleton, which is a list of post URIs with an optional reason field on each, and the PDS then fetches the full post content before handing it back to the client.

This separation has one important implication for the developer: the generator never stores post bodies. It stores post URIs or whatever signals your algorithm needs, and the PDS handles content lookup on its own. Your server processes a lightweight list, not a stream of full posts.

Each Feed Generator service is identified by a DID, while each algorithm it hosts gets its own at-uri tied to a declaration record in the publishing account's repository. A single service can host multiple algorithms under one DID, and Bluesky's own feeds work this way with each declared under @bsky.app's account. Each algorithm's identity comes from the at-uri of its declaration record in the publishing account's repository, not from the service DID.

## src/subscription.ts indexes events; src/algos answers skeleton requests

The kit draws a clear line between the boilerplate it provides and the logic you supply, and two locations hold all your code.

Start with src/subscription.ts, which subscribes to the repo subscription stream on startup and receives events from the Bluesky network at wss://bsky.network (the default value of FEEDGEN_SUBSCRIPTION_ENDPOINT). You parse those events and index whatever signals your algorithm needs. What you store, and in what table, is your decision.

src/algos is where you define the algorithm that answers a getFeedSkeleton request, and whatever you write must return an array satisfying the SkeletonFeedPost[] type. A minimal example called whats-alf ships with the kit: it returns all posts mentioning the TV show ALF. Its purpose is to show the structure, not to serve as a template worth shipping.

By design, the starter kit stops short of a production-ready implementation. It hands you the wiring and leaves the substance to you, because the data model, the indexing criteria and the ranking logic are entirely yours to write.

## Installing the TypeScript server and running it against the Bluesky firehose

Yarn 1 and Node 18 or newer are both required. The engines field in package.json states this explicitly. Install dependencies with:

```bash
yarn
```

Copy .env.example to .env and edit the values. Three variables matter most before a first run: FEEDGEN_HOSTNAME (the hostname where the service will be reachable), FEEDGEN_PUBLISHER_DID (the DID of the Bluesky account that will publish the feed), and FEEDGEN_SQLITE_LOCATION. Its default value is :memory:, which means the database resets every time the server stops.

Start the server with:

```bash
yarn start
```

Port 3000 is the default, overridden by FEEDGEN_PORT. Firehose output appears in the console immediately.

For a container deployment, the Dockerfile starts from node:20 and runs yarn install before setting yarn start as the command. Build and run it with:

```bash
docker build -t feed-generator . && docker run feed-generator
```

## did:web ties the service to a hostname; did:plc lets the service migrate later

By default the starter kit uses did:web, which ties the service's DID document to FEEDGEN_HOSTNAME. That works fine for testing, but the complication arrives when you need to move the feed to a different domain: did:web is bound to the hostname, so a domain change means a new DID and a new feed identity.

did:plc avoids this problem, and the README explicitly recommends it for feeds expected to run long-term or to change domains. Setting FEEDGEN_SERVICE_DID in .env to a did:plc value instead of leaving it unset makes the switch.

Production also requires the service to answer HTTPS queries on port 443, which means a feed generator running only locally or behind a plain HTTP proxy cannot be published to Bluesky until the hostname is publicly reachable with a valid certificate.

A go/ directory appears in the top-level repository alongside the TypeScript source, but the README documents only the TypeScript implementation. What the Go code contains and whether it is maintained is not described in the project documentation.

## JWT authentication is only required when the feed varies by user

Feed generators that return the same set of posts for every user do not need to check authentication at all. For a generic feed that returns the same posts to everyone, checking who is asking adds no value, and the implementation cost is not worth it.

When the feed does vary by user (to filter posts from accounts the user follows, for instance), an incoming request carries a JWT signed by the user's repo signing key. Its algorithm is ES256K, meaning the underlying key is secp256k1. Each JWT header names the algorithm, and each payload carries iss (the user's DID), aud (the feed generator's DID), and exp (a Unix timestamp in seconds).

Utilities for verifying these tokens live in @atproto/xrpc-server (version ^0.7.9 in this kit), and an example of them in use appears in src/auth.ts. A developer adding per-user logic can start from that file rather than building JWT verification from scratch.

For feeds that skip auth validation, an incoming request still arrives with an auth field from the PDS, and ignoring it does not affect the skeleton response.

## Cursor format is the feed generator's choice, but uniqueness prevents duplicate posts

Pagination in the AT Protocol works through a cursor that the feed generator defines. This cursor is passed from the PDS back to the generator on each subsequent request without modification. Defining a format that works reliably is the generator's responsibility.

Because the PDS treats it as opaque, the generator has complete freedom over format. Keeping each cursor value unique per feed item prevents unexpected pagination behavior, and the project documentation gives a concrete example combining a millisecond timestamp with a CID: 1683654690921::bafyreia3tbsfxe3cc75xrxyyn6qc42oupi73fxiox76prlyi5bpx7hr72u.

A cursor tied only to a timestamp can cause duplicate posts or skipped posts at page boundaries when multiple posts share the same millisecond. Compound cursors that include a CID avoid this entirely, because each value is tied to the specific item it marks rather than just the time it appeared. Per the README, uniqueness must apply per feed item, not merely per page.

## SQLite is in-memory by default, and swapping the database is undocumented

The kit uses better-sqlite3 (version ^11.3.0) as the database driver and kysely (version ^0.27.4) as the query builder. Both are listed as production dependencies in package.json, so they ship in any built image.

The default in .env.example sets FEEDGEN_SQLITE_LOCATION=":memory:". With this setting, the entire indexed dataset lives in process memory, so every restart clears all posts that the subscription stream had previously indexed. For local development this is acceptable: the subscription stream resumes automatically after FEEDGEN_SUBSCRIPTION_RECONNECT_DELAY (3000 milliseconds by default). For production, change the location to a file path such as db.sqlite.

The README invites developers to switch to any other database but gives no guidance on how to do that. Replacing the database means replacing the schema setup, the queries and the kysely dialect. Nothing in the repository documents this process. This gap is the most significant difference between what the starter kit provides and what a production feed actually requires.

## yarn publishFeed registers the feed and the same script updates its metadata

Once the service is running at a reachable HTTPS hostname, publishing it to Bluesky means filling in the variables at the top of scripts/publishFeedGen.ts and running:

```bash
yarn publishFeed
```

FEEDGEN_PUBLISHER_DID must point to the DID of the Bluesky account that will own the feed declaration, while some of the other variables at the top of the script are optional. After a successful run, the feed appears in the Bluesky app and can be shared by embedding a link in a post, similar to a quote post.

Updating the feed's display data follows the same path: change the name, avatar or description in the variables and run yarn publishFeed again, because no separate update command exists.

The repository has no GitHub releases and no tagged version to pin, and the last push was on 2026-09-25. Anyone running this in production should record which commit they deployed, because no version identifier appears in the repository to reference in a bug report.

## Conclusion

This starter kit suits a developer who has a Bluesky account, a public HTTPS hostname and a specific algorithmic idea to implement. It is not suitable for someone who wants a ready-made feed algorithm, production-grade infrastructure or documented scaling guidance. Before deploying, change FEEDGEN_SQLITE_LOCATION from :memory: to a file path, confirm the service answers HTTPS requests on port 443, and decide between did:web and did:plc before publishing: did:web locks the feed identity to the hostname, and changing domains later means starting over with a new DID.

## FAQ

### What is the ATProto Feed Generator starter kit?

It is a TypeScript starter kit from the bluesky-social organization that provides the boilerplate for a custom feed algorithm on the AT Protocol. It ships an Express server, SQLite via better-sqlite3, and a subscription client for the Bluesky firehose, but the indexing and ranking logic are left for the developer to write.

### What Node.js version does feed-generator require?

The engines field in package.json requires Node 18 or newer. The Dockerfile uses node:20 as its base image.

### What happens when FEEDGEN_SQLITE_LOCATION is set to :memory:?

All indexed data lives in process memory and is lost when the server restarts. Setting it to a file path such as db.sqlite stores the database on disk and makes indexed data survive restarts.

### Can I use Docker to deploy feed-generator?

Yes. The Dockerfile at the repository root uses node:20, runs yarn install, and sets yarn start as the command. Build and run it with docker build -t feed-generator . followed by docker run feed-generator.

### How do I publish my feed generator to Bluesky?

Fill in the variables at the top of scripts/publishFeedGen.ts, including FEEDGEN_PUBLISHER_DID, then run yarn publishFeed. After a successful run the feed appears in the Bluesky app and can be shared via a post link.

## Sources

- [bluesky-social/feed-generator on GitHub](https://github.com/bluesky-social/feed-generator)
- [Issues](https://github.com/bluesky-social/feed-generator/issues)
- [License: MIT](https://github.com/bluesky-social/feed-generator/blob/main/LICENSE)
- [README](https://github.com/bluesky-social/feed-generator/blob/main/README.md)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/bluesky-social-feed-generator
