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 returningResult. See Error Handling for details. The bot example below usestask {}deliberately -- see Why task instead of taskResult for the reasoning.
Prerequisites
- .NET 10 SDK or later
- A Bluesky account with an App Password (do not use your main password)
Create the Project
|
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:
|
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:
- Reply to mentions: Use
Bluesky.getNotificationsto check for mentions, thenBluesky.replyToto respond. See the Notifications guide. - Follow back: When someone follows you, use
Bluesky.followto follow them back. See the Social Actions guide. - Post on a schedule: Use
Bluesky.poston a timer to publish content at regular intervals. See the Posts guide. - Search for users: Use
Bluesky.searchActorsto find accounts by keyword. See the Profiles guide. - Send DMs: Use
Chat.getConvoForMembersandChat.sendMessageto message users directly. See the Chat guide. - Advanced search filters: Drop down to
AppBskyFeed.SearchPosts.queryfor author, language, domain, and date range filters. See the Posts guide.
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>
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> 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>A human-readable error message from the response body, or <c>None</c> if absent.</summary>
<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>
<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>
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>
[<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)
<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([<StringSyntaxAttribute ("DateTimeFormat")>] format: string) : string
DateTime.ToString(provider: IFormatProvider) : string
DateTime.ToString([<StringSyntaxAttribute ("DateTimeFormat")>] format: string, provider: IFormatProvider) : string
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(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
[<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(value: float) : TimeSpan
TimeSpan.FromSeconds(seconds: int64, ?milliseconds: int64, ?microseconds: int64) : TimeSpan
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 like record.</summary>