Glossary

How a Bluesky Feed Generator Works, From Lexicon to Live Feed

Taras Shynkarenko
Taras Shynkarenko
Updated: 9 min read
How a Bluesky feed generator works, from lexicon to live feedHow a Bluesky feed generator works, from lexicon to live feed

TL;DR, Quick Answer

9 min read

A Bluesky feed generator is a web service that implements one AT Protocol query, app.bsky.feed.getFeedSkeleton, and returns a list of post AT-URIs with no post content in them. Bluesky's AppView hydrates those URIs into real posts before the client ever sees them. The feed itself is declared by an app.bsky.feed.generator record in the creator's own repo, which requires only did, displayName and createdAt, and points at the service DID. The service proves it owns that DID with a .well-known/did.json carrying a service entry of type BskyFeedGenerator. Requests arrive with a JWT signed by the requesting user's repo signing key, and limit is capped at 100.

What is a Bluesky feed generator?

Every custom feed in the Bluesky app is served by a Bluesky feed generator, a plain HTTPS service that answers one query and returns nothing but a list of post addresses. It holds no post text, no avatars and no like counts. It ranks, and that is all.

The official starter kit puts it plainly: "the server receives a request from a user's server and returns a list of post URIs with some optional metadata attached. Those posts are then hydrated into full views by the requesting server and sent back to the client."

That split is the whole design. Your service decides which posts and in what order. Bluesky's infrastructure turns the addresses into renderable posts.

Which lexicon does a feed generator implement?

Exactly one: app.bsky.feed.getFeedSkeleton. The lexicon describes it as "Get a skeleton of a feed provided by a feed generator. Auth is optional, depending on provider requirements, and provides the DID of the requester. Implemented by Feed Generator Service."

Four lexicons sit around the flow, and knowing which side implements which saves a day of debugging:

LexiconTypeImplemented byPurpose
app.bsky.feed.generatorrecordthe creator's repoDeclares the feed exists and names the service DID
app.bsky.feed.getFeedSkeletonqueryyour feed generatorReturns the ranked list of post URIs
app.bsky.feed.describeFeedGeneratorqueryyour feed generatorLists which feed URIs this service serves
app.bsky.feed.getFeedquerythe Bluesky AppViewReturns the hydrated feed the client actually renders

describeFeedGenerator is explicitly not an AppView method: "Does not require auth; implemented by Feed Generator services (not App View)." Calling it on the AppView returns {"error":"MethodNotImplemented","message":"Method Not Implemented"}, while calling it on Bluesky's own Discover service at discover.bsky.app returns every feed URI that service hosts.

What goes in the app.bsky.feed.generator record?

The record is what makes the feed appear in the app, and it lives in your own repo rather than on your server. The lexicon calls it a "Record declaring of the existence of a feed generator, and containing metadata about it. The record can exist in any repository."

FieldTypeRequiredConstraint
didstring, format: didyesThe DID of the service that serves the feed
displayNamestringyesmaxGraphemes: 24, maxLength: 240
createdAtstring, format: datetimeyes
descriptionstringnomaxGraphemes: 300, maxLength: 3000
descriptionFacetsarraynoapp.bsky.richtext.facet entries
avatarblobnoaccept: image/png, image/jpeg, maxSize: 1000000
acceptsInteractionsbooleannoOpts the feed into app.bsky.feed.sendInteractions
contentModestringnocontentModeUnspecified or contentModeVideo

Three fields are required and none of them is the algorithm. Note the displayName cap of 24 graphemes, short enough that most feed names get trimmed before you notice, and the 1,000,000 byte avatar ceiling. The grapheme versus byte distinction behind those numbers is the same one that trips people up with Bluesky facet byte offsets.

contentMode is the one field that changes rendering. app.bsky.feed.defs#contentModeVideo is a token that "Declares the feed generator returns posts containing app.bsky.embed.video embeds," switching the client into the full screen video player.

The record's rkey becomes the feed's public identifier. Bluesky's own Discover feed is addressed as at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.generator/whats-hot, where the DID belongs to the bsky.app account and whats-hot is the record key.

How does Bluesky find my service?

Through a DID document with a service entry of type BskyFeedGenerator. The record gives Bluesky a DID. Resolving that DID gives Bluesky a URL.

The starter kit sets this up as a did:web, so your own domain serves the document at /.well-known/did.json. Bluesky's production Discover service does the same, and you can fetch it:

{
  "id": "did:web:discover.bsky.app",
  "service": [
    {
      "id": "#bsky_fg",
      "type": "BskyFeedGenerator",
      "serviceEndpoint": "https://discover.bsky.app"
    }
  ]
}

The id of #bsky_fg and the type of BskyFeedGenerator are both fixed strings. Get either wrong and the feed resolves to nothing.

Two hosting constraints come from the README rather than from any spec. "Your feed will need to be accessible at the value supplied to the FEEDGEN_HOSTNAME environment variable," and "The service must be set up to respond to HTTPS queries over port 443." Plain HTTP is not an option, and neither is a non-standard port.

