Adventures on Atproto
I found myself with a bit of time between jobs recently and there’s only so much Dickinson’s Real Deal a man can take, so in an attempt to stay sharp I got stuck into Atproto, the protocol powering Bluesky.
This isn’t a post about the current state of Twitter X (The Everything App) but… yeah… infer what you will about my desire to explore alternatives.
I’d been on the lookout for a simple idea to reimplement when I found meetinghouse. The concept is pretty straightforward: you plant a pin with a description and contact info, allowing you to find and be found. For the avoidance of doubt, this is not in any way an original idea. I just thought it’d be cool to build a Bluesky equivalent.
The ATmosphere
Before we get to that though, a slight detour. The Bluesky signup journey is excellent, and a majority of users won’t know a thing about the underlying protocol. This is great! I sometimes wish I were of those people.
For the rest of us, there’s a nifty little CLI tool called goat that we can use to interact with Atproto. The protocol’s main selling point is decentralisation and while most, including my own, data is hosted at Bluesky, it doesn’t have to be. We can see where my Personal Data Server (PDS) currently resides:
goat resolve luke.whiteley.io | jq .service
1[
2 {
3 "id": "#atproto_pds",
4 "type": "AtprotoPersonalDataServer",
5 "serviceEndpoint": "https://conocybe.us-west.host.bsky.network"
6 }
7]Billionaire megalomaniac takes control of bsky.network one day? No problem, just move.
Handles (often something.bsky.social on Bluesky) are just human-friendly identifiers for accounts, which each have a less-friendly Decentralised Identifier (DID). You might’ve noticed that my handle uses this domain, and so its DID is resolved via DNS:
dig +short -t TXT _atproto.whiteley.io
1"did=did:plc:2yt5tojliyazlgmtrqrbvobl"That’s to say you can (and should!) bring your own domain - just put your DID in a TXT record.
We can browse the data hosted on an account’s PDS. To list a user’s collections:
goat ls -c luke.whiteley.io
1app.bsky.actor.profile
2app.bsky.feed.like
3app.bsky.feed.post
4app.bsky.graph.follow
5com.atproto.lexicon.schema
6io.whiteley.ATlas.pin Of note is the fact that these endpoints are unauthenticated and the data is public. The protocol doesn’t yet solve for privacy and I’m not sure it should be a priority. A clear delineation between your public and private internet use feels like good hygiene. Use Signal!
In the above output you’ll see collections containing records for my profile, posts, likes, and follows. But what are those last two?
ATlas
Services built on top of Atproto are known as ‘App Views’ which are applications exposing some curated aggregation of the network’s underlying data to the user. Bluesky itself is an App View, allowing users to interact with the app.bsky.* lexicon (posts, likes, follows, etc.).
Publishing app.bsky.post records from goat is trivial (requires auth):
goat bsky post "This post will be consumed by the Bluesky App View"Our app, ATlas, is super simple. It shows the user a globe covered in other users’ pins and allows them to place their own. Authentication is handled by Atproto; we just need to define the shape of the data our App View will be dealing with (geographical pins, in this case) and provide the logic to do so. This is known as a lexicon but for now just think of it as a JSON Schema. Here’s the shape of a pin on the network:
1{
2 "lexicon":1,
3 "id":"io.whiteley.ATlas.pin",
4 "defs":{
5 "main":{
6 "type":"record",
7 "key":"literal:self",
8 "description":"A user's geographical pin on the atlas",
9 "record":{
10 "type":"object",
11 "required":[
12 "did",
13 "longitude",
14 "latitude",
15 "description",
16 "placedAt"
17 ],
18 "properties":{
19 "did":{
20 "type":"string",
21 "format":"did"
22 },
23 "placedAt":{
24 "type":"string",
25 "format":"datetime"
26 },
27 "longitude":{
28 "$comment":"Longitude as per WGS84 (EPSG:4326)",
29 "type":"string",
30 "maxLength":32
31 },
32 "latitude":{
33 "$comment":"Latitude as per WGS84 (EPSG:4326)",
34 "type":"string",
35 "maxLength":32
36 },
37 "description":{
38 "$comment":"Pin description (as submitted by the user)",
39 "type":"string",
40 "maxLength":256
41 },
42 "website":{
43 "$comment":"User-submitted website",
44 "type":"string",
45 "maxLength":256
46 }
47 }
48 }
49 }
50 }
51}Publishing schemas is strongly encouraged as it allows other clients to validate records. If you scroll up a little, you’ll see a collection of type com.atproto.lexicon.schema under my account and there’s another TXT record on my domain linking the published type io.whiteley.ATlas.pin to my DID for schema validation purposes:
dig +short -t TXT _lexicon.atlas.whiteley.io
1"did=did:plc:2yt5tojliyazlgmtrqrbvobl"You can read more about lexicon development here.
Now that our pin record type is published to the network, we just need an App View. Except, we actually don’t. Our io.whiteley.atlas.pin records can be manipulated directly via the API. Let’s see where I currently am:
goat get at://luke.whiteley.io/io.whiteley.ATlas.pin/self
1{
2 "$type": "io.whiteley.ATlas.pin",
3 "description": "Currently writing this post ✍",
4 "did": "did:plc:2yt5tojliyazlgmtrqrbvobl",
5 "latitude": "53.778027297308284",
6 "longitude": "-1.5714974376787723",
7 "placedAt": "2026-01-11T20:20:58Z",
8 "website": "https://luke.whiteley.io"
9}Of course, hand-cranking POST request bodies isn’t for everyone, so I did build an App View:

You can play with it here:
(Pins don’t currently persist between deployments but it’d be trivial to support. For now, logging back in will sync your latest pin from your PDS)
I’ll skip the implementation details in this post but the stack briefly consists of Go, SQLite, Templ, and Alpine.js. The code is mirrored on GitHub, but since you’ve made it this far, I hope you’ll give an Atproto-powered alternative a go instead:
Closing
I learned an absolute boatload while building this and have had to omit a ton from this post but hopefully it serves as a nice whistle-stop tour of the network’s basics.
Totally unprompted and definitely not sponsored but if you want to ship a wee side project quickly I can’t recommend Railway enough, great platform.
Cheers!
#atproto #bluesky #social #networking #go #golang #templ #maplibre-gl-js