Likes, reposts, follows, mutes, blocks, lists and starter packs. Some of these have client methods; the rest you write as records through the app.bsky.graph namespace.
Keep the URI, or you cannot undo it
like, repost and follow all create a record in your repository, and all return a CreateRecordResponse with uri and cid.
That uri is the like, not the post. It is the only handle on the record you just wrote, and unlike, unrepost and unfollow take it, not the post URI and not the DID of the account you followed. Throw the response away and you have no way to undo the action without going back and finding the record again.
post=client.get_posts([post_uri]).posts[0]like=client.like(uri=post.uri,cid=post.cid)# like.uri is the *like* recordclient.unlike(like.uri)# not post.uri
The un- methods return a boolean.
Note
unlike, unrepost and unfollow are also exposed as delete_like, delete_repost and delete_follow. Same methods, different names.
If you did lose the URI, the like record is still in your repository. List the collection and find the one whose subject.uri matches:
like and repost both take the subjectβs uriandcid. The CID pins the exact version of the record, which is why you need a hydrated post (or a CreateRecordResponse) rather than just a URI.
examples/like_post.py
fromatprotoimportClientdefmain()->None:client=Client()client.login('my-handle','my-password')post=client.send_post(text='Hello World from Python SDK!')print('Post reference:',post)print('Like reference:',client.like(uri=post.uri,cid=post.cid))if__name__=='__main__':main()
examples/unlike_post.py
fromatprotoimportClientdefmain()->None:client=Client()client.login('my-handle','my-password')post=client.send_post('Test like-unlike from Python SDK')print('Post reference:',post)like=client.like(uri=post.uri,cid=post.cid)print('Like reference:',like)# this method return True/False depends on the response. could throw exceptions tooprint(client.unlike(like.uri))if__name__=='__main__':main()
like is not limited to posts. The subject can be any record, so you can like a feed generator or a starter pack the same way.
examples/repost_post.py
fromatprotoimportClientdefmain()->None:client=Client()client.login('my-handle','my-password')post_ref=client.send_post(text='Hello World from Python SDK!')print('Post reference:',post_ref)print('Reposted post reference:',client.repost(uri=post_ref.uri,cid=post_ref.cid))if__name__=='__main__':main()
A repost is a distinct record from a quote post. repost boosts the post as-is; quoting it means sending a new post with an AppBskyEmbedRecord.Main embed, covered in Posting.
Follows
follow takes the DID of the account, not a handle. Resolve the handle first if that is what you have.
Read the graph back with get_follows and get_followers, both of which take an actor (handle or DID) and page with a cursor. See Pagination.
To ask about relationships without walking the whole list, use get_relationships, which compares one actor against up to 30 others in a single call.
Mutes
mute and unmute take an actor (handle or DID) and return a boolean. There is nothing to keep afterwards: a mute is server-side state, not a record, so unmuting takes the same actor you muted.
Mutes are private and only affect what you see. get_mutes lists them.
Blocks
There is no block method on the client. A block is a public record in your repository, so you create and delete it through the app.bsky.graph.block record namespace directly:
fromatprotoimportAtUri,Client,modelsclient=Client()client.login('my-handle','my-password')block=client.app.bsky.graph.block.create(client.me.did,models.AppBskyGraphBlock.Record(subject='did:plc:kvwvcn5iqfooopmyzvb4qzba',created_at=client.get_current_time_iso(),),)# to unblock, delete the recordclient.app.bsky.graph.block.delete(client.me.did,AtUri.from_str(block.uri).rkey)
subject must be a DID. The create call returns the same uri / cid pair the sugar methods return, and delete takes the repository and the record key, which you get by parsing the URI with AtUri.
Warning
Blocks are public. The record sits in your repository where anyone can read it, and it appears on the firehose like any other record. Mute if you want the effect to stay private.
A list is two record types working together. app.bsky.graph.list is the list itself (name, purpose, description) and each member is a separate app.bsky.graph.listitem record pointing at the list URI and a subject DID. Adding someone to a list means creating a listitem; removing them means deleting it.
The purpose decides what the list does, and it is a plain string: 'app.bsky.graph.defs#curatelist' is a curation list you can use as a feed, 'app.bsky.graph.defs#modlist' is a moderation list you can mute or block in bulk, and 'app.bsky.graph.defs#referencelist' is a list that only exists to be pointed at, which is what a starter pack uses.
new_list=client.app.bsky.graph.list.create(client.me.did,models.AppBskyGraphList.Record(name='People I argue with',purpose='app.bsky.graph.defs#curatelist',description='A curation list.',created_at=client.get_current_time_iso(),),)
Note
models.AppBskyGraphDefs.Curatelist, Modlist and Referencelist are typing.Literaltype aliases, for annotating your own code. They are not constants, so do not pass them as the value of purpose.
This example resolves a handle, adds the account to an existing moderation list, reads the list back to confirm, and deletes the listitem again:
examples/advanced_usage/add_user_to_list.py
fromtimeimportsleepfromatproto_clientimportClient,modelsfromatproto_core.uriimportAtUrifromatproto_identity.resolverimportIdResolverdefmain()->None:client=Client()client.login('my-handle','my-password')# https://bsky.app/profile/test.marshal.dev/lists/3k5z5k4k6qw2rmod_list_uri='at://did:plc:kvwvcn5iqfooopmyzvb4qzba/app.bsky.graph.list/3k5z5k4k6qw2r'user_handle_to_add='test.marshal.dev'mod_list_owner=AtUri.from_str(mod_list_uri).hostuser_to_add=IdResolver().handle.resolve(user_handle_to_add)print(f'Adding {user_to_add} to the list {mod_list_uri} (owned by {mod_list_owner})')created_list_item=client.app.bsky.graph.listitem.create(mod_list_owner,models.AppBskyGraphListitem.Record(list=mod_list_uri,subject=user_to_add,created_at=client.get_current_time_iso(),),)print(f'Created list item: CID={created_list_item.cid}; URI={created_list_item.uri}')sleep(3)# sleep for 3 sec because it takes some time to update the list for the backendmod_list=client.app.bsky.graph.get_list(models.AppBskyGraphGetList.Params(list=mod_list_uri))mod_list_users=[item.subject.didforiteminmod_list.items]print(f'List users: {mod_list_users}')assertuser_to_addinmod_list_users,f'User {user_to_add} not found in the list {mod_list_uri}'deleted_success=client.app.bsky.graph.listitem.delete(mod_list_owner,AtUri.from_str(created_list_item.uri).rkey)print(f'Deleted list item: {deleted_success}')if__name__=='__main__':main()
Two things in there are worth calling out. The repository you write the listitem to is the list ownerβs DID, parsed out of the list URI, not necessarily your own. And the sleep(3) is real: the AppView indexes the write asynchronously, so get_list can still return the old membership immediately after a successful create.
The lists you have muted. get_list_blocks does the same for the ones you have blocked.
Blocking a whole list is itself a record, app.bsky.graph.listblock, created through client.app.bsky.graph.listblock.create.
Starter packs
A starter pack is an app.bsky.graph.starterpack record: a name, a list AT-URI pointing at the list of people it contains, and up to three feeds. Create the list first, then the pack that references it.
pack=client.app.bsky.graph.starterpack.create(client.me.did,models.AppBskyGraphStarterpack.Record(name='Python devs on atproto',list=new_list.uri,description='People building on the protocol in Python.',created_at=client.get_current_time_iso(),),)
Your profile is a record too: app.bsky.actor.profile, always at rkey='self'. There is no update_profile method: you read the current record, change the fields you care about, and put it back.
examples/advanced_usage/update_profile.py
importosfromatprotoimportClient,modelsfromatproto.exceptionsimportBadRequestErrordefmain()->None:client=Client()client.login(os.environ['USERNAME'],os.environ['PASSWORD'])try:current_profile_record=client.app.bsky.actor.profile.get(client.me.did,'self')current_profile=current_profile_record.valueswap_record_cid=current_profile_record.cidexceptBadRequestError:current_profile=swap_record_cid=Noneold_description=old_display_name=Noneifcurrent_profile:old_description=current_profile.descriptionold_display_name=current_profile.display_name# set new values to updatenew_description=Nonenew_display_name=Noneclient.com.atproto.repo.put_record(models.ComAtprotoRepoPutRecord.Data(collection=models.ids.AppBskyActorProfile,repo=client.me.did,rkey='self',swap_record=swap_record_cid,record=models.AppBskyActorProfile.Record(avatar=current_profile.avatar,# keep old avatar. to set a new one, you should upload blob firstbanner=current_profile.banner,# keep old banner. to set a new one, you should upload blob firstdescription=new_descriptionorold_description,display_name=new_display_nameorold_display_name,),))if__name__=='__main__':main()
The swap_record argument carries the CID you read, which makes the write fail rather than clobber a concurrent change. Note that the example keeps avatar and banner by copying them across, because a put_record replaces the whole record and any field you omit is gone. New avatars and banners are blobs; upload them first, as in Posting.
See also
Records and repos: the create / delete machinery the blocks, lists and starter packs on this page are built on.
Posting: quote posts, which are a different thing from reposts.
Reading: get_likes and get_reposted_by on the other side of these records.
Notifications: finding out when someone likes or follows you.
Social graph
Likes, reposts, follows, mutes, blocks, lists and starter packs. Some of these have client methods; the rest you write as records through the
app.bsky.graphnamespace.Keep the URI, or you cannot undo it
like, repost and follow all create a record in your repository, and all return a
CreateRecordResponsewithuriandcid.That
uriis the like, not the post. It is the only handle on the record you just wrote, and unlike, unrepost and unfollow take it, not the post URI and not the DID of the account you followed. Throw the response away and you have no way to undo the action without going back and finding the record again.The un- methods return a boolean.
Note
unlike,unrepostandunfolloware also exposed asdelete_like,delete_repostanddelete_follow. Same methods, different names.If you did lose the URI, the like record is still in your repository. List the collection and find the one whose
subject.urimatches:Likes and reposts
likeandrepostboth take the subjectβsuriandcid. The CID pins the exact version of the record, which is why you need a hydrated post (or aCreateRecordResponse) rather than just a URI.likeis not limited to posts. The subject can be any record, so you can like a feed generator or a starter pack the same way.A repost is a distinct record from a quote post.
repostboosts the post as-is; quoting it means sending a new post with anAppBskyEmbedRecord.Mainembed, covered in Posting.Follows
follow takes the DID of the account, not a handle. Resolve the handle first if that is what you have.
Read the graph back with get_follows and get_followers, both of which take an
actor(handle or DID) and page with a cursor. See Pagination.To ask about relationships without walking the whole list, use get_relationships, which compares one actor against up to 30 others in a single call.
Mutes
mute and unmute take an actor (handle or DID) and return a boolean. There is nothing to keep afterwards: a mute is server-side state, not a record, so unmuting takes the same actor you muted.
Mutes are private and only affect what you see. get_mutes lists them.
Blocks
There is no
blockmethod on the client. A block is a public record in your repository, so you create and delete it through theapp.bsky.graph.blockrecord namespace directly:subjectmust be a DID. Thecreatecall returns the sameuri/cidpair the sugar methods return, anddeletetakes the repository and the record key, which you get by parsing the URI with AtUri.Warning
Blocks are public. The record sits in your repository where anyone can read it, and it appears on the firehose like any other record. Mute if you want the effect to stay private.
get_blocks lists the accounts you block.
Lists
A list is two record types working together.
app.bsky.graph.listis the list itself (name, purpose, description) and each member is a separateapp.bsky.graph.listitemrecord pointing at the list URI and a subject DID. Adding someone to a list means creating a listitem; removing them means deleting it.The
purposedecides what the list does, and it is a plain string:'app.bsky.graph.defs#curatelist'is a curation list you can use as a feed,'app.bsky.graph.defs#modlist'is a moderation list you can mute or block in bulk, and'app.bsky.graph.defs#referencelist'is a list that only exists to be pointed at, which is what a starter pack uses.Note
models.AppBskyGraphDefs.Curatelist,ModlistandReferencelistaretyping.Literaltype aliases, for annotating your own code. They are not constants, so do not pass them as the value ofpurpose.This example resolves a handle, adds the account to an existing moderation list, reads the list back to confirm, and deletes the listitem again:
Two things in there are worth calling out. The repository you write the listitem to is the list ownerβs DID, parsed out of the list URI, not necessarily your own. And the
sleep(3)is real: the AppView indexes the write asynchronously, so get_list can still return the old membership immediately after a successful create.The read side:
One list, hydrated, plus its members under
.items. Pages with a cursor.Every list an actor created.
purposesfilters to'modlist'or'curatelist'.Mute everyone on a moderation list, by list URI. unmute_actor_list reverses it.
The lists you have muted. get_list_blocks does the same for the ones you have blocked.
Blocking a whole list is itself a record,
app.bsky.graph.listblock, created throughclient.app.bsky.graph.listblock.create.Starter packs
A starter pack is an
app.bsky.graph.starterpackrecord: a name, alistAT-URI pointing at the list of people it contains, and up to three feeds. Create the list first, then the pack that references it.Read them with get_starter_pack (one, by AT-URI), get_starter_packs (several) and get_actor_starter_packs (everything one account made).
Your own profile
Your profile is a record too:
app.bsky.actor.profile, always atrkey='self'. There is noupdate_profilemethod: you read the current record, change the fields you care about, and put it back.The
swap_recordargument carries the CID you read, which makes the write fail rather than clobber a concurrent change. Note that the example keepsavatarandbannerby copying them across, because aput_recordreplaces the whole record and any field you omit is gone. New avatars and banners are blobs; upload them first, as in Posting.See also
Records and repos: the
create/deletemachinery the blocks, lists and starter packs on this page are built on.Posting: quote posts, which are a different thing from reposts.
Reading:
get_likesandget_reposted_byon the other side of these records.Notifications: finding out when someone likes or follows you.