Building a feed generator
A feed generator is a service you run that answers one question: given a cursor and a limit, which posts belong in this feed? Bluesky calls your endpoint, you return a list of post URIs, and the app hydrates and renders them. You never serve post content, only an ordering.
This is the most complete thing you can build with this SDK, and the only place that uses all of it at once: the firehose to ingest, atproto_core to decode commits, atproto_client to publish the feed record, and atproto_server plus atproto_identity to authenticate inbound requests.
Tip
There is a complete, working implementation to clone and modify: MarshalX/bluesky-feed-generator. Everything below walks through how it works. Read the protocol-level overview for the parts that are not Python.
The four pieces
Ingest. Subscribe to the firehose, decode commits, keep the posts you care about.
Serve. Answer
app.bsky.feed.getFeedSkeletonwith URIs and a cursor.Identify. Publish a DID document so the network knows who you are, and optionally verify the tokens it sends you.
Publish. Write an
app.bsky.feed.generatorrecord to your own repository so the feed appears in the app.
1. Ingest from the firehose
The firehose delivers signed commits. Each carries a CAR file of blocks, and each operation in the commit names a CID you look up in those blocks to get the record.
from atproto import CAR, AtUri, models
_INTERESTED_RECORDS = {
models.AppBskyFeedLike: models.ids.AppBskyFeedLike,
models.AppBskyFeedPost: models.ids.AppBskyFeedPost,
models.AppBskyGraphFollow: models.ids.AppBskyGraphFollow,
}
def _get_ops_by_type(commit: models.ComAtprotoSyncSubscribeRepos.Commit) -> defaultdict:
operation_by_type = defaultdict(lambda: {'created': [], 'deleted': []})
car = CAR.from_bytes(commit.blocks)
for op in commit.ops:
uri = AtUri.from_str(f'at://{commit.repo}/{op.path}')
if op.action == 'create':
record_raw_data = car.blocks.get(op.cid)
if not record_raw_data:
continue
record = models.get_or_create(record_raw_data, strict=False)
if record is None: # unknown record, outside the bsky lexicons
continue
for record_type, record_nsid in _INTERESTED_RECORDS.items():
if uri.collection == record_nsid and models.is_record_type(record, record_type):
operation_by_type[record_nsid]['created'].append(
{'record': record, 'uri': str(uri), 'cid': str(op.cid), 'author': commit.repo}
)
break
if op.action == 'delete':
operation_by_type[uri.collection]['deleted'].append({'uri': str(uri)})
return operation_by_type
Three SDK details are doing the work here:
- CAR.from_bytes
Decodes the commitās block store.
car.blocksmaps CID to raw record data.- get_or_create with
strict=False Turns raw block data into a model, resolving the class from the recordās
$type.strict=Falsematters: the firehose carries every lexicon on the network, including ones this SDK has never heard of, and strict mode would raise on them. Records you do not know degrade to a DotDict instead of taking your process down.- is_record_type
Narrows the type after the fact. The collection check alone is not enough: a recordās path says what it claims to be, its
$typesays what it is.
See Firehose for the subscription itself, and Working with models for the model machinery.
Tip
If you do not need cryptographic verifiability, Jetstream delivers the same events as plain JSON with server-side filtering and no CAR decoding. It is substantially less code and less CPU. Use the firehose when you need to verify what you received.
Persist the cursor
A feed generator that restarts should not re-ingest from the beginning, and should not skip what it missed. The firehose sequence number is your cursor. Store it periodically and pass it back on reconnect:
def _run(name, operations_callback, stream_stop_event=None):
state = SubscriptionState.get_or_none(SubscriptionState.service == name)
params = None
if state:
params = models.ComAtprotoSyncSubscribeRepos.Params(cursor=state.cursor)
client = FirehoseSubscribeReposClient(params)
def on_message_handler(message: firehose_models.MessageFrame) -> None:
commit = parse_subscribe_repos_message(message)
if not isinstance(commit, models.ComAtprotoSyncSubscribeRepos.Commit):
return
# every ~1k events; more often than this costs more than it saves
if commit.seq % 1000 == 0:
client.update_params(models.ComAtprotoSyncSubscribeRepos.Params(cursor=commit.seq))
SubscriptionState.update(cursor=commit.seq).where(SubscriptionState.service == name).execute()
if not commit.blocks:
return
operations_callback(_get_ops_by_type(commit))
client.start(on_message_handler)
update_params changes what the client reconnects with, so an automatic reconnect resumes from the stored sequence rather than from the live tip.
Warning
Wrap client.start(...) in a loop that catches FirehoseError and reconnects. A stream that runs for weeks will disconnect.
Filter
Everything past this point is your algorithm. The reference implementation keeps posts whose text mentions āpythonā:
for created_post in ops[models.ids.AppBskyFeedPost]['created']:
record = created_post['record']
if 'python' in record.text.lower():
posts_to_create.append({'uri': created_post['uri'], 'cid': created_post['cid']})
Two things worth handling that are easy to forget:
Process deletions. If you only ever insert, your feed will serve URIs for posts that no longer exist.
Consider backdated posts. Accounts importing their history from elsewhere can flood a feed with old content. The reference implementation compares
record.created_atagainst a threshold and optionally drops them.
2. Serve the skeleton
Your service needs three HTTP endpoints. Any framework will do; the reference implementation uses Flask.
GET /xrpc/app.bsky.feed.getFeedSkeletonThe feed itself. Takes
feed,cursorandlimitquery parameters, returns{'cursor': ..., 'feed': [{'post': uri}, ...]}.GET /xrpc/app.bsky.feed.describeFeedGeneratorDeclares which feeds this service hosts.
GET /.well-known/did.jsonYour DID document, if you use a
did:webidentity.
The skeleton response is only URIs. Bluesky hydrates them:
@app.route('/xrpc/app.bsky.feed.getFeedSkeleton', methods=['GET'])
def get_feed_skeleton():
feed = request.args.get('feed', default=None, type=str)
algo = algos.get(feed)
if not algo:
return 'Unsupported algorithm', 400
try:
cursor = request.args.get('cursor', default=None, type=str)
limit = request.args.get('limit', default=20, type=int)
body = algo(cursor, limit)
except ValueError:
return 'Malformed cursor', 400
return jsonify(body)
Your cursor is yours to define, and only has to be opaque and stable. The reference implementation encodes the last postās index time and CID as {timestamp}::{cid}, which makes paging a stable keyset query rather than an offset.
3. Verify inbound requests
If your feed is personalised, you need to know who is asking. Bluesky sends a service auth JWT in the Authorization header, signed by the requesting userās key.
This is where atproto_server and atproto_identity come in:
from atproto import DidInMemoryCache, IdResolver, verify_jwt
from atproto.exceptions import InvalidTokenError
_CACHE = DidInMemoryCache()
_ID_RESOLVER = IdResolver(cache=_CACHE)
def validate_auth(request) -> str:
auth_header = request.headers.get('Authorization')
if not auth_header or not auth_header.startswith('Bearer '):
raise AuthorizationError('Invalid authorization header')
jwt = auth_header[len('Bearer ') :].strip()
try:
# own_did binds the token to this service by checking the "aud" claim
payload = verify_jwt(jwt, _ID_RESOLVER.did.resolve_atproto_key, own_did=config.SERVICE_DID)
except InvalidTokenError as e:
raise AuthorizationError(f'Invalid token: {e}') from e
# the "lxm" claim binds the token to a single XRPC method
nsid = request.path.rsplit('/', 1)[-1]
if getattr(payload, 'lxm', None) != nsid:
raise AuthorizationError(f'Token is not bound to the "{nsid}" method')
return payload.iss
verify_jwt does the cryptography. The second argument is a callable that resolves an issuer DID to its signing key. IdResolver provides exactly that as resolver.did.resolve_atproto_key.
Three checks matter, and only the first is automatic:
- Signature
verify_jwtresolves the issuerās key and verifies against it.- Audience (
aud) Pass
own_did=so a token minted for a different service cannot be replayed against yours. Omitting this is the common mistake.- Method (
lxm) Check it yourself. The claim binds the token to one XRPC method, so a token for
getFeedSkeletoncannot be used against another endpoint.
payload.iss is the requesting userās DID. That is what you personalise on.
Important
Cache DID resolution. Every request would otherwise resolve the issuerās DID document over the network. DidInMemoryCache is enough for a single process; subclass DidBaseCache for something shared. See Identity.
4. Publish the feed record
The feed only appears in the app once there is an app.bsky.feed.generator record in your repository pointing at your service.
from atproto import Client, models
client = Client()
client.login(HANDLE, PASSWORD)
avatar_blob = None
if AVATAR_PATH:
with open(AVATAR_PATH, 'rb') as f:
avatar_blob = client.upload_blob(f.read()).blob
response = client.com.atproto.repo.put_record(
models.ComAtprotoRepoPutRecord.Data(
repo=client.me.did,
collection=models.ids.AppBskyFeedGenerator,
rkey=RECORD_NAME,
record=models.AppBskyFeedGenerator.Record(
did=feed_did, # did:web:<your hostname>, or a did:plc you control
display_name=DISPLAY_NAME,
description=DESCRIPTION,
avatar=avatar_blob,
accepts_interactions=ACCEPTS_INTERACTIONS,
created_at=client.get_current_time_iso(),
),
)
)
print('Feed URI:', response.uri)
put_record rather than create_record, because rkey is a stable name you chose: running it again updates the feedās display data instead of creating a second feed. That returned URI is what your service matches on in getFeedSkeleton.
Deleting that record removes the feed from the network, along with all of its likes.
Note
did:web:<hostname> is the easy identity: it needs no registration, just the /.well-known/did.json endpoint above. It binds your feed to that domain forever, though. If you expect to migrate domains, use a did:plc instead.
Running it
The firehose subscription and the HTTP server are separate concerns. The reference implementation runs the subscription on a background thread inside the Flask process, which is fine for a single worker.
Warning
If you run multiple workers, run the firehose consumer as its own process. Otherwise every worker opens its own subscription and writes the same posts.
Use a production WSGI server rather than Flaskās development one.
Where to go from here
Firehose: the subscription API in full, including labels and error handling.
Jetstream: the cheaper ingest path, and archive backfill for bootstrapping.
Working with models:
get_or_create,is_record_type, andDotDict.Identity: resolvers and caching.
Custom lexicons: if your service exposes an API of its own.