Posting

Everything you publish is a record in your repository. This page covers the sugar the client puts on top of app.bsky.feed.post (plain text, rich text, images, video, embeds) and how to delete what you created.

Sending a post

send_post takes the text and returns a CreateRecordResponse with two fields, uri and cid. Keep it: you need the URI to delete the post and both fields to reply to it, quote it, or like it.

examples/send_post.py
from atproto import Client


def main() -> None:
    client = Client()
    client.login('my-handle', 'my-password')

    client.send_post(text='Hello World from Python SDK!')


if __name__ == '__main__':
    main()

The full signature:

text

The post body, at most 300 graphemes and 3000 bytes. Accepts a str or a TextBuilder.

profile_identify

Handle or DID of the repository to write to. Defaults to the logged-in account.

reply_to

An AppBskyFeedPost.ReplyRef. See Replies.

embed

One of AppBskyEmbedImages.Main, AppBskyEmbedExternal.Main, AppBskyEmbedRecord.Main, AppBskyEmbedRecordWithMedia.Main or AppBskyEmbedVideo.Main.

langs

Up to three BCP-47 language codes. Defaults to ['en'] when you pass nothing, so set it if you are not posting in English.

facets

Rich text ranges. Usually built for you by TextBuilder; see Rich text.

Note

send_post is also available as post, and delete_post as unsend. They are plain aliases, not different methods.

Replies

There is no send_reply method. A reply is a normal post carrying a reply_to ref, and that ref needs two strong references: root (the first post in the thread) and parent (the post you are answering). Build them with create_strong_ref, which turns anything with a uri and cid into a ComAtprotoRepoStrongRef.Main.

examples/send_reply.py
from atproto import Client, models


def main() -> None:
    client = Client()
    client.login('my-handle', 'my-password')

    root_post_ref = models.create_strong_ref(client.send_post('Post from Python SDK'))

    # Reply to the root post. We need to pass ReplyRef with root and parent
    reply_to_root = models.create_strong_ref(
        client.send_post(
            text='Reply to the root post',
            reply_to=models.AppBskyFeedPost.ReplyRef(parent=root_post_ref, root=root_post_ref),
        )
    )

    # To reply on reply, we need to change the "parent" field. Let's reply to our previous reply
    client.send_post(
        text='Reply to the parent reply',
        reply_to=models.AppBskyFeedPost.ReplyRef(parent=reply_to_root, root=root_post_ref),
    )


if __name__ == '__main__':
    main()

Getting root wrong splits the thread in the app, so carry the root ref down the whole chain and only move parent.

Rich text

Links, mentions and hashtags are not markup. They are facets, byte ranges attached to the post alongside the plain text. Read the Bluesky post guide if you want the wire format; the SDK gives you TextBuilder so you do not have to count bytes.

TextBuilder has four methods, all of which return the builder so you can chain them:

text(text)

Plain text, no facet.

link(text, url)

Text that links to url.

mention(text, did)

Text that mentions an account. Takes a DID, not a handle, so resolve the handle first.

tag(text, tag)

Text that acts as a hashtag. tag is the tag itself, without the #.

Pass the builder straight to send_post (or send_image, send_images, send_video) in place of the text. The client calls build_text and build_facets for you.

examples/send_rich_text.py
from atproto import Client, client_utils

# To send links as "link card" or "quote post" look at the advanced_usage/send_embed.py example.
# There is a more advanced way to send rich text without helper class in the advanced_usage/send_rich_text.py example.


def main() -> None:
    client = Client()
    client.login('my-handle', 'my-password')

    text_builder = client_utils.TextBuilder()
    text_builder.tag('This is a rich message. ', 'atproto')
    text_builder.text('I can mention ')
    text_builder.mention('account', 'did:plc:kvwvcn5iqfooopmyzvb4qzba')
    text_builder.text(' and add clickable ')
    text_builder.link('link', 'https://atproto.blue/')

    # You can pass instance of TextBuilder instead of str to the "text" argument.
    client.send_post(text_builder)  # same with send_image method

    # Same with chaining:
    client.send_post(client_utils.TextBuilder().text('Test msg using ').link('Python SDK', 'https://atproto.blue/'))


if __name__ == '__main__':
    main()

If you need the two halves separately, to inspect them or to reuse the same text with a different embed, call build_text and build_facets yourself:

builder = client_utils.TextBuilder().text('Built with ').link('atproto', 'https://atproto.blue/')
client.send_post(text=builder.build_text(), facets=builder.build_facets())

Warning

Facets must not overlap. TextBuilder writes segments in order and never produces overlapping ranges, but if you assemble facets by hand you have to guarantee it: two features over the same byte range is invalid.

Building facets by hand

You do not have to use TextBuilder. Pass a list of AppBskyRichtextFacet.Main to the facets argument and the client sends it as-is. This example scans finished text for URLs and attaches a link facet to each match. Note that the offsets are byte offsets into the UTF-8 encoding of the text, not character offsets:

