bsky Documentation

TypeScript monorepo for Bluesky packages

14 stars TypeScript TypeScript 1 file ยท ~7,845 tokens
RAW Doc

File: packages/sdk/docs/moderation.md

Moderation API

Applying the moderation system is a challenging task, but we've done our best to simplify it for you. The Moderation API helps handle a wide range of tasks, including:

  • Moderator labeling
  • User muting (including mutelists)
  • User blocking
  • Mutewords
  • Hidden posts

Configuration

Every moderation function takes a set of options which look like this:

typescript
{
  // the logged-in user's DID
  userDid: 'did:plc:1234...',

  prefs: {
    // is adult content allowed?
    adultContentEnabled: true,

    // the global label settings (used on self-labels)
    labels: {
      porn: 'hide',
      sexual: 'warn',
      nudity: 'ignore',
      // ...
    },

    // the subscribed labelers and their label settings
    labelers: [
      {
        did: 'did:plc:1234...',
        labels: {
          porn: 'hide',
          sexual: 'warn',
          nudity: 'ignore',
          // ...
        }
      }
    ],

    mutedWords: [/* ... */],
    hiddenPosts: [/* ... */]
  },

  // custom label definitions
  labelDefs: {
    // labelerDid => defs[]
    'did:plc:1234...': [
      /* ... */
    ]
  }
}

This should match the following interfaces:

typescript
export interface ModerationPrefsLabeler {
  did: DidString
  labels: Record<string, LabelPreference>
}

export interface ModerationPrefs {
  adultContentEnabled: boolean
  labels: Record<string, LabelPreference>
  labelers: ModerationPrefsLabeler[]
  mutedWords: app.bsky.actor.defs.MutedWord[]
  hiddenPosts: AtUriString[]
}

export interface ModerationOpts {
  userDid: DidString | undefined
  prefs: ModerationPrefs
  /**
   * Map of labeler did -> custom definitions
   */
  labelDefs?: Record<string, InterpretedLabelValueDefinition[]>
}

You can quickly grab the ModerationPrefs using the getPreferences action:

typescript
import { getPreferences } from '@bsky/sdk'
import { moderatePost } from '@bsky/sdk/moderation'

const prefs = await client.call(getPreferences)
moderatePost(post, {
  userDid: /*...*/,
  prefs: prefs.moderationPrefs,
  labelDefs: /*...*/
})

To gather the label definitions (labelDefs) see the Labelers section below.

Labelers

Labelers are services that provide moderation labels. Your application will typically have 1+ top-level labelers set with the ability to do "takedowns" on content. This is controlled via the appLabelers client option. When no labeler headers are sent at all, the Bluesky API applies its own moderation service by default โ€” so this only needs to be set when introducing other labelers:

typescript
import { Client } from '@atproto/lex'

const client = new Client(session, {
  appLabelers: ['did:web:my-labeler.com'],
})

(Client.configure({ appLabelers }) sets the same thing globally for all client instances; prefer the per-client option.)

Users may also add their own labelers. The active labelers are controlled via an HTTP header which is set by the client from its labelers configuration (client.setLabelers(), client.addLabelers()). Use the addLabeler/removeLabeler actions to persist a user's labeler subscriptions in their preferences.

Labelers publish a app.bsky.labeler.service record that looks like this:

js
{
  $type: 'app.bsky.labeler.service',
  policies: {
    // the list of label values the labeler will publish
    labelValues: [
      'rude',
    ],
    // any custom definitions the labeler will be using
    labelValueDefinitions: [
      {
        identifier: 'rude',
        blurs: 'content',
        severity: 'alert',
        defaultSetting: 'warn',
        adultOnly: false,
        locales: [
          {
            lang: 'en',
            name: 'Rude',
            description: 'Not keeping things civil.',
          },
        ],
      },
    ],
  },
  createdAt: '2024-03-12T17:17:17.215Z'
}

The label value definitions are custom labels which only apply to that labeler. Your client needs to sync those definitions in order to correctly interpret them. To do that, call app.bsky.labeler.getServices with detailed: true periodically to fetch their definitions. We recommend caching the response (at time of our writing the official client uses a TTL of 6 hours).

