DEV Community

Cover image for Auto-posting RSS to Bluesky with Make.com — with an actually clickable link
Troy Cottam
Troy Cottam

Posted on

Auto-posting RSS to Bluesky with Make.com — with an actually clickable link

How to get past the "links are just plain text" problem using one fixed-offset facet and zero code.

I run a Fallout 76 fan site — a static, no-framework photo archive with an Atom feed. I'm a huge fan of automating grunt work — the wife says lazy — so I wanted every new in-game screenshot I add to the site to auto-post to Bluesky with the image attached and a clickable link back to my site. I chose Make.com because it can do the RSS-watching and the image-uploading out of the box, but the last step in making this automation happen hides a trap that took me a fair amount of debugging to solve:

Weirdly, Bluesky does not auto-link URLs, and Make's native "Create a post" module doesn't make links clickable. Your URL posts as inert plain text. I ran into this known limitation, and after some digging located a fix — it just requires understanding one piece of the AT Protocol: rich text facets.

This article is a missing manual for the process and specifically that last step. After reading this piece, you'll have a five-module Make scenario that watches your feed and posts new images + clickable links + hashtags to Bluesky, with no code and no per-post byte counting.

Why links on Bluesky are hard

On most platforms, you post text containing a URL and the platform "linkifies" it. Bluesky's API doesn't do that. A post record's text is dumb bytes; anything interactive — links, mentions, hashtags — is declared separately in a facets array. Each facet says "bytes N through M of the text are a link to this URI."

Two things make this painful in a no-code tool:

  1. The offsets are byte offsets into the UTF-8 encoding, not character positions. An em dash is three bytes. One — before your link and your character-based math is wrong.
  2. Make.com has no facility for computing byte offsets of a dynamic substring. If your link's position shifts from post to post, you're screwed.

The trick: pin the link to a constant prefix

You can sidestep all byte math with one formatting trick: start every post with a fixed, ASCII-only prefix, and make the prefix the link.

My posts all begin with the literal string fallout76er.com — 15 ASCII characters, which is exactly 15 bytes, always at position 0. So the facet is a constant:

"facets": [
  {
    "index": { "byteStart": 0, "byteEnd": 15 },
    "features": [
      { "$type": "app.bsky.richtext.facet#link", "uri": "{{2.URL}}" }
    ]
  }
]
Enter fullscreen mode Exit fullscreen mode

No formulas, no counting, ever. The facet's target (uri) is dynamic — it's mapped from the RSS item, so each post links to its own entry — but the clickable region never moves. Readers see a click-through fallout76er.com at the start of every post.

Here are the rules for the prefix:

  • Keep it exactly the byte length in byteEnd. Change the prefix, recount the bytes.
  • ASCII only. The moment you put a — or an emoji inside the linked region, bytes will no longer equal characters.
  • byteEnd is exclusive: [0, 15) covers positions 0–14.
  • Anything after the facet region can be whatever multi-byte string you like. My text continues with — {{Title}} and this em dash doesn't matter, because it's outside the linked range.

The five-module scenario

Here's a capture of the whole chain — five modules, built in this order:

Make.com scenario: RSS watch, photo filter, HTTP download, Bluesky upload, API call

1. RSS → Watch RSS feed items

Point it at your feed. One gotcha for Atom feeds: the module copies each entry's content element into its Description output, but it ignores <enclosure> and <media:content> completely — so its Image field stays empty even when your feed dutifully provides image metadata. You'll fetch the image yourself in module 3.

2. Filter

I only want photo entries to post, not every feed item. My photo entry URLs all contain a #photo= fragment, so the filter is just: URL → Text operators: Contains → #photo=. Guide pages and other entry types sail past untouched. If you control your feed's URL scheme, building a filterable marker into it is the cheapest routing mechanism you'll ever write.

3. HTTP → Download a file

My entry URLs are the image URL plus that fragment, so the mapping is one function: replace(2.URL; "#photo="; emptystring). Adapt to taste — the point is deriving the image URL from data already in the feed item.

4. Bluesky → Upload media

This calls com.atproto.repo.uploadBlob and returns a blob reference you'll need in module 5. Use the "Map" option for the file input and map File name and Data from the HTTP module explicitly. The convenient-looking "HTTP - Download a file" radio button did not persist on save for me — twice — producing "Value must not be empty" warnings on the next run. Map is boring and works.

Bluesky Upload media module using the Map option, with File name and Data mapped from the HTTP module

5. Bluesky → Make an API Call

