Bluesky — public profiles, follows and feeds, with no login
prompts/pkg/llms/bluesky.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
When someone asks to look at Bluesky — a profile card, who somebody follows, a
wall of an account's posts, a search for people — build it on the public
AT Protocol XRPC host, https://public.api.bsky.app/xrpc. Every endpoint on
this page answers an anonymous request. This is a backend.js feature (see the
backend docs for the general handler contract); this page is the recipe that
keeps it credential-free, honest about what the host refuses, and correct about
paging.
1. No credential, ever — and why this is not a connection
Bluesky's public API needs no login, so the app renders no token field, no password box and no sign-in step.
There is no API key to get, no app password to paste, no OAuth redirect and no
ctx.secrets entry. public.api.bsky.app reads public data about any
account — the same data an anonymous visitor to bsky.app can see — and it
reads it for anybody who asks. An app that grows a "Bluesky app password" input
has invented a requirement the API does not have, and has put a credential in a
readable channel for nothing.
This is public data about any account, so no login is needed. An app where
each visitor links their OWN account is a different thing — that is a
connection (the connections skill) — and Bluesky is not a connections
provider, so it needs none. Read the boundary the way the request is worded:
"show me @alice's followers", "a wall of posts from these five accounts",
"search Bluesky for people who mention pottery" are all public reads and belong
here. "Post to my Bluesky", "show me MY timeline", "reply as me" need an
authenticated session this page does not give you — say so plainly and build
the public half, rather than asking the person for a password.
2. What answers unauthenticated, and what does not
Base: https://public.api.bsky.app/xrpc. actor takes a handle
(alice.bsky.social) or a DID (did:plc:…) — the same value works either way.
| Endpoint | Query | Answers |
|---|---|---|
app.bsky.actor.getProfile |
actor |
{ did, handle, displayName, avatar, description, followersCount, followsCount, postsCount } |
app.bsky.graph.getFollows |
actor, limit, cursor |
{ follows: [profile…], cursor } |
app.bsky.graph.getFollowers |
actor, limit, cursor |
{ followers: [profile…], cursor } |
app.bsky.feed.getAuthorFeed |
actor, limit, cursor |
{ feed: [{ post: { uri, cid, author, record, … } }], cursor } |
app.bsky.actor.searchActors |
q, limit, cursor |
{ actors: [profile…], cursor } |
app.bsky.feed.getPostThread |
uri (at://…) |
a post and its replies |
app.bsky.feed.searchPosts is not available unauthenticated — it answers
403, so do not build post search on this host. Full-text post search needs
a logged-in session. When somebody asks to search posts, searchActors finds
the people and getAuthorFeed reads what they wrote — say what the app does
instead of failing silently on a refusal it was never going to survive.
The profile objects inside follows, followers, actors and post.author
are the same small shape: did, handle, displayName, avatar,
description. A post's text is at post.record.text and its time at
post.record.createdAt; displayName and avatar are frequently absent, so
fall back to the handle rather than rendering an empty card.
3. Check the response before you parse it
Check res.ok before calling res.json() — a refusal from this host comes back as HTML, so parsing it first throws instead of returning an error object.
This is the trap that turns a handled refusal into a dead app. The 403 from
searchPosts has an HTML body, so await res.json() raises a SyntaxError
before any if (body.error) line can run, and — because an uncaught throw in a
handler renders as an opaque 404 — the app tells its user it does not exist.
A 400 from a real XRPC route (a bad uri, an unknown actor) is JSON, shaped
{ "error": "InvalidRequest", "message": "Profile not found" } for an unknown actor, which is worth passing to the screen.
So: status first, JSON second, and a stated reason either way.
jsasync function bsky(ctx, method, params) {
const url = new URL(`https://public.api.bsky.app/xrpc/${method}`);
for (const [k, v] of Object.entries(params)) if (v != null) url.searchParams.set(k, String(v));
const res = await ctx.fetch(url.toString(), { headers: { accept: "application/json" } });
const type = res.headers.get("content-type") ?? "";
if (!type.includes("application/json")) {
// A 403 from searchPosts lands here, as HTML. Parsing it would throw.
return { ok: false, reason: "unavailable", message: `Bluesky answered ${res.status} for ${method}.` };
}
const body = await res.json().catch(() => null);
if (!res.ok || !body) {
// A missing, renamed or deactivated actor is a 400 `InvalidRequest` whose
// message says "not found"; name it once here so the screen can say so.
const notFound = /not found/i.test(body?.message ?? "");
return { ok: false, reason: notFound ? "not-found" : (body?.error ?? "http-error"), message: body?.message ?? `HTTP ${res.status}` };
}
return { ok: true, body };
}
The host is CORS-open (access-control-allow-origin: *), so a direct call from
App.jsx would reach it too. Build it in backend.js anyway: the status check,
the paging loop and the read cache then live in one place, and the client has
one /_api shape to branch on instead of a raw upstream body.
4. Paging follows the cursor
Page with cursor: pass the previous response's cursor back as ?cursor=, and stop when the response carries none — never loop until a page comes back empty.
limit caps a page (100 is the ceiling on the graph and feed routes). The
response's cursor is the address of the next page; its absence is the end
of the list. A filtered page can be empty and still carry a cursor, so an
emptiness test stops early and quietly loses the rest of somebody's followers.
jsasync function pages(ctx, method, params, key, max = 500) {
const rows = [];
let cursor;
do {
const page = await bsky(ctx, method, { ...params, limit: 100, cursor });
if (!page.ok) return rows.length ? { ok: true, rows, partial: page.reason } : page;
rows.push(...(page.body[key] ?? []));
cursor = page.body.cursor;
} while (cursor && rows.length < max);
return { ok: true, rows };
}
Bound the walk. A popular account has hundreds of thousands of followers, and a handler that walks all of them hits the 15s request budget and returns nothing at all — so cap the sweep, and tell the screen the list was capped rather than implying it is complete.
5. Every /_api route keeps its reason
An uncaught throw in a handler reaches the app as a bare 500 backend.js threw: <ErrorClass> — the full message goes to the app's log, never to the caller — so every /_api route wraps its work in try/catch and answers with a stated reason.
A transient upstream blip is something the person can wait out, and the screen
can say so only when the route says so. Wrap the body of each route, answer
{ ok: false, reason }, and log the throw with ctx.log so an operator reading
vibes-diy app logs sees the route's own account alongside the platform's.
A not-found from Bluesky is worth its own reason too: an actor who does not
exist, a renamed handle, and a deactivated account all come back as a 400 with
error: "InvalidRequest" and a message saying the profile was not found — the
helpers below name that reason: "not-found" — and "we could not find @name on Bluesky" is an answer the
person can act on — "no data" is not.
Complete backend.js
backend.js
jsconst XRPC = "https://public.api.bsky.app/xrpc";
const DB = "bluesky";
async function bsky(ctx, method, params) {
const url = new URL(`${XRPC}/${method}`);
for (const [k, v] of Object.entries(params)) if (v != null) url.searchParams.set(k, String(v));
const res = await ctx.fetch(url.toString(), { headers: { accept: "application/json" } });
// Status and content-type BEFORE parsing: a refusal from this host has an HTML body.
const type = res.headers.get("content-type") ?? "";
if (!type.includes("application/json")) {
return { ok: false, reason: "unavailable", message: `Bluesky answered ${res.status} for ${method}.` };
}
const body = await res.json().catch(() => null);
if (!res.ok || !body) {
// A missing, renamed or deactivated actor is a 400 `InvalidRequest` whose
// message says "not found"; name it once here so the screen can say so.
const notFound = /not found/i.test(body?.message ?? "");
return { ok: false, reason: notFound ? "not-found" : (body?.error ?? "http-error"), message: body?.message ?? `HTTP ${res.status}` };
}
return { ok: true, body };
}
/** Walk `cursor` to the end, or to `max` rows — never until a page looks empty. */
async function pages(ctx, method, params, key, max = 500) {
const rows = [];
let cursor;
do {
const page = await bsky(ctx, method, { ...params, limit: 100, cursor });
if (!page.ok) return rows.length ? { ok: true, rows, capped: true, partial: page.reason } : page;
rows.push(...(page.body[key] ?? []));
cursor = page.body.cursor;
} while (cursor && rows.length < max);
return { ok: true, rows, capped: Boolean(cursor) };
}
const asProfile = (p) => ({
did: p.did,
handle: p.handle,
displayName: p.displayName ?? p.handle,
avatar: p.avatar ?? null,
description: p.description ?? "",
});
function json(payload, status = 200) {
return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}
export async function fetch(request, ctx) {
const url = new URL(request.url);
const actor = (url.searchParams.get("actor") ?? "").trim().replace(/^@/, "");
try {
if (url.pathname === "/profile") {
if (!actor) return json({ ok: false, reason: "no-actor", message: "Type a Bluesky handle." }, 400);
const result = await bsky(ctx, "app.bsky.actor.getProfile", { actor });
if (!result.ok) return json(result);
const p = result.body;
return json({
ok: true,
profile: {
...asProfile(p),
followersCount: p.followersCount ?? 0,
followsCount: p.followsCount ?? 0,
postsCount: p.postsCount ?? 0,
},
});
}
if (url.pathname === "/follows" || url.pathname === "/followers") {
if (!actor) return json({ ok: false, reason: "no-actor", message: "Type a Bluesky handle." }, 400);
const following = url.pathname === "/follows";
const method = following ? "app.bsky.graph.getFollows" : "app.bsky.graph.getFollowers";
const result = await pages(ctx, method, { actor }, following ? "follows" : "followers");
if (!result.ok) return json(result);
return json({ ok: true, capped: result.capped === true, people: result.rows.map(asProfile) });
}
if (url.pathname === "/feed") {
if (!actor) return json({ ok: false, reason: "no-actor", message: "Type a Bluesky handle." }, 400);
const result = await pages(ctx, "app.bsky.feed.getAuthorFeed", { actor }, "feed", 100);
if (!result.ok) return json(result);
const posts = result.rows
.map((row) => row.post)
.filter(Boolean)
.map((post) => ({
uri: post.uri,
cid: post.cid,
text: post.record?.text ?? "",
createdAt: post.record?.createdAt ?? null,
author: asProfile(post.author ?? {}),
replyCount: post.replyCount ?? 0,
repostCount: post.repostCount ?? 0,
likeCount: post.likeCount ?? 0,
}));
return json({ ok: true, posts });
}
if (url.pathname === "/search") {
const q = (url.searchParams.get("q") ?? "").trim();
if (!q) return json({ ok: false, reason: "no-query", message: "Type something to search for." }, 400);
// Only PEOPLE search answers without a session; post search is a 403 on this host.
const result = await bsky(ctx, "app.bsky.actor.searchActors", { q, limit: 25 });
if (!result.ok) return json(result);
return json({ ok: true, people: (result.body.actors ?? []).map(asProfile) });
}
return new Response("not found", { status: 404 });
} catch (err) {
// Without this, the app gets a bare platform 500 naming only the error class — no reason to show.
ctx.log("error", "bluesky route failed", { path: url.pathname, message: String(err) });
return json({ ok: false, reason: "handler-failed", message: "Bluesky could not be read just now." }, 500);
}
}
App.jsx sketch
A profile card, its follow graph, and the account's posts — one handle in, no account of the visitor's involved.
App.jsx
jsximport React, { useState } from "react";
import { Button, Field, Input, Row, RowCard, Stack } from "@vibes.diy/pouf";
export default function App() {
const [handle, setHandle] = useState("bsky.app");
const [profile, setProfile] = useState(null);
const [people, setPeople] = useState([]);
const [posts, setPosts] = useState([]);
const [problem, setProblem] = useState(null);
// Takes the handle as an argument, defaulting to what is in the input. A click
// in the follow list below passes the person's handle straight in: calling
// `load()` right after `setHandle(...)` would read the PREVIOUS handle, because
// the state update has not applied yet — an off-by-one that looks correct on
// the second click.
const load = async (who = handle) => {
setProblem(null);
const actor = encodeURIComponent(who.trim().replace(/^@/, ""));
const [p, f, w] = await Promise.all([
fetch(`/_api/profile?actor=${actor}`).then((r) => r.json()),
fetch(`/_api/follows?actor=${actor}`).then((r) => r.json()),
fetch(`/_api/feed?actor=${actor}`).then((r) => r.json()),
]);
if (!p.ok) {
setProblem(p.reason === "not-found" ? `We could not find @${who} on Bluesky.` : p.message);
return;
}
setProfile(p.profile);
setPeople(f.ok ? f.people : []);
setPosts(w.ok ? w.posts : []);
};
return (
<div>
<Row gap={2} align="top">
<Field label="Bluesky handle">{(id, describedBy) => <Input id={id} describedBy={describedBy} value={handle} onChange={setHandle} placeholder="alice.bsky.social" />}</Field>
<Button type="button" onClick={() => load()}>Look up</Button>
</Row>
{problem && <p role="alert">{problem}</p>}
{profile && (
<section>
{profile.avatar && <img src={profile.avatar} alt="" width={64} height={64} />}
<h2>{profile.displayName}</h2>
<p>@{profile.handle}</p>
<p>{profile.description}</p>
<p>
{profile.followersCount} followers · {profile.followsCount} following · {profile.postsCount} posts
</p>
</section>
)}
<section>
<h3>Following</h3>
<Stack gap={2}>
{people.map((person) => (
<RowCard
key={person.did}
onClick={() => {
setHandle(person.handle);
load(person.handle);
}}
>
{person.displayName} (@{person.handle})
</RowCard>
))}
</Stack>
</section>
<section>
<h3>Posts</h3>
{posts.map((post) => (
<article key={post.uri}>
<p>{post.text}</p>
<small>
{post.createdAt} · {post.likeCount} likes · {post.replyCount} replies
</small>
</article>
))}
</section>
</div>
);
}
Clicking a person in the follow list re-runs the same three routes against their
handle, which is the whole of a follower-graph explorer. A search page is the
same shape over /_api/search, and a multi-account wall is /_api/feed per
handle merged and sorted by createdAt.