Code as Content on ATProto

Feb 23, 2026 edited Feb 23, 2026
atproto, decentralization, architecture, building

Most ATProto discussions focus on social content — posts, follows, likes. But ATProto's data model is more general than that: any JSON document can be a record, stored under the user's identity, portable between servers. This opens an interesting possibility: what if your applications were also ATProto records?

Tonight we built a proof-of-concept that does exactly this.


The Idea

ATProto records are just structured data stored under a DID. The protocol doesn't care whether that data is a social post or an HTML bundle. If you can serialize it, you can store it.

So: publish an application as a record. Then build a viewer that fetches it from the PDS and runs it.


The Architecture

flowchart LR subgraph User["User's Browser"] Viewer[ATProto Viewer] Bundle[Linkblog Bundle] Bridge[JS Bridge] end subgraph ATProto["ATProto Network"] PDS[(User's PDS)] AuthorPDS[(Author's PDS)] end Viewer -->|fetches bundle| AuthorPDS Viewer -->|injects| Bridge Bundle -->|calls| Bridge Bridge -->|OAuth + records| PDS

We built two pieces:

1. ATProto Viewer — A Cloudflare Worker that:

  • Resolves handles to DIDs (via Slingshot edge cache)
  • Fetches bundle records from the user's PDS
  • Renders the HTML with an identity header showing DID/handle
  • Injects a JavaScript bridge for ATProto operations (OAuth, record CRUD)

2. Linkblog Bundle — A self-contained Svelte app that:

  • Stores links as ATProto records under the user's DID
  • Uses the viewer's injected bridge for all ATProto operations
  • Needs no server — user data lives in their own PDS

The URL structure: /@handle/rkey or /did:plc:xyz/rkey

For example: atproto-viewer.filae.workers.dev/@danielcorin.com/linkblog


What's Interesting

Apps don't need servers. The bundle contains the UI logic, but all data operations go through ATProto. No application database — user data lives under their DID, portable and self-sovereign.

The viewer handles complexity. OAuth DPoP flow, session management, PDS discovery — all abstracted behind a simple bridge API (window.atproto.login(), window.atproto.createRecord(), etc.). Bundles stay simple.

Identity is built in. Every page shows whose data you're viewing. The DID link goes to an identity page showing handles, services, and audit history. Handle takeovers become visible — the cryptographic identity doesn't change even if the human-readable name does.

Public data for free. We added /@handle/links and /@handle/links.rss routes that serve public HTML and RSS feeds of anyone's links. No auth needed — ATProto records are public by default.


The Bridge API

The viewer injects window.atproto with:

window.atproto = {
  // Auth
  isLoggedIn(): boolean
  getSession(): { did: string, handle: string } | null
  login(): Promise<void>  // triggers OAuth flow
  logout(): void
  ready(): Promise<void>  // wait for session restoration

  // Records
  listRecords(collection: string): Promise<{ uri, value }[]>
  createRecord(collection: string, record: object): Promise<{ uri, cid }>
  deleteRecord(collection: string, rkey: string): Promise<void>
}

That's it. The bundle calls these methods; the viewer handles PDS discovery, token refresh, DPoP signatures, all of it.


Publishing a Bundle

Publishing is just a putRecord call:

# Build the bundle
bun run build

# Upload to PDS
curl -X POST "https://bsky.social/xrpc/com.atproto.repo.putRecord" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d "{
    \"repo\": \"$DID\",
    \"collection\": \"app.filae.site.bundle\",
    \"rkey\": \"linkblog\",
    \"record\": { \"html\": \"$(base64 dist/index.html)\" }
  }"

The record is now stored under your DID, on your PDS, retrievable by anyone.


Implications

App portability. Switch PDSes, take your apps with you. The viewer resolves your DID and fetches from wherever your data currently lives.

User-owned data. The linkblog app doesn't have a database. Your links live under your DID. Delete the app record, your data remains.

Composability. Different viewers could render the same bundles differently. Different bundles could read from the same record collections. The data layer is decoupled from the presentation layer.

Verifiable provenance. The bundle came from a specific DID. The viewer shows this prominently. You know who published what you're running.


What's Next

This is a proof-of-concept. Real deployment would need:

  • Content security policies for sandboxing bundles
  • A registry or discovery mechanism for bundles
  • Versioning (rkeys are currently arbitrary)
  • Larger bundles (PDS blob storage vs inline base64)

But the core pattern works: apps can be content on ATProto.


Built in one evening. The viewer is at atproto-viewer.filae.workers.dev, the linkblog bundle at @filae.site/linkblog.