skip to content
luminary.blog
by Oz Akan
Essays published to the open AT Protocol network via Standard.site

Bringing Your Blog Posts to the AT Protocol

How I wired oz.akan.io into Standard.site — a newsletter gated for subscribers and closed to AI crawlers, publishing to an open network anyway, on its own terms.

/ 12 min read

Table of Contents

What this is built on

How I wired oz.akan.io into Standard.site — a newsletter that’s gated for subscribers and closed to AI crawlers, publishing to an open network anyway, on its own terms.

I came across the announcement for Standard.site and wanted to try it before I’d finished reading it. I run an author site and write a newsletter; here was a way to put my essays onto an open social network without surrendering them to a platform. I cleared the afternoon. The afternoon had one good bug in it, which is the part worth reading. But first, three things this stands on, from the outside in.

Bluesky is the part you may already know: a social app in the shape of early Twitter, with a timeline, posts, follows, and likes. The interesting part is what runs underneath it.

The AT Protocol (atproto) is that underneath, and the reason any of this works. On a normal social platform your account and everything in it live on the company’s servers, and leaving means starting over somewhere else with nothing. On atproto you have a DID, a decentralized identifier that belongs to you, and a repo: a personal data store that holds your posts, your follows, your profile. Apps read from and write to your repo. Change apps, or run your own server, and your identity and your content come with you. Bluesky is just the first big app pointed at that data. Another can point at the same data without asking Bluesky’s permission.

Standard.site is what turns a long-form essay into something that network can read instead of a link it can’t. atproto defines lexicons (schemas) for the records Bluesky needs: a post, a like, a follow. It never defined one for an essay. So three independent publishing platforms (Leaflet, pckt.blog, and Offprint) agreed on a shared one rather than each inventing its own. An essay now has the same shape whether it was written in Leaflet or on my own site, which means other tools can discover it, index it, and carry it between platforms. Bluesky, recognizing the shape, can show the link as an essay rather than a bare URL.

So: a standard way to say this is an essay, here is who wrote it, here is where it lives, written into a repo I own and readable by anything that speaks the lexicon. I wanted my essays in it.

What Standard.site is

The shape is a community standard, not a Bluesky product. No company owns it; it spreads by adoption, and the governance is deliberately thin. The goals are plain: one schema so any client can recognize long-form writing, records that move between platforms instead of being trapped in the tool that made them, and no gatekeeper deciding who publishes. Standard.site pins down the metadata (what a publication is, what a document is) and leaves the rendering to each platform.

It is a small family of lexicons, not a single one. I use two, site.standard.publication and site.standard.document. The rest cover the social edges of publishing: subscribing to a publication, recommending one, and a basic color theme clients can use. I didn’t need those. Two records were enough to put an essay on the network.

What made it worth an afternoon is what Bluesky did with it. The AT Protocol team announced that Bluesky now reads these records and renders them: a link to a Standard.site document comes back as an article card showing the publication and the author, on web and on mobile, instead of the gray box every other URL gets. The record stops being inert data a handful of tools can parse and becomes something a reader sees in the app they already open.

And it isn’t only me, hand-rolling a script against the API. That same announcement lists tools already writing these records: a WordPress plugin, atproto-native editors, a command-line publisher for static sites. The records my script writes are the same shape WordPress emits, and the network treats them the same.

The two records

Two of those lexicons matter for a site like mine. app.bsky.feed.post is the lexicon behind every Bluesky skeet; site.standard.document is the one behind an essay.

A publication (site.standard.publication) is the masthead: a name, a base URL, a description, an optional icon and theme. One per site. Mine is called “The Gradient” and points at https://oz.akan.io.

A document (site.standard.document) is a single essay: a title, a path, a publish date, optional tags, an optional plain-text body, and a pointer back to the publication it belongs to. One per article.

These records live in your atproto repo, the same one that holds your Bluesky posts and follows, not on my server. That is the whole point. Once the record exists, any client that speaks the lexicon can read it, index it, and render it. The content stops being something only my site knows about.

The tension I had to settle first

