MainSDK
Search⌘K
Interfaces

Data

Feeds and timelines

feeds

Follows, blocks and mutes, home timelines that fan out on write and on read, and trending per region, with a feed that has an end.

Building for 0.4ships as a experimental APIPhase 1 · Core, public betaSpec feedsFree in your app, Apache-2.0

Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.

Stability The promise an interface ships with.

  • Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
  • Beta Usable in production; may change before it becomes stable.
  • Experimental May change or go away; needs an explicit opt-in in main.yaml.
Stability levels
Updated 2026-10-28

feeds gives your app a follow graph and a home timeline that stays fast at any follower count, plus trending counts per region. The feed is chronological by default and has an end.

People follow, block and mute each other, post and read, whether an author has ten followers or ten million. Reach for it for a social app, a community or an activity stream.

The feed is chronological by default. You opt in to ranking, and every ranked entry carries a reason and a way back to chronological. The feed has an end: no endless scroll, and no new items inserted while a person reads.

At a glance

feeds:
  driver: db
  items: posts
  fanout: { threshold: 10000, batch: 500 }
  timeline: { order: chronological }
  page: { end: true, pause_after: 200 }
  trending: { window: 1h, k: 20, min-count: 25 }

Calls

  • feeds.follow(follower, target)
  • feeds.unfollow(follower, target)
  • feeds.followers(user)
  • feeds.following(user)
  • feeds.counts(user)
  • feeds.requests(user)
  • feeds.accept(user, from)
  • feeds.decline(user, from)
  • feeds.block(user, target)
  • feeds.unblock(user, target)
  • feeds.mute(user, target, { until? })
  • feeds.unmute(user, target)
  • feeds.relation(a, b)
  • feeds.publish(author, { id, visibility })
  • feeds.edit(author, id)
  • feeds.remove(by, id)
  • feeds.timeline(user, { order?, cursor?, continue? })
  • feeds.profile(viewer, author)
  • feeds.ranker(fn)
  • feeds.count(key, { by })
  • feeds.trending({ window?, k? })

Guarantees

Whatever the driver.

  • New items never written to timelines in the request; authors see their own items at once
  • Edits, deletes and moderation apply on the next read of every timeline
  • Chronological by default; a ranked timeline explains each item and can always be switched off
  • Every timeline has an end and a pause, and new items arrive as a count, never pushed into the list
  • Trending stores counts only, never a person

Drivers

2 drivers. Select one to see its line in main.yaml.

main.yamlBuilt in on db (default)
feeds:
  driver: db

Change this one line to swap providers; your calls stay the same.Your database integration

Codes

CodeWhere it appearsMeaning
MAIN-FEED-0010Checkmainsdk check, before deployA feed with no end: page.end: false, page.pause_after off or above 500, or a client that requests the next page with no user action
MAIN-FEED-0011Checkmainsdk check, before deploytimeline.order: ranked, or a feeds.ranker, in an app with a children's audience and no chronological-only rule for it
MAIN-FEED-0012Checkmainsdk check, before deploytrending.min-count below 10, or a trending key built from a labelled field
MAIN-FEED-0013Checkmainsdk check, before deployfanout.threshold above 100,000, timeline.max above 5,000 or max-following above 50,000
MAIN-FEED-0001RuntimeError responses and logsFollow, request or profile read refused: one side blocks the other (403)
MAIN-FEED-0002RuntimeError responses and logsA follow or item across tenants (403)
MAIN-FEED-0003RuntimeError responses and logsThe item doesn't exist, is removed, or isn't visible to the viewer (404)
MAIN-FEED-0004RuntimeError responses and logsEdit or remove by someone who isn't the author and whom access doesn't allow feeds.remove (403)
MAIN-FEED-0005RuntimeError responses and logsFollowing more than max-following (422)
MAIN-FEED-0006RuntimeError responses and logsA fan-out batch failed after its last retry and is in the dead-letter queue; affected timelines are served by merging at read time (logged)
MAIN-FEED-0007RuntimeError responses and logsThe ranker threw, returned an entry without a reason, or passed ranked.budget; the chronological timeline was served (logged)
MAIN-FEED-0008RuntimeError responses and logsA pushed value loosens page.end, page.pause_after, trending.min-count or max-following; refused, last good kept
MAIN-FEED-0009RuntimeError responses and logsSee the spec

