Back to Blog & Resources
Engineering

How to Schedule Bluesky Posts with the AT Protocol API

June 23, 2026 - 9 min read

The API posts now, not later

The first thing to understand: the AT Protocol has no native scheduling. There is no scheduledAt field. The endpoint that creates a post, com.atproto.repo.createRecord, publishes immediately. "Scheduling" is something you build around it by storing the post and firing the call at the right moment. Once you internalize that, the architecture becomes obvious: a datastore for pending posts, a timer, and a worker that calls createRecord.

Authenticate with an app password

Never use the account's main password. In the Bluesky app, go to Settings, App Passwords, and generate one. Then create a session with com.atproto.server.createSession using the handle and app password. You get an accessJwt and a refreshJwt. The access token is short-lived, so for any long-running scheduler you must refresh it with com.atproto.server.refreshSession before it expires, or your 9am post fails at 9am with a 401.

Create the post record

A post is a record of type app.bsky.feed.post with a text field and a createdAt timestamp. With the TypeScript SDK it is roughly agent.post with a text value and an ISO createdAt timestamp. The createdAt you send is what users see as the post time, so set it at publish time, not at schedule time, or your post will display as hours old the moment it appears.

Add the scheduling layer

Two common patterns. A cron approach runs every minute and asks "is anything due?" It is dead simple and accurate to your cron granularity, usually a minute, which is fine for almost all content. A queue approach (a delayed job in something like BullMQ, Celery, or a cloud scheduler) fires closer to exact-second precision and scales better when you have thousands of pending posts. For a personal bot, cron-every-minute is plenty. For a product, use a real queue.

The facets gotcha that breaks links

Here is the mistake nearly everyone makes on their first scheduler: you put a URL in the text and the link is not clickable. Bluesky does not auto-detect links. Rich text, links, and mentions are described by a separate facets array that points at byte ranges in your text. Critically, facets index by UTF-8 byte offsets (byteStart and byteEnd), not by character position. If your post has an emoji or any non-ASCII character before the link, naive character-based offsets will be wrong and the link will land on the wrong text or break entirely.

Do not compute byte offsets by hand. Use the RichText helper in @atproto/api: create a RichText with your text, call detectFacets, then pass both the text and facets to the post. It walks the string in UTF-8 and builds correct facets for links, mentions, and tags. This single step is the difference between a scheduler that posts dead text and one that posts working links.

What you are signing up to own

Building it yourself is satisfying and free in licensing, but be honest about the operational surface: token refresh, retry logic, facet correctness, alt text on images (a separate embed step), thread chaining via reply references, timezone handling, and uptime so a scheduled job actually fires. None of it is hard individually; together it is a small service you now maintain.

When to just use a tool

If scheduling Bluesky is the goal rather than the project, a managed tool already solved the facets, refresh, threads, and uptime problems. ONYX is built on the AT Protocol and does exactly this for a flat $7/month, with a free tier. Build it yourself to learn the protocol; reach for a tool when you would rather ship content than maintain a cron worker.

FAQ

Does the AT Protocol have a built-in schedule field?

No. The createRecord endpoint publishes immediately. You implement timing yourself with a cron job or a job queue that calls createRecord when the post is due.

Why are my links not clickable when posting via the API?

Bluesky uses a facets array indexed by UTF-8 byte offsets to mark links, not auto-detection. Use the RichText detectFacets helper in @atproto/api so byte offsets are computed correctly, especially when emoji or non-ASCII text precedes the URL.

Should I use my real password to authenticate?

No. Generate an app password in Bluesky settings and authenticate with that. App passwords are revocable and scoped, so a leaked one does not compromise your full account.

Schedule your BlueSky posts with ONYX

AI drafts in your voice, a real calendar, threads, and analytics - built for BlueSky. Free forever, no credit card.

Start Free

Related ONYX resources

Keep reading