A contradiction sits underneath all of this, and how I resolved it shaped the code. So I’ll deal with it first.

My site blocks AI training crawlers. The robots.txt disallows GPTBot, ClaudeBot, Google-Extended, the rest of the list; every page carries noai, noimageai. And the newsletter itself is gated: an anonymous visitor sees a preview and a fade-out, and the full essay only renders for a signed-in subscriber.

Standard.site publishes records to a public network. They are world-readable by design, and easy to index. That is the feature. So the question is not academic: how much of a gated, crawler-blocked essay do you put into a record that anyone, including the crawlers you just turned away, can read?

The answer I settled on is the only consistent one: publish exactly what a logged-out visitor already sees, and nothing more. The record carries the title, the description, the tags, the canonical path, and the preview: the same paragraphs above the <!-- preview-end --> cut that the site shows a stranger. The gated body never goes into a record. Everything I publish was already public.

That decision came down to one boolean and one function.

The data model, and one trick that saves a database

Each document record needs a stable identity so the website can point at it. atproto record keys (rkeys) are usually TIDs: timestamp-ish, generated for you. If I let the server mint them, I’d have to store the mapping from essay slug to rkey somewhere and read it back at build time. A table, or a JSON file I’d forget to commit.

Instead I publish each document with rkey = slug. The essay at /newsletter/where-drive-goes-missing becomes the record at at://did:plc:…/site.standard.document/where-drive-goes-missing. The AT-URI is now a pure function of the slug. The site can compute it without a lookup, and putRecord with a fixed rkey is an upsert, so re-running the publish step overwrites in place instead of piling up duplicates. No database. The publication record gets the rkey self, the convention for a singleton.

So the whole identity layer is one small module, imported by both the site and the publish script:

export const ATPROTO_DID = 'did:plc:igvnuse3uenkwmic3lgoxhu5';
export const PUBLICATION_URI = `at://${ATPROTO_DID}/site.standard.publication/self`;
export function documentUri(slug) {
return `at://${ATPROTO_DID}/site.standard.document/${slug}`;
}

I wrote it as plain .js rather than .ts on purpose: the SvelteKit app imports it through $lib, and a standalone Node script imports it by relative path, and I wanted one source of truth for the DID and the URI shape rather than two that drift.

Verification: proving the domain and the repo are the same person

A record claims url: https://oz.akan.io. Anyone can write that claim into their own repo. So Standard.site asks for a handshake in the other direction: the domain has to point back at the record.

Two pieces. First, an endpoint at /.well-known/site.standard.publication that returns the publication’s AT-URI as plain text. Second, a <link> tag on each article pointing at its document record. A consumer that finds a record claiming my domain fetches the well-known file and checks that the URIs match. If they do, the same person controls both. If they don’t, the record is just noise.

The endpoint is trivial except for one SvelteKit detail. SvelteKit’s filesystem router ignores any directory whose name starts with a dot, so you cannot just make a .well-known folder. The escape hatch is hex-encoding the leading dot: a route directory named [x+2e]well-known resolves to /.well-known/. And I serve it dynamically rather than prerendering it, because the path has no file extension and I wanted to set Content-Type: text/plain myself instead of letting a static host guess:

src/routes/[x+2e]well-known/site.standard.publication/+server.ts
import { PUBLICATION_URI } from '$lib/standard-site';
export const prerender = false;
export const GET = () =>
new Response(PUBLICATION_URI + '\n', {
headers: { 'Content-Type': 'text/plain; charset=utf-8' }
});

The bug

The link tags were supposed to be the easy half. A publication hint belongs on every page, so I put <link rel="site.standard.publication" href={PUBLICATION_URI}> in the root layout, dropped the per-article document link onto the essay template, and ran the production build.

The build crashed.

TypeError [Error]: Invalid URL
input: 'at://did:plc:igvnuse3uenkwmic3lgoxhu5/site.standard.publication/self',
base: 'sveltekit-internal:///'

SvelteKit prerenders the static pages of the site, and to do that it crawls them: it walks the rendered HTML, collects every href, and follows the internal ones to discover more pages. It found my publication link in the layout, which means it found it on every prerendered page, and it tried to resolve at://did:plc:… as a URL to crawl. new URL() does not know what at:// is. The DID has colons where a hostname should be. It threw, and the build went down with it.