Here is how to do this:

typescript
import { Client } from '@atproto/lex'
import { getPreferences } from '@bsky/sdk'
import { app } from '@bsky/sdk/lexicons'
import {
  type InterpretedLabelValueDefinition,
  interpretLabelValueDefinitions,
  moderatePost,
} from '@bsky/sdk/moderation'

// assume `client` is backed by a signed-in session
const prefs = await client.call(getPreferences)

const services = await client.call(app.bsky.labeler.getServices, {
  dids: prefs.moderationPrefs.labelers.map((labeler) => labeler.did),
  detailed: true,
})

const labelDefs: Record<string, InterpretedLabelValueDefinition[]> = {}
for (const view of services.views) {
  if (app.bsky.labeler.defs.labelerViewDetailed.isTypeOf(view)) {
    labelDefs[view.creator.did] = interpretLabelValueDefinitions(view)
  }
}

moderatePost(post, {
  userDid: client.assertDid,
  prefs: prefs.moderationPrefs,
  labelDefs,
})

The `moderate*()` APIs

The SDK exports methods to moderate the different kinds of content on the network.

typescript
import {
  moderateProfile,
  moderatePost,
  moderateNotification,
  moderateFeedGenerator,
  moderateUserList,
  moderateStatus,
  moderateViewExternal,
  moderateProfileLink,
} from '@bsky/sdk/moderation'

Each of these follows the same API signature:

typescript
const res = moderatePost(post, moderationOptions)

The response object provides an API for figuring out what your UI should do in different contexts.

typescript
res.ui(context) /* =>

ModerationUI {
  filter: boolean // should the content be removed from the interface?
  blur: boolean // should the content be put behind a cover?
  alert: boolean // should an alert be put on the content? (negative)
  inform: boolean // should an informational notice be put on the content? (neutral)
  noOverride: boolean // if blur=true, should the UI disable opening the cover?

  // the reasons for each of the flags:
  filters: ModerationCause[]
  blurs: ModerationCause[]
  alerts: ModerationCause[]
  informs: ModerationCause[]
}
*/

There are multiple UI contexts available:

  • profileList A profile being listed, eg in search or a follower list
  • profileView A profile being viewed directly
  • avatar The user's avatar in any context
  • banner The user's banner in any context
  • displayName The user's display name in any context
  • contentList Content being listed, eg posts in a feed, posts as replies, a user list list, a feed generator list, etc
  • contentView Content being viewed direct, eg an opened post, the user list page, the feedgen page, etc
  • contentMedia Media inside the content, eg a picture embedded in a post

Here's how a post in a feed would use these tools to make a decision:

typescript
const mod = moderatePost(post, moderationOptions)

if (mod.ui('contentList').filter) {
  // dont show the post
}
if (mod.ui('contentList').blur) {
  // cover the post with the explanation from mod.ui('contentList').blurs[0]
  if (mod.ui('contentList').noOverride) {
    // dont allow the cover to be removed
  }
}
if (mod.ui('contentMedia').blur) {
  // cover the post's embedded images with the explanation from mod.ui('contentMedia').blurs[0]
  if (mod.ui('contentMedia').noOverride) {
    // dont allow the cover to be removed
  }
}
if (mod.ui('avatar').blur) {
  // cover the avatar with the explanation from mod.ui('avatar').blurs[0]
  if (mod.ui('avatar').noOverride) {
    // dont allow the cover to be removed
  }
}
for (const alert of mod.ui('contentList').alerts) {
  // render this alert
}
for (const inform of mod.ui('contentList').informs) {
  // render this inform
}

External embeds

Labels can be attached directly to an app.bsky.embed.external#viewExternal (for example, on link cards backed by Atmosphere records). These labels are not covered by moderatePost, so compute a separate decision for the embed with moderateViewExternal and apply it to the rendered card.

An external card acts as both the "content" and the "media" of its embed, so combine the contentView and contentMedia contexts with mergeModUIResults:

typescript
import { mergeModUIResults, moderateViewExternal } from '@bsky/sdk/moderation'

