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
getFeedSkeletonrequest 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.
This cursor is treated as an opaque value and fully at the Feed Generator's discretion. It is passed through the AppView directly to and from the client. The cursor be unique per feed item to prevent unexpected behavior in pagination. For instance, a compound cursor with a timestamp + a CID:
1683654690921::bafyreia3tbsfxe3cc75xrxyyn6qc42oupi73fxiox76prlyi5bpx7hr72u
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)
This example only sees posts created after it starts, so it may take a minute for the first matches to appear. If you limit FEED_INITIAL_REPOS to a few accounts, matches will be much less frequent.
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!