Publishing to standard.site
With a pds section in the config, blogwright mirrors the site to your AT Protocol PDS as standard.site records: one site.standard.publication record for the site and one site.standard.document record per post. After a one-time setup, publishing is hands-off — every successful production deploy re-reconciles the records against your local content.
The model
Section titled “The model”The sync is a reconcile, not an event stream:
- One publication record (
site.standard.publication) describes the site: its URL (https://<domain>), the display name frompds.name, and the optionalpds.description. It is created once bypds initand updated whenever the config drifts from the live record. Preferences you set out-of-band in a standard.site client (such as turningshowInDiscoveroff) are preserved — a config-driven update never clobbers them. - One document record per post (
site.standard.document) carries the post’s title, description, publication date, and URL path. Posts are enumerated from the content collection (src/content/blogby default, overridable viapaths.content): every.md/.mdxfile, excluding drafts (draft: true); each post must carrytitle,description, andpubDatefrontmatter — a file missing any of them fails the sync rather than being skipped. A frontmatterslugwins outright; otherwise the slug is the file path minus its extension and any trailing/indexsegment — the same id Astro’s glob loader produces.
Each document’s record key (rkey) is derived deterministically from the post’s URL path — /posts/<slug>/ — using a vendored TID scheme from mastrojs/atproto. No lookup table, no PDS round-trip: the CLI and your site can both compute the rkey from the path alone, which is how the site builds its <link rel="site.standard.document"> tags via the blogwright/rkey subpath export. See the rkey reference for the derivation.
Configuration
Section titled “Configuration”A name is all that is required:
{ "region": "us-east-1", "siteName": "example", "domain": "example.com", "pds": { "name": "My Blog", },}| Key | Default | Purpose |
|---|---|---|
pds.name |
(required) | Publication display name, and the OAuth client name shown on the consent screen. |
pds.description |
none | Optional publication description. |
pds.secretName |
<siteName>/atproto |
Secrets Manager secret holding the OAuth client key and session. |
pds.handleResolver |
https://public.api.bsky.app |
Resolver pds login uses to turn a handle into a DID. Must be an https URL. Logging in with a bare DID skips handle resolution entirely — the PDS endpoint itself is always discovered from the DID document during OAuth. |
Publishing also requires a configured domain — the OAuth client identity and every published URL are built from it. See Custom domains and the configuration reference. Without a pds section the feature is entirely inert.
How authentication works
Section titled “How authentication works”Auth is atproto OAuth, and the site itself is a confidential OAuth client. It serves its own client documents from protocol-fixed paths:
/oauth/client-metadata.json— the client metadata; per the atproto OAuth spec its URL is the client id./oauth/jwks.json— the public half of the client’s ES256 key./oauth/callback— the redirect target. A static page that displays the redirect parameters is enough, because the login flow only asks you to paste the URL you land on back into the terminal.
Both JSON documents are generated by pds keygen and committed to the repo (under paths.publicDir, public/ by default). The private key and the OAuth session live in a Secrets Manager secret (<siteName>/atproto by default), read only at keygen, login, and sync time — they never enter the builder MicroVM.
Refresh tokens are single-use: every sync transparently refreshes the session and writes the rotated token back to the secret. Confidential-client sessions live indefinitely as long as they refresh within 180 days, so a site that never goes that long between production deploys never needs to log in again. Concurrent syncs (overlapping CI deploys) are safe — when one sync rotates the session under another, the client re-reads the secret and adopts the rotated session instead of destroying it.
One-time setup
Section titled “One-time setup”Order matters: the OAuth documents must be live on the site before login can run, because both pds login and pds init first fetch the deployed /oauth/ documents and verify they match the local key and config — a stale or missing deployment would otherwise fail deep inside the OAuth flow.
1. Generate the client key
Section titled “1. Generate the client key”blogwright pds keygenThis generates an ES256 key, stores the private JWK in the Secrets Manager secret, and writes the two public client documents:
public/oauth/client-metadata.jsonpublic/oauth/jwks.json
Keygen clears any stored session — client auth is bound to the key, so re-running it means logging in again. (It is also the migration path from the pre-OAuth app-password secret format, which is no longer supported.)
2. Commit and release the OAuth documents
Section titled “2. Commit and release the OAuth documents”git add public/oauth && git commit -m "Add OAuth client documents"blogwright deployThe documents must be reachable at https://<domain>/oauth/… before the next step.
3. Log in
Section titled “3. Log in”blogwright pds login --identifier <handle-or-did>The CLI prints an authorization URL. Open it in a browser, approve access on your PDS, land on the site’s /oauth/callback page, and paste the full callback URL — query string and all — back into the terminal. The session and your DID are persisted into the secret.
4. Create the publication
Section titled “4. Create the publication”blogwright pds initgit add public/.well-known src/data/atproto.json && git commit -m "Add standard.site publication"pds init creates the site.standard.publication record (or updates it, idempotently, when one already exists) and writes two files for you to commit:
public/.well-known/site.standard.publication— the publication’s AT-URI; standard.site clients use it to verify the site.src/data/atproto.json—{ did, publicationUri }, which the site imports to render its document<link>tags.
If the committed well-known file points at a publication owned by a different account (a fork, or an account migration), pds init refuses rather than silently publishing documents against a publication you do not own — delete the file to start fresh, or log in with the owning account.
5. First sync
Section titled “5. First sync”blogwright pds syncThis reconciles all document records: creates missing ones, updates drifted ones, and reports how many were unchanged. pds sync publishes canonical production URLs and refuses to run for any other environment.
Steady state
Section titled “Steady state”From here on, publishing is automatic. Every successful production deploy re-runs the reconcile as a post-deploy step. It is deliberately non-fatal: a PDS outage must not fail a good site deploy, so a failed sync logs a warning and the next deploy heals. Non-production environments and uninitialised sites skip the step entirely.
A few behaviors worth knowing:
- Deleted posts are never deleted from the PDS. Records with no matching local post are reported as orphans with a warning listing their rkeys — remove them manually if the deletion was intentional.
- A lapsed session warns and skips. If the session is no longer valid (no refresh for 180 days, or a rotation raced), the post-deploy sync warns and the deploy succeeds; a direct
pds syncfails with the same explanation. Either way, re-runblogwright pds login— the client key survives, so no keygen or redeploy is needed. - Sanity checks before anything is written. The sync verifies the committed well-known file matches
src/data/atproto.json, and that the logged-in DID matches the one recorded at init — mismatches fail with a pointer topds initor the offending file. Two slugs that derive the same rkey fail the sync as a collision.
Managing the secret
Section titled “Managing the secret”blogwright pds secret status # metadata + which parts exist — never valuesblogwright pds secret delete --yes # log out and discard the client keypds secret status shows the secret’s name, ARN, and last-changed time, plus whether it holds a client key (and its key id), a DID, and a session — it never prints secret values. pds secret delete requires --yes and deletes the secret immediately, with no recovery window; getting back to a working state afterwards means starting over from pds keygen.