Short answer: pip install atproto, log in with your handle and an app password, and call client.send_post("Hello"). That posts. Links and mentions need one more step – Bluesky does not auto-detect them – and the SDK's TextBuilder handles it.

This guide gets you to a first post, then covers the parts that trip people up: clickable links, link cards, images with alt text, threads, sessions and rate limits. All code was checked against atproto v0.0.72.

On this page:

The Direct Answer

Question Answer
Which library? atproto (the community Python SDK, pip install atproto)
Which password? An app password, never your main one
Method to post client.send_post(text)
Are links clickable automatically? No – use TextBuilder to add facets
Images send_image / send_images, up to 10, with alt text
Character limit 300 characters (graphemes) per post

How Do You Post to Bluesky From Python?

  1. Create an app password. In Bluesky, open Settings, then Privacy and security, then App passwords, and create one for your script.
  2. Install the SDK. Run pip install atproto in a virtual environment with Python 3.9 or later.
  3. Log in. Create a Client and call client.login with your handle and the app password.
  4. Send the post. Call client.send_post with your text. It returns the new post's URI and CID.
  5. Add links, images or replies. Use TextBuilder for links and mentions, send_image for pictures with alt text, and reply_to for replies.

Set up the environment:

python3 -m venv .venv
source .venv/bin/activate
pip install atproto

Keep credentials out of the code. Put them in environment variables:

export BSKY_HANDLE="yourname.bsky.social"
export BSKY_APP_PASSWORD="xxxx-xxxx-xxxx-xxxx"

Then the script:

import os
from atproto import Client

client = Client()
client.login(os.environ["BSKY_HANDLE"], os.environ["BSKY_APP_PASSWORD"])

post = client.send_post("Hello from Python!")
print(post.uri)

send_post returns the new record's uri (an AT-URI like at://did:plc:.../app.bsky.feed.post/3m...) and cid. Save both if you want to reply to, like or delete the post later.

Why an app password? It can post as you but cannot change your email, password or other account settings, and you can revoke it without touching anything else. Our guide to Bluesky app passwords walks through creating one. If you use email two-factor sign-in, an app password is also how a script logs in without a code.

This is the number one surprise. If you send "Read https://example.com @alice.bsky.social", it posts as plain text: no link, no mention, no notification for Alice. The official app detects these as you type and sends facets – byte ranges that mark part of the text as a link, mention or tag. When you post through the API, you are the app.

The SDK's TextBuilder builds the text and the facets together:

from atproto import Client, client_utils

alice = client.resolve_handle("alice.bsky.social").did

text = (
    client_utils.TextBuilder()
    .text("New post on the blog: ")
    .link("how to embed Bluesky posts", "https://getskyscraper.com/blog/")
    .text(" with thanks to ")
    .mention("@alice", alice)
    .text(" ")
    .tag("#python", "python")
)

client.send_post(text)
  • link(text, url) – the visible text can differ from the URL, which is how you keep links short
  • mention(text, did) – takes a DID, not a handle, so resolve the handle first
  • tag(text, tag) – the visible text includes the #; the tag value does not

Facets use UTF-8 byte offsets, not character positions, which is why hand-built facets break on emoji. Let TextBuilder do the counting. The full model is in our Bluesky facets and rich text guide.

A link facet makes text clickable; the preview card under a post is a separate external embed. The app fetches the page's title and image for you; from the API, you supply them:

from atproto import models

card = models.AppBskyEmbedExternal.Main(
    external=models.AppBskyEmbedExternal.External(
        uri="https://getskyscraper.com/blog/",
        title="Learn from Skyscraper's Mistakes",
        description="A Bluesky dev blog.",
    )
)

client.send_post("New on the blog", embed=card)

To include a thumbnail, upload the image with client.upload_blob(image_bytes) and pass the returned blob as thumb on External.

How Do You Post Images With Alt Text?

with open("chart.png", "rb") as f:
    img = f.read()

client.send_image(
    text="Weekly downloads",
    image=img,
    image_alt="Bar chart: downloads rose every week in September.",
)

For several images, use send_images with lists:

client.send_images(
    text="Three screenshots",
    images=[a, b, c],
    image_alts=["Timeline", "Widgets", "Settings"],
)

As of October 2026 a post can carry up to 10 images, each up to 2 MB, and you cannot mix images and video in one post. send_video works the same way with video_alt; videos can run up to 10 minutes. Always fill in the alt text – see how to add alt text on Bluesky for what to write. Passing an image_aspect_ratio stops apps cropping the image before it loads.

How Do You Reply to a Post or Post a Thread?