The starter kit also flags a migration trap worth heeding before you pick a domain: you may want did:plc instead "if you expect this Feed Generator to be long-standing and possibly migrating domains." A did:web is your hostname. Change hosts and every published record pointing at it breaks. A did:plc survives the move.

AdaptlyPost
AdaptlyPost

Start 7-Day FREE Trial

All-platform analytics

Social Inbox

AI-powered assistant

A server rack, standing in for the plain HTTPS service that answers a getFeedSkeleton request with a list of post URIs.

What does getFeedSkeleton return?

A cursor and an array of app.bsky.feed.defs#skeletonFeedPost entries, each of which is an AT-URI plus optional context. Here is a real response from Bluesky's Discover service, trimmed to two items:

{
  "cursor": "eyJvIjoiMjAyNi0wOS0xMVQxMDo0OTowNi42NTI4NDk4NzVaIiwibiI6IjIwMjYtMDktMTFUMjI6NDk6MDYuNjUyODQ5ODc1WiIsInNzIjoiMjAyNi0wOS0xMVQyMjo0OTowNi42NTI4NDk4NzVaIiwibHUiOiIyMDI2LTA5LTExVDIyOjQ5OjA2LjY1NjYyMzMwM1oiLCJwZyI6MSwibSI6IkFRQUFBQUFBQUFBSkFBQUFPakFBQUFJQUFBRHNWZ0FBa1ZjQUFCZ0FBQUFhQUFBQUtMTTNrUT09In0=",
  "feed": [
    {
      "feedContext": "th-nature_animals_pets",
      "post": "at://did:plc:mpvivcve3y2fdlw7pmq7niwz/app.bsky.feed.post/3mvb4r2qh4k2r"
    },
    {
      "feedContext": "th-science_research",
      "post": "at://did:plc:fb57h2fswm7s2qqzxflizlk7/app.bsky.feed.post/3mvasir35ec2e"
    }
  ]
}

Only post is required on each entry. feedContext is optional and capped at maxLength: 2000, described in the lexicon as "Context that will be passed through to client and may be passed to feed generator back alongside interactions." Bluesky uses it for topic labels, as the th-nature_animals_pets string above shows. The response may also carry reqId, a "Unique identifier per request," capped at 100 characters.

The limit parameter has a documented floor and ceiling: minimum: 1, maximum: 100, default: 50, and the AppView enforces it. Requesting 101 returns:

{ "error": "InvalidRequest", "message": "Invalid app.bsky.feed.getFeed params: integer too big (maximum 100, got 101)" }

Pagination runs on an opaque cursor your service invents. The starter kit is specific about the shape it wants: "We strongly encourage that the cursor be unique per feed item to prevent unexpected behavior in pagination. We recommend, for instance, a compound cursor with a timestamp + a CID."

How is the request authenticated?

With a JWT signed by the requesting user's repo signing key, and the starter kit spells out both the header and the payload:

const header = {
  type: 'JWT',
  alg: 'ES256K', // (key algorithm) - in this case secp256k1
};
const payload = {
  iss: 'did:example:alice', // (issuer) the requesting user's DID
  aud: 'did:example:feedGenerator', // (audience) the DID of the Feed Generator
  exp: 1683643619, // (expiration) unix timestamp in seconds
};

Verifying it is optional, and the guidance depends on what your feed does: "If you are creating a generic feed that does not differ for different users, you do not need to check auth. But if a user's state (such as follows or likes) is taken into account, we strongly encourage you to validate their auth token." A feed that personalises on follows without checking the iss is a feed anyone can query as anyone.

Bluesky's own Discover service answers unauthenticated getFeedSkeleton requests with a real skeleton, which is a cheap way to test your understanding of the endpoint.

A developer coding at a laptop, the kind of work behind subscribing to the firehose and filtering posts for a feed.

Should the feed check the JWT?
Same feed for everyone
  • No follows, no likes, no per-user state
  • Auth check is optional
Ranks by follows or likes
  • Ranking reads the iss DID
  • Skip verification and anyone can query as anyone
A feed that only ranks by engagement can skip the JWT check; a feed that reads a user's follows or likes cannot.

Where do the posts come from?

From the firehose, in almost every real implementation: "For most use cases, we recommend subscribing to the firehose at com.atproto.sync.subscribeRepos. This websocket will send you every record that is published on the network."

Because a feed generator never serves content, it can throw nearly all of that away. "Unless your algorithm is intended to provide posts you missed or something similar, you can likely garbage collect any data that is older than 48 hours."

The three shapes the starter kit sketches cover most feeds people build. A what's hot feed tallies likes per post and returns recent posts above a threshold. A community feed compiles a list of DIDs and returns everything they post. A topical feed passes post text through a keyword matcher or a model. The firehose sits alongside the other Bluesky API rate limits you will be working within.

What do the docs leave undefined?

Several things, and the gaps are where feed generators break in production rather than in testing.

