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

Building on Bluesky's AT Protocol with Ruby

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