I shipped the automated follow-growth script for this project's Bluesky account this week, and the design turned out more opinionated than I expected. The short version: it follows, it never unfollows, it ramps up cautiously, and it aborts rather than silently resetting its own safety state. Here's how it works and what I'd do differently.
Why Bluesky warranted automation
My distribution setup is cross-posting articles to Dev.to, Hashnode, and Bluesky simultaneously. When I looked at 187 posts of engagement data across hashtags, the numbers were lopsided: #godot and #hanafuda both had a median of 53 interactions per post. #ai — where most of my content lives — had a median of 4.
That's a 13× gap. I can't control which tags resonate on which platform, but I can follow the audience that actually shows up. The follow-growth script sources its candidates from the tags that performed, not the tags I write for.
Without follower base, Bluesky cross-posts are essentially outbound links to nobody. The engagement data made it clear that building an audience there was the actual constraint, not publishing frequency. And building an audience manually — scrolling hashtag feeds, finding individual accounts — doesn't scale past about 20 minutes a day.
The one rule that shaped everything
The script has a single hard constraint: follow-only, never unfollow programmatically. This isn't a conservative preference. Bluesky's moderation documentation explicitly describes follow→unfollow churn as a pattern they look for. An account that follows 100 people today and unfollows 80 of them tomorrow — repeatedly — gets flagged.
I lose the account, I lose the distribution channel entirely. There's no fallback. So the code has zero unfollow logic. I didn't even stub it out as a future feature — it's not a feature I want to accidentally enable.
The consequence: if the script follows a bad account (spam, bot, wrong niche), that follow lives there permanently. This makes the quality filter, not the unfollow logic, the thing that actually matters.
Ramp-up caps and the dedup log
New automation that immediately runs at full speed looks like a bot. The script ramps: days 1–3 cap at 30/day, days 4–7 cap at 60/day, day 8 and beyond cap at 100/day.
export function dailyCapForDays(activeDays, override) {
if (override && Number.isFinite(override) && override > 0) return override;
if (activeDays < 3) return 30;
if (activeDays < 7) return 60;
return 100;
}
activeDays is not the number of calendar days since the script was deployed — it's the number of distinct JST dates actually present in the follow log. If the cron skips a day (GitHub Actions does this under heavy load), the ramp-up pauses. Ramp-up reflects actual activity, not wall-clock time.
The follow log is data/bluesky-follow-log.jsonl, one JSON object per line:
{"did":"did:plc:abc123","handle":"someone.bsky.social","followed_at":"2026-07-14T01:23:00Z","source_tag":"#indiegame","uri":"at://..."}
It serves two purposes: dedup store (an account ever followed is never followed again, even if later manually unfollowed) and the source of truth for the ramp-up calculation.
The read logic is fail-closed. If the file exists but contains a corrupt line, the script throws rather than treating it as an empty log:
logEntries = raw.split("\n").filter(Boolean).map((l, i) => {
try {
return JSON.parse(l);
} catch {
throw new Error(`corrupt follow log at line ${i + 1}: ${l.slice(0, 80)}`);
}
});
A reset-on-corrupt approach would silently re-follow everyone in the first batch and lose the ramp-up history. That's worse than aborting. Fail closed.
Candidate discovery
The script collects candidates from app.bsky.feed.searchPosts per hashtag, pulls the most recent 100 posts per tag, and deduplicates by author DID. The hashtag list comes from data/bluesky-follow-config.json:
{ "tags": ["#indiegame","#gamedev","#indiedev","#pixelart","#godot","#screenshotsaturday"] }
These are exactly the tags the engagement profile pointed to as my actual audience. I didn't pick them by intuition — I picked them after analyzing 187 posts.
The collection is round-robin across tags so no single hyperactive tag dominates. The script collects 4× the number of candidates it needs to follow, because the quality filter typically rejects more than half.
After collection, profiles are fetched in batches of 25 via app.bsky.actor.getProfiles. The 25-per-batch limit is an AT Protocol constraint; the script handles pagination automatically.
Nine rejection reasons
Every candidate goes through rejectReason() before the script attempts a follow. The function returns a string describing why the candidate is rejected, or null if they're acceptable. The profile data comes from the app.bsky.actor.getProfiles lexicon, batched 25 at a time.
| Reason | What it catches |
|---|---|
self |
The account is the bot itself |
already-in-log |
Already followed at some point (dedup) |
already-following |
Already following right now |
already-follows-us |
They found us organically — don't burn quota, let the relationship be natural |
blocked / blockedBy
|
Blocked in either direction |
muted |
Muted by the bot account |
no-avatar |
Default-avatar accounts are almost always abandoned or bot registrations |
too-few-followers |
Under 15 followers — likely inactive or brand-new |
too-big |
Over 20,000 followers — megaphone account, won't see our follow |
follow-spammer |
Over 8,000 follows — mass follower, follow-back is worthless |
bad-ratio |
follows / followers > 25 for accounts with >100 followers |
The already-follows-us case is worth calling out. If someone has already found and followed the account organically, following them back burns quota on a relationship that already exists. The script leaves that to human judgment.
The no-avatar filter is a heuristic. There are real people who post without avatars. But among the accounts I checked, no-avatar correlated heavily with abandoned accounts and bot registrations, so the false positive rate is low enough to accept.
The ratio filter exempts small accounts: accounts with ≤100 followers are often genuinely new and follow-heavy, not spammers. The math is followers > 100 && follows / max(followers, 1) > 25.
GitHub Actions integration
Two cron slots per day: 01:23 UTC (JST 10:23) and 11:41 UTC (JST 20:41). Off-minute timing is deliberate — the top of the hour has heavy queue drift because every scheduled workflow on GitHub tries to start at the same second.
The workflow adds a random start delay of 0–5 minutes before the script runs, further spreading the fire time.
The script is invoked with node scripts/bluesky-follow-growth.mjs — no pnpm install, no package resolution. The script uses only Node built-in modules (node:fs/promises, node:path) plus fetch, available natively in Node 22. Skipping pnpm install saves 40–60 seconds of Actions minutes per run.
The follow log is committed after each run. The commit step has a push-with-rebase retry (three attempts) because the log file is the only write target and concurrent runs are prevented via concurrency: group: bluesky-follow. The push can still fail if the main branch moved between runs, hence the rebase.
What worked, what didn't, what I'd do differently
Worked: the fail-closed log design. The first time I tested the script against a staged corrupt log, it threw exactly where it should and printed the line number. That's the behavior I want in production.
Worked: no-unfollow as a hard constraint from day one. Not having the feature means it can't accidentally ship.
Worked: pulling the tag list from the engagement profile rather than intuition. I originally assumed #ai and #webdev were the right targets. The data said otherwise.
Didn't work initially: the QC gate I built for outbound posts had a separate candidate pool with different criteria. The follow-growth script and the QC gate weren't using the same tag list, which meant I was optimizing for engagement on one axis and sourcing candidates from a different axis. I unified the tag source.
Would do differently: I'd log per-hashtag accept/reject rates. The current rejects object at the end of a run aggregates across all tags. If #screenshotsaturday produces 95% too-big rejections and #indiedev has a 60% acceptance rate, I'd want to rebalance the tag list. Right now I'd have to add a separate debug mode to see that.
I also considered adding a daily-follows-committed metric to the repo somewhere visible — so I can see the ramp-up progression without parsing the JSONL file. The file is there, the data is there, it's just not surfaced anywhere. A summary card would be the natural home for it.
FAQ
Can the script follow-back accounts that followed you?
No. already-follows-us is an explicit rejection reason. The script ignores mutual-follow candidates entirely.
What happens if the GitHub Actions cron skips a day?
Nothing bad. The ramp-up is based on distinct active days in the log, not calendar days. A skipped day just means the daily cap stays at the current tier for one more day.
Why store the log in the repo instead of a database?
The log is append-only, small (one line per follow), and the commit history gives me a free audit trail. Introducing a database for 100 rows/day would cost more than it's worth. If volume grows, I'd move to Turso libSQL and commit only a summary.
Can I use the same script for a different platform?
The AT Protocol lexicon (app.bsky.graph.follow, app.bsky.feed.searchPosts, app.bsky.actor.getProfiles) is Bluesky-specific. The ramp-up, dedup, and quality-filter patterns are reusable but would need new API calls.
What's the end state?
I don't have a follower target number. I'm watching whether the follow-back rate is positive, and whether bluesky post reach trends up over the next 30 days. If it does, I'll let the automation run at the 100/day cap indefinitely. If it doesn't move the needle, I'll reassess the whole channel strategy.
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)