yeetpostyeetpost

Bluesky API: posting with an app password, threads and images

October 29, 2026

Bluesky is the easiest social network to post to from code. There is no developer approval and no OAuth app to register. You make an app password, log in with it, and write records to your repository.

This post goes through the raw calls for a text post, an image and a thread, then the same three things through yeetpost. The Bluesky protocol side is described in Bluesky's post creation tutorial, which covers starting a session, structuring a post record, rich-text annotations, uploading images and linking replies into threads. The numeric limits below are the ones yeetpost enforces, and the Bluesky rate limits post covers the protocol's own request budgets.

#App passwords

An app password is a separate password you create in Bluesky's settings for one tool. It does almost everything the main password can, and you can delete it any time to cut that tool off, without changing your login. Use one for every script and never your main password.

Create it in Bluesky settings, give it a name that says what uses it, and copy it right away, because it is shown once.

#Step 1: log in

Bluesky's session call is com.atproto.server.createSession. The AT Protocol XRPC spec says initial login uses this endpoint and that it returns a refreshJwt and an initial accessJwt. Procedures are POST requests to /xrpc/<nsid>.

curl -X POST https://bsky.social/xrpc/com.atproto.server.createSession \
  -H "content-type: application/json" \
  -d '{"identifier": "alex.bsky.social", "password": "xxxx-xxxx-xxxx-xxxx"}'

The answer carries your did (the account's permanent id), your handle, and the two tokens. Use the accessJwt as a Bearer token for the next calls.

Do not log in on every run. Sessions are meant to be refreshed, and the login call has its own limits. Cache the session and use the refresh token, as described in the limits post.

If you run your own PDS, replace bsky.social with its host.

#Step 2: write the post record

A post is a record in your repository, in the app.bsky.feed.post collection:

curl -X POST https://bsky.social/xrpc/com.atproto.repo.createRecord \
  -H "authorization: Bearer $ACCESS_JWT" \
  -H "content-type: application/json" \
  -d '{
    "repo": "did:plc:your-did-here",
    "collection": "app.bsky.feed.post",
    "record": {
      "$type": "app.bsky.feed.post",
      "text": "we rewrote the scheduler",
      "createdAt": "2026-10-29T09:00:00.000Z"
    }
  }'

The response gives you an uri and a cid. Keep both, because replies need them.

Plain text is easy. A link in the text is not clickable unless you also add a rich-text annotation (a facet) that marks which part of the text is the link. That is what the tutorial's "rich-text annotations" section covers. Mentions and hashtags work the same way.

#Step 3: images

An image is two steps. First upload the bytes with com.atproto.repo.uploadBlob, which the XRPC spec names, and keep the blob it returns. Then reference the blob in an embed on the post:

{
  "$type": "app.bsky.feed.post",
  "text": "the new dashboard",
  "createdAt": "2026-10-29T09:00:00.000Z",
  "embed": {
    "$type": "app.bsky.embed.images",
    "images": [
      { "alt": "The dashboard showing a weekly chart", "image": { "...": "the blob from uploadBlob" } }
    ]
  }
}

Write alt text every time. It is a field on the image, and screen reader users depend on it.

The limits that catch people are the numbers. Through yeetpost, which checks them before it sends, Bluesky takes up to 4 images per post, 1 MB each, in png, jpeg, gif or webp. A 1 MB limit is small. A screenshot saved as png is often over it, so convert to jpeg or shrink before uploading.

#Step 4: threads

A thread is a chain of replies. Each reply carries a reply field with two references:

{
  "$type": "app.bsky.feed.post",
  "text": "1. polling every second. the queue is a table.",
  "createdAt": "2026-10-29T09:00:05.000Z",
  "reply": {
    "root":   { "uri": "at://did:plc:.../app.bsky.feed.post/3k...", "cid": "bafy..." },
    "parent": { "uri": "at://did:plc:.../app.bsky.feed.post/3k...", "cid": "bafy..." }
  }
}

root is always the first post in the thread. parent is the post you are replying to, which for the second reply is the first reply. Getting root wrong, for instance by setting it to the previous reply, is the classic mistake, and the thread then looks broken in clients.

Text is limited to 300 graphemes. A grapheme is what a person sees as one character, so an emoji with a skin tone counts as one, but a byte count would say more. If you count in JavaScript, use Intl.Segmenter instead of .length.

#The same thing through yeetpost

yeetpost connects with the same app password, once, in the dashboard: paste your handle and the app password. For a self-hosted PDS you add its https service URL. After that your code holds an API key and no Bluesky credentials.

A text post:

curl -X POST https://api.yeetpost.com/api/v2/post/bluesky \
  -H "x-api-key: $YEETPOST_API_KEY" \
  -d 'we rewrote the scheduler'

An image with alt text. The media array takes an id from POST /media and an altText; mediaUrls skips the upload step entirely and yeetpost fetches the image itself:

curl -X POST https://api.yeetpost.com/api/v2/posts \
  -H "x-api-key: $YEETPOST_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "text": "the new dashboard",
    "connectionSlugs": ["bluesky"],
    "media": [
      { "id": "9b1c2d3e-4f50-4a6b-8c7d-1e2f3a4b5c6d", "altText": "The dashboard showing a weekly chart" }
    ]
  }'

A thread. You list the replies in order, up to 24 of them, and yeetpost builds the reply references:

curl -X POST https://api.yeetpost.com/api/v2/posts \
  -H "x-api-key: $YEETPOST_API_KEY" \
  -H "content-type: application/json" \
  -d '{
    "text": "we rewrote the scheduler last month. three things we got wrong:",
    "connectionSlugs": ["bluesky"],
    "thread": [
      "1. polling every second. the queue is a table, and postgres is fine with SKIP LOCKED.",
      "2. retrying forever. five attempts and a dead letter beats an infinite loop.",
      "3. one worker. two overlapping workers found the bug the first one hid."
    ]
  }'

Every segment is checked against the 300 grapheme limit before anything goes out. A segment that is too long refuses the whole request with 400 text_too_long and a field such as thread[1], so you do not end up with half a thread.

Two behaviors worth knowing:

Delete works too: DELETE /posts/{id} takes the post down. Metrics are read back for Bluesky posts.

Posting to Bluesky is free through yeetpost. Billing is per active connected account, and the first 2 are free. See pricing.

#Which to use

If Bluesky is the only network you post to and you like the protocol, the raw calls are manageable. If you post to several networks, or you want threads, alt text and limits handled the same way everywhere, one call is simpler.

The Bluesky posting API page has the limits table, the docs have the full reference, and the Bluesky platform docs cover connecting.