Features

Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.

FeaturePhaseMilestoneSpec ticket
feeds@1 contractPhase 10.4feeds#contract
Follow graph with requests and countsPhase 10.4feeds#graph
Block and mutePhase 10.4feeds#block-mute
Fan-out on writePhase 10.4feeds#fanout-write
Fan-out on read above a threshold and read-your-writesPhase 10.4feeds#fanout-read
Edits and deletes shown at oncePhase 10.4feeds#edits-deletes
Chronological and ranked timelinesPhase 10.4feeds#order
Caught-up end, pause and children's rulesPhase 10.4feeds#ethics
Trending per regionPhase 10.4feeds#trending
Tenants and regionsPhase 10.4feeds#tenancy-regions
ErasurePhase 10.4feeds#erase
Feed checksPhase 10.4feeds#checks

Quick start

main.yaml
feeds:
  driver: db
  items: posts
app/post.ts
import { feeds } from "mainsdk";

declare const user: { id: string };
declare const post: { id: string };

await feeds.publish(user, { id: post.id, visibility: "public" });
const home = await feeds.timeline(user, { limit: 20 });   // ends with a caught-up or pause entry

feeds stores references to your records, not copies. items names the db model whose records are the feed items.

Configuration

main.yaml
feeds:
  driver: db                       # db (default) | local for tests
  items: posts                     # the db model whose records are feed items
  fanout:
    threshold: 10000               # authors with more followers are merged at read time
    batch: 500                     # timeline writes per job
  max-following: 5000
  timeline:
    order: chronological           # chronological (default) | ranked
    max: 800                       # entries kept per timeline
    keep: 30d
  page:
    limit: 20                      # default page size; at most 100
    end: true                      # a caught-up marker where the last session ended
    pause_after: 200               # items per session before a pause marker; 50 for children
  ranked:
    candidates: 500                # entries ranked per request
    budget: 50ms                   # ranker time per page; over it, chronological is served
  trending:
    window: 1h
    k: 20
    min-count: 25                  # keys counted for fewer people are hidden; may only be raised
  realtime: true                   # tell viewers how many new items wait

What you get

A follow graph with blocks and mutes, home timelines that fan out on write (merged at read time for large authors), profiles and trending keys. Every call below takes the acting user as its first argument.

Calls

Follow graph

text
feeds.follow(follower, target)                  // { state: "following" | "requested" }; private accounts get a request
feeds.unfollow(follower, target)
feeds.followers(user, o?)                       // a page of accounts, newest first
feeds.following(user, o?)
feeds.counts(user)                              // { followers, following }
feeds.requests(user, o?)                        // pending follow requests
feeds.accept(user, from)
feeds.decline(user, from)
feeds.relation(a, b)                            // { following, followedBy, requested, blocking, blockedBy, muting }

Following yourself, or following twice, returns the current state and changes nothing. Following more than max-following fails with MAIN-FEED-0005.

Block and mute

text
feeds.block(user, target)
feeds.unblock(user, target)
feeds.mute(user, target, { until? })            // hides the target from the muter's timeline only
feeds.unmute(user, target)

A block deletes follows and requests both ways, refuses new ones with MAIN-FEED-0001, and hides each side's items from the other. A mute leaves follows alone. Neither tells the other person.

Items and timelines

text
feeds.publish(author, { id, visibility })       // visibility: "public" | "followers"
feeds.edit(author, id)                          // bumps the version after the item record changed
feeds.remove(by, id, { reason? })               // the author, or a subject that access allows
feeds.timeline(user, { order?, limit?, cursor?, continue? })
feeds.profile(viewer, author, o?)               // one author's items, as the viewer may see them
feeds.ranker(fn)                                // register the ranking function

feeds.publish resolves once the record reference and the fan-out job are accepted, and writes no follower's timeline in the request. An author sees their own new item at the top of their chronological timeline on the next read.

A timeline entry is one of three kinds:

KindMeaning
itemA feed item. A ranked entry also carries a reason.
caught-upThe person has seen everything after this point.
pausepause_after was reached. There is no next until a request with continue: true.

