Header menu logo FSharp.ATProto

Build a Bot

This tutorial walks through building a Bluesky bot from scratch. By the end, you will have a running F# program that monitors the #fsharp hashtag on Bluesky and automatically likes new posts it finds.

Code samples use taskResult {}, a computation expression that chains async operations returning Result. See Error Handling for details. The bot example below uses task {} deliberately -- see Why task instead of taskResult for the reasoning.

Prerequisites

Create the Project

mkdir fsharp-bot && cd fsharp-bot
dotnet new console -lang F#

Add a project reference to FSharp.ATProto.Bluesky (the top-level package that pulls in everything you need).

Then replace the contents of Program.fs with the code below.

The Complete Bot

open System
open System.Threading.Tasks
open FSharp.ATProto.Syntax
open FSharp.ATProto.Core
open FSharp.ATProto.Bluesky

let main _ =
    let run =
        task {
            // Step 1: Log in
            match! Bluesky.login "https://bsky.social" "your-handle.bsky.social" "your-app-password" with
            | Error err ->
                printfn "Login failed: %s" (err.Message |> Option.defaultValue "unknown error")
            | Ok agent ->
                printfn "Logged in!"

                // Step 2: Poll loop
                while true do
                    match! Bluesky.searchPosts agent "#fsharp" (Some 25L) None with
                    | Ok page ->
                        for post in page.Items do
                            if not post.IsLiked then
                                match! Bluesky.like agent post with
                                | Ok _ ->
                                    printfn "Liked post by @%s: %s"
                                        (Handle.value post.Author.Handle)
                                        (if post.Text.Length > 80 then post.Text.[..79] + "..." else post.Text)
                                | Error err ->
                                    printfn "Failed to like post: %s" (err.Message |> Option.defaultValue "unknown error")

                        printfn "[%s] Checked %d posts" (DateTime.Now.ToString("HH:mm:ss")) page.Items.Length
                    | Error err ->
                        printfn "Search failed: %s" (err.Message |> Option.defaultValue "unknown error")

                    do! Task.Delay(TimeSpan.FromSeconds(60.0))
        }

    run.GetAwaiter().GetResult()
    0

Replace "your-handle.bsky.social" and "your-app-password" with your actual credentials. Run it:

dotnet run

The bot will log in, search for #fsharp posts, like any it has not already liked, then sleep for 60 seconds and repeat.

How It Works

Authentication

Bluesky.login takes a PDS URL, your handle, and an app password. It returns Task<Result<AtpAgent, XrpcError>>. On success, the AtpAgent holds your authenticated session and handles token refresh automatically -- if your access token expires mid-run, the library refreshes it behind the scenes.

Why task {} instead of taskResult {}

You might expect to see taskResult {} here, since most examples in this library use it. The difference: taskResult short-circuits on the first error. That is perfect for linear workflows (log in, post, done), but a bot needs to keep running even when individual operations fail. A search might time out, or a like might hit a rate limit. By using plain task {} with manual match! on each call, we handle errors inline and let the loop continue. See the Error Handling guide for more on choosing between these two styles.

Searching

Bluesky.searchPosts runs a full-text search and returns Page<TimelinePost>. The first argument after the agent is the query string -- hashtags work as you would expect. Some 25L requests up to 25 results per page, and None for the cursor means "start from the beginning." Each TimelinePost has an IsLiked field that reflects whether the authenticated user has already liked that post, so we skip posts we have already liked.

taskResult {
    let! page = Bluesky.searchPosts agent "#fsharp" (Some 25L) None
    for post in page.Items do
        if not post.IsLiked then
            printfn "@%s: %s" (Handle.value post.Author.Handle) post.Text
}

Liking

Bluesky.like accepts a TimelinePost directly and returns a LikeRef on success. The LikeRef contains the AT-URI of the like record itself -- useful if you later want to undo it with Bluesky.unlikePost.

taskResult {
    let! likeRef = Bluesky.like agent firstPost
    printfn "Liked: %s" (AtUri.value likeRef.Uri)
    ()
}

The Loop

The bot re-searches every 60 seconds. Since we pass None as the cursor each time, we always get the most recent results. Posts we have already liked are skipped thanks to the IsLiked check, so running the same search repeatedly is safe. In a production bot, you would track the cursor or a timestamp to avoid re-fetching the same page, and you might persist state across restarts.

