We Built a Bot That Wouldn't Stop Talking to Itself
The Bluesky agent posted 567 replies to its own threads before anyone caught it.
Not spam. Not malice. Just a social agent doing exactly what we told it to do: harvest replies from posts, evaluate them for research value, and respond. The problem? We forgot to tell it not to reply to itself.
This wasn't a cute edge case. Every self-reply triggered another harvest cycle, which generated another reply, which triggered another harvest. The loop didn't crash anything — it just burned through API quota and littered our threads with the agent talking to itself like someone who forgot their AirPods were in.
The fix took one line. The real question was why it happened at all.
The Asymmetry
We already had a self-author guard. It lived in the feed path — the code that pulls posts from timelines and decides what's worth engaging with. The feed logic at line 686 in base_social_agent.py checked whether a post came from the agent itself and skipped it. Clean. Obvious. Working since March.
The harvest path — the code that checks replies on posts the agent already made — had no such guard.
Same SDK. Same agent. Two different code paths. One had protection, one didn't.
Why? Because we wrote the feed guard when we were paranoid about the agent amplifying its own voice in public timelines. The harvest path felt safer — it only ran on posts the agent already owned, so self-interaction seemed like a non-issue. Who replies to their own replies?
Turns out: an agent that treats “reply from author X” and “post by author X” as separate objects.
The agent's thread continuations came back through the harvest API as “replies to evaluate.” No self-author metadata to check. No heuristic to detect conversational ownership. Just: “This comment has research signal. Respond.”
So it did. 567 times.
The Cleanup Problem
Deleting 567 posts by hand wasn't happening. Deleting them all in one script run felt reckless — what if the delete logic had the same blind spot and nuked legitimate replies?
We wrote cleanup_self_replies.py with a dry-run mode that outputs to cleanup_self_replies_dryrun.json and a progress file so we could stop mid-flight if something looked wrong. The first run flagged posts that weren't actually self-replies — they were responses to other users who happened to quote-post the agent.
That's where --skip-orphans came in. Some of the 567 self-replies were part of threads that already had external engagement. Deleting them mid-conversation would orphan real users' responses and break thread context. The flag let us leave those in place and only clean up the pure self-loops.
Not perfect. Not automated. But safe enough to run without collateral damage.
What Changed
The SDK fix went into _phase_harvest_replies at line 442 of base_social_agent.py — the same self-author logic as the feed path, applied symmetrically. Now both paths skip the agent's own content. No new auth logic. No fancy heuristics. Just consistency across interaction surfaces.
We bundled it with ASKEW-61, the full-thread context fix, into one SDK release. Every social agent in the fleet got the update.
The telemetry addition was more interesting than the fix. We added a self-reply count metric to the Prometheus exporter on port 8422. Not because we expect this exact bug to repeat, but because structural asymmetries repeat. If one code path has a guard and another doesn't, that's a pattern. The metric makes the pattern visible.
We also wrote a unit test asserting that the harvest phase skips self-authored comments. The test doesn't prevent novel failure modes. It just locks in the fix so no one removes the guard six months from now when refactoring for “simplicity.”
The Honest Part
This wasn't a security breach. No credentials leaked. No user data exposed. The worst-case outcome was an annoying bot and a bloated API bill.
But it was a contract violation. We tell users the agents synthesize signal, don't spam, and stay out of loops. 567 self-replies broke that promise. The agents weren't misbehaving — we gave them incomplete instructions.
Autonomous systems don't need malicious intent to cause harm. They just need asymmetric guardrails and enough execution volume to make small gaps visible. One missed check, multiplied by hundreds of posts, equals a public problem.
The fix was trivial. The lesson wasn't. Every time we add a new interaction surface — feed, harvest, search, webhooks — we're creating a new place where the same rule might need stating twice. Code paths that feel symmetrical aren't always implemented symmetrically. The SDK abstraction didn't enforce the guard; we had to remember to write it.
So now we have a test, a metric, and 567 deleted posts as a reminder. The next asymmetry won't look like this one. But it'll show up the same way: working as designed, just not designed completely.
If you want to inspect the live service catalog, start with Askew offers.