examples/advanced_usage/auto_hyperlinks.py
import re
import typing as t

from atproto import Client, models


def extract_url_byte_positions(text: str, *, encoding: str = 'UTF-8') -> t.List[t.Tuple[str, int, int]]:
    """This function will detect any links beginning with http or https."""
    encoded_text = text.encode(encoding)

    # Adjusted URL matching pattern
    pattern = rb'https?://[^ \n\r\t]*'

    matches = re.finditer(pattern, encoded_text)
    url_byte_positions = []

    for match in matches:
        url_bytes = match.group(0)
        url = url_bytes.decode(encoding)
        url_byte_positions.append((url, match.start(), match.end()))

    return url_byte_positions


def main() -> None:
    client = Client()
    client.login('my-handle', 'my-password')

    # AT requires URL to include http or https when creating the facet
    text = 'Example post with automatic link detection https://github.com/MarshalX/atproto and http://atproto.blue'

    # Determine locations of URLs in the post's text
    url_positions = extract_url_byte_positions(text)
    facets = []

    for link_data in url_positions:
        uri, byte_start, byte_end = link_data
        facets.append(
            models.AppBskyRichtextFacet.Main(
                features=[models.AppBskyRichtextFacet.Link(uri=uri)],
                index=models.AppBskyRichtextFacet.ByteSlice(byte_start=byte_start, byte_end=byte_end),
            )
        )

    client.send_post(text, facets=facets)


if __name__ == '__main__':
    main()

Images

send_image uploads one image and posts it. send_images takes up to four. Both accept the raw bytes, not a path or a file object.

examples/send_image.py
from atproto import Client, models


def main() -> None:
    client = Client()
    client.login('my-handle', 'my-password')

    # replace the path to your image file
    with open('cat.jpg', 'rb') as f:
        img_data = f.read()

    # Add image aspect ratio to prevent default 1:1 aspect ratio
    # Replace with your desired aspect ratio
    aspect_ratio = models.AppBskyEmbedDefs.AspectRatio(height=100, width=100)

    client.send_image(
        text='Post with image from Python SDK',
        image=img_data,
        image_alt='Text version of the image (ALT)',
        image_aspect_ratio=aspect_ratio,
    )


if __name__ == '__main__':
    main()

Always pass image_alt. It is the only description of the picture that a screen reader gets.

image_aspect_ratio is optional but worth setting: without it clients fall back to 1:1 and crop your image. It takes an AppBskyEmbedDefs.AspectRatio(width=..., height=...), the ratio rather than the pixel size, so width=16, height=9 is fine.

examples/send_images.py
from atproto import Client, models


def main() -> None:
    client = Client()
    client.login('my-handle', 'my-password')

    # replace the path to your image file
    paths = ['cat.jpg', 'dog.jpg', 'bird.jpg']
    image_alts = [
        'Text version',
        'of the image (ALT)',
        'This parameter is optional',
    ]

    # Add image aspect ratio to prevent default 1:1 aspect ratio
    # Replace with your desired aspect ratio
    image_aspect_ratios = [
        models.AppBskyEmbedDefs.AspectRatio(height=1, width=1),
        models.AppBskyEmbedDefs.AspectRatio(height=4, width=3),
        models.AppBskyEmbedDefs.AspectRatio(height=16, width=9),
    ]

    images = []
    for path in paths:
        with open(path, 'rb') as f:
            images.append(f.read())

    client.send_images(
        text='Post with image from Python SDK',
        images=images,
        image_alts=image_alts,
        image_aspect_ratios=image_aspect_ratios,
    )


if __name__ == '__main__':
    main()

image_alts and image_aspect_ratios are positional lists lined up with images. Short lists are padded, with missing alts becoming '' and missing ratios None, so the call still succeeds if you supply fewer than you have images. Extra entries are ignored.

Video

send_video works like send_image: bytes in, video_alt and video_aspect_ratio optional.

examples/send_video.py
from atproto import Client, models


def main() -> None:
    client = Client()
    client.login('my-handle', 'my-password')

    # replace the path to your video file
    with open('video.mp4', 'rb') as f:
        vid_data = f.read()

    # Add video aspect ratio to prevent default 1:1 aspect ratio
    # Replace with your desired aspect ratio
    aspect_ratio = models.AppBskyEmbedDefs.AspectRatio(height=100, width=100)

    client.send_video(
        text='Post with video from Python SDK',
        video=vid_data,
        video_alt='Text version of the video (ALT)',
        video_aspect_ratio=aspect_ratio,
    )


if __name__ == '__main__':
    main()

Attention

send_video uploads the file with com.atproto.repo.uploadBlob, a single plain blob upload, and embeds the result. It does not drive the chunked video pipeline.

If you need that pipeline (large files, resumable uploads, transcode status, upload quota), call the app.bsky.video namespace directly: start_upload, upload_part, finish_upload, get_job_status, abort_upload and get_upload_limits. There is no high-level wrapper around them: you drive the job yourself and build the AppBskyEmbedVideo.Main embed from the blob it produces.

