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:
- 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. - 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}}" }
]
}
]
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. -
byteEndis 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:
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.
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}} }
}
]
}
}
}
(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 →
$linkintoref.$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
createdAtoruriproduces 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; ...), thenowgoes 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>
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.
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)