Skip to content
← All articles
Schedule Bluesky posts: 6 rules that break schedulers

Schedule Bluesky posts: 6 rules that break schedulers

Bluesky does not schedule anything. The app.bsky.feed.post record has two required fields, text and createdAt, and no property in the lexicon reserves a future publish time. A queue holds the copy and the timestamp, then writes one record into the author’s own repository when the clock fires. Get that write right and scheduling becomes bookkeeping; get it wrong and the failure is silent, because the protocol accepts whatever you send.

In short

  • Bluesky has no native scheduling; the scheduler stores the post and calls createRecord at publish time.
  • Store the DID with the PDS endpoint, because the handle is only a label that can change.
  • A created record costs 3 points against a 5,000-point hourly budget, and refresh needs the refresh JWT.

Queue mechanics are the same as in how to schedule social media posts with an API. The rest is Bluesky-specific, starting with where the record lands.

Bluesky has no scheduler, so the queue lives in your product

There is no native scheduling on Bluesky. The post record holds text, embeds, facets, reply references, languages, tags and labels, and a publish time is not among them. Bluesky points at Buffer for scheduling instead of shipping it, which settles where the queue sits.

Your product holds the copy, the media, the thread order and the target account until the clock fires. That means storing more than an access token, opening a session at publish, uploading blobs, and writing records in thread order. PostSider’s Bluesky scheduler is one implementation of that shape.

Writes go to the author’s PDS, and the public AppView will refuse them

A scheduled post becomes one authenticated com.atproto.repo.createRecord call, handled by the PDS that hosts the author’s repository. The public AppView read endpoints do not support authentication and cannot accept a write, so a request aimed there fails regardless of the token.

repo accepts a handle or a DID. Send the DID. Handles change, DIDs do not, and the DID document is where the user’s PDS URL lives. Refresh the handle for display and never build a request on it. That is why account storage needs these fields.

Stored per accountWhy the queue needs it
DIDStable identity for the repository; survives a handle change
PDS endpointThe host that accepts the write, read from the DID document
HandleDisplay label only, refreshed and never used to address the repo
App password labelNames the credential so one client’s access can be revoked
Refresh tokenKeeps the queue alive past the short access token lifetime
Last commit CIDThe anchor for compare-and-swap retries

The payload is short, and createdAt belongs to publish time

createRecord takes a repo, a collection and a record, and the record must carry a $type. For a post the collection is app.bsky.feed.post, and the minimum body is text plus a timestamp.

POST /xrpc/com.atproto.repo.createRecord
{
  "repo": "did:plc:abc123",
  "collection": "app.bsky.feed.post",
  "record": {
    "$type": "app.bsky.feed.post",
    "text": "The queue holds the copy. Publish writes the record.",
    "createdAt": "2026-10-27T09:00:00.000Z"
  }
}

The response returns uri and cid. Keep both: the URI addresses the post, the CID is what a later compare-and-swap needs. The protocol does not enforce createdAt, so a value computed at compose time can publish days in the past and nothing rejects it. Stamp it as the write leaves the queue, in UTC, and never recalculate it on retry. text may be empty when embeds carry the post; otherwise it caps at 300 graphemes and 3,000 characters.

300 graphemes is a separate count from 300 characters and from bytes

Bluesky counts post text in Unicode grapheme clusters, capped at 300, while facet offsets count UTF-8 bytes. JavaScript’s string.length matches neither, which is why counters that look right in a browser produce broken mentions in production.

One emoji with a skin tone modifier can be a single grapheme, several UTF-16 code units and a handful of bytes. The length check passes, then the mention facet points into the middle of the text and the link lands on the wrong word. Convert to a UTF-8 byte array before computing byteStart and byteEnd. Character limits on every network covers the per-platform units; on Bluesky the composer counts graphemes and the facet builder counts bytes. PostSider enforces the 300 grapheme limit and resolves hashtags and mentions as facets at publish.

A thread is a reply chain of records, and order is not cosmetic

Every entry in a Bluesky thread is its own app.bsky.feed.post record, linked by strongRef to the immediate parent and to the thread root. The chain is not implicit, so the scheduler writes the root, captures its uri and cid, and passes that pair to the next entry.

A failure halfway leaves a partial thread that is already public, and rewriting the root orphans the replies under it. Retry the failed entry, then continue.

Images take explicit steps. Up to four per post, each uploaded with uploadBlob before the record write, and the embed requires image and alt. The docs and the lexicon disagree on the size ceiling, so compress against the smaller number and skip printing a limit in your UI. Link cards need uri, title, description and a thumbnail; read the Open Graph tags at publish, since titles and images get edited. A quote post embeds a reference to another post record. Thread gates allow up to five rules, reuse the rkey of the gated post, and an empty gate means nobody can reply.