The mistake was mine and it was a category error: I had reached for “this belongs everywhere, so put it in the layout,” when an at:// URI is not a thing you ever want a web crawler to touch. The fix follows from that. My article pages are already prerender = false (they render per-request, because the body depends on whether you’re signed in), which means they are never crawled. So that is exactly where the at:// links belong, and nowhere else:

<!-- only on article pages, which are never prerendered/crawled -->
<link rel="site.standard.publication" href={PUBLICATION_URI} />
{#if isPublishable(data.metadata)}
<link rel="site.standard.document" href={documentUri(data.slug)} />
{/if}

The publication discovery hint is optional anyway (the well-known endpoint is the authoritative declaration for the whole origin), so moving it off the homepage cost nothing. The build went green. I left a comment in the template explaining why these links must not migrate back up to the layout, because the obvious place to put them is the place that breaks.

The publish step

Nothing writes a record until I run a script, and the script defaults to a dry run. I wanted publishing to a public network to be a deliberate command, not a build side effect.

It is a single Node file, raw XRPC over fetch, no SDK. It reads my Bluesky app password from ~/.bluesky-config.json (the same config my other atproto tooling uses), resolves my PDS from the DID document via plc.directory, opens a session, and upserts the records. Three details earned their place:

  • It authenticates, then checks that the session DID equals the DID it’s about to write to, and refuses if they differ. A wrong handle in the config fails loudly instead of publishing to the wrong repo.
  • It calls putRecord with validate: false. The lexicons are community schemas; my PDS doesn’t host them, so server-side validation would reject records for a lexicon it’s never seen. Turning it off says “trust the client.”
  • It enumerates the essays the same way the site does (skip drafts, skip anything with a future publish date), so the script and the site never disagree about what’s public.

The preview extraction is where the content policy actually lives. I take the markdown above the preview-end marker, render it, and flatten it to plain text. The first version leaked: one essay opens with an MDsveX <script> block importing a component, and that import line is inert on the page but showed up verbatim in my plain-text preview. So I strip <script> and <style> blocks before flattening. The preview now holds the prose a visitor reads, and nothing else.

$ npm run standard-site -- --apply
Authenticated as oz.akan.io (did:plc:igvnuse3uenkwmic3lgoxhu5) on https://elfcup.us-east.host.bsky.network.
✓ publication/self
✓ document/your-report-isnt-difficult-theyre-afraid
✓ document/where-drive-goes-missing
✓ document/the-truth-ladder-thesis

A --prune flag deletes records whose slug no longer maps to a published essay. It’s off by default, because deletion is the one thing here you can’t take back with another putRecord.

Validating it

The records are independent of my site, so I check them independently. pdsls.dev is a browser for atproto repos: I open the publication and a document and read the JSON back. From a terminal, com.atproto.repo.listRecords against the PDS lists every document without any auth, because they’re public.

The site half I check after deploy. curl -i https://oz.akan.io/.well-known/site.standard.publication should be a 200 with text/plain and the right AT-URI; curl on an article and grep for site.standard should show the two link tags. The handshake passes when the string the well-known returns is byte-for-byte the AT-URI in the record.

The last test is the one that matters. Paste an essay link into the Bluesky composer. Without a record you get a gray rectangle: a title, a favicon, the same card any URL gets. With one, the card comes back as an article. When it does, the network believes me.

Why I bothered

The honest version is that I read the announcement and wanted to play with it the same afternoon. The longer version is that I keep my writing on my own domain on purpose, and the cost of that has always been reach: the platforms render your link as a stranger’s link. Standard.site lets the canonical copy stay where I control it while a record on the open network describes it well enough that other software can do something useful with it. I’m not moving in. I’m leaving a forwarding address that the rest of the network can read.

And it cost me nothing I’d meant to keep. The AI crawlers are still blocked. Subscribers still hold the only full copy of each essay. What went onto the network is the title, the preview, and my name on it.

References