Header menu logo FSharp.ATProto

Quickstart

Get from zero to posting on Bluesky in under 5 minutes.

Code samples use taskResult {}, a computation expression that chains async operations returning Result. See Error Handling for details.

Prerequisites

Create a Project

dotnet new console -lang F# -n MyBskyBot
cd MyBskyBot

Add the NuGet package:

dotnet add package FSharp.ATProto.Bluesky

Log In

Replace the contents of Program.fs:

open FSharp.ATProto.Syntax
open FSharp.ATProto.Core
open FSharp.ATProto.Bluesky

let loginExample () =
    let result =
        taskResult {
            let! agent = Bluesky.login "https://bsky.social" "your-handle.bsky.social" "your-app-password"
            printfn "Logged in!"
            return 0
        }

    result.Result
    |> function
        | Ok code -> code
        | Error e -> printfn "Error: %A" e; 1

Run it:

dotnet run
# Logged in!

Bluesky.login creates the agent, authenticates, and returns it ready to use -- all in one call. If anything fails, you get an Error with details. No exceptions.

Make Your First Post

Bluesky.post automatically detects @mentions, links, and #hashtags in your text and creates the correct rich text facets:

taskResult {
    let! post = Bluesky.post agent "Hello world from F#! #atproto"
    printfn "Posted! URI: %s" (AtUri.value post.Uri)
    ()
}

Every @handle.domain in the text is resolved to a DID via the API. Links and hashtags are detected by pattern. You never need to compute byte offsets or construct facet objects yourself. The result is a PostRef containing the AT-URI and CID of the new post.

Read Your Timeline

Bluesky.getTimeline wraps the app.bsky.feed.getTimeline endpoint with a simpler signature:

taskResult {
    let! timeline = Bluesky.getTimeline agent (Some 10L) None

    for item in timeline.Items do
        let author = Handle.value item.Post.Author.Handle
        let text = item.Post.Text
        printfn "@%s: %s" author text
}

Each FeedItem has a .Post field (a TimelinePost) with .Text, .Author, .Uri, .Cid, and engagement counts directly available. If you drop down to the raw XRPC layer, extension properties like .Text and .Facets are available on PostView.

Like a Post

Bluesky.like accepts a TimelinePost (or a PostRef) directly:

taskResult {
    let firstPost = timeline.Items.[0].Post
    let! likeRef = Bluesky.like agent firstPost
    printfn "Liked! %s" (AtUri.value likeRef.Uri)
    ()
}

The result is a LikeRef you can hold on to. To undo the like later, pass it to Bluesky.undoLike:

taskResult {
    let! _ = Bluesky.undoLike agent likeRef
    ()
}

Reply to a Post

Bluesky.replyTo fetches the parent post to resolve the thread root automatically. Pass the post you are replying to directly:

taskResult {
    let! reply = Bluesky.replyTo agent "Great post!" firstPost
    printfn "Replied: %s" (AtUri.value reply.Uri)
    ()
}

Post with Images

Bluesky.postWithImages handles blob uploading and embed construction. Pass a list of ImageUpload records:

taskResult {
    let imageBytes = System.IO.File.ReadAllBytes("photo.jpg")

    let! post =
        Bluesky.postWithImages agent "Check out this photo!" [
            { Data = imageBytes; MimeType = Jpeg; AltText = "A sunny landscape" }
        ]
    ()
}

MimeType is an ImageMime discriminated union with cases Jpeg, Png, Gif, Webp, and Custom of string. Up to 4 images per post.

Complete Example

Here is a full program that ties everything together using the taskResult computation expression. Every let! binding short-circuits to the Error case if something fails -- no nested match trees needed:

open FSharp.ATProto.Syntax
open FSharp.ATProto.Core
open FSharp.ATProto.Bluesky

