Header menu logo FSharp.ATProto

Pagination

Many AT Protocol endpoints return paginated results. FSharp.ATProto provides single-page convenience functions, pre-built paginators for full iteration, and a general Xrpc.paginate function for everything else.

All examples below use the taskResult computation expression, which short-circuits on errors automatically. See Error Handling for details.

Single-Page Queries

Most use cases only need one page. Use the convenience functions in the Bluesky module -- they accept an optional page size and an optional cursor, and return a single Page<'T>:

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

taskResult {
    let! page = Bluesky.getTimeline agent (Some 50L) None
    for item in page.Items do
        printfn "@%s: %s" item.Post.Author.DisplayName item.Post.Text
}

There are matching single-page functions for most read endpoints: getFollowers, getFollows, getAuthorFeed, getNotifications, searchPosts, searchActors, and more.

Pre-Built Paginators

When you need to process an unbounded result set without loading everything into memory, use a paginator. Each returns IAsyncEnumerable<Result<Page<'T>, XrpcError>> -- pages arrive lazily, one at a time.

Paginator

Signature

Bluesky.paginateTimeline

AtpAgent -> int64 option -> IAsyncEnumerable<Result<Page<FeedItem>, XrpcError>>

Bluesky.paginateFollowers

AtpAgent -> actor -> int64 option -> IAsyncEnumerable<Result<Page<ProfileSummary>, XrpcError>>

Bluesky.paginateNotifications

AtpAgent -> int64 option -> IAsyncEnumerable<Result<Page<Notification>, XrpcError>>

Bluesky.paginateBlocks

AtpAgent -> int64 option -> IAsyncEnumerable<Result<Page<ProfileSummary>, XrpcError>>

Bluesky.paginateMutes

AtpAgent -> int64 option -> IAsyncEnumerable<Result<Page<ProfileSummary>, XrpcError>>

Bluesky.paginateFeed

AtpAgent -> AtUri -> int64 option -> IAsyncEnumerable<Result<Page<FeedItem>, XrpcError>>

Bluesky.paginateListFeed

AtpAgent -> AtUri -> int64 option -> IAsyncEnumerable<Result<Page<FeedItem>, XrpcError>>

paginateFollowers accepts a ProfileSummary, Profile, Handle, or Did as the actor parameter -- pass entities directly instead of extracting identifiers. Pass None as the page size to use the server's default.

Consuming Pages

Here is how to iterate through all pages of your home timeline:

task {
    let pages = Bluesky.paginateTimeline agent (Some 25L)
    let enumerator = pages.GetAsyncEnumerator()

    let mutable hasMore = true
    while hasMore do
        let! moved = enumerator.MoveNextAsync()
        if not moved then
            hasMore <- false
        else
            match enumerator.Current with
            | Ok page ->
                for item in page.Items do
                    printfn "@%s: %s" item.Post.Author.DisplayName item.Post.Text
            | Error err ->
                printfn "Error: %A" err
                hasMore <- false
}

To take a fixed number of pages, add a counter:

task {
    let pages = Bluesky.paginateTimeline agent (Some 25L)
    let enumerator = pages.GetAsyncEnumerator()

    let mutable pageCount = 0
    let mutable hasMore = true
    while hasMore && pageCount < 3 do
        let! moved = enumerator.MoveNextAsync()
        if not moved then
            hasMore <- false
        else
            match enumerator.Current with
            | Ok page ->
                pageCount <- pageCount + 1
                printfn "Page %d: %d items" pageCount page.Items.Length
            | Error _ ->
                hasMore <- false
}

A note on IAsyncEnumerable. This is a .NET interface, not a native F# type, so consuming it requires manual enumerator management as shown above. If you prefer a more functional style, consider the FSharp.Control.TaskSeq NuGet package, which provides taskSeq {} computation expressions for async sequences.

Custom Pagination

For endpoints without a pre-built paginator, use Xrpc.paginate directly. It takes five arguments: the endpoint's TypeId, initial parameters (with Cursor = None), a cursor extractor, a cursor setter, and the agent.