order: "chronological" is always accepted, so a person can leave ranking at any time. For a children's audience the timeline is always chronological and pauses after at most 50 items.

text
feeds.count(key, { by })                        // one occurrence of a key such as "tag:launch"
feeds.trending({ window?, k? })                 // { region, window, keys: [{ key, count }] }

feeds.count is synchronous and in memory, and a job flushes it to db each minute. Each person counts once per key per window, using a salted hash that rotates every window. No person id is stored. Keys counted for fewer than min-count people are hidden, and regions are never merged.

Examples

Rank with a reason

app/ranker.ts
import { feeds } from "mainsdk";

declare function affinity(user: { id: string }, item: { id: string }): number;

feeds.ranker((candidates, { user }) =>
  candidates.map((c) => ({ id: c.id, score: affinity(user, c), reason: "Because you follow this author" })),
);

A ranker that throws, omits a reason or passes ranked.budget makes feeds.timeline serve the chronological timeline and log MAIN-FEED-0007.

Show new-item counts

With realtime: true, each fan-out batch publishes the event feeds.new with { count } on user:<id>. It never carries an item. Show "3 new posts" and let the person ask for them.

Errors and checks

CodeWhenFix
MAIN-FEED-0001One side blocks the other (403)Nothing; the block is the point
MAIN-FEED-0002A follow or item across tenants (403)Keep both in one tenant
MAIN-FEED-0003The item is missing, removed or not visible to the viewer (404)Check the id and visibility
MAIN-FEED-0004Edit or remove by someone who is not the author and not allowed by access (403)Allow feeds.remove for moderators
MAIN-FEED-0005Following more than max-following (422)Raise the limit, within the check ceiling
MAIN-FEED-0010A feed with no end, or a client that requests the next page with no user actionKeep page.end on and wait for a person
MAIN-FEED-0011Ranking in an app with a children's audienceServe chronological only
MAIN-FEED-0012trending.min-count below 10, or a key built from a labelled fieldRaise it; count tags, not people
MAIN-FEED-0013fanout.threshold, timeline.max or max-following above its ceilingLower it

Two codes are runtime only: MAIN-FEED-0006 (a failed fan-out batch, served by merging at read time) and MAIN-FEED-0008 (a pushed value that loosens a limit is refused). See check codes.

mainsdk check has a feeds gate for MAIN-FEED-0010 to 0013, each reported with its file and line.

Swapping drivers

feeds.driver is db (the default, on your app's db stores) or local, an in-process SQLite store for tests. Your calls do not change when you change it.

Testing

driver: local keeps the graph, items and timelines in an in-process SQLite store, so tests run offline on the fake clock and the local job queue. See Test your app.

Pushed config

A push may only tighten (ADR-0017): feeds.page.end is never turned off, feeds.page.pause_after and feeds.max-following only go lower, feeds.trending.min-count only goes higher. A looser value is refused with MAIN-FEED-0008, the last good value stays, and the refusal is written to the audit log.

What is connected, and what is not

app.feeds (and import { feeds } from "mainsdk") is built on first use from the feeds section over the app's db SQL stores, tenancy, jobs, access and the log. These modules are not built yet, so set app.socialPorts.feeds = { ... } before the first use if you have your own:

PortWhat it doesWithout it
realtimePublishes feeds.new with the pending countThe count is read with the timeline
itemsReads the item record in the author's region to check its ownerOwnership is not checked against the feeds.items model
accountsSays whether users.private is setEvery account is public
audienceAnalytics' children ruleA child is known by the subject's own fields
moderationSays whether moderate removed an itemNothing is removed by moderation
eraseRegisters feeds.erase with privacy.eraseCall the erase hook yourself

Security and privacy

  • A timeline entry holds only the item id, author id and time, and lives in the follower's region. Item records stay in their author's region.
  • Follow, block and mute changes are audited without content and visible only to the person who made them.
  • privacy.erase removes a person's follows, blocks, mutes, item references, timeline entries and counts.
  • A link to an item goes through links, not through feeds.

FAQ

Are likes, replies and reposts built in? No. Model them as records and count them for your ranker.

Can I push people more items while they read? No. A timeline never inserts items while a person reads; use the feeds.new count.

Next

Was this page helpful?

↑↓ moveEnter openEsc close

Not decided yet