How-to guide

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

  1. 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.

  2. 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.

  3. 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.

  4. 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.

post_bluesky.py
# 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.

post-bluesky.mjs
// 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.

post_bluesky_opentweet.py
# 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:

post-opentweet.mjs
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:

thread.py
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:

json
"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 in skip_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 needatproto SDKOpenTweet 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.

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.