---
title: POSSE to Bluesky with the AT Protocol
description: Automatically syndicating new articles to Bluesky after each deploy — fetching the live OG image, uploading it as a blob, and posting a rich link card via the AT Protocol API.
pubDate: '2026-03-23T14:41:00+01:00'
category: en/development
tags:
  - indieweb
  - bluesky
seriesParent: joining-the-indieweb
seriesOrder: 6
---

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:

```js
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](/en/articles/2026/03/23/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`:

```js
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

**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:

```js
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:

```js
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:

```js
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:

```js
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:

```bash
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.
