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:
- a python script that does the syndicating:
bin/posse.py - a github action that runs that script on every push to
main - a state file at
data/syndicated.jsonthat 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:
- walks
content/posts,content/til, andcontent/linkslooking for markdown files - filters out drafts, future-dated entries, and anything with
syndicate: falsein the frontmatter - for
content/links/entries it also requires asyndicatetag. most links are ephemeral and don’t deserve a social post by default - 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) - compares that against
data/syndicated.jsonto find new entries - for each new entry it polls
feed.jsonuntil the entry actually appears live (so we don’t post a link before cloudflare has propagated the build), then posts to mastodon and bluesky - 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
graphemepackage rather thanlen()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, andbluesky_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: truein frontmatter (also keeps it out of the build, obviously)syndicate: falsein frontmatter for a specific entry- for
content/links/entries: leave off thesyndicatetag. 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 --initthe 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.