const embedMod = moderateViewExternal(embed.external, moderationOptions)
const ui = mergeModUIResults(
  embedMod.ui('contentView'),
  embedMod.ui('contentMedia'),
)

if (ui.blur) {
  // cover the card with the explanation from ui.blurs[0]
  if (ui.noOverride) {
    // don't allow the cover to be removed
  }
}

mergeModUIResults accepts any number of ModerationUI values (undefined entries are skipped), combining their causes and preserving the strictest noOverride.

A detailed profile (app.bsky.actor.defs#profileViewDetailed) can include links, a list of profileLinkViews. Each link can carry hydrated labels. Moderate each link separately with moderateProfileLink, in addition to (not instead of) the decision for the profile itself:

typescript
import { moderateProfile, moderateProfileLink } from '@bsky/sdk/moderation'

const profileMod = moderateProfile(profile, moderationOptions)

for (const link of profile.links ?? []) {
  const linkMod = moderateProfileLink(link, moderationOptions)

  if (linkMod.ui('contentView').blur) {
    // cover the link with the explanation from linkMod.ui('contentView').blurs[0]
  }
  if (linkMod.ui('contentMedia').blur) {
    // cover the link's icon with the explanation from linkMod.ui('contentMedia').blurs[0]
  }
}

Notes:

  • Moderation is label-only. Link labels are treated as content labels, so use the content* UI contexts. moderateProfileLink does not include the profile's account or profile labels, blocks, mutes, or muted words, and moderateProfile does not include link labels. Combine decisions yourself where your UI needs to.
  • The link view has no owner profile. The owner is read from the DID in the link record's uri (never from the destination url or a label's source). This is what lets the owner's own self-labels apply and sets isMe when the viewer owns the link. If the uri is handle-based, no owner is inferred, though labels from subscribed labelers still apply.
  • labels on a profileLinkView are hydrated by the app view and may be absent, so treat a missing value as no labels. This is distinct from the labels self-label union that the record author sets on an app.bsky.actor.link record.

Sending moderation reports

Any Labeler is capable of receiving moderation reports. As a result, you need to specify which labeler should receive the report. You do this with the service option (the atproto-proxy header), overridable per request:

typescript
import { com } from '@bsky/sdk/lexicons'

await client.call(
  com.atproto.moderation.createReport,
  {
    reasonType: com.atproto.moderation.defs.reasonViolation.$token,
    reason: 'They were being such a jerk to me!',
    subject: com.atproto.admin.defs.repoRef.$build({
      did: 'did:web:bob.com',
    }),
  },
  { service: 'did:web:my-labeler.com#atproto_labeler' },
)

The Bluesky moderation service's address is available via the api export from the SDK:

typescript
import { api } from '@bsky/sdk'

await client.call(com.atproto.moderation.createReport, report, {
  service: api.moderation.service, // did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler
})

File: packages/sdk/README.md

@bsky/sdk

Bluesky SDK built on @atproto/lex. Provides Bluesky lexicon definitions, typed actions, moderation utilities, rich-text helpers, and string utilities.

Install

sh
npm install @bsky/sdk @atproto/lex

@atproto/lex provides the Client class that powers all API calls. @bsky/sdk provides the Bluesky-specific lexicons, actions, and utilities.

Getting started

typescript
import { Client } from '@atproto/lex'
import { PasswordSession } from '@atproto/lex-password-session'
import { post } from '@bsky/sdk'

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'your.bsky.social', // your handle
  password: 'xxxx-xxxx-xxxx-xxxx', // an app password, generated in the Bluesky app
})
const client = new Client(session)

await client.call(post, { text: 'Hello, world!' })

Usage

Session management

You'll need an authenticated session for most API calls. There are two ways to
manage sessions:

  1. App password based session management
  2. OAuth based session management

App password based session management

Username / password based authentication can be performed using the
PasswordSession class from @atproto/lex-password-session.

[!CAUTION]

This method is deprecated in favor of OAuth based session management. It is
recommended to use OAuth based session management (through the
@atproto/oauth-client-* packages).

