This year at IEEE VIS 2026, unlike last years discord server, every paper’s discussion is done on Bluesky threads and the VIS website adds those Bluesky threads on their website. The conference posts an announcement for each talk, and every reply to that post shows up under the paper. I really liked this feature and wanted the same thing on my own paper pages, so I added it. It turned out to be very easy to add (especially with Claude).
Why this is so easy
Bluesky runs on the AT Protocol, and reading public posts needs nothing from you:
- You don’t need an API key or an account to read. The API
public.api.bsky.app is public.
- You don’t need a server either. The API allows requests from any website (
Access-Control-Allow-Origin: *), so the reader’s browser fetches the thread directly.
- As the comments are stored on Bluesky, people reply with their own Bluesky accounts, and Bluesky handles blocking and moderation.
- Your page only makes one request to Bluesky. Unlike Disqus-style widgets, there’s no third-party script tracking anything.
The comments at the bottom of this article work the same way. They are replies to the Bluesky post where I announced this article: the article links to the post, and the post links back to the article.
One request gets the whole thread
A Bluesky post link has two parts, the author’s handle and the post’s ID:
https://bsky.app/profile/rahatzaman.bsky.social/post/3mzsxgncky22w
The API identifies the same post with an AT URI, which is built from the same two parts:
at://rahatzaman.bsky.social/app.bsky.feed.post/3mzsxgncky22w
Pass it to app.bsky.feed.getPostThread and you get the post with its replies:
1
2
| curl -s "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread?uri=at://rahatzaman.bsky.social/app.bsky.feed.post/3mzsxgncky22w&depth=1" \
| jq '{post: .thread.post.record.text, replies: [.thread.replies[].post.record.text]}'
|
The response is like this:
1
2
3
4
5
6
7
8
9
10
11
12
13
| {
"thread": {
"post": {
"uri": "at://did:plc:rbagslo5yrhgqqajonsdmfkd/app.bsky.feed.post/3mzsxgncky22w",
"author": { "handle": "rahatzaman.bsky.social", "avatar": "https://cdn.bsky.app/..." },
"record": { "text": "This year @ieeevis.org puts a Bluesky thread ...", "createdAt": "2026-11-08T..." },
"likeCount": 0
},
"replies": [
{ "post": { "author": {}, "record": {} }, "replies": [] }
]
}
}
|
Try it
Here is a simple example. Put a bluesky post link:
An example code (Don’t Read)
Who reads how the implementation is done these days? Just ask your Claude/OpenCode/Codex/Cursor/whatever AI to do it. But its only ~50 lines (which is exactly used for the example above), so for brevity, here it is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
| <div id="comments" data-post="https://bsky.app/profile/rahatzaman.bsky.social/post/3mzsxgncky22w"></div>
<script type="module">
const API = "https://public.api.bsky.app/xrpc/app.bsky.feed.getPostThread";
// https://bsky.app/profile/<handle>/post/<id> -> at://<handle>/app.bsky.feed.post/<id>
function toAtUri(link) {
const [, , handle, , id] = new URL(link).pathname.split("/");
return `at://${handle}/app.bsky.feed.post/${id}`;
}
function el(tag, props, ...children) {
const node = Object.assign(document.createElement(tag), props);
node.append(...children);
return node;
}
function renderPost({ post, replies = [] }, hidden) {
const { author, record } = post;
const url = post.uri.replace("at://", "https://bsky.app/profile/").replace("app.bsky.feed.post", "post");
return el("article", { className: "bsky-post" },
el("header", {},
author.avatar ? el("img", { src: author.avatar, alt: "", width: 28, height: 28 }) : "",
el("a", { href: `https://bsky.app/profile/${author.handle}`, textContent: author.displayName || author.handle }),
el("a", { href: url, textContent: new Date(record.createdAt).toLocaleDateString() }),
),
el("p", { textContent: record.text }),
el("small", { textContent: `♥ ${post.likeCount ?? 0}` }),
...replies
.filter((reply) => reply.post && !hidden.has(reply.post.uri)) // blocked and deleted replies have no post
.sort((a, b) => (b.post.likeCount ?? 0) - (a.post.likeCount ?? 0))
.map((reply) => renderPost(reply, hidden)),
);
}
async function showComments(box) {
const link = box.dataset.post;
const response = await fetch(`${API}?uri=${encodeURIComponent(toAtUri(link))}&depth=10`);
const replyLink = el("a", { href: link, textContent: "Reply on Bluesky" });
if (!response.ok) return box.replaceChildren(replyLink);
const { thread } = await response.json();
const hidden = new Set(thread.post.threadgate?.record?.hiddenReplies ?? []); // replies the author hid on Bluesky
box.replaceChildren(replyLink, renderPost(thread, hidden));
}
showComments(document.querySelector("#comments"));
</script>
<style>
.bsky-post { margin-top: 1rem; }
.bsky-post .bsky-post { padding-left: 1rem; border-left: 2px solid color-mix(in srgb, currentColor 20%, transparent); }
.bsky-post header { display: flex; align-items: center; gap: 0.5rem; }
.bsky-post img { border-radius: 50%; }
.bsky-post p { margin: 0.25rem 0; white-space: pre-wrap; }
</style>
|
Bluesky's moderation service labels spam, adult content, and similar material. Ask for its labels with one header, then skip anything that carries them. !no-unauthenticated matters too: it's a label people put on their own account to ask not to be shown to logged-out viewers.
My IEEE VIS papers have one extra step, since the posts are not yet written. The conference's bot posts one around the time of each talk, so before a paper is presented, there's no post to link to. Instead, I link the paper's page on the VIS website. VIS runs a small public lookup service that maps each paper to its Bluesky post. When someone opens my paper page, their browser takes the paper's ID from that link and asks the service for the post. If the post exists, the page loads the thread the same way as above.