I'm growing a Bluesky account to distribute content for three AI directory sites — aiappdex.com, findindiegame.com, ossfind.com. The follow growth ramp I built in July was adding around 100 targeted accounts per day when I wrote this, with the cap recently raised to 150/day. The problem: not everyone follows back, and a list of 2,000 followed accounts where 1,400 are non-mutual is noisy — and, more concretely, resembles what spam accounts do.
AT Protocol does track follow-unfollow patterns. Rapid follow-then-unfollow churn is the primary signal low-quality growth tools produce, and Bluesky moderation flags accounts for it. So when I built the pruner, I wrote the safety rules in first and the core logic second.
Rule 1: A 14-day grace period before any account is eligible for unfollowing
Non-mutual doesn't mean never-will-follow. A newly followed account might not have seen the notification yet, or might check Bluesky infrequently. I settled on 14 days as the minimum grace period because most active users check notifications at least weekly, and I'm not following accounts with millions of followers — my targets are developers and tech-adjacent people who post irregularly.
const cutoff = now.getTime() - graceDays * 24 * 3600 * 1000;
return followEntries.filter((e) => {
const t = Date.parse(e.followed_at ?? "");
return Number.isFinite(t) && t <= cutoff;
});
An account I followed on July 1 isn't eligible until July 15. If they follow back on July 14, viewer.followedBy flips to true and they're kept permanently regardless of the eligibility window. The grace period is configurable via GRACE_DAYS env var; I'm keeping it at the default 14.
Rule 2: A daily cap of 30 unfollows counted in JST
Even within eligible accounts, the script never unfollows more than 30 per calendar day. The count is read from a persistent unfollow log file (data/bluesky-unfollow-log.jsonl), filtered to today's JST date:
const doneToday = logEntries.filter(
(e) => e.action === "unfollowed" &&
jstDate(new Date(e.unfollowed_at)) === today
).length;
return Math.max(0, cap - doneToday);
I'm counting in JST because my follow growth script also uses JST, and I want both numbers on the same calendar page when I audit them. If my daily unfollow count ever approaches my daily follow count, something is wrong with my targeting. Consistent timezone makes that arithmetic immediate.
Why 30 as the cap? It's a fraction of my daily follow rate. If the script malfunctions and attempts to unfollow the entire follow list at once, it burns 30 on day one, then pauses. The cap resets the next calendar day and I notice before significant damage accumulates.
Rule 3: The script only touches accounts from its own follow log
The riskiest design mistake a pruner can make is unfollowing accounts you followed manually — people you're genuinely interested in who didn't follow back, which is entirely normal Bluesky behavior. The script avoids this by treating data/bluesky-follow-log.jsonl as the strict source of eligible candidates:
const followEntries = await readLog(FOLLOW_LOG_PATH);
const candidates = eligibleEntries(followEntries, processedDids, now, GRACE_DAYS);
If a DID isn't in bluesky-follow-log.jsonl, the script never evaluates it. Accounts I followed via the Bluesky web app or desktop client are completely invisible to the pruner. The log is append-only and stays permanent — an unfollowed account's entry remains in the log, which means the follow growth script won't re-follow it in a later run.
Deleted and suspended accounts: no mutuality check, but no grace-period bypass
Accounts that return no profile from the AT Protocol (deleted, suspended, or deactivated) are treated as dead follow records — the decision function unfollows them without checking mutuality. They do still wait out the 14-day window, though: candidates are filtered by the grace period before any profile is fetched, so a deleted account followed five days ago simply isn't evaluated yet.
export function decide(profile) {
if (!profile) return "unfollow"; // account gone; dead follow record
if (profile.viewer?.followedBy) return "keep-mutual";
if (!profile.viewer?.following) return "skip-not-following"; // already unfollowed manually
return "unfollow";
}
The skip-not-following branch handles accounts I unfollowed manually before the script ran — viewer.following being false means the follow record is already gone, so the script skips silently rather than trying to delete a record that doesn't exist.
How it runs in CI
The script runs as a separate GitHub Actions workflow from the follow growth script. Commit messages from the unfollow log updates include [skip bluesky-unfollow], and each workflow checks only for its own tag — since both workflows run on cron schedules and manual dispatch rather than on push, the tags don't actually gate anything today.
Run sequence each day: follow growth runs first (adds new follows), then the pruner runs (evaluates accounts against the now-updated log). The daily cap resets at JST midnight, so both scripts start each day from zero.
A week in, I don't have useful impact numbers yet. Most of my follows are still inside the 14-day grace window, so the pruner is mostly evaluating eligibility rather than actually unfollowing. The real data — how many non-mutuals shake out after 14 days — should be visible in another week. I don't know if 14 days is too conservative or about right; I'll adjust based on what the log shows.
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)