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
| Option | Environment variable | Default |
|---|---|---|
--pds-url | ATP_PDS_HOST | https://bsky.social |
--record-service-url | ATP_RECORD_SERVICE_HOST | https://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