TL;DR, Kurze Antwort
9 Min. LesezeitEin Facet ist ein Eintrag im facets-Array eines app.bsky.feed.post-Records. Es hat einen index, der ein byteSlice aus byteStart und byteEnd ist, und ein features-Array mit einer Link-uri, einer Mention-did oder einem tag-String. Die Offsets zählen Bytes des UTF-8-kodierten Textes, Start inklusive, Ende exklusive. Das Lexicon selbst warnt, dass Sprachen wie JavaScript Strings nach UTF-16 indizieren. Sobald dein Text einen Akzent oder ein Emoji enthält, liefert indexOf also die falsche Zahl, und der daraus entstehende Post ist trotzdem schemakonform.
Was sind Bluesky facets, und was messen byteStart und byteEnd?
Bluesky speichert Post-Text als einfachen String ohne Markup und ohne automatische Link-Erkennung, deshalb sind Bluesky facets byteStart byteEnd die einzige Angabe, die einem Client verrät, welcher Abschnitt dieses Strings ein Link, eine Erwähnung oder ein Hashtag ist. Das Lexicon app.bsky.richtext.facet beschreibt ein Facet als „Annotation of a sub-string within rich text.“ Zwei Felder sind bei jedem Facet Pflicht: index, ein #byteSlice, und features, ein Array von Annotationen für genau diesen Abschnitt.
So sieht die Struktur aus, direkt aus der Lexicon-Definition:
{
"index": { "byteStart": 17, "byteEnd": 36 },
"features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com" }]
}Die Definition von byteSlice legt Zählregel und Grenzregel in einem Satz fest: „Start index is inclusive, end index is exclusive. Indices are zero-indexed, counting bytes of the UTF-8 encoded text.“
| Feld | Typ | Constraint im Lexicon | Bedeutung |
|---|---|---|---|
index.byteStart | integer | minimum: 0 | Erstes Byte des annotierten Bereichs, eingeschlossen |
index.byteEnd | integer | minimum: 0 | Byte nach dem letzten annotierten Byte, ausgeschlossen |
features | array | Union aus #mention, #link, #tag | Eine oder mehrere Annotationen für diesen Byte-Bereich |
Lies die Constraint-Spalte noch einmal. minimum: 0 ist die einzige numerische Regel für beide Offsets. Es gibt keine Obergrenze, die an die Länge deines Textes gekoppelt wäre, keine Regel, dass byteEnd größer als byteStart sein muss, und keine Regel, dass der Bereich auf einer Zeichengrenze enden muss. Genau deshalb erzeugen falsche Offsets keinen Fehler.
Warum wird mein Bluesky-Link als reiner Text gepostet?
Ein Link erscheint als reiner Text, weil der Post-Record kein Facet enthält, dessen Byte-Bereich diese URL abdeckt. Die URL im Feld text bewirkt für sich genommen nichts. Bluesky-Clients durchsuchen den Post-Text nicht nach http://, sie lesen das facets-Array und annotieren exakt die Byte-Bereiche, die dort stehen.
Der Fehler bleibt in beide Richtungen still. Lässt du das Facet weg, bekommst du reinen Text. Schickst du ein Facet mit Offsets, die um drei danebenliegen, verlinkt der Client brav genau die Bytes, auf die du gezeigt hast, also womöglich das Ende des vorherigen Wortes plus den größten Teil der URL. In beiden Fällen liefert com.atproto.repo.createRecord eine normale Erfolgsantwort, denn ein Record mit falschen Offsets ist immer noch ein schemakonformer Record.
Das Lexicon lässt mehrere Verhaltensweisen offen, und diese Lücke sollte man klar benennen statt zu raten. app.bsky.richtext.facet legt nicht fest, was ein Renderer tun muss, wenn ein Byte-Bereich über das Textende hinausläuft, wenn sich zwei Facets überlappen oder wenn ein Bereich ein Mehrbyte-Zeichen zerschneidet. Es setzt auch keine maxLength für das facets-Array in app.bsky.feed.post, während das benachbarte tags-Array im selben Record ausdrücklich auf maxLength: 8 begrenzt ist. Das Limit für tags ist dokumentiert, das Limit für facets, falls es in der Praxis eines gibt, nicht.

