Post to Bluesky with Python(and Node)
Install atproto, log in with your handle and an App Password, and call client.send_post(). Links are only clickable when the post carries a facet, so build the text with TextBuilder. Or skip the SDK: one requests.post to OpenTweet publishes or schedules to Bluesky, X and LinkedIn.
Last updated: September 2026
7-day free trial. Cancel anytime.
Two routes, both legitimate
The SDK is free and gives you the whole protocol, reads included. OpenTweet is one HTTP call that also schedules, threads and cross-posts, without an App Password in your environment. This page shows both with working code so you can pick on the facts.Route A: the atproto SDK directly
Create an App Password
Create one in your Bluesky account settings. Give it a name that says which script holds it, so you know which one to delete later. It grants close to full access to the account, so treat it like a password, not like a scoped key.
Install the SDK and log in once per run
Python: pip install atproto. Node: npm install @atproto/api. Log in once and reuse the session. Bluesky caps session creation at 30 per 5 minutes and 300 per day per account, so a loop that logs in before every post will hit it.
Build facets for anything clickable
A URL or @handle in a plain string is just text. It becomes a link only when the record carries a facet with the byte range and the target. Both SDKs ship a helper that computes this for you.
Upload images as blobs, then reference them
Images are uploaded first and the returned blob goes into an app.bsky.embed.images embed on the post. Up to 4 per post, each within the size cap, each with alt text.
Python with the atproto SDK
Client, TextBuilder and send_image, as documented on atproto.blue.
# pip install atproto
import os
from atproto import Client, client_utils
client = Client()
client.login("yourname.bsky.social", os.environ["BLUESKY_APP_PASSWORD"])
# 1. Plain text. A URL in this string is NOT clickable: there is no facet.
client.send_post("Hello from Python.")
# 2. Clickable link. TextBuilder writes the text and the link facet together.
text = (
client_utils.TextBuilder()
.text("Release notes are up: ")
.link("v2.4 changelog", "https://example.com/changelog")
)
client.send_post(text)
# 3. One image with alt text. send_image uploads the blob for you.
with open("chart.png", "rb") as f:
client.send_image(
text="Weekly signups",
image=f.read(),
image_alt="Bar chart of signups per day this week",
)TextBuilder also has .mention(text, did) and .tag(text, tag). Note that a mention needs the DID, not the handle, so you resolve the handle first. For several images, send_images takes a list of bytes and a list of alt texts.
Node with @atproto/api
AtpAgent, RichText and uploadBlob. Same record, built by hand.
// npm install @atproto/api
import { AtpAgent, RichText } from '@atproto/api'
import { readFile } from 'node:fs/promises'
// Your PDS. bsky.social for accounts hosted by Bluesky.
const agent = new AtpAgent({ service: 'https://bsky.social' })
await agent.login({
identifier: 'yourname.bsky.social',
password: process.env.BLUESKY_APP_PASSWORD,
})
// detectFacets finds URLs and @mentions and resolves handles to DIDs
const rt = new RichText({ text: 'Release notes are up: https://example.com/changelog' })
await rt.detectFacets(agent)
// Upload first, then reference the returned blob in the embed
const bytes = await readFile('chart.png')
const { data } = await agent.uploadBlob(bytes, { encoding: 'image/png' })
await agent.post({
text: rt.text,
facets: rt.facets,
embed: {
$type: 'app.bsky.embed.images',
images: [{ image: data.blob, alt: 'Bar chart of signups per day this week' }],
},
createdAt: new Date().toISOString(),
})detectFacets makes a network call to resolve each @handle to a DID. If you only need links, the helper can run without resolution, but then mentions will not link. Either way the facet carries UTF-8 byte offsets, which is why hand-rolled facets break on the first emoji.
When the SDK is the right choice
- You only post to Bluesky and cost matters. The SDK and the network are free. Nothing about a hobby bot needs a paid service.
- Your work is mostly reading. Feeds, notifications, replies, search, the firehose. OpenTweet publishes and schedules; it does not read Bluesky.
- You need a record OpenTweet does not build. Link cards via
app.bsky.embed.external, quote posts, video, or anything custom.
What the SDK leaves to you: storing the App Password, reusing sessions under the login cap, counting 300 graphemes before the server refuses the record, resizing images, and building a scheduler, because creating a record publishes it now.
Route B: one call to OpenTweet
Connect Bluesky once over atproto OAuth in the dashboard. After that the only secret is an ot_ key.
# pip install requests
import os
import requests
r = requests.post(
"https://opentweet.io/api/v1/posts",
headers={"Authorization": f"Bearer {os.environ['OPENTWEET_API_KEY']}"},
json={
"text": "Release notes are up: https://example.com/changelog",
"platforms": ["bluesky"],
"scheduled_date": "2026-09-21T09:00:00Z",
},
)
print(r.status_code, r.json())The URL in that text arrives clickable, because OpenTweet builds the link facet. The same call with publish_now instead of a date posts immediately, and adding "x" or "linkedin" to platforms sends the same post there too. In Node:
const res = await fetch('https://opentweet.io/api/v1/posts', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPENTWEET_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
text: 'Release notes are up: https://example.com/changelog',
platforms: ['bluesky', 'x', 'linkedin'],
publish_now: true,
media_urls: ['https://example.com/chart.png'],
}),
})
console.log(await res.json())Threads are the same endpoint. On Bluesky they post as a real reply chain, each part replying to the one before:
requests.post(
"https://opentweet.io/api/v1/posts",
headers={"Authorization": f"Bearer {os.environ['OPENTWEET_API_KEY']}"},
json={
"text": "What changed in v2.4, in three posts.",
"is_thread": True,
"thread_tweets": [
"One. Exports are 4x faster on large workspaces.",
"Two. The API returns per-network results now.",
],
"platforms": ["bluesky", "x"],
"publish_now": True,
},
)Each network reports on its own. A Bluesky entry in results carries the record key, the at:// URI, the CID and the public URL:
"results": [
{
"platform": "bluesky",
"status": "published",
"post_id": "3l5x7k2qz3c2t",
"uri": "at://did:plc:abc123/app.bsky.feed.post/3l5x7k2qz3c2t",
"cid": "bafyreib2rxk3rh6kzwq...",
"url": "https://bsky.app/profile/you.bsky.social/post/3l5x7k2qz3c2t"
}
]Clickable links, no link card
As of September 2026 OpenTweet adds link and mention facets but does not attach a link card, does not shorten links, and does not split long text. A post over 300 graphemes (or over 3,000 bytes) is skipped on Bluesky with the reason inskip_reason, and still publishes on the other networks. Video is not posted to Bluesky. Check length first with the Bluesky character counter.Side by side
| What you need | atproto SDK | OpenTweet API |
|---|---|---|
| Cost | Free | From $11.99/mo, Bluesky on every plan |
| Auth | Handle + App Password in your environment | atproto OAuth once in the dashboard, then an ot_ key |
| Clickable links and mentions | Yes, with TextBuilder or RichText | Yes, facets added for you |
| Link card | Yes, if you build app.bsky.embed.external | No |
| Scheduling | Your own cron or queue | scheduled_date on the same call |
| Images | Upload blobs yourself, up to 4 | media_urls, up to 4, resized to fit 2,000,000 bytes |
| Threads | Set reply root and parent on each post | is_thread + thread_tweets, posted as a real reply chain |
| Over 300 graphemes | Server rejects the record | Skipped on Bluesky with a reason, no auto-split |
| Same post to X and LinkedIn | Separate integrations | Add them to platforms |
| Reading feeds, replies, search | Yes | No, publishing only |
Full parameter reference in the cross-posting docs. The auth trade-off is covered in Bluesky OAuth without an App Password.
Frequently asked questions
How do I post to Bluesky from Python?
Install the atproto package, create a Client, call client.login with your handle and an App Password, then call client.send_post with your text. For a clickable link, build the text with client_utils.TextBuilder and its .link method, which adds the link facet. For images, client.send_image uploads the blob and attaches it in one call.
Why is the link in my Bluesky post not clickable?
Bluesky only renders a link when the post record carries a link facet that points at the URL inside the text. A plain string sent through send_post or agent.post has no facet, so the URL shows as text. In Python use TextBuilder, in Node run RichText.detectFacets before posting, or post through OpenTweet, which adds link and mention facets for you.
What is the Node equivalent of the Python atproto SDK?
The @atproto/api package. Create an AtpAgent with your server URL, call agent.login with identifier and password, then agent.post with text, facets and an optional embed. RichText handles facet detection and agent.uploadBlob handles images.
Does the Bluesky API have a scheduled post endpoint?
No. Creating a post writes a record to your repository immediately, and there is no field that delays it. To schedule with the SDK you run your own cron or queue. OpenTweet takes a scheduled_date on the same call and publishes at that time.
Does OpenTweet add link cards to Bluesky posts?
No. As of September 2026 OpenTweet adds link facets, so URLs are clickable, but it does not attach a link card (the app.bsky.embed.external embed with title, description and thumbnail). It also does not shorten links. If the card matters to you, build that embed yourself with the SDK.
Is the atproto SDK free?
Yes. The SDKs are open source and Bluesky charges nothing to post. The limits are rate limits: 5,000 points an hour and 35,000 a day per account, with each new record costing 3 points.
Related guides
Bots, rate limits, auth, and the same call pointed at X.
Build a Bluesky bot
A scheduled poster from GitHub Actions, the RSS version, and the rate limits it runs into.
How many posts a day can a bot make?
11,666 records a day per account, and what a thread costs against it.
Is there a Bluesky API key?
No. What you authorize instead, and why an App Password is not a key.
Bluesky OAuth, no App Password
What a scoped OAuth grant changes compared to pasting a credential into a script.
Cross-posting API docs
The platforms array, per-network results, and why a network gets skipped.
Post to X from Python
The same requests.post, pointed at X.
Bluesky character counter
Count graphemes the way Bluesky does before a post gets skipped.
Bluesky scheduler
Schedule posts and threads without writing any code.
One call to Bluesky, X and LinkedIn
Connect Bluesky over OAuth once, then post or schedule from Python, Node, a cron job or an agent. From $11.99 a month, Bluesky on every plan.