Header menu logo FSharp.ATProto

Feed Generator

A feed generator is an AT Protocol service that provides custom feed algorithms. When a user subscribes to a custom feed, Bluesky's app view calls your feed generator to get a "skeleton" -- a list of post AT-URIs -- and then hydrates those posts with full content before displaying them. Your generator decides which posts appear and in what order; the app view handles everything else.

FSharp.ATProto.FeedGenerator provides an ASP.NET minimal API framework for building feed generators. It handles the protocol-level endpoints so you can focus on your feed logic.

open FSharp.ATProto.FeedGenerator
open FSharp.ATProto.Syntax

Key Types

A feed skeleton response is built from these types:

type SkeletonItem = {
    Post: AtUri
    Reason: SkeletonReason option
}

type SkeletonFeed = {
    Feed: SkeletonItem list
    Cursor: string option
}

Each SkeletonItem contains a post AT-URI. The optional Reason field can indicate that a post is included because someone reposted it:

type SkeletonReason =
    | RepostBy of did: Did * indexedAt: string

Your feed algorithm receives a FeedQuery with the requested feed URI, page limit, and optional cursor for pagination:

type FeedQuery = {
    Feed: AtUri
    Limit: int
    Cursor: string option
}

Implementing a Feed Algorithm

The IFeedAlgorithm interface has a single method:

type IFeedAlgorithm =
    abstract member GetFeedSkeleton : query: FeedQuery -> Task<SkeletonFeed>

You can implement this interface directly, or use the helper functions in the FeedAlgorithm module.

FeedAlgorithm.fromFunction wraps an async function:

let myFeed =
    FeedAlgorithm.fromFunction (fun query ->
        task {
            // Your async feed logic here
            let! posts = fetchRecentPosts query.Limit query.Cursor
            return { Feed = posts; Cursor = nextCursor }
        })

FeedAlgorithm.fromSync wraps a synchronous function -- useful for feeds backed by in-memory data:

let myFeed2 =
    FeedAlgorithm.fromSync (fun query ->
        let posts =
            allPosts
            |> List.take (min query.Limit (List.length allPosts))
            |> List.map (fun uri -> { Post = uri; Reason = None })
        { Feed = posts; Cursor = None })

Server Configuration

FeedGeneratorConfig ties your algorithms to a hostname and DID:

type FeedGeneratorConfig = {
    Hostname: string
    ServiceDid: Did
    Feeds: Map<string, IFeedAlgorithm>
    Descriptions: FeedDescription list
    Port: int
}

The Feeds map is keyed by the record key (rkey) portion of the feed AT-URI. For example, if a user subscribes to at://did:web:feed.example.com/app.bsky.feed.generator/chronological, the framework looks up "chronological" in this map.

Descriptions are returned by the describeFeedGenerator endpoint so clients know what feeds you offer:

type FeedDescription = {
    Uri: AtUri
    DisplayName: string
    Description: string option
    Avatar: string option
}

Running the Server

FeedServer.configure builds an ASP.NET WebApplication with three endpoints:

Route

Purpose

GET /.well-known/did.json

DID document for did:web resolution

GET /xrpc/app.bsky.feed.describeFeedGenerator

Lists available feeds

GET /xrpc/app.bsky.feed.getFeedSkeleton

Returns the feed skeleton for a query

Complete Example

open FSharp.ATProto.FeedGenerator
open FSharp.ATProto.Syntax

// Define a feed algorithm
let chronoFeed =
    FeedAlgorithm.fromSync (fun query ->
        // Your feed logic -- return post AT-URIs
        { Feed =
            [ { Post = AtUri.parse "at://did:plc:xyz/app.bsky.feed.post/abc" |> Result.defaultWith failwith
                Reason = None } ]
          Cursor = None })

// Build the feed URI for descriptions
let feedUri =
    AtUri.parse "at://did:web:feed.example.com/app.bsky.feed.generator/chronological"
    |> Result.defaultWith failwith

let serviceDid = Did.parse "did:web:feed.example.com" |> Result.defaultWith failwith

// Configure and run the server
let config : FeedGeneratorConfig =
    { Hostname = "feed.example.com"
      ServiceDid = serviceDid
      Feeds = Map.ofList [ "chronological", chronoFeed ]
      Descriptions =
        [ { Uri = feedUri
            DisplayName = "Chronological"
            Description = Some "Posts in reverse chronological order"
            Avatar = None } ]
      Port = 3000 }
let app = FeedServer.configure config
app.Run()

The server starts on port 3000. Bluesky's app view will call GET /xrpc/app.bsky.feed.getFeedSkeleton?feed=at://...&limit=50 and your algorithm returns the skeleton. The framework handles parameter parsing, limit clamping (1--100, default 50), unknown feed errors, and JSON serialization.

