TL;DR, Quick Answer
9 min readA 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:
| Lexicon | Type | Implemented by | Purpose |
|---|---|---|---|
app.bsky.feed.generator | record | the creator's repo | Declares the feed exists and names the service DID |
app.bsky.feed.getFeedSkeleton | query | your feed generator | Returns the ranked list of post URIs |
app.bsky.feed.describeFeedGenerator | query | your feed generator | Lists which feed URIs this service serves |
app.bsky.feed.getFeed | query | the Bluesky AppView | Returns 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."
| Field | Type | Required | Constraint |
|---|---|---|---|
did | string, format: did | yes | The DID of the service that serves the feed |
displayName | string | yes | maxGraphemes: 24, maxLength: 240 |
createdAt | string, format: datetime | yes | |
description | string | no | maxGraphemes: 300, maxLength: 3000 |
descriptionFacets | array | no | app.bsky.richtext.facet entries |
avatar | blob | no | accept: image/png, image/jpeg, maxSize: 1000000 |
acceptsInteractions | boolean | no | Opts the feed into app.bsky.feed.sendInteractions |
contentMode | string | no | contentModeUnspecified 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
Start 7-Day FREE Trial
All-platform analytics
Social Inbox
AI-powered assistant

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.

- No follows, no likes, no per-user state
- Auth check is optional
- Ranking reads the iss DID
- Skip verification and anyone can query as anyone
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
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.
Put this into practice with AdaptlyPost
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
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


Why Bluesky Facets byteStart byteEnd Count Bytes, Not Characters
Bluesky facets byteStart byteEnd offsets count UTF-8 bytes, not JavaScript UTF-16 indexes. The lexicon warning, a worked example, and code that gets it right.


Why the Bluesky Image Size Limit Is 2,000,000 Bytes
The Bluesky image size limit is 2,000,000 bytes per post image, set by maxSize in the images lexicon. Avatars and banners stop at 1,000,000 bytes.


Converting the Bluesky API Rate Limit Into Posts Per Hour
The Bluesky API rate limit on writes is a points budget, not a request count: 5,000 points an hour, 3 per post, so 1,666 posts an hour.
Related Articles


The Two Records Behind a Bluesky Custom Domain Handle
Your Bluesky custom domain handle needs one of two records: a TXT on _atproto, or plain text at /.well-known/atproto-did. Both values, plus the reserved TLDs.


Meta Sets the Instagram API alt_text Character Limit at 1,000
Meta caps the Instagram API alt_text character limit at 1,000 and scopes the field to still images. Reels and stories accept no alt text at all.


The TikTok AI Generated Label Is Two Different Labels
The TikTok AI generated label comes two ways: a creator label you apply with is_aigc, and an auto label from AI effects or C2PA that you cannot remove.

