Write a Custom Feed

Host an algorithm that's accessible from apps like Bluesky.

Custom feeds, or feed generators, are services that provide algorithms to users. This gives you lots of freedom: to choose your own timeline view, or to create a embeddable feed for other users.

In this tutorial, you'll create a custom feed server by consuming posts from Jetstream, algorithmically sorting them, and broadcasting them as an XRPC feed.

Prerequisites

This tutorial involves building a TypeScript application from scratch. You should have a working understanding of TypeScript and Node.js.

You should have installed:

  • Node.js 22.15+

On platforms supported by homebrew, you can install it with:

brew install node

You should also have our lex CLI tool and tsx installed globally:

npm install -g @atproto/lex tsx

Now, create a new TypeScript project directory and initialize your project:

mkdir my-agent
cd my-agent
npm init -y
npm pkg set type=module
npm i -D typescript @types/node dotenv @atproto/lex-server
npm i @atproto/lex @bsky/jetstream
npx tsc --init --verbatimModuleSyntax false

You'll begin by adding Lexicons to your project.

Part 1: Lexicons

Lexicons define atproto records. A baseline feed generator requires the app.bsky.feed.describeFeedGenerator and app.bsky.feed.getFeedSkeleton Lexicons. You can use lex to download and build these Lexicons into your project:

lex install app.bsky.feed.describeFeedGenerator app.bsky.feed.getFeedSkeleton

From here, you can run lex build to generate TypeScript types for all of your installed Lexicons:

lex build

The generated code will live in src/lexicons.

The project setup also installs the @bsky/jetstream SDK, which you'll use in Part 3.

Now you can start building your feed generator.

Part 2: Feed Structure

The general flow of providing a custom algorithm to a user is as follows:

  • A user requests a feed from the application backend (e.g. Bluesky)
  • The application finds the DID doc of the requested feed to look up its hosting server
  • The application sends a getFeedSkeleton request to the hosting server
  • The feed's hosting server returns a list of post URLs
  • The application hydrates the feed (user info, post contents, aggregates, etc.)
  • The application returns the hydrated feed to the user

You'll start by capturing some configuration from environment variables. Much of this is configuration for the published feed (FEED_PUBLISHER_DID, FEED_NAME) but some details configure how the feed will behave (FEED_MAX_POSTS, SEARCH_TERMS).

For this example, you'll be creating our entire app in a single file at src/index.ts. Create that file now, and add these imports and config lines:

import { Jetstream } from '@bsky/jetstream'
import { AtUriString, DidString, isDidString } from '@atproto/lex'
import { LexError, LexRouter, serviceAuth } from '@atproto/lex-server'
import { serve } from '@atproto/lex-server/nodejs'
import * as app from './lexicons/app.js'

// =============================================================================
// Configuration
// =============================================================================

interface FeedConfig {
    publisherDid: DidString
    feedName: string
    searchTerms: string[]
    maxPosts: number
    port: number
    initialRepos: DidString[]
}

const publisherDid = process.env.FEED_PUBLISHER_DID || 'did:example:alice'
if (!isDidString(publisherDid)) {
    throw new Error('FEED_PUBLISHER_DID must be a valid DID')
}

const config: FeedConfig = {
    publisherDid,
    feedName: process.env.FEED_NAME || 'goblin-mode',
    searchTerms: (process.env.FEED_SEARCH_TERMS || 'goblin,goblins,gremlin,gremlins,wizard,wizards,chaos').split(',').map(s => s.trim()),
    maxPosts: parseInt(process.env.FEED_MAX_POSTS || '1000', 10),
    port: parseInt(process.env.FEED_PORT || '3000', 10),
    initialRepos: (process.env.FEED_INITIAL_REPOS || '').split(',').map(s => s.trim()).filter(Boolean).map(s => s as DidString),
}

const FEED_URI: AtUriString = `at://${config.publisherDid}/app.bsky.feed.generator/${config.feedName}` as AtUriString

There are a few things to note here. FEED_INITIAL_REPOS sets the Jetstream DID filter. It's empty by default, so you'll receive posts from the whole network. You can set it to a comma-separated list of DIDs to follow specific accounts.

You're sorting this feed by searching for posts that contain certain terms. You can configure these terms with the FEED_SEARCH_TERMS environment variable. In this case, you're building a goblin-mode feed of posts that mention goblins, gremlins, wizards, or chaos.

Next, you'll set up the logic to consume posts from Jetstream.

Part 3: Consuming Jetstream

