Free Developer Tool

Bluesky Facet Generator

Generate Bluesky facets for any post: clickable links, @mentions and hashtags with the right UTF-8 byteStart and byteEnd, mentions resolved to DIDs, and the full record JSON ready to send.

What are Bluesky facets?

Bluesky stores links, mentions and hashtags as facets: byte ranges in UTF-8 plus a target. Without facets a URL shows as plain text. Paste your post to get the record JSON with correct offsets.

Up to 3, comma separated
83 / 300
Graphemes (Bluesky limit)
86 / 3,000
UTF-8 bytes (Bluesky limit)
84
JavaScript length (UTF-16)
3
Facets detected

Resolving @bsky.app to a DID...

Preview with facet ranges

LinkMentionTag
Shipped v2 🚀 notes at https://example.com/changelog thanks @bsky.app #buildinpublic

Facets

byteStart is inclusive, byteEnd is exclusive. The JS index column is what text.indexOf would give you, and why naive code puts the link in the wrong place.

TypebyteStartbyteEndJS indexValue
Link255423 to 52off by 2app.bsky.richtext.facet#linkhttps://example.com/changelog
Mention627160 to 69off by 2app.bsky.richtext.facet#mention@bsky.appresolving...
Tag728670 to 84off by 2app.bsky.richtext.facet#tagtag: buildinpublic

Record JSON (app.bsky.feed.post)

Send this as the record in com.atproto.repo.createRecord. createdAt is the time you opened this page.

post-record.json
{
  "$type": "app.bsky.feed.post",
  "text": "Shipped v2 🚀 notes at https://example.com/changelog thanks @bsky.app #buildinpublic",
  "facets": [
    {
      "$type": "app.bsky.richtext.facet",
      "index": {
        "byteStart": 25,
        "byteEnd": 54
      },
      "features": [
        {
          "$type": "app.bsky.richtext.facet#link",
          "uri": "https://example.com/changelog"
        }
      ]
    },
    {
      "$type": "app.bsky.richtext.facet",
      "index": {
        "byteStart": 72,
        "byteEnd": 86
      },
      "features": [
        {
          "$type": "app.bsky.richtext.facet#tag",
          "tag": "buildinpublic"
        }
      ]
    }
  ],
  "createdAt": "2026-10-10T09:00:00.000Z",
  "langs": [
    "en"
  ]
}

Why facet offsets are bytes, and why one emoji breaks naive code

The facet lexicon (app.bsky.richtext.facet) defines byteStart and byteEnd as zero-indexed positions in the UTF-8 encoded text, start inclusive and end exclusive. Its own description warns that languages like JavaScript index strings differently, so you have to convert before building facets. Most broken facet code skips that step.

Take the sample post. The link starts at JavaScript index 23, but at byte 25. The difference is the rocket emoji before it: 2 UTF-16 code units in JavaScript, 1 code point in Python, 4 bytes in UTF-8. Code that uses text.indexOf(url) as byteStart puts the clickable range 2 bytes early: it starts on the "t " before the URL and stops 2 characters short of its end. Plain ASCII text hides the bug, because there every character is 1 byte.

CharacterJavaScript length (UTF-16)Python len (code points)UTF-8 bytes (facets)Bluesky graphemes
a1111
é1121
日1131
🚀2141
👨‍👩‍👧‍👦117251

The 300 limit is counted in graphemes and the 3,000 limit in bytes, which is why the counters above show both. Check a draft against them with the Bluesky Character Counter.

The three facet features

Link

#link with a uri. The lexicon allows the visible text to be shortened, but the uri must be the complete URL.

Mention

#mention with a did, not a handle. Resolve the handle first with com.atproto.identity.resolveHandle.

Tag

#tag with a tag stored without the #, up to 64 graphemes. The range in the text covers the # too.

Facets are not link cards

A link facet makes the URL clickable. The preview card is a separate app.bsky.embed.external embed. If your automation posts show links with no card, that is a different fix, explained in why Make.com links are not clickable on Bluesky.

Build facets in code

In TypeScript, RichText from @atproto/api does detection, byte math and handle resolution in one call. This page uses the same detection patterns as RichText.

post.ts
// npm install @atproto/api
import { AtpAgent, RichText } from '@atproto/api'

const agent = new AtpAgent({ service: 'https://bsky.social' })
await agent.login({
  identifier: 'yourname.bsky.social',
  password: process.env.BLUESKY_APP_PASSWORD!,
})

const rt = new RichText({
  text: 'Shipped v2 🚀 notes at https://example.com/changelog thanks @bsky.app #buildinpublic',
})
// Finds links, mentions and tags, computes UTF-8 offsets, resolves each handle to a DID
await rt.detectFacets(agent)

// A handle that did not resolve keeps an empty did. Drop it so the record stays valid.
const facets = rt.facets?.filter((f) =>
  f.features.every((ft) => ft.$type !== 'app.bsky.richtext.facet#mention' || (ft as { did: string }).did)
)

await agent.post({
  text: rt.text,
  facets,
  langs: ['en'],
  createdAt: new Date().toISOString(),
})

In Python, run the regexes on the UTF-8 bytes rather than the string, and the match positions are the offsets. This version is simpler than RichText: it does not validate domains or skip number-only tags. The atproto SDK's TextBuilder is the other option, covered in how to post to Bluesky with Python.

post.py
# pip install requests
import os
import re
from datetime import datetime, timezone

import requests