let main _ =
    let result =
        taskResult {
            // Log in
            let! agent = Bluesky.login "https://bsky.social" "your-handle.bsky.social" "your-app-password"
            printfn "Logged in!"

            // Create a post with auto-detected rich text
            let! post = Bluesky.post agent "Hello from F#! #fsharp #atproto"
            printfn "Posted: %s" (AtUri.value post.Uri)

            // Read timeline
            let! timeline = Bluesky.getTimeline agent (Some 5L) None
            printfn "Timeline (%d posts):" timeline.Items.Length

            for item in timeline.Items do
                printfn "  @%s: %s" (Handle.value item.Post.Author.Handle) item.Post.Text

            // Like the first post from the timeline
            match timeline.Items with
            | first :: _ ->
                let! likeRef = Bluesky.like agent first.Post
                printfn "Liked: %s" (AtUri.value likeRef.Uri)

                // Reply to it
                let! reply = Bluesky.replyTo agent "Nice post!" first.Post
                printfn "Replied: %s" (AtUri.value reply.Uri)

                // Clean up: undo the like and delete the reply
                let! _ = Bluesky.undoLike agent likeRef
                let! _ = Bluesky.deleteRecord agent reply
                printfn "Cleaned up."
            | [] -> ()

            // Delete our original post
            let! _ = Bluesky.deleteRecord agent post

            printfn "Done!"
            return 0
        }

    result.Result
    |> function
        | Ok code -> code
        | Error e -> printfn "Error: %A" e; 1

What's Next

Multiple items
namespace FSharp

--------------------
namespace Microsoft.FSharp
namespace FSharp.ATProto
namespace FSharp.ATProto.Syntax
namespace FSharp.ATProto.Core
namespace FSharp.ATProto.Bluesky
val agent: AtpAgent
module Unchecked from Microsoft.FSharp.Core.Operators
val defaultof<'T> : 'T
Multiple items
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>
val post: PostRef
type PostRef = { Uri: AtUri Cid: Cid }
<summary> A reference to a specific version of a post record. Contains both the AT-URI (identifying the record) and the CID (identifying the exact version). Accepted by <c>like</c>, <c>repost</c>, and <c>replyTo</c>. </summary>
val timeline: Page<FeedItem>
type Page<'T> = { Items: 'T list Cursor: string option }
<summary>A paginated result containing a list of items and an optional cursor for the next page.</summary>
Multiple items
module FeedItem from FSharp.ATProto.Bluesky

--------------------
type FeedItem = { Post: TimelinePost Reason: FeedReason option ReplyParent: TimelinePost option }
<summary>A single item in a feed or timeline.</summary>
val firstPost: TimelinePost
Multiple items
module TimelinePost from FSharp.ATProto.Bluesky

