# Build your own Blockenheimer

This is a spec for a coding agent. Hand it to Claude Code, Codex, or whatever you use, and it should be enough to build your own version of a little tool I use on Bluesky. The original is about 250 lines of Python in my dotfiles. You'll probably end up with something similar.

## What it does

You give it a Bluesky post URL. It adds the post's author and everyone who liked the post to a block list (a moderation list that you or anyone else can subscribe to and block).

```sh
blockenheimer https://bsky.app/profile/someone.bsky.social/post/3abc123xyz
blockenheimer --dry-run https://bsky.app/profile/someone.bsky.social/post/3abc123xyz
```

## Why

Every so often a truly vile post goes viral. The people who like it are a pretty good list of accounts I don't want to hear from, and blocking them one at a time isn't practical. Putting them on a list instead of blocking them directly means I can share the list, look over it later, and take people off it in the normal Bluesky UI.

## Shape of the thing

- One standalone Python script run with `uv`, with PEP 723 inline dependencies. The only dependency is the `atproto` SDK. No config files, no database, no server.
- Configuration lives in constants at the top of the script, in a clearly marked `CONFIGURATION` block, so whoever copies it edits it there:
  - `LOGIN_HANDLE`: the account that owns the list and does the blocking.
  - `LIST_RKEY`: the list's record key, i.e. the last path segment of its `bsky.app/profile/<you>/lists/<rkey>` URL. The list has to be created beforehand in the Bluesky app, as a moderation list, by `LOGIN_HANDLE`.
  - `PROTECTED_ACCOUNTS`: other accounts of yours whose social graphs should be protected too (see below).
  - The name of the environment variable that holds the app password.
- Auth is an app password (Settings → Privacy and Security → App Passwords) taken from an environment variable. Never a real password, and never a command-line argument, because that ends up in shell history.
- Progress goes to stderr.

## The flow

1. Parse the URL with a regex: `https://bsky.app/profile/<handle-or-did>/post/<rkey>`. Exit with an error on anything else.
2. Log in. Use a generous request timeout (I use 30s) because the default in the SDK was too short for big lists.
3. Resolve the handle to a DID unless it's already a `did:`, then build the AT URI: `at://<did>/app.bsky.feed.post/<rkey>`.
4. Page through `app.bsky.feed.getLikes` (limit 100, follow `cursor` until it's empty) and collect the likers' DIDs.
5. The block set is likers + the author, minus yourself.
6. **Remove anyone connected to you.** For `LOGIN_HANDLE` and every account in `PROTECTED_ACCOUNTS`, page through both `getFollows` and `getFollowers` and drop anyone who shows up there. Print the handles you skipped so you can see who got spared. This matters more than you'd expect: friends sometimes like bad posts ironically, or before they've read the whole thing, and you don't want to find out a week later that you've blocked a mutual.
7. Fetch the current list members with `app.bsky.graph.getList` (paginated) and subtract them. Only add new people.
8. With `--dry-run`, print who would be added and stop.
9. Otherwise create one `app.bsky.graph.listitem` record per DID (`subject`, `list`, `createdAt`) in your own repo. Sleep about 100ms between writes to be polite to the PDS.

## Details worth copying

- **Cache list membership on disk.** Lists get big, and re-fetching thousands of members on every run is slow. Cache the member DIDs as JSON in `$XDG_CACHE_HOME/blockenheimer/` (default `~/.cache/`), keyed by list URI, with a timestamp and a TTL of a week. Write it atomically (temp file + `os.replace`). After adding people, update the cache with the new members instead of throwing it away. If the cache is missing, stale, for a different list, or broken in any way, fall back to fetching the list. A stale cache isn't a big deal: worst case you try to add someone who's already on the list.
- **Handle the boring cases.** A post with no likes still blocks the author. If everyone is already on the list, say so and exit cleanly.
- **Don't confirm interactively.** `--dry-run` is the safety valve. The tool is meant to be run quickly from a terminal when you're annoyed.

## Things I deliberately didn't do

- No reposters or quote posters. Likes are the clearest signal, and quote posts are often people dunking on the original, who you probably don't want to block.
- No unblock or undo command. The list is visible in the Bluesky app, so you can remove someone there.
- No concurrency. Sequential writes are fast enough and less likely to get rate limited.

## Testing it

Make a throwaway moderation list and point `LIST_RKEY` at it. Run `--dry-run` against a post with a handful of likes and check that the protected-accounts filter removes someone you follow. Then do it for real and check the list in the app.
