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 |
|---|---|
|
DID document for |
|
Lists available feeds |
|
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.
namespace FSharp
--------------------
namespace Microsoft.FSharp
val string: value: 'T -> string
--------------------
type string = System.String
val int: value: 'T -> int (requires member op_Explicit)
--------------------
type int = int32
--------------------
type int<'Measure> = int
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
<summary> A single item in a feed skeleton response. </summary>
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> Helper module for creating feed algorithms from functions. </summary>
<summary> Create a feed algorithm from an async function. </summary>
<summary> Create a feed algorithm from a synchronous function. </summary>
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 ...
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>
<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 -> printfn "Valid: %s" (AtUri.value uri) | Error e -> printfn "Invalid: %s" e </code></example>
module Result from Microsoft.FSharp.Core
--------------------
[<Struct>] type Result<'T,'TError> = | Ok of ResultValue: 'T | Error of ErrorValue: 'TError
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>
<summary> Parse and validate a DID string. </summary>
<param name="s"> A DID string in the format <c>did:<method>:<method-specific-id></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 -> printfn "Valid: %s" (Did.value did) | Error e -> printfn "Invalid: %s" e </code></example>
<summary> Configuration for the feed generator server. </summary>
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>