let pages =
    Xrpc.paginate<AppBskyFeed.GetAuthorFeed.Params, AppBskyFeed.GetAuthorFeed.Output>
        AppBskyFeed.GetAuthorFeed.TypeId
        { Actor = Handle.value myHandle
          Cursor = None; Filter = None
          IncludePins = None; Limit = Some 25L }
        (fun o -> o.Cursor)
        (fun c p -> { p with Cursor = c })
        agent

Chat endpoints need the chat proxy agent:

let chatAgent = AtpAgent.withChatProxy agent

let chatPages =
    Xrpc.paginate<ChatBskyConvo.ListConvos.Params, ChatBskyConvo.ListConvos.Output>
        ChatBskyConvo.ListConvos.TypeId
        { Limit = Some 20L; Cursor = None; ReadState = None; Status = None }
        (fun o -> o.Cursor)
        (fun c p -> { p with Cursor = c })
        chatAgent

The pre-built paginators are just this pattern pre-wired for common endpoints.

How Cursors Work

The AT Protocol uses opaque cursor strings for pagination. The server includes a cursor field in the response when more results are available. When the cursor is None, you have reached the end.

Each call to MoveNextAsync on the enumerator:

  1. Sends a query with the current parameters (including the cursor from the previous response)
  2. Yields the response as Ok page or Error err
  3. Extracts the new cursor from the response for the next iteration
  4. If the cursor is None or an error occurs, marks the sequence as finished