--------------------
type TimelinePost = { Uri: AtUri Cid: Cid Author: ProfileSummary Text: string Facets: Facet list LikeCount: int64 RepostCount: int64 ReplyCount: int64 QuoteCount: int64 IndexedAt: DateTimeOffset ... }
<summary> A post with engagement counts and viewer state, used in feeds and timelines. Maps from <c>PostView</c>. </summary>
val likeRef: LikeRef
type LikeRef = { Uri: AtUri }
<summary> A reference to a like record, returned by <c>Bluesky.like</c>. Pass to <c>Bluesky.unlike</c> to undo. </summary>
val loginExample: unit -> int
val result: System.Threading.Tasks.Task<Result<int,XrpcError>>
val taskResult: TaskResultBuilder
module Bluesky from FSharp.ATProto.Bluesky
<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>
val login: baseUrl: string -> identifier: string -> password: string -> System.Threading.Tasks.Task<Result<AtpAgent,XrpcError>>
val printfn: format: Printf.TextWriterFormat<'T> -> 'T
property System.Threading.Tasks.Task.Result: Result<int,XrpcError> with get
<summary>Gets the result value of this <see cref="T:System.Threading.Tasks.Task`1" />.</summary>
<exception cref="T:System.AggregateException">The task was canceled. The <see cref="P:System.AggregateException.InnerExceptions" /> collection contains a <see cref="T:System.Threading.Tasks.TaskCanceledException" /> object. -or- An exception was thrown during the execution of the task. The <see cref="P:System.AggregateException.InnerExceptions" /> collection contains information about the exception or exceptions.</exception>
<returns>The result value of this <see cref="T:System.Threading.Tasks.Task`1" />, which is of the same type as the task's type parameter.</returns>
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
val code: int
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
val e: XrpcError
val post: agent: AtpAgent -> text: string -> System.Threading.Tasks.Task<Result<PostRef,XrpcError>>
<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>
Multiple items
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://&lt;authority&gt;[/&lt;collection&gt;[/&lt;rkey&gt;]]</c>. Maximum length is 8192 characters. </summary>
<remarks> See the AT Protocol specification: https://atproto.com/specs/at-uri-scheme </remarks>
val value: AtUri -> string
<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>
PostRef.Uri: AtUri
<summary>The AT-URI of the post record.</summary>
val getTimeline: agent: AtpAgent -> limit: int64 option -> cursor: string option -> System.Threading.Tasks.Task<Result<Page<FeedItem>,XrpcError>>
<summary> Get the authenticated user's home timeline. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="limit">Maximum number of posts to return (optional, pass <c>None</c> for server default).</param>
<param name="cursor">Pagination cursor from a previous response (optional, pass <c>None</c> to start from the beginning).</param>
<returns>A page of <see cref="FeedItem" /> with an optional cursor, or an <see cref="XrpcError" />.</returns>
union case Option.Some: Value: 'T -> Option<'T>
union case Option.None: Option<'T>
val item: FeedItem
Page.Items: FeedItem list
val author: string
Multiple items
module Handle from FSharp.ATProto.Syntax
<summary> Functions for creating, validating, and extracting data from <see cref="Handle" /> values. </summary>

