This blog federates over ActivityPub through Ghost so Mastodon users can follow @index@subaud.io. standard.site is the counterpart for the AT Protocol side: a set of lexicons that describe a blog as records in an atproto repo, the same identity and data layer behind a Bluesky account. Anything that speaks the protocol can then follow the blog, recommend posts from it, and index it — follows and recommendations live as records in readers' own repos, the same way Bluesky follows do. I checked how much of it subaud.io satisfied: the domain was already my Bluesky handle, but the site had no records in the repo and nothing a verifier could use to tie the domain to the identity.

Ghost(Pro) makes a few parts of this harder than they look, and one requirement isn't documented anywhere except the lexicon schemas themselves.

What compliant means

Compliance comes down to three things, each verifiable in both directions so nobody can claim a site they don't control:

  • A publication record lives in your atproto repo — a site.standard.publication record holding the site's url and name, plus a description and icon.
  • A verification endpoint at https://yoursite/.well-known/site.standard.publication returns that record's AT-URI — its at:// address — as plain text; a verifier fetches it and accepts the publication only if the returned URI matches the record it found.
  • A site.standard.document record exists for every post, each referencing the publication, and each post's HTML head carries a <link rel="site.standard.document"> tag pointing at its record.

The records live under your DID — the portable identifier behind your atproto account — not your CMS. If the blog ever moves off Ghost, the publication and whatever social graph accrues to it move with it.

Serving the verification endpoint

The verification requirement from the spec:

To verify a publication, add a .well-known endpoint to the domain: /.well-known/site.standard.publication … The endpoint should return the AT-URI of the publication record.

That's a one-line text file. I put it in an S3 bucket and added a rule to Ghost's redirects.yaml — the site-wide redirect map uploaded in Ghost Admin under Settings → Labs → Redirects — to send the path there:

301:
  /.well-known/site.standard.publication:
    https://llms.subaud.io/.well-known/site.standard.publication

The object is served as text/plain. A verifier follows the redirect and matches the URI it gets back against the publication record.

The document-side requirement:

To verify an individual document, include a <link> tag in the document's <head> that references its AT-URI.

Ghost's per-post codeinjection_head field carries that tag, and doubles as the record of which document belongs to which post — wrapped in marker comments so the sync never touches anything else in the field:

<!-- standard-site:start --><link
  rel="site.standard.document"
  href="at://did:plc:.../site.standard.document/3mqph6ri7mr2u"
><!-- standard-site:end -->

Ghost injects codeinjection_head into {{ghost_head}}, so the theme needs no changes. The sync reads the tag to find the record, which also makes slug renames safe: the record keeps its key and only its path field updates.

The records themselves carry metadata and the excerpt, not the post body:

{
  "$type": "site.standard.document",
  "site": "at://did:plc:.../site.standard.publication/...",
  "title": "Updated Full Stack Deployment - Single Stack",
  "publishedAt": "2021-08-21T00:00:00.000Z",
  "path": "/updated-full-stack-deployment-single-stack/",
  "description": "...",
  "tags": ["CDK", "AWS"],
  "coverImage": { "$type": "blob", "...": "..." }
}

The lexicon has an optional textContent field for the full plaintext body. I left it out: my posts are mostly code blocks and tables, which degrade badly as plaintext, and I'd rather the records point readers at the site than mirror a worse version of it.

Keeping records in sync with posts

A document record represents each post. The spec's quick-start defines the step — "Create a Document Record" — and its required fields:

Documents require site, title, and publishedAt.

Keeping those records true as posts come and go is the publisher's job.

Ghost announces every post change through webhooks: an HTTP POST of the post's JSON to a URL chosen when the webhook is created. I created one for each of four events — post.published, post.published.edited, post.unpublished, and post.deleted — all pointing at one API Gateway endpoint backed by a Lambda.

On publish or edit, the Lambda re-fetches the post from the Admin API and mirrors it into the document record: title, publish date, path, excerpt, and tags. It then writes the record's <link rel="site.standard.document"> tag — the one from the previous section — into the post's codeinjection_head. On unpublish or delete, it deletes the record and removes the tag.

The tag write is itself a post edit, so it triggers one more post.published.edited webhook. The second time through, the Lambda finds the record and tag already correct and writes nothing, and the loop stops there.

Backfilling existing posts

Webhooks only cover posts that change after they exist. Posts already on the site had no document records and no link tags. A one-time sweep created them: it listed every published post through the Admin API, created a document record for each, and wrote the record's link tag into each post's codeinjection_head.

Record keys

Both lexicons declare key: tid. A TID is atproto's timestamp identifier: 13 characters encoding a write time in microseconds, in a character set that sorts chronologically. The constraint lives in the published lexicon schemas — themselves atproto records, found through a DNS record on the lexicon publisher's domain:

dig +short TXT _lexicon.standard.site
# did=did:plc:re3ebnp5v7ffagz6rb6xfei4
# that repo's com.atproto.lexicon.schema records
# hold the schemas; defs.main.key == "tid"

The sync writes every record into the site's own repo, authenticated as the account that owns it. It satisfies the key rule by omitting the key: com.atproto.repo.createRecord called with only the repo, the collection, and the record returns a TID key assigned by the PDS, the server that hosts the repo.

The publication's key is generated too, so its AT-URI is only known after the record exists. Everything that embeds that URI — the well-known file, each document record's site field — comes from looking the record up: list the repo's publications and take the one whose url matches the site.

Checking the whole site

standard.subaud.io audits a site against everything above. Give it a site root and it walks the full chain: it fetches the well-known endpoint, resolves the publication record and lints it against the live lexicon schemas, lists every document record, fetches a sample of post pages to confirm each carries its link tag, and checks every key's format. Give it a post URL instead and it checks that one document: tag, record, publication, domain. Both modes exist because the publication can verify while individual posts drift — a missing tag or a stale record only shows up when the pages themselves are checked. It reports this site compliant.