typescript
import { Client } from '@atproto/lex'
import {
  PasswordSession,
  type SessionData,
} from '@atproto/lex-password-session'

const session = await PasswordSession.login({
  service: 'https://bsky.social',
  identifier: 'your.bsky.social', // your handle
  password: 'xxxx-xxxx-xxxx-xxxx', // an app password, generated in the Bluesky app
  onUpdated: (data: SessionData) => saveToStorage(data),
  onDeleted: (data: SessionData) => clearStorage(data.did),
})
const client = new Client(session)
console.log(`Authenticated as: ${client.assertDid}`)

// Next time, resume with the persisted session data to avoid storing
// user credentials:
const resumed = await PasswordSession.resume(loadFromStorage(), {
  onUpdated: (data: SessionData) => saveToStorage(data),
  onDeleted: (data: SessionData) => clearStorage(data.did),
})

OAuth based session management

Depending on the environment used by your application, different OAuth clients
are available:

Every @atproto/oauth-client-* implementation has a different way to obtain an
OAuth session instance that can be used to instantiate a Client. Here is an
example restoring a previously saved session:

typescript
import { Client } from '@atproto/lex'
import { OAuthClient } from '@atproto/oauth-client'

const oauthClient = new OAuthClient({
  // ...
})

const oauthSession = await oauthClient.restore('did:plc:123')

// Instantiate the lex Client using an OAuth session
const client = new Client(oauthSession)

Authenticated and public clients

Use the api.* constants for addressing Bluesky services:

typescript
import { Client } from '@atproto/lex'
import { api } from '@bsky/sdk'

// Bluesky API (authenticated queries, proxied through the account host;
// record writes and mutations automatically target the account host directly)
const bskyClient = new Client(session, { service: api.app.service })

// Bluesky API (unauthenticated public queries)
const publicBskyClient = new Client(api.app.urlPublic)

API calls

The SDK exports actions for many common operations. Invoke an action against a
client with client.call(action, input):

typescript
import {
  blockActor,
  blockActorList,
  deleteFollow,
  deleteLike,
  deletePost,
  deleteRepost,
  follow,
  getPreferences,
  like,
  muteActor,
  muteActorList,
  post,
  repost,
  unblockActor,
  unblockActorList,
  unmuteActor,
  unmuteActorList,
  updateSeenNotifications,
  upsertProfile,
} from '@bsky/sdk'

// The DID of the user currently authenticated (or undefined)
bskyClient.did
bskyClient.assertDid // Throws if the user is not authenticated

// Feeds and content
await bskyClient.call(post, { text: 'Hello, world!' })
await bskyClient.call(deletePost, postUri)
await bskyClient.call(like, { uri, cid })
await bskyClient.call(deleteLike, likeUri)
await bskyClient.call(repost, { uri, cid })
await bskyClient.call(deleteRepost, repostUri)

// Social graph
await bskyClient.call(follow, { did })
await bskyClient.call(deleteFollow, followUri)
await bskyClient.call(muteActor, { actor })
await bskyClient.call(unmuteActor, { actor })
await bskyClient.call(blockActor, { did })
await bskyClient.call(unblockActor, blockUri)
await bskyClient.call(muteActorList, { list })
await bskyClient.call(unmuteActorList, { list })
await bskyClient.call(blockActorList, { list })
await bskyClient.call(unblockActorList, { list })

// Actors
await bskyClient.call(upsertProfile, (existing) => ({
  ...existing,
  displayName: 'Alice',
}))

// Notifications
await bskyClient.call(updateSeenNotifications, seenAt)

// Preferences
const prefs = await bskyClient.call(getPreferences)

Queries without a dedicated action are one xrpc call away using the generated
lexicons โ€” see Advanced API calls.

Validation and types

The lexicons include a complete types system with validation and type-guards.
For example, to validate a post record:

typescript
import { app } from '@bsky/sdk/lexicons'

const post = { ... }
if (app.bsky.feed.post.$isTypeOf(post)) {
  // typescript now recognizes `post` may be an app.bsky.feed.post.Main
  // however -- we still need to validate it
  const res = app.bsky.feed.post.main.safeValidate(post)
  if (res.success) {
    // a valid record
  } else {
    // something is wrong
    console.log(res.error)
  }
}

