Skip to content
postpeg

· 6 min read · postpeg team

Publishing to Bluesky from code: facets, links and mentions explained

Why links and @mentions in Bluesky posts aren't clickable without facets, how UTF-8 byte ranges work, and how to build facets correctly in TypeScript.

If you post to Bluesky from code and your links come out as plain, unclickable text, nothing is broken: Bluesky doesn't parse the text for you. Links, mentions and hashtags are stored as separate annotations called facets, each pointing at a range of UTF-8 bytes in the post text. Get the ranges right and everything lights up. Get them wrong, usually by using JavaScript string indices, and the highlight lands in the wrong place.

Bluesky posts don't use a markup language like HTML or Markdown. The Links, mentions, and rich text guide explains that rich text is handled with facets that point at locations in the text instead. The post record carries a plain text string plus an optional facets array, and apps render the decorations from that array.

So when your code sends "text": "Read this: https://example.com" with no facets, the record is valid, but there is nothing telling any app that those characters are a link. The guide puts the job on the client: parse the text and produce the facets yourself before publishing.

(Bluesky's developer docs used to live at docs.bsky.app. Those URLs now redirect to bsky.network, which is where the links in this post point.)

What a facet looks like

The facet schema is defined in the app.bsky.richtext.facet lexicon. Each facet has two parts:

  • index: a byteStart and byteEnd, zero-indexed, counting bytes of the UTF-8 encoded text. The start is inclusive and the end is exclusive, so byteEnd - byteStart is the length of the range in bytes.
  • features: an array of one or more decorations for that range.

There are three feature types:

Feature $typeFieldWhat goes in it
app.bsky.richtext.facet#linkuriThe complete URL
app.bsky.richtext.facet#mentiondidThe mentioned account's DID, not its handle
app.bsky.richtext.facet#tagtagThe hashtag text without the leading # (max 64 graphemes)

A minimal post with one link facet:

json
{
  "$type": "app.bsky.feed.post",
  "text": "Go to this site",
  "createdAt": "2026-09-24T09:00:00.000Z",
  "facets": [
    {
      "index": { "byteStart": 6, "byteEnd": 15 },
      "features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com" }]
    }
  ]
}

Two details worth knowing. The rich text guide says facets can't overlap, and recommends that renderers discard overlapping ones. And the lexicon notes that the visible link text may be shortened or truncated while the uri stays complete, so "this site" in the example above is a perfectly valid link label.

Why UTF-8 bytes, not JavaScript string indices

Strings in the AT Protocol are UTF-8, and facet ranges are UTF-8 byte offsets. JavaScript strings are not: String.length counts UTF-16 code units, and indexOf and slice work in the same units. For plain ASCII the two happen to match, which is why naive code passes a quick test and then fails the first time someone adds an emoji or an accented letter.

Bluesky's guide is blunt about this: in TypeScript or JavaScript you can't use .slice() or other native string methods to compute facet offsets. The fix is to encode the text before the position with TextEncoder, which always produces UTF-8, and count the bytes:

ts
const encoder = new TextEncoder();

// Convert a JS string index (UTF-16 code units) to a UTF-8 byte offset.
function utf8Offset(text: string, jsIndex: number): number {
  return encoder.encode(text.slice(0, jsIndex)).length;
}

function linkFacet(text: string, url: string) {
  const start = text.indexOf(url);
  if (start === -1) throw new Error('URL not found in text');
  return {
    index: {
      byteStart: utf8Offset(text, start),
      byteEnd: utf8Offset(text, start + url.length),
    },
    features: [{ $type: 'app.bsky.richtext.facet#link', uri: url }],
  };
}

const text = '🦋 New guide: https://example.com/facets';
const facet = linkFacet(text, 'https://example.com/facets');
// facet.index => { byteStart: 16, byteEnd: 42 }

Walk through the emoji. The butterfly is one character on screen, two UTF-16 code units in JavaScript, and four bytes in UTF-8. So text.indexOf('https') returns 14, but the URL actually starts at byte 16. If you sent 14 as byteStart, the highlighted range would start two bytes early (at the colon) and stop two bytes short, cutting the end of the URL off the link. The bigger the emoji (flags and family emoji are built from several code points), the further off it drifts.

