Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Building on Bluesky's AT Protocol with Ruby

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest. →

Building on Bluesky's AT Protocol with Ruby

Overview of the architecture of the AT Protocol network, some examples of the projects I've built on it with Ruby, and a showcase of some other people's projects

Avatar for Kuba Suder

Kuba Suder

April 21, 2026

More Decks by Kuba Suder

Other Decks in Programming

Transcript

  1. Kuba Suder Ruby & Mac/iOS dev Rubyist since 2007 h"ps:/

    /mackuba.eu @mackuba.eu on Bluesky
  2. History • 2019: idea at Twi/er • 2020: research phase

    • 2021: Bluesky PBLLC • 2022: protocol design & impl • Elonocalypse • 2023: beta launch • 2024: public launch • 2025-26: decentralizing the Atmosphere
  3. Iden%ty: Handles @mackuba.eu $ dig TXT _atproto.mackuba.eu ;; ANSWER SECTION:

    _atproto.mackuba.eu. 60 IN TXT "did=did:plc:oio4hkxaop4ao4wz2pp3f4cr" @donald-tusk.bsky.social $ curl https://donald-tusk.bsky.social/.well-known/atproto-did did:plc:2wrsehnu74dmbuwa5t43i3ts
  4. Iden%ty: DID Document { "id": "did:plc:oio4hkxaop4ao4wz2pp3f4cr", "alsoKnownAs": [ "at://mackuba.eu" ],

    "verificationMethod": [ { "id": "did:plc:oio4hkxaop4ao4wz2pp3f4cr#atproto", "type": "Multikey", "controller": "did:plc:oio4hkxaop4ao4wz2pp3f4cr", "publicKeyMultibase": "zQ3shMcFGXMMsEX5nxmV8QfBZQc1Uw6mSWADuKSgsvieu5ezC" } ], "service": [ { "id": "#atproto_pds", "type": "AtprotoPersonalDataServer", "serviceEndpoint": "https://lab.martianbase.net" } ] }
  5. Iden%ty: DID types did:plc (Public Ledger of Creden4als) → h#ps:/

    /plc.directory/did:plc:oio4hkxaop4ao4wz2pp3f4cr did:web, e.g. "did:web:fry69.dev" → h#ps:/ /fry69.dev/.well-known/did.json → very rarely used
  6. Records { "$type": "app.bsky.feed.post", "createdAt": "2025-01-21T18:14:11.065Z", "text": "kitty!!!", "langs": ["en"],

    "embed": { "$type": "app.bsky.embed.images", "images": [ { "alt": "two kittens all snuggled up next to each other on my desk", "aspectRatio": { "height": 1999, "width": 1500 }, "image": { "$type": "blob", "ref": { "$link": "bafkreibv3ywjecijuwu2uwpkq2qkbe6tollcz2qcsn4jfbusur4tazua4m" }, "mimeType": "image/jpeg", "size": 950095 } } ] } }
  7. Lexicon - record JSON schema "type": "record", "description": "Record containing

    a Bluesky post.", "key": "tid", "record": { "type": "object", "required": ["text", "createdAt"], "properties": { "text": { "type": "string", "maxLength": 3000, "maxGraphemes": 300, "description": "The primary post content. May be an empty string, if there are embeds." }, "facets": { "type": "array", "description": "Annotations of text (mentions, URLs, hashtags, etc)", "items": { "type": "ref", "ref": "app.bsky.richtext.facet" } }, "reply": { "type": "ref", "ref": "#replyRef" }, "langs": { "type": "array", "description": "Indicates human language of post primary text content.", "maxLength": 3, "items": { "type": "string", "format": "language" } },
  8. Migra&on { "id": "did:plc:oio4hkxaop4ao4wz2pp3f4cr", ... "service": [ { "id": "#atproto_pds",

    "type": "AtprotoPersonalDataServer", - "serviceEndpoint": "https://amanita.us-east.host.bsky.network" + "serviceEndpoint": "https://lab.martianbase.net" } ], }
  9. sky = Skyfall::Firehose.new('bsky.network') sky.on_message do |msg| next unless msg.type ==

    :commit msg.operations.each do |op| if op.action == :create && op.collection == 'app.bsky.feed.post' Post.create!( did: op.repo, text: op.raw_record['text'], time: Time.parse(op.raw_record['createdAt']), ... ) end end end sky.connect
  10. result = Post.select("COUNT(*) AS posts, " + "COUNT(DISTINCT did) AS

    users") .where("time >= ?", last_day.date + 1) .where("time < CURRENT_DATE") .first PostStat.create!( date: last_day.date + 1, posts: result.posts, unique_users: result.users )
  11. importer = DIDKit::PLCImporter.new(since: last_date) loop do importer.fetch do |ops| ops.each

    do |op| next unless op.type == :plc_operation handle = Handle.find_or_initialize_by(did: op.did) handle.name = op.handles.first handle.tld = handle.name.split('.').last handle.save! end end sleep 60 end
  12. appview = Minisky.new('public.api.bsky.app', nil) Handle.all.in_groups_of(25, false) do |batch| dids =

    batch.map(&:did) result = appview.get_request('app.bsky.actor.getProfiles', { actors: dids }) result['profiles'].each do |p| handle = batch.detect { |h| h.did == p['did'] } if followers = p['followersCount'] handle.followers = followers end handle.save! end end
  13. ops.each do |op| ... handle.pds = op.pds_endpoint.gsub('https://', '') handle.save! end

    Handle.select('COUNT(*) as count, pds') .group('pds') .order('count DESC, pds')
  14. class LinuxFeed < Feed REGEXPS = [ /linux/i, /debian/i, /ubuntu/i,

    /\bredhat\b/i, /\bRHEL\b/, /\bSUSE\b/, /\bCentOS\b/, /\bopensuse\b/i, /\bslackware\b/i, /\bKDE\b/, /\bGTK\d?\b/, /#GNOME\b/, /\bGNOME\s?\d+/, /\bkde plasma\b/i, /apt\-get/, /\bflatpak\b/i, /\b[Xx]org\b/ ] EXCLUDE = [ /\bamzn\.to\b/i, /\bwww\.amazon\.com\b/i, /\bmercadolivre\.com\b/i, /\bpromoção\b/i, /\bofertas?\b/i ] MUTED_PROFILES = [ 'did:plc:35c6qworuvguvwnpjwfq3b5p', # Linux Kernel Releases 'did:plc:ppuqidjyabv5iwzeoxt4fq5o', # GitHub Trending JS/TS 'did:plc:eidn2o5kwuaqcss7zo7ivye5', # GitHub Trending 'did:plc:lontmsdex36tfjyxjlznnea7', # RustTrending 'did:plc:myutg2pwkjbukv7pq2hp5mtl', # CVE Alerts ] def post_matches?(post) return false if MUTED_PROFILES.include?(post.repo) REGEXPS.any? { |r| post.text =~ r } && !(EXCLUDE.any? { |r| post.text =~ r }) end end
  15. sky = Skyfall::Firehose.new('bsky.network') sky.on_message do |msg| next unless msg.type ==

    :commit msg.operations.each do |op| if op.action == :create && op.collection == 'app.bsky.feed.post' post = Post.create!(...) @feeds.each do |feed| if feed.post_matches?(post) post.feed_posts.create!(feed_id: feed.id, time: msg.time) end end end end end
  16. def get_posts(params, context) query = FeedPost.where(feed_id: feed_id) .joins(:post) .select('posts.did, posts.rkey,

    feed_posts.time') .order('feed_posts.time DESC, post_id DESC') .limit(limit) if params[:cursor].to_s != "" query = query.where("feed_posts.time < ?", Time.at(params[:cursor])) end posts = query.to_a cursor = posts.last && sprintf('%.06f', posts.last.time.to_f) { cursor: cursor, posts: posts.map { |p| "at://#{p.did}/app.bsky.feed.post/#{p.rkey}" } end
  17. require 'blue_factory' require_relative 'feeds/linux' BlueFactory.set :publisher_did, 'did:plc:oio4hkxaop4ao4wz2pp3f4cr' BlueFactory.set :hostname, 'blue.mackuba.eu'

    BlueFactory.add_feed 'linux', LinuxFeed.new --- get '/xrpc/app.bsky.feed.getFeedSkeleton' do begin feed = get_feed(params[:feed]) args = params.slice(:feed, :cursor, :limit) context = RequestContext.new(request) response = feed.get_posts(args, context) return json_response(response) rescue ... end end
  18. sources.each do |host| input, output = IO.pipe pid = fork

    do input.close sky = Skyfall::Firehose.new(host) events = 0 users = Set.new sky.on_message { |msg| events += 1; users << msg.did } trap('SIGINT') { sky.disconnect } sky.connect output.puts(JSON.generate({ events: events, users: users.size })) end output.close workers << Worker.new(host, type, pid, input) end
  19. ... sleep(duration) Process.kill('SIGINT', *workers.map(&:pid)) while !workers.empty? pid = Process.wait worker

    = workers.detect { |w| w.pid == pid } workers.delete(worker) line = worker.pipe.gets next if line.nil? result = JSON.parse(line) puts "#{worker.host}: #{result.inspect}" if verbose end end
  20. did = DID.new(@user.did) minisky = Minisky.new(did.document.pds_host, nil) params = {

    repo: did, collection: 'app.bsky.feed.like', limit: 100 } loop do response = @minisky.get_request('com.atproto.repo.listRecords', params) records = response['records'] cursor = response['cursor'] process_likes(records) params[:cursor] = cursor break if !cursor end
  21. def process_likes(likes) likes.each do |record| like_rkey = record['uri'].split('/').last like_time =

    Time.parse(record['value']['createdAt']) post_uri = record['value']['subject']['uri'] parts = post_uri.split('/') next if parts[3] != 'app.bsky.feed.post' post_did, _, post_rkey = parts[2..4] post_author = User.find_or_create_by!(did: post_did) post = post_author.posts.find_by(rkey: post_rkey) if post @user.likes.create!(rkey: like_rkey, time: like_time, post: post) else like_stub = @user.likes.create!(rkey: like_rkey, time: like_time, post_uri: post_uri) @post_queue.push(like_stub) end end end
  22. @sky = Minisky.new('public.api.bsky.app', nil) loop do likes = @post_queue.pop_batch #

    pops first (up to) 25 posts sleep 1; next if likes.empty? response = @sky.get_request('app.bsky.feed.getPosts', { uris: likes.map(&:post_uri) }) response['posts'].each do |data| like = likes.detect { |x| x.post_uri == data['uri'] } text = data['record']['text'] created = data['record']['createdAt'] did, _, rkey = data['uri'].split('/')[2..4] author = User.find_or_create_by!(did: did) post = author.posts.create!( rkey: rkey, time: Time.parse(created), text: text ) like.update!(post: post, post_uri: nil) end end
  23. get '/xrpc/blue.feeds.lycan.searchPosts' do @user = auth_user @query = params[:query].to_s.gsub('%', "\\%")

    likes = @user.likes.joins(:post) .includes(:post => :user) .where("text ILIKE ?", "%#{query}%") .order('likes.time DESC, likes.id DESC') .limit(PAGE_LIMIT) if params[:cursor] time = Time.at(params[:cursor].to_f) likes = likes.where("likes.time < ?", time) end content_type :json JSON.generate({ posts: likes.map(&:post).map(&:at_uri), cursor: likes.last&.time&.to_f.to_s }) end
  24. Tangled { "$type": "sh.tangled.repo", "knot": "knot1.tangled.sh", "name": "pulsar", "labels": [

    "at://did:plc:wshs7t2adsemcrrd4snkeqli/sh.tangled.label.definition/duplicate", ... ], "createdAt": "2026-03-01T23:32:57+02:00", "description": "A tool for measuring the coverage of Bluesky/ATProto relays" }
  25. Tangled { "$type": "sh.tangled.repo.issue", "repo": "at://did:plc:g2nj54o5lr36vbtw55n7xktw/sh.tangled.repo/3mb2m3oocrq22", "title": "license file missing?",

    "body": "The readme says it's MIT and links to a \"LICENSE\" file, \ but the link returns 503", "createdAt": "2026-01-04T01:32:51Z" }
  26. Grain { "$type": "social.grain.gallery", "title": "vancouver 2026", "createdAt": "2026-04-05T21:29:12.214Z" }

    { "$type": "social.grain.gallery.item", "item": "at://did:plc:gq4fo3u6tqzzdkjlwzpb23tj/social.grain.photo/3miz2o6sv6e2y", "gallery": "at://did:plc:gq4fo3u6tqzzdkjlwzpb23tj/social.grain.gallery/3miz2ocjj342y", "position": 0, "createdAt": "2026-04-08T19:46:35.013Z" }
  27. Blogging { "$type": "com.whtwnd.blog.entry", "theme": "github-light", "title": "A Full-Network Relay

    for $34 a Month", "content": "This is an update to a \ [Summer 2024 blog post](https://whtwnd.com/bnewbold.net/3kwzl7tye6u2y). At the time, atproto \ relays required a cache of the full network on local disk to validate data structures. With \ the [Sync v1.1](https://github.com/bluesky-social/proposals/tree/main/0006-sync-iteration) \ updates, relays don't need all that disk I/O. What impact does that have on hosting setup and \ operating costs?\n\n Turns out the dev community ......", "createdAt": "2025-08-27T19:41:13.094Z", "visibility": "public" }
  28. { "$type": "site.standard.document", "site": "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/site.standard.publication/3ly4hnkatvc2p", "path": "/3lzhmtognls2q", "title": "Private data:

    developing a rubric for success", "description": "What will an effective solution look like?", "content": {...}, "bskyPostRef": { "cid": "bafyreia7gh2vftyrhawxkbdckq4m362g2z25umz3sd22hkhw3qp3omo4ni", "uri": "at://did:plc:ragtjsm2j2vknwkz3zp4oxrd/app.bsky.feed.post/3lzhmttqnzk2q", "commit": { "cid": "bafyreif67ckuz337rwwmh6zpv5lgjcfbty5jbx5oh6al3qd4pcztd6ehjy", "rev": "3lzhmtttz4k2v" }, "validationStatus": "valid" }, "publishedAt": "2025-09-23T00:05:57.011Z" }
  29. Resources • h#ps:/ /atproto.com • ATProto Touchers Discord (ask for

    invite) • my ATProto Bluesky feed • h#ps:/ /mackuba.eu/2025/08/20/introducFon-to-atproto/ • h#ps:/ /overreacted.io/open-social/ • h#ps:/ /overreacted.io/where-its-at/ • h#ps:/ /overreacted.io/a-social-filesystem/
  30. ruby.sdk.blue • Skyfall – streaming from the firehose • Minisky

    – HTTP API client (for PDS, AppView, ...) • DIDKit – working with DIDs & handles • BlueFactory – Sinatra server for hosKng feeds • examples
  31. sdk.blue • 130+ projects • JavaScript, Go, Rust, Python, Swi=,

    PHP, Ruby, Dart, C#, Elixir, Kotlin, OCaml, Zig, R, C++, PowerShell, Perl, Clojure, F# • XRPC / firehose clients, OAuth, feeds, bots, indexers, moderaRon tools, labellers, tools for processing Lexicons & code generaRon, full example apps, PDS / relay / AppView implementaRons, tools for building apps...
  32. sdk.blue • cryptography (secp256k1 ellip6c curve), Merkle Search Trees, CAR

    (Content Addressable Archives), CIDs (Content Iden6fiers), CBOR / DAG-CBOR (Concise Binary Object Representa6on), JWT (JSON Web Tokens), OAuth DPoP (Demonstra6ng Proof of Possession), OAuth scopes and permission sets, ...