Next Steps

Here are some ideas for extending the bot:

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 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>
namespace System
namespace System.Threading
namespace System.Threading.Tasks
val main: 'a -> int
val run: Task<unit>
val task: TaskBuilder
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 -> Task<Result<AtpAgent,XrpcError>>
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
val err: XrpcError
val printfn: format: Printf.TextWriterFormat<'T> -> 'T
XrpcError.Message: string option
<summary>A human-readable error message from the response body, or <c>None</c> if absent.</summary>
module Option from Microsoft.FSharp.Core
val defaultValue: value: 'T -> option: 'T option -> 'T
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
val searchPosts: agent: AtpAgent -> query: string -> limit: int64 option -> cursor: string option -> Task<Result<Page<TimelinePost>,XrpcError>>
<summary> Search for posts matching a query string. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="query">The search query string.</param>
<param name="limit">Maximum number of posts to return (optional).</param>
<param name="cursor">Pagination cursor from a previous response (optional).</param>
<returns>A page of <see cref="TimelinePost" /> 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 page: Page<TimelinePost>
val post: TimelinePost
Page.Items: TimelinePost list
TimelinePost.IsLiked: bool
val like: agent: AtpAgent -> target: 'a -> 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>
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>
TimelinePost.Author: ProfileSummary
ProfileSummary.Handle: Handle
TimelinePost.Text: string
property String.Length: int with get
Multiple items
[<Struct>] type DateTime = new: date: DateOnly * time: TimeOnly -> unit + 16 overloads member Add: value: TimeSpan -> DateTime member AddDays: value: float -> DateTime member AddHours: value: float -> DateTime member AddMicroseconds: value: float -> DateTime member AddMilliseconds: value: float -> DateTime member AddMinutes: value: float -> DateTime member AddMonths: months: int -> DateTime member AddSeconds: value: float -> DateTime member AddTicks: value: int64 -> DateTime ...
<summary>Represents an instant in time, typically expressed as a date and time of day.</summary>

--------------------
DateTime ()
   (+0 other overloads)
DateTime(ticks: int64) : DateTime
   (+0 other overloads)
DateTime(date: DateOnly, time: TimeOnly) : DateTime
   (+0 other overloads)
DateTime(ticks: int64, kind: DateTimeKind) : DateTime
   (+0 other overloads)
DateTime(date: DateOnly, time: TimeOnly, kind: DateTimeKind) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int, calendar: Globalization.Calendar) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, kind: DateTimeKind) : DateTime
   (+0 other overloads)
DateTime(year: int, month: int, day: int, hour: int, minute: int, second: int, calendar: Globalization.Calendar) : DateTime
   (+0 other overloads)
property DateTime.Now: DateTime with get
<summary>Gets a <see cref="T:System.DateTime" /> object that is set to the current date and time on this computer, expressed as the local time.</summary>
<returns>An object whose value is the current local date and time.</returns>
DateTime.ToString() : string
DateTime.ToString([<StringSyntaxAttribute ("DateTimeFormat")>] format: string) : string
DateTime.ToString(provider: IFormatProvider) : string
DateTime.ToString([<StringSyntaxAttribute ("DateTimeFormat")>] format: string, provider: IFormatProvider) : string
property List.Length: int with get
Multiple items
type Task = interface IAsyncResult interface IDisposable new: action: Action -> unit + 7 overloads member ConfigureAwait: continueOnCapturedContext: bool -> ConfiguredTaskAwaitable + 1 overload member ContinueWith: continuationAction: Action<Task,obj> * state: obj -> Task + 19 overloads member Dispose: unit -> unit member GetAwaiter: unit -> TaskAwaiter member RunSynchronously: unit -> unit + 1 overload member Start: unit -> unit + 1 overload member Wait: unit -> unit + 5 overloads ...
<summary>Represents an asynchronous operation.</summary>

