Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1,406 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

atproto-website

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.

Making edits to atproto.com

  • clone this repo
  • run npm install
  • run the development server with npm run dev or yarn 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.


Hiding the on-page section nav

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.


Blog tools

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.

Configure credentials (one-time)

Use the site's canonical publishing account, not your personal Bluesky. npm run blog ssite creates standard.site records on whichever PDS the .env credentials 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 .env

Fill in ATPROTO_HANDLE and ATPROTO_APP_PASSWORD (create an app password in Bluesky settings).

Creating a new blog post

npm run blog create

This 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.

Content page structure

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.mdx and 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.

Author bylines

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.

Dev Studio (dev only)

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.

Ground rules (both editors)

  • 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 .mdx is 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 in sessionStorage, 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 .mdx while 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 --porcelain counts, 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 create and npm run podcast create apply the same transform, so it doesn't matter which tool you author with.
  • The title lives in two places, not three. page.tsx derives its route metadata from the MDX header via generateMetadata(), so editing a title updates <title> and the OG preview without anything having to copy it there. See Content page structure.

Credentials

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.

Blog editor

  • 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's at:// URI with Copy and pdsls ↗ buttons.
  • Delete does not retract an already-published standard.site record, same as the CLI.

Podcast editor

  • 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; use npm run podcast create instead 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. hasShowNotes is 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. Set blueskyPostUrl by hand in en.mdx to attach a discussion thread; for a transcript, paste it into the generated transcript.mdx and flip hasTranscript: 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.

Removing a blog post

npm run blog remove

This 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)

RSS / Atom / JSON Feed

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.


Publishing blog posts to AT Protocol

Blog posts can be published to the AT Protocol using the site.standard lexicon. This enables decentralized discovery and verification of content.

Install and build Lexicons

lex install site.standard.document
lex build

Troubleshooting: if npm run build fails with a type error like '…/@atproto/lex-schema/dist/external' has no exported member named 'TypedObject', the locally-generated src/lexicons directory is stale relative to the current @atproto/lex API. Regenerate with the --clear flag, which removes the existing output before rebuilding:

npx ts-lex build --clear

src/lexicons is gitignored, so this is a local-only fix.

Create the publication record

npm run blog create-publication

Save the returned AT-URI to your .env as ATPROTO_PUBLICATION_URI.

Publishing a post

npm run blog ssite <slug>

For example:

npm run blog ssite welcome-to-the-blog

This will:

  • Create a standard.site document 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

Verification

The site implements site.standard verification:

  • Publication: /.well-known/site.standard.publication returns 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.


Bluesky Discussion Component

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.

How it works

  1. Post the blog link from the account on Bluesky
  2. 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'
    }
  3. The conversation section renders automatically below the post content

Standalone usage

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>

Attributes

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.

Template syntax

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.

Site-wide defaults

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: "...",
}

CSS custom properties

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.

Behavior notes

  • 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., getRepostedBy returning 500) degrade gracefully — the rest of the conversation still renders.

Moderation script

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.threadgate record. 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.postgate record. This is equivalent to "Detach quote" on bsky.app.

TODO

  • 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(?)

Scope Builder widgets

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 into oauth-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.

@atproto/oauth-scopes dependency

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 pages

The 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.

Adding a permission set to the catalog

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.title and defs.main.detail, easiest to grab from https://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:

  1. Add a DID constant near the top of scopeData.ts, alongside the existing BSKY_DID, BEACONBITS_DID, etc:

    const ACME_DID = 'did:plc:...'
  2. Append to the apps[] array, keeping it alphabetical:

    { id: 'acme', name: 'Acme', did: ACME_DID },
  3. Append one entry per permission set to permissionSets[], with appId matching 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 defaultAud field is in unencoded form (raw #); the library handles %23 encoding when emitting scope strings. Only include it if the underlying Lexicon contains rpc permissions with inheritAud: true — for repo-only sets, omit it and the include-scope string drops the ?aud= suffix.

  4. Run the test suite (npm test) to confirm the data shape is valid. Then npm run dev and visit /guides/scope-builder to 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.

Off Protocol (Podcast)

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.

Add an episode

npm run podcast create

Refuses 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.tsx
  • src/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 remove

This deletes local files only. Once a feed guid has been distributed to subscribers, you cannot retroactively unsubscribe them — be deliberate.

Add an episode in the browser

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.

Audio upload (R2)

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"

Why two date fields and two duration fields

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 (pubDate in RFC 822, <itunes:duration> in HH:MM:SS).

The npm run podcast create script populates both fields in sync. They cannot drift unless edited by hand.

Pre-launch checklist

Things that must happen before submitting the feed to Apple Podcasts or Spotify:

  • At least one episode added via npm run podcast create or /studio/podcast

RSS feed validation

Before announcing the show or submitting to directories:

  1. Run npm run dev and visit http://localhost:3000/off-protocol/rss.xml
  2. Validate against validator.podcastindex.org and castfeedvalidator.com — both must pass
  3. Subscribe to the local feed in Pocket Casts (it accepts arbitrary URLs) and confirm episodes appear with art, duration, and show notes
  4. Confirm audio plays from each episode page on desktop and mobile

Post-launch: subscribe links

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.

GUID stability

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.


Are you a developer interested in building on atproto?

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.

License

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.

About

Resources

Stars

336 stars

Watchers

20 watching

Forks

Releases

Packages

Used by

Contributors

Languages