Note that slice is fine inside utf8Offset, because there you're using it to cut the JavaScript string at a JavaScript index. What you must never do is treat the result of indexOf as the byte offset itself.

Mentions: resolve the handle to a DID first

A mention facet covers the visible @handle text, but the facet itself must hold a DID. Handles can change, while DIDs are the stable account IDs, as the Resolving Identities guide describes. To get the DID, call com.atproto.identity.resolveHandle with the handle (without the @):

http
GET https://bsky.social/xrpc/com.atproto.identity.resolveHandle?handle=atproto.com

It returns {"did": "did:plc:..."}, or a HandleNotFound error if the handle doesn't resolve. The example parser in the rich text guide simply skips handles that fail to resolve, leaving them as plain text rather than inventing a mention. That's a sensible default: a mention with a wrong DID notifies the wrong account, or no one.

Hashtags need no lookup. Put the tag text in tag without the #, while the byte range covers the # in the visible text.

The easy path: the RichText helper

You don't have to write any of this by hand. The @atproto/api README ships a RichText class and strongly recommends it, precisely because converting between UTF-16 and UTF-8 is easy to get wrong:

ts
import { Agent, CredentialSession, RichText } from '@atproto/api';

const session = new CredentialSession(new URL('https://bsky.social'));
await session.login({ identifier: 'you.bsky.social', password: process.env.BSKY_APP_PASSWORD! });
const agent = new Agent(session);

const rt = new RichText({ text: 'Hello @atproto.com, see https://example.com #atproto' });
await rt.detectFacets(agent); // finds links, mentions and tags, and resolves handles to DIDs

await agent.post({ text: rt.text, facets: rt.facets });

detectFacets(agent) detects the facets and calls resolveHandle for each mention. There's also detectFacetsWithoutResolution(), which skips the network calls but, in the library's own words, produces mentions without DIDs, so only use it for previews.

One thing to be aware of: the sources don't quite agree on which package to reach for. The rich text guide points to the RichText helper in @bsky/sdk, and the @atproto/api README recommends its newer lex SDK for new projects. @atproto/api still documents and exports RichText with detectFacets, so it works today, but check the current SDK list at atproto.com/sdks before you commit a new codebase to either.

Watch the 300-grapheme limit

The app.bsky.feed.post lexicon caps text at 300 graphemes and 3,000 UTF-8 bytes. Graphemes are what a person sees as one character, so one emoji counts once, however many code points it's built from (the Lexicon spec defines how both limits are counted). JavaScript's .length is the wrong measure here too: RichText exposes a graphemeLength property for this, or you can use Intl.Segmenter.

Facets don't add to the count, but the visible URL text does. A long tracking URL can eat a third of your post. Since the lexicon allows the visible text to be shortened while the facet keeps the full uri, you can show a trimmed label such as example.com/facets… and link the full address.

A link facet makes text clickable. It does not create the preview card with a title and thumbnail. That card is an embed of type app.bsky.embed.external, stored in the post's embed field with its own uri, title, description and optional thumb image blob.

The Posts in-depth guide explains that the client fetches the page's metadata (typically the og:title, og:description and og:image tags), uploads the image as a blob, and writes the card into the record. The network doesn't generate it for you. A post can have a link facet, a card, or both, and a card uses the post's single embed slot, so it can't sit alongside an images embed.

App passwords or OAuth?

For quick scripts, the examples above log in with a handle and an app password. Bluesky's OAuth page draws the line clearly: applications with their own end-user login flow should implement OAuth, while single-purpose tools like bots or command-line scripts may use app passwords. If you're building something other people sign in to, plan for OAuth, and use an SDK for it: the atproto flow involves DPoP, PKCE and PAR, which you don't want to hand-roll.

Where postpeg fits

postpeg publishes to Bluesky over the AT Protocol and builds facets for you: links, #hashtags (a purely numeric tag like #1 is left as text) and @mentions, with each handle resolved to its DID and every range computed in UTF-8 bytes. Your text is also checked against Bluesky's 300-grapheme limit before anything is sent. Accounts connect with a handle and an app password; see /docs/profiles-and-accounts for the flow and the Bluesky API page for what's supported, or check a draft with the character counter.

Publishing from your own product?

postpeg is one API for posting and scheduling on ten networks, with each network’s rules checked before anything is sent. The 7-day trial needs no card.