## 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`.

### Profile links

A detailed profile (`app.bsky.actor.defs#profileViewDetailed`) can include `links`, a list of `profileLinkView`s. 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](https://github.com/bluesky-social/atproto/tree/main/packages/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](#app-password-based-session-management)
2. [OAuth based session management](#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:

- [@atproto/oauth-client-browser](https://www.npmjs.com/package/@atproto/oauth-client-browser):
  for the browser.
- [@atproto/oauth-client-node](https://www.npmjs.com/package/@atproto/oauth-client-node): for
  Node.js.
- [@atproto/oauth-client](https://www.npmjs.com/package/@atproto/oauth-client):
  Lower level; compatible with most JS engines.

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](#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](./docs/moderation.md).

```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](../../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:

- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)
- Apache License, Version 2.0, ([LICENSE-APACHE.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-APACHE.txt) or http://www.apache.org/licenses/LICENSE-2.0)

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](https://github.com/bluesky-social/jetstream), a friendly way to consume data published to AT Protocol in realtime.

## Quick start

Install the Jetstream client:

```
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`](https://npmx.dev/@atproto/ws-client).