Create bsky Post (Python)

August 16, 2026 · View on GitHub

A single-file script demonstrating how to create posts with the Bluesky API, covering rich-text facets, replies, quote posts, image embeds and website cards — without an SDK in the way.

This is a security-hardened fork of Bryan Newbold's original cookbook script. Beyond the extra features, it treats every network fetch as potentially hostile, because --embed-url makes the script follow URLs you don't control. See posting-via-the-bluesky-api.md for the full walkthrough of what it does and why.

Requirements

Python 3.9 or newer. Dependencies are pinned in requirements.txt — install from there rather than by hand, as the SSRF/TLS layer depends on specific urllib3 and idna behaviour:

pip install -r requirements.txt

Credentials

You need a Bluesky account and an app password, created at https://bsky.app/settings/app-passwords. Do not use your main account password: app passwords can be revoked individually and cannot change account settings.

Prefer the environment variables over --handle/--password, since command line arguments are visible to other local users via ps (the script warns you if you pass --password anyway):

export ATP_AUTH_HANDLE='your-handle.bsky.social'
export ATP_AUTH_PASSWORD='xxxx-xxxx-xxxx-xxxx'

Usage

# plain text, with a hashtag facet
python3 create_bsky_post.py "Hello, Bluesky! #greetings"

# up to 4 images, one --alt-text each, in order
python3 create_bsky_post.py "Sunset over the bay" \
    --image sunset.jpg --alt-text "Orange sunset over a calm bay"

# website card built from the page's Open Graph tags
python3 create_bsky_post.py "Worth a read" --embed-url "https://example.com/article"

# reply
python3 create_bsky_post.py "Replying" \
    --reply-to "at://did:plc:xxx/app.bsky.feed.post/yyy"

# quote post -- bsky.app URLs work as well as at:// URIs
python3 create_bsky_post.py "Quoting this post" \
    --embed-ref "https://bsky.app/profile/example.com/post/yyy"

# quote post with attached media (record-with-media)
python3 create_bsky_post.py "Quoted with media" \
    --embed-ref "at://did:plc:xxx/app.bsky.feed.post/yyy" --image photo.jpg

# language tags (at most 3)
python3 create_bsky_post.py "สวัสดีชาวโลก! Hello World!" --lang th --lang en-US

For the full list of options and arguments:

python3 create_bsky_post.py --help

--verbose prints the complete pending record before it is sent, plus the body of a failed createRecord — the quickest way to inspect the facets and embeds the script built for you.

Services

OptionEnvironment variableDefault
--pds-urlATP_PDS_HOSThttps://bsky.social
--record-service-urlATP_RECORD_SERVICE_HOSThttps://public.api.bsky.app

Replies and quotes are looked up through the record service rather than your own PDS, so you can reference posts hosted anywhere in the network. Both URLs must be HTTPS and must be a bare scheme and host. --allow-insecure-pds permits plain HTTP for local development, and only for localhost/loopback addresses.

Tests

The script carries its own test suite — facet parsers, URI and CID validation, SSRF and IDN handling, redirect limits, response size and content-encoding limits, image file safety, login error reporting, and the card/thumbnail degradation paths:

python3 create_bsky_post.py --self-test

../tests/run_bsky_tests.sh runs that suite under both normal and optimized (python3 -O) Python, and checks that no assert statements have crept in that optimization would strip.

What the hardening covers

  • SSRF. For every attacker-influenced fetch (the embed page, its redirects, its og:image), DNS is resolved once, every answer must be a public unicast address, and the connection is pinned to the validated IP while TLS still authenticates the original hostname.
  • Redirects. Never followed automatically; each of at most 3 hops is revalidated from scratch, HTTPS-to-HTTP downgrades are refused, and credential-bearing requests refuse redirects entirely.
  • Resource limits. Wall-clock deadlines on every network operation, with size caps applied to decoded bytes so a compressed body cannot inflate past them. Each request gets its own HTTP session, so a call abandoned at its deadline can never disturb a later one.
  • Input validation. AT URIs, record CIDs, handles, BCP 47 language tags and URLs are all checked before use; image files are read symlink-safely and bounded by size, dimensions and pixel count.
  • Graceful degradation. A failed thumbnail, an unreadable embed page or an unresolvable mention costs you that one feature, never the post.

Credits

Original script and blog post by Bryan Newbold and the AT Protocol team: "Posting via the Bluesky API". Fork: https://github.com/CleasbyCode/cookbook/blob/main/python-bsky-post/create_bsky_post.py