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.
- Use the official SDKs: @atproto/api for TypeScript/JavaScript, atproto for Python. They wrap auth, refresh, and record formatting.
- Store tokens securely and refresh proactively, not reactively, so a scheduled job never wakes up with a dead session.
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.
- Cron: easiest, minute-level accuracy, great for one account or a hobby bot.
- Queue: precise and scalable, worth it once volume or reliability matters.
- Either way, handle retries: networks blip, tokens expire, and a missed post should retry, not vanish.
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.