Proxies and labelers
Some services are not reached directly. You send the request to your PDS with a header naming the service you actually want, and the PDS forwards it, signing a service token on your behalf. That is how direct messages and moderation tooling work: one client, one session, different destinations.
Two headers do this, and the SDK wraps both.
The protocol side is documented at bsky.network/docs/bluesky-api/request-proxying.
Proxying
with_proxy sets the atproto-proxy header, whose value is <did>#<service_type>: the DID of the service, and the id of the service entry in that DID’s document.
ozone = client.with_proxy('atproto_labeler', 'did:plc:ar7c4by46qjdydhdevvrndac')
ozone.tools.ozone.moderation.get_repo({'did': 'did:plc:...'})
service_type also accepts the AtprotoServiceType enum, which names the two service types the SDK knows about:
AtprotoServiceType.ATPROTO_LABELER('atproto_labeler')A labeler service, which also serves the Ozone moderation endpoints under
tools.ozone.AtprotoServiceType.BSKY_CHAT('bsky_chat')The Bluesky chat service.
The DID must start with did:. Anything else raises AtProtocolError immediately, before any request.
The chat shortcut
Direct messages always go through the same service, so there is a wrapper:
dm_client = client.with_bsky_chat_proxy()
dm_client.chat.bsky.convo.list_convos()
with_bsky_chat_proxy is exactly with_proxy(AtprotoServiceType.BSKY_CHAT, Client.BSKY_CHAT_DID), where BSKY_CHAT_DID is the class constant 'did:web:api.bsky.chat'. Every chat.bsky.* call needs it. See Direct messages.
Labelers
Labels are not applied by the AppView on its own initiative. The client declares which labeler services it subscribes to, per request, and the AppView hydrates labels from those services into the response.
with_labelers takes a list of DIDs and sets the atproto-accept-labelers header:
labeled = client.with_labelers(['did:plc:ar7c4by46qjdydhdevvrndac'])
profile = labeled.get_profile('bsky.app')
for label in profile.labels or []:
print(label.val, label.src)
with_bsky_labeler is the shortcut for Bluesky’s own moderation service, Client.BSKY_LABELER_DID ('did:plc:ar7c4by46qjdydhdevvrndac').
Note
Each DID is emitted with a ;redact suffix, as in did:plc:abc;redact, which tells the AppView the client honours takedown labels from that labeler and wants the content removed rather than flagged. The SDK always sends ;redact; there is no option to opt out of it.
Entries that do not start with did: are silently dropped from the list rather than raising.
from atproto import Client
USERNAME = 'example.com'
PASSWORD = 'hunter2' # noqa: S105 never hardcode your password in a real application
def main() -> None:
client = Client()
client.login(USERNAME, PASSWORD) # use App Password with access to Direct Messages!
# `with_*` returns a configured clone; the original client keeps its own headers
dm_client = client.with_bsky_chat_proxy()
print('Proxy header:', dm_client.request.get_headers()['atproto-proxy'])
print('Set on the original client:', 'atproto-proxy' in client.request.get_headers())
# spelled out, this is what the convenience wrapper above does
dm_client = client.with_proxy(Client.AtprotoServiceType.BSKY_CHAT, Client.BSKY_CHAT_DID)
convos = dm_client.chat.bsky.convo.list_convos()
print(f'You have {len(convos.convos)} conversations.')
# ask the AppView to apply the labels of the Bluesky moderation service
labeled_client = client.with_bsky_labeler()
print('Labelers header:', labeled_client.request.get_headers()['atproto-accept-labelers'])
# any set of labeler DIDs works
labeled_client = client.with_labelers([Client.BSKY_LABELER_DID])
profile = labeled_client.get_profile(USERNAME)
for label in profile.labels or []:
print(f'- {label.val} (from {label.src})')
# clones share the session, so a token refresh on one is visible to all of them
print('Same session:', client.export_session_string() == dm_client.export_session_string())
if __name__ == '__main__':
main()
Cloning
Both with_* methods call clone and configure the copy. The client you called them on is unchanged, so a proxied client for chat and a plain client for everything else can coexist, and you do not have to unset a header when you are done.
What a clone shares with its original:
The session. Same
Sessionobject, same access and refresh tokens.The session dispatcher, and with it every
on_session_changecallback.me, the profile fetched at login.
What a clone copies:
The request’s additional headers. A fresh dict, so configuring the clone does not touch the original’s headers.
The additional-header sources. The list of callbacks is copied; the callbacks themselves are shared.
The configuration of the underlying
httpxclient. Every keyword argument the original Request was constructed with, so a timeout, transport or proxy you set carries over.
What a clone does not share is the httpx client itself. Each one opens its own connection pool, so close() it separately. See HTTP and transport.
Sharing the session is the important half. A token refresh triggered by any one of the clones updates the session all of them are using, and fires the callbacks registered on any of them. Logging in once and fanning out into several proxied clients is the intended pattern.
Because the session lives in the shared dispatcher rather than on the client, the order does not matter: a clone taken before login() picks up the session the original creates later, and authenticates from that point on.
Attention
A clone inherits the base URL as it stands at the moment you clone. Login repoints the client at the PDS discovered in the DID document, so a clone taken before login keeps the URL you constructed the client with. Cloning after login is still the simpler thing to do. See Authentication.
Headers, and who wins
Every request’s headers are assembled by get_headers in a fixed order, each layer overriding the last. Comparison is case-insensitive, so Atproto-Proxy and atproto-proxy are the same header.
Mandatory headers. Currently just
User-Agent, set toatproto/<version> Python SDK (atproto.blue). It is applied first, which means it is the one thing anything else can override: set your ownUser-Agentand it wins.Additional headers, set by set_additional_headers or add_additional_header. This is where
configure_proxy_headerandconfigure_labelers_headerwrite.Header sources, registered with add_additional_headers_source. Each is a zero-argument callable returning a dict, invoked on every request. This is how the
Authorizationheader stays current across token refreshes without anyone rewriting it.Per-request headers, passed as
headers=to an individual call.
client.request.add_additional_header('X-Trace-Id', 'abc123')
client.request.set_additional_headers({'X-Trace-Id': 'abc123'}) # replaces the whole dict
client.request.add_additional_headers_source(lambda: {'X-Request-Time': now()})
Attention
set_additional_headers replaces the dict wholesale. Calling it after with_proxy or with_labelers drops the header they set. Use add_additional_header to add one without disturbing the others.
If you want the header without the clone, to configure a client once at construction, call configure_proxy_header or configure_labelers_header directly. They mutate the client they are called on and return None.