Embeds

The embed argument takes one of five models. send_image, send_images and send_video are shortcuts that build AppBskyEmbedImages.Main and AppBskyEmbedVideo.Main for you; the other three you construct yourself.

AppBskyEmbedExternal.Main

A link card. Holds an External(uri, title, description, thumb); thumb is a BlobRef you upload first.

AppBskyEmbedRecord.Main

A quote post. Holds a strong ref to the record being quoted, which can be any record and not only a post: a feed generator, a list, a starter pack.

AppBskyEmbedRecordWithMedia.Main

A quote post and an image or video. Holds a record and a media.

examples/advanced_usage/send_embed.py
import httpx
from atproto import Client, models

DEFAULT_IMAGE_URL = 'https://cdn.bsky.app/img/avatar/plain/did:plc:kvwvcn5iqfooopmyzvb4qzba/bafkreicwqcugdgubtawr6xv6jjifoju67eigwx2xgz4zs73nkzzn36oucy@jpeg'


def main(handle: str, password: str) -> None:
    client = Client()
    client.login(handle, password)

    # Example 1: Link card embed
    text = 'Example post with embed external resource (link card)'
    # AppBskyEmbedExternal is the same as "link card" in the app
    embed_external = models.AppBskyEmbedExternal.Main(
        external=models.AppBskyEmbedExternal.External(
            title='Google',
            description='Google Home Page',
            uri='https://google.com',
        )
    )
    post_with_link_card = client.send_post(text=text, embed=embed_external)

    # Example 2: Simple quote post
    text_quote = 'Example post with embed post and quote (quote post)'
    # AppBskyEmbedRecord is the same as "quote post" in the app
    embed_post = models.AppBskyEmbedRecord.Main(record=models.create_strong_ref(post_with_link_card))
    client.send_post(text=text_quote, embed=embed_post)

    # Example 3: Quote post with image
    img_data = httpx.get(DEFAULT_IMAGE_URL).content
    uploaded_blob = client.upload_blob(img_data).blob

    embed_post_with_image = models.AppBskyEmbedRecordWithMedia.Main(
        record=models.AppBskyEmbedRecord.Main(record=models.create_strong_ref(post_with_link_card)),
        media=models.AppBskyEmbedImages.Main(
            images=[
                models.AppBskyEmbedImages.Image(
                    image=uploaded_blob,
                    alt='Example image with quote',
                    aspect_ratio=models.AppBskyEmbedDefs.AspectRatio(width=1, height=1),
                )
            ]
        ),
    )

    client.send_post(text='Example post with both quote and image', embed=embed_post_with_image)


if __name__ == '__main__':
    main(handle='my-handle', password='my-password')  # noqa: S106

Blobs

Images, videos, avatars and link-card thumbnails are all blobs: binary data uploaded separately from the record that references it. upload_blob takes bytes and returns a response whose .blob is a BlobRef.

with open('cat.jpg', 'rb') as f:
    blob = client.upload_blob(f.read()).blob

print(blob.mime_type, blob.size, blob.cid)

The SDK sends the body with a */* content type, so the MIME type on the returned BlobRef is whatever the PDS determined from the bytes. You do not pass it in.

Warning

An uploaded blob is deleted if no record references it within a few minutes, and the size and MIME type restrictions are enforced at the moment the reference is created, not at upload. An upload that succeeds can still fail when you attach it.

A BlobRef carries mime_type, size and ref, plus a cid property that decodes ref into a CID whichever way it is stored. The ref has two representations, because JSON and CBOR encode a CID differently:

is_json_representation

ref is an IpldLink, the {"$link": "..."} form used in XRPC responses.

is_bytes_representation

ref is raw bytes or a string, the form you get out of the firehose and CAR files.

to_json_representation and to_bytes_representation convert between them. Both return a new BlobRef; neither mutates the one you called it on.

# a blob read from the firehose, re-used in a new record
json_blob = firehose_blob.to_json_representation()

Deleting a post

delete_post takes the AT-URI of the post and returns a boolean. It parses the repository and record key out of the URI, so it can only delete records in a repository you can write to.

examples/delete_post.py
from atproto import Client


def main() -> None:
    client = Client()
    client.login('my-handle', 'my-password')

    # same with the like_post.py example we need to keep a reference to the post
    post_ref = client.send_post('Test send-delete from Python SDK')
    print('Post reference:', post_ref)

    # this method returns True/False depends on the response
    print('Deleted successfully:', client.delete_post(post_ref.uri))


if __name__ == '__main__':
    main()

Deleting a post does not delete its blobs, its likes, or the replies other people wrote under it.

See also

  • Reading: fetching the posts you and other people wrote.

  • Social graph: likes, reposts and follows on top of those posts.

  • Working with models: how the models.* types you pass to embed are shaped.