Decentralized personal feeds. Channels you own, subscriptions you pick, no platform between author and reader.
2026-05-01.13-49-32.mp4
A channel is a publishing handle — a person, a persona, a topic, a project. You own as many as you want and subscribe to others' by pasting a URL. The thing you publish is a post — short plaintext (281 characters, one more than Twitter, intentional), optionally with attachments (images, audio, video, files) shown beneath the body. Your home is a chronological mix from every channel you've subscribed to. When something's worth keeping, pin it — pinning mirrors the bytes into your own Sia storage so your copy survives even if the original publisher unpins. It's the verb the app is named after.
There is no Pin server, no Pin database, no platform between authors and readers. Item bytes live on Sia, encrypted with per-object keys. The mutable channel record lives on ATProto as a publicly-readable record whose body is ciphertext encrypted under a per-channel key K that never leaves the URL fragment of the subscribe link. Anyone can fetch a record; only people you sent the URL to can decrypt it. A reader who pins becomes a host of those bytes — Sia gets stronger for that channel as more readers commit. An author can retract from their own storage, but a subscriber's pinned copy persists. Twitter delete is unilateral; Pin retract is custody being released.
Best in Chrome. Best with a friend — pair up, each open the URL on your own machine, walk through it together. One of you plays the author, the other the subscriber. (Solo? Two browser windows on one machine, Incognito for the second so the Sia accounts stay distinct.)
-
Both of you: finish Sia onboarding. Then click + Subscribe and paste this — it's the build journal I kept while making Pin:
pin://johnwilliams.codes#k=zDaitAkRQnSa2X3YsNXlLEomoIStjfGyxSlbIL0/7bs=Your feed populates immediately with the day-by-day record of building this app. That's what subscribing to a real channel feels like.
-
Author (one of you): also finish Bluesky onboarding. Click + Create a channel, give it a name and (optionally) a cover image. Copy the subscribe URL and send it to the subscriber.
-
Subscriber (the other): in + Subscribe, paste the author's URL.
-
Author: publish from the inline composer at the top of the feed. The composer is one line at rest — click it and it expands. Type your post (up to 281 characters), and drag a file onto the composer to attach it (image / audio / video / file) below the body. Click Publish — the form clears immediately, and the upload queue in the right sidebar takes over, uploading the attachment bytes then the body bytes then writing the manifest entry. The UI never blocks.
-
Subscriber: items appear LIVE as the author publishes — no refresh needed. Pin subscribes to ATProto's JetStream firehose, filtered to the channels you follow, so publishes propagate within ~1 second. The green pulsing Live indicator on the toolbar shows the WS connection. Manual Refresh stays as a backstop.
-
Pin moment. Subscriber: hover an item and click the pin icon. The item is now mirrored into your Sia storage; the right sidebar's bar ticks up and the item appears in Pinned. Then the author: click the (filled, owned-author-green) pin icon on the same item and type
DELETEto retract. The item disappears from the author's feed and storage — but the subscriber's pinned copy persists, with a working share URL. That's custody at work.
The rest of this README goes deeper: the specific Sia SDK calls Pin uses (and where), the architecture, the sandboxed App Host API, the roadmap, and how to run locally if you want to clone instead of clicking the link above.
Pin uses @siafoundation/sia-storage load-bearingly:
| SDK call | Where it's used |
|---|---|
Sdk instance per user |
Every authenticated session (core/sia.ts, AppKey approve flow from create-sia-app) |
sdk.upload(new PinnedObject(), Blob.stream()) |
Post body bytes, every attachment file on a post, channel cover art. The composer never blocks on upload — bytes get pushed to Sia by the background runner after Publish, then the manifest is written with the resolved URLs. |
sdk.pinObject(obj) |
Two uses: durability for items you publish (body and each attachment), and mirroring an item from another channel into your own storage when you pin it from the feed |
sdk.shareObject(obj, validUntil) |
Per-item distribution URL with the per-object encryption key in the URL fragment; year-9999 expiries verified safe |
sdk.sharedObject(url) |
Resolves a shared URL into a PinnedObject handle. Used before downloading (subscriber reads) and before mirroring (pinning a friend's item — sharedObject then pinObject adds the bytes to your indexer scope) |
sdk.download(obj) (as ReadableStream) |
Subscriber reads (cached in IndexedDB after first fetch — see Architecture) |
sdk.deleteObject(id) |
Retraction. Item-level (typed-DELETE confirm on your own item) and channel-level (Unpin channel walks every item, then deletes the manifest record). Subscribers who mirrored items keep their independent copies |
sdk.uploadPacked() + packed.add(stream) + packed.finalize() |
Bin-packed multi-object uploads. Composer tasks (post body + attachments) and drag-drop intake into My Storage share a single ~40 MiB slab rather than one slab per object. Same call powers the background repack runner that consolidates sub-full slabs after the fact |
obj.slabs() |
Per-object slab inventory (encryptionKey, offset, length, sectors). The repack runner reads this to compute fill levels and pick consolidation batches; the SlabInspector debug view uses it for the per-slab object listing |
sdk.objectEvents(cursor, limit) |
Paginated walk of every pinned object in scope. Used by the orphan sweep to enumerate scope and diff against the known set so pinned-but-unreferenced bytes (typically from failed historic repacks) get cleaned up |
sdk.pruneSlabs() |
Releases unused slab capacity after deleteObject. The indexer doesn't auto-drop empty slabs, so without this pinnedData stays inflated even after deletes succeed. Called at the end of every repack batch and orphan-sweep pass — load-bearing for the bar to actually fall |
sdk.account() |
The storage card at the top of the right sidebar — pinnedData / maxPinnedData, refreshed on every pin / unpin / retract / repack / orphan-sweep |
sdk.appKey().publicKey() |
Recorded inside the encrypted channel manifest as the technical author identity |
ATProto handles the channel-record layer: @atproto/oauth-client-browser for the user sign-in (standards OAuth handoff — Pin never sees their password), @atproto/api for the resulting Agent's com.atproto.repo.putRecord / getRecord / listRecords / deleteRecord calls. The OAuth scope is narrow — repo:dev.sia.pin.channel plus delete-only on the legacy dev.sia.dispatch.channel — so the auth screen at bsky.social grants access only to the lexicons Pin actually touches, never to the user's profile / posts / likes / follows. JetStream WS subscription handles live updates without polling. Together with Sia, ATProto covers the two halves Sia explicitly does not aim to solve on its own — naming and mutability of multi-user-readable state.
Item bytes (per item) Channel state (per channel)
│ │
Sia object ATProto record under
(encrypted via dev.sia.pin.channel†
per-object URL (publicly readable; body is
fragment key) AES-GCM-256 ciphertext)
│ │
└────── itemURL ──────► ChannelManifest{name, description, items[]}
│
Encrypted under K
rkey = base32(sha256(K)).slice(0,16)
K lives only in the subscribe URL fragment
† Writes go to dev.sia.pin.channel. Reads (and the JetStream subscription) also check the legacy dev.sia.dispatch.channel collection so pre-rename channels keep resolving. Channels migrate to the new lexicon on their next publish; channels that nobody publishes to stay readable in legacy forever.
- Channel ATProto record body is only
{ $type, encryptedManifest }. No client-controlled metadata fields. - Channel ID (the rkey) is derived from
K, not stored as a separate field. Listing an author's collection reveals only opaque rkeys. - Subscribe URL is
pin://<authorHandle>#k=<base64-K>. Sharing the URL = granting decrypt access. WithoutK, you can tell that the author publishes (via the rkey list) but nothing about what. - Item URLs (which themselves contain Sia's per-object encryption keys in their fragments) are stored inside the encrypted manifest, so without
Kyou also can't fetch the item bytes meaningfully.
Performance layers — none of these are Sia, all of them shape how it feels.
- Local IndexedDB cache (
lib/itemCache.ts) — every item byte fetch goes through a cache keyed on the item's plaintext CIDv1 content hash (core/contentHash.ts), withitemURLas the fallback for legacy items written before the hash field existed. The hash is stable across repack URL swaps and across encryption regimes (today's K-encrypted manifests, future public manifests, future per-recipient envelopes — all share the same plaintext-derived identity), so a Sia URL rewrite is a no-op for the cache. The sameUint8Arrayreference flows throughuseItemBlobURLto the rendered media element and there's no flash on re-render. Soft cap ismin(500 MB, 25 % of navigator.storage.estimate().quota), LRU eviction. First fetch hits Sia hosts; reload-and-rerender is sub-millisecond. - Background upload queue (
stores/uploadQueue.ts) —Compose*enqueues a task and resets the form; the runner serializes tasks, surfaces shard-upload progress in the right sidebar, supports retry on failure. Publish never blocks the UI. - JetStream live updates (
core/jetstream.ts) — WS subscription to Bluesky's JetStream firehose, filtered to the DIDs of your subscribed channels. Commit events trigger per-channel manifest re-fetches. Region (us-east / us-west) is timezone-derived. Reconnect uses exponential backoff and triggers a full refresh on rejoin. - Storage hygiene runners (
useUploadRunnereager-pack path,core/repack.ts+useRepackRunner,core/orphanSweep.ts+useOrphanSweep) — three runners keeppinnedDatahonest. Eager packing at upload time bin-packs every byte source for a task (body + attachments, or all dropped files in a single intake) into one shared slab viauploadPacked— a post + 3 attachments costs 1 slab, not 4. The repack runner triggers on every pin event (upload-queue success, pinStore growth), walks scope (own-channel items + their attachments + cover art + library + external pins, deduped by Sia object ID), picks a greedy bin-packed batch of sub-full slabs (≥3 candidates, <80% full each, newest-object age ≥2 min), re-uploads packed, swaps URLs in affected channel manifests (itempublishedAtpreserved — repack is housekeeping, not republish), deletes old objects, callspruneSlabs. Loops until clean. The orphan sweep runs once on app load after manifests and pinStore settle (with a 5 s defer + manifest-loaded + uploads-idle gates), walksobjectEventsto enumerate every pinned object in your scope, diffs against the known set (channel item bodies + their attachments + cover art + library + external pins + settings), deletes the leftovers, prunes. Settings get two protections — explicit ID in the known set plus a metadata-shape skip (kind === 'pin:settings') — so channel keys are never at risk. A 5-minute age gate keeps in-flight uploads exempt. TheBoxicon next to "My Storage" lights up while any of these are working.
src/
core/ # platform-agnostic: Sia + ATProto calls, channel crypto, manifest, feed, jetstream, pin
components/ # web UI (React)
stores/ # Zustand — auth, feed, pin, upload queue, compose (armed-link state), toast
lib/ # constants, item cache, markdown, time helpers, app bridge, hooks
core/ doesn't import React, DOM, or localStorage. A future React Native client (using react-native-sia) can be a new UI layer over the same module — the Sia and ATProto plumbing transfers unchanged.
A program-as-item — type app, a single self-contained .html file — is one of the more interesting consequences of the architecture. The program is content-addressed, encrypted, and distributed by exactly the same machinery as a JPEG: it travels like media. Pong and Snake, both included as bundled examples (examples/pong/, examples/snake/), ship in a channel as small HTML files you can subscribe to, fetch, and run. Where it gets interesting is what an app should and shouldn't be able to do — that surface is barely sketched today.
Apps run inside an iframe with sandbox="allow-scripts allow-modals allow-pointer-lock". The sandbox blocks network, popups, top-navigation, forms, and same-origin access — an app can compute, render, and accept input, but can't reach our DOM, our keys, the user's other tabs, or any external service. Anything an app needs from the outside has to come through a postMessage channel the host explicitly proxies. That's the permission boundary: the host decides which capabilities it exposes as RPCs, and apps are free to use only those.
One RPC pair: per-app local state. Null-origin iframes don't get their own localStorage, so the host exposes get/set so apps can persist things like high scores, save games, or preferences. State is scoped by appID (the Sia content hash of the HTML), so the same bytes share state across whichever channels publish them. Storage is local to the device; not synced across devices (yet). The protocol's dispatch: message-type prefix predates the app's rename and is preserved so that already-published apps continue to work.
const requestID = crypto.randomUUID()
window.parent.postMessage(
{ type: 'dispatch:state.get', requestID, key: 'hiscore' },
'*',
)
window.addEventListener('message', (e) => {
if (
e.data?.type === 'dispatch:state.get.result' &&
e.data.requestID === requestID
) {
console.log(e.data.value) // null if unset, otherwise the stored value
}
})window.parent.postMessage(
{
type: 'dispatch:state.set',
requestID: crypto.randomUUID(),
key: 'hiscore',
value: 42,
},
'*',
)Values are JSON-serialized; anything JSON.stringify accepts works. The host replies with { type: 'dispatch:state.set.result', requestID, ok: true }, or { ok: false, error } on failure (quota exceeded, serialization failed).
The sandbox blocks network and same-origin access, so an app can't call Sia hosts itself. There are roughly three shapes for letting an app use the SDK without giving it free rein, each with a different place where permission lives:
-
Host-as-proxy (the current shape). The host has the SDK; the app makes typed
postMessagerequests; the host executes and returns the result. Permission lives at the RPC boundary — we approve or deny each call individually. Simple to reason about. The API the app sees isn't shaped like the SDK; it's shaped like whatever message types we choose to expose. -
SDK-as-contract. The app imports a shim that looks like the SDK (
await sdk.upload(...)), and the shim marshals each call overpostMessage. The host implements the SDK on the app's behalf. App code reads like ordinary SDK usage; permission still lives at the host boundary, but the contract is the SDK itself. This is the cleanest shape if we ever want apps to be portable to other host environments — a desktop runtime, a different web client, a CLI — without rewriting them. -
AppKey-per-app. Sia's existing
AppKeyis the permission primitive — every authenticated session is scoped to one, and the indexer already enforces per-AppKey storage caps (maxPinnedData,remainingStorage). We could derive a sub-AppKey deterministically from(user-AppKey, appID), let the app run a real SDK instance against that sub-key, and have the user approve a storage cap at install time. The sandbox still blocks raw network, but the host could expose just enough of a network shim for the app's SDK to reach the indexer — gated by the sub-AppKey's authorization. The most federated shape: each app becomes a first-class Sia identity, with its own quota and an isolated pinned set, separate from the host user's.
The third shape is architecturally interesting because Sia already has the permission primitive — we don't need to invent one. Storage cap, isolated pinned set, all derivable from a root identity. We'd be making the AppKey hierarchy one level deeper, and the existing indexer enforcement comes along for free. Pin currently runs shape #1 because it was the smallest thing that worked for pong's hi-score; future thinking probably starts at #3.
Pong is one example; the broader question — what should an app be able to do — is barely explored. Every capability beyond pure compute is a host-side permission decision, and none of them are settled. A non-exhaustive list of open questions, in roughly increasing order of risk:
- Read other items in the same channel. Useful (an app could render its own playlist over audio items in the channel), low risk.
- Read the manifest's metadata (channel name, description, item refs). Same shape — useful for context-aware apps.
- Upload a new item to its own channel. Only meaningful if the running user owns the channel; needs an explicit "this app wants to publish on your behalf" prompt to avoid vandalism.
- See the user's pinned set. Privacy concern; probably no by default.
- Sign with the user's
AppKey. Identity proxy — powerful and dangerous; needs explicit per-call consent UI. - Pin a URL the app constructs. Storage-cost vector against the user's Sia allowance; needs consent and probably a size cap.
The framing that makes this tractable: apps can't reach Sia or ATProto directly. Anything they do goes through host RPCs. So designing the App Host API is the same exercise as designing a permission surface over the Sia SDK — which calls are safe to proxy, under what consent model, at what scope. Today's answer: compute and your own state, nothing else. Growing that surface is the open question.
- Per-recipient access control + revocation. Pin currently treats subscribe URLs as universal access — anyone with the URL has equal read, same model as Sia's
shareObject. The plan: per-subscriber NaCl box envelopes via a separate ATProto record collection, with key rotation on removal. - Notifications, replies, likes, mentions, threads. Intentionally absent. Not on the roadmap either — conversation by inference, not threading.
- Native mobile.
core/is platform-agnostic and ready. Pin currently ships the web SPA only; a React Native client built onreact-native-siais the path. - More editing surface. Channel metadata (name, description, cover image) is fully editable from the channel header. Apps (HTML items) are editable today: title and/or body. The implementation uploads new bytes to Sia and only swaps the manifest pointer if the upload succeeds; on failure the old version stays live and the orphan upload is rolled back. Edited items keep their original
publishedAtso editing isn't republishing — they stay where they were in chronological order. Apps editing is the load-bearing case: ship a program, find a bug, fix it in place. Posts and other media types aren't editable yet; retract and republish. Pinned copies on subscribers' devices are snapshots — an author's edit changes the manifest's pointer, but readers' mirrored bytes stay as they were when they pinned. That's true to "K is custody, not authorship": the reader's pin is fixed-in-time stewardship; the author's edit doesn't reach into the reader's library. - Pagination, drafts, AppView discovery. Single-page manifest, single channel-key per channel — none of these are here yet.
- Settings slab reclaim. Settings are written through
sdk.upload(notuploadPacked) and live in their own dedicated ~40 MiB slab. Architecturally the price of a separately-discoverable settings record (the orphan sweep finds it viakind === 'pin:settings'metadata and explicitly protects it). Could be reclaimed by either packing settings alongside other writes or accepting the overhead as the cost of an easy-to-find, never-deletable record. Not urgent. - Persistent upload queue. Tab close during a slow upload drops the pending bytes. The fix: store task bytes in IndexedDB by task UUID so the queue resumes across reload.
- Channel export / import (manifest portability). A small JSON file containing
{ channelKey, channelID, manifest }is the entire backup image of a channel. The plan: Download manifest + Import manifest affordances. Import walks every item URL and re-pins the bytes into the importer's indexer scope (mandatory because each AppKey is a distinct pinned-objects scope), then republishes the manifest under the importer's DID. Same-user import = clean migration (AppKey rotation, cross-device portability, and the path that would let us retire the legacydev.sia.dispatch.channellexicon entirely). Different-user import = fork, surfaced as an explicit verb with aforkedFromprovenance field. The framing:Kis custody capability, not authorship credential. - Parallel attachment uploads. The runner currently uploads attachments sequentially (one shard stream at a time, in source order). Parallel would be faster for multi-file posts but adds bookkeeping (per-source progress, error aggregation). Earns its keep when a real "this felt slow" moment shows up.
If you'd rather clone than click. Requires Bun and Chrome.
bun install
bun run devOpen http://127.0.0.1:5173 in Chrome. (Vite binds to the loopback IP rather than localhost because atproto OAuth's RFC 8252 redirect rules forbid the localhost hostname; the app auto-redirects you if you hit the localhost URL.) The first-time flow opens at a welcome screen with two paths: Get started routes through Bluesky sign-in first (OAuth — you redirect to bsky.social, authorize there, come back signed in) then through Sia approval (Connect → Approve at sia.storage → save Recovery phrase → connected); Just reading skips Bluesky and goes straight to Sia approval. Bluesky can also be added lazily later from inside the app the first time you try to create or publish.
A note on browser support: we pinned @siafoundation/sia-storage early and built against Chrome throughout the 3-day window. Cross-browser validation was out of scope — Chrome was the target, it was green from day one, and we kept iterating. The app may run elsewhere, but Chrome is the only environment we exercised.
Pin sits on infrastructure built by other people:
- My colleagues at the Sia Foundation maintain the libraries advancing the broader Sia network: Luke Champine, Nate, Christopher Schinnerl, Peter-Jan Brone, Christopher Tarry, Alrighttt, and Alex Freska.
- ATProto and Bluesky for the protocol and the public infrastructure (PDS, JetStream relay) that handle Pin's mutable channel-naming layer.
@atproto/apifor client calls; the JetStream firehose for sub-second live updates without polling.
Scaffolded from SiaFoundation/create-sia-app by Alex Freska.