You never need to manage cursors yourself -- the paginator handles it all. Pages are fetched lazily, so you only pay for the pages you actually consume.

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 myHandle: Handle
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 taskResult: TaskResultBuilder
val page: Page<FeedItem>
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 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 printfn: format: Printf.TextWriterFormat<'T> -> 'T
FeedItem.Post: TimelinePost
TimelinePost.Author: ProfileSummary
ProfileSummary.DisplayName: string
TimelinePost.Text: string
val task: TaskBuilder
val pages: System.Collections.Generic.IAsyncEnumerable<Result<Page<FeedItem>,XrpcError>>
val paginateTimeline: agent: AtpAgent -> pageSize: int64 option -> System.Collections.Generic.IAsyncEnumerable<Result<Page<FeedItem>,XrpcError>>
<summary> Paginate the home timeline. Returns an async enumerable of pages. Each element is a <c>Result</c> containing one page of feed items. Pagination stops automatically when the server returns no cursor. </summary>
<param name="agent">An authenticated <see cref="AtpAgent" />.</param>
<param name="pageSize">Maximum number of posts per page (optional, pass <c>None</c> for server default).</param>
<returns>An <see cref="System.Collections.Generic.IAsyncEnumerable{T}" /> of paginated results.</returns>
val enumerator: System.Collections.Generic.IAsyncEnumerator<Result<Page<FeedItem>,XrpcError>>
System.Collections.Generic.IAsyncEnumerable.GetAsyncEnumerator(?cancellationToken: System.Threading.CancellationToken) : System.Collections.Generic.IAsyncEnumerator<Result<Page<FeedItem>,XrpcError>>
val mutable hasMore: bool
val moved: bool
System.Collections.Generic.IAsyncEnumerator.MoveNextAsync() : System.Threading.Tasks.ValueTask<bool>
property System.Collections.Generic.IAsyncEnumerator.Current: Result<Page<FeedItem>,XrpcError> with get
<summary>Gets the element in the collection at the current position of the enumerator.</summary>
<returns>The element in the collection at the current position of the enumerator.</returns>
union case Result.Ok: ResultValue: 'T -> Result<'T,'TError>
union case Result.Error: ErrorValue: 'TError -> Result<'T,'TError>
val err: XrpcError
val mutable pageCount: int
property List.Length: int with get
val pages: System.Collections.Generic.IAsyncEnumerable<Result<AppBskyFeed.GetAuthorFeed.Output,XrpcError>>
module Xrpc from FSharp.ATProto.Core
<summary> XRPC transport layer for AT Protocol API calls. Provides functions for executing queries (HTTP GET) and procedures (HTTP POST) against an authenticated <see cref="AtpAgent" />. </summary>
<remarks> All public functions in this module automatically handle: <list type="bullet"><item><description>Bearer token authentication from the agent's session.</description></item><item><description>Automatic session refresh on 401 <c>ExpiredToken</c> responses (retries once with a new access token).</description></item><item><description>Rate-limit retry on 429 responses (waits for the <c>Retry-After</c> duration, then retries once).</description></item><item><description>JSON serialization/deserialization using <see cref="Json.options" />.</description></item><item><description>Extra headers from <see cref="AtpAgent.ExtraHeaders" /> (e.g. proxy headers).</description></item></list></remarks>
val paginate: nsid: string -> initialParams: 'P -> getCursor: ('O -> string option) -> setCursor: (string option -> 'P -> 'P) -> agent: AtpAgent -> System.Collections.Generic.IAsyncEnumerable<Result<'O,XrpcError>>
<summary> Paginates through a cursor-based XRPC query, returning an <c>IAsyncEnumerable</c> of pages. </summary>
<param name="nsid">The NSID of the XRPC query method (e.g. <c>"app.bsky.feed.getTimeline"</c>).</param>
<param name="initialParams">The initial query parameters (typically with cursor set to <c>None</c>).</param>
<param name="getCursor"> A function that extracts the next-page cursor from a response. Return <c>None</c> to signal that there are no more pages. </param>
<param name="setCursor"> A function that produces updated parameters with the given cursor value set. </param>
<param name="agent">The <see cref="AtpAgent" /> to send requests through.</param>
<typeparam name="P">The parameter record type.</typeparam>
<typeparam name="O">The output type for each page of results.</typeparam>
<returns> An <c>IAsyncEnumerable</c> that yields one <c>Result</c> per page. Enumeration stops when <paramref name="getCursor" /> returns <c>None</c> or when an error occurs. On error, the error result is yielded as the final element. </returns>
<remarks> Each page is fetched lazily as the caller iterates the async enumerable. The underlying <see cref="query" /> function handles rate limiting and token refresh automatically. </remarks>
<example><code> let pages = Xrpc.paginate "app.bsky.feed.getTimeline" { Limit = Some 50; Cursor = None } (fun output -&gt; output.Cursor) (fun cursor p -&gt; { p with Cursor = cursor }) agent </code></example>
module AppBskyFeed from FSharp.ATProto.Bluesky
module GetAuthorFeed from FSharp.ATProto.Bluesky.AppBskyFeed
type Params = { Actor: string Cursor: string option Filter: ParamsFilter option IncludePins: bool option Limit: int64 option }
type Output = { Cursor: string option Feed: FeedViewPost list }
[<Literal>] val TypeId: string = "app.bsky.feed.getAuthorFeed"
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>
val o: AppBskyFeed.GetAuthorFeed.Output
AppBskyFeed.GetAuthorFeed.Output.Cursor: string option
val c: string option
val p: AppBskyFeed.GetAuthorFeed.Params
val chatAgent: AtpAgent
val withChatProxy: agent: AtpAgent -> AtpAgent
<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>
val chatPages: System.Collections.Generic.IAsyncEnumerable<Result<ChatBskyConvo.ListConvos.Output,XrpcError>>
module ChatBskyConvo from FSharp.ATProto.Bluesky
module ListConvos from FSharp.ATProto.Bluesky.ChatBskyConvo
type Params = { Cursor: string option Limit: int64 option ReadState: ParamsReadState option Status: ParamsStatus option }
type Output = { Convos: ConvoView list Cursor: string option }
[<Literal>] val TypeId: string = "chat.bsky.convo.listConvos"
val o: ChatBskyConvo.ListConvos.Output
ChatBskyConvo.ListConvos.Output.Cursor: string option
val p: ChatBskyConvo.ListConvos.Params

Type something to start searching.