Rich text

Some records (ie posts) use the app.bsky.richtext lexicon. At the moment richtext is only used for links and mentions, but it will be extended over time to include bold, italic, and so on.

โ„น๏ธ It is strongly recommended to use this package's RichText library. Javascript encodes strings in utf16 while the protocol (and most other programming environments) use utf8. Converting between the two is challenging, but RichText handles that for you.

typescript
import { currentDatetimeString } from '@atproto/lex'
import { RichText } from '@bsky/sdk/richtext'

// creating richtext โ€” detects mentions and links, resolving mentions to DIDs
const rt = await RichText.resolve(
  'Hello @alice.com, check out this link: https://example.com',
  { resolver: client },
)
const postRecord = {
  $type: 'app.bsky.feed.post',
  text: rt.text,
  facets: rt.facets,
  createdAt: currentDatetimeString(),
}

// rendering as markdown
let markdown = ''
for (const segment of rt.segments()) {
  if (segment.isLink()) {
    markdown += `[${segment.text}](${segment.link?.uri})`
  } else if (segment.isMention()) {
    markdown += `[${segment.text}](https://my-bsky-app.com/user/${segment.mention?.did})`
  } else {
    markdown += segment.text
  }
}

// calculating string lengths
const rt2 = new RichText({ text: 'Hello' })
console.log(rt2.length) // => 5
console.log(rt2.graphemeLength) // => 5
const rt3 = new RichText({ text: '๐Ÿ‘จโ€๐Ÿ‘ฉโ€๐Ÿ‘งโ€๐Ÿ‘ง' })
console.log(rt3.length) // => 25
console.log(rt3.graphemeLength) // => 1

Moderation

Applying the moderation system is a challenging task, but we've done our best to simplify it for you. The Moderation API helps handle a wide range of tasks, including:

  • Moderator labeling
  • User muting (including mutelists)
  • User blocking
  • Mutewords
  • Hidden posts

For more information, see the Moderation Documentation.

typescript
import { getPreferences } from '@bsky/sdk'
import { moderatePost } from '@bsky/sdk/moderation'

// First get the user's moderation prefs and their label definitions
// =

const prefs = await bskyClient.call(getPreferences)
const labelDefs = {
  /* see the Moderation Documentation for gathering labelDefs */
}

// We call the appropriate moderation function for the content
// =

const postMod = moderatePost(postView, {
  userDid: bskyClient.assertDid,
  prefs: prefs.moderationPrefs,
  labelDefs,
})

// We then use the output to decide how to affect rendering
// =

// in feeds
if (postMod.ui('contentList').filter) {
  // don't include in feeds
}
if (postMod.ui('contentList').blur) {
  // render the whole object behind a cover (use postMod.ui('contentList').blurs to explain)
  if (postMod.ui('contentList').noOverride) {
    // do not allow the cover the be removed
  }
}
if (postMod.ui('contentList').alert || postMod.ui('contentList').inform) {
  // render warnings on the post
  // find the warnings in postMod.ui('contentList').alerts and postMod.ui('contentList').informs
}

// viewed directly
if (postMod.ui('contentView').filter) {
  // don't render the view
}
if (postMod.ui('contentView').blur) {
  // render the whole object behind a cover (use postMod.ui('contentView').blurs to explain)
  if (postMod.ui('contentView').noOverride) {
    // do not allow the cover the be removed
  }
}
if (postMod.ui('contentView').alert || postMod.ui('contentView').inform) {
  // render warnings on the post
  // find the warnings in postMod.ui('contentView').alerts and postMod.ui('contentView').informs
}

// post embeds in all contexts
if (postMod.ui('contentMedia').blur) {
  // render the whole object behind a cover (use postMod.ui('contentMedia').blurs to explain)
  if (postMod.ui('contentMedia').noOverride) {
    // do not allow the cover the be removed
  }
}

Advanced

Advanced API calls

The actions above are convenience wrappers. They cover most but not all
available methods.

