POSSE to Bluesky with the AT Protocol
POSSE — Publish on your Own Site, Syndicate Elsewhere — means the article lives here first. Bluesky gets a copy. If Bluesky disappears, the original is unaffected.
The syndication runs automatically as the last step of the deploy script. No manual posting, no third-party integration, no webhook service.
Separate RSS feeds
Before the syndication script existed, the site had one RSS feed covering all content types. That feed is still there, but four additional feeds now cover each type independently:
/rss.xml — all content
/rss/articles.xml — articles only
/rss/notes.xml — notes only
/rss/links.xml — links only
/rss/photos.xml — photos only
The per-type feeds enable granular subscriptions — readers who only want articles aren’t forced to see photos, and vice versa. They also let webmention.app and the syndication script target specific content types.
The syndication script
scripts/bluesky-syndicate.mjs handles the posting. It runs locally after each deploy, fetches the live RSS feed, and posts any articles that haven’t been posted yet.
State is tracked in .bluesky-posted.json — a flat array of GUIDs (article URLs). Anything already in that file is skipped:
const posted = existsSync(STATE_FILE)
? JSON.parse(readFileSync(STATE_FILE, "utf8"))
: [];
const newItems = items.filter((item) => !posted.includes(item.guid));
After a successful post, the GUID is appended and the file is written back.
Authentication
The AT Protocol uses app passwords — scoped credentials that don’t expose your main account password. Creating one takes a minute in Bluesky’s settings. The script authenticates via com.atproto.server.createSession:
const authRes = await fetch(`${BSKY_API}/com.atproto.server.createSession`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ identifier: IDENTIFIER, password: APP_PASSWORD }),
});
const { accessJwt, did } = await authRes.json();
Rich link cards with OG images
A plain text post with a URL works, but Bluesky supports app.bsky.embed.external — a structured embed that displays a title, description, and thumbnail image as a link card. This is what produces the preview that looks like a proper share.
The thumbnail has to be uploaded separately as a blob before the post is created. The script fetches the OG image from the live article page:
const html = await fetch(item.link).then((r) => r.text());
const match = html.match(
/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i
);
const ogImageUrl = match?.[1] ?? null;
Then downloads it and uploads it to Bluesky:
const buffer = await fetch(ogImageUrl).then((r) => r.arrayBuffer());
const uploadRes = await fetch(`${BSKY_API}/com.atproto.repo.uploadBlob`, {
method: "POST",
headers: { "Content-Type": contentType, Authorization: `Bearer ${accessJwt}` },
body: buffer,
});
const { blob } = await uploadRes.json();
The blob reference goes into the post record:
const record = {
$type: "app.bsky.feed.post",
text: item.title,
createdAt: new Date(item.pubDate).toISOString(),
embed: {
$type: "app.bsky.embed.external",
external: {
uri: item.link,
title: item.title,
description: item.description,
thumb: blob,
},
},
};
The OG images for articles are generated at build time by Satori — a dark card with the title in large type, the description below it, and an accent stripe at the top. Each article gets its own image at /og/articles/[slug].png.
Post timestamps
createdAt is set to the article’s original publish date from the RSS pubDate, not the deploy time. An article published weeks ago posts with that original date. This places it correctly in the chronological timeline rather than making everything look like it was just written today.
When multiple articles share the same publish date (common during a batch-publishing session), a per-item index offset of one second prevents duplicate timestamps:
const createdAt = new Date(baseDate.getTime() + i * 1000).toISOString();
Wiring into the deploy script
The credentials are read from .env.production and passed as environment variables:
BLUESKY_IDENTIFIER="$(grep -E '^BLUESKY_IDENTIFIER=' "$PRODUCTION_ENV_FILE" | cut -d'=' -f2-)"
BLUESKY_APP_PASSWORD="$(grep -E '^BLUESKY_APP_PASSWORD=' "$PRODUCTION_ENV_FILE" | cut -d'=' -f2-)"
if [[ -n "$BLUESKY_IDENTIFIER" && -n "$BLUESKY_APP_PASSWORD" ]]; then
BLUESKY_IDENTIFIER="$BLUESKY_IDENTIFIER" \
BLUESKY_APP_PASSWORD="$BLUESKY_APP_PASSWORD" \
node "$SCRIPT_DIR/bluesky-syndicate.mjs"
fi
If the credentials aren’t present, the block is skipped silently. The deploy still succeeds.
The full sequence on every deploy: build → rsync → redeploy on VPS → send webmentions → syndicate to Bluesky. New content lands on the site and goes out to Bluesky without any manual steps.