POSSE to Bluesky with the AT Protocol

Category: Development

Tags: indieweb, bluesky


POSSE — Publish on your Own Site, Syndicate Elsewhere — means the article lives here first and Bluesky gets a copy. If Bluesky disappears, the original is unaffected. I wanted this to run as the last step of every deploy, not as a manual “copy link, paste into client” ritual, and not via a third-party webhook service.

The setup

  • Site: Astro 6 static build with separate per-type RSS feeds.
  • Deploy: shell script that builds, rsyncs to a VPS, sends webmentions, then syndicates.
  • Bluesky: app-password credentials, AT Protocol directly — no SDK, no third-party bridge.

Separate RSS feeds

Before the syndication work the site had one feed covering everything. That feed is still there; four per-type feeds now sit next to it:

/rss.xml           — all content
/rss/articles.xml  — articles only
/rss/notes.xml     — notes only
/rss/links.xml     — links only
/rss/photos.xml    — photos only

Two benefits fall out. Readers who want articles but not photos can subscribe granularly. And the syndication script — plus webmention.app — can target specific content types instead of having to filter a mixed feed.

The syndication script

scripts/bluesky-syndicate.mjs runs locally as the last deploy step. It pulls the live RSS feed and posts any articles that haven’t been posted yet.

State lives in .bluesky-posted.json — at this stage, a flat array of GUIDs. Anything already listed 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. A later post in this series extends the state file to also record the Bluesky URL — see Improving Bluesky Syndication.

Authentication

Problem: posting requires an authenticated session. Using the main account password in a script would be careless.

Implementation: app passwords — scoped credentials created 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();

Problem: a plain URL in a post body works, but it renders as plain text. What gets shared around is the rich link card — title, description, thumbnail.

Implementation: Bluesky’s app.bsky.embed.external is a structured embed that produces exactly that, but the thumbnail has to be uploaded separately as a blob before the post is created. The script fetches the OG image URL straight 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 the image and uploads it as a blob:

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,
    },
  },
};

Solution: the OG image itself is generated at build time by Satori — a dark card with the title in large type, the description underneath, an accent stripe at the top. One image per article at /og/articles/[slug].png.

Post timestamps

createdAt is the article’s original publish date from the RSS pubDate, not the deploy time. An article published weeks ago posts with that original date and lands in the right place on the timeline instead of claiming to be fresh.

When multiple articles share the same publish date — common in a batch-publishing session — a per-item index offset of one second prevents duplicate timestamps:

const createdAt = new Date(baseDate.getTime() + i * 1000).toISOString();

Without the offset, Bluesky deduplicates identical-timestamp posts and only one of them shows up in the feed.

Wiring into the deploy script

Credentials come from .env.production — never committed, never in the repo:

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 and 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 with no manual steps in between.

What to take away

  • POSSE is worth the 200 lines of script. Your canonical copy is on your own site; Bluesky is a distribution channel.
  • App passwords, not account passwords. Scoped credentials belong in .env.production, never in the repo.
  • Rich link cards need a blob upload first. The two-step dance — upload image as blob, then reference the blob in the embed — is the whole point of app.bsky.embed.external.
  • Use the article’s publish date. Deploy-time timestamps make everything look like it was posted today.
  • A one-second index offset prevents silent dedup. Identical createdAt values across a batch cause Bluesky to show only one.