Warum liefert JavaScript den falschen byteStart?
JavaScript liefert den falschen byteStart, weil String.prototype.indexOf und String.prototype.length UTF-16-Code-Units zählen, während Facets UTF-8-Bytes zählen. Das Lexicon sagt es in einer Notiz, die kaum jemand liest, weil sie in einer JSON-Datei steht und nicht in einem Tutorial:
NOTE: some languages, like Javascript, use UTF-16 or Unicode codepoints for string slice indexing; in these languages, convert to byte arrays before working with facets.
Bei reinem ASCII-Text stimmen beide Systeme überein, und genau deshalb geht dieser Bug in Produktion. Du testest mit Opening hours https://example.com, jeder Offset passt, und der Code geht live. Dann enthält ein Post ein Wort mit Akzent oder ein Emoji, und die Offsets verrutschen.
Nimm diesen String:
Café 🥐 hours https://example.com
Drei Zählsysteme sind sich darüber uneinig:
| Zähleinheit | Wert für diesen String | Wo Bluesky sie verwendet |
|---|---|---|
| Grapheme | 32 | maxGraphemes: 300 für text in app.bsky.feed.post |
| UTF-16-Code-Units | 33 | Nirgends. Das ist der Wert, den text.length in JavaScript liefert |
| UTF-8-Bytes | 36 | byteStart und byteEnd, dazu maxLength: 3000 für text |
Die Abweichung geht auf zwei Zeichen zurück. é ist eine UTF-16-Code-Unit und zwei UTF-8-Bytes. 🥐 ist ein Surrogatpaar, also zwei UTF-16-Code-Units und vier UTF-8-Bytes. Bis zur URL sind die beiden Indexsysteme drei Stellen auseinander:
| Präfix | UTF-16-Code-Units | UTF-8-Bytes |
|---|---|---|
Café | 5 | 6 |
Café 🥐 | 8 | 11 |
Café 🥐 hours | 14 | 17 |
text.indexOf('https://example.com') liefert also 14, der korrekte byteStart ist aber 17. Nimmst du die JavaScript-Zahl, annotiert Bluesky die Bytes 14 bis 32, die zu rs https://example. dekodieren. Dem Wort „hours“ fehlen dann die letzten beiden Buchstaben, das abschließende com fällt aus dem Link heraus, und niemand bekommt einen Fehler.
AdaptlyPost
7-Tage-Testversion starten
Plattformübergreifende Analysen
Sozialer Posteingang
KI-gestützter Assistent
Wie berechne ich byteStart und byteEnd korrekt?
Kodiere den Text mit TextEncoder zu UTF-8-Bytes und leite beide Offsets aus den Längen der kodierten Daten ab, nicht aus String-Indizes. Die Kodierung des Präfixes vor einem Treffer ergibt byteStart, die Kodierung des Treffers selbst ergibt die Länge, die du für byteEnd addierst.
Das läuft unverändert unter Node 18 oder neuer und in jedem modernen Browser:
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function detectFacets(text) {
const bytes = encoder.encode(text);
const facets = [];
const add = (match, feature) => {
const byteStart = encoder.encode(text.slice(0, match.index)).length;
const byteEnd = byteStart + encoder.encode(match[0]).length;
if (decoder.decode(bytes.slice(byteStart, byteEnd)) !== match[0]) {
throw new Error(`byte range ${byteStart}..${byteEnd} does not cover "${match[0]}"`);
}
facets.push({ index: { byteStart, byteEnd }, features: [feature] });
};
for (const m of text.matchAll(/https?:\/\/[^\s]+/g)) {
add(m, { $type: 'app.bsky.richtext.facet#link', uri: m[0] });
}
for (const m of text.matchAll(/#([A-Za-z0-9_]+)/g)) {
add(m, { $type: 'app.bsky.richtext.facet#tag', tag: m[1] });
}
return facets.sort((a, b) => a.index.byteStart - b.index.byteStart);
}
console.log(JSON.stringify(detectFacets('Café 🥐 hours https://example.com #bakery'), null, 2));Ausgabe:
[
{
"index": { "byteStart": 17, "byteEnd": 36 },
"features": [{ "$type": "app.bsky.richtext.facet#link", "uri": "https://example.com" }]
},
{
"index": { "byteStart": 37, "byteEnd": 44 },
"features": [{ "$type": "app.bsky.richtext.facet#tag", "tag": "bakery" }]
}
]Die Guard Clause in add ist der Teil, den du behalten solltest. Sie schneidet den Bereich aus dem kodierten Byte-Array heraus, dekodiert ihn und vergleicht ihn mit dem gefundenen Text. Da das Protokoll dir nie sagen wird, dass der Bereich falsch war, ist diese Prüfung das einzige Fehlersignal, das du bekommst, und sie kostet einen Vergleich pro Facet.
Zwei Details in diesem Snippet stammen aus dem Lexicon, nicht aus Geschmacksfragen. Das tag-Feature trägt bakery, nicht #bakery, weil das Lexicon sagt, die Referenz „should not“ das Präfix enthalten. Und features ist ein Array, ein einzelner Byte-Bereich kann also mehr als eine Annotation tragen.
Was steht im features-Array?
Jeder Eintrag in features ist eines von drei Union-Mitgliedern aus derselben Lexicon-Datei, unterschieden durch seinen $type-String.
| Feature | $type | Pflichtfeld | Constraint | Formulierung im Lexicon |
|---|---|---|---|---|
| Mention | app.bsky.richtext.facet#mention | did | format: did | „The text is usually a handle, including a '@' prefix, but the facet reference is a DID.“ |
| Link | app.bsky.richtext.facet#link | uri | format: uri | „The text URL may have been simplified or truncated, but the facet reference should be a complete URL.“ |
| Tag | app.bsky.richtext.facet#tag | tag | maxLength: 640, maxGraphemes: 64 | „The text usually includes a '#' prefix, but the facet reference should not (except in the case of 'double hash tags').“ |
Mentions brauchen einen zusätzlichen Schritt. Der sichtbare Text ist ein Handle wie @example.com, das Facet speichert aber eine DID. Du löst das Handle also zuerst mit der Query com.atproto.identity.resolveHandle auf, die einen Parameter handle nimmt und eine did zurückgibt. Ihr einziger dokumentierter Fehler ist HandleNotFound, beschrieben als „The resolution process confirmed that the handle does not resolve to any DID.“ Dass eine DID statt eines Handles gespeichert wird, sorgt dafür, dass eine alte Erwähnung auch nach einer Umbenennung des Kontos noch auf das richtige Konto zeigt.
Die Formulierung zum Link ist für den Anzeigetext wichtig. Weil das Facet die vollständige URL hält, während der Text gekürzt sein darf, kannst du im Post example.com/very-long... anzeigen und die Leute trotzdem zur vollen Adresse schicken, solange der Byte-Bereich den gekürzten Text abdeckt, den du tatsächlich geschrieben hast.
Warum verwenden ältere Code-Beispiele stattdessen start und end?
Ältere Beispiele verwenden start und end, weil der Post-Record früher ein anderes Feld mit anderen Zählregeln trug und beide Versionen bis heute in den Lexicon-Dateien stehen. app.bsky.feed.post definiert ein entities-Array, markiert mit „DEPRECATED: replaced by app.bsky.richtext.facet.“ Sein Index-Typ ist #textSlice, dessen eigene Beschreibung lautet: „Deprecated. Use app.bsky.richtext instead -- A text segment. Start is inclusive, end is exclusive. Indices are for utf16-encoded strings.“
Das Protokoll hat also einen UTF-16-Slice-Typ ausgeliefert, ihn durch einen UTF-8-Typ ersetzt und beide Definitionen im Schema belassen. Jedes Snippet, das { start, end } baut, zielt auf das veraltete Feld, und wer dessen Index-Arithmetik in ein byteSlice kopiert, reproduziert genau den Bug, vor dem die neuere NOTE warnt. Prüfe die Feldnamen, bevor du den Offsets vertraust: byteStart und byteEnd bedeuten Bytes, start und end bedeuteten Code-Units.

Wo passt ein Planungstool hier hinein?
Ein Scheduler, der auf Bluesky veröffentlicht, muss das facets-Array für dich bauen, denn der Record, den er schreibt, ist derselbe app.bsky.feed.post-Record, den du von Hand schreiben würdest. AdaptlyPost plant Bluesky-Posts zusammen mit den anderen Netzwerken, auf denen es veröffentlicht, sodass die Byte-Arithmetik einmal in der Publishing-Schicht passiert statt in jedem Skript, das du schreibst. Derselbe Post kann an mehrere Konten und Netzwerke in einer Aktion gehen, und die Bluesky-Interaktionen landen danach in den Bluesky Analytics.
Am Protokoll ändert das nichts. Das Limit von 300 Graphemen, das Limit von 3.000 Bytes und die UTF-8-Offsets gehören zu Bluesky und gelten gleichermaßen, ob nun ein Tool den Record schreibt oder du selbst. Was ein Tool wegnimmt, ist die Chance, ausgerechnet bei dem einen Post mit Emoji einen um drei verschobenen byteStart auszuliefern.
Häufig gestellte Fragen
Brauche ich ein Facet, wenn die vollständige URL schon im Post-Text steht?
Ja. Bluesky parst Links überhaupt nicht aus dem Post-Text, eine ausgeschriebene URL erscheint also als nicht klickbarer reiner Text, solange kein Facet mit einem #link-Feature ihren Byte-Bereich abdeckt. Das ist die häufigste Überraschung für alle, die von einer API kommen, die automatisch verlinkt.
Ist byteEnd der Index des letzten Bytes oder des Bytes danach?
Des Bytes danach. Das Lexicon sagt „Start index is inclusive, end index is exclusive,“ was bedeutet: byteEnd minus byteStart ergibt exakt die Byte-Länge des annotierten Textes. Für https://example.com, neunzehn ASCII-Zeichen, ist die Differenz 19.
AdaptlyPost
7-Tage-Testversion starten
Plattformübergreifende Analysen
Sozialer Posteingang
KI-gestützter Assistent
Was passiert, wenn byteStart und byteEnd falsch sind?
Der Record wird akzeptiert. Die einzige numerische Beschränkung, die das Lexicon für eines der beiden Felder setzt, ist minimum: 0. Offsets, die auf die falschen Bytes oder über das Textende hinaus zeigen, validieren also trotzdem. Du merkst es erst am gerenderten Post, und genau deshalb lohnt sich die Dekodier-und-Vergleich-Prüfung aus dem Code oben.
Soll ich die Raute aus einem Tag-Facet entfernen?
Entferne sie. Das Lexicon sagt, der sichtbare Text „usually includes a '#' prefix, but the facet reference should not,“ mit einer Ausnahme, die es „double hash tags“ nennt. Der Byte-Bereich deckt das # im Text weiterhin ab, nur der tag-String lässt es weg.
Kann ein Facet gleichzeitig einen Link und einen Tag tragen?
Ja. features ist als Array einer Union aus #mention, #link und #tag definiert, ein einzelnes byteSlice kann also mehr als eine Annotation halten. Das Lexicon sagt nicht, wie ein Client einen Bereich mit zwei widersprüchlichen Features rendern soll, bleib also bei einer Annotation pro Bereich, solange du das Ergebnis nicht getestet hast.
Wie viele Facets kann ein einzelner Post haben?
Das Lexicon sagt es nicht. Das facets-Array in app.bsky.feed.post hat keine maxLength, anders als das tags-Array im selben Record, das auf maxLength: 8 begrenzt ist. Da kein Limit veröffentlicht ist, behandle die praktische Obergrenze als unbekannt, statt anzunehmen, es gebe keine.
Was ist der Unterschied zwischen dem veralteten Feld entities und facets?
Das Array entities in app.bsky.feed.post gilt als veraltet zugunsten von app.bsky.richtext.facet und verwendete einen anderen Index-Typ namens #textSlice. textSlice zählte start und end in UTF-16-Codeeinheiten, denselben Einheiten, die indexOf und length in JavaScript verwenden, während byteSlice UTF-8-Bytes zählt. Beide Definitionen stehen noch heute in den Lexikon-Dateien, ein Codebeispiel, das { start, end } aufbaut, zielt also auf das ausrangierte Feld, nicht auf das, was Bluesky-Clients heute lesen.
Warum speichert ein Bluesky-Mention-Facet eine DID statt des Handle-Texts?
Der sichtbare Text einer Erwähnung ist meist ein Handle wie @example.com, doch das Lexikon verlangt, dass die Facet-Referenz eine DID ist, aufgelöst über die Abfrage com.atproto.identity.resolveHandle. Ein Handle kann sich ändern, wenn jemand sein Konto umbenennt, eine DID bleibt jedoch bestehen, das Speichern der DID hält eine alte Erwähnung also auch nach einer Umbenennung an der richtigen Person. Der einzige dokumentierte Fehler der Abfrage, HandleNotFound, tritt auf, wenn der Auflösungsprozess bestätigt, dass das Handle zu keiner DID aufgelöst werden kann.
Kann der angezeigte Linktext von der tatsächlichen URL abweichen?
Das Link-Feature behält die vollständige URL im Facet, auch wenn der angezeigte Text gekürzt ist. Das Lexikon vermerkt, dass die Text-URL "vereinfacht oder gekürzt worden sein kann", die Facet-Referenz aber "eine vollständige URL sein sollte". Diese Trennung erlaubt es, im Post etwas wie example.com/very-long... zu zeigen, während der Byte-Bereich weiterhin auf die vollständige Adresse verweist, solange er den gekürzten Text abdeckt, der tatsächlich getippt wurde.
Welche Zeichen- und Byte-Grenzen gelten für einen Bluesky-Post?
Bluesky begrenzt den Post-Text über maxGraphemes auf dem Feld text in app.bsky.feed.post auf 300 Grapheme und separat über maxLength auf 3.000 UTF-8-Bytes. Das sind zwei Zähleinheiten für dasselbe Feld: Grapheme für die Anzeigegrenze, Bytes für die Speichergrenze, und byteStart/byteEnd verwenden dieselbe Byte-Zählung. Ein Emoji oder ein akzentuiertes Zeichen verbraucht mehr vom Byte-Budget als vom Graphem-Budget, dieselbe Lücke, die byteStart-Offsets zum Stolpern bringt.
Setze das mit AdaptlyPost in die Praxis um
War dieser Artikel hilfreich?
Teilen Sie uns Ihre Meinung mit!
Sieh uns öfter bei Google
Ein Klick macht AdaptlyPost zu einer bevorzugten Quelle. Unsere Artikel stehen dann weiter oben in deinen Top-Meldungen, im KI-Modus und in den KI-Übersichten.
Bevor Sie gehen...
AdaptlyPost
Planen Sie Ihre Inhalte für alle Plattformen
Verwalten Sie alle Ihre Social-Media-Konten an einem Ort mit AdaptlyPost.
Plattformübergreifende Analysen
Sozialer Posteingang
KI-gestützter Assistent
Verwandte Glossarbegriffe


So funktioniert ein Bluesky Feed Generator, vom Lexicon zum fertigen Feed
Ein Bluesky Feed Generator ist ein HTTPS-Dienst, der eine XRPC-Query beantwortet. Die Lexicons, der DID-Dokument-Eintrag, das JWT und die Lücken in den Docs.


Warum das Bluesky-Bildgrößenlimit bei 2,000,000 Bytes liegt
Das Bluesky-Bildgrößenlimit liegt bei 2,000,000 Bytes pro Beitragsbild, gesetzt per maxSize im images-Lexicon. Avatare und Banner enden bei 1,000,000 Bytes.


So rechnest du das Bluesky-API-Rate-Limit in Posts pro Stunde um
Das Bluesky-API-Rate-Limit für Schreibvorgänge ist ein Punktebudget, kein Anfragezähler: 5,000 Punkte pro Stunde, 3 pro Post, also 1,666 Posts pro Stunde.
Verwandte Artikel


Die zwei Records hinter einem Bluesky-Handle mit eigener Domain
Ein Bluesky-Handle mit eigener Domain braucht einen von zwei Records: TXT auf _atproto oder Klartext unter /.well-known/atproto-did. Plus die gesperrten TLDs.


Meta setzt das alt_text-Zeichenlimit der Instagram API auf 1.000
Meta begrenzt das alt_text-Zeichenlimit der Instagram API auf 1.000 Zeichen und beschränkt es auf Standbilder. Reels und Stories nehmen keinen Alt-Text an.


Hinter der TikTok KI-Kennzeichnung stecken zwei Labels
Die TikTok KI-Kennzeichnung gibt es zweimal: ein Creator-Label, das du per is_aigc setzt, und ein Auto-Label durch KI-Effekte oder C2PA, das bleibt.