You'll store the posts you want to serve in an array.

Jetstream streams filtered, decoded JSON events over a WebSocket. The @bsky/jetstream SDK handles the connection and reconnects while your code processes new events. Add the following contents to src/index.ts:

// =============================================================================
// Post Index
// =============================================================================

interface IndexedPost {
    uri: AtUriString
    indexedAt: number
}

const postIndex: IndexedPost[] = []
const jetstream = new Jetstream('https://jetstream.us-east.bsky.network')

function escapeRegExp(value: string) {
    return [...value].map(char => '.^$*+?()[]{}|\\'.includes(char) ? `\\${char}` : char).join('')
}

const searchPattern = new RegExp(
    `\\b(${config.searchTerms.map(escapeRegExp).join('|')})\\b`,
    'i'
)

async function indexJetstream(signal: AbortSignal) {
    for await (const evt of jetstream.live({
        collections: ['app.bsky.feed.post'],
        ...(config.initialRepos.length > 0 && { dids: config.initialRepos }),
        kinds: ['commit', 'account'],
        signal,
    })) {
        if (evt.kind === 'account') {
            if (evt.account.active) continue
            const removed = postIndex.filter(p => p.uri.startsWith(`at://${evt.did}/`)).length
            if (removed > 0) {
                postIndex.splice(0, postIndex.length, ...postIndex.filter(p => !p.uri.startsWith(`at://${evt.did}/`)))
                console.log(`Account ${evt.did} inactive: removed ${removed} posts`)
            }
            continue
        }

        if (evt.kind !== 'commit' || evt.commit.collection !== 'app.bsky.feed.post') continue

        const { rkey } = evt.commit
        const uri = `at://${evt.did}/app.bsky.feed.post/${rkey}` as AtUriString
        const idx = postIndex.findIndex(p => p.uri === uri)

        if (evt.commit.operation === 'delete') {
            if (idx !== -1) {
                postIndex.splice(idx, 1)
                console.log(`DELETE ${uri}`)
            }
            continue
        }

        const text = typeof evt.commit.record?.text === 'string' ? evt.commit.record.text : ''
        if (!searchPattern.test(text)) {
            if (idx !== -1) postIndex.splice(idx, 1)
            continue
        }

        if (idx !== -1) postIndex.splice(idx, 1)
        postIndex.unshift({ uri, indexedAt: Date.now() })
        if (postIndex.length > config.maxPosts) postIndex.pop()

        const preview = text.substring(0, 60).replace(/\n/g, ' ')
        console.log(`${evt.commit.operation.toUpperCase()} ${uri}`)
        console.log(`  \"${preview}${text.length > 60 ? '...' : ''}\"`)
        console.log(`  ⭐ Added to index (${postIndex.length} total)`)
    }
}

This opens a live subscription for app.bsky.feed.post commits. By default, it receives posts from the whole network. Set FEED_INITIAL_REPOS to a comma-separated list of DIDs to follow only those accounts.

For each create or update, the code searches the post text for your configured terms and adds matches to postIndex. It removes posts that no longer match, processes deletions, and removes posts when an account event marks the account inactive. The records are already decoded JSON, so you can process them directly without running Tap or adding a separate decoding step.

This example follows the live stream only, so it indexes posts as they arrive after startup. For historical posts, see the Jetstream replay documentation.

Part 4: Serving the Feed

Now, you'll add XRPC routes to serve our feed. Your server will be handling two kinds of requests:

GET /xrpc/app.bsky.feed.describeFeedGenerator
GET /xrpc/app.bsky.feed.getFeedSkeleton

Add the Feed Generator server logic to src/index.ts:

// =============================================================================
// Feed Generator Server
// =============================================================================

// Auth is optional for this demo since we only log the requester's DID.
// In production, you may want to use credentials to personalize the feed.
const auth = serviceAuth({
    audience: config.publisherDid,
    unique: async () => true,
})

const router = new LexRouter()

router.add(app.bsky.feed.describeFeedGenerator, {
    auth,
    handler: (ctx) => {
        console.log('describeFeedGenerator from', ctx.credentials?.did)
        return {
            body: {
                did: config.publisherDid,
                feeds: [app.bsky.feed.describeFeedGenerator.feed.$build({ uri: FEED_URI })],
                links: {
                    privacyPolicy: 'https://example.com/privacy',
                    termsOfService: 'https://example.com/tos',
                },
            },
        }
    },
})

