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

> Source: <https://dev.to/morinaga/three-rules-my-bluesky-non-mutual-unfollow-script-uses-to-avoid-churn-flags-47ee>
> Published: 2026-07-29 08:16:43+00:00

I'm growing a Bluesky account to distribute content for three AI directory sites — [aiappdex.com](https://aiappdex.com), [findindiegame.com](https://findindiegame.com), [ossfind.com](https://ossfind.com). The [follow growth ramp I built in July](https://dev.to/morinaga/how-i-built-the-bluesky-follow-growth-automation-with-ramp-up-and-quality-filters-27kp) 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.

``` js
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:

``` js
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 pauses. 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:

``` js
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.*
