SemiLayerDocs

Feeds — Live evolution

A feed page is a snapshot. Live evolution is the protocol that tells the UI when that snapshot is stale: "there are N new things that would land in this feed if you refetched."

The mechanism is a debounced WebSocket tick. No row payloads cross the wire — just counts and a topChanged hint. The UI decides what to do with them.

The tick contract

interface FeedTickEvent {
  name: string            // feed name, e.g. 'discover'
  newCount: number        // candidates that would land in the feed
  topChanged: boolean     // whether the new content would change the top item
  ts: ISODateString       // when the tick was computed
}

Frequency is throttled by evolve.minNotifyInterval on the feed config (default '30s'). The server coalesces multiple NOTIFYs within the window into one tick.

Configuring evolution

feeds: {
  discover: {
    candidates: { /* ... */ },
    rank:       { /* ... */ },
    evolve: {
      onSubscribe:       'newCount',   // 'newCount' (default) | 'replace' | 'merge'
      pollOnIngest:      true,         // default true — emit ticks on ingest
      minNotifyInterval: '30s',        // '5s' | '30s' | '1m', default '30s'
    },
  },
}
  • pollOnIngest: true (default) — the server listens for row ingest on the lens and emits ticks when new candidates match the feed's predicates.
  • pollOnIngest: false — no automatic ticks. Useful for feeds where freshness comes from a separate signal (e.g. you compute ranks on a cron).
ℹ️

onSubscribe modes 'replace' and 'merge' are declared in the config (for forward-compat with client behavior), but the v1 tick contract is "count-only." The UI decides whether to refetch, prepend, or ignore. Modes 'replace' and 'merge' become meaningful when the Beam codegen ships stronger React primitives in a future release — safe to configure now.

Subscribe from the client

import { beam } from './beam'

for await (const tick of beam.articles.feed.foryou.subscribe({
  context: { topics: ['security', 'auth'] },
})) {
  console.log(tick)
  // { name: 'foryou', newCount: 3, topChanged: true, ts: '2026-04-20T14:02:00Z' }

  if (tick.newCount > 0) {
    showRefreshPill(tick.newCount)
  }
}

The iterator is long-lived — awaits the next tick each time through the loop. break or an unhandled throw closes the WebSocket op (the shared connection stays open for other ops).

React hook

useFeed owns the subscription lifecycle for you:

import { useFeed } from '@semilayer/react'
import { beam } from '@/lib/beam'

const feed = beam.articles.feed.foryou

export function ForYou() {
  const { items, liveCount, evolved, refetch } = useFeed(feed, {
    context:      { topics: ['security', 'auth'] },
    liveUpdates:  true,       // subscribe and accumulate ticks
    evolveOn:     'topChanged', // or 'any' | 'never'
    onEvolve:     (tick) => console.log('tick', tick),
  })

  return (
    <div>
      {liveCount > 0 && (
        <button onClick={refetch}>
          {liveCount} new — refresh
        </button>
      )}
      {items.map((it) => (/* ... */))}
    </div>
  )
}
  • liveCount — running sum of newCount since the last refetch().
  • evolved — mirrors the FeedPage.evolved flag on the most recent page.
  • evolveOn — when to auto-refetch: 'topChanged' (default), 'any', 'never'.

Access rules for streaming

grants.stream is separate from grants.feed:

grants: {
  feed: {
    foryou: 'public',    // HTTP /v1/feed/articles/foryou open to pk_ keys
  },
  stream: {
    enabled: true,                      // default true
    modes: ['chunked', 'live'],         // default both
    maxLiveSubscriptions: 5,            // per-connection cap, default 5
  },
}

A lens can expose HTTP feed pages without streaming, or vice-versa. Feeds with liveUpdates: true require 'live' in stream.modes.

The raw WebSocket op

If you're writing a non-TypeScript client, here's the frame protocol:

// Open one shared WebSocket per environment
const ws = new WebSocket('wss://api.semilayer.com/v1/stream/articles?key=pk_live_...')

ws.onopen = () => {
  ws.send(JSON.stringify({
    id: 'sub_1',
    op: 'feed.subscribe',
    params: {
      name: 'foryou',
      context: { topics: ['security', 'auth'] },
    },
  }))
}

ws.onmessage = (e) => {
  const frame = JSON.parse(e.data)

  if (frame.type === 'event' && frame.kind === 'feed.tick') {
    // frame.data is FeedTickEvent
    handleTick(frame.data)
  }
  if (frame.type === 'error') {
    console.error(frame.code, frame.message)
  }
}

Close codes and the full frame protocol live in HTTP & WebSocket.

What ticks don't carry

Ticks are signals, not payloads. They tell the UI "something changed" — the UI decides whether to care. If you want the new rows themselves, call beam.<lens>.feed.<name>() again (or useFeed's refetch()).

This keeps the WS protocol cheap: 100k subscribers × one small tick every 30s is a fraction of the bandwidth of streaming payloads to each. And the same approach works whether your feed returns 10 items or 10,000 — ticks don't scale with result size.