A reply carries two references: the parent (the post you are answering) and the root (the first post of the thread). For a thread of your own:

from atproto import models

parts = [
    "1/ A thread from Python.",
    "2/ Each reply points at the previous post as parent...",
    "3/ ...and at the first post as root.",
]

root = parent = None
for part in parts:
    reply_to = None
    if root:
        reply_to = models.AppBskyFeedPost.ReplyRef(parent=parent, root=root)
    res = client.send_post(part, reply_to=reply_to)
    parent = models.create_strong_ref(res)
    root = root or parent

To reply to someone else's post, fetch it with client.get_posts([uri]). If it is itself a reply, copy its record.reply.root as your root; otherwise the post is both root and parent. Getting the root wrong does not fail – it just breaks the thread in every app.

How Do You Reuse a Bluesky Login Session?

Logins (createSession) have a much tighter rate limit than posting. A cron job that logs in fresh every minute will hit it. Save the session and reuse it:

import os
from atproto import Client, SessionEvent

def on_session_change(event, session):
    if event in (SessionEvent.CREATE, SessionEvent.REFRESH):
        with open("session.txt", "w") as f:
            f.write(session.export())

client = Client()
client.on_session_change(on_session_change)

try:
    client.login(session_string=open("session.txt").read())
except FileNotFoundError:
    client.login(os.environ["BSKY_HANDLE"], os.environ["BSKY_APP_PASSWORD"])

The SDK refreshes tokens on its own; the callback saves each new session so the next run picks it up. Treat session.txt like a password.

How Do You Handle Errors and Rate Limits?

import time
from datetime import datetime, timezone
from atproto.exceptions import BadRequestError, RateLimitExceededError

def post_with_retry(text, attempts=3):
    for i in range(attempts):
        try:
            return client.send_post(text)
        except RateLimitExceededError as e:
            if e.reset_at:
                wait = (e.reset_at - datetime.now(timezone.utc)).total_seconds()
            else:
                wait = 60 * (i + 1)
            time.sleep(max(wait, 1))
        except BadRequestError as e:
            print("Rejected:", e)  # e.g. text over 300 characters
            raise
    raise RuntimeError("gave up")
  • RateLimitExceededError is an HTTP 429. The SDK reads the ratelimit-reset header for you as e.reset_at; wait until then. Our rate limits guide has the numbers, and "Rate limit exceeded" explained covers what users see.
  • BadRequestError usually means invalid input: over 300 characters, an image too large, a malformed facet. Retrying will not help.
  • UnauthorizedError means a revoked app password or a dead session. Log in again.

Building a bot? Say it is a bot in its bio, post at a human pace, and read our Bluesky bot tutorial (TypeScript, but the design advice applies). If your bot reacts to posts as they happen, pair this with the Python Jetstream tutorial. And note that Bluesky has no scheduling API – to post later, run this from cron; see how to schedule Bluesky posts.

Frequently Asked Questions

How do I post to Bluesky with Python?

Install the atproto package with pip, create a Client, log in with your handle and an app password, and call client.send_post with your text. The whole thing is about five lines of code.

Should I use my Bluesky password in a Python script?

No. Create an app password in Bluesky's Privacy and security settings and use that. It can post on your behalf but cannot change your account settings, and you can revoke it at any time.

Why don't links and mentions work in my Bluesky API posts?

Bluesky does not detect links, mentions or hashtags in post text on its own. The posting app has to send facets that mark them. In the Python SDK, build the text with client_utils.TextBuilder and its link, mention and tag methods.

How do I post an image to Bluesky with Python?

Read the file as bytes and call client.send_image with the text, the image bytes and an image_alt description. For up to 10 images use client.send_images with a list of images and a list of alt texts.

How do I reply to a post with the Bluesky API?

Pass reply_to to send_post with a models.AppBskyFeedPost.ReplyRef. Its parent is the post you are replying to and its root is the first post of the thread; both are strong refs made with models.create_strong_ref.

What are the rate limits for posting to Bluesky from a script?

Logins are limited much more tightly than posts, so log in once and reuse the session. Writes are measured in points per hour and per day, enough for well over a thousand posts an hour, but a bot posting that much will be treated as spam.

Built With the Same APIs: Skyscraper

Skyscraper is a native Bluesky client for iPhone, iPad, Mac, Apple TV, Apple Watch and Vision Pro, built on the same ATProtocol APIs this guide uses. A few things it does that you would otherwise script yourself:

  • Account Data – browse your repository's records in lexicon form or plain English
  • Full HTML and JSON backups of your posts
  • Drafts synced through Bluesky's own draft API, and whole threads from the composer

Download Skyscraper →

Related Reading