--------------------
type Task<'TResult> = inherit Task new: ``function`` : Func<obj,'TResult> * state: obj -> unit + 7 overloads member ConfigureAwait: continueOnCapturedContext: bool -> ConfiguredTaskAwaitable<'TResult> + 1 overload member ContinueWith: continuationAction: Action<Task<'TResult>,obj> * state: obj -> Task + 19 overloads member GetAwaiter: unit -> TaskAwaiter<'TResult> member WaitAsync: cancellationToken: CancellationToken -> Task<'TResult> + 4 overloads member Result: 'TResult static member Factory: TaskFactory<'TResult>
<summary>Represents an asynchronous operation that can return a value.</summary>
<typeparam name="TResult">The type of the result produced by this <see cref="T:System.Threading.Tasks.Task`1" />.</typeparam>


--------------------
Task(action: Action) : Task
Task(action: Action, cancellationToken: Threading.CancellationToken) : Task
Task(action: Action, creationOptions: TaskCreationOptions) : Task
Task(action: Action<obj>, state: obj) : Task
Task(action: Action, cancellationToken: Threading.CancellationToken, creationOptions: TaskCreationOptions) : Task
Task(action: Action<obj>, state: obj, cancellationToken: Threading.CancellationToken) : Task
Task(action: Action<obj>, state: obj, creationOptions: TaskCreationOptions) : Task
Task(action: Action<obj>, state: obj, cancellationToken: Threading.CancellationToken, creationOptions: TaskCreationOptions) : Task

--------------------
Task(``function`` : Func<'TResult>) : Task<'TResult>
Task(``function`` : Func<obj,'TResult>, state: obj) : Task<'TResult>
Task(``function`` : Func<'TResult>, cancellationToken: Threading.CancellationToken) : Task<'TResult>
Task(``function`` : Func<'TResult>, creationOptions: TaskCreationOptions) : Task<'TResult>
Task(``function`` : Func<obj,'TResult>, state: obj, cancellationToken: Threading.CancellationToken) : Task<'TResult>
Task(``function`` : Func<obj,'TResult>, state: obj, creationOptions: TaskCreationOptions) : Task<'TResult>
Task(``function`` : Func<'TResult>, cancellationToken: Threading.CancellationToken, creationOptions: TaskCreationOptions) : Task<'TResult>
Task(``function`` : Func<obj,'TResult>, state: obj, cancellationToken: Threading.CancellationToken, creationOptions: TaskCreationOptions) : Task<'TResult>
Task.Delay(delay: TimeSpan) : Task
Task.Delay(millisecondsDelay: int) : Task
Task.Delay(delay: TimeSpan, timeProvider: TimeProvider) : Task
Task.Delay(delay: TimeSpan, cancellationToken: Threading.CancellationToken) : Task
Task.Delay(millisecondsDelay: int, cancellationToken: Threading.CancellationToken) : Task
Task.Delay(delay: TimeSpan, timeProvider: TimeProvider, cancellationToken: Threading.CancellationToken) : Task
Multiple items
[<Struct>] type TimeSpan = new: hours: int * minutes: int * seconds: int -> unit + 4 overloads member Add: ts: TimeSpan -> TimeSpan member CompareTo: value: obj -> int + 1 overload member Divide: divisor: float -> TimeSpan + 1 overload member Duration: unit -> TimeSpan member Equals: value: obj -> bool + 2 overloads member GetHashCode: unit -> int member Multiply: factor: float -> TimeSpan member Negate: unit -> TimeSpan member Subtract: ts: TimeSpan -> TimeSpan ...
<summary>Represents a time interval.</summary>

--------------------
TimeSpan ()
TimeSpan(ticks: int64) : TimeSpan
TimeSpan(hours: int, minutes: int, seconds: int) : TimeSpan
TimeSpan(days: int, hours: int, minutes: int, seconds: int) : TimeSpan
TimeSpan(days: int, hours: int, minutes: int, seconds: int, milliseconds: int) : TimeSpan
TimeSpan(days: int, hours: int, minutes: int, seconds: int, milliseconds: int, microseconds: int) : TimeSpan
TimeSpan.FromSeconds(seconds: int64) : TimeSpan
TimeSpan.FromSeconds(value: float) : TimeSpan
TimeSpan.FromSeconds(seconds: int64, ?milliseconds: int64, ?microseconds: int64) : TimeSpan
Task.GetAwaiter() : Runtime.CompilerServices.TaskAwaiter<unit>
val taskResult: TaskResultBuilder
val likeRef: LikeRef
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>
LikeRef.Uri: AtUri
<summary>The AT-URI of the like record.</summary>

Type something to start searching.