--------------------
type Handle = private | Handle of string override ToString: unit -> string
<summary> A handle (domain name) used as a human-readable identifier in the AT Protocol. Handles are DNS-based names (e.g. <c>my-handle.bsky.social</c>) that resolve to a <see cref="Did" />. They must be valid domain names with at least two segments and a maximum length of 253 characters. </summary>
<remarks> See the AT Protocol specification: https://atproto.com/specs/handle </remarks>
val value: Handle -> string
<summary> Extract the string representation of a handle. </summary>
<param name="handle">The handle to extract the value from.</param>
<returns>The full handle string (e.g. <c>"my-handle.bsky.social"</c>).</returns>
FeedItem.Post: TimelinePost
TimelinePost.Author: ProfileSummary
ProfileSummary.Handle: Handle
val text: string
TimelinePost.Text: string
val like: agent: AtpAgent -> target: 'a -> System.Threading.Tasks.Task<Result<LikeRef,XrpcError>> (requires member ToPostRef)
<summary> Like a post or other record. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="target">A <see cref="PostRef" /> or <see cref="TimelinePost" /> identifying the record to like.</param>
<returns>A <see cref="LikeRef" /> on success, or an <see cref="XrpcError" />. Pass the <c>LikeRef</c> to <see cref="unlike" /> to undo.</returns>
LikeRef.Uri: AtUri
<summary>The AT-URI of the like record.</summary>
val undoLike: agent: AtpAgent -> likeRef: LikeRef -> System.Threading.Tasks.Task<Result<UndoResult,XrpcError>>
<summary> Undo a like by deleting the like record. Returns <see cref="UndoResult.Undone" /> on success. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="likeRef">The <see cref="LikeRef" /> returned by <see cref="like" />.</param>
<returns><c>Ok Undone</c> on success, or an <see cref="XrpcError" />. Note: the AT Protocol's deleteRecord is idempotent, so this always returns <c>Undone</c> even if the record was already deleted. Only target-based functions (<see cref="unlikePost" />/<see cref="unrepostPost" />) can return <c>WasNotPresent</c>. </returns>
val reply: PostRef
val replyTo: agent: AtpAgent -> text: string -> parent: 'a -> System.Threading.Tasks.Task<Result<PostRef,XrpcError>> (requires member ToPostRef)
<summary> Reply to a post. Fetches the parent to auto-resolve the thread root. This is the recommended way to reply: you only need the parent post's <see cref="PostRef" />. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="text">The reply text. Mentions, links, and hashtags are auto-detected.</param>
<param name="parent">A <see cref="PostRef" /> or <see cref="TimelinePost" /> for the post being replied to.</param>
<returns>A <see cref="PostRef" /> with the AT-URI and CID on success, or an <see cref="XrpcError" />.</returns>
<remarks> Fetches the parent post via <c>app.bsky.feed.getPosts</c> to determine the thread root. If the parent has a <c>reply</c> field, its root is used. Otherwise, the parent itself is the root. For full control over both parent and root, use <see cref="replyWithKnownRoot" /> instead. </remarks>
val imageBytes: byte array
namespace System
namespace System.IO
type File = static member AppendAllBytes: path: string * bytes: byte array -> unit + 1 overload static member AppendAllBytesAsync: path: string * bytes: byte array * ?cancellationToken: CancellationToken -> Task + 1 overload static member AppendAllLines: path: string * contents: IEnumerable<string> -> unit + 1 overload static member AppendAllLinesAsync: path: string * contents: IEnumerable<string> * encoding: Encoding * ?cancellationToken: CancellationToken -> Task + 1 overload static member AppendAllText: path: string * contents: ReadOnlySpan<char> -> unit + 3 overloads static member AppendAllTextAsync: path: string * contents: ReadOnlyMemory<char> * encoding: Encoding * ?cancellationToken: CancellationToken -> Task + 3 overloads static member AppendText: path: string -> StreamWriter static member Copy: sourceFileName: string * destFileName: string -> unit + 1 overload static member Create: path: string -> FileStream + 2 overloads static member CreateSymbolicLink: path: string * pathToTarget: string -> FileSystemInfo ...
<summary>Provides static methods for the creation, copying, deletion, moving, and opening of a single file, and aids in the creation of <see cref="T:System.IO.FileStream" /> objects.</summary>
System.IO.File.ReadAllBytes(path: string) : byte array
val postWithImages: agent: AtpAgent -> text: string -> images: ImageUpload list -> System.Threading.Tasks.Task<Result<PostRef,XrpcError>>
<summary> Create a post with attached images and automatic rich text detection. Uploads each image as a blob, then creates the post with an <c>app.bsky.embed.images</c> embed. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="text">The post text. Mentions, links, and hashtags are auto-detected.</param>
<param name="images"> A list of <see cref="ImageUpload" /> records describing the images to attach. Alt text is required for accessibility. Bluesky supports up to 4 images per post. </param>
<returns>A <see cref="PostRef" /> with the AT-URI and CID on success, or an <see cref="XrpcError" />.</returns>
<remarks> Images are uploaded sequentially. If any image upload fails, the entire operation returns the error without creating the post. </remarks>
<example><code> let imageBytes = System.IO.File.ReadAllBytes("photo.jpg") let! result = Bluesky.postWithImages agent "Check this out!" [ { Data = imageBytes; MimeType = Jpeg; AltText = "A photo" } ] </code></example>
namespace Microsoft.FSharp.Data
union case ImageMime.Jpeg: ImageMime
val main: 'a -> int
property List.Length: int with get
val first: FeedItem
val deleteRecord: agent: AtpAgent -> target: 'a -> System.Threading.Tasks.Task<Result<unit,XrpcError>> (requires member ToAtUri)
<summary> Delete a record by its AT-URI. Accepts an <see cref="AtUri" />, <see cref="PostRef" />, or <see cref="TimelinePost" />. Can be used to unlike, un-repost, unfollow, unblock, or delete a post. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="target">The AT-URI of the record to delete (an <see cref="AtUri" />, <see cref="PostRef" />, or <see cref="TimelinePost" />).</param>
<returns><c>Ok ()</c> on success, or an <see cref="XrpcError" />.</returns>
<remarks> The AT-URI is parsed to extract the repo DID, collection, and record key. This is a general-purpose delete; pass the AT-URI returned when the record was created. </remarks>

Type something to start searching.