
Table Of Contents
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, and orphaned records — 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.documentrecords 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.socialto 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:
npm install --save-dev sequoia-cliOr run it on demand with npx:
npx sequoia-cli --versionNext, log in with your AT Protocol identity. This opens a browser OAuth flow:
npx sequoia-cli loginYou 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:
npx sequoia-cli initThe 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.jsonconfig file is created in your project root. - A
site.standard.publicationrecord is written to your PDS and the resulting AT URI is written topublic/.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:
{
"$schema": "https://tangled.org/stevedylan.dev/sequoia/raw/main/sequoia.schema.json",
"contentDir": ".cache/sequoia-content",
"siteUrl": "https://www.adamdjbrett.com",
"identity": "adjb.co",
"publicationUri": "at://did:plc:3vmq5usrh3yvhbrrzf4ymo23/site.standard.publication/3mpfboadoa72e",
"autoSync": true,
"publicDir": "public",
"outputDir": "_site",
"pathTemplate": "/blog/{slug}/",
"stripDatePrefix": false,
"removeIndexFromSlug": true,
"publishContent": true,
"bluesky": {
"enabled": false,
"maxAgeDays": 14
},
"frontmatter": {
"publishDate": "date",
"title": "title",
"description": "description",
"draft": "draft",
"tags": "tags",
"coverImage": "image"
},
"ignore": [
"**/README.md",
"**/demo.md",
"**/_*.md"
]
}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:
"pathTemplate": "/blog/{slug}/"If your URLs look like https://example.com/my-post, set:
"pathTemplate": "/{slug}"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:
// scripts/prepare-sequoia-content.mjs
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
import yaml from "js-yaml";
import fg from "fast-glob";
const root = process.cwd();
const sourceDir = path.join(root, "content", "blog");
const stagedDir = path.join(root, ".cache", "sequoia-content");
const publicDir = path.join(root, "public");
// Posts whose canonical URL is on another platform are excluded —
// they already have an authoritative AT record there.
const excludedCanonicalHosts = new Set(["lemma.pub", "www.lemma.pub"]);
const parseFrontMatter = (source) => {
const match = source.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
if (!match) return { data: {}, body: source, hasFrontMatter: false };
// IMPORTANT: use CORE_SCHEMA to prevent js-yaml from auto-parsing
// date strings as JS Date objects, which corrupts timezone-aware dates.
return {
data: yaml.load(match[1], { schema: yaml.CORE_SCHEMA }) || {},
body: match[2] || "",
hasFrontMatter: true,
};
};
const stringifyFrontMatter = (data, body) => {
const fm = yaml.dump(data, {
schema: yaml.CORE_SCHEMA,
lineWidth: 1000,
noRefs: true,
sortKeys: false,
});
return `---\n${fm}---\n${body.replace(/^\n+/, "")}`;
};
const shouldSkip = (data) => {
if (data.draft === true || data.published === false) return "draft";
if (Array.isArray(data.tags) && data.tags.includes("book-review")) return "book-review tag";
try {
const host = new URL(String(data.canonical_url || data.canonicalUrl || "")).hostname.toLowerCase();
if (excludedCanonicalHosts.has(host)) return `canonical URL is ${host}`;
} catch {}
return "";
};
// Resolve cover image paths relative to the staged file, since sequoia
// reads images relative to contentDir, not the project root.
const normalizeCoverImage = (image, stagedPath) => {
if (typeof image !== "string" || !image.startsWith("/")) return image;
const abs = path.join(publicDir, image.replace(/^\/+/, ""));
return path.relative(path.dirname(stagedPath), abs).split(path.sep).join("/");
};
await rm(stagedDir, { recursive: true, force: true });
await mkdir(stagedDir, { recursive: true });
const files = await fg(["**/*.md"], { cwd: sourceDir, onlyFiles: true });
let published = 0, skipped = 0;
for (const relativePath of files.sort()) {
const sourcePath = path.join(sourceDir, relativePath);
const stagedPath = path.join(stagedDir, relativePath);
const source = await readFile(sourcePath, "utf8");
const { data, body, hasFrontMatter } = parseFrontMatter(source);
if (!hasFrontMatter) { skipped++; continue; }
const skipReason = shouldSkip(data);
if (skipReason) { skipped++; continue; }
const stagedData = { ...data };
// Inject a date for posts whose filename has one but front matter doesn't.
if (!stagedData.date) {
const m = relativePath.match(/^(\d{4}-\d{2}-\d{2})/);
if (m) stagedData.date = `${m[1]}T12:00:00.000Z`;
}
// If the post already has a live AT URI from a previous publish,
// pass it to sequoia so it updates rather than creates a new record.
if (stagedData.standard_site_document && !stagedData.atUri) {
stagedData.atUri = stagedData.standard_site_document;
}
stagedData.image = normalizeCoverImage(stagedData.image, stagedPath);
await mkdir(path.dirname(stagedPath), { recursive: true });
await writeFile(stagedPath, stringifyFrontMatter(stagedData, body), "utf8");
published++;
}
console.log(`Prepared ${published} posts. Skipped ${skipped}.`);Install the dependencies this script needs:
npm install --save-dev js-yaml fast-globStep 5: Wire Up npm Scripts #
Add these scripts to your package.json so you have a consistent publish workflow:
{
"scripts": {
"standard:prepare": "node scripts/prepare-sequoia-content.mjs",
"standard:publish:dry": "npm run standard:prepare && npx sequoia-cli publish --dry-run",
"standard:publish": "npm run standard:prepare && npx sequoia-cli publish",
"standard:inject": "npx sequoia-cli inject --output _site"
}
}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.
npm run standard:publish:dryWhen you are ready to go live:
npx sequoia-cli login # refresh the OAuth token first
npm run standard:publishOn 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:
curl "https://bsky.social/xrpc/com.atproto.repo.listRecords?\
repo=did:plc:YOUR_DID_HERE\
&collection=site.standard.document\
&limit=5"You can also check your publication record:
curl "https://bsky.social/xrpc/com.atproto.repo.listRecords?\
repo=did:plc:YOUR_DID_HERE\
&collection=site.standard.publication"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.
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:
// Always pair these options together:
const data = yaml.load(frontMatter, { schema: yaml.CORE_SCHEMA });
const output = yaml.dump(data, { schema: yaml.CORE_SCHEMA, lineWidth: 1000 });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: trueorpublished: 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:
const shouldSkip = (data) => {
// Standard draft/hidden flags
if (data.draft === true || data.published === false) return "draft";
if (data.eleventyExcludeFromCollections === true) return "excluded from collections";
// Skip posts tagged as book reviews (managed separately on lemma.pub)
if (Array.isArray(data.tags) && data.tags.includes("book-review")) {
return "book-review tag";
}
// Skip posts whose canonical URL belongs to another platform —
// those posts already have authoritative records there.
try {
const url = data.canonical_url || data.canonicalUrl;
if (url) {
const host = new URL(String(url)).hostname.toLowerCase();
const excluded = new Set(["lemma.pub", "www.lemma.pub", "medium.com"]);
if (excluded.has(host)) return `canonical on ${host}`;
}
} catch {}
return ""; // empty string = include
};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:
"publicationUri": "at://did:plc:YOUR_DID/site.standard.publication/YOUR_RKEY"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 loginrecently (within the hour) - You are using
Authorization: DPoP {token}notAuthorization: Bearer {token}
For automation scripts, skip DPoP complexity entirely by using a Bluesky app password with the legacy createSession API:
const res = await fetch("https://bsky.social/xrpc/com.atproto.server.createSession", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier: "yourhandle.bsky.social", password: "your-app-password" }),
});
const { accessJwt } = await res.json();
// Use: Authorization: Bearer ${accessJwt}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 #
Once everything works locally, you can publish automatically on every deploy. Here is a GitHub Actions step:
- name: Publish to AT Protocol
env:
APP_PASSWORD: $
run: |
node scripts/prepare-sequoia-content.mjs
node scripts/sequoia-publish-with-app-password.mjsFor CI you will want a version of the publish step that authenticates with an app password (stored as a GitHub Actions secret) rather than the interactive OAuth flow. The sequoia-cli team is working on first-class app password support; in the meantime, the createSession pattern above can be adapted into a lightweight publish wrapper.
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": truein your publication'spreferencesfield opts you in. - Bluesky cross-posting: sequoia-cli has a Bluesky cross-posting option (
"bluesky": { "enabled": true, "maxAgeDays": 14 }). This auto-posts new articles as Bluesky posts when you publish — useful for discovery, but optional.
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.
Tags : 11ty indieweb atproto open-access digital-humanities
Webmentions
No webmentions yet.