Working with rich text, mentions, links, and tags in AT Protocol
Rich text in AT Protocol allows you to add interactive elements like mentions, links, and hashtags to your posts. The RichText class handles the complexity of encoding these elements correctly.
Always use the RichText class: JavaScript uses UTF-16 encoding while AT Protocol uses UTF-8. The RichText class handles the conversion automatically, preventing character position bugs.
detectFacets() makes network requests to resolve handles to DIDs. For offline detection, use detectFacetsWithoutResolution(), but you’ll need to resolve DIDs manually.
const rt = new RichText({ text: 'Thanks @alice.bsky.social and @bob.bsky.social!'})await rt.detectFacets(agent)// Mentions are now resolved to DIDsfor (const facet of rt.facets || []) { for (const feature of facet.features) { if (feature.$type === 'app.bsky.richtext.facet#mention') { console.log('Mentioned DID:', feature.did) } }}
const rt = new RichText({ text: 'Check out https://example.com and http://test.org!'})await rt.detectFacets(agent)// Both links are detectedconsole.log(rt.facets?.length) // 2
const rt = new RichText({ text: 'This is #cool and #awesome!'})await rt.detectFacets(agent)// Both tags are detectedfor (const facet of rt.facets || []) { for (const feature of facet.features) { if (feature.$type === 'app.bsky.richtext.facet#tag') { console.log('Tag:', feature.tag) // 'cool', 'awesome' } }}
import { RichText } from '@atproto/api'const rt = new RichText({ text: 'Hello @alice.bsky.social! Visit https://example.com #cool'})await rt.detectFacets(agent)// Render as HTMLlet html = ''for (const segment of rt.segments()) { if (segment.isLink()) { html += `<a href="${segment.link.uri}">${segment.text}</a>` } else if (segment.isMention()) { html += `<a href="/profile/${segment.mention.did}">${segment.text}</a>` } else if (segment.isTag()) { html += `<a href="/tag/${segment.tag.tag}">${segment.text}</a>` } else { html += segment.text }}console.log(html)
import { RichText } from '@atproto/api'const rt = new RichText({ text: 'Check out https://example.com'})await rt.detectFacets(agent)// Add a tag at the endrt.insert(rt.length, ' #cool')// Re-detect facets to pick up the new tagrt.detectFacetsWithoutResolution()await agent.post({ text: rt.text, facets: rt.facets, createdAt: new Date().toISOString()})
Never manually construct facets from user input. Always use RichText and detectFacets().
// Badconst text = userInputconst facets = manuallyParseMentions(text) // Don't do this!// Goodconst rt = new RichText({ text: userInput })await rt.detectFacets(agent)
2
Check grapheme length, not string length
Always validate using graphemeLength for proper emoji and unicode handling.
if (rt.graphemeLength > 300) { throw new Error('Post too long')}
3
Handle detection failures gracefully
Handle resolution may fail. Provide fallbacks.
try { await rt.detectFacets(agent)} catch (error) { // Mention resolution failed, but we can still post console.warn('Failed to resolve mentions:', error) rt.detectFacetsWithoutResolution() // Mentions won't work, but links and tags will}
4
Preserve facets when editing
When users edit posts, preserve existing facets when possible.
// Load existing postconst { data } = await agent.getPost({ uri: postUri })const existingRecord = data.value as AppBskyFeedPost.Record// Create RichText with existing facetsconst rt = new RichText({ text: existingRecord.text, facets: existingRecord.facets})// User makes changesrt.insert(0, 'EDIT: ')// Re-detect to update positionsawait rt.detectFacets(agent)
UTF-8 vs UTF-16 confusion: Never use string indices directly in facets.
// Bad - this will break with emojisconst text = 'Hello 👋 @alice'const mentionStart = text.indexOf('@alice') // Wrong!// Good - use RichTextconst rt = new RichText({ text })await rt.detectFacets(agent)
Not resolving mentions: Mentions must have valid DIDs.
// Bad - DID is not resolvedconst facets = [{ features: [{ $type: 'app.bsky.richtext.facet#mention', did: 'alice.bsky.social' // This is a handle, not a DID! }]}]// Good - let detectFacets resolve itawait rt.detectFacets(agent)
Forgetting to await detection: detectFacets() is async.