'A view of the launch of Space Shuttle Atlantis on mission STS-115 as it soars over the Space Coast, taken from NASA's WB-57 aircraft.' | Rawpixel (edited by the Author)
Publishing My Eleventy Blog to the ATmosphere with Standard.site
Over the past few months, I've written about how I've added various functionality to my 11ty-based blog, ranging from an IndieAuth commenting system, a Guestbook, Webmentions, to adding a Micropub endpoint.
Those who live in Alberta might be aware that Calgary recently rebranded as the Blue Sky city, which was launched to replace the former oil-and-gas "Be a Part of the Energy" slogan. The rebrand is because Calgary is Canada's sunniest city, with over 333 days of sunshine annually on average! 🔆 (but that's contested).
So, of course, I've been thinking about adding integration with the AT Protocol, the open and decentralized network underneath Bluesky, a (kinda) spiritual successor to Twitter. Recently, a small ecosystem has grown up around making the protocol work for long-form publishing, not just short-form social posts.
This is a writeup of how I integrated Standard.site into my 11ty blog, from writing a custom script to migrating to Sequoia for ongoing publishing.
Prologue: What even is the AT Protocol?
I'll start with a confession: despite having a Bluesky account for the past several months, I've never really looked into the AT Protocol. I thought this was a great way to ease myself into it, so I want to start at the beginning, both for my sake and yours.
Until I started this project, my understanding of what's underneath Bluesky began and ended essentially with "the nerd version of Twitter." So before we get into records and rkeys, let's back up.
First, AT Protocol stands for Authenticated Transfer Protocol, and it is not the same thing as Bluesky. Bluesky-the-app is just the first (and biggest) thing built with it. Per the official protocol overview, AT Protocol is a decentralized protocol for building large-scale social apps, and the growing ecosystem built on top of it has picked up the adorable nickname ATmosphere.
In December 2019, Twitter announced it was funding a small independent team to research an open, decentralized standard for social media. That effort spun out into its own company in 2021, and eventually shipped Bluesky's invite-only beta in February 2023, opening to the public a year later, in February 2024. It first showed up on GitHub in May 2022 under the delightfully clunky name "Authenticated Data Experiment," and didn't become the "AT Protocol" we know today until that October.
Glossary
I found myself faced with a surprising amount of jargon, so I thought it'd be good to define everything before starting as well:
- Lexicon: A contract for a data type, spelling out what fields a given kind of record must have, written in JSON. A Bluesky post is the
app.bsky.feed.postlexicon, for example. A few platforms sat down and agreed on a lexicon for longform blog posts, which we'll get to shortly. - Skeet: Both a shotgun sport and an informal term for posts on Bluesky, a portmanteau of "tweet" and "sky", which is strongly discouraged by CEO Jay Graber.
- DID (Decentralized Identifier): a permanent (difficult to read) string like
did:plc:h4fm3emeptzegfs452eoiaz7which identifies "you" underneath everything else. It never changes, even if your handle, your host, and everything about your account does. - Handle: the human-friendly nickname for your DID. If you already own a domain, then your handle can just be that domain. More on this in Step 1.
- PDS (Personal Data Server): the protocol's own docs describe it as "your home in the cloud", it's where your data lives, as a signed repository of records. You can run your own, or rent space on someone else's. You can also move between the two without losing anything.
- Repo: your PDS stores everything you've published as something that behaves an awful lot like a Git repository. Researchers involved with the protocol have written up this comparison if you want a deep-dive.
- Relay / Firehose: a relay slurps up every account's updates across the whole network and rebroadcasts them as one continuous real-time stream, nicknamed the "firehose." This is how indexes and apps stay current without individually pestering every PDS on earth.
- AppView: the layer that turns raw firehose data into an actual app you'd recognize. Bluesky-the-app is one AppView; nothing stops someone else from building a wildly different one on the exact same underlying data.
- XRPC: the plain old HTTP API used to actually talk to a PDS: log in, create a record, fetch a record. You'll see plenty of it in Step 2.
It's a lot to put together, but when you do, the pitch is that your identity and your data no longer live inside whatever app happens to be rendering them today. If Bluesky-the-company vanished overnight, your DID, PDS, repo, etc. would keep right on existing, as none of it actually lives inside Bluesky's walls.
This is why a guy running a plain static blog has any business caring about a protocol built for Twitter-successor microblogging: if a blog post can be a record on this network too, and not just something Bluesky happens to link to, then this blog gets to participate in the same portable, un-ownable, app-agnostic world the rest of the ATmosphere already enjoys.
That's exactly the gap that Standard.site fills. Onward!
Why AT Protocol for a Personal Blog?
AT Protocol uses lexicons, which are typed schemas defining what a record looks like to describe everything stored on the network:
- A Bluesky skeet is an
app.bsky.feed.postrecord. - A follow is an
app.bsky.graph.followrecord.
Since the beginning, the protocol never had a lexicon for a long-form essay. That gap is exactly what Standard.site fills.
Leaflet, pckt.blog, and Offprint were three platforms that agreed on a shared schema rather than each inventing their own. The result is site.standard.publication and site.standard.document, two lexicons describing a blog and its posts in a form all of ATmosphere can read, index, and carry between platforms.
The other piece is handle-based identity. Serving a DID document at /.well-known/atproto-did means your domain is your identity. My at:// URIs resolve to brennan.day, not from the platform's centralized identifier.
The point of this is if Bluesky ceased to exist tomorrow, the records and the identity would remain under my control.
The Two Records You Need
Every Standard.site integration creates two types of records on your Personal Data Server (PDS):
site.standard.publication: Created once for the whole blog. Contains the URL, name, description, and an optional icon. This is the feed-level record.site.standard.document: Created once per post. Contains the title, path, description, tags, publish date, and an optional plain-text body. Points back at the publication via its AT-URI.
Verification links these records to your domain. The publication is verified by a /.well-known/site.standard.publication file containing its AT-URI. Each document is verified by a <link> tag in the post's <head>:
<link rel="site.standard.publication" href="at://brennan.day/site.standard.publication/self">
<link rel="site.standard.document" href="at://brennan.day/site.standard.document/3mpwdqt4xn42j">
Aggregators and indexers can see your records but can't confirm they belong to your domain without these verification links.
Step 1: Handle Verification
Before the publication records, I needed my domain to be my AT Protocol identity. Bluesky's handle verification uses a /.well-known/atproto-did file served from your domain containing just your DID.
For an Eleventy site, you just need to create src/.well-known/atproto-did with a single line:
did:plc:h4fm3emeptzegfs452eoiaz7
Eleventy's .eleventy.js needs a passthrough copy rule:
eleventyConfig.addPassthroughCopy("src/.well-known");
Once the site is deployed, you confirm the handle in the Bluesky settings under "Change Handle → I have my own domain". Bluesky fetches https://yourdomain.tld/.well-known/atproto-did, finds your DID, and updates your handle on the network. After that, your AT-URIs read as at://yourdomain.tld/... instead of at://did:plc:.../... which is great for readability.
Step 2: A Custom Publish Script
Before setting up Sequoia, I wrote scripts/standard-site-publish.js to publish manually. You can skip this part if you're just using Sequoia, but I figured I'd add it to the write-up anyways for the sake of documentation (and to have my work on it not be in vain!).
The pattern for AT Protocol interaction is: authenticate, get a session token, then upsert records via XRPC.
Authentication uses com.atproto.server.createSession with a handle and app password (never your main password):
async function authenticate(identifier, password) {
const res = await fetch(`${PDS_URL}/xrpc/com.atproto.server.createSession`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ identifier, password }),
});
const data = await res.json();
return { token: data.accessJwt, did: data.did };
}
Creating a record is a com.atproto.repo.putRecord call with the collection name and a record key (rkey). I used self as the rkey for the singleton publication record and a TID for each document:
async function putRecord(token, did, collection, rkey, record) {
const res = await fetch(`${PDS_URL}/xrpc/com.atproto.repo.putRecord`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify({ repo: did, collection, rkey, record }),
});
return res.json();
}
The document record for a post pulls from the frontmatter. The minimum required fields are $type, site (the publication AT-URI), title, and publishedAt:
const record = {
$type: 'site.standard.document',
site: publicationUri,
title,
path: `/${slugify(title)}/`,
publishedAt: new Date(date).toISOString(),
description,
tags: tags.slice(0, 10),
textContent: markdownBody.slice(0, 10000),
};
After creation, the script writes the returned AT-URI back to the post's frontmatter as atUri. This value populates the <link> tag at build time.
I also wrote a small helper to convert the DID-based AT-URI the PDS returns (at://did:plc:.../...) into a handle-based one (at://brennan.day/...) before writing to frontmatter:
function toHandleUri(uri, handle) {
return uri.replace(/^at:\/\/did:[^/]+/, `at://${handle}`);
}
Both forms resolve identically; the handle-based form is simply more readable in the HTML source.
Step 3: Link Tags in Nunjucks
Once atUri is in frontmatter, the template work is minimal. In src/_includes/partials/meta.njk:
{# Standard.site AT Protocol integration | https://standard.site #}
{% if atUri %}
<link rel="site.standard.document" href="{{ atUri }}">
{% endif %}
{% if site.atproto.publicationUri %}
<link rel="site.standard.publication" href="{{ site.atproto.publicationUri }}">
{% endif %}
The publication link appears on each page of my site. The document link appears on all the posts, atUri in the YAML frontmatter, since I have a publishing script for that.
The publication URI lives in src/_data/site.json so it's globally accessible to every template:
"atproto": {
"publicationUri": "at://brennan.day/site.standard.publication/self",
"did": "did:plc:h4fm3emeptzegfs452eoiaz7",
"icon": "/assets/images/favicon/apple-touch-icon.png",
"basicTheme": {
"background": "#ffffff",
"foreground": "#1a1a1a",
"accent": "#8a1814",
"accentForeground": "#ffffff"
},
"preferences": {
"showInDiscover": true
}
}
The optional fields (icon, basicTheme, preferences) are used by Standard.site-compatible apps and aggregators when displaying your publication. The basicTheme colors help readers see your content in a way that matches your site's visual identity.
Step 4: Migrating to Sequoia
Sequoia is a purpose-built CLI for this kind of workflow. Once I learned it existed (thanks Kat I migrated to it for ongoing publishing. It's framework-agnostic, reads standard frontmatter fields, and maintains a .sequoia-state.json file for change detection via content hashing.
The config lives in sequoia.json at the project root. The key settings for an Eleventy blog with title-based slugs and no path prefix:
{
"$schema": "https://tangled.org/stevedylan.dev/sequoia/raw/main/sequoia.schema.json",
"contentDir": "src/posts",
"publicationUri": "at://did:plc:h4fm3emeptzegfs452eoiaz7/site.standard.publication/self",
"siteUrl": "https://brennan.day",
"publicDir": "src",
"outputDir": "_site",
"pathPrefix": "",
"frontmatter": {
"publishDate": "date",
"slugField": "title",
"updatedAt": "updated",
"coverImage": "featured_image",
"tags": "tags",
"description": "description",
"textContent": "textContent",
"bskyPostRef": "bskyPostRef",
"contributors": "contributors",
"labels": "labels"
},
"identity": "brennan.day",
"autoSync": true,
"ignore": ["posts.json"]
}
A few things to note:
slugField: "title"tells Sequoia to derive post paths from thetitlefrontmatter field rather than the filename. This matches Eleventy's| slugifypermalink behaviour.frontmattermappings connect your existing frontmatter fields to Standard.site document fields. For example,featured_imagemaps tocoverImage, andupdatedmaps toupdatedAt. This lets you keep your existing field names while publishing valid Standard.site records.publicDir: "src"so the/.well-known/site.standard.publicationfile is written tosrc/.well-known/, which Eleventy's passthrough copy then includes in the build output.autoSync: truemeans Sequoia fetches your existing PDS records and reconciles them with local files before publishing, preventing duplicates on fresh clones or CI builds.ignore: ["posts.json"]skips the Eleventy data cascade file that lives insrc/posts/.
Since I had 220 posts with records already created by the custom script, I wrote a one-time state builder script that reads each post's atUri frontmatter and computes its SHA-256 content hash, generating .sequoia-state.json in Sequoia's format. With the state file in place, sequoia publish --dry-run correctly reported all posts as up-to-date, rather than trying to create 220 duplicate records.
Sequoia stores credentials via sequoia auth (app password) for local use, and reads ATP_IDENTIFIER / ATP_APP_PASSWORD environment variables in CI, which are the same names I was already using. The package.json scripts became:
"standard-site:publish": "sequoia publish",
"standard-site:dry-run": "sequoia publish --dry-run",
"standard-site:inject": "sequoia inject",
"standard-site:sync": "sequoia sync --update-frontmatter"
The workflow for a new post is:
write → `npm run standard-site:publish` → commit the updated frontmatter and state file → push.
Sequoia adds the atUri to the post's frontmatter automatically after creating the record, so the <link> tag is present the moment the site builds.
Step 5: Handling Cover Images
The AT Protocol has a 1MB size limit for blob uploads, which means the cover images for my blog posts need to be compressed before they can be uploaded as part of Standard.site documents. Since my blog images are hosted on brennan.day and many exceed this limit (I'm a sucker for high quality, public domain work, what can I say!), I created a compression workflow.
The solution is to add a new frontmatter field atproto_image that points to compressed versions, while keeping featured_image for the full-quality site images.
First, I wrote scripts/compress-atproto-images.js which:
- Scans all blog posts for
featured_imagepaths - Compresses images to under 900KB (safely under the 1MB limit)
- Converts PNG to WebP, JPEG to JPEG with quality 75%
- Resizes if over 1920px width/height
- Saves compressed versions to
src/assets/images/atproto/ - Adds
atproto_imagefield to each post's frontmatter pointing to the compressed version
The script uses sharp for image processing:
const sharp = require('sharp');
async function compressImage(sourcePath, outputPath) {
let image = sharp(inputPath);
const metadata = await image.metadata();
// Resize if too large
if (metadata.width > 1920 || metadata.height > 1920) {
image = image.resize(1920, 1920, {
fit: 'inside',
withoutEnlargement: true
});
}
// Convert to WebP or JPEG with compression
if (outputExt === '.webp') {
image = image.webp({ quality: 75 });
} else {
image = image.jpeg({ quality: 75 });
}
await image.toFile(outputPathFull);
}
Then I updated sequoia.json to use the new atproto_image field:
{
"imagesDir": "src/assets/images/atproto",
"frontmatter": {
"coverImage": "atproto_image"
}
}
The blog post frontmatter now has both fields:
featured_image: /assets/images/blog/squeeze.jpg
atproto_image: assets/images/atproto/squeeze.jpg
The workflow is now:
write → node scripts/compress-atproto-images.js → npm run standard-site:publish → commit
I still have full-quality images on my site, and new compressed versions uploaded as blobs to the AT Protocol for rich previews in Bluesky and other ATmosphere apps.
What I'd Do Differently
The custom script I created was a good learning exercise in understanding the XRPC calls, how putRecord works, and how TIDs are generated. But I would skip straight to using Sequoia, since it handles all of this and more.
Also, stick with TID-based rkeys for site.standard.document. The site.standard.document lexicon currently specifies key: "tid", which means the record key must be a valid Timestamp Identifier (TID) such as 3mpwdqt4xn42j. I previously suggested using a slug-based rkey like why-start-again because it makes the AT-URI a pure function of the post slug, but that is not valid under the current lexicon. Some PDSes and validators may accept it anyway, but strict lexicon validation will reject it and it may cause interop problems with aggregators.
The idiomatic approach is to let the PDS generate a TID for each document (via com.atproto.repo.createRecord) and write the returned atUri back to the post's frontmatter. To re-run publishing without creating duplicates, use a state file like Sequoia's .sequoia-state.json or check the frontmatter for an existing atUri before creating a new record. There is an ongoing discussion in the Standard.site lexicons repo about whether the rkey type could be relaxed to any in the future, but until the lexicon is officially changed, use TIDs.
Making Sure It All Works
You can verify any Standard.site-compatible blog by fetching its .well-known endpoint and checking the <link> tags in the HTML:
# Publication verification
curl https://brennan.day/.well-known/site.standard.publication
# Document verification (check <head> for the link tag)
curl -s https://brennan.day/a-case-for-the-ethical-treatment-of-generative-ai/ \
| grep "site.standard"
You can also browse records directly at pdsls.dev (thanks again Kat for showing me this!) a handy AT Protocol record explorer that requires no account to use.
Troubleshooting
I ran into a few issues during setup that are worth mentioning:
As I wrote in step 5, The AT Protocol has a 1MB limit for blob uploads. My compression script targets 900KB to stay safely under this limit, but some images still exceeded it. I had to manually recompress the offending image with more aggressive settings (quality 50 instead of 75).
My custom publish script checked for atproto_uri in frontmatter to skip already-published posts, but I was using atUri instead. This caused the script to add duplicate atUri lines to all posts. I fixed this by updating the script to check for atUri instead.
Even with the verification files in place, my posts weren't showing up on Leaflet or pckt.blog because the actual records didn't exist on the PDS. The publication and document records need to be created via XRPC calls—the verification files alone aren't enough. Once I ran sequoia publish successfully, the records were created and the aggregators could find them.
When debugging, I initially queried api.bsky.app which returned RecordNotFound errors. The correct endpoint for direct PDS queries is https://bsky.social/xrpc/ (or your custom PDS URL).
Parting Words
I've written over and over how the web you publish on should be your own. Standard.site is a small, practical step in that direction: your posts are still on your domain, but they're also first-class records on an open network that any client can read. The implementation is honestly not that bad once you understand the two records and two verification files involved.
Like always, the publish script, Nunjucks partials, filter patch, state builder, and the Sequoia config are all available in the site's source on GitLab.
Comments
To comment, please sign in with your website:
How it works: Your website needs to support IndieAuth. GitHub profiles work out of the box. You can also use IndieAuth.com to authenticate via GitLab, Codeberg, email, or PGP. Setup instructions.
Signed in as: