RatProto for AtProto 🐀

© Joshua J. Cotten

I've been thinking on and off about making some kind of Ruby CLI for random atproto things, combining all my Ruby gems like skyfall , minisky and didkit to do some practical stuff.

Back in January, I randomly got a great idea for a name for this, so I had to quickly reserve the name on RubyGems 😏 so I vibe-coded a proof of concept 0.0.1 version of this, with 3 commands and an all in one flat script. And then I kinda forgot about this again, because I have like 20 different ATProto projects that I switch between, so I tend to work on one for a while and then leave it for a couple of months, switching to some others…

I came back to it this week. I refactored the code using a proper CLI framework ( clamp ), and added a bunch of new options and features.

You can try it if you have Ruby installed – it should be installed or available on any Linux, and there's a version bundled in macOS too. That Mac version is rather ancient right now – 2.6, which was originally released in Dec 2018 – because at some point Apple decided to stop shipping updated scripting language runtimes like Perl, Python or Ruby in macOS, and left some old versions only for legacy software compatibility. But I'm still supporting that version in some of my tools, because people have that built in, so it makes it easier to start playing with things without having to download some installers.

If you want to/need to install a newer version, I recommend e.g. asdf or ruby-install . (Right now 4.0 is the latest, 3.4 is supported, 3.3 gets security updates, and 3.2 was recently EOL'd.)

If you have Ruby, you can install ratproto with gem install ratproto (you might need to add sudo depending on your setup, e.g. with the bundled macOS Ruby, which installs to /Library/Ruby/Gems):

% gem install ratproto
Fetching websocket-extensions-0.1.5.gem
Fetching eventmachine-1.2.7.gem
Fetching skyfall-0.7.0.gem
Fetching websocket-driver-0.8.2.gem
Fetching cbor-0.5.10.3.gem
Fetching faye-websocket-0.12.0.gem
Fetching base32-0.3.4.gem
Fetching minisky-0.5.1.gem
Fetching didkit-0.4.0.gem
Fetching clamp-1.5.2.gem
Fetching ratproto-0.3.2.gem
Successfully installed websocket-extensions-0.1.5
Building native extensions. This could take a while...
Successfully installed websocket-driver-0.8.2
Building native extensions. This could take a while...
Successfully installed eventmachine-1.2.7
Successfully installed faye-websocket-0.12.0
Building native extensions. This could take a while...
Successfully installed cbor-0.5.10.3
Successfully installed base32-0.3.4
Successfully installed skyfall-0.7.0
Successfully installed minisky-0.5.1
Successfully installed didkit-0.4.0
Successfully installed clamp-1.5.2
Successfully installed ratproto-0.3.2
11 gems installed
%

Now, what can you do with it? There are 4 subcommands at the moment:

rat resolve

Lets you resolve a handle or DID to a DID doc:

% rat resolve firefox.com
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/multikey/v1",
"https://w3id.org/security/suites/secp256k1-2019/v1"
],
"id": "did:plc:m424cqoxwhxgjutbta7jmrur",
"alsoKnownAs": [
"at://firefox.com"
],
"verificationMethod": [
{
"id": "did:plc:m424cqoxwhxgjutbta7jmrur#atproto",
"type": "Multikey",
"controller": "did:plc:m424cqoxwhxgjutbta7jmrur",
"publicKeyMultibase": "zQ3shdZpagHtuERBjkWBsUjdAPCWGQsUr6YxJHFPHsCsZnF1j"
}
],
"service": [
{
"id": "#atproto_pds",
"type": "AtprotoPersonalDataServer",
"serviceEndpoint": "https://woodtuft.us-west.host.bsky.network"
}
]
}

Pass -d to only print DID or -p to only print PDS:

% rat resolve -d firefox.com
did:plc:m424cqoxwhxgjutbta7jmrur

% rat resolve -p firefox.com
https://woodtuft.us-west.host.bsky.network

rat fetch

Takes an at:// URI and fetches and prints the record:

% rat fetch at://did:plc:vc7f4oafdgxsihk4cry2xpze/app.bsky.feed.post/3mkqgqgf44s27
{
"text": "\"fr\" actually stands for \"for raccoons.\" not many people know this",
"$type": "app.bsky.feed.post",
"langs": [
"en"
],
"createdAt": "2026-04-30T20:19:00.775Z"
}

(you know who this is ;)

rat stream

This is the biggest one: it lets you stream events with various filters from a PDS/relay/Jetstream firehose:

% rat stream eurosky.social
Connecting to wss://eurosky.social/xrpc/com.atproto.sync.subscribeRepos...
Connected
[2026-07-30T17:39:58+02:00] (43982019) did:plc:onqxflbsow57dte4vbjcpo7k :create app.bsky.feed.like 3mrurlajlgi2a {"subject":{"cid":"bafyreidoad3d6gvuaopm34ksmjgzolrkr4koiadijrhpsdgcieku24j7iq","uri":"at://did:plc:6x36lmztmhk2zbfx5xr565qi/app.bsky.feed.post/3mrumnbfae22f"},"createdAt":"2026-07-30T15:39:58.172Z"}
[2026-07-30T17:40:00+02:00] (43982033) did:plc:axv7ywnugewqau6rpp53lflx :create app.bsky.feed.post 3mrurlcb5ns2s {"text":"the whole album is amazing.","langs":["en"],"reply":{"root":{"cid":"bafyreigzd5dpc5lgob6hjoeyupbgrwmdpwuadra3m7xeb6xlhqwa4vtsdu","uri":"at://did:plc:e4xkjgw73nlh4cpcwl3zobpu/app.bsky.feed.post/3mrupssdfds2i"},"parent":{"cid":"bafyreibzkj6fkdoifo6dy3bvjjphk4owt6jx5747v7gmehow7sm3xr6gqq","uri":"at://did:plc:e4xkjgw73nlh4cpcwl3zobpu/app.bsky.feed.post/3mrurk4rvok2i"}},"createdAt":"2026-07-30T15:40:00.187Z"}
[2026-07-30T17:40:02+02:00] (43982051) did:plc:qzqjeysuvjsg7hsttruz2j47 :update app.bsky.actor.profile self {"avatar":{"ref":{"$link":"bafkreihdl2p6ajjmj2vyvkkdiqjujd2upcymzku4rgjzhdfn5ubmsvvahm"},"size":111656,"$type":"blob","mimeType":"image/jpeg"},"banner":{"ref":{"$link":"bafkreidwnjrus6t4dnlifp5wjvbqcu6xmxvmihkinyahczoaqss2pmlpfu"},"size":746229,"$type":"blob","mimeType":"image/jpeg"},"pinnedPost":{"cid":"bafyreiedzumax7uxisupvzwl2usjl5c57ve3uxpdlm4x6lsjszjwgvlfn4","uri":"at://did:plc:qzqjeysuvjsg7hsttruz2j47/app.bsky.feed.post/3lif4yplk7k25"},"description":"UX Engineer. Design Systems, HTML, CSS, JavaScript, and Ruby on Rails. Mechanical keyboard builder extraordinaire.\n\n𝔰𝔱𝔯𝔞𝔱𝔬𝔫𝔞𝔲𝔱 — https://links.jeroen.wtf/","displayName":"Jeroen ✦ 112→"}
...
^C
Disconnecting...
Disconnected
Stopped stream at cursor 43982114 (2026-07-30T17:40:10+02:00)

Available options:

pass -j when connecting to a Jetstream

-r lets you start from a given cursor (pass e.g. -r0 to rewind as far back as the playback buffer allows, usually 24h)

-d lets you filter by DID(s)/handle(s) of the account

-c filters by record collection(s) (-c - to disable commit events completely)

-o filters by record operation type (create etc.)

-a or --no-account enables/disables printing account status change events

-s connects and only prints the current seq/cursor and stops

-q disables status logs

So you can do things like:

print all account events:

% rat stream bsky.network -q -c -
[2026-07-30T17:48:57+02:00] (32304142464) did:plc:45twg3eqmpfk5gy2hqlut5gg #account (status = :active)
[2026-07-30T17:48:58+02:00] (32304143007) did:plc:jr6dyqqzkt4fvwlaa25jl6p4 #account (status = :active)
[2026-07-30T17:49:01+02:00] (32304144015) did:plc:qrm43mkl5gaq7xrqwm3g24yb #account (status = :takendown)
[2026-07-30T17:49:04+02:00] (32304145253) did:plc:fapmj6krt2oid3c5cdtmert4 #account (status = :active)
[2026-07-30T17:49:14+02:00] (32304149979) did:plc:2rvd2bqdp3b55nbqhmfy3rsr #account (status = :deleted)
[2026-07-30T17:49:15+02:00] (32304150084) did:plc:btspiod6rvujkt53kvgvhliv #account (status = :active)
[2026-07-30T17:49:16+02:00] (32304150614) did:plc:mme2dzgbkvrnc2zjx75kqr3v #account (status = :active)
[2026-07-30T17:49:16+02:00] (32304150822) did:plc:5bpib7mlsxmsuttxc3fyepjf #account (status = :deactivated)
[2026-07-30T17:49:26+02:00] (32304155364) did:plc:yfh6xikwyfrdouz32c7vg23m #account (status = :active)
[2026-07-30T17:49:29+02:00] (32304156505) did:plc:r4h2ex4n5zprstai3daa664f #account (status = :active)

track events from a specific account(s) (if you use Jetstream, it passes the filter as wantedDids so the server filters server-side):

% rat stream sfo.firehose.stream -j -q -d mackuba.eu
[2026-07-30T17:31:26+02:00] (1785425486090795) did:plc:oio4hkxaop4ao4wz2pp3f4cr :create app.bsky.feed.like 3mrur3xr47s2e {"createdAt":"2026-07-30T15:31:25.733Z","subject":{"cid":"bafyreiftsqlcc3gj3taytrkf6u3ngv7kzj2b723u7pdqu62reqax6m73bi","uri":"at://did:plc:fkvdf5xwcvc6wes4wdunucmc/app.bsky.feed.post/3mruqsgoumk2t"}}
[2026-07-30T17:33:02+02:00] (1785425582287448) did:plc:oio4hkxaop4ao4wz2pp3f4cr :create app.bsky.feed.post 3mrur6sfvg224 {"createdAt":"2026-07-30T15:33:00.913Z","embed":{"$type":"app.bsky.embed.video","aspectRatio":{"height":1080,"width":1920},"presentation":"default","video":{"$type":"blob","ref":{"$link":"bafkreidyqtzytjlq5crrazetr46e2pom3dolxngs7nunfei2zcc3565rge"},"mimeType":"video/mp4","size":4373094}},"langs":["en"],"reply":{"parent":{"cid":"bafyreiftqvigaau2rjcwnh65oj4d63g3ejx7dngs6bwfdlqk7ynw6xfcye","uri":"at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/app.bsky.feed.post/3mruqv3ctvc24"},"root":{"cid":"bafyreiftqvigaau2rjcwnh65oj4d63g3ejx7dngs6bwfdlqk7ynw6xfcye","uri":"at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/app.bsky.feed.post/3mruqv3ctvc24"}},"text":""}
[2026-07-30T17:38:02+02:00] (1785425882088304) did:plc:oio4hkxaop4ao4wz2pp3f4cr :create app.bsky.feed.post 3mrurhqstqc24 {"createdAt":"2026-07-30T15:38:01.230Z","embed":{"$type":"app.bsky.embed.video","aspectRatio":{"height":1920,"width":1080},"presentation":"default","video":{"$type":"blob","ref":{"$link":"bafkreic32j437f5u2meieliwdqns42wgfprgsxowxtxkerexdfae6y3vhu"},"mimeType":"video/mp4","size":10312438}},"langs":["en"],"reply":{"parent":{"cid":"bafyreifq2irx4hnppi6yyoiqqiner7q5qwu5gjg23xmzcwnf7fdwr6lzvq","uri":"at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/app.bsky.feed.post/3mrur6sfvg224"},"root":{"cid":"bafyreiftqvigaau2rjcwnh65oj4d63g3ejx7dngs6bwfdlqk7ynw6xfcye","uri":"at://did:plc:oio4hkxaop4ao4wz2pp3f4cr/app.bsky.feed.post/3mruqv3ctvc24"}},"text":""}
[2026-07-30T17:49:18+02:00] (1785426558341496) did:plc:oio4hkxaop4ao4wz2pp3f4cr :create app.bsky.feed.like 3mrus3vrrzk2e {"createdAt":"2026-07-30T15:49:17.480Z","subject":{"cid":"bafyreihbgs6vz7hykcnh4vjivprn3ahit4u2bhirgdbou3ckabae2d3yc4","uri":"at://did:plc:fm5kxwjgvbg63mvvgcdxf6lj/app.bsky.feed.post/3mrurlt4ock2n"}}
[2026-07-30T17:53:25+02:00] (1785426805123464) did:plc:oio4hkxaop4ao4wz2pp3f4cr :create app.bsky.feed.like 3mrusdbf6rc2e {"createdAt":"2026-07-30T15:53:24.550Z","subject":{"cid":"bafyreid6uxaa2vmrsbhgmr7ikofh2dngonn7atnmvcw3jgnnvhqb7kcmxq","uri":"at://did:plc:gmhm34dtgp4w3ennpzpc5jrd/app.bsky.feed.post/3mrus7rvcvc23"}}

print only profile edits:

% rat stream bsky.network -q -c app.bsky.actor.profile -o update
[2026-07-30T18:01:40+02:00] (32304482004) did:plc:iqsl3qdmatarbtms2gs4kbcz :update app.bsky.actor.profile self {"avatar":{"ref":{"$link":"bafkreievs67cd6dzuinvaytahpxrjyoo7tbnujj76mey524u3k3bcwjm6a"},"size":977064,"$type":"blob","mimeType":"image/jpeg"},"banner":{"ref":{"$link":"bafkreifd4jibokwa7dj6vziqbuxq7gvj56kzxtszonsiwwaiua6kuklgbm"},"size":989494,"$type":"blob","mimeType":"image/jpeg"},"createdAt":"2024-11-23T00:21:07.521Z","description":"Keeper of Dogs, Frogs, Lizards\n\n Hobbyist photographer\n\n🏳️‍🌈🏳️‍⚧️","displayName":"Vin"}
[2026-07-30T18:01:41+02:00] (32304482051) did:plc:hdm3wthgkvbqtdbzgvm2inbg :update app.bsky.actor.profile self {"avatar":{"ref":{"$link":"bafkreigfqwicmggrz3pnrk2x3y4nrizd4xi2o6dgnsh2mvhfivf3t2sdtu"},"size":993607,"$type":"blob","mimeType":"image/jpeg"},"createdAt":"2024-11-15T01:59:34.422Z","description":"✍️ writing The Fixture, a newsletter about women’s sports \nsign up: katiemcinerney.substack.com\n\nalso: editor at the Boston Globe 🗞️","displayName":"Katie McInerney"}
[2026-07-30T18:01:43+02:00] (32304483197) did:plc:ijklzugfjw3vwjand47gkuri :update app.bsky.actor.profile self {"avatar":{"ref":{"$link":"bafkreid6yhdcus7tysp3mqxa45mm3yu37nsbt4b37ofhpjjtjj4sm4ggtu"},"size":955694,"$type":"blob","mimeType":"image/jpeg"},"createdAt":"2025-02-04T22:49:25.081Z","description":"Master of tasty riffs, producer of excellent jams, micro - indie record executive.","displayName":"Ryan Gross"}
[2026-07-30T18:01:47+02:00] (32304484779) did:plc:wvcnm7ozpdoe7v3jki6qpnkh :update app.bsky.actor.profile self {"avatar":{"ref":{"$link":"bafkreiahepdzpvvcbk642xhykosbzuhayqkg3nn2djzn5ivomxd2gpcxya"},"size":12986,"$type":"blob","mimeType":"image/jpeg"},"banner":{"ref":{"$link":"bafkreiayqmffv3zr6h2o5yx656t7mc65vjxtlargvpzupfcenwgpjfyqsa"},"size":573764,"$type":"blob","mimeType":"image/jpeg"},"pinnedPost":{"cid":"bafyreidkaxlwqei2opsepyk4iugiwp2ci2p5lhxtoonx4rjbgkdu3eooji","uri":"at://did:plc:wvcnm7ozpdoe7v3jki6qpnkh/app.bsky.feed.post/3li5bpcaivc24"},"description":"プリうさ狂おじいさん。\nプリティーフィードの人。","displayName":"ゆずき"}
[2026-07-30T18:01:47+02:00] (32304484875) did:plc:gatw35qhl733sw7sz5oxlv34 :update app.bsky.actor.profile self {"avatar":{"ref":{"$link":"bafkreihw5ckcxqwqdp6bi72dzupfsfvr42xio4ttrzqu6x3siteoqktnga"},"size":186002,"$type":"blob","mimeType":"image/jpeg"},"createdAt":"2025-12-04T16:28:14.218Z","description":"Data analyst/AI curious, dad, drummer, news and politics consumer.","displayName":"Sam Scribner"}

And so on. You can pass -c, -d and -o multiple times, or pass a comma-separated list in a single option, and combine them in any way (e.g. only new posts from these 3 people). And of course you can pipe the whole thing through grep to do some more advanced filtering e.g. by post content (I'd love to add more options for pattern matching or digging into records later).

rat stream-labels

Similar to rat stream, but connects to the subscribeLabels firehose of a labeller service. You can pass the labeller to connect to as a service hostname (e.g. "mod.bsky.app"), or as a DID/handle of the labeller account and it will look up the hostname through the DID doc (e.g. "@moderation.bsky.app").

stream-labels has options:

-r, -s, -q as above in stream

-l filters by one or more label types (values)

-t filters by label target i.e. the labelled URI or DID, and you can use patterns with * here

So you can for example:

stream all needs-review labels:

% rat stream-labels @moderation.bsky.app -l needs-review
Connecting to wss://mod.bsky.app/xrpc/com.atproto.label.subscribeLabels...
Connected
[2026-07-30 16:11:56 UTC] (39387868) {"neg":true,"uri":"did:plc:4w7fosrnqzcfuxdskrec33zi","val":"needs-review"}
[2026-07-30 16:12:03 UTC] (39387873) {"exp":"2026-08-06T16:12:03.509Z","uri":"did:plc:utjfrwsjouvbdkktwoqcjsms","val":"needs-review"}
[2026-07-30 16:12:23 UTC] (39387895) {"neg":true,"uri":"did:plc:42sdiesjs5jc2hst2z4ju3ta","val":"needs-review"}
[2026-07-30 16:12:41 UTC] (39387909) {"neg":true,"uri":"did:plc:rrlz2bdlw4samsevbwy6cz5b","val":"needs-review"}
[2026-07-30 16:12:45 UTC] (39387913) {"exp":"2026-08-06T16:12:45.263Z","uri":"did:plc:hzui7dsqk5cw65cbppfu6ve4","val":"needs-review"}
[2026-07-30 16:12:46 UTC] (39387914) {"neg":true,"uri":"did:plc:c4dotfakxb4ljj6h4eqjs5vb","val":"needs-review"}
^C
Disconnecting...
Disconnected
Stopped stream at cursor 39387916 (2026-07-30 16:12:51 UTC)

print all labels added to any of the given account's posts by a labeller:

% rat stream-labels @skywatch.blue -r 5690000 -t '*bgt62gzl3y5ke7b45xi5vkvu*'
Connecting to wss://ozone.skywatch.blue/xrpc/com.atproto.label.subscribeLabels?cursor=5690000...
Connected
[2026-07-29 12:30:18 UTC] (5690028) {"uri":"at://did:plc:bgt62gzl3y5ke7b45xi5vkvu/app.bsky.feed.post/3mrrwhvv5hf24","val":"fringe-media"}
[2026-07-29 12:31:20 UTC] (5690036) {"uri":"at://did:plc:bgt62gzl3y5ke7b45xi5vkvu/app.bsky.feed.post/3mrrwjp7rhn24","val":"fringe-media"}
[2026-07-29 12:34:41 UTC] (5690047) {"uri":"at://did:plc:bgt62gzl3y5ke7b45xi5vkvu/app.bsky.feed.post/3mrrwp2wmhx2a","val":"fringe-media"}
[2026-07-29 12:57:27 UTC] (5690132) {"uri":"at://did:plc:bgt62gzl3y5ke7b45xi5vkvu/app.bsky.feed.post/3mrrxwhnkzv24","val":"fringe-media"}
...

And note that labeller firehoses can (usually?) rewind all the way back to cursor 1, not only the last 24 hours, so you can possibly ask it to print e.g. "all sexual labels added to any of my posts by the Bluesky moderation since January" (you'll need to figure out by trial & error what cursor to pass to go back to January):

% rat stream-labels @moderation.bsky.app -r 27000000 -l sexual,porn,nudity -t '*your_did_here*'
...

In later versions I'd also love to add things like more advanced filters (e.g. using jq language or by passing a fragment of Ruby code to eval), color output, formatting options to print the output fields differently, customizing date format, and so on. Maybe even authenticated calls? We'll see.

Kinda hilarious footnote: shortly before uploading the 0.1 release I added a rat emoji to the version line in the help output. But then I downloaded the newly released version for testing, and I noticed that… this wasn't actually a rat. It was… a fucking badger 🦡. Wtf??

So turns out, I have Polish keyboard layout set on the Mac, so I can write Polish letters like ĄĆĘŁŃ easily. And macOS emoji selector, helpfully, uses this as an indicator that it should look up emoji in the emoji selector dialog in Polish, even though the system language is English. Which is why when I type 'star', I don't get ⭐️, I get a stary człowiek 👴🏻… and when I type 'rat', I don't get 🐀, I get 🦡. Why? Probably because " ratel " 😅

And they look similar enough for my 40+-years-old eyes that I haven't noticed, which is why a 0.1.1 had to happen 10 minutes later with a critical bugfix 🫠