Records and repositories
The high-level client (send_post, like, get_profile) is sugar. Each of those methods builds a model and calls an XRPC method underneath. It covers the common Bluesky cases and nothing else, so as soon as you touch a lexicon it does not know about, you drop a level.
There are three levels, and one script can mix them freely:
High-level methods on Client.
Namespaces: the generated, one-to-one mapping of every lexicon the SDK ships.
invoke_query and invoke_procedure, which take a raw NSID string. See HTTP and transport.
Level 2 is the real API. This page is about it.
Namespaces
Namespaces are classes that group sub-namespaces, queries, and procedures, following the NSID structure of the lexicons. ClientRaw, which Client inherits from, attaches seven roots:
appApplication lexicons: what an app exposes to the people using it.
app.bskyis the Bluesky microblogging app: profiles, posts, feeds, and the social graph.chatPrivate messaging.
chat.bskyis Bluesky direct messages, which need a proxied client. See Proxies and labelers.comThe protocol itself under
com.atproto(identities, repositories, servers, sync), plus the lexicons of third-party services such ascom.germnetwork.internalInternal Bluesky endpoints that are not part of the public API and may change without notice.
networkInfrastructure services behind the network. Currently
network.bsky.jetstream.sitesite.standard, the vocabulary for websites published on atproto.toolstools.ozone, the moderation tooling of the network.
Navigate down to the method you want. The leaf is always the query or procedure:
from atproto import Client
client = Client()
client.com.atproto.server.create_session(...)
client.com.atproto.sync.get_blob(...)
client.app.bsky.feed.get_likes(...)
client.app.bsky.graph.get_follows(...)
client.tools.ozone.moderation.get_event(...)
Procedures take a data argument; queries take params, or nothing when the lexicon defines no parameters. Both are covered in Working with models.
Tip
The SDK is fully type hinted. Autocompletion from the dot is the fastest way to find a method, and the method’s own type hints tell you which model to pass.
Record sugar
Some sub-namespaces carry a record instead of a method group. Records are not defined in the lexicon as endpoints. The SDK generates them as a convenience layer over com.atproto.repo.* for every collection the lexicons declare a record type for:
client.app.bsky.feed.post
client.app.bsky.feed.like
client.app.bsky.graph.follow
client.app.bsky.graph.block
client.app.bsky.actor.profile
client.site.standard.document
# ... and one for every other record type
Each of them exposes four methods, with the collection NSID already filled in:
- create
create(repo, record, rkey=None, swap_commit=None, validate=True, **kwargs). Wrapscom.atproto.repo.createRecord. Returns aCreateRecordResponsewithuriandcid.- get
get(repo, rkey, cid=None, **kwargs). Wrapscom.atproto.repo.getRecord. Returns aGetRecordResponsewithuri,cid, and a typedvalue.- list
list(repo, cursor=None, limit=None, reverse=None, **kwargs). Wrapscom.atproto.repo.listRecords. Returns aListRecordsResponsewhoserecordsis a dict of AT-URI to record, plus acursor.- delete
delete(repo, rkey, swap_commit=None, swap_record=None, **kwargs). Wrapscom.atproto.repo.deleteRecord. Returns abool.
swap_commit and swap_record are compare-and-swap guards: pass a CID and the write fails unless the repo (or the record) is still at that CID. rkey on create lets you pick the record key instead of letting the server mint a TID.
Note
validate defaults to True on create, which asks the server to require Lexicon validation. The protocol’s own default is to validate only for known lexicons. Pass validate=False when you are writing a record whose lexicon the PDS does not have.
from atproto import AtUri, Client, models
client = Client()
client.login('my-handle.bsky.social', 'my-password')
posts = client.app.bsky.feed.post.list(client.me.did, limit=10)
for uri, post in posts.records.items():
print(uri, post.text)
post = client.app.bsky.feed.post.get(client.me.did, AtUri.from_str(uri).rkey)
print(post.value.text)
record = models.AppBskyFeedPost.Record(text='Hello', created_at=client.get_current_time_iso())
new_post = client.app.bsky.feed.post.create(client.me.did, record)
print(new_post.uri, new_post.cid)
client.app.bsky.feed.post.delete(client.me.did, AtUri.from_str(new_post.uri).rkey)
What the sugar does not cover
The sugar is create, read, list, delete. Everything else on the repository is a plain namespace call on com.atproto.repo:
- put_record
Updating a record. There is no
updatein the sugar.putRecordwrites a record at a knownrkey, creating it if it does not exist.- apply_writes
Several creates, updates, and deletes committed atomically.
- describe_repo
The handle, DID document, and the list of collections a repo actually holds.
- upload_blob and list_missing_blobs
Blobs are uploaded separately and referenced from a record.
Updating a post’s text, for example:
from atproto import AtUri, Client, models
client = Client()
client.login('my-handle.bsky.social', 'my-password')
uri = AtUri.from_str('at://did:plc:.../app.bsky.feed.post/3k...')
current = client.app.bsky.feed.post.get(uri.hostname, uri.rkey)
updated = current.value
updated.text = 'Edited text'
client.com.atproto.repo.put_record(
models.ComAtprotoRepoPutRecord.Data(
repo=uri.hostname,
collection=uri.collection,
rkey=uri.rkey,
record=updated,
swap_record=current.cid, # fail if someone else wrote first
)
)
Three equivalent ways to post an image
The same write, at each of the three levels:
from atproto import Client, models
client = Client()
client.login('my-handle.bsky.social', 'my-password')
with open('cat.jpg', 'rb') as f:
img_data = f.read()
upload = client.upload_blob(img_data)
images = [models.AppBskyEmbedImages.Image(alt='Img alt', image=upload.blob)]
embed = models.AppBskyEmbedImages.Main(images=images)
# 1. low-level: com.atproto.repo.createRecord, collection spelled out
client.com.atproto.repo.create_record(
models.ComAtprotoRepoCreateRecord.Data(
repo=client.me.did,
collection=models.ids.AppBskyFeedPost,
record=models.AppBskyFeedPost.Record(
created_at=client.get_current_time_iso(), text='Text of the post', embed=embed
),
)
)
# 2. record sugar: the collection is implied by the namespace path
post = models.AppBskyFeedPost.Record(
text='Text of the post',
embed=embed,
created_at=client.get_current_time_iso(),
)
client.app.bsky.feed.post.create(client.me.did, post)
# 3. high-level client: the blob upload and the embed are built for you
client.send_image(text='Text of the post', image=img_data, image_alt='Img alt')
All three produce the same record. Pick the highest level that does what you need.
AT-URIs
Records are addressed by AT-URI: at://<handle-or-did>/<collection>/<rkey>. The SDK returns them as strings and AtUri takes them apart:
from atproto import AtUri
uri = AtUri.from_str('at://did:plc:bv6ggog3tya2z3vxsub7hnal/app.bsky.feed.post/3k2a...')
uri.hostname # 'did:plc:bv6ggog3tya2z3vxsub7hnal', the repo
uri.collection # 'app.bsky.feed.post'
uri.rkey # '3k2a...'
hostname is the repo the record lives in: a DID or a handle, whichever the URI carries. It is what you pass as repo to any of the record or com.atproto.repo methods. collection and rkey are empty strings, not None, when the URI does not reach that deep, so at://alice.example.com parses fine and yields '' for both.
Build one with make, and turn it back into a string with str() or href:
uri = AtUri.make('did:plc:bv6ggog3tya2z3vxsub7hnal', 'app.bsky.feed.post', '3k2a...')
print(str(uri)) # at://did:plc:bv6ggog3tya2z3vxsub7hnal/app.bsky.feed.post/3k2a...
from_str raises InvalidAtUriError on input that is not an AT-URI. AtUri is hashable and compares by its string form, so it works as a dict key.
Note
AtUri.http is a misnomer, since it never returns an HTTP URL, and is deprecated. Use href.
The scheme itself is specified at atproto.com/specs/at-uri-scheme; repositories and records are at atproto.com/specs/repository.