The declared error does not match the shipped error. Both getFeedSkeleton and getFeed declare exactly one error in their lexicons, UnknownFeed. Ask the AppView for a feed that does not exist and you get {"error":"InvalidRequest","message":"could not find feed"}. Ask Bluesky's own Discover service and you get {"message":"no such feed: \"nope-not-real\""}, with no error field at all. Two shapes in production, neither of them the documented name.

The starter kit is older than the lexicon it describes. Its skeleton metadata section says "for now, the only defined reason is a repost, but this is open to extension." The current skeletonFeedPost has a reason union of two members, #skeletonReasonRepost and #skeletonReasonPin. Build against the README's Reason type and you will not know pinning exists.

AdaptlyPost
AdaptlyPost

Start 7-Day FREE Trial

All-platform analytics

Social Inbox

AI-powered assistant

The hydration story has moved too. The README says "In the future, the PDS will hydrate the feed with the help of an App View, but for now, the PDS handles hydration itself," while the getFeed lexicon now says flatly "Implemented by App View."

No limit is published on the skeleton itself. limit caps a request at 100 items, but the response feed array carries no maxLength, and nothing states what happens if a generator returns more than were asked for. getFeedGenerator returns isOnline and isValid, described as whether the service "has been online recently, or else seems to be inactive" and whether it "is compatible with the record declaration." Neither "recently" nor "compatible" is defined anywhere.

How do I publish the feed?

By writing the record with com.atproto.repo.putRecord into your own repo. The starter kit's publish script does exactly that, with collection set to app.bsky.feed.generator, the rkey set to the short name that becomes the feed's URL, and did set to did:web:$FEEDGEN_HOSTNAME unless you supplied a service DID of your own. Updating the name, description or avatar is the same call with the same rkey. There is no approval queue.

None of this replaces ordinary posting. A feed generator decides what a reader sees; getting your own posts onto the network is still scheduling Bluesky posts through a normal record write, which AdaptlyPost handles alongside the other networks it publishes to, with engagement landing in Bluesky analytics afterwards.

Frequently asked questions

Do I need a server to run a Bluesky feed generator?

Yes. The feed record lives in your repo, but getFeedSkeleton has to be answered by a service you host, over HTTPS on port 443 at the hostname your DID document points to. There is no way to define a custom feed purely inside Bluesky.

Can a feed generator see post content?

Only what it collects itself, usually from the firehose. The skeleton it returns carries AT-URIs and nothing else, and hydration happens on Bluesky's side after your service has answered.

What is the difference between getFeed and getFeedSkeleton?

getFeedSkeleton is yours and returns skeletonFeedPost entries, which are bare URIs. getFeed is the AppView's and returns feedViewPost entries, which are full posts with author profiles and counts. Clients call getFeed. Your service never does.

How do I know if my feed is broken?

Call app.bsky.feed.getFeedGenerator with your feed's AT-URI. It returns isOnline and isValid alongside the feed view. Bluesky does not publish how recently a service must have answered to count as online, so treat a false as a prompt to check your own logs.

Can I charge for a feed or restrict who sees it?

The protocol gives you the hook and no billing. Auth is optional on getFeedSkeleton, and when present the JWT carries the requester's DID in the iss claim, so gating on a list of DIDs is straightforward. Payment, and how a rejected user is told, are left entirely to your service.

How do I measure how a feed is doing?

Bluesky publishes one number about a feed, the likeCount on its generatorView. Everything else about how the feed performs you have to log yourself, from the requests hitting your own service. That shortage is the same one covered in does Bluesky have analytics.

What happens if a client requests more than 100 items from a feed?

The AppView enforces that ceiling itself. Asking getFeed for a limit of 101 returns an InvalidRequest error saying the integer is too big, with a maximum of 100. The limit parameter's minimum is 1, its maximum is 100, and its default is 50, so a feed generator never has to build that rejection on its own end.

Why does the starter kit suggest did:plc over did:web for some feed generators?

A did:web is just your hostname, so changing domains breaks every published record that points at it. A did:plc survives a move, which is why the starter kit flags it for anything meant to be long-standing or likely to migrate.

How should a feed generator's pagination cursor be built?

The lexicon declares cursor as a bare string with no constraints, and the starter kit calls the value opaque and fully the generator's choice. The starter kit recommends a compound cursor, a timestamp paired with a CID, and stresses that it should be unique per feed item to avoid pagination bugs.

What does acceptsInteractions do in the feed generator record?

Setting acceptsInteractions to true opts the feed into app.bsky.feed.sendInteractions, so clients can report back which posts a user acted on. The field is optional and boolean, and nothing in the docs requires it for a feed to work.

Was This Article Helpful?

Let us know what you think!

See us more often in Google

One click marks AdaptlyPost as a preferred source, so our articles sit higher in your Top Stories, AI Mode, and AI Overviews.

Before you go...

AdaptlyPost

AdaptlyPost

Schedule your content across all platforms

Manage all your social media accounts in one place with AdaptlyPost.

All-platform analytics

Social Inbox

AI-powered assistant

Related Glossary Terms

Related Articles