Multiple items
namespace FSharp

--------------------
namespace Microsoft.FSharp
namespace FSharp.ATProto
namespace FSharp.ATProto.Syntax
namespace FSharp.ATProto.FeedGenerator
type SkeletonItem = { Post: obj Reason: obj }
type 'T option = Option<'T>
type SkeletonFeed = { Feed: SkeletonItem list Cursor: string option }
type 'T list = List<'T>
Multiple items
val string: value: 'T -> string

--------------------
type string = System.String
type SkeletonReason = | RepostBy of did: obj * indexedAt: string
type FeedQuery = { Feed: obj Limit: int Cursor: string option }
Multiple items
val int: value: 'T -> int (requires member op_Explicit)

--------------------
type int = int32

--------------------
type int<'Measure> = int
type IFeedAlgorithm = abstract GetFeedSkeleton: query: FeedQuery -> 'a
val query: Linq.QueryBuilder
val fetchRecentPosts: _limit: int -> _cursor: string option -> System.Threading.Tasks.Task<SkeletonItem list>
val _limit: int
val _cursor: string option
namespace System
namespace System.Threading
namespace System.Threading.Tasks
Multiple items
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>


--------------------
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>

--------------------
System.Threading.Tasks.Task(``function`` : System.Func<'TResult>) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<obj,'TResult>, state: obj) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<'TResult>, cancellationToken: System.Threading.CancellationToken) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<'TResult>, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<obj,'TResult>, state: obj, cancellationToken: System.Threading.CancellationToken) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<obj,'TResult>, state: obj, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<'TResult>, cancellationToken: System.Threading.CancellationToken, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task<'TResult>
System.Threading.Tasks.Task(``function`` : System.Func<obj,'TResult>, state: obj, cancellationToken: System.Threading.CancellationToken, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task<'TResult>

--------------------
System.Threading.Tasks.Task(action: System.Action) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action, cancellationToken: System.Threading.CancellationToken) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action<obj>, state: obj) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action, cancellationToken: System.Threading.CancellationToken, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action<obj>, state: obj, cancellationToken: System.Threading.CancellationToken) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action<obj>, state: obj, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task
System.Threading.Tasks.Task(action: System.Action<obj>, state: obj, cancellationToken: System.Threading.CancellationToken, creationOptions: System.Threading.Tasks.TaskCreationOptions) : System.Threading.Tasks.Task
type SkeletonItem = { Post: AtUri Reason: SkeletonReason option }
<summary> A single item in a feed skeleton response. </summary>
module Unchecked from Microsoft.FSharp.Core.Operators
val defaultof<'T> : 'T
val nextCursor: string option
val allPosts: AtUri list
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 myFeed: IFeedAlgorithm
module FeedAlgorithm from FSharp.ATProto.FeedGenerator
<summary> Helper module for creating feed algorithms from functions. </summary>
val fromFunction: f: (FeedQuery -> System.Threading.Tasks.Task<SkeletonFeed>) -> IFeedAlgorithm
<summary> Create a feed algorithm from an async function. </summary>
val query: FeedQuery
val task: TaskBuilder
val posts: SkeletonItem list
FeedQuery.Limit: int
FeedQuery.Cursor: string option
val myFeed2: IFeedAlgorithm
val fromSync: f: (FeedQuery -> SkeletonFeed) -> IFeedAlgorithm
<summary> Create a feed algorithm from a synchronous function. </summary>
Multiple items
module List from Microsoft.FSharp.Collections

--------------------
type List<'T> = | op_Nil | op_ColonColon of Head: 'T * Tail: 'T list interface IReadOnlyList<'T> interface IReadOnlyCollection<'T> interface IEnumerable interface IEnumerable<'T> member GetReverseIndex: rank: int * offset: int -> int member GetSlice: startIndex: int option * endIndex: int option -> 'T list static member Cons: head: 'T * tail: 'T list -> 'T list member Head: 'T with get member IsEmpty: bool with get member Item: index: int -> 'T with get ...
val take: count: int -> list: 'T list -> 'T list
val min: e1: 'T -> e2: 'T -> 'T (requires comparison)
val length: list: 'T list -> int
val map: mapping: ('T -> 'U) -> list: 'T list -> 'U list
val uri: AtUri
union case Option.None: Option<'T>
type FeedGeneratorConfig = { Hostname: string ServiceDid: obj Feeds: Map<string,IFeedAlgorithm> Descriptions: obj Port: int }
Multiple items
module Map from Microsoft.FSharp.Collections

