This repository contains the documentation for the AT Protocol, available to read at atproto.com.
To read documentation for the Bluesky API, go to docs.bsky.app or this repo.
- clone this repo
- run
npm install - run the development server with
npm run devoryarn dev - open http://localhost:3000 with your browser.
src/app/[locale]/en.mdx generates http://localhost:3000 -- start there if you'd like to make changes.
The page auto-updates as you edit the file.
Long-form pages render a sticky table-of-contents nav on the right (PageSectionsNavigation), auto-generated from the page's ## headings. To hide it on a specific page — for example, when the page supplies its own in-content table of contents — set hideSectionNav in the MDX header export:
export const header = {
// ...
hideSectionNav: true,
}The flag is opt-in and defaults to off, so every other page is unaffected: the right-hand nav still renders wherever a page has ## sections. Heading anchors are generated independently, so in-page links keep working either way.
All blog-related commands are available through a single entry point:
npm run blog <command>| Command | Description |
|---|---|
npm run blog create |
Create a new blog post |
npm run blog remove |
Remove a blog post |
npm run blog ssite <slug> |
Publish a post as a standard-site record |
npm run blog hide-reply <url> |
Hide a reply or detach a quote post |
npm run blog create-publication |
Create the publication record (one-time setup) |
Run npm run blog with no arguments to see this list.
Use the site's canonical publishing account, not your personal Bluesky.
npm run blog ssitecreates standard.site records on whichever PDS the.envcredentials authenticate against. For atproto.com that needs to be the atproto.com Bluesky account — records published from a personal account won't verify against the site, and have to be manually deleted from that PDS to clean up the duplicate. Coordinate with the team if you don't already have the shared credentials.
cp .env.example .envFill in ATPROTO_HANDLE and ATPROTO_APP_PASSWORD (create an app password in Bluesky settings).
npm run blog createThis will prompt you for:
- Title - The post title
- Slug - URL-friendly identifier (auto-suggested from title)
- Description - Short summary for the blog index
- Author - Defaults to "AT Protocol Team"
- Bluesky DID - If the author isn't in the registry, you'll be prompted for their DID (optional)
The script creates the necessary files and updates the blog index automatically.
After scaffolding, create automatically publishes the post's standard.site
record (the same step as npm run blog ssite <slug>) and writes the resulting
standardSiteUri back into the post's MDX header. This requires
ATPROTO_HANDLE / ATPROTO_APP_PASSWORD in .env and network access.
-
If the publish fails (missing credentials, offline, wrong publishing account), creation still succeeds — you'll get a warning and the exact
npm run blog ssite <slug>command to publish later. -
To skip publishing entirely (offline drafting), pass
--no-ssite:npm run blog create -- --no-ssite
Because the record's canonical URL only resolves after the post is merged and
deployed, the publish is metadata-only and idempotent — re-running
npm run blog ssite <slug> after edits updates the same record.
Every content route — blog posts, episodes, guides, specs — is a page.tsx next
to an en.mdx. Two rules hold across all 131 of them:
Route metadata comes from the MDX header. page.tsx exports
generateMetadata() reading header.title / header.description, rather than
repeating them in a metadata object. That object used to be a second copy that
nothing kept in sync — the studio and CLIs rewrite en.mdx and the
posts.ts/episodes.ts entry but never touched page.tsx — which left episodes
whose <h1> and OG preview disagreed. There is now nothing to keep in sync.
en.mdx is imported statically. A template-literal import() makes webpack
build a context module, so the route has no dependency edge on the specific
file and editing content doesn't hot-reload until you restart the dev server.
A static import fixes that.
Which shape a page uses depends on whether its area is translated. Only
guides, articles, and specs are (per crowdin.yml, into ja/ko/pt):
- Untranslated — blog, off-protocol, about — import
en.mdxand nothing else. No locale resolution at all. - Translated import English statically for the fallback and the module edge, and resolve the requested locale per request.
Both share two helpers in src/lib/localizedMdx.ts, so the fallback rule and the
header lookup are defined and tested once rather than regenerated into 130 route
files: resolveLocaleMdx() (English short-circuits; a locale with no translated
file falls back rather than 500ing) and mdxRouteMetadata(). The dynamic
import() still lives in each page, since webpack resolves it relative to that
file.
Route metadata is localised. /ja/guides/account-management reports
アカウント管理, and a page with no translation for the requested locale falls
back to English. The - AT Protocol suffix comes from the root layout and stays
English.
Two pages predate the header convention and export metadata from their MDX
instead (guides/data-validation, specs/permission) — mdxRouteMetadata()
prefers header and falls back to metadata, so both conventions work, including
specs/permission, whose English file uses one and its Korean the other. The
former page also renders its MDX directly, with no <Page> wrapper, because its
content supplies its own heading.
mdx.d.ts types the named MDX exports. @types/mdx only types the default
export, and nothing surfaced that before, because the dynamic import resolved to
any.
Individual blog post pages display an author byline below the date. Named authors with a Bluesky DID are linked to their bsky.app profile.
Author-to-DID mappings are stored in src/lib/authors.json, which serves as the single source of truth. The PageHeader component looks up the DID at render time based on the author name from the post's MDX header — no need to store DIDs in individual posts.
When creating a new post, if the author name isn't found in the registry, the script will prompt for a DID and automatically add it to authors.json for future posts. Authors without a DID (e.g. guest authors) simply get a plain text byline with no link.
A browser UI for authoring both blog posts and Off Protocol episodes. It
writes exactly the same files the CLIs write — page.tsx, en.mdx with the
export const header front matter, and the src/lib/posts.ts or
src/lib/episodes.ts entry — so the two approaches are interchangeable. Use
whichever suits the moment.
npm run dev
# then open http://localhost:3000/studio/studio is the index. From there:
| Editor | URL | Writes |
|---|---|---|
| Blog | /studio/blog |
src/app/[locale]/blog/<slug>/ + src/lib/posts.ts |
| Podcast | /studio/podcast |
src/app/[locale]/off-protocol/<slug>/ + src/lib/episodes.ts |
Each editor has a switch to the other in its sidebar.
- Dev only. Pages and API routes return 404 when
NODE_ENV === 'production'. The site deploys to the Cloudflare edge, where filesystem writes can't run anyway, so it is never reachable in prod. - Files are the source of truth. Every load re-reads from disk, and a save
rewrites only the fields the form owns, plus the body. Imports, custom JSX,
and header fields the form doesn't manage are preserved byte-for-byte.
Hand-editing the
.mdxis fully supported — the UI is the easy path, the raw file is for everything else. - Unsaved work survives a reload, not a closed tab. The open document's slug
is in the URL (
?slug=…), and a draft of the form is kept insessionStorage, so a full page reload comes back to the same document with your text intact and says so in the action bar, with a Discard beside it. Next issues full reloads in dev for reasons that have nothing to do with the studio — opening another localhost tab can be enough — which is what this exists for. A draft is only kept while the form differs from the file, so the message only appears when there was something to recover. Drafts are per document and per tab: switching to another episode and back brings its draft with it, and two tabs on two episodes don't tread on each other. Closing the tab drops the draft, on purpose — a draft that outlived the session would eventually be offered for a file that had moved on since. - A save is refused if the file changed underneath you. Every load
fingerprints the file it read, and every save sends that fingerprint back; if it
no longer matches, nothing is written and the editor says so with a Reload
from disk button. Nothing is ever merged silently — resolving it is the
author's call, because the two versions can't be reconciled without knowing
which one is wanted. You'll meet this if you hand-edit the
.mdxwhile a tab has it open, or from a second tab on the same document. A restored draft carries the fingerprint it was captured with, so it conflicts the same way rather than overwriting a newer file. The CLIs send no fingerprint and stay last-write-wins. - Lists scan the content directory, so anything you created by hand or with the CLI shows up in the sidebar.
- Slug is read-only after creation. To rename, delete and recreate.
- Delete removes the directory and the index entry, behind a confirmation. Recoverable from git if it was committed.
- Open Graph image: drag-and-drop (or click to choose) saves the image as
opengraph-image.<ext>in the item's directory, which is the Next file convention. PNG/JPG/GIF, ≤8MB, exactly one per item; a new drop replaces it. Automatic generation is not implemented for either editor. - No body preview. Use the Open ↗ link in the action bar to see the real page.
- Creating offers a branch. The create form shows the branch it will make
from
origin/main, with an editable name and the exact commands it will run. Defaults match the names already in use:blog-<slug>for posts,off-protocol-<YYYY-MM-DD>for episodes. Untick it to create on the current branch instead. Editing existing content never branches — the action bar just shows which branch you're on, so it isn't silent. - A dirty working tree blocks branching, not authoring. Any output from
git status --porcelaincounts, untracked files included: a stray file would travel to the new branch and could land in the PR. The form lists what's dirty and still lets you create where you are. Nothing is written when a branch is requested and refused — the refusal is complete, not partial. - Nothing else is a git operation. Staging, committing, and pushing stay in your normal flow.
- Smart typography on save. Titles and descriptions are stored with curly
quotes, em/en dashes, and ellipses, matching what the prose pipeline does to
MDX bodies at render time. Those two fields are plain JS strings in
posts.ts/episodes.ts, so remark never sees them — without this a title reads"Like This"while the body around it is curled. The editor shows the transformed value after saving. Slugs, dates, URLs, and author/host names are left exactly as typed.npm run blog createandnpm run podcast createapply the same transform, so it doesn't matter which tool you author with. - The title lives in two places, not three.
page.tsxderives its route metadata from the MDX header viagenerateMetadata(), so editing a title updates<title>and the OG preview without anything having to copy it there. See Content page structure.
The Studio runs fine with no credentials at all — you just lose two features.
Both read .env, and Next only reads .env at boot, so restart the dev
server after editing it.
| Feature | Needs |
|---|---|
| Blog → standard.site publishing | ATPROTO_HANDLE, ATPROTO_APP_PASSWORD |
| Podcast → audio upload | CLOUDFLARE_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, R2_PUBLIC_BASE |
See .env.example for the full annotated list.
- Create: pick New, fill title/description/date/author — the slug derives
from the title, and an Author DID field appears for authors not yet in
authors.json. Write the body as raw MDX, then Save. - standard.site: creating a post auto-publishes its record and every Save
updates it; there's also a manual Publish button. It shells out to the
same path as
npm run blog ssite, so it needs the publishing credentials. Failures are non-blocking — the post still saves and a warning shows. Once published, the front matter shows the record'sat://URI with Copy and pdsls ↗ buttons. - Delete does not retract an already-published standard.site record, same as the CLI.
- Two-step create. Fill in the metadata and press Create episode; audio, show notes, and the OG image appear afterwards. Those three write into the episode's directory, which doesn't exist until the episode does.
- Audio upload is the only way to set an episode's audio here — there is no
URL field. Without R2 credentials the drop zone reports
R2 not configured; usenpm run podcast createinstead if you already have a hosted MP3 URL. See audio upload below for the details. - Publish date. One control sets
pubDate(what RSS reads) and derives the Display date from it, so the two can't drift. Edit the display date on its own for custom wording. - Show notes are the MDX body.
hasShowNotesis recomputed from whether the body has content, so there's no flag to remember. - Hosts defaults to the show host when left empty; fill it in only for guest-hosted episodes.
- Not in the UI: the Bluesky discussion URL (
blueskyPostUrl) and transcripts. SetblueskyPostUrlby hand inen.mdxto attach a discussion thread; for a transcript, paste it into the generatedtranscript.mdxand fliphasTranscript: true. The editor preserves all three on save. - Delete asks a second time whether to remove the MP3 from storage as well. That one is irreversible — see audio upload.
npm run blog removeThis displays a paginated list of blog posts (10 at a time, most recent first). Select a post by number to remove it. The script will:
- Ask for confirmation before proceeding
- Remove the post entry from
src/lib/posts.ts - Delete the post directory and all its files (content, translations, images)
The blog provides three feed formats, auto-discoverable via <link> tags:
- Atom (recommended):
/feed.xml - RSS 2.0:
/rss.xml - JSON Feed 1.1:
/feed.json
Each feed includes the 50 most recent posts. Post data is shared from src/lib/posts.ts.
Blog posts can be published to the AT Protocol using the site.standard lexicon. This enables decentralized discovery and verification of content.
lex install site.standard.document
lex buildTroubleshooting: if
npm run buildfails with a type error like'…/@atproto/lex-schema/dist/external' has no exported member named 'TypedObject', the locally-generatedsrc/lexiconsdirectory is stale relative to the current@atproto/lexAPI. Regenerate with the--clearflag, which removes the existing output before rebuilding:npx ts-lex build --clear
src/lexiconsis gitignored, so this is a local-only fix.
npm run blog create-publicationSave the returned AT-URI to your .env as ATPROTO_PUBLICATION_URI.
npm run blog ssite <slug>For example:
npm run blog ssite welcome-to-the-blogThis will:
- Create a
standard.sitedocument record on the site's PDS (see the credentials note above — this is the publishing account, not yours) - Save the AT-URI back to the post's MDX file for verification
- Update the record if it already exists
TODO:
- Integrate this into the publishing pipeline so all of this happens automatically
The site implements site.standard verification:
- Publication:
/.well-known/site.standard.publicationreturns the publication AT-URI - Documents: Each published post includes a
<link rel="site.standard.document">tag pointing at the document record, plus a<link rel="site.standard.publication">tag pointing at the shared publication record
For production, set ATPROTO_PUBLICATION_URI in your deployment environment.
Blog posts can display a conversation section powered by Bluesky. The <bsky-conversation> web component fetches replies, quote posts, and reposts for a given Bluesky post and renders them as a threaded timeline.
- Post the blog link from the account on Bluesky
- Add the post URL (using the DID, not the handle) to the blog post's MDX header:
export const header = { // ... blueskyPostUrl: 'https://bsky.app/profile/did:plc:ewvi7nxzyoun6zhxrhs64oiz/post/3mf2y35apvc2i' }
- The conversation section renders automatically below the post content
The web component at public/bsky-conversation.js has zero dependencies and can be used on any site:
<script src="/bsky-conversation.js"></script>
<bsky-conversation uri="https://bsky.app/profile/did:plc:.../post/..."></bsky-conversation>| Attribute | Default | Description |
|---|---|---|
uri |
(required) | The bsky.app post URL. Use DID-based URLs for reliability. |
max-depth |
3 |
How many levels of nested replies to show. Also controls how deep the API fetches. At the cutoff, a "More of the conversation on Bluesky" link appears. |
show-original-post |
false |
Set to "true" to include the root post in the timeline. |
engage-text |
"Add your thoughts on Bluesky" |
CTA link text shown in the header and at the bottom of the conversation. Set to "" to hide both. |
header-template |
(none) | Custom header template string. Overrides the default <ul> header format. |
The header-template attribute supports a mini template language for interpolating conversation data.
Simple tokens — replaced with their value:
| Token | Value |
|---|---|
{replies} |
Raw reply count |
{quotes} |
Raw quote count |
{reposts} |
Raw repost count |
{repostedBy} |
Linked names, e.g. @alice, @bob, and 3 others |
{postUrl} |
The bsky.app post URL |
Pluralization — {name|singular|plural} outputs nothing when the count is 0, "1 singular" when 1, "N plural" when 2+:
{replies|reply|replies} → "" or "1 reply" or "17 replies"
{quotes|quote|quotes} → "" or "1 quote" or "5 quotes"
Conditional blocks — {name?content} renders content only if the value is truthy (non-zero, non-empty). Use this to wrap text around tokens that might be absent:
{repostedBy?Reposted by {repostedBy}.} → "" or "Reposted by @alice, @bob."
{replies?{replies|reply|replies} so far} → "" or "17 replies so far"
Full example:
<bsky-conversation
uri="https://bsky.app/profile/did:plc:.../post/..."
header-template="This post has {replies?{replies|reply|replies}}{quotes?, {quotes|quote|quotes}}{repostedBy?, and has been reposted by {repostedBy}}."
/>When no template is provided, the component falls back to its default <ul>-based header with individual stats items.
The header template for this site is configured as a constant in src/components/Page.tsx. Per-page overrides are possible via the MDX header:
export const header = {
// ...
blueskyHeaderTemplate: "...",
}The component defines design tokens with sensible defaults, overridable from the host page. More to come!
| Property | Light default | Dark default | Controls |
|---|---|---|---|
--bsky-border-color |
#e5e7eb |
#374151 |
Separators, thread lines |
--bsky-muted-color |
#6b7280 |
#9ca3af |
Handles, timestamps, secondary text |
--bsky-link-color |
black |
#60a5fa |
Link text color |
--bsky-link-hover |
#2563eb |
#3b82f6 |
Link hover color |
--bsky-link-underline |
rgba(82,82,91,0.5) |
rgba(59,130,246,0.3) |
Link underline color |
--bsky-link-underline-hover |
rgba(59,130,246,0.3) |
rgba(59,130,246,0.3) |
Link underline hover color |
Override example:
bsky-conversation {
--bsky-link-color: #333;
--bsky-muted-color: #888;
}The component inherits all typography (font-family, font-size, line-height, color) from its parent. All internal sizing uses em units so it scales with the inherited font size.
- The root post author's direct replies are filtered out (they're extensions of the original post, not conversation). The author's replies to other people's comments are shown.
- Hidden replies are filtered out. If you hide a reply on bsky.app (click the
···menu on a reply → "Hide reply for everyone"), it won't appear in the conversation component. This works at all nesting levels. Note: "Hide reply for me" is a personal mute and won't affect what the component shows — you need "Hide reply for everyone" to write to the public threadgate record. - Reply threads are capped at 3 levels deep by default (configurable via
max-depth). A "More of the conversation on Bluesky" link appears at the cutoff. - Reply threads stay grouped — nested replies are not flattened into the timeline.
- Detached quote posts are filtered out. If you detach a quote on bsky.app (or via the script below), it won't appear in the conversation component.
- Quote posts are interleaved chronologically with top-level reply threads.
- Reposts appear only in the header summary, not as timeline items.
- API failures (e.g.,
getRepostedByreturning 500) degrade gracefully — the rest of the conversation still renders.
The hide-reply script lets you hide replies or detach quote posts from the conversation component via the command line. It auto-detects the post type:
# Hide a reply (adds to threadgate hiddenReplies)
npm run blog hide-reply https://bsky.app/profile/did:plc:.../post/...
# Detach a quote post (adds to postgate detachedEmbeddingUris)
npm run blog hide-reply https://bsky.app/profile/did:plc:.../post/...Requires ATPROTO_HANDLE and ATPROTO_APP_PASSWORD in .env. The authenticated user must own the root post being replied to or quoted.
- Replies: The script walks up the thread to find the root post and adds the reply URI to the root post's
app.bsky.feed.threadgaterecord. This is equivalent to "Hide reply for everyone" on bsky.app. - Quote posts: The script detects the embedded post and adds the quote URI to the root post's
app.bsky.feed.postgaterecord. This is equivalent to "Detach quote" on bsky.app.
- handle newlines in replies
- handle images in replies (or don't!)
- lots of styling
- more templating
- how should quote posts appear differently from replies?
- extract into standalone project(?)
Two interactive web components live at src/components/ScopeBuilder/ and are embedded as the only content of two guide pages:
<scope-builder>at/guides/scope-builder— picks scopes from a curated catalog (apps + their permission sets, plus standalone individual scopes) and assembles a complete OAuth scope string ready to paste intooauth-client-metadata.json.<permission-author>at/guides/permission-set-builder— composes individual permissions into a permission-set Lexicon JSON document for lexicon authors who want to publish their own.
Both are vanilla JS custom elements; thin React loaders (ScopeBuilderLoader.tsx, PermissionAuthorLoader.tsx) handle client-side registration. See src/components/ScopeBuilder/README.md for architecture details and how to add a new app to the curated catalog.
Scope-string serialization uses the official @atproto/oauth-scopes package as the canonical implementation. Our wrappers in scopeUtils.ts adapt the library's strict types to the looser shapes our forms produce, but the actual scope-string formatting flows through the library at runtime.
To keep it current:
npm update @atproto/oauth-scopes
npm test
npm run dev # spot-check both guide pagesThe unit tests cover the format expectations end-to-end. If the library's output format ever changes, the test suite is the first place you'll see it. After upgrades, also visit /guides/scope-builder and /guides/permission-set-builder to verify the generated strings still look right; the assembled scope string is sorted alphabetically by the library, so any visual regression there is the first signal.
The curated permission-set catalog in scopeData.ts is hand-maintained — adding a new app or a new permission set means appending a few lines there, not running a generator. The library does not author Lexicons, only parse them.
When a third-party app publishes a permission-set Lexicon and we want it to appear in the Scope Builder's pill row, edit src/components/ScopeBuilder/scopeData.ts. Both the app and the set live in this one file.
You'll need:
- The publishing repo's DID (e.g.,
did:plc:...). Find it on the app's profile or via Lexicon Garden. - The permission set's NSID (e.g.,
app.acme.authFull). - The set's title and detail (copy from the Lexicon record's
defs.main.titleanddefs.main.detail, easiest to grab fromhttps://lexicon.garden/lexicon/<did>/<nsid>/llms.txt). - The list of permissions the set bundles (also in the same Lexicon record, under
defs.main.permissions). - The set's audience DID if it contains rpc permissions with
inheritAud: true. Most third-party sets are repo-only and don't need this. - Claude can usually one shot adding a new permission set if you pass the llms.txt from Lexicon Garden
The recipe:
-
Add a DID constant near the top of
scopeData.ts, alongside the existingBSKY_DID,BEACONBITS_DID, etc:const ACME_DID = 'did:plc:...'
-
Append to the
apps[]array, keeping it alphabetical:{ id: 'acme', name: 'Acme', did: ACME_DID },
-
Append one entry per permission set to
permissionSets[], withappIdmatching what you used in step 2:{ id: 'app.acme.authFull', // same as the NSID appId: 'acme', label: 'Full Acme Access', // from the Lexicon's title description: 'One-line summary for the checkbox.', kind: 'permission-set', resourceType: 'include', scopeString: 'include:app.acme.authFull', // add `?aud=...%23...` only if defaultAud is set // defaultAud: 'did:web:api.acme.app#api', // ONLY for sets with rpc inheritAud permissions expandedPermissions: { repo: [ { collection: 'app.acme.thing', actions: [...ALL_WRITE_ACTIONS] }, ], rpc: ['app.acme.getThings'], // omit if the set has no rpc permissions }, specLink: lexiconGardenLink(ACME_DID, 'app.acme.authFull'), explanation: 'Longer prose shown when the user expands the checkbox.', }
The
defaultAudfield is in unencoded form (raw#); the library handles%23encoding when emitting scope strings. Only include it if the underlying Lexicon containsrpcpermissions withinheritAud: true— for repo-only sets, omit it and the include-scope string drops the?aud=suffix. -
Run the test suite (
npm test) to confirm the data shape is valid. Thennpm run devand visit/guides/scope-builderto verify the new pill appears alphabetically and the set's bundled permissions render under "Bundled permissions (N)."
For adding individual (non-set) scopes, scopes with subset relationships, warning badges, or the deeper rendering details, see src/components/ScopeBuilder/README.md.
The site hosts the Off Protocol podcast at /off-protocol. Episodes follow the same MDX-per-directory pattern as the blog, with podcast-specific additions: native <audio> playback, optional transcripts, an RSS feed for podcatchers, and subscribe links.
npm run podcast createRefuses to run on a dirty working tree, then offers to create a branch from
origin/main — the same step, and the same implementation (src/lib/git.mjs),
as the Dev Studio. The CLIs refuse outright on a dirty
tree rather than offering to continue, which is reasonable for a command you
invoke deliberately.
Prompts for title, guests, slug, episode number, description, audio URL, and an
optional Bluesky discussion link. Guests come before the slug so the suggested
slug can include the guest: episodes default to
YYYY-MM-DD-title[-first-guest], matching the show's existing slugs. The script
HEADs the audio URL (failing if unreachable) and probes its duration via
ffprobe if installed (falling back to a manual prompt). It scaffolds:
src/app/[locale]/off-protocol/<slug>/page.tsxsrc/app/[locale]/off-protocol/<slug>/en.mdx(show notes)src/app/[locale]/off-protocol/<slug>/transcript.mdx(optional transcript stub)
…and prepends a new entry to src/lib/episodes.ts.
To remove an episode:
npm run podcast removeThis deletes local files only. Once a feed guid has been distributed to subscribers, you cannot retroactively unsubscribe them — be deliberate.
npm run podcast create has a browser equivalent — see
Dev Studio. It writes the same three files and the same
episodes.ts entry, and adds two things the CLI can't do: it uploads the MP3
for you, and it reads the duration out of the file rather than asking ffprobe.
Only the Studio uploads audio; npm run podcast create expects a URL you've
already hosted. Uploading needs five values in .env:
CLOUDFLARE_ACCOUNT_ID= # 32-char hex, from the R2 overview page
R2_ACCESS_KEY_ID= # from an R2 API token, Object Read & Write
R2_SECRET_ACCESS_KEY= # ditto — NOT the "Token value" on that page
R2_BUCKET= # the media bucket behind R2_PUBLIC_BASE
R2_PUBLIC_BASE= # https://media.atproto.com
Uploads use R2's S3-compatible API with the bucket-scoped Access Key pair,
not a Cloudflare API token. That's deliberate: the REST API behind
wrangler r2 object put only accepts an account-wide
Workers R2 Storage: Edit token, which could write to every bucket in the
account, while S3 credentials can be scoped to this one bucket. wrangler
commands against these objects will fail for the same reason — use an S3 client
or the dashboard. CLOUDFLARE_API_TOKEN is not used by anything here.
Drop an MP3 on the zone and it uploads, then writes audioUrl,
audioSizeBytes, duration, and durationSeconds into en.mdx and
episodes.ts immediately — no separate Save needed, so an uploaded object is
never left unreferenced. Large episodes take a few minutes; the status line is
the only progress signal, since the upload isn't streamed back to the browser.
The object key is date-stamped from the episode's publish date, matching the layout the show's existing objects use:
off-protocol/<YYYY-MM-DD>-<slug>/<slug>.mp3
The key is fixed at upload time — changing the publish date afterwards does not
move the object, and audioUrl in the episode header stays the authoritative
pointer.
Deleting an episode in the Studio offers to delete its MP3 too, as a second
confirmation after the episode one. It's opt-in per deletion and irreversible —
the files are recoverable from git, the object isn't. Decline it and the object
stays. The key comes from the episode's stored audioUrl rather than being
recomputed, so it stays correct even if the publish date changed after upload,
and audio hosted outside the bucket is never touched. If the object delete
fails, the episode is left in place rather than stranding an object whose key
you no longer have.
To remove an object by hand — for an episode deleted before this existed, or
one removed with npm run podcast remove — use the R2 dashboard or an S3
client:
aws s3 rm "s3://<bucket>/off-protocol/<YYYY-MM-DD>-<slug>/<slug>.mp3" \
--endpoint-url "https://<account-id>.r2.cloudflarestorage.com"src/lib/episodes.ts stores both date ("May 7, 2026") and pubDate (ISO 8601), and both duration ("HH:MM:SS") and durationSeconds (a number). This is deliberate:
- Display formats and machine formats serve different consumers.
- Deriving one from the other at render time means re-parsing on every page load and risks subtle locale bugs.
- The RSS spec wants specific formats (
pubDatein RFC 822,<itunes:duration>inHH:MM:SS).
The npm run podcast create script populates both fields in sync. They cannot drift unless edited by hand.
Things that must happen before submitting the feed to Apple Podcasts or Spotify:
- At least one episode added via
npm run podcast createor/studio/podcast
Before announcing the show or submitting to directories:
- Run
npm run devand visithttp://localhost:3000/off-protocol/rss.xml - Validate against validator.podcastindex.org and castfeedvalidator.com — both must pass
- Subscribe to the local feed in Pocket Casts (it accepts arbitrary URLs) and confirm episodes appear with art, duration, and show notes
- Confirm audio plays from each episode page on desktop and mobile
Once Apple/Spotify/Overcast/Pocket Casts have ingested the feed (typically 24–72h after submission), populate the corresponding URLs in SHOW.subscribe in src/lib/episodes.ts. The SubscribeLinks component renders a button only for non-null entries — at launch only RSS and the generic podcast:// link are populated.
Episode RSS GUIDs are off-protocol-ep-<episodeNumber> and must never change. Slugs may be renamed; GUIDs may not. Renaming a GUID makes every podcatcher re-download the episode as new.
The feed builder validates inputs at render time — invalid pubDate or audioSizeBytes will throw rather than emit a malformed feed.
Bluesky is an open social network built on the AT Protocol, a flexible technology that will never lock developers out of the ecosystems that they help build. With atproto, third-party can be as seamless as first-party through custom feeds, federated services, clients, and more.
Documentation text and the atproto specifications are under Creative Commons Attribution (CC-BY).
Inline code examples, example data, and regular expressions are under Creative Commons Zero (CC-0, aka Public Domain) and copy/pasted without attribution.
Please see LICENSE.txt with reminders about derivative works, and LICENSE-CC-BY.txt for a copy of license legal text.
Bluesky Social PBC has committed to a software patent non-aggression pledge. For details see the original announcement.