Three AT Protocol behaviors that shaped my Bluesky post queue design
Three behaviors stopped me from shipping my first Bluesky post queue correctly. None of them are prominently surfaced in the AT Protocol lexicon documentation in a form that would have helped me avoid them. I found all three by reading failed requests in CI logs. The AT Protocol enforces rate limits

Three behaviors stopped me from shipping my first Bluesky post queue correctly. None of them are prominently surfaced in the AT Protocol lexicon documentation in a form that would have helped me avoid them. I found all three by reading failed requests in CI logs. 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. My first version of the queue script ran nightly at 07:00 JST and posted three items: the morning article link, an afternoon reshare, and an evening site update. The timing worked until I added a burst of retrospective posts from a backlog flush. The hourly limit triggered mid-flush because I was tracking daily headroom, not hourly. The fix: track the create count in the JSONL queue ledger per session, with a rolling 60-minute window check before each create call. If the projected call would exceed headroom, the script waits until the oldest create in the window is more than 60 minutes old. Not elegant but reliable. This also means 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. 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. I handle this by treating the blob's CID as a cache key in the JSONL queue. If the queue record already contains a blob_cid field, the post step skips the upload and reuses the existing CID. If the createRecord fails again, the retry doesn't create a second orphan. The blob upload becomes idempotent via the ledger even though the API itself isn't. The practical implication for queue design: keep the blob reference and the queue item in the same record. If you store them separately โ blob uploaded and CID stored in one system, queue item in another โ you can lose the CID association on a crash and re-upload the same image, accumulating orphans over time. 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 the 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. The JSONL post queue I've been running since April now handles all three cases explicitly: Each queue entry tracks a create_count_window timestamp to check rolling headroom before posting Blob CIDs are written back to the queue entry on successful upload, so retries reuse the same blob createdAt is always populated with Date.now() at the moment the script fires, not pulled from the entry None of these required significant changes to the overall architecture. They're all three-line fixes once you know which behavior you're working around. The difficult part is knowing which behavior caused the 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.
Key Takeaways
- โขThree behaviors stopped me from shipping my first Bluesky post queue correctly
- โขThis story was reported by Dev.to, covering developments in the dev space.
- โขAI advancements continue to reshape industries โ read the full article on Dev.to for complete coverage.
๐ Continue reading the full article:
Read Full Article on Dev.to โShare this article



