Skip to main content
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.

Understanding Facets

Facets are annotations on text that add meaning or interactivity. They include:
  • Mentions: References to other users (@alice.bsky.social)
  • Links: Clickable URLs (https://example.com)
  • Hashtags: Topic tags (#bluesky)
  • Cashtags: Stock tickers ($AAPL)
Each facet has:
  • Index: The byte position in the UTF-8 encoded text
  • Features: What the facet represents (link, mention, tag)
interface Facet {
  index: {
    byteStart: number  // UTF-8 byte position
    byteEnd: number    // UTF-8 byte position
  }
  features: (
    | { $type: 'app.bsky.richtext.facet#link'; uri: string }
    | { $type: 'app.bsky.richtext.facet#mention'; did: string }
    | { $type: 'app.bsky.richtext.facet#tag'; tag: string }
  )[]
}

Creating Rich Text

Basic Usage

import { RichText } from '@atproto/api'

const rt = new RichText({
  text: 'Hello world! Check out https://example.com'
})

Detecting Facets

Use detectFacets() to automatically find and resolve mentions and links:
import { RichText } from '@atproto/api'

const rt = new RichText({
  text: 'Hello @alice.bsky.social! Check out https://example.com #cool'
})

// Detects links and mentions, resolves handles to DIDs
await rt.detectFacets(agent)

console.log(rt.facets)
// [
//   {
//     index: { byteStart: 6, byteEnd: 25 },
//     features: [{
//       $type: 'app.bsky.richtext.facet#mention',
//       did: 'did:plc:alice123'
//     }]
//   },
//   {
//     index: { byteStart: 37, byteEnd: 56 },
//     features: [{
//       $type: 'app.bsky.richtext.facet#link',
//       uri: 'https://example.com'
//     }]
//   },
//   {
//     index: { byteStart: 57, byteEnd: 62 },
//     features: [{
//       $type: 'app.bsky.richtext.facet#tag',
//       tag: 'cool'
//     }]
//   }
// ]
detectFacets() makes network requests to resolve handles to DIDs. For offline detection, use detectFacetsWithoutResolution(), but you’ll need to resolve DIDs manually.

Creating a Post with Rich Text

import { RichText } from '@atproto/api'

const rt = new RichText({
  text: 'Hello @alice.bsky.social! Check out https://example.com'
})
await rt.detectFacets(agent)

await agent.post({
  text: rt.text,
  facets: rt.facets,
  createdAt: new Date().toISOString()
})

Working with Mentions

Auto-detecting Mentions

const rt = new RichText({
  text: 'Thanks @alice.bsky.social and @bob.bsky.social!'
})
await rt.detectFacets(agent)

// Mentions are now resolved to DIDs
for (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)
    }
  }
}

Manually Creating Mentions

If you already have a user’s DID:
import { AppBskyRichtextFacet } from '@atproto/api'

const text = 'Hello @alice!'
const rt = new RichText({ text })

// Add mention facet manually
const mentionStart = text.indexOf('@alice')
const mentionEnd = mentionStart + '@alice'.length

rt.facets = [
  {
    index: {
      byteStart: new TextEncoder().encode(text.slice(0, mentionStart)).length,
      byteEnd: new TextEncoder().encode(text.slice(0, mentionEnd)).length
    },
    features: [
      {
        $type: 'app.bsky.richtext.facet#mention',
        did: 'did:plc:alice123'
      }
    ]
  }
]
Let detectFacets() handle the UTF-8 byte indexing for you. Manual facet creation is error-prone.
const rt = new RichText({
  text: 'Check out https://example.com and http://test.org!'
})
await rt.detectFacets(agent)

// Both links are detected
console.log(rt.facets?.length) // 2
The detector automatically:
  • Adds https:// to URLs without a protocol
  • Strips trailing punctuation (., ,, ;, :, !, ?)
  • Handles parentheses correctly
  • Validates domain TLDs
// These all work correctly:
const examples = [
  'Visit example.com',           // → https://example.com
  'See https://example.com.',    // → https://example.com (strips .)
  'Link (https://example.com)',  // → https://example.com
  'Check https://example.com/path?q=1&x=2'  // Preserves query params
]

Working with Hashtags

Auto-detecting Hashtags

const rt = new RichText({
  text: 'This is #cool and #awesome!'
})
await rt.detectFacets(agent)

// Both tags are detected
for (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'
    }
  }
}

Hashtag Rules

  • Must start with #
  • Can contain letters, numbers, and underscores
  • Maximum 64 characters
  • Trailing punctuation is stripped