router.add(app.bsky.feed.getFeedSkeleton, {
    auth,
    handler: (ctx) => {
        if (ctx.params.feed !== FEED_URI) {
            throw new LexError('InvalidRequest', 'Feed not found')
        }
        console.log('getFeedSkeleton from', ctx.credentials?.did)

        const limit = Math.min(ctx.params.limit ?? 50, 100)
        const cursor = ctx.params.cursor as string | undefined

        let startIdx = 0
        if (cursor) {
            const cursorTime = parseInt(cursor, 10)
            startIdx = postIndex.findIndex(p => p.indexedAt < cursorTime)
            if (startIdx === -1) startIdx = postIndex.length
        }

        const slice = postIndex.slice(startIdx, startIdx + limit)
        const feed = slice.map(p => app.bsky.feed.defs.skeletonFeedPost.$build({ post: p.uri }))
        const lastPost = slice.at(-1)
        const nextCursor = lastPost && slice.length === limit && startIdx + limit < postIndex.length
            ? lastPost.indexedAt.toString()
            : undefined

        return { body: { feed, cursor: nextCursor } }
    },
})

Here, you're using LexRouter from the @atproto/lex-server package to define your XRPC routes. You'll notice that the getFeedSkeleton method returns a cursor in its response and takes a cursor param as input. This allows clients to paginate through your feed.

Finally, you'll add the runtime logic to start the server.

Part 5: Running the Server

You'll start the Jetstream consumer and then initiate your server. Add the runtime logic to src/index.ts:

// =============================================================================
// Start
// =============================================================================

const jetstreamAbort = new AbortController()
const indexer = indexJetstream(jetstreamAbort.signal).catch((err) => {
    if (!jetstreamAbort.signal.aborted) console.error('Jetstream error:', err)
})
console.log('Connecting to Jetstream...')

serve(router, { port: config.port }).then((server) => {
    const feedParam = encodeURIComponent(FEED_URI)
    const repoFilter = config.initialRepos.length > 0 ? config.initialRepos.length : 'all accounts'

    console.log(`
Feed Generator Running

Server: http://localhost:${config.port}
Feed: ${config.feedName}
Terms: ${config.searchTerms.join(', ')}
Jetstream: US East
DID filter: ${repoFilter}

To test (generate a JWT with goat):
goat account service-auth --aud ${config.publisherDid}

Then:
curl -H "Authorization: Bearer <jwt>" "http://localhost:${config.port}/xrpc/app.bsky.feed.getFeedSkeleton?feed=${feedParam}"

Listening for posts matching: ${config.searchTerms.join(', ')}
`)

    const shutdown = async () => {
        console.log('Shutting down...')
        jetstreamAbort.abort()
        await indexer
        await server.terminate()
        process.exit(0)
    }

    process.on('SIGINT', shutdown)
    process.on('SIGTERM', shutdown)
})

This starts your Jetstream consumer (your feed input) and your XRPC server (your feed output). The SDK handles reconnecting the live stream.

Run it with npx tsx src/index.ts. New matching posts will be indexed as they arrive:

Connecting to Jetstream...

Feed Generator Running

Server: http://localhost:3000
Feed: goblin-mode
Terms: goblin, goblins, gremlin, gremlins, wizard, wizards, chaos
Jetstream: US East
DID filter: all accounts

To test (generate a JWT with goat):
goat account service-auth --aud did:example:alice

Then:
curl -H "Authorization: Bearer " "http://localhost:3000/xrpc/app.bsky.feed.getFeedSkeleton?feed=at%3A%2F%2Fdid%3Aexample%3Aalice%2Fapp.bsky.feed.generator%2Fgoblin-mode"

Listening for posts matching: goblin, goblins, gremlin, gremlins, wizard, wizards, chaos

CREATE at://did:plc:q6gjnaw2blty4crticxkmujt/app.bsky.feed.post/3myxs2zcgjk2a
  "entering my goblin era. do not perceive me"
  ⭐ Added to index (1 total)

Conclusion

You've now built a custom atproto feed that retrieves posts from the network, indexes them based on your own algorithm, and serves them via XRPC to clients like Bluesky.

Now that your feed server is running, you can customize it further, then publish it for others to use. To do that, you'll want to deploy it to a public server (e.g., AWS, DigitalOcean, etc.) and ensure it's accessible over HTTPS.

After that, you can clone down our original feed-generator repo and run the publishFeedGen.ts script interactively. This creates the necessary atproto records to point to your feed server, and allow it to be discovered:

You can find more guides and tutorials in our Guides section, and more example apps in the Cookbook repository. Happy building!