Auto-Posting to Bluesky with Flask, APScheduler, and atproto
If you follow @nerdymark.com on Bluesky, you mostly see me posting the regular way, from the web and the iOS app. But mixed in with the human posts, the account also announces new blog posts, celebrates Steam achievements minutes after they unlock, reports meteors falling out of the sky, and publishes verification digests - all on its own. Here's how the whole thing works: one Flask app, one scheduler, and a handful of small announcer bots that share the same plumbing.
The shared plumbing
Everything runs inside the same Flask application that serves this site, using Flask-APScheduler for timing and the official atproto Python SDK for the AT Protocol. A single BlueskyBlogIntegration class owns the client session and knows how to do the fancy parts:
- Rich link cards: an
app.bsky.embed.externalembed with a title, description, and a thumbnail. Bluesky doesn't fetch your OG tags for you - the client has to download the image, upload it as a blob, and attach it to the record. Skip that and every card ships gray and sad. - Low-level record creation: posts go through
com.atproto.repo.create_recorddirectly, which allows attaching self-labels and custom embeds that the convenience helpers don't expose. - Duplicate protection: every announcer persists what it has already posted (DynamoDB, plus tracking labels on the posts themselves), so an app restart never replays old announcements.
That last point is the golden rule of the whole system: record state first, announce second. Every bot below follows it.
Method 1: The blog cross-poster
Every four hours, a scheduler task scans the in-memory posts list for anything promoted, created in the last 24 hours, and not yet stamped with a bluesky_url. Matches get posted with a link card that uses the post's OG image as the thumbnail, and the resulting URL is written back onto the post record in DynamoDB - which doubles as the "already posted" flag.
The loop also runs in reverse: every 30 minutes another task pulls likes, replies, and reposts from Bluesky back onto each blog post, so the engagement counts you see on this site are real Bluesky numbers.
Method 2: Steam achievements
The newest bot. Every 15 minutes it asks the Steam Web API two questions: which games has this account played in the last two weeks? (GetRecentlyPlayedGames) and what's the achievement state in each? (GetPlayerAchievements). It diffs the unlocked set against what's stored in DynamoDB, and anything new gets announced with the achievement's name, description, icon, and a link to the game's achievements page.
Two design details do the heavy lifting:
- Silent seeding: the first time the bot sees a game, it records every already-unlocked achievement without posting. Years of backlog never hit the feed - only unlocks that happen after the bot knows the game.
- Flood cap: at most five announcements per cycle, oldest unlock first. A marathon session can't turn the account into a slot machine. Anything over the cap is still persisted as seen, so it isn't re-detected next cycle - it just goes unannounced.
One gotcha if you build your own: Steam's overall profile visibility and the Game details privacy setting are separate. GetPlayerAchievements returns 403 "Profile is not public" until Game details is set to Public, even on a public profile.
Method 3: Skyfall monitor
Every 15 minutes, a scraper checks NASA's fireballs page for newly recorded meteor events. New event IDs get posted with date, time, and location. Same pattern as everything else: remember what's been seen, announce only the delta.
Method 4: Verification digests
The most elaborate one. A background thread holds a WebSocket to Bluesky's Jetstream firehose and watches verification records get created in real time. Detected verifications go into a pending queue in DynamoDB, and scheduled digest tasks batch them into posts with proper @mention facets (byte-offset ranges pointing at each account's DID, so the mentions render as links). Digests that would blow past the character limit split into threads automatically.
Lessons learned
Timestamps will humble you. The post record's createdAt is written by the client, and Bluesky sorts feeds by it - while the "4 minutes ago" label comes from indexedAt, when the server received the post. This code originally stamped createdAt with datetime.now() plus a hardcoded "Z". Fine on the UTC production server; on my Mac, it backdated a test post by seven hours. The post existed, the direct link worked, but it was buried deep in the timeline reading "4 minutes ago" between hours-old posts. Always use timezone-aware UTC.
Idempotency is the whole game. Restarts, redeploys, and crashed cycles are normal life for a scheduler. If your bot can't survive replaying its own state, it will eventually double-post - probably at 3 AM.
Caps and seeds keep bots polite. Every announcer here answers two questions before it ships: what happens on the very first run, and what happens after an unusually eventful cycle? Silent seeding answers the first, per-cycle caps answer the second.
Why Bluesky? Because the API is free, open, and pleasant. This site used to cross-post to Twitter/X too, until the API went pay-to-play - that story has its own post. On the AT Protocol, everything described above costs nothing but a scheduler slot.
The result is a set of bots that fill in around my own posting without me lifting a finger. The next time you see a 🏆 up there, know that a Raspberry-Pi-grade amount of Python noticed it, checked its notes, uploaded a thumbnail, and told the world - all before the next race loaded.