The AT Protocol identifies methods and records with reverse-DNS names. You can
call any of them using the generated lexicons and the client's call and
record helpers:

typescript
import { currentDatetimeString } from '@atproto/lex'
import { app, com } from '@bsky/sdk/lexicons'

// Record helpers always target the user's account host
const res1 = await bskyClient.createRecord({
  $type: 'app.bsky.feed.post',
  text: 'Hello, world!',
  createdAt: currentDatetimeString(),
})
const res2 = await bskyClient.call(
  com.atproto.repo.listRecords,
  { repo: alice.did, collection: 'app.bsky.feed.post' },
  { service: null }, // target the account host rather than the Bluesky API
)
// or, using the typed record convenience helper:
const res2b = await bskyClient.list(app.bsky.feed.post, {
  repo: alice.did, // optional. Defaults to bskyClient.did (throws if unauthenticated)
})

const res3 = await bskyClient.call(app.bsky.feed.getTimeline, {
  limit: 20,
})
// res3: app.bsky.feed.getTimeline.OutputSchema

Namespace roots exported from /lexicons: app, com, chat.

Bring your own fetch

If you want to provide your own fetch implementation โ€” for logging, mocking,
retries, or environments without a built-in fetch โ€” pass it when constructing
the Client:

typescript
import { Client } from '@atproto/lex'

const myFetch: typeof globalThis.fetch = async (input, init) => {
  console.log('requesting', input)
  const response = await globalThis.fetch(input, init)
  console.log('got response', response)
  return response
}

const client = new Client({
  service: 'https://example.com',
  fetch: myFetch,
})

Subpaths

Import Contents
@bsky/sdk api constants, actions, DEFAULT_LABEL_SETTINGS
@bsky/sdk/lexicons Generated lexicon definitions (app, com, chat)
@bsky/sdk/moderation moderatePost, moderateProfile, and friends; ModerationDecision, ModerationUI
@bsky/sdk/richtext RichText builder
@bsky/sdk/utils Age assurance helpers, sanitizeMutedWordValue, nux validation

Upgrading from @atproto/api

See skills/upgrade-from-atproto-api/SKILL.md for a full migration guide, including all removed APIs and their replacements.

License

This project is dual-licensed under MIT and Apache 2.0 terms:

Downstream projects and end users may chose either license individually, or both together, at their discretion. The motivation for this dual-licensing is the additional software patent assurance provided by Apache 2.0.


File: packages/jetstream/README.md

@bsky/jetstream

Client library for Jetstream, a friendly way to consume data published to AT Protocol in realtime.

Quick start

Install the Jetstream client:

text
npm install @bsky/jetstream @atproto/lex

@atproto/lex is a peer dependency providing the lexicon type system that events are typed with.

This package speaks Jetstream's v2 wire and supports all three of its consumption modes: live() for realtime events, snapshot() for the sealed archive of past events, and replay() for the archive followed by a seamless handoff to realtime. Consuming live events looks like:

ts
import { Jetstream, isCreate } from '@bsky/jetstream'

const js = new Jetstream('https://jetstream.us-east.bsky.network')

for await (const evt of js.live({ collections: ['app.bsky.feed.post'] })) {
  if (isCreate(evt)) {
    console.log(evt.commit.collection, evt.commit.record)
  }
}

collections constrains commit events only โ€” identity, account, and sync events flow regardless โ€” so a commits-only stream also needs kinds: ['commit']. kinds accepts commit, identity, account, and sync, and omitting it means all kinds. Conversely, a kinds list without commit leaves collections with nothing to constrain: that pair is rejected rather than silently ignoring either side.

Using a lexicon as your collection filter will validate and type the events for you:

ts
import { Jetstream, isCreate } from '@bsky/jetstream'
import { app } from '@bsky/sdk/lexicons'

const js = new Jetstream('https://jetstream.us-east.bsky.network')

for await (const evt of js.live({ collections: [app.bsky.feed.post] })) {
  if (isCreate(evt)) {
    console.log(evt.commit.collection, evt.commit.record.text)
  }
}

