Publishing Your Eleventy Blog on the AT Protocol with Standard.site and Sequoia
Have you ever been conceptually into the idea of the blockchain or NFTs? and wanted to try something a little more confusing and harder? I give you standard.site and this is why I love and hate it.
The web you publish on doesn't have to be the web someone else owns. If you run a static site built with Build Awesome the SSG formerly known as Eleventy (11ty) — or are thinking about starting one — you can now register it as a first-class publication on the AT Protocol, the open decentralized network that powers Bluesky. That means your posts appear in AT Protocol readers and aggregators, readers can follow your blog the way they follow Bluesky accounts, and your content lives in your own repository rather than inside a platform's database.
This guide walks through the whole process: what AT Protocol is, what standard.site and sequoia-cli do, and the exact configuration and scripts needed to publish a real Eleventy site. I learned most of what is here the hard way — through broken tokens, wrong path templates, date corruption bugs, orphaned records, and a CI/CD publish loop that failed every single build until I understood where publishing is allowed to happen — so you don't have to.
What Is AT Protocol?
AT Protocol (ATProto) is an open standard for decentralized social networking. Bluesky is the most well-known application built on it, but ATProto is protocol-agnostic: it is a general-purpose system for publishing records, following accounts, and federating data across servers.
A few concepts worth understanding before you dive in:
DID (Decentralized Identifier): Your permanent identity on the AT Protocol. It looks like did:plc:abc123xyz. Unlike a username, it never changes even if you move servers. You can verify your DID at bsky.social by looking at your profile's advanced settings.
PDS (Personal Data Server): The server that stores your data. Bluesky hosts a PDS for most users at bsky.social, but you can self-host. Your PDS holds your records — posts, likes, follows, and for our purposes, your blog documents.
Records and Collections: Everything on AT Protocol is a record in a collection. Bluesky posts are app.bsky.feed.post records. Standard.site defines two collections: site.standard.publication (your blog, once) and site.standard.document (one record per post).
Handle: Your human-readable identifier, like adjb.co or yourname.bsky.social. You can verify a custom domain as your handle through DNS.
The important thing for blog publishing: your blog posts become AT Protocol records that any app or aggregator following the site.standard schema can discover, display, and index — without you being locked into any particular platform.
What Is Standard.site?
Standard.site is a specification and ecosystem for publishing static websites as AT Protocol publications. It defines the site.standard.publication and site.standard.document lexicons — the schema that tells AT Protocol clients what a blog post looks like (title, description, path, cover image, publication date, tags, and so on).
Think of it as RSS but native to the decentralized web. Instead of an XML file that feed readers poll, your posts are first-class records in the AT Protocol network that apps can subscribe to, aggregate, and display in real time.
What Is sequoia-cli?
sequoia-cli (the project, confusingly, also goes by the name sequoia and lives at standard.site) is the command-line tool that bridges your static site to the AT Protocol. It:
- Authenticates with your AT Protocol identity via OAuth
- Reads your staged blog post files
- Creates or updates site.standard.document records on your PDS
- Optionally uploads cover images as blobs
- Writes the resulting AT URIs back to your post front matter so your site can link to them
You do not need to write any AT Protocol API calls by hand. sequoia-cli handles the protocol, the authentication, and the record management.
Prerequisites
Before starting, you need:
- An Eleventy site with posts in Markdown files that have YAML front matter. If you are brand new to Eleventy, start with the official docs.
- A Bluesky account. Sign up at bsky.app. Your DID is assigned automatically.
- Node.js 20+ installed.
- A custom domain handle (optional but recommended). You can use yourname.bsky.social to start and upgrade later. Setting a custom domain handle requires adding a DNS TXT record — Bluesky's settings walk you through it.
- npm for installing dependencies.
Step 1: Install sequoia-cli and Log In
Install the CLI as a dev dependency in your Eleventy project:
Or run it on demand with npx:
Next, log in with your AT Protocol identity. This opens a browser OAuth flow:
You will be redirected to Bluesky to authorize the app. After completing the flow, sequoia stores an OAuth session token at ~/.config/sequoia/oauth.json. This token expires roughly every hour, so you will need to re-run login periodically during development.
Tip: For automated CI/CD workflows, use a Bluesky app password instead (Settings → Privacy and Security → App Passwords). App passwords use legacy Bearer auth and do not expire, making them much more reliable in scripts. We will cover that in the advanced section.
Step 2: Initialize Your Publication
Run the init wizard to register your site as a publication on the AT Protocol:
The wizard asks several questions. Here is what each one means:
| Prompt | What to enter |
|---|---|
| Site URL | Your full site URL, e.g. https://www.example.com |
| Content directory | The staged directory (see Step 3), e.g. .cache/sequoia-content |
| Public/static directory | Where your .well-known files live, e.g. public |
| Handle | Your AT Protocol handle, e.g. yourname.bsky.social or yourname.com |
| Publication name | The human-readable name for your blog |
| Description | A short description |
| Path template | The URL pattern for posts — important, see below |
| Cover images directory | Leave empty, or the path to your image assets folder |
| Icon image path | Optional; your site's icon/logo |
Run init only once. Running it a second time creates a duplicate publication record on the AT Protocol. If you accidentally run it twice, you will need to manually delete the duplicate record — we will cover that in the troubleshooting section.
After init completes, two things happen:
- A sequoia.json config file is created in your project root.
- A site.standard.publication record is written to your PDS and the resulting AT URI is written to public/.well-known/site.standard.publication.
Step 3: Configure sequoia.json
Open sequoia.json and review every field carefully. Here is the full configuration from this site, with each field explained:
The most important field to get right is pathTemplate. This controls the path field written into each AT Protocol document record — the path is combined with your siteUrl to produce the canonical URL for the post in any AT Protocol reader.
If your blog URLs look like https://example.com/blog/my-post/, set:
If your URLs look like https://example.com/my-post, set:
Getting this wrong is the most common mistake. If the path is wrong, every link from an AT Protocol reader or aggregator will 404. You can fix it later by updating pathTemplate and re-running publish — sequoia will detect the change and update all records.
The contentDir field is equally critical. Do not point it at your actual source content/blog directory. Sequoia reads from a staged directory where you have preprocessed and normalized your posts (more on this in Step 4). Using a .cache/ subdirectory keeps staged files out of source control.
Step 4: Stage Your Content with a Prepare Script
Your source Markdown files often contain things sequoia cannot handle directly: front matter fields that do not map to AT Protocol fields, posts that should be excluded (drafts, posts mirrored from other platforms, book reviews), or images with paths relative to your site root that need to be resolved against your public/ directory.
The solution is a prepare script that runs before sequoia publish and copies your posts into the contentDir staging area, transforming them as needed. Here is a complete example:
Install the dependencies this script needs:
Step 5: Wire Up npm Scripts
Add these scripts to your package.json so you have a consistent publish workflow:
The dry-run command is valuable: it shows you exactly which posts will be published and what their AT Protocol paths will be, without actually writing anything to the network.
When you are ready to go live:
On first run you will see Published: 57 (or however many posts you have). On subsequent runs sequoia detects which posts have changed and shows Updated: 3 for only those files. Each record gets an AT URI written back to its front matter field (standard_site_document).
Step 6: Verify on the AT Protocol
After publishing, you can verify your records are live using the public AT Protocol API. Replace the DID with your own:
You can also check your publication record:
If you see your records with the correct path values (matching your actual blog URLs), everything is working. Standard.site readers and aggregators will begin discovering your content.
Step 7: Validate Your Work with the Standard.site Validator
Querying the raw API tells you a record exists, but it does not tell you whether your site is actually verified — that is, whether the tags and the .well-known publication record line up the way standard.site requires. For that, there is a purpose-built validator.
Visit site-validator.fly.dev and paste in the full URL of any published blog post, for example:
The validator checks three things, and you want a green check on all of them:
- Document record — the tag in the page points to a real site.standard.document record on your PDS, and the record's schema fields (title, description, path, publishedAt) are valid.
- Publication record — the tag resolves, and your site.standard.publication record exists.
- Publication verification — https://yourdomain.com/.well-known/site.standard.publication is reachable and contains the matching publication AT URI.
That last check is the one most people miss. Your build has to actually serve the .well-known file. You can confirm it independently with curl:
Eleventy gotcha: Some static site generators silently drop dotfiles and dot-directories from the build output. In Eleventy, make sure your public/ passthrough copies the whole folder — eleventyConfig.addPassthroughCopy({ "./public/": "/" }) — so public/.well-known/ lands in _site/.well-known/. If the validator reports the publication as unverified but your document record is fine, this missing .well-known file is almost always why.
If you want a second opinion on the raw record schema, pds.ls lets you paste an AT URI and inspect the stored record field by field. It checks schema validity but not standard.site verification, so use it alongside the validator, not instead of it.
A Critical Gotcha: Dates and YAML Parsing
If your post dates include timezone offsets — like 2026-06-28T17:00:00-05:00 — and you ever write a script that parses and re-serializes your front matter with js-yaml, you will run into date corruption unless you use the CORE_SCHEMA option.
By default, js-yaml parses YYYY-MM-DD date strings and timezone-aware ISO strings as JavaScript Date objects. When it serializes them back to YAML, it converts to UTC — silently shifting your dates. A post dated 2026-06-28T17:00:00-05:00 (5pm Chicago) becomes 2026-06-28T22:00:00.000Z in the output.
The fix is one option in every yaml.load() and yaml.dump() call:
CORE_SCHEMA treats date strings as plain strings and never auto-converts them. Make this a habit in any script that touches Markdown front matter.
Excluding Posts Selectively
Not every post in your blog may belong on the AT Protocol. Common cases to skip:
- Drafts: Posts with draft: true or published: false
- Book reviews or off-topic content: Filter by tag
- Posts mirrored from another platform: Filter by canonical_url
All of this lives in the shouldSkip() function in your prepare script. Here is an expanded version:
Managing Multiple Publications
You can have more than one site.standard.publication record under your DID. For example, this site has:
- Adam DJ Brett's blog — main technical and academic posts at adamdjbrett.com
- Spine & Style — book reviews published through lemma.pub
Each publication is a separate AT record with a unique rkey. The sequoia.json publicationUri field controls which publication your Eleventy site's posts are filed under. If you manage content across multiple publications, keep separate sequoia.json configs (or separate Eleventy sites) for each.
A Brief Note on Lemma.pub
Before settling on sequoia-cli as the publishing tool for this site, I tried lemma.pub for publishing book reviews as AT Protocol documents. Lemma.pub is a beautiful platform and it works well for books specifically, but it creates its own site.standard.publication record in your AT Protocol repository and manages its own documents. When I started integrating sequoia for my main blog, the lemma.pub publication and the new sequoia publication existed side by side — which is fine — but I had orphaned documents from earlier failed sequoia attempts piling up under a deleted publication. The cleanup required writing a custom script with DPoP authentication (AT Protocol's OAuth security mechanism) and cost an afternoon of debugging. The short lesson: do not run sequoia init more than once, verify your publication record exists before publishing, and if you use lemma.pub alongside sequoia, treat them as independent — they will not interfere with each other.
Troubleshooting Common Issues
publicationUri is required in config This error means your sequoia.json is missing the publicationUri field. After sequoia init, find your publication's AT URI in public/.well-known/site.standard.publication and add it manually:
Posts appear at the wrong URL in AT readers Check pathTemplate in sequoia.json. The path must match your actual Eleventy URL structure exactly. If your posts live at /blog/my-post/, you need "/blog/{slug}/". After fixing it, re-run publish — sequoia detects the change and updates all 57 records automatically.
InvalidToken: Malformed token AT Protocol OAuth uses a security mechanism called DPoP (Demonstrating Proof of Possession) that requires a freshly signed proof JWT with every request. The OAuth token also expires in roughly one hour. If you see this error in a custom script, check that:
- You re-ran npx sequoia-cli login recently (within the hour)
- You are using Authorization: DPoP {token} not Authorization: Bearer {token}
For automation scripts, skip DPoP complexity entirely by using a Bluesky app password with the legacy createSession API:
App passwords use plain Bearer auth, never expire mid-script, and work perfectly for one-off admin operations.
Cover images not uploading sequoia-cli resolves cover image paths relative to contentDir, not your project root. If your image is at public/assets/img/post.webp and your staged files are in .cache/sequoia-content, you need a relative path like ../../public/assets/img/post.webp. The prepare script in Step 4 handles this automatically with the normalizeCoverImage() helper.
Automating Publish in CI/CD — and the Trap I Fell Into
This is the part that cost me the most grief, so it gets the most detail.
The obvious thing to do is run sequoia publish as part of your normal deploy build — fold it into the same command your host (Netlify, Vercel, Cloudflare Pages) runs. I did exactly that. My Netlify build command was effectively:
And it worked once. Then every subsequent build failed.
Why publishing inside the host build is a trap
Here is the chicken-and-egg problem. When sequoia publish creates a new document record, it writes the resulting AT URI back into your post's front matter and into .sequoia-state.json. That writeback is the whole mechanism that stops the next run from creating a duplicate record.
But your host's build environment cannot commit to your git repository. Netlify checks out your repo, builds, and throws the checkout away. So the AT URI that sequoia publish just wrote vanishes the moment the build finishes. On the next deploy, the post looks new again — and if you have a writeback step that fails when a URI is missing (mine called process.exit(1)), you get a permanent failure loop: every build dies trying to reconcile a URI that never gets persisted.
The lesson: the environment that publishes must be the environment that can commit the results back to git. Your host build is not that environment. A GitHub Action is.
The fix: publish in CI, build on the host
Split the two responsibilities cleanly:
- A GitHub Action publishes to the AT Protocol and commits the AT URIs back into the repo (both the front matter and .sequoia-state.json).
- Your host only builds and injects. It never runs sequoia publish. It runs your normal Eleventy build plus sequoia inject, which reads the already-committed .sequoia-state.json to stamp the tags into the finished HTML.
.sequoia-state.json is the key. Commit it to git. It is the durable record of every AT URI, and because sequoia inject sources its mappings from that file, your host build always produces correct verification tags without needing credentials or network access to your PDS.
Here is the workflow that does the publishing. It runs on pushes that touch your posts, plus a daily cron as a safety net, plus manual dispatch:
Two details that make this loop-safe:
- sequoia publish in CI authenticates with an app password, not the interactive OAuth flow. App passwords use plain Bearer auth, never expire mid-run, and are perfect for headless automation. Store yours as a repository secret. (Set ATP_IDENTIFIER and ATP_APP_PASSWORD and sequoia-cli picks them up automatically — no custom wrapper required.)
- The commit uses the default GITHUB_TOKEN. Pushes made with GITHUB_TOKEN deliberately do not trigger new workflow runs, so the bot's commit cannot retrigger this workflow — no infinite loop. It will, however, trigger your host's deploy webhook, which is exactly what you want: the host rebuilds with the AT URIs now present in git.
The host build: build and inject only
On the host side, drop sequoia publish entirely. My Netlify build command is now just:
Make the writeback script forgiving
Finally, whatever script copies AT URIs back into your front matter should warn, not fail, when a URI is missing. A brand-new post legitimately has no URI for the few seconds between staging and publishing, and the daily cron backfill will catch any straggler. Failing the build there is what created my loop in the first place:
The end result is a system you never have to think about. You write a post, push it, and the Action publishes it, commits the AT URI back, and the resulting commit triggers a clean deploy that injects the verification tags. The daily cron sweeps up anything missed.
Cross-Posting New Posts to Bluesky
sequoia-cli can announce each new article as a Bluesky post automatically, which is the simplest way to get your writing in front of people. I have this turned on now:
When sequoia publish runs and finds a new or changed post, it creates a Bluesky post with the title, description, cover image, and canonical link, then links that Bluesky post back to your site.standard.document record. The maxAgeDays window is the important safety valve: it only cross-posts articles dated within that many days. Without it, the first time you enable Bluesky posting sequoia would happily dump your entire back catalogue — in my case 58 posts — into your followers' timelines at once. With maxAgeDays: 7, only genuinely recent posts get announced; everything older stays quietly published to the AT Protocol without a Bluesky blast.
One subtlety worth knowing: because cross-posting only fires for new or changed posts, flipping enabled to true does not retroactively post your existing recent articles. It applies going forward. If you specifically want to backfill the last week's posts to Bluesky, you can force a republish (sequoia publish --force) once — but be deliberate about it, since it posts publicly and is not easily undone.
A Future Next Step: Adding Comments via Bluesky Replies
Here is the payoff for enabling Bluesky cross-posting: you can turn the replies on that Bluesky post into a comment section under your blog post — no comment database, no third-party widget, no spam moderation service. The conversation lives on the AT Protocol, and your page just renders it.
The flow is:
- Cross-posting is enabled (above), so each published post has a corresponding Bluesky post.
- People reply to that Bluesky post.
- A small web component, sequoia-comments, finds the tag already in your page head, fetches the linked post and its reply thread, and renders it.
Install the component into your project — it will ask where to save the file:
Then drop the element into your post layout wherever you want comments to appear. Since it is a standard Web Component, no framework wiring is needed; in an Eleventy/Nunjucks post template it is just:
Because sequoia inject already put the site.standard.document link tag in your , the component knows which thread to load with zero extra configuration. You can tune it with attributes — depth="10" to fetch deeper reply nesting, or document-uri="…" to point it at a specific record explicitly — and theme it with CSS custom properties to match your site:
I have deliberately left comments off on this site for now — I wanted Bluesky cross-posting settled first, and comments are a presentation choice I would rather make intentionally than by default. But the groundwork is all in place: the moment I add the component to my post template, the Bluesky replies become my comment section. That is the next step whenever I am ready.
What's Next
Getting your Eleventy blog onto the AT Protocol is a starting point, not a finish line. A few directions worth exploring:
- Zotero harvestability: Adding RDFa metadata to your HTML makes your posts citable in Zotero with one click. Martin Paul Eve has written about the technique; it pairs well with the open-access ethic behind AT Protocol publishing.
- DOI integration: If your posts are formal academic writing, minting DOIs (via services like Zenodo or institutional repositories) and linking them from your AT Protocol records makes your work permanently citable. (I hope to learn this magic from Martin Paul Eve and/or the brilliant people at Knowledge Commons Soon.)
- standard.site Discover: Once your publication is live, it may appear in the standard.site Discover feed. Setting "showInDiscover": true in your publication's preferences field opts you in.
- Comments via Bluesky: With cross-posting enabled, the sequoia-comments web component turns replies to your Bluesky posts into a native comment section — see the section above. It is the next thing on my own list.
The decentralized web is most useful when independent publishers actually use it. If you run an Eleventy site and care about owning your content and your audience relationships, adding AT Protocol publishing is a half-day investment with compounding returns. Your posts become discoverable without surrendering them to a platform's terms of service, recommendation algorithm, or business model.
If you get stuck, the AT Protocol docs and the sequoia-cli source at tangled.org are the most reliable references.
Discussion in the ATmosphere