Remember, not "Create a post" — that's the module that can't do facets. The generic API-call module uses your existing Bluesky connection's auth, so this is still no-code, just hand-written JSON. POST to /xrpc/com.atproto.repo.createRecord with header Content-Type: application/json and this body:

{
  "repo": "did:plc:YOUR-DID-HERE",
  "collection": "app.bsky.feed.post",
  "record": {
    "$type": "app.bsky.feed.post",
    "text": "fallout76er.com — {{2.Title}}\n#Fallout76 #FO76",
    "facets": [
      {
        "index": { "byteStart": 0, "byteEnd": 15 },
        "features": [
          { "$type": "app.bsky.richtext.facet#link", "uri": "{{2.URL}}" }
        ]
      }
    ],
    "langs": ["en"],
    "createdAt": "{{formatDate(now; "YYYY-MM-DDTHH:mm:ssZ")}}",
    "embed": {
      "$type": "app.bsky.embed.images",
      "images": [
        {
          "alt": "{{2.Title}}",
          "image": {
            "$type": "blob",
            "ref": { "$link": "{{6.blob.ref.$link}}" },
            "mimeType": "{{6.blob.mimeType}}",
            "size": {{6.blob.size}}
          },
          "aspectRatio": { "width": {{6.width}}, "height": {{6.height}} }
        }
      ]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

(The module numbers here match the scenario capture above — RSS is 2, Upload media is 6. Yours will differ, so map the blob fields from whatever your Upload media module returns. Your DID is on your profile via any DID-lookup tool, or grab it from https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=yourhandle.)

Details that will bite you if you skip them:

  • The blob goes in as a reference, not as data. Map Upload media's Blob → Ref → $link into ref.$link. If you map the raw file Data there instead, the API answers [400] Expected blob value type.
  • Stray spaces inside quoted JSON values corrupt them. A space that sneaks in around a mapped value inside the quotes of createdAt or uri produces an invalid datetime or URI. Spaces outside quotes are harmless. This is miserable to spot in Make's editor; if you get baffling validation errors, hunt for whitespace first.
  • In formatDate(now; ...), the now goes inside the parentheses. Writing it outside fails in ways that aren't obviously about the date.
  • Images have a ~1 MB cap. Standard web-quality compression (my q80 WebP files land around 200–300 KB) clears it with room to spare.
  • Watch for vendor typos in mapped field names. My Upload media module offers image height as "Media Heigth." Map what the picker shows you, not what spelling suggests.
  • Hashtags here are plain text. Making them clickable tag facets would mean real byte math per post, since they sit after variable-length titles. Plain-text tags still surface in Bluesky search, which was enough for me.

The gotcha hiding on your server: charset

My first automated posts rendered em dashes as â€". Nothing was wrong in Make's mappings — the problem was the feed itself being served without an explicit charset. Make decoded my UTF-8 feed as Latin-1, and every multi-byte character shattered.

The fix is one server config line ensuring the feed ships with Content-Type: application/atom+xml; charset=utf-8. On Apache:

<Files "feed.xml">
  AddDefaultCharset utf-8
</Files>
Enter fullscreen mode Exit fullscreen mode

If your automated posts have mangled punctuation, check the Content-Type header on your feed before you touch the scenario.

Testing without waiting for the scheduler

Two Make features make the debug loop tolerable:

  • "Run once" (scenario editor, bottom-left) executes the chain immediately instead of waiting for the next scheduled poll.
  • The RSS watcher keeps a pointer of what it's already seen — and every run advances it, even failed ones. To re-test with the same entry: right-click the RSS module → "Choose where to start" → "Since specific date", and reset it before each test run.

The result

New photo goes up on the site, feed regenerates, and at the next poll the post appears on Bluesky: image embedded, title and hashtags in the text, and a clickable fallout76er.com at the front. Total running cost is 1–2 Make operations per day on the free tier.

Automated Bluesky post with image embed and clickable site link

The site this feeds is fallout76er.com, a deliberately framework-free static site — feed included — generated by a few small Node scripts, all public on GitHub if you want to see the feed-generation side of the pipeline.

The fixed-prefix facet trick generalizes to any no-code tool talking to Bluesky, not just Make: if you can't compute byte offsets, design your post format so you never have to.

That's it, gentle reader. I hope this walkthrough helps you automate some boring task-grind using Make. I'm not a Make affiliate — I'm more of an open-source guy — but if you visit my site and buy me a Ko-fi, I won't complain.

Top comments (0)