isCreate(), isUpdate(), isDelete(), and isPut() (create-or-update) narrow an event to a commit with that operation. Pass a lexicon โ€” or a bare NSID โ€” as a second argument to also narrow to its collection: isCreate(evt, app.bsky.feed.post). The record narrows exactly as app.bsky.feed.post.$isTypeOf() would narrow it, so a record the event already types precisely keeps that type. Like $isTypeOf(), these helpers narrow types without validating; when shape guarantees matter, use a validating collection filter as above.

Typed records are converted lex data โ€” Cid, Uint8Array, and BlobRef values rather than wire JSON's {$link} / {$bytes} shapes. Pass live({ raw: true }) when you want the wire-faithful record instead (its $link/$bytes markers intact).

A validating collection filter (a lexicon, or { collection, validateRecord: true }, the default) routes a schema-invalid record away from delivery. Passing validateRecord: false only drops the schema check โ€” a record that isn't a $type'd map still fails conversion and is skipped, reported via onError (or, for LexIndexer, onValidationError) rather than delivered.

For indexing workloads, LexIndexer dispatches schema-validated records to per-collection handlers with bounded concurrency and per-record ordering, and JetstreamRunner drives it with durable cursor tracking. The runner asks the server only for the kinds a LexIndexer has registered handlers for โ€” register .identity()/.account()/.sync() (or a collection via .commit()) to receive that kind at all:

ts
import { Jetstream, LexIndexer, MemoryCursorStore } from '@bsky/jetstream'
import { app } from '@bsky/sdk/lexicons'

const js = new Jetstream('https://jetstream.us-east.bsky.network')

const indexer = new LexIndexer()
  .commit(app.bsky.feed.like, {
    put: async (e) => {
      console.log('index', e.uri, e.record)
    },
    del: async (e) => {
      console.log('remove', e.uri)
    },
  })
  .identity(async (e) => {
    console.log('identity change', e.did, e.handle)
  })

// Implement CursorStore to persist the resume cursor durably.
const cursor = new MemoryCursorStore()

await js.runner(indexer).live({ cursor })

Cursors

live()'s cursor is a v2 seq. Values >= 1e15 are read by the server as unix-microsecond timestamps instead. When the server clamps a stale timestamp it sends an OutdatedCursor advisory, reported through onInfo without ending the stream โ€” an advisory is dropped silently if no onInfo is registered.

Cursors are not portable between versions: a v1 cursor is a time_us value and a v2 cursor is a seq. Never replay a stored cursor against the other version.

Legacy v1 instances

The public jetstream*.bsky.network hosts speak the legacy v1 format. Use JetstreamV1, which offers live() only โ€” v1 does not support historical network replay, has no kinds filter, and no runner/indexer path.

ts
import { JetstreamV1 } from '@bsky/jetstream'

const js = new JetstreamV1('https://jetstream1.us-east.bsky.network')
for await (const evt of js.live({ collections: ['app.bsky.feed.post'] })) {
  console.log(evt.seq, evt.kind)
}

Connection behavior

This package runs on Node.js and in the browser.

A live stream stays up on its own: it reconnects automatically โ€” including when the server closes cleanly or goes silent โ€” and resumes from its cursor, so no events are lost or duplicated across reconnects. It ends only when you break out of the loop, abort the signal, or a genuinely fatal error occurs (which rejects the loop).

To observe or tune connection behavior, configure a transport with websocketTransport() and pass it via the liveTransport option:

ts
import { Jetstream, websocketTransport } from '@bsky/jetstream'

const js = new Jetstream('https://jetstream.us-east.bsky.network')
for await (const ev of js.live({
  collections: ['app.bsky.feed.post'],
  liveTransport: websocketTransport({
    onReconnect: (err, { attempt }) => console.warn('reconnecting', attempt),
  }),
})) {
  // ...
}

Commonly useful options: onReconnect to observe connection trouble (retries are otherwise silent), shouldReconnect to change when the stream gives up, and idleTimeoutMs to tune dead-connection detection (default 60s; false disables). websocketTransport() inherits the rest of its options from @atproto/ws-client.