def resolve(handle):
    r = requests.get(
        "https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle",
        params={"handle": handle},
    )
    return r.json().get("did") if r.ok else None


def build_facets(text):
    data = text.encode("utf-8")  # offsets are positions in these bytes
    facets = []
    for m in re.finditer(rb"https?://\S+", data):
        uri = m.group(0).rstrip(b".,;:!?")
        facets.append({
            "index": {"byteStart": m.start(), "byteEnd": m.start() + len(uri)},
            "features": [{"$type": "app.bsky.richtext.facet#link", "uri": uri.decode()}],
        })
    for m in re.finditer(rb"(?:^|\s)(@([a-zA-Z0-9.-]+))", data):
        handle = m.group(2).rstrip(b".")
        did = resolve(handle.decode())
        if did:
            facets.append({
                "index": {"byteStart": m.start(1), "byteEnd": m.start(1) + 1 + len(handle)},
                "features": [{"$type": "app.bsky.richtext.facet#mention", "did": did}],
            })
    for m in re.finditer(rb"(?:^|\s)(#[^\s#]+)", data):
        tag = m.group(1).rstrip(b".,;:!?")
        if len(tag) > 1:
            facets.append({
                "index": {"byteStart": m.start(1), "byteEnd": m.start(1) + len(tag)},
                "features": [{"$type": "app.bsky.richtext.facet#tag", "tag": tag[1:].decode()}],
            })
    return sorted(facets, key=lambda f: f["index"]["byteStart"])


text = "Shipped v2 🚀 notes at https://example.com/changelog thanks @bsky.app #buildinpublic"
session = requests.post(
    "https://bsky.social/xrpc/com.atproto.server.createSession",
    json={"identifier": "yourname.bsky.social", "password": os.environ["BLUESKY_APP_PASSWORD"]},
).json()
record = {
    "$type": "app.bsky.feed.post",
    "text": text,
    "facets": build_facets(text),
    "createdAt": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
    "langs": ["en"],
}
r = requests.post(
    "https://bsky.social/xrpc/com.atproto.repo.createRecord",
    headers={"Authorization": f"Bearer {session['accessJwt']}"},
    json={"repo": session["did"], "collection": "app.bsky.feed.post", "record": record},
)
print(r.status_code, r.json())

Building a bot around this? The Bluesky bot guide covers login, sessions and scheduling.

Or skip facets: send plain text to OpenTweet

OpenTweet's Bluesky adapter runs RichText.detectFacets on every post, so links, @mentions and hashtags in plain text arrive clickable. Connect Bluesky once over atproto OAuth, then post from the REST API, the MCP server or the composer. Add "x" or "linkedin" to platforms to send the same post there too.

bash
curl -X POST https://opentweet.io/api/v1/posts \
  -H "Authorization: Bearer $OPENTWEET_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Shipped v2 🚀 notes at https://example.com/changelog thanks @bsky.app #buildinpublic",
    "platforms": ["bluesky"],
    "publish_now": true
  }'

A mention that does not resolve is posted as plain text rather than failing the post. OpenTweet does not attach link cards.

7-day free trial, then from $11.99/mo. Bluesky, X and LinkedIn on every plan.

Bluesky facets FAQ

What are Bluesky facets?

Facets are how a Bluesky post marks up rich text. Each facet in the post record has an index (byteStart and byteEnd, counted in UTF-8 bytes of the text) and a feature: a link with a uri, a mention with a did, or a tag. The app draws those byte ranges as clickable. Text without a facet is plain text.

Why is my Bluesky link not clickable when I post through the API?

Because the record has no link facet. Bluesky does not scan the text for URLs when it shows a post. If your code, Make scenario or bot sends only the text field, every URL arrives as dead text. Add a facet with the byte range of the URL and the full URL as uri, or build the text with an SDK helper that does it for you.

Why are byteStart and byteEnd counted in bytes?

The lexicon defines the index as zero-indexed UTF-8 byte offsets, start inclusive and end exclusive, so every language computes the same numbers. JavaScript string indexes are UTF-16 code units and Python len counts code points, so both drift from the byte count as soon as an emoji or accented letter appears before the link.

How do I make an @mention clickable on Bluesky?

Resolve the handle to a DID first, with com.atproto.identity.resolveHandle, then add a mention facet whose did is that DID and whose byte range covers the @handle in the text. The did field has format did in the lexicon, so a handle there is not valid. This tool resolves handles through Bluesky’s public API and leaves out any that do not resolve.

Do hashtags need a facet on Bluesky?

Yes. A hashtag is a tag facet over the #word in the text. The tag value is stored without the # and the lexicon caps it at 64 graphemes. Without the facet the hashtag is plain text and does not link to the tag feed.

Does a link facet give my post a link card?

No. A facet only makes text clickable. The preview card with title, description and thumbnail is a separate app.bsky.embed.external embed on the post, which the sender has to build. A post can have a working link facet and no card.

Is my post text sent anywhere?

No. Links, tags and byte offsets are computed in your browser. Only the @handles you type are sent to Bluesky’s public API (public.api.bsky.app) to look up their DIDs. Nothing is stored and nothing is posted.

Does OpenTweet build Bluesky facets for me?

Yes. Send plain text to the OpenTweet API, MCP server or composer and the Bluesky adapter runs RichText.detectFacets from @atproto/api, so links, @mentions and hashtags arrive clickable. A mention whose handle does not resolve is left as plain text so the post still goes out. OpenTweet does not attach link cards.