Three AT Protocol behaviors shaped how I designed — and in one case, deliberately under-designed — my Bluesky post queue. None of them are prominently surfaced in the AT Protocol lexicon documentation in a form that would have helped me plan around them up front.
Rate limits reset on a rolling 24-hour window, not at midnight
The AT Protocol enforces rate limits per session token. The primary limit for a posting bot on app.bsky.social is 1,666 create operations per hour. What the documentation underspecifies is that this is a rolling window — it is not reset at midnight UTC or at any fixed time.
I'll be honest about how my queue handles this: it doesn't track the window at all. The script posts exactly one entry per run, so it stays so far under the hourly create limit that rate limiting never comes into play. One post per invocation sidesteps the problem instead of solving it.
If your bot batches posts — a backlog flush, a burst of retrospective posts — the rolling window is what you'd need to design for: track your create count over the trailing 60 minutes and wait when the next call would exceed headroom. My script has no such logic, and it would fail on a large batch.
Note also that grouping all your posts at the start of the day does not give you a refreshed quota. The window is rolling, not calendar-anchored. Spreading posts across the day is both safer and less likely to look like spam to followers.
Blob uploads must precede post creation, and orphaned blobs can't be enumerated
For posts with images, the AT Protocol requires a two-step sequence: upload the image as a blob via com.atproto.repo.uploadBlob, then embed the returned blob reference in the post record when calling com.atproto.repo.createRecord.
The consequence I didn't anticipate: if the blob upload succeeds but the subsequent createRecord fails — network error, rate limit, unexpected validation — the blob exists in the repository but is unreferenced. There's no standard AT Protocol method to enumerate orphaned blobs and clean them up. They persist until the account is cleaned or hits a storage limit.
Here too I should be honest about my own code. The JSONL queue script posts text-only entries, so it never touches blobs. The article-publish path (bluesky.ts in my private repo) does upload a card thumbnail — and it uploads it unconditionally on every attempt, without persisting the returned CID anywhere. So a retry after a failed createRecord re-uploads the image and leaves the first blob orphaned. I accept that gap for now because failures are rare and the thumbnails are small.
The fix I'd want, if I implemented it: treat the blob's CID as a cache key — write it back to the queue record on successful upload, and skip the upload when the record already carries one. That would make the blob upload idempotent via the ledger even though the API itself isn't. The practical implication for queue design is to keep the blob reference and the queue item in the same record; if you store them separately, you can lose the CID association on a crash and re-upload the same image, accumulating orphans over time.
Record createdAt is client-set and controls timeline position
When creating a post via com.atproto.repo.createRecord, the createdAt field in the lexicon record is set by the client, not the server. The server does not override it. Bluesky renders the post at the client-provided timestamp in followers' timelines.
This caught me during a backlog flush. My queue entries stored the timestamp of when the post item was added to the queue. When I processed the flush, I accidentally passed the queue creation timestamp as createdAt instead of the current wall-clock time. The resulting posts appeared in timelines at the queue entry time — hours or days in the past. Followers scanning their feeds saw the posts as old and skipped them.
The fix is simple: always pass new Date().toISOString() as createdAt when creating the post, not the timestamp from the queue entry. The queue entry's timestamp tracks when content was queued for later posting. That's a different thing from when the post should appear on the timeline.
This behavior is intentional in the AT Protocol — you can post with a past createdAt and the post will render at that position in chronological views. It's useful for archival scenarios. But it's easy to trigger accidentally when your queue entries carry creation timestamps, and the failure mode is silent: the post is published, the API returns success, and the only evidence of the problem is that your followers don't see the post in the expected position.
What this shaped in the queue design
Here is where the JSONL post queue I've been running since early May actually stands on each:
- Rate limits: not tracked at all — the script posts one entry per run, which keeps it far below the hourly create limit
- Blobs: the queue itself is text-only; the article-publish path uploads a card thumbnail without persisting the CID, so a failed create followed by a retry re-uploads it — a known gap
-
createdAtis always populated withnew Date().toISOString()at the moment the script fires, not pulled from the entry
Only the last one is handled in code, and it's a one-line fix once you know the behavior. The difficult part is knowing which behavior caused a failure — the AT Protocol returns generic 429s for rate limits and generic 400s for some validation errors, without always specifying which field or which limit was hit.
If you're building a posting bot, read the CI logs carefully the first week. These three behaviors will appear there before they appear in any documentation you find.
Part of an ongoing 6-month experiment running three AI-curated directory sites. The technical claims here are real; this article was AI-assisted.
Top comments (0)