Rich Text
Most examples use
taskResult {}. Some usetask {}where noted --RichTextfunctions return bareTask(notTask<Result<>>), sotask {}is appropriate when only calling those functions. See the Error Handling guide for details.
Posts and messages on Bluesky support rich text through facets -- annotations on byte ranges within the text that mark up @mentions, links, and #hashtags. The AT Protocol specifies facet positions in UTF-8 byte offsets, not character indices.
FSharp.ATProto handles all of this for you by default, but also gives you full control when you need it.
The Easy Path: Bluesky.post
The simplest way to post with rich text is Bluesky.post, which auto-detects mentions, links, and hashtags, resolves mentions to DIDs, and posts everything in one call:
open FSharp.ATProto.Core
open FSharp.ATProto.Bluesky
taskResult {
let! result =
Bluesky.post agent
"Hello @other-user.bsky.social! Check https://atproto.com #atproto"
return result
}
This detects three facets:
1. @other-user.bsky.social -- resolved to its DID via the API
2. https://atproto.com -- marked as a link
3. #atproto -- marked as a hashtag
How Facets Work
A facet is a triple of (byte range, feature type, value):
- Mentions (
app.bsky.richtext.facet#mention): byte range covering@handle, value is the resolved DID - Links (
app.bsky.richtext.facet#link): byte range covering the URL, value is the URL itself - Tags (
app.bsky.richtext.facet#tag): byte range covering#tag, value is the tag text (without the#)
Byte offsets are in UTF-8 bytes, not characters. This matters for text with emoji or non-ASCII characters. For example, a single emoji like a red heart might be 1 character but 4+ UTF-8 bytes.
Step-by-Step: Detect, Then Resolve
For more control, split the process into detection and resolution.
Detection (Offline)
RichText.detect scans text for patterns and returns DetectedFacet values with byte offsets. No network calls are made:
let detected = RichText.detect "Hello @my-handle.bsky.social! #atproto"
// Returns:
// [ DetectedMention(6, 28, "my-handle.bsky.social")
// DetectedTag(30, 38, "atproto") ]
The DetectedFacet type is a discriminated union:
type DetectedFacet =
| DetectedMention of byteStart: int * byteEnd: int * handle: string
| DetectedLink of byteStart: int * byteEnd: int * uri: string
| DetectedTag of byteStart: int * byteEnd: int * tag: string
Resolution (Network)
RichText.resolve takes detected facets and resolves mentions to DIDs via the API. Mentions that can't be resolved are silently dropped:
// task {} because RichText.resolve returns Task<Facet list>, not Task<Result<_,_>>
task {
let! facets = RichText.resolve agent detected
// facets : AppBskyRichtext.Facet.Facet list
return facets
}
Combined: RichText.parse
RichText.parse combines both steps -- detect and resolve in one call:
// task {} because RichText.parse returns Task<Facet list>, not Task<Result<_,_>>
task {
let! facets = RichText.parse agent "Hello @my-handle.bsky.social! #atproto"
return facets
}
Posting with Pre-Computed Facets
If you've already computed facets (for example, from RichText.parse or constructed manually), use Bluesky.postWithFacets to skip auto-detection:
// task {} because RichText.parse returns bare Task, requiring manual match on postWithFacets result
task {
let! facets = RichText.parse agent text
// Maybe filter or modify facets here...
let filteredFacets = facets |> List.filter (fun _ -> true)
let! result = Bluesky.postWithFacets agent text filteredFacets
match result with
| Ok postRef -> printfn "Posted: %s" (AtUri.value postRef.Uri)
| Error err -> printfn "Failed: %A" err
}
Note: RichText.parse and RichText.resolve return bare Task<Facet list> (not Task<Result<_, _>>), because unresolvable mentions are silently dropped rather than producing an error. When mixing these with result-returning functions like Bluesky.postWithFacets, use task {} and match the result manually.
This is useful when you want to: - Cache resolved mention DIDs across multiple posts - Filter out certain facet types - Add custom facets
Measuring Text Length
Bluesky enforces a 300-grapheme limit on posts (not 300 characters or 300 bytes). Check grapheme length before posting to avoid the server rejecting your post:
let len = RichText.graphemeLength "Hello world!" // 12
let emojiLen = RichText.graphemeLength "\U0001F468\u200D\U0001F469\u200D\U0001F467\u200D\U0001F466" // 1 (family emoji)
Byte length is used internally for facet offsets -- you rarely need it directly:
let bytes = RichText.byteLength "Hello!" // 6
let emojiBytes = RichText.byteLength "\U0001F600" // 4
Rich Text in Chat Messages
Chat.sendMessage auto-detects mentions, links, and hashtags -- just like Bluesky.post:
taskResult {
let! result =
Chat.sendMessage agent convoId "Check out https://example.com! cc @friend.bsky.social"
return result
}
If you need to supply custom or pre-computed facets, drop down to the raw API:
// task {} because RichText.parse returns bare Task, requiring manual match on the XRPC result
task {
let text = "Check out https://example.com!"
let! facets = RichText.parse agent text
let! result =
ChatBskyConvo.SendMessage.call (AtpAgent.withChatProxy agent)
{ ConvoId = convoId
Message =
{ Text = text
Facets = if facets.IsEmpty then None else Some facets
Embed = None } }
match result with
| Ok msg -> printfn "Sent with custom facets"
| Error err -> printfn "Failed: %A" err
}
See the Chat / DMs guide for more on sending messages.
Rich Text Manipulation
The RichTextValue type pairs text with its facets and supports safe manipulation that keeps byte offsets correct.
RichTextValue
Field |
Type |
Description |
|---|---|---|
|
|
The text content |
|
|
Rich text annotations |
Creating
Function |
Signature |
Description |
|---|---|---|
|
|
Create from text and facets |
|
|
Create plain text (no facets) |
Manipulating
Function |
Signature |
Description |
|---|---|---|
|
|
Insert text at byte index, shifting facets |
|
|
Delete byte range, adjusting facets |
|
|
Split into annotated segments |
|
|
Remove invalid or out-of-range facets |
|
|
Truncate to grapheme length, trimming facets |
Example
let rt = RichText.create "Hello @alice.bsky.social!" facets
// Insert text -- facet offsets shift automatically
let updated = rt |> RichText.insert 0 "Hey! "
// Truncate to 50 graphemes -- facets that extend past the boundary are trimmed
let truncated = rt |> RichText.truncate 50
// Split into segments for rendering
let segments = rt |> RichText.segments
for seg in segments do
match seg.Facet with
| Some _ -> printfn "[rich] %s" seg.Text
| None -> printfn "%s" seg.Text
namespace FSharp
--------------------
namespace Microsoft.FSharp
module AtpAgent from FSharp.ATProto.Core
<summary> Functions for creating and authenticating <see cref="AtpAgent" /> instances. </summary>
--------------------
type AtpAgent = { HttpClient: HttpClient mutable BaseUrl: Uri mutable Session: AtpSession option ExtraHeaders: (string * string) list AuthenticateRequest: (HttpRequestMessage -> unit) option RefreshAuthentication: (unit -> Task<Result<unit,XrpcError>>) option OnSessionChanged: (unit -> unit) option }
<summary> Client agent for communicating with an AT Protocol Personal Data Server (PDS). Holds the HTTP client, base URL, optional authenticated session, and extra headers. </summary>
<remarks> Create an agent with <see cref="AtpAgent.create" /> or <see cref="AtpAgent.createWithClient" />, then authenticate with <see cref="AtpAgent.login" />. The agent's <see cref="Session" /> field is mutable: it is updated automatically on login and token refresh. </remarks>
<example><code> let agent = AtpAgent.create "https://bsky.social" let! session = AtpAgent.login "my-handle.bsky.social" "app-password-here" agent </code></example>
<summary> High-level convenience methods for common Bluesky operations: posting, replying, liking, reposting, following, blocking, uploading blobs, and deleting records. All methods require an authenticated <see cref="AtpAgent" />. </summary>
<summary> Create a post with automatic rich text detection. Mentions, links, and hashtags are automatically detected and resolved to facets. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="text">The post text. Mentions (<c>@handle</c>), links (<c>https://...</c>), and hashtags (<c>#tag</c>) are auto-detected.</param>
<returns>A <see cref="PostRef" /> with the AT-URI and CID on success, or an <see cref="XrpcError" />.</returns>
<remarks> Internally calls <see cref="RichText.parse" /> to detect and resolve facets before creating the post. Unresolvable mentions are silently omitted from facets. For pre-resolved facets, use <see cref="postWithFacets" /> instead. </remarks>
<example><code> let! result = Bluesky.post agent "Hello @my-handle.bsky.social! Check out https://example.com #atproto" </code></example>
<summary> Rich text processing for Bluesky posts. Detects mentions, links, and hashtags in text and resolves them to facets with correct UTF-8 byte offsets as required by the AT Protocol. </summary>
<summary> Detect mentions, links, and hashtags in text. Returns facets with UTF-8 byte offsets, sorted by start position. </summary>
<param name="text">The text to scan for rich text entities.</param>
<returns> A list of <see cref="DetectedFacet" /> values sorted by byte start position. Mentions match <c>@handle.domain</c>, links match <c>http(s)://...</c>, and hashtags match <c>#tag</c> patterns. </returns>
<remarks> This performs detection only. To resolve mentions to DIDs and produce <see cref="AppBskyRichtext.Facet.Facet" /> records suitable for the API, pass the result to <see cref="resolve" /> or use <see cref="parse" /> for a combined detect-and-resolve step. </remarks>
<example><code> let facets = RichText.detect "Hello @my-handle.bsky.social! #atproto" // Returns [DetectedMention(6, 28, "my-handle.bsky.social"); DetectedTag(30, 38, "atproto")] </code></example>
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
val string: value: 'T -> string
--------------------
type string = System.String
<summary> Resolve detected facets into API-ready facet records. Mentions are resolved to DIDs via <c>com.atproto.identity.resolveHandle</c>; unresolvable mentions are silently dropped. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="detected">The list of <see cref="DetectedFacet" /> values to resolve (typically from <see cref="detect" />).</param>
<returns> A list of <see cref="AppBskyRichtext.Facet.Facet" /> records with resolved features. Mentions whose handles cannot be resolved are omitted from the result. </returns>
<summary> Detect and resolve all rich text facets in a single step. Combines <see cref="detect" /> and <see cref="resolve" />: scans the text for mentions, links, and hashtags, then resolves mentions to DIDs. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="text">The text to scan and resolve.</param>
<returns>A list of resolved <see cref="AppBskyRichtext.Facet.Facet" /> records.</returns>
<example><code> let! facets = RichText.parse agent "Hello @my-handle.bsky.social! Check https://example.com #atproto" </code></example>
module List from Microsoft.FSharp.Collections
--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T with get member IsEmpty: bool with get member Item: index: int -> 'T with get ...
<summary> Create a post with pre-resolved facets. Use this when you have already detected and resolved rich text facets, or when you want full control over facet content. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="text">The post text content.</param>
<param name="facets">Pre-resolved facets (mentions, links, hashtags). Pass an empty list for plain text.</param>
<returns>A <see cref="PostRef" /> with the AT-URI and CID on success, or an <see cref="XrpcError" />.</returns>
module AtUri from FSharp.ATProto.Syntax
<summary> Functions for creating, validating, and extracting data from <see cref="AtUri" /> values. </summary>
--------------------
type AtUri = private | AtUri of string override ToString: unit -> string
<summary> An AT-URI that identifies a resource in the AT Protocol network. AT-URIs use the scheme <c>at://</c> followed by an authority (DID or handle), an optional collection (NSID), and an optional record key. Format: <c>at://<authority>[/<collection>[/<rkey>]]</c>. Maximum length is 8192 characters. </summary>
<remarks> See the AT Protocol specification: https://atproto.com/specs/at-uri-scheme </remarks>
<summary> Extract the string representation of an AT-URI. </summary>
<param name="atUri">The AT-URI to extract the value from.</param>
<returns>The full AT-URI string (e.g. <c>"at://did:plc:z72i7hdynmk6r22z27h6tvur/app.bsky.feed.post/3k2la3b"</c>).</returns>
<summary>The AT-URI of the post record.</summary>
<summary> Count the number of grapheme clusters (user-perceived characters) in a string. Bluesky uses grapheme length for the 300-character post limit. </summary>
<param name="text">The text to measure.</param>
<returns>The number of extended grapheme clusters in the text.</returns>
<remarks> Grapheme length differs from <see cref="System.String.Length" /> for multi-codepoint characters such as emoji (e.g., family emoji, flag emoji) and combining character sequences. </remarks>
<summary> Count the UTF-8 byte length of a string. The AT Protocol specifies facet positions and text limits in UTF-8 bytes. </summary>
<param name="text">The text to measure.</param>
<returns>The number of bytes when the text is encoded as UTF-8.</returns>
<summary> Convenience methods for Bluesky direct message (DM) and chat operations. Wraps the <c>chat.bsky.convo.*</c> XRPC endpoints with a simplified API. All methods require an authenticated <see cref="AtpAgent" />. The chat proxy header (<c>atproto-proxy: did:web:api.bsky.chat#bsky_chat</c>) is applied automatically -- callers do not need to use <see cref="AtpAgent.withChatProxy" /> manually. </summary>
<summary> Send a message to a conversation. Rich text (links, mentions, hashtags) is automatically detected and resolved, matching the behaviour of <c>Bluesky.post</c>. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="convoId">A <see cref="ConvoSummary" /> or <c>string</c> conversation ID.</param>
<param name="text">The message text content. Links, mentions, and hashtags are auto-detected.</param>
<returns>The sent message as a <see cref="ChatMessage" />, or an <see cref="XrpcError" />.</returns>
<summary> Returns a copy of the agent configured to proxy requests through the Bluesky Chat service. Adds the <c>atproto-proxy: did:web:api.bsky.chat#bsky_chat</c> header. </summary>
<param name="agent">The agent to copy with chat proxy configuration.</param>
<returns>A new <see cref="AtpAgent" /> with the chat proxy header prepended to <see cref="AtpAgent.ExtraHeaders" />.</returns>
<remarks> The returned agent shares the same <see cref="System.Net.Http.HttpClient" /> as the original but has an independent <see cref="AtpAgent.Session" /> field (it is a record copy). If you need chat functionality, prefer using the <see cref="FSharp.ATProto.Bluesky.Chat" /> module functions directly — they handle the proxy header automatically and always use the current session from the original agent. </remarks>
<summary> Annotation of a sub-string within rich text. </summary>
<summary> Create a RichTextValue from text and facets. </summary>
<summary> Insert text at a UTF-8 byte index. Facets that start at or after the insertion point are shifted forward. Facets that span the insertion point are expanded. </summary>
<param name="bytePos">The UTF-8 byte offset at which to insert.</param>
<param name="insertText">The text to insert.</param>
<param name="rt">The rich text value to modify.</param>
<returns>A new RichTextValue with the text inserted and facet indices adjusted.</returns>
<summary> Truncate rich text to a maximum UTF-8 byte length while preserving facet integrity. The text is truncated at the byte limit (on a valid UTF-8 boundary), and any facets that extend beyond the limit are removed entirely. </summary>
<param name="maxBytes">The maximum number of UTF-8 bytes to keep.</param>
<param name="rt">The rich text value to truncate.</param>
<returns> A new RichTextValue truncated to the byte limit. Facets that would extend beyond the truncated text are removed (not partially preserved). </returns>
<summary> Split rich text into segments by facet boundaries for rendering. Each segment has plain text and an optional facet. Non-faceted text between facets becomes segments with <c>Facet = None</c>. Segments are returned in text order. </summary>
<param name="rt">The rich text value to segment.</param>
<returns>A list of <see cref="RichTextSegment" /> values covering the entire text.</returns>