Skip to content
Owais Khan Software Reviews

Bluesky Rich Text Facet Calculator

Type a post to get the facets Bluesky needs — mentions, links and hashtags with the UTF-8 byte offsets the AT Protocol requires — and a complete app.bsky.feed.post record you can send as is.

Everything runs locally: what you type never leaves your browser.

Calculate Bluesky facets

70 / 300 graphemes · 71 UTF-16 units · 74 UTF-8 bytes

Preview

👋 Hi @alice.bsky.social! Café notes: example.com and the docs #atproto

Facets
Type Text byteStart byteEnd
mention @alice.bsky.social 8 26
link example.com 41 52
link the docs 57 65
tag #atproto 66 74

Why the offsets are in bytes

In the example, the mention starts at byte 8, but at position 6 in a JavaScript string. The difference is the wave emoji: one character on screen, two UTF-16 code units, four UTF-8 bytes. The AT Protocol defines facet ranges in UTF-8 bytes of the post text — byteStart inclusive, byteEnd exclusive — so a client that uses string indices highlights the wrong characters as soon as a post contains an emoji or an accented letter.

AT Protocol rich text byte offsets, step by step

To compute AT Protocol rich text byte offsets by hand: find the facet in the text, encode everything before it as UTF-8 and count the bytes — that is byteStart. Add the UTF-8 length of the facet text for byteEnd. This calculator does exactly that for every facet, and the table lets you check each range against the highlighted preview.

A Bluesky post JSON payload generator

The record on the right is a complete Bluesky post JSON payload: pass it as the record of a com.atproto.repo.createRecord call with collection app.bsky.feed.post. Replace the placeholder DID in any mention first, and the timestamp updates to the current time as soon as you edit the text.

Where these rules come from

The facet structure, the byte-offset definition and the mention, link and tag feature types are from the AT Protocol lexicon app.bsky.richtext.facet; the post record is app.bsky.feed.post. Mention, link and tag detection follows the patterns Bluesky’s own clients use.

Frequently asked questions

Why do Bluesky facets use UTF-8 byte offsets instead of character counts?
Because the AT Protocol is language-neutral, and UTF-8 bytes are the one measure every language agrees on. A JavaScript string counts UTF-16 code units, Python counts code points and a user sees graphemes — three different numbers for the same text once it contains an emoji or an accent. Facet indexes are defined as byteStart (inclusive) and byteEnd (exclusive) into the UTF-8 encoding of the post text, so every client highlights the same characters.
How do emojis affect byte offset calculation in a Bluesky post?
Every emoji is at least 4 UTF-8 bytes, and many are much more: a family emoji is 1 grapheme but 8 UTF-16 units and 18 bytes. So each emoji before a mention or link pushes that facet’s byte offset further ahead of its string index. If you compute offsets with string indices, the first emoji in a post shifts every facet after it and the highlight lands on the wrong characters — the most common facet bug.
Can I convert Markdown links and hashtags into Bluesky facets?
Yes. A Markdown link like [the docs](https://docs.bsky.app) becomes the plain text “the docs” with a link facet pointing at the URL, which is how Bluesky shows a link with custom text. Hashtags become tag facets without the #, bare domains like example.com become https links, and @handles become mention facets.
Why does the mention facet show a placeholder DID?
A mention facet must contain the account’s DID, not its handle, and turning a handle into a DID needs a lookup on Bluesky’s servers. This page makes no network requests, so it shows the exact com.atproto.identity.resolveHandle URL to call instead; paste the DID it returns in place of the placeholder before posting.
How long can a Bluesky post be?
300 characters, counted as graphemes — what a reader perceives as one character, so an emoji counts once however many bytes it takes. The counter above shows graphemes alongside UTF-16 units and bytes so you can see where they diverge.
Is my post text sent anywhere?
No. The calculator runs entirely as a small script inside this page; what you type never leaves your browser, and there is no upload, no API call and no analytics here. That is enforced rather than promised: this site’s test suite scans the shipped HTML for every browser API capable of sending data off the page and fails the build if it finds one.