Contents

syndicating botwerks via POSSE

what is POSSE?

POSSE stands for “Publish on your Own Site, Syndicate Elsewhere”. it’s an indieweb pattern where your own site is the canonical source of truth for what you write, and the social platforms get pointers/copies that link back home. the inverse of the more typical mode, where you write on twitter/mastodon/bluesky and your blog (if you even have one) is an afterthought.

i’ve been meaning to wire this up for botwerks for a while. the goal is simple: when i merge a new post to main, something picks it up, waits for the deploy to settle, and pushes a short link to both mastodon and bluesky. that’s the whole thing. no fancy webmentions, no rich embeds, no salmention shiz. just a link with a title and (when it fits) a description.

the moving parts

there are three elements:

  1. a python script that does the syndicating: bin/posse.py
  2. a github action that runs that script on every push to main
  3. a state file at data/syndicated.json that tracks what’s already been posted so nothing gets double-posted

i’ll provide an overview of all of these elements.

the script

bin/posse.py is a single-file python script that uses uv for dependency management. the inline script metadata at the top of the file declares the runtime requirements - atproto, Mastodon.py, python-frontmatter, grapheme, and requests. no requirements.txt, no virtualenv setup, just uv run bin/posse.py. this is one of the nicer python developments of the last couple of years.

note: the following is an embedded gist



at a high level it does this:

  1. walks content/posts, content/til, and content/links looking for markdown files
  2. filters out drafts, future-dated entries, and anything with syndicate: false in the frontmatter
  3. for content/links/ entries it also requires a syndicate tag. most links are ephemeral and don’t deserve a social post by default
  4. builds the canonical permalink from the file’s date and basename to match hugo’s permalink config as defined in my hugo.toml (:year/:month/:contentbasename)
  5. compares that against data/syndicated.json to find new entries
  6. for each new entry it polls feed.json until the entry actually appears live (so we don’t post a link before cloudflare has propagated the build), then posts to mastodon and bluesky
  7. updates the state file after each successful post

a couple of details worth calling out:

  • bluesky has a hard 300 grapheme (yeah, i had to dig into this. a grapheme is a user perceived character.) limit on post length. the script measures with the grapheme package rather than len() because emoji and combining characters will throw off naive character counting. if a post would overflow, the description gets truncated with an ellipsis until the whole thing fits.
  • mastodon doesn’t have the same tight limit (500 chars by default, higher on my instance), but i format both posts identically for consistency.
  • bluesky needs an explicit text builder with a URL facet to make the link clickable. mastodon parses URLs from text automatically.
  • the script writes the state file atomically (write to .tmp, then rename). an interrupted run can’t leave a half-written JSON blob behind.

the github action

the workflow lives at .github/workflows/posse.yml. it triggers on push to main when any of these paths change:

  • content/posts/**
  • content/til/**
  • content/links/**
  • bin/posse.py
  • the workflow itself

note: the following is an embedded gist:



before running the script the workflow sleeps for 120 seconds. that’s a buffer for the hugo build on cloudflare pages plus cloudflare’s edge propagation. it’s not strictly necessary - the script polls feed.json for each entry before posting anyway - but it cuts down on wasted polls.

the workflow uses a github environment to gate access to the social account credentials. the environment holds:

  • MASTODON_INSTANCE_URL (variable)
  • MASTODON_ACCESS_TOKEN (secret)
  • BLUESKY_HANDLE (variable)
  • BLUESKY_APP_PASSWORD (secret)

after a successful run, if data/syndicated.json has been modified the workflow commits it back to the repo. the commit message ends with [skip ci] so the resulting push doesn’t trigger another run of the workflow. without the [skip ci] token the workflow would loop on its own state commits, burning CI minutes for no reason.

there’s also a workflow_dispatch trigger with a dry_run input. this lets me run the workflow from the github UI in a non-destructive mode if i want to verify what would happen without actually posting.

state management

data/syndicated.json is the source of truth for what’s already been syndicated. it looks roughly like this:

{
  "entries": {
    "https://botwerks.net/2026/05/20260522-130616/": {
      "posted_at": "2026-05-22T18:30:42+00:00",
      "mastodon_id": "112345678901234567",
      "bluesky_uri": "at://did:plc:abc.../app.bsky.feed.post/xyz..."
    },
    "https://botwerks.net/2009/01/2009-01-01-some-old-post/": {
      "init": true,
      "posted_at": "2026-05-30T17:49:36+00:00"
    }
  }
}

the canonical URL is the primary key. there are two flavors of entry:

  • normal entries have posted_at, mastodon_id, and bluesky_uri
  • init entries (marked with init: true) were bulk-seeded when POSSE was first turned on. these exist purely to prevent the script from going back and posting every entry in the archive at once. no sense spamming folks with old news.

the file is sorted alphabetically by key on write so diffs are readable when the workflow commits state updates. you can scroll the file and see the chronology of new posts at the bottom (sort by key happens to roughly track sort by date because of the date-based slugs).

opting out

there are a few knobs to keep something from being syndicated:

  • draft: true in frontmatter (also keeps it out of the build, obviously)
  • syndicate: false in frontmatter for a specific entry
  • for content/links/ entries: leave off the syndicate tag. by default link posts don’t syndicate

future-dated posts also don’t get syndicated until they’re actually live. this matters because i sometimes write something now and schedule it for later. (ha! in theory)

bootstrap

the script has an --init flag. this is what i used to backfill the state file with every existing post on the site before turning on the action. without that step, the first real run would have tried to syndicate seventeen years of archive in one go.

uv run bin/posse.py --init

the script also takes --dry-run which prints what it would post without making API calls or modifying state. i leaned on this heavily during setup.

what i’d change

honestly, i’m not sure. it’s all a little new and i don’t really anticipate “syndicating” much. the polling-for-live behavior is the part i’m least sure about. it would be cleaner to have the deploy notify the script directly when a new entry is live, rather than the script polling for the entry to appear. but the polling is cheap, it works, and it doesn’t require me to run a service somewhere to receive the webhook. simple wins.

it might also be nice to surface failures more loudly. if a post fails to syndicate the workflow run gets marked as failed, but i have to actually go look at github actions to notice. some kind of notification would be useful, but i’d rather not add more infrastructure for what is a very low-volume tool.