You publish to Bluesky through the API, the post appears, and the link in it is dead. Not broken — just plain text, sitting there unclickable. Nothing errored. The API returned success.
This one matters more than it looks, because if you are posting a tracked link the whole point of the post is that someone can click it. We publish real posts to Bluesky, so here is what is going on, the part people get wrong when they fix it, and how to prove you have fixed it properly.
Bluesky does not parse your text
Most platforms scan a post for anything that looks like a URL and linkify it for you. Bluesky does not. The AT Protocol stores the text exactly as you sent it, and separately stores annotations describing which ranges of that text mean something. Those annotations are called facets.
No facets, no link. The client has nothing telling it that those particular characters are a URL, so it renders them as characters. This is a deliberate design choice rather than an oversight: the protocol keeps the text canonical and pushes all interpretation into structured metadata, which is why the same record renders identically across every client that reads it.
A facet is a range plus one or more features. The range is an object with byteStart and byteEnd. The feature carries a $type discriminator and whatever payload that type needs:
- app.bsky.richtext.facet#link — carries a uri field with the real destination. This is the one you need for clickable links.
- app.bsky.richtext.facet#mention — carries a did, because mentions resolve to a decentralised identifier and not to a handle. Handles can change; DIDs do not.
- app.bsky.richtext.facet#tag — carries a tag string for hashtags.
Because the uri is separate from the text, the displayed text and the destination do not have to match. That is how clients show a shortened link over a long URL. It is also, worth noting, how a malicious record could show one domain and link to another — so if you ever render AT Protocol records yourself, do not trust the text.
The record you actually POST to com.atproto.repo.createRecord is a body with three fields: repo set to the author’s DID, collection set to app.bsky.feed.post, and record — the post itself. The record carries a $type of app.bsky.feed.post, the text, a createdAt ISO timestamp, an optional facets array, and an optional embed. Note the framing: you are writing a record into the user’s repository, not calling a create-post endpoint. That distinction explains most of the API’s shape.
The part everyone gets wrong: those are BYTE offsets
byteStart and byteEnd are UTF-8 byte offsets. They are not string indices, and in JavaScript those two things are not the same number.
JavaScript strings are UTF-16. A plain ASCII character is one byte in UTF-8 and one code unit in UTF-16, so for an all-ASCII post the two agree exactly and your code looks correct. The moment anything multi-byte appears before the URL — an emoji, an accented letter, a curly apostrophe, a non-Latin script — they diverge, and the facet points at the wrong span.
The scale of the divergence is worth internalising. An accented Latin character is two UTF-8 bytes and one UTF-16 unit, so it shifts you by one. A CJK character is three bytes and one unit: a shift of two. A typical emoji is four bytes and two UTF-16 units: a shift of two. A composed emoji with skin-tone or ZWJ sequences can be far more. So a post that opens with a single rocket emoji shifts every byte offset after it relative to the string index, and the link either loses its first characters or extends past its end. Bluesky faithfully renders whatever the range covers, which is usually a fragment of a URL wrapped around a fragment of your sentence.
This is why the bug is so slippery. It works perfectly in every test you write with ASCII fixtures, and then breaks for the first user who starts a post with an emoji — which, on social media, is roughly the first user.
Why the obvious fix is still wrong
The obvious fix is to reach for a byte length somewhere. That is directionally right and still commonly wrong in two ways.
The first mistake is measuring the whole string instead of the prefix. What you want for byteStart is the byte length of everything before the match — the UTF-8 length of the slice from the beginning of the text up to the match index. In Node that is a byte length of text.slice(0, matchIndex). Not the length of the text, not the match index itself.
The second mistake is subtler and survives code review. Having computed byteStart correctly, it is tempting to write byteEnd as byteStart plus the URL’s string length. That works for every ASCII URL, which is nearly all of them, so it ships. It breaks on internationalised domain names and on any URL carrying non-ASCII characters in a path or query string, which happens more often than you would think once people paste links from search results. byteEnd must be byteStart plus the byte length of the URL itself.
Both mistakes have the same shape: partially converting to bytes and leaving one term in character space. If your facet-building function contains any bare .length on a string, look at it again.
A second detail: trailing punctuation
A URL at the end of a sentence usually has a full stop attached. A naive URL regex — something like https?://[^\s]+ — is greedy up to whitespace and swallows it. Now your facet claims the link includes the period, and the uri you attach includes it too, which produces a link that 404s.
Trim trailing sentence punctuation off the matched URL before computing the range: periods, commas, semicolons, colons, exclamation and question marks, closing brackets and closing parentheses. Trim them as a group, not one at a time, because a URL ending a parenthetical can pick up two at once. Note that this trimming has to happen before you compute byteEnd, since the range and the uri must describe the same string — a facet whose range covers one span while its uri holds another is a bug that only shows up visually.
Closing parentheses are the one judgement call, since some real URLs legitimately end in one. Trimming them is the right default: a broken link inside a parenthetical is a worse outcome than a slightly short link on a rare URL shape.
How to detect this in your own code
The test that catches all of it is a round trip. Do not assert on the numbers you computed — assert that slicing the text by the range you produced gives back exactly the URL you claimed.
- Encode the post text to a UTF-8 buffer, slice it from byteStart to byteEnd, decode it back to a string, and assert it equals the facet’s uri. This single assertion catches prefix errors, span errors and punctuation errors at once.
- Run that assertion over a fixture set that is deliberately hostile: a leading emoji, an accented word before the link, a CJK sentence, a curly apostrophe, two links in one post, a link at position zero, and a link that ends the sentence with a period.
- Add the same round-trip check as a cheap runtime assertion before you publish, and log loudly if it fails. It costs microseconds and it converts a silent visual bug into a log line.
- If you already have posts in the wild, you can audit them: fetch the records back, re-run the round trip against the stored text and facets, and count mismatches. Anything that fails was published broken.
What makes this worth the effort is the cost of shipping it. The API returns success. Your monitoring is green. The post looks fine in your own dashboard, because your dashboard renders your text, not Bluesky’s facets. The only place the failure is visible is on Bluesky itself, to your readers, on the posts that matter most — the ones with a link in them. If you are tracking clicks, the signal is a channel that reports impressions and zero clicks forever, which reads like a distribution problem rather than a bug.
Other things worth knowing before you build this
- There is no OAuth redirect flow to implement for the app-password path. You authenticate with a handle and an app password against com.atproto.server.createSession, and refresh with com.atproto.server.refreshSession.
- App passwords are created by the user in their Bluesky settings and are revocable independently of their real password. This is a genuinely nicer model than most OAuth integrations.
- Session tokens are short-lived — on the order of a couple of hours — so refresh eagerly rather than waiting for a 401 mid-publish.
- Distinguish 4xx from 5xx on refresh. A 4xx means the grant is gone and the user must reconnect; a 5xx means the server blipped and you should retry. Collapsing both into one error class means a revoked app password gets retried forever while a routine server hiccup permanently disconnects a working account. We shipped that conflation once and it produced both failure modes.
- Store the DID, not the handle, as your account identifier. Handles are mutable; DIDs are the stable identity, and every mention facet is keyed by DID for exactly this reason.
- The 300-character limit is counted in graphemes, not bytes and not UTF-16 units. A family emoji is one grapheme and a great many bytes. Three different length definitions are in play in the same API — bytes for facets, graphemes for the limit, UTF-16 for your language’s string type.
- Images are uploaded as blobs first via com.atproto.repo.uploadBlob with the file’s real Content-Type, then referenced from the record’s embed.
- An embed is one of images, video or external — never a mix, and video uses its own $type with a single blob rather than an array. Building an images array regardless of media type gets a video blob rejected with a type error naming the mismatch, which is at least an honest failure.
- The response returns an at:// URI. The public web URL is built from the DID and the last path segment, the rkey.
- Profiles come back with displayName as an empty string, not null, for accounts that never set one — so a nullish-coalescing fallback silently does nothing and you render a blank name.
Why we cared enough to get this exactly right
seenpaid attributes revenue to individual posts, and it does that by tracking the link inside each post. On Bluesky, a link without a facet is an unclickable string — which means no clicks, no attribution, and a feature that silently does nothing while reporting success at every layer.
So this was not a cosmetic bug. It was the difference between Bluesky being a supported channel and being a channel where the core product quietly failed. That is broadly the story of every platform integration: LinkedIn retires API versions on a date with no warning, Meta’s own setup guide names a permission that does not exist, and Bluesky will happily accept a post whose link does nothing at all.