// Valid tags:
'#bluesky'      // ✓
'#web3'         // ✓
'#hello_world'  // ✓

// These work but are normalized:
'#test!'        // → 'test' (punctuation stripped)
'#TAG'          // → 'TAG' (case preserved)

Working with Cashtags

Cashtags are stock ticker symbols:
const rt = new RichText({
  text: 'Buy $AAPL and $GOOGL!'
})
await rt.detectFacets(agent)

// Cashtags are normalized to uppercase
for (const facet of rt.facets || []) {
  for (const feature of facet.features) {
    if (feature.$type === 'app.bsky.richtext.facet#tag') {
      console.log('Ticker:', feature.tag) // '$AAPL', '$GOOGL'
    }
  }
}

Rendering Rich Text

Use the segments() iterator to render rich text:
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 HTML
let 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)

Rendering as Markdown

let markdown = ''
for (const segment of rt.segments()) {
  if (segment.isLink()) {
    markdown += `[${segment.text}](${segment.link.uri})`
  } else if (segment.isMention()) {
    markdown += `[@${segment.text}](https://bsky.app/profile/${segment.mention.did})`
  } else if (segment.isTag()) {
    markdown += `[${segment.text}](/tag/${segment.tag.tag})`
  } else {
    markdown += segment.text
  }
}

React Rendering Example

jsx
import { RichText, RichTextSegment } from '@atproto/api'

function RichTextComponent({ text }: { text: RichText }) {
  return (
    <span>
      {Array.from(text.segments()).map((segment, i) => {
        if (segment.isLink()) {
          return (
            <a key={i} href={segment.link.uri} target="_blank" rel="noopener">
              {segment.text}
            </a>
          )
        }
        
        if (segment.isMention()) {
          return (
            <a key={i} href={`/profile/${segment.mention.did}`}>
              {segment.text}
            </a>
          )
        }
        
        if (segment.isTag()) {
          return (
            <a key={i} href={`/tag/${segment.tag.tag}`}>
              {segment.text}
            </a>
          )
        }
        
        return <span key={i}>{segment.text}</span>
      })}
    </span>
  )
}

Text Length Calculations

Grapheme Length

AT Protocol limits posts to 300 graphemes (user-perceived characters):
const rt = new RichText({ text: 'Hello 👨‍👩‍👧‍👧' })

console.log(rt.length)          // 25 (UTF-16 code units)
console.log(rt.graphemeLength)  // 7 (graphemes: 'H' 'e' 'l' 'l' 'o' ' ' '👨‍👩‍👧‍👧')
Always check graphemeLength, not length, when validating post length.

Validating Post Length

const MAX_GRAPHEME_LENGTH = 300

function validatePostText(text: string): string | null {
  const rt = new RichText({ text })
  
  if (rt.graphemeLength > MAX_GRAPHEME_LENGTH) {
    return `Post is too long (${rt.graphemeLength}/${MAX_GRAPHEME_LENGTH} characters)`
  }
  
  return null // Valid
}

Modifying Rich Text

Inserting Text

const rt = new RichText({
  text: 'Hello world'
})

rt.insert(6, 'beautiful ') // Insert at byte position 6
console.log(rt.text) // 'Hello beautiful world'

// Facets are automatically adjusted

Deleting Text

const rt = new RichText({
  text: 'Hello beautiful world'
})

rt.delete(6, 16) // Delete from byte 6 to 16
console.log(rt.text) // 'Hello world'

// Facets are automatically adjusted or removed

Example: Adding a Tag to Existing Text

import { RichText } from '@atproto/api'

const rt = new RichText({
  text: 'Check out https://example.com'
})
await rt.detectFacets(agent)

// Add a tag at the end
rt.insert(rt.length, ' #cool')

// Re-detect facets to pick up the new tag
rt.detectFacetsWithoutResolution()

await agent.post({
  text: rt.text,
  facets: rt.facets,
  createdAt: new Date().toISOString()
})

Sanitizing Rich Text

Cleaning Newlines

Remove excessive newlines:
import { RichText } from '@atproto/api'

const rt = new RichText(
  {
    text: 'Hello\n\n\n\nworld'
  },
  {
    cleanNewlines: true // Reduces to max 2 consecutive newlines
  }
)

console.log(rt.text) // 'Hello\n\nworld'

Manual Sanitization

import { sanitizeRichText } from '@atproto/api'