--------------------
type Map<'Key,'Value (requires comparison)> = interface IReadOnlyDictionary<'Key,'Value> interface IReadOnlyCollection<KeyValuePair<'Key,'Value>> interface IEnumerable interface IStructuralEquatable interface IComparable interface IEnumerable<KeyValuePair<'Key,'Value>> interface ICollection<KeyValuePair<'Key,'Value>> interface IDictionary<'Key,'Value> new: elements: ('Key * 'Value) seq -> Map<'Key,'Value> member Add: key: 'Key * value: 'Value -> Map<'Key,'Value> ...

--------------------
new: elements: ('Key * 'Value) seq -> Map<'Key,'Value>
type FeedDescription = { Uri: obj DisplayName: string Description: string option Avatar: string option }
val chronoFeed: IFeedAlgorithm
val parse: s: string -> Result<AtUri,string>
<summary> Parse and validate an AT-URI string. </summary>
<param name="s"> An AT-URI string starting with <c>"at://"</c> followed by an authority (DID or handle), and optionally a collection (NSID) and record key path segments. Query parameters and fragments are not allowed. </param>
<returns><c>Ok</c> with a validated <see cref="AtUri" />, or <c>Error</c> with a message describing the validation failure. Validation failures include: null input, exceeding 8192 characters, missing <c>at://</c> prefix, presence of query/fragment components, invalid authority (must be a valid DID or handle), invalid collection (must be a valid NSID), invalid record key, or trailing slash. </returns>
<example><code> match AtUri.parse "at://my-handle.bsky.social/app.bsky.feed.post/3k2la3b" with | Ok uri -&gt; printfn "Valid: %s" (AtUri.value uri) | Error e -&gt; printfn "Invalid: %s" e </code></example>
Multiple items
module Result from Microsoft.FSharp.Core

--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
val defaultWith: defThunk: ('Error -> 'T) -> result: Result<'T,'Error> -> 'T
val failwith: message: string -> 'T
val feedUri: AtUri
val serviceDid: Did
Multiple items
module Did from FSharp.ATProto.Syntax
<summary> Functions for creating, validating, and extracting data from <see cref="Did" /> values. </summary>

--------------------
type Did = private | Did of string override ToString: unit -> string
<summary> A decentralized identifier (DID) as defined by the AT Protocol. DIDs are the primary stable identifier for accounts. Two methods are currently supported: <c>did:plc:</c> (hosted, managed by PLC directory) and <c>did:web:</c> (self-hosted, DNS-based). </summary>
<remarks> See the AT Protocol specification: https://atproto.com/specs/did and the W3C DID specification: https://www.w3.org/TR/did-core/ </remarks>
val parse: s: string -> Result<Did,string>
<summary> Parse and validate a DID string. </summary>
<param name="s"> A DID string in the format <c>did:&lt;method&gt;:&lt;method-specific-id&gt;</c> (e.g. <c>"did:plc:z72i7hdynmk6r22z27h6tvur"</c> or <c>"did:web:example.com"</c>). </param>
<returns><c>Ok</c> with a validated <see cref="Did" />, or <c>Error</c> with a message describing the validation failure. Validation failures include: null input, exceeding the 2048-character limit, invalid DID syntax, or invalid percent-encoding. </returns>
<example><code> match Did.parse "did:plc:z72i7hdynmk6r22z27h6tvur" with | Ok did -&gt; printfn "Valid: %s" (Did.value did) | Error e -&gt; printfn "Invalid: %s" e </code></example>
val config: FeedGeneratorConfig
type FeedGeneratorConfig = { Hostname: string ServiceDid: Did Feeds: Map<string,IFeedAlgorithm> Descriptions: FeedDescription list Port: int }
<summary> Configuration for the feed generator server. </summary>
val ofList: elements: ('Key * 'T) list -> Map<'Key,'T> (requires comparison)
Multiple items
module Uri from FSharp.ATProto.Syntax
<summary> Functions for creating, validating, and extracting data from <see cref="Uri" /> values. </summary>

--------------------
type Uri = private | Uri of string override ToString: unit -> string
<summary> A general URI as defined by RFC 3986, used in the AT Protocol for links and references. Must have a valid scheme (starting with a letter, followed by letters, digits, <c>+</c>, <c>-</c>, or <c>.</c>) followed by <c>:</c> and a non-empty scheme-specific part with no whitespace. Maximum length is 8192 characters. </summary>
<remarks> This performs basic syntactic validation only. Valid scheme examples include <c>https</c>, <c>dns</c>, <c>at</c>, <c>did</c>, and <c>content-type</c>. See https://www.rfc-editor.org/rfc/rfc3986 for the full URI specification. </remarks>
union case Option.Some: Value: 'T -> Option<'T>
val app: obj

Type something to start searching.