Quickstart
Get from zero to posting on Bluesky in under 5 minutes.
Code samples use
taskResult {}, a computation expression that chains async operations returningResult. See Error Handling for details.
Prerequisites
- .NET 10 SDK or later
- A Bluesky account with an App Password (do not use your main password)
Create a Project
|
Add the NuGet package:
|
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:
|
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
- Build a Bot -- end-to-end bot tutorial
- Concepts -- AT Protocol terms explained (DID, Handle, AT-URI, CID, PDS)
- Posts Guide -- reading posts, threads, and search
- Social Actions Guide -- like, repost, follow, block, and undo
- Feeds Guide -- timelines and custom feeds
- Profiles Guide -- fetch user profiles
- Media Guide -- image uploads
- Chat / DM Guide -- direct messaging
- Notifications -- unread counts, mark-as-read
- Moderation -- mute, block, report
- Rich Text Guide -- finer control over mention/link/hashtag detection
- Identity Guide -- resolve handles and DIDs
- Error Handling -- XrpcError, taskResult, retry behaviour
- Pagination Guide -- iterate through large result sets
- Raw XRPC -- drop to generated wrappers for advanced usage
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> 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>
<summary>A paginated result containing a list of items and an optional cursor for the next page.</summary>
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>
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>
<summary> A reference to a like record, returned by <c>Bluesky.like</c>. Pass to <c>Bluesky.unlike</c> to undo. </summary>
<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>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>
<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>
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> 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>
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>
<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>
<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>
<summary>The AT-URI of the like record.</summary>
<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>
<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>
<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>
<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>
<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>