const rt = new RichText({
  text: 'Hello\n\n\n\nworld'
})

const sanitized = sanitizeRichText(rt, {
  cleanNewlines: true
})

console.log(sanitized.text) // 'Hello\n\nworld'

Advanced Usage

Cloning RichText

const original = new RichText({
  text: 'Hello @alice.bsky.social'
})
await original.detectFacets(agent)

const copy = original.clone()

// Modify copy without affecting original
copy.insert(0, 'Hi! ')

Manual Facet Creation

For advanced use cases, create facets manually:
import { UnicodeString } from '@atproto/api'

const text = 'Hello @alice'
const unicodeText = new UnicodeString(text)

const facets = [
  {
    index: {
      byteStart: unicodeText.utf16IndexToUtf8Index(6),  // Start of '@alice'
      byteEnd: unicodeText.utf16IndexToUtf8Index(12)    // End of '@alice'
    },
    features: [
      {
        $type: 'app.bsky.richtext.facet#mention',
        did: 'did:plc:alice123'
      }
    ]
  }
]

const rt = new RichText({ text, facets })

Best Practices

1

Always use RichText for user input

Never manually construct facets from user input. Always use RichText and detectFacets().
// Bad
const text = userInput
const facets = manuallyParseMentions(text) // Don't do this!

// Good
const 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 post
const { data } = await agent.getPost({ uri: postUri })
const existingRecord = data.value as AppBskyFeedPost.Record

// Create RichText with existing facets
const rt = new RichText({
  text: existingRecord.text,
  facets: existingRecord.facets
})

// User makes changes
rt.insert(0, 'EDIT: ')

// Re-detect to update positions
await rt.detectFacets(agent)

Common Pitfalls

UTF-8 vs UTF-16 confusion: Never use string indices directly in facets.
// Bad - this will break with emojis
const text = 'Hello 👋 @alice'
const mentionStart = text.indexOf('@alice') // Wrong!

// Good - use RichText
const rt = new RichText({ text })
await rt.detectFacets(agent)
Not resolving mentions: Mentions must have valid DIDs.
// Bad - DID is not resolved
const facets = [{
  features: [{
    $type: 'app.bsky.richtext.facet#mention',
    did: 'alice.bsky.social' // This is a handle, not a DID!
  }]
}]

// Good - let detectFacets resolve it
await rt.detectFacets(agent)
Forgetting to await detection: detectFacets() is async.
// Bad
rt.detectFacets(agent) // Forgot await!
await agent.post({ text: rt.text, facets: rt.facets })

// Good
await rt.detectFacets(agent)
await agent.post({ text: rt.text, facets: rt.facets })

Examples

Complete Post with Multiple Facets

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

const agent = new Agent(session)

const rt = new RichText({
  text: 'Hey @alice.bsky.social and @bob.bsky.social! Check out https://example.com #cool #awesome'
})

await rt.detectFacets(agent)

const postResult = await agent.post({
  text: rt.text,
  facets: rt.facets,
  createdAt: new Date().toISOString()
})

console.log('Post created:', postResult.uri)

Building a Rich Text Editor

import { RichText } from '@atproto/api'

class RichTextEditor {
  private rt: RichText
  
  constructor(initialText: string = '') {
    this.rt = new RichText({ text: initialText })
  }
  
  async setText(text: string) {
    this.rt = new RichText({ text })
    await this.detectFacets()
  }
  
  async detectFacets() {
    try {
      await this.rt.detectFacets(agent)
    } catch (error) {
      console.warn('Facet detection failed:', error)
      this.rt.detectFacetsWithoutResolution()
    }
  }
  
  getText(): string {
    return this.rt.text
  }
  
  getFacets() {
    return this.rt.facets
  }
  
  getLength(): number {
    return this.rt.graphemeLength
  }
  
  isValid(): boolean {
    return this.rt.graphemeLength <= 300
  }
  
  async createPost() {
    if (!this.isValid()) {
      throw new Error('Post is too long')
    }
    
    return await agent.post({
      text: this.rt.text,
      facets: this.rt.facets,
      createdAt: new Date().toISOString()
    })
  }
}

// Usage
const editor = new RichTextEditor()
await editor.setText('Hello @alice.bsky.social!')
console.log('Length:', editor.getLength())
if (editor.isValid()) {
  await editor.createPost()
}

Next Steps

Using the API

Learn more about the Agent API

Moderation

Implement content moderation

API Reference

Explore the complete API

Development Setup

Set up your development environment

Build docs developers (and LLMs) love