Putting this blog on ATProto with standard.site
Table of contents
I added standard.site support to this blog. Every post now also lives as a record on ATProto , the protocol behind Bluesky, and new ones publish themselves whenever I push to main .
What it is #
standard.site is a set of shared ATProto lexicons
. The two that matter here are
site.standard.publication and site.standard.document. The publication record describes
the blog: name, URL, icon. Each post becomes a document record that lives in my own data
repository
on a PDS
and points back at the publication. To prove the records are actually
mine, there’s a /.well-known/site.standard.publication
file on my domain and a link-rel
tag
in every post’s HTML pointing at the matching record. Two ends tied together, no
central registry in the middle.
Why bother #
Mostly the previews. A link to one of my posts on Bluesky now shows up as a card with the title, description, and image instead of a plain URL, because the post is a real record the network can read. Bluesky shows richer previews for standard.site links now. Beyond that, the records live in my own PDS, so any indexer or reader can pick them up, and they turn up in readers like docs.surf on their own. And it’s cheap POSSE : rednafi.com stays the canonical copy, a copy syndicates out to the ATmosphere .
Setting it up with Sequoia #
I didn’t hand-roll any of the ATProto plumbing. Sequoia is a CLI by Steve Simkins that does the whole thing for static sites, and it doesn’t much care what built yours, Hugo, Astro, Eleventy, as long as it’s Markdown. If you want to put your own blog on standard.site, it goes roughly like this.
First, get an ATProto identity, since the records live in your own PDS. A Bluesky account is
one. Ownership is checked against a domain, so it helps to set your site’s domain as your
handle (mine is rednafi.com) and mint an app password for the CLI to log in with.
Then run sequoia init in the repo. It authenticates against your PDS, creates a
site.standard.publication record describing the blog (name, URL, icon), and scaffolds a
sequoia.json. That config is small: it points at your content directory and maps the
frontmatter fields it reads, like the publish date and the slug.
{
"siteUrl": "https://rednafi.com",
"contentDir": "content",
"publicationUri": "at://did:plc:fgtm2c26vfcj74rfmeggbyqj/site.standard.publication/3mnl6f7ob462z",
"frontmatter": { "publishDate": "date", "slugField": "slug" }
}
That publicationUri is the at:// address of the publication record init just made,
which is where it comes from. The same URI also lands in
static/.well-known/site.standard.publication, so the domain and the record name each other
and the ownership check holds.
Each post’s HTML also needs a <link rel="site.standard.document"> pointing at that post’s
record. sequoia inject can patch the tags into your built HTML; I emit them from my Hugo
head partial instead.
With that wired up, sequoia publish walks the content, creates a site.standard.document
record per post, and writes the resulting atUri back into each post’s frontmatter. State
lives in .sequoia-state.json, so reruns only touch what actually changed.
Making it hands-free #
I didn’t want to run sequoia publish by hand, so it happens in CI
. Two pieces make that
work.
A small Go script
fills in one frontmatter field before Sequoia runs. standard.site gives
each document a stable path, and Sequoia reads that from an atprotoPath field in the
frontmatter. Rather than type it into every post, I derive it from the file’s location and
slug, so content/zephyr/carry_the_pager.md with slug: carry-the-pager gets
atprotoPath: /zephyr/carry-the-pager/ written in. It also fails the build if a post has no
slug to derive one from.
Then GitHub Actions handles the rest on every push to main: run that sync script,
sequoia publish with my handle and app password from repo secrets, prettier-format the
metadata Sequoia generated, then commit the new atUris, the .sequoia-state.json, and the
.well-known file back with a [skip ci] tag before Hugo builds and deploys.
- name: Sync standard.site frontmatter
run: go run ./scripts/stdsitesync
- name: Publish standard.site records
env:
ATP_IDENTIFIER: ${{ secrets.ATP_IDENTIFIER }}
ATP_APP_PASSWORD: ${{ secrets.ATP_APP_PASSWORD }}
run: npx -y sequoia-cli publish
So my actual routine didn’t change at all. Write Markdown, push to main, walk away. The
ATProto side catches up by itself. This very post turned into a site.standard.document the
moment the deploy ran.
Seeing it work #
Here’s the part I actually wanted. I share a post on Bluesky and it unfurls into a card built from the record. And since the record is just data, I can render that same card right here, clickable, instead of pasting a screenshot:

Drive-by AI changes break the shared model a team builds around its code, and the ICs end up cleaning up the mess. Why pushing to mainline should come with the pager.
And the same post sitting on the network as its site.standard.document record, viewed
through pdsls
. Same title and description the card used, plus the path, tags, and the full
body, all as portable data instead of only HTML:

The config , the script , and the ci workflow are all in the repo if you want to grab the setup.