PostSider publishes several entries as an ordered reply chain and keeps alt text as an editor field.

The write budget is 5,000 points an hour, and a new record costs 3

Each account gets 5,000 points per hour and 35,000 per day. A created record costs 3 points, so about 1,666 new records per hour and 11,666 per day.

ActionPointsPer hourPer day
CREATE3about 1,666about 11,666
UPDATE2about 2,500about 17,500
DELETE15,00035,000

An illustrative example: an agency scheduling 40 posts a day on one account spends 120 points a day, or 2.4 percent of the hourly budget if all 40 land in the same hour. A backfill of 2,000 historical posts at 3 points each is 6,000 points, which does not fit in one hour and has to be spread.

Other caps surround that budget. The per-IP limit is 3,000 points per 5 minutes, createSession allows 30 per 5 minutes and 300 per day, and a blob tops out at 50 MB. Over the cap the PDS usually returns HTTP 429, with RateLimit-Limit, RateLimit-Reset, RateLimit-Remaining and Retry-After headers, so back off to Retry-After rather than a fixed sleep. Opening a session per post burns the session budget first, which is why the session belongs in the account row.

Refresh uses the refresh JWT, and one app password per client limits the damage

createSession returns accessJwt, refreshJwt, handle and did. The access token expires after a few minutes, so a job holding only that token stalls on its second run. Refresh with com.atproto.server.refreshSession and the refresh JWT.

The reference PDS defaults to 120 minutes for access and 90 days for refresh. That is a code default, not a published bsky.social guarantee, so read the expiry from the response and refresh early. OAuth guidance is stricter: access tokens under 30 minutes with 5 recommended, untrusted public sessions and refresh tokens capped at 2 weeks, confidential refresh tokens at 180 days, refresh tokens generally single use. Apps with their own login flow should implement OAuth, while single-purpose bots and command-line tools can use app passwords.

App passwords are per client and revocable, grant slightly restricted permissions, and revocation deletes the refresh tokens issued under that label. One label per client means a revoked credential strands one queue. What happens to access JWTs already issued is not something I would state; design as if the session can die at any moment.

Retries need a commit check, not a second hopeful write

A timeout does not tell you whether the record landed, and the lexicon does not settle whether repeating an rkey counts as a duplicate. createRecord accepts swapCommit, which makes the write a compare-and-swap and fails with InvalidSwap when the repository moved since the CID you stored.

So persist the last commit CID, send it on retry, and on InvalidSwap read the repository for your record before writing again. If it is there, mark the job published with that URI. If it is not, write against the new head. Without the check, a flaky connection turns one post into two.

One queue serves the dashboard, the API, the MCP server and the SDK

Bluesky publishing is available from the dashboard, from POST /public/v1/posts on the social media scheduler API, from the MCP server and from the Node SDK, and the account row plus the record write are identical in all four.

Agents are the newest caller and the least forgiving, which is why where AI agents fail at social media is mostly about confirmation rather than composition.

Operational checklist

  • Store DID, PDS endpoint and refresh token together, and re-resolve the PDS from the DID document when a write fails on a host or auth error.
  • Stamp createdAt at publish, in UTC, and freeze it for the life of the job.
  • Count graphemes for the 300 limit and UTF-8 bytes for facet offsets in two separate functions.
  • Cap concurrent record writes per account well under 1,666 an hour, and honor Retry-After on 429.
  • Keep one app password label per client, and pause the affected queue when it is revoked.
  • Persist the commit CID and use swapCommit before any retry that might duplicate a post.

Lukasz builds PostSider, which supports Bluesky beside the other networks.

Frequently asked questions

Does Bluesky have native scheduling?

No. The app.bsky.feed.post record has no scheduling field, and Bluesky's own scheduling page is a partnership with Buffer, not a built-in feature. A scheduler holds the copy and calls createRecord at the publish time.

Can I schedule a Bluesky thread with images and alt text?

Yes, as a reply chain where each entry is its own app.bsky.feed.post record with strongRef links to the immediate parent and the thread root. Images upload as blobs first, and alt text is a required field in the image embed.

What happens to queued posts if an app password is revoked?

The refresh token issued for that app password stops working, so the queue cannot publish until the account reconnects. One app password per client keeps that blast radius small.

What are Bluesky's write rate limits?

5,000 points per hour and 35,000 per day, where creating a record costs 3 points, so roughly 1,666 new records per hour. Requests over the cap usually return HTTP 429.

Why do 300 characters count differently in code?

Bluesky counts 300 graphemes, and facet offsets count UTF-8 bytes. JavaScript string length matches neither, so emoji, accents and mention links break naive counters.

Run your social media
on autopilot.

7 days free, no credit card. Publish it yourself, or let your AI agent take the wheel.

30+ networks · MCP, REST and SDK · No credit card