cd /news/developer-tools/three-rules-my-bluesky-non-mutual-un… · home topics developer-tools article
[ARTICLE · art-78280] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

Three rules my Bluesky non-mutual unfollow script uses to avoid churn flags

A developer built a Bluesky unfollow script that prunes non-mutual follows while avoiding churn flags. The script enforces a 14-day grace period before unfollowing, caps daily unfollows at 30, and only targets accounts added via the developer's own follow log, ignoring manually followed accounts. The rules are designed to avoid triggering Bluesky's moderation for follow-unfollow churn.

read4 min views3 publishedJul 29, 2026

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 adds 20–30 targeted accounts per 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.

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.

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 I'm following 25 per day and unfollowing 30 per day (net -5), something is wrong with my targeting. Consistent timezone makes that arithmetic immediate.

Why 30 as the cap? It's roughly the same order of magnitude as 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 s. The cap resets the next calendar day and I notice before significant damage accumulates.

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.

Accounts that return no profile from the AT Protocol (deleted, suspended, or deactivated) get unfollowed immediately without waiting for the 14-day window. A deleted account can't follow back, so waiting serves no purpose.

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.

The script runs as a separate GitHub Actions workflow from the follow growth script. Commit messages from the unfollow log updates include [skip bluesky-follow]

to prevent cross-workflow loops — the follow growth workflow checks for that tag and bails if it's present.

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.

Two weeks 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.

── more in #developer-tools 4 stories · sorted by recency
── more on @bluesky 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/three-rules-my-blues…] indexed:0 read:4min 2026-07-29 ·