{"owner":"bluesky-social","repo":"bsky","hasSkills":true,"totalSkillsCount":4,"totalTokensCount":14965,"categories":["plugin-manifest","anthropic-skill"],"hasMcp":false,"mcpConfig":null,"found":["packages/jetstream/README.md","packages/sdk/README.md","packages/sdk/docs/moderation.md","skills/upgrade-from-atproto-api/SKILL.md"],"skills":{"packages/jetstream/README.md":"# @bsky/jetstream\n\nClient library for [Jetstream](https://github.com/bluesky-social/jetstream), a friendly way to consume data published to AT Protocol in realtime.\n\n## Quick start\n\nInstall the Jetstream client:\n\n```\nnpm install @bsky/jetstream @atproto/lex\n```\n\n`@atproto/lex` is a peer dependency providing the lexicon type system that events are typed with.\n\nThis 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:\n\n```ts\nimport { Jetstream, isCreate } from '@bsky/jetstream'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\n\nfor await (const evt of js.live({ collections: ['app.bsky.feed.post'] })) {\n  if (isCreate(evt)) {\n    console.log(evt.commit.collection, evt.commit.record)\n  }\n}\n```\n\n`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.\n\nUsing a lexicon as your collection filter will validate and type the events for you:\n\n```ts\nimport { Jetstream, isCreate } from '@bsky/jetstream'\nimport { app } from '@bsky/sdk/lexicons'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\n\nfor await (const evt of js.live({ collections: [app.bsky.feed.post] })) {\n  if (isCreate(evt)) {\n    console.log(evt.commit.collection, evt.commit.record.text)\n  }\n}\n```\n\n`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.\n\nTyped 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).\n\nA 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.\n\nFor 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:\n\n```ts\nimport { Jetstream, LexIndexer, MemoryCursorStore } from '@bsky/jetstream'\nimport { app } from '@bsky/sdk/lexicons'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\n\nconst indexer = new LexIndexer()\n  .commit(app.bsky.feed.like, {\n    put: async (e) => {\n      console.log('index', e.uri, e.record)\n    },\n    del: async (e) => {\n      console.log('remove', e.uri)\n    },\n  })\n  .identity(async (e) => {\n    console.log('identity change', e.did, e.handle)\n  })\n\n// Implement CursorStore to persist the resume cursor durably.\nconst cursor = new MemoryCursorStore()\n\nawait js.runner(indexer).live({ cursor })\n```\n\n### Cursors\n\n`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.\n\nCursors 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.\n\n### Legacy v1 instances\n\nThe 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.\n\n```ts\nimport { JetstreamV1 } from '@bsky/jetstream'\n\nconst js = new JetstreamV1('https://jetstream1.us-east.bsky.network')\nfor await (const evt of js.live({ collections: ['app.bsky.feed.post'] })) {\n  console.log(evt.seq, evt.kind)\n}\n```\n\n## Connection behavior\n\nThis package runs on Node.js and in the browser.\n\nA 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).\n\nTo observe or tune connection behavior, configure a transport with `websocketTransport()` and pass it via the `liveTransport` option:\n\n```ts\nimport { Jetstream, websocketTransport } from '@bsky/jetstream'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\nfor await (const ev of js.live({\n  collections: ['app.bsky.feed.post'],\n  liveTransport: websocketTransport({\n    onReconnect: (err, { attempt }) => console.warn('reconnecting', attempt),\n  }),\n})) {\n  // ...\n}\n```\n\nCommonly 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).\n","packages/sdk/README.md":"# @bsky/sdk\n\nBluesky 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.\n\n## Install\n\n```sh\nnpm install @bsky/sdk @atproto/lex\n```\n\n`@atproto/lex` provides the `Client` class that powers all API calls. `@bsky/sdk` provides the Bluesky-specific lexicons, actions, and utilities.\n\n## Getting started\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport { post } from '@bsky/sdk'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'your.bsky.social', // your handle\n  password: 'xxxx-xxxx-xxxx-xxxx', // an app password, generated in the Bluesky app\n})\nconst client = new Client(session)\n\nawait client.call(post, { text: 'Hello, world!' })\n```\n\n## Usage\n\n### Session management\n\nYou'll need an authenticated session for most API calls. There are two ways to\nmanage sessions:\n\n1. [App password based session management](#app-password-based-session-management)\n2. [OAuth based session management](#oauth-based-session-management)\n\n#### App password based session management\n\nUsername / password based authentication can be performed using the\n`PasswordSession` class from `@atproto/lex-password-session`.\n\n> [!CAUTION]\n>\n> This method is deprecated in favor of OAuth based session management. It is\n> recommended to use OAuth based session management (through the\n> `@atproto/oauth-client-*` packages).\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport {\n  PasswordSession,\n  type SessionData,\n} from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'your.bsky.social', // your handle\n  password: 'xxxx-xxxx-xxxx-xxxx', // an app password, generated in the Bluesky app\n  onUpdated: (data: SessionData) => saveToStorage(data),\n  onDeleted: (data: SessionData) => clearStorage(data.did),\n})\nconst client = new Client(session)\nconsole.log(`Authenticated as: ${client.assertDid}`)\n\n// Next time, resume with the persisted session data to avoid storing\n// user credentials:\nconst resumed = await PasswordSession.resume(loadFromStorage(), {\n  onUpdated: (data: SessionData) => saveToStorage(data),\n  onDeleted: (data: SessionData) => clearStorage(data.did),\n})\n```\n\n#### OAuth based session management\n\nDepending on the environment used by your application, different OAuth clients\nare available:\n\n- [@atproto/oauth-client-browser](https://www.npmjs.com/package/@atproto/oauth-client-browser):\n  for the browser.\n- [@atproto/oauth-client-node](https://www.npmjs.com/package/@atproto/oauth-client-node): for\n  Node.js.\n- [@atproto/oauth-client](https://www.npmjs.com/package/@atproto/oauth-client):\n  Lower level; compatible with most JS engines.\n\nEvery `@atproto/oauth-client-*` implementation has a different way to obtain an\nOAuth session instance that can be used to instantiate a `Client`. Here is an\nexample restoring a previously saved session:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { OAuthClient } from '@atproto/oauth-client'\n\nconst oauthClient = new OAuthClient({\n  // ...\n})\n\nconst oauthSession = await oauthClient.restore('did:plc:123')\n\n// Instantiate the lex Client using an OAuth session\nconst client = new Client(oauthSession)\n```\n\n### Authenticated and public clients\n\nUse the `api.*` constants for addressing Bluesky services:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { api } from '@bsky/sdk'\n\n// Bluesky API (authenticated queries, proxied through the account host;\n// record writes and mutations automatically target the account host directly)\nconst bskyClient = new Client(session, { service: api.app.service })\n\n// Bluesky API (unauthenticated public queries)\nconst publicBskyClient = new Client(api.app.urlPublic)\n```\n\n### API calls\n\nThe SDK exports actions for many common operations. Invoke an action against a\nclient with `client.call(action, input)`:\n\n```typescript\nimport {\n  blockActor,\n  blockActorList,\n  deleteFollow,\n  deleteLike,\n  deletePost,\n  deleteRepost,\n  follow,\n  getPreferences,\n  like,\n  muteActor,\n  muteActorList,\n  post,\n  repost,\n  unblockActor,\n  unblockActorList,\n  unmuteActor,\n  unmuteActorList,\n  updateSeenNotifications,\n  upsertProfile,\n} from '@bsky/sdk'\n\n// The DID of the user currently authenticated (or undefined)\nbskyClient.did\nbskyClient.assertDid // Throws if the user is not authenticated\n\n// Feeds and content\nawait bskyClient.call(post, { text: 'Hello, world!' })\nawait bskyClient.call(deletePost, postUri)\nawait bskyClient.call(like, { uri, cid })\nawait bskyClient.call(deleteLike, likeUri)\nawait bskyClient.call(repost, { uri, cid })\nawait bskyClient.call(deleteRepost, repostUri)\n\n// Social graph\nawait bskyClient.call(follow, { did })\nawait bskyClient.call(deleteFollow, followUri)\nawait bskyClient.call(muteActor, { actor })\nawait bskyClient.call(unmuteActor, { actor })\nawait bskyClient.call(blockActor, { did })\nawait bskyClient.call(unblockActor, blockUri)\nawait bskyClient.call(muteActorList, { list })\nawait bskyClient.call(unmuteActorList, { list })\nawait bskyClient.call(blockActorList, { list })\nawait bskyClient.call(unblockActorList, { list })\n\n// Actors\nawait bskyClient.call(upsertProfile, (existing) => ({\n  ...existing,\n  displayName: 'Alice',\n}))\n\n// Notifications\nawait bskyClient.call(updateSeenNotifications, seenAt)\n\n// Preferences\nconst prefs = await bskyClient.call(getPreferences)\n```\n\nQueries without a dedicated action are one `xrpc` call away using the generated\nlexicons — see [Advanced API calls](#advanced-api-calls).\n\n### Validation and types\n\nThe lexicons include a complete types system with validation and type-guards.\nFor example, to validate a post record:\n\n```typescript\nimport { app } from '@bsky/sdk/lexicons'\n\nconst post = { ... }\nif (app.bsky.feed.post.$isTypeOf(post)) {\n  // typescript now recognizes `post` may be an app.bsky.feed.post.Main\n  // however -- we still need to validate it\n  const res = app.bsky.feed.post.main.safeValidate(post)\n  if (res.success) {\n    // a valid record\n  } else {\n    // something is wrong\n    console.log(res.error)\n  }\n}\n```\n\n### Rich text\n\nSome 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.\n\nℹ️ 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.\n\n```typescript\nimport { currentDatetimeString } from '@atproto/lex'\nimport { RichText } from '@bsky/sdk/richtext'\n\n// creating richtext — detects mentions and links, resolving mentions to DIDs\nconst rt = await RichText.resolve(\n  'Hello @alice.com, check out this link: https://example.com',\n  { resolver: client },\n)\nconst postRecord = {\n  $type: 'app.bsky.feed.post',\n  text: rt.text,\n  facets: rt.facets,\n  createdAt: currentDatetimeString(),\n}\n\n// rendering as markdown\nlet markdown = ''\nfor (const segment of rt.segments()) {\n  if (segment.isLink()) {\n    markdown += `[${segment.text}](${segment.link?.uri})`\n  } else if (segment.isMention()) {\n    markdown += `[${segment.text}](https://my-bsky-app.com/user/${segment.mention?.did})`\n  } else {\n    markdown += segment.text\n  }\n}\n\n// calculating string lengths\nconst rt2 = new RichText({ text: 'Hello' })\nconsole.log(rt2.length) // => 5\nconsole.log(rt2.graphemeLength) // => 5\nconst rt3 = new RichText({ text: '👨‍👩‍👧‍👧' })\nconsole.log(rt3.length) // => 25\nconsole.log(rt3.graphemeLength) // => 1\n```\n\n### Moderation\n\nApplying 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:\n\n- Moderator labeling\n- User muting (including mutelists)\n- User blocking\n- Mutewords\n- Hidden posts\n\nFor more information, see the [Moderation Documentation](./docs/moderation.md).\n\n```typescript\nimport { getPreferences } from '@bsky/sdk'\nimport { moderatePost } from '@bsky/sdk/moderation'\n\n// First get the user's moderation prefs and their label definitions\n// =\n\nconst prefs = await bskyClient.call(getPreferences)\nconst labelDefs = {\n  /* see the Moderation Documentation for gathering labelDefs */\n}\n\n// We call the appropriate moderation function for the content\n// =\n\nconst postMod = moderatePost(postView, {\n  userDid: bskyClient.assertDid,\n  prefs: prefs.moderationPrefs,\n  labelDefs,\n})\n\n// We then use the output to decide how to affect rendering\n// =\n\n// in feeds\nif (postMod.ui('contentList').filter) {\n  // don't include in feeds\n}\nif (postMod.ui('contentList').blur) {\n  // render the whole object behind a cover (use postMod.ui('contentList').blurs to explain)\n  if (postMod.ui('contentList').noOverride) {\n    // do not allow the cover the be removed\n  }\n}\nif (postMod.ui('contentList').alert || postMod.ui('contentList').inform) {\n  // render warnings on the post\n  // find the warnings in postMod.ui('contentList').alerts and postMod.ui('contentList').informs\n}\n\n// viewed directly\nif (postMod.ui('contentView').filter) {\n  // don't render the view\n}\nif (postMod.ui('contentView').blur) {\n  // render the whole object behind a cover (use postMod.ui('contentView').blurs to explain)\n  if (postMod.ui('contentView').noOverride) {\n    // do not allow the cover the be removed\n  }\n}\nif (postMod.ui('contentView').alert || postMod.ui('contentView').inform) {\n  // render warnings on the post\n  // find the warnings in postMod.ui('contentView').alerts and postMod.ui('contentView').informs\n}\n\n// post embeds in all contexts\nif (postMod.ui('contentMedia').blur) {\n  // render the whole object behind a cover (use postMod.ui('contentMedia').blurs to explain)\n  if (postMod.ui('contentMedia').noOverride) {\n    // do not allow the cover the be removed\n  }\n}\n```\n\n## Advanced\n\n### Advanced API calls\n\nThe actions above are convenience wrappers. They cover most but not all\navailable methods.\n\nThe AT Protocol identifies methods and records with reverse-DNS names. You can\ncall any of them using the generated lexicons and the client's `call` and\nrecord helpers:\n\n```typescript\nimport { currentDatetimeString } from '@atproto/lex'\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// Record helpers always target the user's account host\nconst res1 = await bskyClient.createRecord({\n  $type: 'app.bsky.feed.post',\n  text: 'Hello, world!',\n  createdAt: currentDatetimeString(),\n})\nconst res2 = await bskyClient.call(\n  com.atproto.repo.listRecords,\n  { repo: alice.did, collection: 'app.bsky.feed.post' },\n  { service: null }, // target the account host rather than the Bluesky API\n)\n// or, using the typed record convenience helper:\nconst res2b = await bskyClient.list(app.bsky.feed.post, {\n  repo: alice.did, // optional. Defaults to bskyClient.did (throws if unauthenticated)\n})\n\nconst res3 = await bskyClient.call(app.bsky.feed.getTimeline, {\n  limit: 20,\n})\n// res3: app.bsky.feed.getTimeline.OutputSchema\n```\n\nNamespace roots exported from `/lexicons`: `app`, `com`, `chat`.\n\n### Bring your own fetch\n\nIf you want to provide your own `fetch` implementation — for logging, mocking,\nretries, or environments without a built-in `fetch` — pass it when constructing\nthe `Client`:\n\n```typescript\nimport { Client } from '@atproto/lex'\n\nconst myFetch: typeof globalThis.fetch = async (input, init) => {\n  console.log('requesting', input)\n  const response = await globalThis.fetch(input, init)\n  console.log('got response', response)\n  return response\n}\n\nconst client = new Client({\n  service: 'https://example.com',\n  fetch: myFetch,\n})\n```\n\n## Subpaths\n\n| Import                 | Contents                                                                             |\n| ---------------------- | ------------------------------------------------------------------------------------ |\n| `@bsky/sdk`            | `api` constants, actions, `DEFAULT_LABEL_SETTINGS`                                   |\n| `@bsky/sdk/lexicons`   | Generated lexicon definitions (`app`, `com`, `chat`)                                 |\n| `@bsky/sdk/moderation` | `moderatePost`, `moderateProfile`, and friends; `ModerationDecision`, `ModerationUI` |\n| `@bsky/sdk/richtext`   | `RichText` builder                                                                   |\n| `@bsky/sdk/utils`      | Age assurance helpers, `sanitizeMutedWordValue`, nux validation                      |\n\n## Upgrading from @atproto/api\n\nSee [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.\n\n## License\n\nThis project is dual-licensed under MIT and Apache 2.0 terms:\n\n- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)\n- 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)\n\nDownstream 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.\n","packages/sdk/docs/moderation.md":"# Moderation API\n\nApplying 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:\n\n- Moderator labeling\n- User muting (including mutelists)\n- User blocking\n- Mutewords\n- Hidden posts\n\n## Configuration\n\nEvery moderation function takes a set of options which look like this:\n\n```typescript\n{\n  // the logged-in user's DID\n  userDid: 'did:plc:1234...',\n\n  prefs: {\n    // is adult content allowed?\n    adultContentEnabled: true,\n\n    // the global label settings (used on self-labels)\n    labels: {\n      porn: 'hide',\n      sexual: 'warn',\n      nudity: 'ignore',\n      // ...\n    },\n\n    // the subscribed labelers and their label settings\n    labelers: [\n      {\n        did: 'did:plc:1234...',\n        labels: {\n          porn: 'hide',\n          sexual: 'warn',\n          nudity: 'ignore',\n          // ...\n        }\n      }\n    ],\n\n    mutedWords: [/* ... */],\n    hiddenPosts: [/* ... */]\n  },\n\n  // custom label definitions\n  labelDefs: {\n    // labelerDid => defs[]\n    'did:plc:1234...': [\n      /* ... */\n    ]\n  }\n}\n```\n\nThis should match the following interfaces:\n\n```typescript\nexport interface ModerationPrefsLabeler {\n  did: DidString\n  labels: Record<string, LabelPreference>\n}\n\nexport interface ModerationPrefs {\n  adultContentEnabled: boolean\n  labels: Record<string, LabelPreference>\n  labelers: ModerationPrefsLabeler[]\n  mutedWords: app.bsky.actor.defs.MutedWord[]\n  hiddenPosts: AtUriString[]\n}\n\nexport interface ModerationOpts {\n  userDid: DidString | undefined\n  prefs: ModerationPrefs\n  /**\n   * Map of labeler did -> custom definitions\n   */\n  labelDefs?: Record<string, InterpretedLabelValueDefinition[]>\n}\n```\n\nYou can quickly grab the `ModerationPrefs` using the `getPreferences` action:\n\n```typescript\nimport { getPreferences } from '@bsky/sdk'\nimport { moderatePost } from '@bsky/sdk/moderation'\n\nconst prefs = await client.call(getPreferences)\nmoderatePost(post, {\n  userDid: /*...*/,\n  prefs: prefs.moderationPrefs,\n  labelDefs: /*...*/\n})\n```\n\nTo gather the label definitions (`labelDefs`) see the _Labelers_ section below.\n\n## Labelers\n\nLabelers 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:\n\n```typescript\nimport { Client } from '@atproto/lex'\n\nconst client = new Client(session, {\n  appLabelers: ['did:web:my-labeler.com'],\n})\n```\n\n(`Client.configure({ appLabelers })` sets the same thing globally for all client instances; prefer the per-client option.)\n\nUsers 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.\n\nLabelers publish a `app.bsky.labeler.service` record that looks like this:\n\n```js\n{\n  $type: 'app.bsky.labeler.service',\n  policies: {\n    // the list of label values the labeler will publish\n    labelValues: [\n      'rude',\n    ],\n    // any custom definitions the labeler will be using\n    labelValueDefinitions: [\n      {\n        identifier: 'rude',\n        blurs: 'content',\n        severity: 'alert',\n        defaultSetting: 'warn',\n        adultOnly: false,\n        locales: [\n          {\n            lang: 'en',\n            name: 'Rude',\n            description: 'Not keeping things civil.',\n          },\n        ],\n      },\n    ],\n  },\n  createdAt: '2024-03-12T17:17:17.215Z'\n}\n```\n\nThe 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).\n\nHere is how to do this:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { getPreferences } from '@bsky/sdk'\nimport { app } from '@bsky/sdk/lexicons'\nimport {\n  type InterpretedLabelValueDefinition,\n  interpretLabelValueDefinitions,\n  moderatePost,\n} from '@bsky/sdk/moderation'\n\n// assume `client` is backed by a signed-in session\nconst prefs = await client.call(getPreferences)\n\nconst services = await client.call(app.bsky.labeler.getServices, {\n  dids: prefs.moderationPrefs.labelers.map((labeler) => labeler.did),\n  detailed: true,\n})\n\nconst labelDefs: Record<string, InterpretedLabelValueDefinition[]> = {}\nfor (const view of services.views) {\n  if (app.bsky.labeler.defs.labelerViewDetailed.isTypeOf(view)) {\n    labelDefs[view.creator.did] = interpretLabelValueDefinitions(view)\n  }\n}\n\nmoderatePost(post, {\n  userDid: client.assertDid,\n  prefs: prefs.moderationPrefs,\n  labelDefs,\n})\n```\n\n## The `moderate*()` APIs\n\nThe SDK exports methods to moderate the different kinds of content on the network.\n\n```typescript\nimport {\n  moderateProfile,\n  moderatePost,\n  moderateNotification,\n  moderateFeedGenerator,\n  moderateUserList,\n  moderateStatus,\n  moderateViewExternal,\n  moderateProfileLink,\n} from '@bsky/sdk/moderation'\n```\n\nEach of these follows the same API signature:\n\n```typescript\nconst res = moderatePost(post, moderationOptions)\n```\n\nThe response object provides an API for figuring out what your UI should do in different contexts.\n\n```typescript\nres.ui(context) /* =>\n\nModerationUI {\n  filter: boolean // should the content be removed from the interface?\n  blur: boolean // should the content be put behind a cover?\n  alert: boolean // should an alert be put on the content? (negative)\n  inform: boolean // should an informational notice be put on the content? (neutral)\n  noOverride: boolean // if blur=true, should the UI disable opening the cover?\n\n  // the reasons for each of the flags:\n  filters: ModerationCause[]\n  blurs: ModerationCause[]\n  alerts: ModerationCause[]\n  informs: ModerationCause[]\n}\n*/\n```\n\nThere are multiple UI contexts available:\n\n- `profileList` A profile being listed, eg in search or a follower list\n- `profileView` A profile being viewed directly\n- `avatar` The user's avatar in any context\n- `banner` The user's banner in any context\n- `displayName` The user's display name in any context\n- `contentList` Content being listed, eg posts in a feed, posts as replies, a user list list, a feed generator list, etc\n- `contentView` Content being viewed direct, eg an opened post, the user list page, the feedgen page, etc\n- `contentMedia ` Media inside the content, eg a picture embedded in a post\n\nHere's how a post in a feed would use these tools to make a decision:\n\n```typescript\nconst mod = moderatePost(post, moderationOptions)\n\nif (mod.ui('contentList').filter) {\n  // dont show the post\n}\nif (mod.ui('contentList').blur) {\n  // cover the post with the explanation from mod.ui('contentList').blurs[0]\n  if (mod.ui('contentList').noOverride) {\n    // dont allow the cover to be removed\n  }\n}\nif (mod.ui('contentMedia').blur) {\n  // cover the post's embedded images with the explanation from mod.ui('contentMedia').blurs[0]\n  if (mod.ui('contentMedia').noOverride) {\n    // dont allow the cover to be removed\n  }\n}\nif (mod.ui('avatar').blur) {\n  // cover the avatar with the explanation from mod.ui('avatar').blurs[0]\n  if (mod.ui('avatar').noOverride) {\n    // dont allow the cover to be removed\n  }\n}\nfor (const alert of mod.ui('contentList').alerts) {\n  // render this alert\n}\nfor (const inform of mod.ui('contentList').informs) {\n  // render this inform\n}\n```\n\n### External embeds\n\nLabels 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.\n\nAn external card acts as both the \"content\" and the \"media\" of its embed, so combine the `contentView` and `contentMedia` contexts with `mergeModUIResults`:\n\n```typescript\nimport { mergeModUIResults, moderateViewExternal } from '@bsky/sdk/moderation'\n\nconst embedMod = moderateViewExternal(embed.external, moderationOptions)\nconst ui = mergeModUIResults(\n  embedMod.ui('contentView'),\n  embedMod.ui('contentMedia'),\n)\n\nif (ui.blur) {\n  // cover the card with the explanation from ui.blurs[0]\n  if (ui.noOverride) {\n    // don't allow the cover to be removed\n  }\n}\n```\n\n`mergeModUIResults` accepts any number of `ModerationUI` values (undefined entries are skipped), combining their causes and preserving the strictest `noOverride`.\n\n### Profile links\n\nA 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:\n\n```typescript\nimport { moderateProfile, moderateProfileLink } from '@bsky/sdk/moderation'\n\nconst profileMod = moderateProfile(profile, moderationOptions)\n\nfor (const link of profile.links ?? []) {\n  const linkMod = moderateProfileLink(link, moderationOptions)\n\n  if (linkMod.ui('contentView').blur) {\n    // cover the link with the explanation from linkMod.ui('contentView').blurs[0]\n  }\n  if (linkMod.ui('contentMedia').blur) {\n    // cover the link's icon with the explanation from linkMod.ui('contentMedia').blurs[0]\n  }\n}\n```\n\nNotes:\n\n- 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.\n- 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.\n- `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.\n\n## Sending moderation reports\n\nAny 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:\n\n```typescript\nimport { com } from '@bsky/sdk/lexicons'\n\nawait client.call(\n  com.atproto.moderation.createReport,\n  {\n    reasonType: com.atproto.moderation.defs.reasonViolation.$token,\n    reason: 'They were being such a jerk to me!',\n    subject: com.atproto.admin.defs.repoRef.$build({\n      did: 'did:web:bob.com',\n    }),\n  },\n  { service: 'did:web:my-labeler.com#atproto_labeler' },\n)\n```\n\nThe Bluesky moderation service's address is available via the `api` export from the SDK:\n\n```typescript\nimport { api } from '@bsky/sdk'\n\nawait client.call(com.atproto.moderation.createReport, report, {\n  service: api.moderation.service, // did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler\n})\n```\n","skills/upgrade-from-atproto-api/SKILL.md":"---\nname: upgrade-from-atproto-api\ndescription: Migrate code from @atproto/api to @bsky/sdk + @atproto/lex — import mappings, agent-to-client patterns, string format types\n---\n\n# Migrating from `@atproto/api` to `@bsky/sdk` + `@atproto/lex`\n\nThe `@atproto/api` package has been superseded by two packages:\n\n- **`@bsky/sdk`** — Bluesky-specific actions, moderation helpers, rich text, and the `api` constants object.\n- **`@atproto/lex`** — the generic AT Protocol client, lexicon utilities, and schema types. A peer dependency of the sdk; install both. (`@atproto/lex-password-session` is separate, only needed for password-based sessions.)\n\n```\nnpm install @bsky/sdk @atproto/lex\n```\n\nImport `Client` and other lex utilities from `@atproto/lex` — not from its underlying sub-packages (`@atproto/lex-client`, `@atproto/lex-schema`), which shouldn't be depended upon directly.\n\n## 1. Client Setup\n\nThere are two common client patterns.\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport { api } from '@bsky/sdk'\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// --- Pattern 1: Authenticated Bluesky client ---\n// Queries are proxied through the account host to api.bsky.app and signed\n// with the user's identity. Record writes and mutations (createRecord,\n// applyWrites, etc.) automatically target the account host directly, so one\n// client covers both.\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx', // App password\n  onUpdated: (data) => saveToStorage(data),\n  onDeleted: (data) => clearStorage(data.did),\n})\nconst bskyClient = new Client(session, {\n  service: api.app.service, // 'did:web:api.bsky.app#bsky_appview'\n})\n\n// --- Pattern 2: Public Bluesky API client (unauthenticated read-only) ---\n// Use for reading public data without authentication.\nconst publicBskyClient = new Client(api.app.urlPublic) // 'https://public.api.bsky.app'\n```\n\nTo target a different service for a single request (e.g. the account host\nitself, or a labeler), pass `service` in the per-request options rather than\nconstructing another client — `{ service: null }` reaches the account host\ndirectly:\n\n```typescript\nconst records = await bskyClient.call(\n  com.atproto.repo.listRecords,\n  { repo: bskyClient.assertDid, collection: 'app.bsky.feed.post' },\n  { service: null },\n)\n```\n\nDeployments that already keep separate clients for the account host vs the\nBluesky API can continue to do so — construct each client from the same\nsession:\n\n```typescript\nconst accountClient = new Client(session)\nconst bskyClient = new Client(session, { service: api.app.service })\n```\n\n**`api` constants reference:**\n\n| Constant                 | Value                                                |\n| ------------------------ | ---------------------------------------------------- |\n| `api.app.did`            | `'did:web:api.bsky.app'`                             |\n| `api.app.service`        | `'did:web:api.bsky.app#bsky_appview'`                |\n| `api.app.url`            | `'https://api.bsky.app'`                             |\n| `api.app.urlPublic`      | `'https://public.api.bsky.app'`                      |\n| `api.chat.did`           | `'did:web:api.bsky.chat'`                            |\n| `api.chat.service`       | `'did:web:api.bsky.chat#bsky_chat'`                  |\n| `api.chat.url`           | `'https://api.bsky.chat'`                            |\n| `api.moderation.did`     | `'did:plc:ar7c4by46qjdydhdevvrndac'`                 |\n| `api.moderation.service` | `'did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler'` |\n\n**Labeler caveat:** When no labeler headers are sent, the Bluesky API applies its own moderation service by default — so if you don't introduce other labelers, no labeler configuration is needed at all. The `atproto-accept-labelers` header works by replacement, not addition: sending _any_ labelers header replaces that server-side default. So when you introduce other labelers, keep the Bluesky labeler active by setting it as an _app labeler_ — app labelers are always included in the header with the `;redact` parameter (allowing takedowns), which a plain `labelers` entry does not set:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { api } from '@bsky/sdk'\n\nconst client = new Client(session, {\n  service: api.app.service,\n  appLabelers: [api.moderation.did], // keep Bluesky moderation, with `;redact`\n  labelers: ['did:plc:mycustomlabeler...'], // the user's labeler subscriptions\n})\n```\n\n(`Client.configure({ appLabelers })` sets the same thing globally for all client instances; prefer the per-client option.) Defaults cascade app → client → request: a per-request `labelers` array replaces the client's, `labelers: null` disables the header for that request, and `appLabelers: null` opts a client or request out of the app labelers.\n\n---\n\n## 2. Import Mappings\n\n### Client / Session classes\n\n| Old (`@atproto/api`) | New               | Package                         |\n| -------------------- | ----------------- | ------------------------------- |\n| `AtpAgent`           | `Client`          | `@atproto/lex`                  |\n| `Agent`              | `Client`          | `@atproto/lex`                  |\n| `BskyAgent`          | `Client`          | `@atproto/lex`                  |\n| `AtpBaseClient`      | `Client`          | `@atproto/lex`                  |\n| `CredentialSession`  | `PasswordSession` | `@atproto/lex-password-session` |\n\n### Sessions\n\n| Old (`@atproto/api`)       | New           | Package                         | Notes                                                              |\n| -------------------------- | ------------- | ------------------------------- | ------------------------------------------------------------------ |\n| `AtpSessionData`           | `SessionData` | `@atproto/lex-password-session` | Shape for persisted session data; renamed for clarity              |\n| `AtpSessionEvent`          | removed       | —                               | Session event types now inferred from `PasswordSession` callbacks  |\n| `AtpPersistSessionHandler` | removed       | —                               | Use `onUpdated`/`onDeleted` callbacks on `PasswordSession` instead |\n| `AtpAgentLoginOpts`        | removed       | —                               | Merged into `PasswordSession.login()` parameters                   |\n| `AtpAgentGlobalOpts`       | removed       | —                               | Replaced by `Client` constructor options                           |\n\n### Preferences Types\n\n| Old (`@atproto/api`)       | New                        | Package     | Notes                                                      |\n| -------------------------- | -------------------------- | ----------- | ---------------------------------------------------------- |\n| `BskyPreferences`          | `BskyPreferences`          | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n| `BskyFeedViewPreference`   | `BskyFeedViewPreference`   | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n| `BskyThreadViewPreference` | `BskyThreadViewPreference` | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n| `BskyInterestsPreference`  | `BskyInterestsPreference`  | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n\n### Errors\n\n| Old (`@atproto/api`) | New                                 | Package        |\n| -------------------- | ----------------------------------- | -------------- |\n| `XRPCError`          | `XrpcResponseError` / `XrpcFailure` | `@atproto/lex` |\n\n### Utility / codec functions\n\n| Old (`@atproto/api`) | New                                                    | Package              |\n| -------------------- | ------------------------------------------------------ | -------------------- |\n| `jsonStringToLex`    | `lexParse`                                             | `@atproto/lex`       |\n| `jsonToLex`          | `jsonToLex`                                            | `@atproto/lex`       |\n| `lexToJson`          | `lexToJson`                                            | `@atproto/lex`       |\n| `stringifyLex`       | `lexStringify`                                         | `@atproto/lex`       |\n| `BlobRef`            | `BlobRef`                                              | `@atproto/lex`       |\n| `asPredicate`        | `schema.$matches` (e.g. `app.bsky.feed.post.$matches`) | `@bsky/sdk/lexicons` |\n| `parseLanguage`      | removed — use `isLanguageString`                       | `@atproto/lex`       |\n\n### Type utilities\n\n| Old (`@atproto/api`) | New            | Package                                    |\n| -------------------- | -------------- | ------------------------------------------ |\n| `$Typed<T>`          | `$Typed<V, T>` | `@atproto/lex-schema` (via `@atproto/lex`) |\n| `Un$Typed<T>`        | `Un$Typed<V>`  | `@atproto/lex-schema` (via `@atproto/lex`) |\n\n### Constants\n\n| Old (`@atproto/api`)     | New                      | Package     |\n| ------------------------ | ------------------------ | ----------- |\n| `BSKY_LABELER_DID`       | `api.moderation.did`     | `@bsky/sdk` |\n| `DEFAULT_LABEL_SETTINGS` | `DEFAULT_LABEL_SETTINGS` | `@bsky/sdk` |\n\n### Moderation\n\n| Old (`@atproto/api`)             | New                              | Package                |\n| -------------------------------- | -------------------------------- | ---------------------- |\n| `moderatePost`                   | `moderatePost`                   | `@bsky/sdk/moderation` |\n| `moderateProfile`                | `moderateProfile`                | `@bsky/sdk/moderation` |\n| `moderateUserList`               | `moderateUserList`               | `@bsky/sdk/moderation` |\n| `moderateFeedGenerator`          | `moderateFeedGenerator`          | `@bsky/sdk/moderation` |\n| `moderateNotification`           | `moderateNotification`           | `@bsky/sdk/moderation` |\n| `moderateStatus`                 | `moderateStatus`                 | `@bsky/sdk/moderation` |\n| `ModerationUI`                   | `ModerationUI`                   | `@bsky/sdk/moderation` |\n| `ModerationDecision`             | `ModerationDecision`             | `@bsky/sdk/moderation` |\n| `interpretLabelValueDefinition`  | `interpretLabelValueDefinition`  | `@bsky/sdk/moderation` |\n| `interpretLabelValueDefinitions` | `interpretLabelValueDefinitions` | `@bsky/sdk/moderation` |\n| `hasMutedWord`                   | `hasMutedWord`                   | `@bsky/sdk/moderation` |\n| `matchMuteWords`                 | `matchMuteWords`                 | `@bsky/sdk/moderation` |\n| `LABELS`                         | `LABELS`                         | `@bsky/sdk/moderation` |\n\n### Rich text\n\n| Old (`@atproto/api`)               | New                                    | Package              |\n| ---------------------------------- | -------------------------------------- | -------------------- |\n| `RichText`                         | `RichText`                             | `@bsky/sdk/richtext` |\n| `RichText.detectFacets(agent)`     | `rt.detectFacets(resolver)` — see note | `@bsky/sdk/richtext` |\n| `sanitizeRichText`                 | `sanitizeRichText`                     | `@bsky/sdk/richtext` |\n| `UnicodeString`                    | `UnicodeString`                        | `@bsky/sdk/richtext` |\n| `MENTION_REGEX`, `URL_REGEX`, etc. | same names                             | `@bsky/sdk/richtext` |\n\n**`detectFacets` resolver change:** The method now takes either a `Client` or a `HandleResolver` from `@atproto-labs/handle-resolver` instead of an agent. Passing a `Client` resolves handles via `com.atproto.identity.resolveHandle`:\n\n```typescript\n// Usage with a Client (resolves via com.atproto.identity.resolveHandle):\nconst rt = new RichText({ text: 'Hello @alice.bsky.social!' })\nawait rt.detectFacets(bskyClient)\n\n// Or bring your own HandleResolver:\nawait rt.detectFacets(myHandleResolver)\n\n// Or use the one-step static helper (construct + detect + resolve):\nconst rt2 = await RichText.resolve('Hello @alice.bsky.social!', {\n  resolver: bskyClient,\n})\n```\n\nThe `Client`-backed resolver is also exported directly for standalone use:\n\n```typescript\nimport { ClientHandleResolver } from '@bsky/sdk/utils'\n\nconst resolver = new ClientHandleResolver(bskyClient)\nconst did = await resolver.resolve('alice.bsky.social') // AtprotoDid | null\n```\n\n### Utils\n\n| Old (`@atproto/api`)     | New                                          | Package           |\n| ------------------------ | -------------------------------------------- | ----------------- |\n| `sanitizeMutedWordValue` | `sanitizeMutedWordValue`                     | `@bsky/sdk/utils` |\n| `validateNux`            | `validateNux`                                | `@bsky/sdk/utils` |\n| `nuxSchema`              | `nuxSchema`                                  | `@bsky/sdk/utils` |\n| `savedFeedsToUriArrays`  | removed — compute from `savedFeeds` directly | —                 |\n\n### Removed without direct replacement\n\n| Old (`@atproto/api`)                    | Notes                                                                           |\n| --------------------------------------- | ------------------------------------------------------------------------------- |\n| `mock` / `BskyAgent.mockResolveHandles` | removed; test with a fake `HandleResolver` function                             |\n| `isDid` / `asDid` / `assertDid`         | use `isStringFormat(v, 'did')` / `asStringFormat(v, 'did')` from `@atproto/lex` |\n| `AtUri`                                 | `AtUri` from `@atproto/syntax` (still a direct dependency)                      |\n| `lexicons` export (Lexicons instance)   | use generated schema objects from `@bsky/sdk/lexicons`                          |\n| `getSavedFeedType`                      | internal only; moved to preference actions                                      |\n| `validateSavedFeed`                     | internal only; moved to preference actions                                      |\n\n---\n\n## 3. Agent Methods → Actions / Client Calls\n\nThe `Agent`/`AtpAgent`/`BskyAgent` class has been replaced by:\n\n- **`Client`** from `@atproto/lex` for raw XRPC calls.\n- **Action functions** from `@bsky/sdk` for higher-level operations (preferences, records, graph, notifications).\n\n### Record / graph sugar methods\n\nThese agent methods now correspond to named action functions called via `client.call(action, input)`:\n\n| Old agent method                        | New action                                     | Import      |\n| --------------------------------------- | ---------------------------------------------- | ----------- |\n| `agent.post(input)`                     | `client.call(post, input)`                     | `@bsky/sdk` |\n| `agent.deletePost(uri)`                 | `client.call(deletePost, uri)`                 | `@bsky/sdk` |\n| `agent.like(uri, cid)`                  | `client.call(like, { uri, cid })`              | `@bsky/sdk` |\n| `agent.deleteLike(uri)`                 | `client.call(deleteLike, uri)`                 | `@bsky/sdk` |\n| `agent.repost(uri, cid)`                | `client.call(repost, { uri, cid })`            | `@bsky/sdk` |\n| `agent.deleteRepost(uri)`               | `client.call(deleteRepost, uri)`               | `@bsky/sdk` |\n| `agent.follow(did)`                     | `client.call(follow, { did })`                 | `@bsky/sdk` |\n| `agent.deleteFollow(uri)`               | `client.call(deleteFollow, uri)`               | `@bsky/sdk` |\n| `agent.upsertProfile(fn)`               | `client.call(upsertProfile, fn)`               | `@bsky/sdk` |\n| `agent.mute(actor, opts?)`              | `client.call(muteActor, { actor, ...opts })`   | `@bsky/sdk` |\n| `agent.unmute(actor)`                   | `client.call(unmuteActor, { actor })`          | `@bsky/sdk` |\n| `agent.muteModList(uri)`                | `client.call(muteActorList, { list: uri })`    | `@bsky/sdk` |\n| `agent.unmuteModList(uri)`              | `client.call(unmuteActorList, { list: uri })`  | `@bsky/sdk` |\n| `agent.blockModList(uri)`               | `client.call(blockActorList, { list: uri })`   | `@bsky/sdk` |\n| `agent.unblockModList(uri)`             | `client.call(unblockActorList, { list: uri })` | `@bsky/sdk` |\n| `agent.updateSeenNotifications(seenAt)` | `client.call(updateSeenNotifications, seenAt)` | `@bsky/sdk` |\n\n**Branded input types:** Unlike the old agent methods, action inputs use the lex string format types — URIs are `AtUriString`, DIDs are `DidString`, etc. Values read from API responses already carry these types; for plain strings from your own storage, validate at the boundary with `asStringFormat(v, 'at-uri')` / `asStringFormat(v, 'did')` (see § 5).\n\n### Preferences methods\n\nPreference reads and writes target the user's account host by default (like the client's own record helpers), regardless of the client's `service` setting — so they work unchanged on a client configured to proxy to the Bluesky API. `getPreferences` and `updatePreferences` accept an optional `service` in their input to override:\n\n```typescript\nawait client.call(getPreferences) // account host\nawait client.call(getPreferences, { service: 'did:web:example.com#svc' })\nawait client.call(updatePreferences, (prefs) => prefs) // account host\nawait client.call(updatePreferences, {\n  update: (prefs) => prefs,\n  service: 'did:web:example.com#svc',\n})\n```\n\n| Old agent method                                   | New action                                                          |\n| -------------------------------------------------- | ------------------------------------------------------------------- |\n| `agent.getPreferences()`                           | `client.call(getPreferences)`                                       |\n| `agent.setAdultContentEnabled(v)`                  | `client.call(setAdultContentEnabled, v)`                            |\n| `agent.setContentLabelPref(key, val, labelerDid?)` | `client.call(setContentLabelPref, { key, value: val, labelerDid })` |\n| `agent.addSavedFeeds(feeds)`                       | `client.call(addSavedFeeds, feeds)`                                 |\n| `agent.removeSavedFeeds(ids)`                      | `client.call(removeSavedFeeds, ids)`                                |\n| `agent.updateSavedFeeds(feeds)`                    | `client.call(updateSavedFeeds, feeds)`                              |\n| `agent.overwriteSavedFeeds(feeds)`                 | `client.call(overwriteSavedFeeds, feeds)`                           |\n| `agent.setFeedViewPrefs(feed, prefs)`              | `client.call(setFeedViewPrefs, { feed, ...prefs })`                 |\n| `agent.setThreadViewPrefs(prefs)`                  | `client.call(setThreadViewPrefs, prefs)`                            |\n| `agent.setPersonalDetails({ birthDate })`          | `client.call(setPersonalDetails, { birthDate })`                    |\n| `agent.setInterestsPref({ tags })`                 | `client.call(setInterestsPref, { tags })`                           |\n| `agent.addMutedWord(word)`                         | `client.call(addMutedWord, word)`                                   |\n| `agent.addMutedWords(words)`                       | `client.call(addMutedWords, words)`                                 |\n| `agent.upsertMutedWords(words)`                    | `client.call(upsertMutedWords, words)` (deprecated)                 |\n| `agent.updateMutedWord(word)`                      | `client.call(updateMutedWord, word)`                                |\n| `agent.removeMutedWord(word)`                      | `client.call(removeMutedWord, word)`                                |\n| `agent.removeMutedWords(words)`                    | `client.call(removeMutedWords, words)`                              |\n| `agent.hidePost(uri)`                              | `client.call(hidePost, uri)`                                        |\n| `agent.unhidePost(uri)`                            | `client.call(unhidePost, uri)`                                      |\n| `agent.addLabeler(did)`                            | `client.call(addLabeler, did)`                                      |\n| `agent.removeLabeler(did)`                         | `client.call(removeLabeler, did)`                                   |\n| `agent.setVerificationPrefs(prefs)`                | `client.call(setVerificationPrefs, prefs)`                          |\n| `agent.setPostInteractionSettings(s)`              | `client.call(setPostInteractionSettings, s)`                        |\n| `agent.updateLiveEventPreferences(action)`         | `client.call(updateLiveEventPreferences, action)`                   |\n| `agent.setIsBetaUser(v)`                           | `client.call(setIsBetaUser, v)`                                     |\n\n### Renamed app-state preferences methods (dropped `bskyApp` prefix)\n\n| Old agent method                             | New action                                   |\n| -------------------------------------------- | -------------------------------------------- |\n| `agent.bskyAppQueueNudges(nudges)`           | `client.call(queueNudges, nudges)`           |\n| `agent.bskyAppDismissNudges(nudges)`         | `client.call(dismissNudges, nudges)`         |\n| `agent.bskyAppSetActiveProgressGuide(guide)` | `client.call(setActiveProgressGuide, guide)` |\n| `agent.bskyAppUpsertNux(nux)`                | `client.call(upsertNux, nux)`                |\n| `agent.bskyAppRemoveNuxs(ids)`               | `client.call(removeNuxs, ids)`               |\n\n### Renamed notification method\n\n| Old agent method                         | New                                                         |\n| ---------------------------------------- | ----------------------------------------------------------- |\n| `agent.countUnreadNotifications(params)` | `client.call(app.bsky.notification.getUnreadCount, params)` |\n\n### Bluesky API passthrough methods (17 total)\n\nThese agent shortcuts now map directly to `client.call(schema, params)` with the schema imported from `@bsky/sdk/lexicons`:\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// feeds\nawait client.call(app.bsky.feed.getTimeline, { limit: 50 })\nawait client.call(app.bsky.feed.getAuthorFeed, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.feed.getActorLikes, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.feed.getPostThread, { uri: 'at://...' })\nawait client.call(app.bsky.feed.getPosts, { uris: ['at://...'] })\nawait client.call(app.bsky.feed.getLikes, { uri: 'at://...' })\nawait client.call(app.bsky.feed.getRepostedBy, { uri: 'at://...' })\n\n// actors\nawait client.call(app.bsky.actor.getProfile, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.actor.getProfiles, { actors: ['alice.bsky.social'] })\nawait client.call(app.bsky.actor.getSuggestions, {})\nawait client.call(app.bsky.actor.searchActors, { q: 'alice' })\nawait client.call(app.bsky.actor.searchActorsTypeahead, { q: 'ali' })\n\n// graph\nawait client.call(app.bsky.graph.getFollows, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.graph.getFollowers, { actor: 'alice.bsky.social' })\n\n// notifications\nawait client.call(app.bsky.notification.listNotifications, {})\nawait client.call(app.bsky.notification.getUnreadCount, {})\n\n// labelers\nawait client.call(app.bsky.labeler.getServices, { dids: ['did:plc:...'] })\n```\n\n### `getPost` recipe\n\nThe old `agent.getPost({ repo, rkey })` sugar is replaced by `client.get()` using the generated schema:\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\nimport { AtUri } from '@atproto/syntax'\n\n// Old: agent.getPost({ repo: 'alice.bsky.social', rkey: '3k2...' })\n// New:\nconst post = await client.get(app.bsky.feed.post, {\n  repo: 'alice.bsky.social',\n  rkey: '3k2...',\n})\n// Or parse from a full at:// URI:\nconst uri = new AtUri('at://alice.bsky.social/app.bsky.feed.post/3k2...')\nconst post2 = await client.get(app.bsky.feed.post, {\n  repo: uri.hostname,\n  rkey: uri.rkey,\n})\n```\n\n---\n\n## 4. Sessions\n\n### Password-based sessions (replacing `CredentialSession`)\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport {\n  PasswordSession,\n  type SessionData,\n} from '@atproto/lex-password-session'\n\n// --- Login ---\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx',\n  onUpdated: (data: SessionData) => {\n    // Persist tokens after login and each automatic refresh\n    localStorage.setItem('session', JSON.stringify(data))\n  },\n  onDeleted: (data: SessionData) => {\n    // Called on logout or server-side session invalidation\n    localStorage.removeItem('session')\n    redirectToLogin()\n  },\n})\n\nconst client = new Client(session)\n\n// --- Resume a saved session ---\nconst saved: SessionData = JSON.parse(localStorage.getItem('session')!)\nconst resumed = await PasswordSession.resume(saved, {\n  onUpdated: (data) => localStorage.setItem('session', JSON.stringify(data)),\n  onDeleted: () => localStorage.removeItem('session'),\n})\nconst resumedClient = new Client(resumed)\n\n// --- Logout ---\nawait session.logout() // calls onDeleted, marks session as destroyed\n```\n\nKey differences from `CredentialSession`:\n\n- No constructor; use static `PasswordSession.login()` or `PasswordSession.resume()`.\n- `onUpdated` and `onDeleted` hooks replace manual `session.on('update', ...)` listeners.\n- Token refresh is fully automatic; no need to call `refreshSession()`.\n\n### OAuth\n\nFor user-facing apps, use OAuth via `@atproto/oauth-client`. The resulting session object can be passed directly to `new Client(oauthSession)`. See `@atproto/oauth-client` docs for setup.\n\n---\n\n## 5. String Format Types\n\nAT Protocol uses branded string types (type aliases with a narrow shape) for values that must meet format requirements — DIDs, AT-URIs, handles, datetimes, and more. These are now available from `@atproto/lex` (which re-exports them from `@atproto/syntax` and `@atproto/lex-schema`).\n\n### Available types and helpers\n\n```typescript\nimport {\n  // Types (branded strings)\n  type DidString,\n  type HandleString,\n  type AtUriString,\n  type DatetimeString,\n  type NsidString,\n  type RecordKeyString,\n  type AtIdentifierString, // DidString | HandleString\n  // Per-format as/is/assert/if helpers (via @atproto/syntax re-export)\n  asDatetimeString,\n  assertDatetimeString,\n  isDatetimeString,\n  ifDatetimeString,\n  asAtUriString,\n  assertAtUriString,\n  isAtUriString,\n  ifAtUriString,\n  asAtIdentifierString,\n  assertAtIdentifierString,\n  isAtIdentifierString,\n  ifAtIdentifierString,\n  // Generic string-format helpers\n  isStringFormat,\n  asStringFormat,\n  assertStringFormat,\n  ifStringFormat,\n} from '@atproto/lex'\n```\n\n### Reading vs constructing\n\n**Reading (ingress validation at trust boundaries):** When you receive data from the network or user input, validate and narrow the type explicitly:\n\n```typescript\nimport { asStringFormat, type DidString } from '@atproto/lex'\n\nfunction processIncoming(rawDid: string): DidString {\n  // Throws if not a valid DID; narrows to DidString\n  return asStringFormat(rawDid, 'did')\n}\n```\n\n**Constructing (from known-valid sources):** When you construct values from your own code or generated data, you can cast directly — generated lexicon builders (`schema.$build(...)`) already produce correctly-typed values, so no runtime validation is needed.\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// $build ensures the type is correct at compile time\nconst post = app.bsky.feed.post.$build({\n  text: 'Hello!',\n  createdAt: new Date().toISOString(),\n})\n// post.$type is typed as 'app.bsky.feed.post'\n```\n\n**Per-boundary idiom:** Validate at the first point you receive external data. After that, pass the typed values through your app without re-validating:\n\n```typescript\nimport { asStringFormat, asAtUriString } from '@atproto/lex'\n\n// API route handler — validate at the boundary\nfunction handleRepostRequest(rawActor: string, rawUri: string) {\n  const actorDid = asStringFormat(rawActor, 'did')\n  const postUri = asAtUriString(rawUri)\n  return doRepost(actorDid, postUri) // typed throughout\n}\n```\n\n**Note:** A planned helper on the lex `Client` for object-level \"upcast\" (e.g. asserting an entire record object matches a schema) is not yet in `@atproto/lex`. Use schema-level `$assert()` / `$matches()` / `$parse()` on generated schema objects until then:\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\n\nconst unknown: unknown = fetchedRecord\napp.bsky.feed.post.$assert(unknown) // throws if not a valid post\n// unknown is now typed as app.bsky.feed.post.Main\n```\n\n### Replacing old `isDid` / `asDid` etc.\n\nThe `types.ts` helpers that lived in `@atproto/api` (`isDid`, `asDid`, `assertDid`, `isAtprotoProxy`, etc.) are removed. Use the generic string-format helpers or the per-format exports from `@atproto/lex` / `@atproto/syntax`:\n\n```typescript\n// Old: isDid(value) / asDid(value)\n// New:\nimport { isStringFormat, asStringFormat } from '@atproto/lex'\nisStringFormat(value, 'did')\nasStringFormat(value, 'did')\n```\n\n---\n\n## 6. Worked End-to-End Example\n\nLogin, create three clients, post with rich text, read the timeline, and moderate a post for display:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport {\n  PasswordSession,\n  type SessionData,\n} from '@atproto/lex-password-session'\nimport { api, post } from '@bsky/sdk'\nimport { moderatePost, type ModerationOpts } from '@bsky/sdk/moderation'\nimport { RichText } from '@bsky/sdk/richtext'\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// --- 1. Login ---\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx',\n  onUpdated: (data: SessionData) => saveSession(data),\n  onDeleted: () => clearSession(),\n})\n\n// --- 2. Clients ---\n// No labeler configuration needed: with no labeler headers sent, the\n// Bluesky API applies its own moderation service by default.\nconst bskyClient = new Client(session, {\n  service: api.app.service,\n})\n\nconst publicBskyClient = new Client(api.app.urlPublic)\n\n// --- 3. Post with rich text ---\nconst rt = new RichText({ text: 'Hello @bob.bsky.social — check this out!' })\nawait rt.detectFacets(bskyClient) // resolves mentions via the client\n\n// Record writes target the account host automatically\nconst { uri, cid } = await bskyClient.call(post, {\n  text: rt.text,\n  facets: rt.facets,\n})\nconsole.log('Posted:', uri, cid)\n\n// --- 4. Read the timeline ---\nconst timeline = await bskyClient.call(app.bsky.feed.getTimeline, {\n  limit: 20,\n})\n\n// --- 5. Moderate a post for display ---\nconst prefs = await bskyClient.call(app.bsky.actor.getPreferences, {})\nconst moderationOpts: ModerationOpts = {\n  userDid: session.did,\n  prefs: {\n    adultContentEnabled: false,\n    labels: {},\n    labelers: [{ did: api.moderation.did, labels: {} }],\n    mutedWords: [],\n    hiddenPosts: [],\n  },\n}\n\nfor (const feedItem of timeline.feed) {\n  const modDecision = moderatePost(feedItem.post, moderationOpts)\n  const ui = modDecision.ui('contentList')\n  if (ui.filter) {\n    // skip filtered posts\n    continue\n  }\n  // record is typed as LexMap; cast to the generated type if needed\n  const record = feedItem.post.record as app.bsky.feed.post.Main\n  console.log(record.text)\n}\n```\n"},"files":{"packages/jetstream/README.md":"# @bsky/jetstream\n\nClient library for [Jetstream](https://github.com/bluesky-social/jetstream), a friendly way to consume data published to AT Protocol in realtime.\n\n## Quick start\n\nInstall the Jetstream client:\n\n```\nnpm install @bsky/jetstream @atproto/lex\n```\n\n`@atproto/lex` is a peer dependency providing the lexicon type system that events are typed with.\n\nThis 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:\n\n```ts\nimport { Jetstream, isCreate } from '@bsky/jetstream'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\n\nfor await (const evt of js.live({ collections: ['app.bsky.feed.post'] })) {\n  if (isCreate(evt)) {\n    console.log(evt.commit.collection, evt.commit.record)\n  }\n}\n```\n\n`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.\n\nUsing a lexicon as your collection filter will validate and type the events for you:\n\n```ts\nimport { Jetstream, isCreate } from '@bsky/jetstream'\nimport { app } from '@bsky/sdk/lexicons'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\n\nfor await (const evt of js.live({ collections: [app.bsky.feed.post] })) {\n  if (isCreate(evt)) {\n    console.log(evt.commit.collection, evt.commit.record.text)\n  }\n}\n```\n\n`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.\n\nTyped 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).\n\nA 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.\n\nFor 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:\n\n```ts\nimport { Jetstream, LexIndexer, MemoryCursorStore } from '@bsky/jetstream'\nimport { app } from '@bsky/sdk/lexicons'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\n\nconst indexer = new LexIndexer()\n  .commit(app.bsky.feed.like, {\n    put: async (e) => {\n      console.log('index', e.uri, e.record)\n    },\n    del: async (e) => {\n      console.log('remove', e.uri)\n    },\n  })\n  .identity(async (e) => {\n    console.log('identity change', e.did, e.handle)\n  })\n\n// Implement CursorStore to persist the resume cursor durably.\nconst cursor = new MemoryCursorStore()\n\nawait js.runner(indexer).live({ cursor })\n```\n\n### Cursors\n\n`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.\n\nCursors 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.\n\n### Legacy v1 instances\n\nThe 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.\n\n```ts\nimport { JetstreamV1 } from '@bsky/jetstream'\n\nconst js = new JetstreamV1('https://jetstream1.us-east.bsky.network')\nfor await (const evt of js.live({ collections: ['app.bsky.feed.post'] })) {\n  console.log(evt.seq, evt.kind)\n}\n```\n\n## Connection behavior\n\nThis package runs on Node.js and in the browser.\n\nA 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).\n\nTo observe or tune connection behavior, configure a transport with `websocketTransport()` and pass it via the `liveTransport` option:\n\n```ts\nimport { Jetstream, websocketTransport } from '@bsky/jetstream'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\nfor await (const ev of js.live({\n  collections: ['app.bsky.feed.post'],\n  liveTransport: websocketTransport({\n    onReconnect: (err, { attempt }) => console.warn('reconnecting', attempt),\n  }),\n})) {\n  // ...\n}\n```\n\nCommonly 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).\n","packages/sdk/README.md":"# @bsky/sdk\n\nBluesky 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.\n\n## Install\n\n```sh\nnpm install @bsky/sdk @atproto/lex\n```\n\n`@atproto/lex` provides the `Client` class that powers all API calls. `@bsky/sdk` provides the Bluesky-specific lexicons, actions, and utilities.\n\n## Getting started\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport { post } from '@bsky/sdk'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'your.bsky.social', // your handle\n  password: 'xxxx-xxxx-xxxx-xxxx', // an app password, generated in the Bluesky app\n})\nconst client = new Client(session)\n\nawait client.call(post, { text: 'Hello, world!' })\n```\n\n## Usage\n\n### Session management\n\nYou'll need an authenticated session for most API calls. There are two ways to\nmanage sessions:\n\n1. [App password based session management](#app-password-based-session-management)\n2. [OAuth based session management](#oauth-based-session-management)\n\n#### App password based session management\n\nUsername / password based authentication can be performed using the\n`PasswordSession` class from `@atproto/lex-password-session`.\n\n> [!CAUTION]\n>\n> This method is deprecated in favor of OAuth based session management. It is\n> recommended to use OAuth based session management (through the\n> `@atproto/oauth-client-*` packages).\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport {\n  PasswordSession,\n  type SessionData,\n} from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'your.bsky.social', // your handle\n  password: 'xxxx-xxxx-xxxx-xxxx', // an app password, generated in the Bluesky app\n  onUpdated: (data: SessionData) => saveToStorage(data),\n  onDeleted: (data: SessionData) => clearStorage(data.did),\n})\nconst client = new Client(session)\nconsole.log(`Authenticated as: ${client.assertDid}`)\n\n// Next time, resume with the persisted session data to avoid storing\n// user credentials:\nconst resumed = await PasswordSession.resume(loadFromStorage(), {\n  onUpdated: (data: SessionData) => saveToStorage(data),\n  onDeleted: (data: SessionData) => clearStorage(data.did),\n})\n```\n\n#### OAuth based session management\n\nDepending on the environment used by your application, different OAuth clients\nare available:\n\n- [@atproto/oauth-client-browser](https://www.npmjs.com/package/@atproto/oauth-client-browser):\n  for the browser.\n- [@atproto/oauth-client-node](https://www.npmjs.com/package/@atproto/oauth-client-node): for\n  Node.js.\n- [@atproto/oauth-client](https://www.npmjs.com/package/@atproto/oauth-client):\n  Lower level; compatible with most JS engines.\n\nEvery `@atproto/oauth-client-*` implementation has a different way to obtain an\nOAuth session instance that can be used to instantiate a `Client`. Here is an\nexample restoring a previously saved session:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { OAuthClient } from '@atproto/oauth-client'\n\nconst oauthClient = new OAuthClient({\n  // ...\n})\n\nconst oauthSession = await oauthClient.restore('did:plc:123')\n\n// Instantiate the lex Client using an OAuth session\nconst client = new Client(oauthSession)\n```\n\n### Authenticated and public clients\n\nUse the `api.*` constants for addressing Bluesky services:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { api } from '@bsky/sdk'\n\n// Bluesky API (authenticated queries, proxied through the account host;\n// record writes and mutations automatically target the account host directly)\nconst bskyClient = new Client(session, { service: api.app.service })\n\n// Bluesky API (unauthenticated public queries)\nconst publicBskyClient = new Client(api.app.urlPublic)\n```\n\n### API calls\n\nThe SDK exports actions for many common operations. Invoke an action against a\nclient with `client.call(action, input)`:\n\n```typescript\nimport {\n  blockActor,\n  blockActorList,\n  deleteFollow,\n  deleteLike,\n  deletePost,\n  deleteRepost,\n  follow,\n  getPreferences,\n  like,\n  muteActor,\n  muteActorList,\n  post,\n  repost,\n  unblockActor,\n  unblockActorList,\n  unmuteActor,\n  unmuteActorList,\n  updateSeenNotifications,\n  upsertProfile,\n} from '@bsky/sdk'\n\n// The DID of the user currently authenticated (or undefined)\nbskyClient.did\nbskyClient.assertDid // Throws if the user is not authenticated\n\n// Feeds and content\nawait bskyClient.call(post, { text: 'Hello, world!' })\nawait bskyClient.call(deletePost, postUri)\nawait bskyClient.call(like, { uri, cid })\nawait bskyClient.call(deleteLike, likeUri)\nawait bskyClient.call(repost, { uri, cid })\nawait bskyClient.call(deleteRepost, repostUri)\n\n// Social graph\nawait bskyClient.call(follow, { did })\nawait bskyClient.call(deleteFollow, followUri)\nawait bskyClient.call(muteActor, { actor })\nawait bskyClient.call(unmuteActor, { actor })\nawait bskyClient.call(blockActor, { did })\nawait bskyClient.call(unblockActor, blockUri)\nawait bskyClient.call(muteActorList, { list })\nawait bskyClient.call(unmuteActorList, { list })\nawait bskyClient.call(blockActorList, { list })\nawait bskyClient.call(unblockActorList, { list })\n\n// Actors\nawait bskyClient.call(upsertProfile, (existing) => ({\n  ...existing,\n  displayName: 'Alice',\n}))\n\n// Notifications\nawait bskyClient.call(updateSeenNotifications, seenAt)\n\n// Preferences\nconst prefs = await bskyClient.call(getPreferences)\n```\n\nQueries without a dedicated action are one `xrpc` call away using the generated\nlexicons — see [Advanced API calls](#advanced-api-calls).\n\n### Validation and types\n\nThe lexicons include a complete types system with validation and type-guards.\nFor example, to validate a post record:\n\n```typescript\nimport { app } from '@bsky/sdk/lexicons'\n\nconst post = { ... }\nif (app.bsky.feed.post.$isTypeOf(post)) {\n  // typescript now recognizes `post` may be an app.bsky.feed.post.Main\n  // however -- we still need to validate it\n  const res = app.bsky.feed.post.main.safeValidate(post)\n  if (res.success) {\n    // a valid record\n  } else {\n    // something is wrong\n    console.log(res.error)\n  }\n}\n```\n\n### Rich text\n\nSome 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.\n\nℹ️ 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.\n\n```typescript\nimport { currentDatetimeString } from '@atproto/lex'\nimport { RichText } from '@bsky/sdk/richtext'\n\n// creating richtext — detects mentions and links, resolving mentions to DIDs\nconst rt = await RichText.resolve(\n  'Hello @alice.com, check out this link: https://example.com',\n  { resolver: client },\n)\nconst postRecord = {\n  $type: 'app.bsky.feed.post',\n  text: rt.text,\n  facets: rt.facets,\n  createdAt: currentDatetimeString(),\n}\n\n// rendering as markdown\nlet markdown = ''\nfor (const segment of rt.segments()) {\n  if (segment.isLink()) {\n    markdown += `[${segment.text}](${segment.link?.uri})`\n  } else if (segment.isMention()) {\n    markdown += `[${segment.text}](https://my-bsky-app.com/user/${segment.mention?.did})`\n  } else {\n    markdown += segment.text\n  }\n}\n\n// calculating string lengths\nconst rt2 = new RichText({ text: 'Hello' })\nconsole.log(rt2.length) // => 5\nconsole.log(rt2.graphemeLength) // => 5\nconst rt3 = new RichText({ text: '👨‍👩‍👧‍👧' })\nconsole.log(rt3.length) // => 25\nconsole.log(rt3.graphemeLength) // => 1\n```\n\n### Moderation\n\nApplying 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:\n\n- Moderator labeling\n- User muting (including mutelists)\n- User blocking\n- Mutewords\n- Hidden posts\n\nFor more information, see the [Moderation Documentation](./docs/moderation.md).\n\n```typescript\nimport { getPreferences } from '@bsky/sdk'\nimport { moderatePost } from '@bsky/sdk/moderation'\n\n// First get the user's moderation prefs and their label definitions\n// =\n\nconst prefs = await bskyClient.call(getPreferences)\nconst labelDefs = {\n  /* see the Moderation Documentation for gathering labelDefs */\n}\n\n// We call the appropriate moderation function for the content\n// =\n\nconst postMod = moderatePost(postView, {\n  userDid: bskyClient.assertDid,\n  prefs: prefs.moderationPrefs,\n  labelDefs,\n})\n\n// We then use the output to decide how to affect rendering\n// =\n\n// in feeds\nif (postMod.ui('contentList').filter) {\n  // don't include in feeds\n}\nif (postMod.ui('contentList').blur) {\n  // render the whole object behind a cover (use postMod.ui('contentList').blurs to explain)\n  if (postMod.ui('contentList').noOverride) {\n    // do not allow the cover the be removed\n  }\n}\nif (postMod.ui('contentList').alert || postMod.ui('contentList').inform) {\n  // render warnings on the post\n  // find the warnings in postMod.ui('contentList').alerts and postMod.ui('contentList').informs\n}\n\n// viewed directly\nif (postMod.ui('contentView').filter) {\n  // don't render the view\n}\nif (postMod.ui('contentView').blur) {\n  // render the whole object behind a cover (use postMod.ui('contentView').blurs to explain)\n  if (postMod.ui('contentView').noOverride) {\n    // do not allow the cover the be removed\n  }\n}\nif (postMod.ui('contentView').alert || postMod.ui('contentView').inform) {\n  // render warnings on the post\n  // find the warnings in postMod.ui('contentView').alerts and postMod.ui('contentView').informs\n}\n\n// post embeds in all contexts\nif (postMod.ui('contentMedia').blur) {\n  // render the whole object behind a cover (use postMod.ui('contentMedia').blurs to explain)\n  if (postMod.ui('contentMedia').noOverride) {\n    // do not allow the cover the be removed\n  }\n}\n```\n\n## Advanced\n\n### Advanced API calls\n\nThe actions above are convenience wrappers. They cover most but not all\navailable methods.\n\nThe AT Protocol identifies methods and records with reverse-DNS names. You can\ncall any of them using the generated lexicons and the client's `call` and\nrecord helpers:\n\n```typescript\nimport { currentDatetimeString } from '@atproto/lex'\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// Record helpers always target the user's account host\nconst res1 = await bskyClient.createRecord({\n  $type: 'app.bsky.feed.post',\n  text: 'Hello, world!',\n  createdAt: currentDatetimeString(),\n})\nconst res2 = await bskyClient.call(\n  com.atproto.repo.listRecords,\n  { repo: alice.did, collection: 'app.bsky.feed.post' },\n  { service: null }, // target the account host rather than the Bluesky API\n)\n// or, using the typed record convenience helper:\nconst res2b = await bskyClient.list(app.bsky.feed.post, {\n  repo: alice.did, // optional. Defaults to bskyClient.did (throws if unauthenticated)\n})\n\nconst res3 = await bskyClient.call(app.bsky.feed.getTimeline, {\n  limit: 20,\n})\n// res3: app.bsky.feed.getTimeline.OutputSchema\n```\n\nNamespace roots exported from `/lexicons`: `app`, `com`, `chat`.\n\n### Bring your own fetch\n\nIf you want to provide your own `fetch` implementation — for logging, mocking,\nretries, or environments without a built-in `fetch` — pass it when constructing\nthe `Client`:\n\n```typescript\nimport { Client } from '@atproto/lex'\n\nconst myFetch: typeof globalThis.fetch = async (input, init) => {\n  console.log('requesting', input)\n  const response = await globalThis.fetch(input, init)\n  console.log('got response', response)\n  return response\n}\n\nconst client = new Client({\n  service: 'https://example.com',\n  fetch: myFetch,\n})\n```\n\n## Subpaths\n\n| Import                 | Contents                                                                             |\n| ---------------------- | ------------------------------------------------------------------------------------ |\n| `@bsky/sdk`            | `api` constants, actions, `DEFAULT_LABEL_SETTINGS`                                   |\n| `@bsky/sdk/lexicons`   | Generated lexicon definitions (`app`, `com`, `chat`)                                 |\n| `@bsky/sdk/moderation` | `moderatePost`, `moderateProfile`, and friends; `ModerationDecision`, `ModerationUI` |\n| `@bsky/sdk/richtext`   | `RichText` builder                                                                   |\n| `@bsky/sdk/utils`      | Age assurance helpers, `sanitizeMutedWordValue`, nux validation                      |\n\n## Upgrading from @atproto/api\n\nSee [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.\n\n## License\n\nThis project is dual-licensed under MIT and Apache 2.0 terms:\n\n- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)\n- 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)\n\nDownstream 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.\n","packages/sdk/docs/moderation.md":"# Moderation API\n\nApplying 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:\n\n- Moderator labeling\n- User muting (including mutelists)\n- User blocking\n- Mutewords\n- Hidden posts\n\n## Configuration\n\nEvery moderation function takes a set of options which look like this:\n\n```typescript\n{\n  // the logged-in user's DID\n  userDid: 'did:plc:1234...',\n\n  prefs: {\n    // is adult content allowed?\n    adultContentEnabled: true,\n\n    // the global label settings (used on self-labels)\n    labels: {\n      porn: 'hide',\n      sexual: 'warn',\n      nudity: 'ignore',\n      // ...\n    },\n\n    // the subscribed labelers and their label settings\n    labelers: [\n      {\n        did: 'did:plc:1234...',\n        labels: {\n          porn: 'hide',\n          sexual: 'warn',\n          nudity: 'ignore',\n          // ...\n        }\n      }\n    ],\n\n    mutedWords: [/* ... */],\n    hiddenPosts: [/* ... */]\n  },\n\n  // custom label definitions\n  labelDefs: {\n    // labelerDid => defs[]\n    'did:plc:1234...': [\n      /* ... */\n    ]\n  }\n}\n```\n\nThis should match the following interfaces:\n\n```typescript\nexport interface ModerationPrefsLabeler {\n  did: DidString\n  labels: Record<string, LabelPreference>\n}\n\nexport interface ModerationPrefs {\n  adultContentEnabled: boolean\n  labels: Record<string, LabelPreference>\n  labelers: ModerationPrefsLabeler[]\n  mutedWords: app.bsky.actor.defs.MutedWord[]\n  hiddenPosts: AtUriString[]\n}\n\nexport interface ModerationOpts {\n  userDid: DidString | undefined\n  prefs: ModerationPrefs\n  /**\n   * Map of labeler did -> custom definitions\n   */\n  labelDefs?: Record<string, InterpretedLabelValueDefinition[]>\n}\n```\n\nYou can quickly grab the `ModerationPrefs` using the `getPreferences` action:\n\n```typescript\nimport { getPreferences } from '@bsky/sdk'\nimport { moderatePost } from '@bsky/sdk/moderation'\n\nconst prefs = await client.call(getPreferences)\nmoderatePost(post, {\n  userDid: /*...*/,\n  prefs: prefs.moderationPrefs,\n  labelDefs: /*...*/\n})\n```\n\nTo gather the label definitions (`labelDefs`) see the _Labelers_ section below.\n\n## Labelers\n\nLabelers 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:\n\n```typescript\nimport { Client } from '@atproto/lex'\n\nconst client = new Client(session, {\n  appLabelers: ['did:web:my-labeler.com'],\n})\n```\n\n(`Client.configure({ appLabelers })` sets the same thing globally for all client instances; prefer the per-client option.)\n\nUsers 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.\n\nLabelers publish a `app.bsky.labeler.service` record that looks like this:\n\n```js\n{\n  $type: 'app.bsky.labeler.service',\n  policies: {\n    // the list of label values the labeler will publish\n    labelValues: [\n      'rude',\n    ],\n    // any custom definitions the labeler will be using\n    labelValueDefinitions: [\n      {\n        identifier: 'rude',\n        blurs: 'content',\n        severity: 'alert',\n        defaultSetting: 'warn',\n        adultOnly: false,\n        locales: [\n          {\n            lang: 'en',\n            name: 'Rude',\n            description: 'Not keeping things civil.',\n          },\n        ],\n      },\n    ],\n  },\n  createdAt: '2024-03-12T17:17:17.215Z'\n}\n```\n\nThe 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).\n\nHere is how to do this:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { getPreferences } from '@bsky/sdk'\nimport { app } from '@bsky/sdk/lexicons'\nimport {\n  type InterpretedLabelValueDefinition,\n  interpretLabelValueDefinitions,\n  moderatePost,\n} from '@bsky/sdk/moderation'\n\n// assume `client` is backed by a signed-in session\nconst prefs = await client.call(getPreferences)\n\nconst services = await client.call(app.bsky.labeler.getServices, {\n  dids: prefs.moderationPrefs.labelers.map((labeler) => labeler.did),\n  detailed: true,\n})\n\nconst labelDefs: Record<string, InterpretedLabelValueDefinition[]> = {}\nfor (const view of services.views) {\n  if (app.bsky.labeler.defs.labelerViewDetailed.isTypeOf(view)) {\n    labelDefs[view.creator.did] = interpretLabelValueDefinitions(view)\n  }\n}\n\nmoderatePost(post, {\n  userDid: client.assertDid,\n  prefs: prefs.moderationPrefs,\n  labelDefs,\n})\n```\n\n## The `moderate*()` APIs\n\nThe SDK exports methods to moderate the different kinds of content on the network.\n\n```typescript\nimport {\n  moderateProfile,\n  moderatePost,\n  moderateNotification,\n  moderateFeedGenerator,\n  moderateUserList,\n  moderateStatus,\n  moderateViewExternal,\n  moderateProfileLink,\n} from '@bsky/sdk/moderation'\n```\n\nEach of these follows the same API signature:\n\n```typescript\nconst res = moderatePost(post, moderationOptions)\n```\n\nThe response object provides an API for figuring out what your UI should do in different contexts.\n\n```typescript\nres.ui(context) /* =>\n\nModerationUI {\n  filter: boolean // should the content be removed from the interface?\n  blur: boolean // should the content be put behind a cover?\n  alert: boolean // should an alert be put on the content? (negative)\n  inform: boolean // should an informational notice be put on the content? (neutral)\n  noOverride: boolean // if blur=true, should the UI disable opening the cover?\n\n  // the reasons for each of the flags:\n  filters: ModerationCause[]\n  blurs: ModerationCause[]\n  alerts: ModerationCause[]\n  informs: ModerationCause[]\n}\n*/\n```\n\nThere are multiple UI contexts available:\n\n- `profileList` A profile being listed, eg in search or a follower list\n- `profileView` A profile being viewed directly\n- `avatar` The user's avatar in any context\n- `banner` The user's banner in any context\n- `displayName` The user's display name in any context\n- `contentList` Content being listed, eg posts in a feed, posts as replies, a user list list, a feed generator list, etc\n- `contentView` Content being viewed direct, eg an opened post, the user list page, the feedgen page, etc\n- `contentMedia ` Media inside the content, eg a picture embedded in a post\n\nHere's how a post in a feed would use these tools to make a decision:\n\n```typescript\nconst mod = moderatePost(post, moderationOptions)\n\nif (mod.ui('contentList').filter) {\n  // dont show the post\n}\nif (mod.ui('contentList').blur) {\n  // cover the post with the explanation from mod.ui('contentList').blurs[0]\n  if (mod.ui('contentList').noOverride) {\n    // dont allow the cover to be removed\n  }\n}\nif (mod.ui('contentMedia').blur) {\n  // cover the post's embedded images with the explanation from mod.ui('contentMedia').blurs[0]\n  if (mod.ui('contentMedia').noOverride) {\n    // dont allow the cover to be removed\n  }\n}\nif (mod.ui('avatar').blur) {\n  // cover the avatar with the explanation from mod.ui('avatar').blurs[0]\n  if (mod.ui('avatar').noOverride) {\n    // dont allow the cover to be removed\n  }\n}\nfor (const alert of mod.ui('contentList').alerts) {\n  // render this alert\n}\nfor (const inform of mod.ui('contentList').informs) {\n  // render this inform\n}\n```\n\n### External embeds\n\nLabels 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.\n\nAn external card acts as both the \"content\" and the \"media\" of its embed, so combine the `contentView` and `contentMedia` contexts with `mergeModUIResults`:\n\n```typescript\nimport { mergeModUIResults, moderateViewExternal } from '@bsky/sdk/moderation'\n\nconst embedMod = moderateViewExternal(embed.external, moderationOptions)\nconst ui = mergeModUIResults(\n  embedMod.ui('contentView'),\n  embedMod.ui('contentMedia'),\n)\n\nif (ui.blur) {\n  // cover the card with the explanation from ui.blurs[0]\n  if (ui.noOverride) {\n    // don't allow the cover to be removed\n  }\n}\n```\n\n`mergeModUIResults` accepts any number of `ModerationUI` values (undefined entries are skipped), combining their causes and preserving the strictest `noOverride`.\n\n### Profile links\n\nA 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:\n\n```typescript\nimport { moderateProfile, moderateProfileLink } from '@bsky/sdk/moderation'\n\nconst profileMod = moderateProfile(profile, moderationOptions)\n\nfor (const link of profile.links ?? []) {\n  const linkMod = moderateProfileLink(link, moderationOptions)\n\n  if (linkMod.ui('contentView').blur) {\n    // cover the link with the explanation from linkMod.ui('contentView').blurs[0]\n  }\n  if (linkMod.ui('contentMedia').blur) {\n    // cover the link's icon with the explanation from linkMod.ui('contentMedia').blurs[0]\n  }\n}\n```\n\nNotes:\n\n- 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.\n- 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.\n- `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.\n\n## Sending moderation reports\n\nAny 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:\n\n```typescript\nimport { com } from '@bsky/sdk/lexicons'\n\nawait client.call(\n  com.atproto.moderation.createReport,\n  {\n    reasonType: com.atproto.moderation.defs.reasonViolation.$token,\n    reason: 'They were being such a jerk to me!',\n    subject: com.atproto.admin.defs.repoRef.$build({\n      did: 'did:web:bob.com',\n    }),\n  },\n  { service: 'did:web:my-labeler.com#atproto_labeler' },\n)\n```\n\nThe Bluesky moderation service's address is available via the `api` export from the SDK:\n\n```typescript\nimport { api } from '@bsky/sdk'\n\nawait client.call(com.atproto.moderation.createReport, report, {\n  service: api.moderation.service, // did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler\n})\n```\n","skills/upgrade-from-atproto-api/SKILL.md":"---\nname: upgrade-from-atproto-api\ndescription: Migrate code from @atproto/api to @bsky/sdk + @atproto/lex — import mappings, agent-to-client patterns, string format types\n---\n\n# Migrating from `@atproto/api` to `@bsky/sdk` + `@atproto/lex`\n\nThe `@atproto/api` package has been superseded by two packages:\n\n- **`@bsky/sdk`** — Bluesky-specific actions, moderation helpers, rich text, and the `api` constants object.\n- **`@atproto/lex`** — the generic AT Protocol client, lexicon utilities, and schema types. A peer dependency of the sdk; install both. (`@atproto/lex-password-session` is separate, only needed for password-based sessions.)\n\n```\nnpm install @bsky/sdk @atproto/lex\n```\n\nImport `Client` and other lex utilities from `@atproto/lex` — not from its underlying sub-packages (`@atproto/lex-client`, `@atproto/lex-schema`), which shouldn't be depended upon directly.\n\n## 1. Client Setup\n\nThere are two common client patterns.\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport { api } from '@bsky/sdk'\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// --- Pattern 1: Authenticated Bluesky client ---\n// Queries are proxied through the account host to api.bsky.app and signed\n// with the user's identity. Record writes and mutations (createRecord,\n// applyWrites, etc.) automatically target the account host directly, so one\n// client covers both.\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx', // App password\n  onUpdated: (data) => saveToStorage(data),\n  onDeleted: (data) => clearStorage(data.did),\n})\nconst bskyClient = new Client(session, {\n  service: api.app.service, // 'did:web:api.bsky.app#bsky_appview'\n})\n\n// --- Pattern 2: Public Bluesky API client (unauthenticated read-only) ---\n// Use for reading public data without authentication.\nconst publicBskyClient = new Client(api.app.urlPublic) // 'https://public.api.bsky.app'\n```\n\nTo target a different service for a single request (e.g. the account host\nitself, or a labeler), pass `service` in the per-request options rather than\nconstructing another client — `{ service: null }` reaches the account host\ndirectly:\n\n```typescript\nconst records = await bskyClient.call(\n  com.atproto.repo.listRecords,\n  { repo: bskyClient.assertDid, collection: 'app.bsky.feed.post' },\n  { service: null },\n)\n```\n\nDeployments that already keep separate clients for the account host vs the\nBluesky API can continue to do so — construct each client from the same\nsession:\n\n```typescript\nconst accountClient = new Client(session)\nconst bskyClient = new Client(session, { service: api.app.service })\n```\n\n**`api` constants reference:**\n\n| Constant                 | Value                                                |\n| ------------------------ | ---------------------------------------------------- |\n| `api.app.did`            | `'did:web:api.bsky.app'`                             |\n| `api.app.service`        | `'did:web:api.bsky.app#bsky_appview'`                |\n| `api.app.url`            | `'https://api.bsky.app'`                             |\n| `api.app.urlPublic`      | `'https://public.api.bsky.app'`                      |\n| `api.chat.did`           | `'did:web:api.bsky.chat'`                            |\n| `api.chat.service`       | `'did:web:api.bsky.chat#bsky_chat'`                  |\n| `api.chat.url`           | `'https://api.bsky.chat'`                            |\n| `api.moderation.did`     | `'did:plc:ar7c4by46qjdydhdevvrndac'`                 |\n| `api.moderation.service` | `'did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler'` |\n\n**Labeler caveat:** When no labeler headers are sent, the Bluesky API applies its own moderation service by default — so if you don't introduce other labelers, no labeler configuration is needed at all. The `atproto-accept-labelers` header works by replacement, not addition: sending _any_ labelers header replaces that server-side default. So when you introduce other labelers, keep the Bluesky labeler active by setting it as an _app labeler_ — app labelers are always included in the header with the `;redact` parameter (allowing takedowns), which a plain `labelers` entry does not set:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { api } from '@bsky/sdk'\n\nconst client = new Client(session, {\n  service: api.app.service,\n  appLabelers: [api.moderation.did], // keep Bluesky moderation, with `;redact`\n  labelers: ['did:plc:mycustomlabeler...'], // the user's labeler subscriptions\n})\n```\n\n(`Client.configure({ appLabelers })` sets the same thing globally for all client instances; prefer the per-client option.) Defaults cascade app → client → request: a per-request `labelers` array replaces the client's, `labelers: null` disables the header for that request, and `appLabelers: null` opts a client or request out of the app labelers.\n\n---\n\n## 2. Import Mappings\n\n### Client / Session classes\n\n| Old (`@atproto/api`) | New               | Package                         |\n| -------------------- | ----------------- | ------------------------------- |\n| `AtpAgent`           | `Client`          | `@atproto/lex`                  |\n| `Agent`              | `Client`          | `@atproto/lex`                  |\n| `BskyAgent`          | `Client`          | `@atproto/lex`                  |\n| `AtpBaseClient`      | `Client`          | `@atproto/lex`                  |\n| `CredentialSession`  | `PasswordSession` | `@atproto/lex-password-session` |\n\n### Sessions\n\n| Old (`@atproto/api`)       | New           | Package                         | Notes                                                              |\n| -------------------------- | ------------- | ------------------------------- | ------------------------------------------------------------------ |\n| `AtpSessionData`           | `SessionData` | `@atproto/lex-password-session` | Shape for persisted session data; renamed for clarity              |\n| `AtpSessionEvent`          | removed       | —                               | Session event types now inferred from `PasswordSession` callbacks  |\n| `AtpPersistSessionHandler` | removed       | —                               | Use `onUpdated`/`onDeleted` callbacks on `PasswordSession` instead |\n| `AtpAgentLoginOpts`        | removed       | —                               | Merged into `PasswordSession.login()` parameters                   |\n| `AtpAgentGlobalOpts`       | removed       | —                               | Replaced by `Client` constructor options                           |\n\n### Preferences Types\n\n| Old (`@atproto/api`)       | New                        | Package     | Notes                                                      |\n| -------------------------- | -------------------------- | ----------- | ---------------------------------------------------------- |\n| `BskyPreferences`          | `BskyPreferences`          | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n| `BskyFeedViewPreference`   | `BskyFeedViewPreference`   | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n| `BskyThreadViewPreference` | `BskyThreadViewPreference` | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n| `BskyInterestsPreference`  | `BskyInterestsPreference`  | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n\n### Errors\n\n| Old (`@atproto/api`) | New                                 | Package        |\n| -------------------- | ----------------------------------- | -------------- |\n| `XRPCError`          | `XrpcResponseError` / `XrpcFailure` | `@atproto/lex` |\n\n### Utility / codec functions\n\n| Old (`@atproto/api`) | New                                                    | Package              |\n| -------------------- | ------------------------------------------------------ | -------------------- |\n| `jsonStringToLex`    | `lexParse`                                             | `@atproto/lex`       |\n| `jsonToLex`          | `jsonToLex`                                            | `@atproto/lex`       |\n| `lexToJson`          | `lexToJson`                                            | `@atproto/lex`       |\n| `stringifyLex`       | `lexStringify`                                         | `@atproto/lex`       |\n| `BlobRef`            | `BlobRef`                                              | `@atproto/lex`       |\n| `asPredicate`        | `schema.$matches` (e.g. `app.bsky.feed.post.$matches`) | `@bsky/sdk/lexicons` |\n| `parseLanguage`      | removed — use `isLanguageString`                       | `@atproto/lex`       |\n\n### Type utilities\n\n| Old (`@atproto/api`) | New            | Package                                    |\n| -------------------- | -------------- | ------------------------------------------ |\n| `$Typed<T>`          | `$Typed<V, T>` | `@atproto/lex-schema` (via `@atproto/lex`) |\n| `Un$Typed<T>`        | `Un$Typed<V>`  | `@atproto/lex-schema` (via `@atproto/lex`) |\n\n### Constants\n\n| Old (`@atproto/api`)     | New                      | Package     |\n| ------------------------ | ------------------------ | ----------- |\n| `BSKY_LABELER_DID`       | `api.moderation.did`     | `@bsky/sdk` |\n| `DEFAULT_LABEL_SETTINGS` | `DEFAULT_LABEL_SETTINGS` | `@bsky/sdk` |\n\n### Moderation\n\n| Old (`@atproto/api`)             | New                              | Package                |\n| -------------------------------- | -------------------------------- | ---------------------- |\n| `moderatePost`                   | `moderatePost`                   | `@bsky/sdk/moderation` |\n| `moderateProfile`                | `moderateProfile`                | `@bsky/sdk/moderation` |\n| `moderateUserList`               | `moderateUserList`               | `@bsky/sdk/moderation` |\n| `moderateFeedGenerator`          | `moderateFeedGenerator`          | `@bsky/sdk/moderation` |\n| `moderateNotification`           | `moderateNotification`           | `@bsky/sdk/moderation` |\n| `moderateStatus`                 | `moderateStatus`                 | `@bsky/sdk/moderation` |\n| `ModerationUI`                   | `ModerationUI`                   | `@bsky/sdk/moderation` |\n| `ModerationDecision`             | `ModerationDecision`             | `@bsky/sdk/moderation` |\n| `interpretLabelValueDefinition`  | `interpretLabelValueDefinition`  | `@bsky/sdk/moderation` |\n| `interpretLabelValueDefinitions` | `interpretLabelValueDefinitions` | `@bsky/sdk/moderation` |\n| `hasMutedWord`                   | `hasMutedWord`                   | `@bsky/sdk/moderation` |\n| `matchMuteWords`                 | `matchMuteWords`                 | `@bsky/sdk/moderation` |\n| `LABELS`                         | `LABELS`                         | `@bsky/sdk/moderation` |\n\n### Rich text\n\n| Old (`@atproto/api`)               | New                                    | Package              |\n| ---------------------------------- | -------------------------------------- | -------------------- |\n| `RichText`                         | `RichText`                             | `@bsky/sdk/richtext` |\n| `RichText.detectFacets(agent)`     | `rt.detectFacets(resolver)` — see note | `@bsky/sdk/richtext` |\n| `sanitizeRichText`                 | `sanitizeRichText`                     | `@bsky/sdk/richtext` |\n| `UnicodeString`                    | `UnicodeString`                        | `@bsky/sdk/richtext` |\n| `MENTION_REGEX`, `URL_REGEX`, etc. | same names                             | `@bsky/sdk/richtext` |\n\n**`detectFacets` resolver change:** The method now takes either a `Client` or a `HandleResolver` from `@atproto-labs/handle-resolver` instead of an agent. Passing a `Client` resolves handles via `com.atproto.identity.resolveHandle`:\n\n```typescript\n// Usage with a Client (resolves via com.atproto.identity.resolveHandle):\nconst rt = new RichText({ text: 'Hello @alice.bsky.social!' })\nawait rt.detectFacets(bskyClient)\n\n// Or bring your own HandleResolver:\nawait rt.detectFacets(myHandleResolver)\n\n// Or use the one-step static helper (construct + detect + resolve):\nconst rt2 = await RichText.resolve('Hello @alice.bsky.social!', {\n  resolver: bskyClient,\n})\n```\n\nThe `Client`-backed resolver is also exported directly for standalone use:\n\n```typescript\nimport { ClientHandleResolver } from '@bsky/sdk/utils'\n\nconst resolver = new ClientHandleResolver(bskyClient)\nconst did = await resolver.resolve('alice.bsky.social') // AtprotoDid | null\n```\n\n### Utils\n\n| Old (`@atproto/api`)     | New                                          | Package           |\n| ------------------------ | -------------------------------------------- | ----------------- |\n| `sanitizeMutedWordValue` | `sanitizeMutedWordValue`                     | `@bsky/sdk/utils` |\n| `validateNux`            | `validateNux`                                | `@bsky/sdk/utils` |\n| `nuxSchema`              | `nuxSchema`                                  | `@bsky/sdk/utils` |\n| `savedFeedsToUriArrays`  | removed — compute from `savedFeeds` directly | —                 |\n\n### Removed without direct replacement\n\n| Old (`@atproto/api`)                    | Notes                                                                           |\n| --------------------------------------- | ------------------------------------------------------------------------------- |\n| `mock` / `BskyAgent.mockResolveHandles` | removed; test with a fake `HandleResolver` function                             |\n| `isDid` / `asDid` / `assertDid`         | use `isStringFormat(v, 'did')` / `asStringFormat(v, 'did')` from `@atproto/lex` |\n| `AtUri`                                 | `AtUri` from `@atproto/syntax` (still a direct dependency)                      |\n| `lexicons` export (Lexicons instance)   | use generated schema objects from `@bsky/sdk/lexicons`                          |\n| `getSavedFeedType`                      | internal only; moved to preference actions                                      |\n| `validateSavedFeed`                     | internal only; moved to preference actions                                      |\n\n---\n\n## 3. Agent Methods → Actions / Client Calls\n\nThe `Agent`/`AtpAgent`/`BskyAgent` class has been replaced by:\n\n- **`Client`** from `@atproto/lex` for raw XRPC calls.\n- **Action functions** from `@bsky/sdk` for higher-level operations (preferences, records, graph, notifications).\n\n### Record / graph sugar methods\n\nThese agent methods now correspond to named action functions called via `client.call(action, input)`:\n\n| Old agent method                        | New action                                     | Import      |\n| --------------------------------------- | ---------------------------------------------- | ----------- |\n| `agent.post(input)`                     | `client.call(post, input)`                     | `@bsky/sdk` |\n| `agent.deletePost(uri)`                 | `client.call(deletePost, uri)`                 | `@bsky/sdk` |\n| `agent.like(uri, cid)`                  | `client.call(like, { uri, cid })`              | `@bsky/sdk` |\n| `agent.deleteLike(uri)`                 | `client.call(deleteLike, uri)`                 | `@bsky/sdk` |\n| `agent.repost(uri, cid)`                | `client.call(repost, { uri, cid })`            | `@bsky/sdk` |\n| `agent.deleteRepost(uri)`               | `client.call(deleteRepost, uri)`               | `@bsky/sdk` |\n| `agent.follow(did)`                     | `client.call(follow, { did })`                 | `@bsky/sdk` |\n| `agent.deleteFollow(uri)`               | `client.call(deleteFollow, uri)`               | `@bsky/sdk` |\n| `agent.upsertProfile(fn)`               | `client.call(upsertProfile, fn)`               | `@bsky/sdk` |\n| `agent.mute(actor, opts?)`              | `client.call(muteActor, { actor, ...opts })`   | `@bsky/sdk` |\n| `agent.unmute(actor)`                   | `client.call(unmuteActor, { actor })`          | `@bsky/sdk` |\n| `agent.muteModList(uri)`                | `client.call(muteActorList, { list: uri })`    | `@bsky/sdk` |\n| `agent.unmuteModList(uri)`              | `client.call(unmuteActorList, { list: uri })`  | `@bsky/sdk` |\n| `agent.blockModList(uri)`               | `client.call(blockActorList, { list: uri })`   | `@bsky/sdk` |\n| `agent.unblockModList(uri)`             | `client.call(unblockActorList, { list: uri })` | `@bsky/sdk` |\n| `agent.updateSeenNotifications(seenAt)` | `client.call(updateSeenNotifications, seenAt)` | `@bsky/sdk` |\n\n**Branded input types:** Unlike the old agent methods, action inputs use the lex string format types — URIs are `AtUriString`, DIDs are `DidString`, etc. Values read from API responses already carry these types; for plain strings from your own storage, validate at the boundary with `asStringFormat(v, 'at-uri')` / `asStringFormat(v, 'did')` (see § 5).\n\n### Preferences methods\n\nPreference reads and writes target the user's account host by default (like the client's own record helpers), regardless of the client's `service` setting — so they work unchanged on a client configured to proxy to the Bluesky API. `getPreferences` and `updatePreferences` accept an optional `service` in their input to override:\n\n```typescript\nawait client.call(getPreferences) // account host\nawait client.call(getPreferences, { service: 'did:web:example.com#svc' })\nawait client.call(updatePreferences, (prefs) => prefs) // account host\nawait client.call(updatePreferences, {\n  update: (prefs) => prefs,\n  service: 'did:web:example.com#svc',\n})\n```\n\n| Old agent method                                   | New action                                                          |\n| -------------------------------------------------- | ------------------------------------------------------------------- |\n| `agent.getPreferences()`                           | `client.call(getPreferences)`                                       |\n| `agent.setAdultContentEnabled(v)`                  | `client.call(setAdultContentEnabled, v)`                            |\n| `agent.setContentLabelPref(key, val, labelerDid?)` | `client.call(setContentLabelPref, { key, value: val, labelerDid })` |\n| `agent.addSavedFeeds(feeds)`                       | `client.call(addSavedFeeds, feeds)`                                 |\n| `agent.removeSavedFeeds(ids)`                      | `client.call(removeSavedFeeds, ids)`                                |\n| `agent.updateSavedFeeds(feeds)`                    | `client.call(updateSavedFeeds, feeds)`                              |\n| `agent.overwriteSavedFeeds(feeds)`                 | `client.call(overwriteSavedFeeds, feeds)`                           |\n| `agent.setFeedViewPrefs(feed, prefs)`              | `client.call(setFeedViewPrefs, { feed, ...prefs })`                 |\n| `agent.setThreadViewPrefs(prefs)`                  | `client.call(setThreadViewPrefs, prefs)`                            |\n| `agent.setPersonalDetails({ birthDate })`          | `client.call(setPersonalDetails, { birthDate })`                    |\n| `agent.setInterestsPref({ tags })`                 | `client.call(setInterestsPref, { tags })`                           |\n| `agent.addMutedWord(word)`                         | `client.call(addMutedWord, word)`                                   |\n| `agent.addMutedWords(words)`                       | `client.call(addMutedWords, words)`                                 |\n| `agent.upsertMutedWords(words)`                    | `client.call(upsertMutedWords, words)` (deprecated)                 |\n| `agent.updateMutedWord(word)`                      | `client.call(updateMutedWord, word)`                                |\n| `agent.removeMutedWord(word)`                      | `client.call(removeMutedWord, word)`                                |\n| `agent.removeMutedWords(words)`                    | `client.call(removeMutedWords, words)`                              |\n| `agent.hidePost(uri)`                              | `client.call(hidePost, uri)`                                        |\n| `agent.unhidePost(uri)`                            | `client.call(unhidePost, uri)`                                      |\n| `agent.addLabeler(did)`                            | `client.call(addLabeler, did)`                                      |\n| `agent.removeLabeler(did)`                         | `client.call(removeLabeler, did)`                                   |\n| `agent.setVerificationPrefs(prefs)`                | `client.call(setVerificationPrefs, prefs)`                          |\n| `agent.setPostInteractionSettings(s)`              | `client.call(setPostInteractionSettings, s)`                        |\n| `agent.updateLiveEventPreferences(action)`         | `client.call(updateLiveEventPreferences, action)`                   |\n| `agent.setIsBetaUser(v)`                           | `client.call(setIsBetaUser, v)`                                     |\n\n### Renamed app-state preferences methods (dropped `bskyApp` prefix)\n\n| Old agent method                             | New action                                   |\n| -------------------------------------------- | -------------------------------------------- |\n| `agent.bskyAppQueueNudges(nudges)`           | `client.call(queueNudges, nudges)`           |\n| `agent.bskyAppDismissNudges(nudges)`         | `client.call(dismissNudges, nudges)`         |\n| `agent.bskyAppSetActiveProgressGuide(guide)` | `client.call(setActiveProgressGuide, guide)` |\n| `agent.bskyAppUpsertNux(nux)`                | `client.call(upsertNux, nux)`                |\n| `agent.bskyAppRemoveNuxs(ids)`               | `client.call(removeNuxs, ids)`               |\n\n### Renamed notification method\n\n| Old agent method                         | New                                                         |\n| ---------------------------------------- | ----------------------------------------------------------- |\n| `agent.countUnreadNotifications(params)` | `client.call(app.bsky.notification.getUnreadCount, params)` |\n\n### Bluesky API passthrough methods (17 total)\n\nThese agent shortcuts now map directly to `client.call(schema, params)` with the schema imported from `@bsky/sdk/lexicons`:\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// feeds\nawait client.call(app.bsky.feed.getTimeline, { limit: 50 })\nawait client.call(app.bsky.feed.getAuthorFeed, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.feed.getActorLikes, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.feed.getPostThread, { uri: 'at://...' })\nawait client.call(app.bsky.feed.getPosts, { uris: ['at://...'] })\nawait client.call(app.bsky.feed.getLikes, { uri: 'at://...' })\nawait client.call(app.bsky.feed.getRepostedBy, { uri: 'at://...' })\n\n// actors\nawait client.call(app.bsky.actor.getProfile, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.actor.getProfiles, { actors: ['alice.bsky.social'] })\nawait client.call(app.bsky.actor.getSuggestions, {})\nawait client.call(app.bsky.actor.searchActors, { q: 'alice' })\nawait client.call(app.bsky.actor.searchActorsTypeahead, { q: 'ali' })\n\n// graph\nawait client.call(app.bsky.graph.getFollows, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.graph.getFollowers, { actor: 'alice.bsky.social' })\n\n// notifications\nawait client.call(app.bsky.notification.listNotifications, {})\nawait client.call(app.bsky.notification.getUnreadCount, {})\n\n// labelers\nawait client.call(app.bsky.labeler.getServices, { dids: ['did:plc:...'] })\n```\n\n### `getPost` recipe\n\nThe old `agent.getPost({ repo, rkey })` sugar is replaced by `client.get()` using the generated schema:\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\nimport { AtUri } from '@atproto/syntax'\n\n// Old: agent.getPost({ repo: 'alice.bsky.social', rkey: '3k2...' })\n// New:\nconst post = await client.get(app.bsky.feed.post, {\n  repo: 'alice.bsky.social',\n  rkey: '3k2...',\n})\n// Or parse from a full at:// URI:\nconst uri = new AtUri('at://alice.bsky.social/app.bsky.feed.post/3k2...')\nconst post2 = await client.get(app.bsky.feed.post, {\n  repo: uri.hostname,\n  rkey: uri.rkey,\n})\n```\n\n---\n\n## 4. Sessions\n\n### Password-based sessions (replacing `CredentialSession`)\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport {\n  PasswordSession,\n  type SessionData,\n} from '@atproto/lex-password-session'\n\n// --- Login ---\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx',\n  onUpdated: (data: SessionData) => {\n    // Persist tokens after login and each automatic refresh\n    localStorage.setItem('session', JSON.stringify(data))\n  },\n  onDeleted: (data: SessionData) => {\n    // Called on logout or server-side session invalidation\n    localStorage.removeItem('session')\n    redirectToLogin()\n  },\n})\n\nconst client = new Client(session)\n\n// --- Resume a saved session ---\nconst saved: SessionData = JSON.parse(localStorage.getItem('session')!)\nconst resumed = await PasswordSession.resume(saved, {\n  onUpdated: (data) => localStorage.setItem('session', JSON.stringify(data)),\n  onDeleted: () => localStorage.removeItem('session'),\n})\nconst resumedClient = new Client(resumed)\n\n// --- Logout ---\nawait session.logout() // calls onDeleted, marks session as destroyed\n```\n\nKey differences from `CredentialSession`:\n\n- No constructor; use static `PasswordSession.login()` or `PasswordSession.resume()`.\n- `onUpdated` and `onDeleted` hooks replace manual `session.on('update', ...)` listeners.\n- Token refresh is fully automatic; no need to call `refreshSession()`.\n\n### OAuth\n\nFor user-facing apps, use OAuth via `@atproto/oauth-client`. The resulting session object can be passed directly to `new Client(oauthSession)`. See `@atproto/oauth-client` docs for setup.\n\n---\n\n## 5. String Format Types\n\nAT Protocol uses branded string types (type aliases with a narrow shape) for values that must meet format requirements — DIDs, AT-URIs, handles, datetimes, and more. These are now available from `@atproto/lex` (which re-exports them from `@atproto/syntax` and `@atproto/lex-schema`).\n\n### Available types and helpers\n\n```typescript\nimport {\n  // Types (branded strings)\n  type DidString,\n  type HandleString,\n  type AtUriString,\n  type DatetimeString,\n  type NsidString,\n  type RecordKeyString,\n  type AtIdentifierString, // DidString | HandleString\n  // Per-format as/is/assert/if helpers (via @atproto/syntax re-export)\n  asDatetimeString,\n  assertDatetimeString,\n  isDatetimeString,\n  ifDatetimeString,\n  asAtUriString,\n  assertAtUriString,\n  isAtUriString,\n  ifAtUriString,\n  asAtIdentifierString,\n  assertAtIdentifierString,\n  isAtIdentifierString,\n  ifAtIdentifierString,\n  // Generic string-format helpers\n  isStringFormat,\n  asStringFormat,\n  assertStringFormat,\n  ifStringFormat,\n} from '@atproto/lex'\n```\n\n### Reading vs constructing\n\n**Reading (ingress validation at trust boundaries):** When you receive data from the network or user input, validate and narrow the type explicitly:\n\n```typescript\nimport { asStringFormat, type DidString } from '@atproto/lex'\n\nfunction processIncoming(rawDid: string): DidString {\n  // Throws if not a valid DID; narrows to DidString\n  return asStringFormat(rawDid, 'did')\n}\n```\n\n**Constructing (from known-valid sources):** When you construct values from your own code or generated data, you can cast directly — generated lexicon builders (`schema.$build(...)`) already produce correctly-typed values, so no runtime validation is needed.\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// $build ensures the type is correct at compile time\nconst post = app.bsky.feed.post.$build({\n  text: 'Hello!',\n  createdAt: new Date().toISOString(),\n})\n// post.$type is typed as 'app.bsky.feed.post'\n```\n\n**Per-boundary idiom:** Validate at the first point you receive external data. After that, pass the typed values through your app without re-validating:\n\n```typescript\nimport { asStringFormat, asAtUriString } from '@atproto/lex'\n\n// API route handler — validate at the boundary\nfunction handleRepostRequest(rawActor: string, rawUri: string) {\n  const actorDid = asStringFormat(rawActor, 'did')\n  const postUri = asAtUriString(rawUri)\n  return doRepost(actorDid, postUri) // typed throughout\n}\n```\n\n**Note:** A planned helper on the lex `Client` for object-level \"upcast\" (e.g. asserting an entire record object matches a schema) is not yet in `@atproto/lex`. Use schema-level `$assert()` / `$matches()` / `$parse()` on generated schema objects until then:\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\n\nconst unknown: unknown = fetchedRecord\napp.bsky.feed.post.$assert(unknown) // throws if not a valid post\n// unknown is now typed as app.bsky.feed.post.Main\n```\n\n### Replacing old `isDid` / `asDid` etc.\n\nThe `types.ts` helpers that lived in `@atproto/api` (`isDid`, `asDid`, `assertDid`, `isAtprotoProxy`, etc.) are removed. Use the generic string-format helpers or the per-format exports from `@atproto/lex` / `@atproto/syntax`:\n\n```typescript\n// Old: isDid(value) / asDid(value)\n// New:\nimport { isStringFormat, asStringFormat } from '@atproto/lex'\nisStringFormat(value, 'did')\nasStringFormat(value, 'did')\n```\n\n---\n\n## 6. Worked End-to-End Example\n\nLogin, create three clients, post with rich text, read the timeline, and moderate a post for display:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport {\n  PasswordSession,\n  type SessionData,\n} from '@atproto/lex-password-session'\nimport { api, post } from '@bsky/sdk'\nimport { moderatePost, type ModerationOpts } from '@bsky/sdk/moderation'\nimport { RichText } from '@bsky/sdk/richtext'\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// --- 1. Login ---\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx',\n  onUpdated: (data: SessionData) => saveSession(data),\n  onDeleted: () => clearSession(),\n})\n\n// --- 2. Clients ---\n// No labeler configuration needed: with no labeler headers sent, the\n// Bluesky API applies its own moderation service by default.\nconst bskyClient = new Client(session, {\n  service: api.app.service,\n})\n\nconst publicBskyClient = new Client(api.app.urlPublic)\n\n// --- 3. Post with rich text ---\nconst rt = new RichText({ text: 'Hello @bob.bsky.social — check this out!' })\nawait rt.detectFacets(bskyClient) // resolves mentions via the client\n\n// Record writes target the account host automatically\nconst { uri, cid } = await bskyClient.call(post, {\n  text: rt.text,\n  facets: rt.facets,\n})\nconsole.log('Posted:', uri, cid)\n\n// --- 4. Read the timeline ---\nconst timeline = await bskyClient.call(app.bsky.feed.getTimeline, {\n  limit: 20,\n})\n\n// --- 5. Moderate a post for display ---\nconst prefs = await bskyClient.call(app.bsky.actor.getPreferences, {})\nconst moderationOpts: ModerationOpts = {\n  userDid: session.did,\n  prefs: {\n    adultContentEnabled: false,\n    labels: {},\n    labelers: [{ did: api.moderation.did, labels: {} }],\n    mutedWords: [],\n    hiddenPosts: [],\n  },\n}\n\nfor (const feedItem of timeline.feed) {\n  const modDecision = moderatePost(feedItem.post, moderationOpts)\n  const ui = modDecision.ui('contentList')\n  if (ui.filter) {\n    // skip filtered posts\n    continue\n  }\n  // record is typed as LexMap; cast to the generated type if needed\n  const record = feedItem.post.record as app.bsky.feed.post.Main\n  console.log(record.text)\n}\n```\n"},"items":[{"name":"moderation.md","path":"packages/sdk/docs/moderation.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/bsky/HEAD/packages/sdk/docs/moderation.md","title":"Docs Skill","category":"anthropic-skill","format":"markdown","content":"# Moderation API\n\nApplying 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:\n\n- Moderator labeling\n- User muting (including mutelists)\n- User blocking\n- Mutewords\n- Hidden posts\n\n## Configuration\n\nEvery moderation function takes a set of options which look like this:\n\n```typescript\n{\n  // the logged-in user's DID\n  userDid: 'did:plc:1234...',\n\n  prefs: {\n    // is adult content allowed?\n    adultContentEnabled: true,\n\n    // the global label settings (used on self-labels)\n    labels: {\n      porn: 'hide',\n      sexual: 'warn',\n      nudity: 'ignore',\n      // ...\n    },\n\n    // the subscribed labelers and their label settings\n    labelers: [\n      {\n        did: 'did:plc:1234...',\n        labels: {\n          porn: 'hide',\n          sexual: 'warn',\n          nudity: 'ignore',\n          // ...\n        }\n      }\n    ],\n\n    mutedWords: [/* ... */],\n    hiddenPosts: [/* ... */]\n  },\n\n  // custom label definitions\n  labelDefs: {\n    // labelerDid => defs[]\n    'did:plc:1234...': [\n      /* ... */\n    ]\n  }\n}\n```\n\nThis should match the following interfaces:\n\n```typescript\nexport interface ModerationPrefsLabeler {\n  did: DidString\n  labels: Record<string, LabelPreference>\n}\n\nexport interface ModerationPrefs {\n  adultContentEnabled: boolean\n  labels: Record<string, LabelPreference>\n  labelers: ModerationPrefsLabeler[]\n  mutedWords: app.bsky.actor.defs.MutedWord[]\n  hiddenPosts: AtUriString[]\n}\n\nexport interface ModerationOpts {\n  userDid: DidString | undefined\n  prefs: ModerationPrefs\n  /**\n   * Map of labeler did -> custom definitions\n   */\n  labelDefs?: Record<string, InterpretedLabelValueDefinition[]>\n}\n```\n\nYou can quickly grab the `ModerationPrefs` using the `getPreferences` action:\n\n```typescript\nimport { getPreferences } from '@bsky/sdk'\nimport { moderatePost } from '@bsky/sdk/moderation'\n\nconst prefs = await client.call(getPreferences)\nmoderatePost(post, {\n  userDid: /*...*/,\n  prefs: prefs.moderationPrefs,\n  labelDefs: /*...*/\n})\n```\n\nTo gather the label definitions (`labelDefs`) see the _Labelers_ section below.\n\n## Labelers\n\nLabelers 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:\n\n```typescript\nimport { Client } from '@atproto/lex'\n\nconst client = new Client(session, {\n  appLabelers: ['did:web:my-labeler.com'],\n})\n```\n\n(`Client.configure({ appLabelers })` sets the same thing globally for all client instances; prefer the per-client option.)\n\nUsers 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.\n\nLabelers publish a `app.bsky.labeler.service` record that looks like this:\n\n```js\n{\n  $type: 'app.bsky.labeler.service',\n  policies: {\n    // the list of label values the labeler will publish\n    labelValues: [\n      'rude',\n    ],\n    // any custom definitions the labeler will be using\n    labelValueDefinitions: [\n      {\n        identifier: 'rude',\n        blurs: 'content',\n        severity: 'alert',\n        defaultSetting: 'warn',\n        adultOnly: false,\n        locales: [\n          {\n            lang: 'en',\n            name: 'Rude',\n            description: 'Not keeping things civil.',\n          },\n        ],\n      },\n    ],\n  },\n  createdAt: '2024-03-12T17:17:17.215Z'\n}\n```\n\nThe 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).\n\nHere is how to do this:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { getPreferences } from '@bsky/sdk'\nimport { app } from '@bsky/sdk/lexicons'\nimport {\n  type InterpretedLabelValueDefinition,\n  interpretLabelValueDefinitions,\n  moderatePost,\n} from '@bsky/sdk/moderation'\n\n// assume `client` is backed by a signed-in session\nconst prefs = await client.call(getPreferences)\n\nconst services = await client.call(app.bsky.labeler.getServices, {\n  dids: prefs.moderationPrefs.labelers.map((labeler) => labeler.did),\n  detailed: true,\n})\n\nconst labelDefs: Record<string, InterpretedLabelValueDefinition[]> = {}\nfor (const view of services.views) {\n  if (app.bsky.labeler.defs.labelerViewDetailed.isTypeOf(view)) {\n    labelDefs[view.creator.did] = interpretLabelValueDefinitions(view)\n  }\n}\n\nmoderatePost(post, {\n  userDid: client.assertDid,\n  prefs: prefs.moderationPrefs,\n  labelDefs,\n})\n```\n\n## The `moderate*()` APIs\n\nThe SDK exports methods to moderate the different kinds of content on the network.\n\n```typescript\nimport {\n  moderateProfile,\n  moderatePost,\n  moderateNotification,\n  moderateFeedGenerator,\n  moderateUserList,\n  moderateStatus,\n  moderateViewExternal,\n  moderateProfileLink,\n} from '@bsky/sdk/moderation'\n```\n\nEach of these follows the same API signature:\n\n```typescript\nconst res = moderatePost(post, moderationOptions)\n```\n\nThe response object provides an API for figuring out what your UI should do in different contexts.\n\n```typescript\nres.ui(context) /* =>\n\nModerationUI {\n  filter: boolean // should the content be removed from the interface?\n  blur: boolean // should the content be put behind a cover?\n  alert: boolean // should an alert be put on the content? (negative)\n  inform: boolean // should an informational notice be put on the content? (neutral)\n  noOverride: boolean // if blur=true, should the UI disable opening the cover?\n\n  // the reasons for each of the flags:\n  filters: ModerationCause[]\n  blurs: ModerationCause[]\n  alerts: ModerationCause[]\n  informs: ModerationCause[]\n}\n*/\n```\n\nThere are multiple UI contexts available:\n\n- `profileList` A profile being listed, eg in search or a follower list\n- `profileView` A profile being viewed directly\n- `avatar` The user's avatar in any context\n- `banner` The user's banner in any context\n- `displayName` The user's display name in any context\n- `contentList` Content being listed, eg posts in a feed, posts as replies, a user list list, a feed generator list, etc\n- `contentView` Content being viewed direct, eg an opened post, the user list page, the feedgen page, etc\n- `contentMedia ` Media inside the content, eg a picture embedded in a post\n\nHere's how a post in a feed would use these tools to make a decision:\n\n```typescript\nconst mod = moderatePost(post, moderationOptions)\n\nif (mod.ui('contentList').filter) {\n  // dont show the post\n}\nif (mod.ui('contentList').blur) {\n  // cover the post with the explanation from mod.ui('contentList').blurs[0]\n  if (mod.ui('contentList').noOverride) {\n    // dont allow the cover to be removed\n  }\n}\nif (mod.ui('contentMedia').blur) {\n  // cover the post's embedded images with the explanation from mod.ui('contentMedia').blurs[0]\n  if (mod.ui('contentMedia').noOverride) {\n    // dont allow the cover to be removed\n  }\n}\nif (mod.ui('avatar').blur) {\n  // cover the avatar with the explanation from mod.ui('avatar').blurs[0]\n  if (mod.ui('avatar').noOverride) {\n    // dont allow the cover to be removed\n  }\n}\nfor (const alert of mod.ui('contentList').alerts) {\n  // render this alert\n}\nfor (const inform of mod.ui('contentList').informs) {\n  // render this inform\n}\n```\n\n### External embeds\n\nLabels 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.\n\nAn external card acts as both the \"content\" and the \"media\" of its embed, so combine the `contentView` and `contentMedia` contexts with `mergeModUIResults`:\n\n```typescript\nimport { mergeModUIResults, moderateViewExternal } from '@bsky/sdk/moderation'\n\nconst embedMod = moderateViewExternal(embed.external, moderationOptions)\nconst ui = mergeModUIResults(\n  embedMod.ui('contentView'),\n  embedMod.ui('contentMedia'),\n)\n\nif (ui.blur) {\n  // cover the card with the explanation from ui.blurs[0]\n  if (ui.noOverride) {\n    // don't allow the cover to be removed\n  }\n}\n```\n\n`mergeModUIResults` accepts any number of `ModerationUI` values (undefined entries are skipped), combining their causes and preserving the strictest `noOverride`.\n\n### Profile links\n\nA 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:\n\n```typescript\nimport { moderateProfile, moderateProfileLink } from '@bsky/sdk/moderation'\n\nconst profileMod = moderateProfile(profile, moderationOptions)\n\nfor (const link of profile.links ?? []) {\n  const linkMod = moderateProfileLink(link, moderationOptions)\n\n  if (linkMod.ui('contentView').blur) {\n    // cover the link with the explanation from linkMod.ui('contentView').blurs[0]\n  }\n  if (linkMod.ui('contentMedia').blur) {\n    // cover the link's icon with the explanation from linkMod.ui('contentMedia').blurs[0]\n  }\n}\n```\n\nNotes:\n\n- 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.\n- 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.\n- `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.\n\n## Sending moderation reports\n\nAny 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:\n\n```typescript\nimport { com } from '@bsky/sdk/lexicons'\n\nawait client.call(\n  com.atproto.moderation.createReport,\n  {\n    reasonType: com.atproto.moderation.defs.reasonViolation.$token,\n    reason: 'They were being such a jerk to me!',\n    subject: com.atproto.admin.defs.repoRef.$build({\n      did: 'did:web:bob.com',\n    }),\n  },\n  { service: 'did:web:my-labeler.com#atproto_labeler' },\n)\n```\n\nThe Bluesky moderation service's address is available via the `api` export from the SDK:\n\n```typescript\nimport { api } from '@bsky/sdk'\n\nawait client.call(com.atproto.moderation.createReport, report, {\n  service: api.moderation.service, // did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler\n})\n```\n","isInternal":false,"tokens":2836,"sizeBytes":11578},{"name":"SKILL.md","path":"skills/upgrade-from-atproto-api/SKILL.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/bsky/HEAD/skills/upgrade-from-atproto-api/SKILL.md","title":"upgrade-from-atproto-api","category":"anthropic-skill","format":"markdown","content":"---\nname: upgrade-from-atproto-api\ndescription: Migrate code from @atproto/api to @bsky/sdk + @atproto/lex — import mappings, agent-to-client patterns, string format types\n---\n\n# Migrating from `@atproto/api` to `@bsky/sdk` + `@atproto/lex`\n\nThe `@atproto/api` package has been superseded by two packages:\n\n- **`@bsky/sdk`** — Bluesky-specific actions, moderation helpers, rich text, and the `api` constants object.\n- **`@atproto/lex`** — the generic AT Protocol client, lexicon utilities, and schema types. A peer dependency of the sdk; install both. (`@atproto/lex-password-session` is separate, only needed for password-based sessions.)\n\n```\nnpm install @bsky/sdk @atproto/lex\n```\n\nImport `Client` and other lex utilities from `@atproto/lex` — not from its underlying sub-packages (`@atproto/lex-client`, `@atproto/lex-schema`), which shouldn't be depended upon directly.\n\n## 1. Client Setup\n\nThere are two common client patterns.\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport { api } from '@bsky/sdk'\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// --- Pattern 1: Authenticated Bluesky client ---\n// Queries are proxied through the account host to api.bsky.app and signed\n// with the user's identity. Record writes and mutations (createRecord,\n// applyWrites, etc.) automatically target the account host directly, so one\n// client covers both.\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx', // App password\n  onUpdated: (data) => saveToStorage(data),\n  onDeleted: (data) => clearStorage(data.did),\n})\nconst bskyClient = new Client(session, {\n  service: api.app.service, // 'did:web:api.bsky.app#bsky_appview'\n})\n\n// --- Pattern 2: Public Bluesky API client (unauthenticated read-only) ---\n// Use for reading public data without authentication.\nconst publicBskyClient = new Client(api.app.urlPublic) // 'https://public.api.bsky.app'\n```\n\nTo target a different service for a single request (e.g. the account host\nitself, or a labeler), pass `service` in the per-request options rather than\nconstructing another client — `{ service: null }` reaches the account host\ndirectly:\n\n```typescript\nconst records = await bskyClient.call(\n  com.atproto.repo.listRecords,\n  { repo: bskyClient.assertDid, collection: 'app.bsky.feed.post' },\n  { service: null },\n)\n```\n\nDeployments that already keep separate clients for the account host vs the\nBluesky API can continue to do so — construct each client from the same\nsession:\n\n```typescript\nconst accountClient = new Client(session)\nconst bskyClient = new Client(session, { service: api.app.service })\n```\n\n**`api` constants reference:**\n\n| Constant                 | Value                                                |\n| ------------------------ | ---------------------------------------------------- |\n| `api.app.did`            | `'did:web:api.bsky.app'`                             |\n| `api.app.service`        | `'did:web:api.bsky.app#bsky_appview'`                |\n| `api.app.url`            | `'https://api.bsky.app'`                             |\n| `api.app.urlPublic`      | `'https://public.api.bsky.app'`                      |\n| `api.chat.did`           | `'did:web:api.bsky.chat'`                            |\n| `api.chat.service`       | `'did:web:api.bsky.chat#bsky_chat'`                  |\n| `api.chat.url`           | `'https://api.bsky.chat'`                            |\n| `api.moderation.did`     | `'did:plc:ar7c4by46qjdydhdevvrndac'`                 |\n| `api.moderation.service` | `'did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler'` |\n\n**Labeler caveat:** When no labeler headers are sent, the Bluesky API applies its own moderation service by default — so if you don't introduce other labelers, no labeler configuration is needed at all. The `atproto-accept-labelers` header works by replacement, not addition: sending _any_ labelers header replaces that server-side default. So when you introduce other labelers, keep the Bluesky labeler active by setting it as an _app labeler_ — app labelers are always included in the header with the `;redact` parameter (allowing takedowns), which a plain `labelers` entry does not set:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { api } from '@bsky/sdk'\n\nconst client = new Client(session, {\n  service: api.app.service,\n  appLabelers: [api.moderation.did], // keep Bluesky moderation, with `;redact`\n  labelers: ['did:plc:mycustomlabeler...'], // the user's labeler subscriptions\n})\n```\n\n(`Client.configure({ appLabelers })` sets the same thing globally for all client instances; prefer the per-client option.) Defaults cascade app → client → request: a per-request `labelers` array replaces the client's, `labelers: null` disables the header for that request, and `appLabelers: null` opts a client or request out of the app labelers.\n\n---\n\n## 2. Import Mappings\n\n### Client / Session classes\n\n| Old (`@atproto/api`) | New               | Package                         |\n| -------------------- | ----------------- | ------------------------------- |\n| `AtpAgent`           | `Client`          | `@atproto/lex`                  |\n| `Agent`              | `Client`          | `@atproto/lex`                  |\n| `BskyAgent`          | `Client`          | `@atproto/lex`                  |\n| `AtpBaseClient`      | `Client`          | `@atproto/lex`                  |\n| `CredentialSession`  | `PasswordSession` | `@atproto/lex-password-session` |\n\n### Sessions\n\n| Old (`@atproto/api`)       | New           | Package                         | Notes                                                              |\n| -------------------------- | ------------- | ------------------------------- | ------------------------------------------------------------------ |\n| `AtpSessionData`           | `SessionData` | `@atproto/lex-password-session` | Shape for persisted session data; renamed for clarity              |\n| `AtpSessionEvent`          | removed       | —                               | Session event types now inferred from `PasswordSession` callbacks  |\n| `AtpPersistSessionHandler` | removed       | —                               | Use `onUpdated`/`onDeleted` callbacks on `PasswordSession` instead |\n| `AtpAgentLoginOpts`        | removed       | —                               | Merged into `PasswordSession.login()` parameters                   |\n| `AtpAgentGlobalOpts`       | removed       | —                               | Replaced by `Client` constructor options                           |\n\n### Preferences Types\n\n| Old (`@atproto/api`)       | New                        | Package     | Notes                                                      |\n| -------------------------- | -------------------------- | ----------- | ---------------------------------------------------------- |\n| `BskyPreferences`          | `BskyPreferences`          | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n| `BskyFeedViewPreference`   | `BskyFeedViewPreference`   | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n| `BskyThreadViewPreference` | `BskyThreadViewPreference` | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n| `BskyInterestsPreference`  | `BskyInterestsPreference`  | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n\n### Errors\n\n| Old (`@atproto/api`) | New                                 | Package        |\n| -------------------- | ----------------------------------- | -------------- |\n| `XRPCError`          | `XrpcResponseError` / `XrpcFailure` | `@atproto/lex` |\n\n### Utility / codec functions\n\n| Old (`@atproto/api`) | New                                                    | Package              |\n| -------------------- | ------------------------------------------------------ | -------------------- |\n| `jsonStringToLex`    | `lexParse`                                             | `@atproto/lex`       |\n| `jsonToLex`          | `jsonToLex`                                            | `@atproto/lex`       |\n| `lexToJson`          | `lexToJson`                                            | `@atproto/lex`       |\n| `stringifyLex`       | `lexStringify`                                         | `@atproto/lex`       |\n| `BlobRef`            | `BlobRef`                                              | `@atproto/lex`       |\n| `asPredicate`        | `schema.$matches` (e.g. `app.bsky.feed.post.$matches`) | `@bsky/sdk/lexicons` |\n| `parseLanguage`      | removed — use `isLanguageString`                       | `@atproto/lex`       |\n\n### Type utilities\n\n| Old (`@atproto/api`) | New            | Package                                    |\n| -------------------- | -------------- | ------------------------------------------ |\n| `$Typed<T>`          | `$Typed<V, T>` | `@atproto/lex-schema` (via `@atproto/lex`) |\n| `Un$Typed<T>`        | `Un$Typed<V>`  | `@atproto/lex-schema` (via `@atproto/lex`) |\n\n### Constants\n\n| Old (`@atproto/api`)     | New                      | Package     |\n| ------------------------ | ------------------------ | ----------- |\n| `BSKY_LABELER_DID`       | `api.moderation.did`     | `@bsky/sdk` |\n| `DEFAULT_LABEL_SETTINGS` | `DEFAULT_LABEL_SETTINGS` | `@bsky/sdk` |\n\n### Moderation\n\n| Old (`@atproto/api`)             | New                              | Package                |\n| -------------------------------- | -------------------------------- | ---------------------- |\n| `moderatePost`                   | `moderatePost`                   | `@bsky/sdk/moderation` |\n| `moderateProfile`                | `moderateProfile`                | `@bsky/sdk/moderation` |\n| `moderateUserList`               | `moderateUserList`               | `@bsky/sdk/moderation` |\n| `moderateFeedGenerator`          | `moderateFeedGenerator`          | `@bsky/sdk/moderation` |\n| `moderateNotification`           | `moderateNotification`           | `@bsky/sdk/moderation` |\n| `moderateStatus`                 | `moderateStatus`                 | `@bsky/sdk/moderation` |\n| `ModerationUI`                   | `ModerationUI`                   | `@bsky/sdk/moderation` |\n| `ModerationDecision`             | `ModerationDecision`             | `@bsky/sdk/moderation` |\n| `interpretLabelValueDefinition`  | `interpretLabelValueDefinition`  | `@bsky/sdk/moderation` |\n| `interpretLabelValueDefinitions` | `interpretLabelValueDefinitions` | `@bsky/sdk/moderation` |\n| `hasMutedWord`                   | `hasMutedWord`                   | `@bsky/sdk/moderation` |\n| `matchMuteWords`                 | `matchMuteWords`                 | `@bsky/sdk/moderation` |\n| `LABELS`                         | `LABELS`                         | `@bsky/sdk/moderation` |\n\n### Rich text\n\n| Old (`@atproto/api`)               | New                                    | Package              |\n| ---------------------------------- | -------------------------------------- | -------------------- |\n| `RichText`                         | `RichText`                             | `@bsky/sdk/richtext` |\n| `RichText.detectFacets(agent)`     | `rt.detectFacets(resolver)` — see note | `@bsky/sdk/richtext` |\n| `sanitizeRichText`                 | `sanitizeRichText`                     | `@bsky/sdk/richtext` |\n| `UnicodeString`                    | `UnicodeString`                        | `@bsky/sdk/richtext` |\n| `MENTION_REGEX`, `URL_REGEX`, etc. | same names                             | `@bsky/sdk/richtext` |\n\n**`detectFacets` resolver change:** The method now takes either a `Client` or a `HandleResolver` from `@atproto-labs/handle-resolver` instead of an agent. Passing a `Client` resolves handles via `com.atproto.identity.resolveHandle`:\n\n```typescript\n// Usage with a Client (resolves via com.atproto.identity.resolveHandle):\nconst rt = new RichText({ text: 'Hello @alice.bsky.social!' })\nawait rt.detectFacets(bskyClient)\n\n// Or bring your own HandleResolver:\nawait rt.detectFacets(myHandleResolver)\n\n// Or use the one-step static helper (construct + detect + resolve):\nconst rt2 = await RichText.resolve('Hello @alice.bsky.social!', {\n  resolver: bskyClient,\n})\n```\n\nThe `Client`-backed resolver is also exported directly for standalone use:\n\n```typescript\nimport { ClientHandleResolver } from '@bsky/sdk/utils'\n\nconst resolver = new ClientHandleResolver(bskyClient)\nconst did = await resolver.resolve('alice.bsky.social') // AtprotoDid | null\n```\n\n### Utils\n\n| Old (`@atproto/api`)     | New                                          | Package           |\n| ------------------------ | -------------------------------------------- | ----------------- |\n| `sanitizeMutedWordValue` | `sanitizeMutedWordValue`                     | `@bsky/sdk/utils` |\n| `validateNux`            | `validateNux`                                | `@bsky/sdk/utils` |\n| `nuxSchema`              | `nuxSchema`                                  | `@bsky/sdk/utils` |\n| `savedFeedsToUriArrays`  | removed — compute from `savedFeeds` directly | —                 |\n\n### Removed without direct replacement\n\n| Old (`@atproto/api`)                    | Notes                                                                           |\n| --------------------------------------- | ------------------------------------------------------------------------------- |\n| `mock` / `BskyAgent.mockResolveHandles` | removed; test with a fake `HandleResolver` function                             |\n| `isDid` / `asDid` / `assertDid`         | use `isStringFormat(v, 'did')` / `asStringFormat(v, 'did')` from `@atproto/lex` |\n| `AtUri`                                 | `AtUri` from `@atproto/syntax` (still a direct dependency)                      |\n| `lexicons` export (Lexicons instance)   | use generated schema objects from `@bsky/sdk/lexicons`                          |\n| `getSavedFeedType`                      | internal only; moved to preference actions                                      |\n| `validateSavedFeed`                     | internal only; moved to preference actions                                      |\n\n---\n\n## 3. Agent Methods → Actions / Client Calls\n\nThe `Agent`/`AtpAgent`/`BskyAgent` class has been replaced by:\n\n- **`Client`** from `@atproto/lex` for raw XRPC calls.\n- **Action functions** from `@bsky/sdk` for higher-level operations (preferences, records, graph, notifications).\n\n### Record / graph sugar methods\n\nThese agent methods now correspond to named action functions called via `client.call(action, input)`:\n\n| Old agent method                        | New action                                     | Import      |\n| --------------------------------------- | ---------------------------------------------- | ----------- |\n| `agent.post(input)`                     | `client.call(post, input)`                     | `@bsky/sdk` |\n| `agent.deletePost(uri)`                 | `client.call(deletePost, uri)`                 | `@bsky/sdk` |\n| `agent.like(uri, cid)`                  | `client.call(like, { uri, cid })`              | `@bsky/sdk` |\n| `agent.deleteLike(uri)`                 | `client.call(deleteLike, uri)`                 | `@bsky/sdk` |\n| `agent.repost(uri, cid)`                | `client.call(repost, { uri, cid })`            | `@bsky/sdk` |\n| `agent.deleteRepost(uri)`               | `client.call(deleteRepost, uri)`               | `@bsky/sdk` |\n| `agent.follow(did)`                     | `client.call(follow, { did })`                 | `@bsky/sdk` |\n| `agent.deleteFollow(uri)`               | `client.call(deleteFollow, uri)`               | `@bsky/sdk` |\n| `agent.upsertProfile(fn)`               | `client.call(upsertProfile, fn)`               | `@bsky/sdk` |\n| `agent.mute(actor, opts?)`              | `client.call(muteActor, { actor, ...opts })`   | `@bsky/sdk` |\n| `agent.unmute(actor)`                   | `client.call(unmuteActor, { actor })`          | `@bsky/sdk` |\n| `agent.muteModList(uri)`                | `client.call(muteActorList, { list: uri })`    | `@bsky/sdk` |\n| `agent.unmuteModList(uri)`              | `client.call(unmuteActorList, { list: uri })`  | `@bsky/sdk` |\n| `agent.blockModList(uri)`               | `client.call(blockActorList, { list: uri })`   | `@bsky/sdk` |\n| `agent.unblockModList(uri)`             | `client.call(unblockActorList, { list: uri })` | `@bsky/sdk` |\n| `agent.updateSeenNotifications(seenAt)` | `client.call(updateSeenNotifications, seenAt)` | `@bsky/sdk` |\n\n**Branded input types:** Unlike the old agent methods, action inputs use the lex string format types — URIs are `AtUriString`, DIDs are `DidString`, etc. Values read from API responses already carry these types; for plain strings from your own storage, validate at the boundary with `asStringFormat(v, 'at-uri')` / `asStringFormat(v, 'did')` (see § 5).\n\n### Preferences methods\n\nPreference reads and writes target the user's account host by default (like the client's own record helpers), regardless of the client's `service` setting — so they work unchanged on a client configured to proxy to the Bluesky API. `getPreferences` and `updatePreferences` accept an optional `service` in their input to override:\n\n```typescript\nawait client.call(getPreferences) // account host\nawait client.call(getPreferences, { service: 'did:web:example.com#svc' })\nawait client.call(updatePreferences, (prefs) => prefs) // account host\nawait client.call(updatePreferences, {\n  update: (prefs) => prefs,\n  service: 'did:web:example.com#svc',\n})\n```\n\n| Old agent method                                   | New action                                                          |\n| -------------------------------------------------- | ------------------------------------------------------------------- |\n| `agent.getPreferences()`                           | `client.call(getPreferences)`                                       |\n| `agent.setAdultContentEnabled(v)`                  | `client.call(setAdultContentEnabled, v)`                            |\n| `agent.setContentLabelPref(key, val, labelerDid?)` | `client.call(setContentLabelPref, { key, value: val, labelerDid })` |\n| `agent.addSavedFeeds(feeds)`                       | `client.call(addSavedFeeds, feeds)`                                 |\n| `agent.removeSavedFeeds(ids)`                      | `client.call(removeSavedFeeds, ids)`                                |\n| `agent.updateSavedFeeds(feeds)`                    | `client.call(updateSavedFeeds, feeds)`                              |\n| `agent.overwriteSavedFeeds(feeds)`                 | `client.call(overwriteSavedFeeds, feeds)`                           |\n| `agent.setFeedViewPrefs(feed, prefs)`              | `client.call(setFeedViewPrefs, { feed, ...prefs })`                 |\n| `agent.setThreadViewPrefs(prefs)`                  | `client.call(setThreadViewPrefs, prefs)`                            |\n| `agent.setPersonalDetails({ birthDate })`          | `client.call(setPersonalDetails, { birthDate })`                    |\n| `agent.setInterestsPref({ tags })`                 | `client.call(setInterestsPref, { tags })`                           |\n| `agent.addMutedWord(word)`                         | `client.call(addMutedWord, word)`                                   |\n| `agent.addMutedWords(words)`                       | `client.call(addMutedWords, words)`                                 |\n| `agent.upsertMutedWords(words)`                    | `client.call(upsertMutedWords, words)` (deprecated)                 |\n| `agent.updateMutedWord(word)`                      | `client.call(updateMutedWord, word)`                                |\n| `agent.removeMutedWord(word)`                      | `client.call(removeMutedWord, word)`                                |\n| `agent.removeMutedWords(words)`                    | `client.call(removeMutedWords, words)`                              |\n| `agent.hidePost(uri)`                              | `client.call(hidePost, uri)`                                        |\n| `agent.unhidePost(uri)`                            | `client.call(unhidePost, uri)`                                      |\n| `agent.addLabeler(did)`                            | `client.call(addLabeler, did)`                                      |\n| `agent.removeLabeler(did)`                         | `client.call(removeLabeler, did)`                                   |\n| `agent.setVerificationPrefs(prefs)`                | `client.call(setVerificationPrefs, prefs)`                          |\n| `agent.setPostInteractionSettings(s)`              | `client.call(setPostInteractionSettings, s)`                        |\n| `agent.updateLiveEventPreferences(action)`         | `client.call(updateLiveEventPreferences, action)`                   |\n| `agent.setIsBetaUser(v)`                           | `client.call(setIsBetaUser, v)`                                     |\n\n### Renamed app-state preferences methods (dropped `bskyApp` prefix)\n\n| Old agent method                             | New action                                   |\n| -------------------------------------------- | -------------------------------------------- |\n| `agent.bskyAppQueueNudges(nudges)`           | `client.call(queueNudges, nudges)`           |\n| `agent.bskyAppDismissNudges(nudges)`         | `client.call(dismissNudges, nudges)`         |\n| `agent.bskyAppSetActiveProgressGuide(guide)` | `client.call(setActiveProgressGuide, guide)` |\n| `agent.bskyAppUpsertNux(nux)`                | `client.call(upsertNux, nux)`                |\n| `agent.bskyAppRemoveNuxs(ids)`               | `client.call(removeNuxs, ids)`               |\n\n### Renamed notification method\n\n| Old agent method                         | New                                                         |\n| ---------------------------------------- | ----------------------------------------------------------- |\n| `agent.countUnreadNotifications(params)` | `client.call(app.bsky.notification.getUnreadCount, params)` |\n\n### Bluesky API passthrough methods (17 total)\n\nThese agent shortcuts now map directly to `client.call(schema, params)` with the schema imported from `@bsky/sdk/lexicons`:\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// feeds\nawait client.call(app.bsky.feed.getTimeline, { limit: 50 })\nawait client.call(app.bsky.feed.getAuthorFeed, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.feed.getActorLikes, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.feed.getPostThread, { uri: 'at://...' })\nawait client.call(app.bsky.feed.getPosts, { uris: ['at://...'] })\nawait client.call(app.bsky.feed.getLikes, { uri: 'at://...' })\nawait client.call(app.bsky.feed.getRepostedBy, { uri: 'at://...' })\n\n// actors\nawait client.call(app.bsky.actor.getProfile, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.actor.getProfiles, { actors: ['alice.bsky.social'] })\nawait client.call(app.bsky.actor.getSuggestions, {})\nawait client.call(app.bsky.actor.searchActors, { q: 'alice' })\nawait client.call(app.bsky.actor.searchActorsTypeahead, { q: 'ali' })\n\n// graph\nawait client.call(app.bsky.graph.getFollows, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.graph.getFollowers, { actor: 'alice.bsky.social' })\n\n// notifications\nawait client.call(app.bsky.notification.listNotifications, {})\nawait client.call(app.bsky.notification.getUnreadCount, {})\n\n// labelers\nawait client.call(app.bsky.labeler.getServices, { dids: ['did:plc:...'] })\n```\n\n### `getPost` recipe\n\nThe old `agent.getPost({ repo, rkey })` sugar is replaced by `client.get()` using the generated schema:\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\nimport { AtUri } from '@atproto/syntax'\n\n// Old: agent.getPost({ repo: 'alice.bsky.social', rkey: '3k2...' })\n// New:\nconst post = await client.get(app.bsky.feed.post, {\n  repo: 'alice.bsky.social',\n  rkey: '3k2...',\n})\n// Or parse from a full at:// URI:\nconst uri = new AtUri('at://alice.bsky.social/app.bsky.feed.post/3k2...')\nconst post2 = await client.get(app.bsky.feed.post, {\n  repo: uri.hostname,\n  rkey: uri.rkey,\n})\n```\n\n---\n\n## 4. Sessions\n\n### Password-based sessions (replacing `CredentialSession`)\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport {\n  PasswordSession,\n  type SessionData,\n} from '@atproto/lex-password-session'\n\n// --- Login ---\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx',\n  onUpdated: (data: SessionData) => {\n    // Persist tokens after login and each automatic refresh\n    localStorage.setItem('session', JSON.stringify(data))\n  },\n  onDeleted: (data: SessionData) => {\n    // Called on logout or server-side session invalidation\n    localStorage.removeItem('session')\n    redirectToLogin()\n  },\n})\n\nconst client = new Client(session)\n\n// --- Resume a saved session ---\nconst saved: SessionData = JSON.parse(localStorage.getItem('session')!)\nconst resumed = await PasswordSession.resume(saved, {\n  onUpdated: (data) => localStorage.setItem('session', JSON.stringify(data)),\n  onDeleted: () => localStorage.removeItem('session'),\n})\nconst resumedClient = new Client(resumed)\n\n// --- Logout ---\nawait session.logout() // calls onDeleted, marks session as destroyed\n```\n\nKey differences from `CredentialSession`:\n\n- No constructor; use static `PasswordSession.login()` or `PasswordSession.resume()`.\n- `onUpdated` and `onDeleted` hooks replace manual `session.on('update', ...)` listeners.\n- Token refresh is fully automatic; no need to call `refreshSession()`.\n\n### OAuth\n\nFor user-facing apps, use OAuth via `@atproto/oauth-client`. The resulting session object can be passed directly to `new Client(oauthSession)`. See `@atproto/oauth-client` docs for setup.\n\n---\n\n## 5. String Format Types\n\nAT Protocol uses branded string types (type aliases with a narrow shape) for values that must meet format requirements — DIDs, AT-URIs, handles, datetimes, and more. These are now available from `@atproto/lex` (which re-exports them from `@atproto/syntax` and `@atproto/lex-schema`).\n\n### Available types and helpers\n\n```typescript\nimport {\n  // Types (branded strings)\n  type DidString,\n  type HandleString,\n  type AtUriString,\n  type DatetimeString,\n  type NsidString,\n  type RecordKeyString,\n  type AtIdentifierString, // DidString | HandleString\n  // Per-format as/is/assert/if helpers (via @atproto/syntax re-export)\n  asDatetimeString,\n  assertDatetimeString,\n  isDatetimeString,\n  ifDatetimeString,\n  asAtUriString,\n  assertAtUriString,\n  isAtUriString,\n  ifAtUriString,\n  asAtIdentifierString,\n  assertAtIdentifierString,\n  isAtIdentifierString,\n  ifAtIdentifierString,\n  // Generic string-format helpers\n  isStringFormat,\n  asStringFormat,\n  assertStringFormat,\n  ifStringFormat,\n} from '@atproto/lex'\n```\n\n### Reading vs constructing\n\n**Reading (ingress validation at trust boundaries):** When you receive data from the network or user input, validate and narrow the type explicitly:\n\n```typescript\nimport { asStringFormat, type DidString } from '@atproto/lex'\n\nfunction processIncoming(rawDid: string): DidString {\n  // Throws if not a valid DID; narrows to DidString\n  return asStringFormat(rawDid, 'did')\n}\n```\n\n**Constructing (from known-valid sources):** When you construct values from your own code or generated data, you can cast directly — generated lexicon builders (`schema.$build(...)`) already produce correctly-typed values, so no runtime validation is needed.\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// $build ensures the type is correct at compile time\nconst post = app.bsky.feed.post.$build({\n  text: 'Hello!',\n  createdAt: new Date().toISOString(),\n})\n// post.$type is typed as 'app.bsky.feed.post'\n```\n\n**Per-boundary idiom:** Validate at the first point you receive external data. After that, pass the typed values through your app without re-validating:\n\n```typescript\nimport { asStringFormat, asAtUriString } from '@atproto/lex'\n\n// API route handler — validate at the boundary\nfunction handleRepostRequest(rawActor: string, rawUri: string) {\n  const actorDid = asStringFormat(rawActor, 'did')\n  const postUri = asAtUriString(rawUri)\n  return doRepost(actorDid, postUri) // typed throughout\n}\n```\n\n**Note:** A planned helper on the lex `Client` for object-level \"upcast\" (e.g. asserting an entire record object matches a schema) is not yet in `@atproto/lex`. Use schema-level `$assert()` / `$matches()` / `$parse()` on generated schema objects until then:\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\n\nconst unknown: unknown = fetchedRecord\napp.bsky.feed.post.$assert(unknown) // throws if not a valid post\n// unknown is now typed as app.bsky.feed.post.Main\n```\n\n### Replacing old `isDid` / `asDid` etc.\n\nThe `types.ts` helpers that lived in `@atproto/api` (`isDid`, `asDid`, `assertDid`, `isAtprotoProxy`, etc.) are removed. Use the generic string-format helpers or the per-format exports from `@atproto/lex` / `@atproto/syntax`:\n\n```typescript\n// Old: isDid(value) / asDid(value)\n// New:\nimport { isStringFormat, asStringFormat } from '@atproto/lex'\nisStringFormat(value, 'did')\nasStringFormat(value, 'did')\n```\n\n---\n\n## 6. Worked End-to-End Example\n\nLogin, create three clients, post with rich text, read the timeline, and moderate a post for display:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport {\n  PasswordSession,\n  type SessionData,\n} from '@atproto/lex-password-session'\nimport { api, post } from '@bsky/sdk'\nimport { moderatePost, type ModerationOpts } from '@bsky/sdk/moderation'\nimport { RichText } from '@bsky/sdk/richtext'\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// --- 1. Login ---\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx',\n  onUpdated: (data: SessionData) => saveSession(data),\n  onDeleted: () => clearSession(),\n})\n\n// --- 2. Clients ---\n// No labeler configuration needed: with no labeler headers sent, the\n// Bluesky API applies its own moderation service by default.\nconst bskyClient = new Client(session, {\n  service: api.app.service,\n})\n\nconst publicBskyClient = new Client(api.app.urlPublic)\n\n// --- 3. Post with rich text ---\nconst rt = new RichText({ text: 'Hello @bob.bsky.social — check this out!' })\nawait rt.detectFacets(bskyClient) // resolves mentions via the client\n\n// Record writes target the account host automatically\nconst { uri, cid } = await bskyClient.call(post, {\n  text: rt.text,\n  facets: rt.facets,\n})\nconsole.log('Posted:', uri, cid)\n\n// --- 4. Read the timeline ---\nconst timeline = await bskyClient.call(app.bsky.feed.getTimeline, {\n  limit: 20,\n})\n\n// --- 5. Moderate a post for display ---\nconst prefs = await bskyClient.call(app.bsky.actor.getPreferences, {})\nconst moderationOpts: ModerationOpts = {\n  userDid: session.did,\n  prefs: {\n    adultContentEnabled: false,\n    labels: {},\n    labelers: [{ did: api.moderation.did, labels: {} }],\n    mutedWords: [],\n    hiddenPosts: [],\n  },\n}\n\nfor (const feedItem of timeline.feed) {\n  const modDecision = moderatePost(feedItem.post, moderationOpts)\n  const ui = modDecision.ui('contentList')\n  if (ui.filter) {\n    // skip filtered posts\n    continue\n  }\n  // record is typed as LexMap; cast to the generated type if needed\n  const record = feedItem.post.record as app.bsky.feed.post.Main\n  console.log(record.text)\n}\n```\n","frontmatter":{"name":"upgrade-from-atproto-api","description":"Migrate code from @atproto/api to @bsky/sdk + @atproto/lex — import mappings, agent-to-client patterns, string format types"},"isInternal":false,"tokens":7319,"sizeBytes":31475},{"name":"README.md","path":"packages/jetstream/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/bsky/HEAD/packages/jetstream/README.md","title":"jetstream Documentation","category":"plugin-manifest","format":"markdown","content":"# @bsky/jetstream\n\nClient library for [Jetstream](https://github.com/bluesky-social/jetstream), a friendly way to consume data published to AT Protocol in realtime.\n\n## Quick start\n\nInstall the Jetstream client:\n\n```\nnpm install @bsky/jetstream @atproto/lex\n```\n\n`@atproto/lex` is a peer dependency providing the lexicon type system that events are typed with.\n\nThis 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:\n\n```ts\nimport { Jetstream, isCreate } from '@bsky/jetstream'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\n\nfor await (const evt of js.live({ collections: ['app.bsky.feed.post'] })) {\n  if (isCreate(evt)) {\n    console.log(evt.commit.collection, evt.commit.record)\n  }\n}\n```\n\n`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.\n\nUsing a lexicon as your collection filter will validate and type the events for you:\n\n```ts\nimport { Jetstream, isCreate } from '@bsky/jetstream'\nimport { app } from '@bsky/sdk/lexicons'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\n\nfor await (const evt of js.live({ collections: [app.bsky.feed.post] })) {\n  if (isCreate(evt)) {\n    console.log(evt.commit.collection, evt.commit.record.text)\n  }\n}\n```\n\n`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.\n\nTyped 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).\n\nA 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.\n\nFor 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:\n\n```ts\nimport { Jetstream, LexIndexer, MemoryCursorStore } from '@bsky/jetstream'\nimport { app } from '@bsky/sdk/lexicons'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\n\nconst indexer = new LexIndexer()\n  .commit(app.bsky.feed.like, {\n    put: async (e) => {\n      console.log('index', e.uri, e.record)\n    },\n    del: async (e) => {\n      console.log('remove', e.uri)\n    },\n  })\n  .identity(async (e) => {\n    console.log('identity change', e.did, e.handle)\n  })\n\n// Implement CursorStore to persist the resume cursor durably.\nconst cursor = new MemoryCursorStore()\n\nawait js.runner(indexer).live({ cursor })\n```\n\n### Cursors\n\n`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.\n\nCursors 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.\n\n### Legacy v1 instances\n\nThe 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.\n\n```ts\nimport { JetstreamV1 } from '@bsky/jetstream'\n\nconst js = new JetstreamV1('https://jetstream1.us-east.bsky.network')\nfor await (const evt of js.live({ collections: ['app.bsky.feed.post'] })) {\n  console.log(evt.seq, evt.kind)\n}\n```\n\n## Connection behavior\n\nThis package runs on Node.js and in the browser.\n\nA 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).\n\nTo observe or tune connection behavior, configure a transport with `websocketTransport()` and pass it via the `liveTransport` option:\n\n```ts\nimport { Jetstream, websocketTransport } from '@bsky/jetstream'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\nfor await (const ev of js.live({\n  collections: ['app.bsky.feed.post'],\n  liveTransport: websocketTransport({\n    onReconnect: (err, { attempt }) => console.warn('reconnecting', attempt),\n  }),\n})) {\n  // ...\n}\n```\n\nCommonly 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).\n","isInternal":false,"tokens":1528,"sizeBytes":6249},{"name":"README.md","path":"packages/sdk/README.md","rawUrl":"https://raw.githubusercontent.com/bluesky-social/bsky/HEAD/packages/sdk/README.md","title":"sdk Documentation","category":"plugin-manifest","format":"markdown","content":"# @bsky/sdk\n\nBluesky 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.\n\n## Install\n\n```sh\nnpm install @bsky/sdk @atproto/lex\n```\n\n`@atproto/lex` provides the `Client` class that powers all API calls. `@bsky/sdk` provides the Bluesky-specific lexicons, actions, and utilities.\n\n## Getting started\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport { post } from '@bsky/sdk'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'your.bsky.social', // your handle\n  password: 'xxxx-xxxx-xxxx-xxxx', // an app password, generated in the Bluesky app\n})\nconst client = new Client(session)\n\nawait client.call(post, { text: 'Hello, world!' })\n```\n\n## Usage\n\n### Session management\n\nYou'll need an authenticated session for most API calls. There are two ways to\nmanage sessions:\n\n1. [App password based session management](#app-password-based-session-management)\n2. [OAuth based session management](#oauth-based-session-management)\n\n#### App password based session management\n\nUsername / password based authentication can be performed using the\n`PasswordSession` class from `@atproto/lex-password-session`.\n\n> [!CAUTION]\n>\n> This method is deprecated in favor of OAuth based session management. It is\n> recommended to use OAuth based session management (through the\n> `@atproto/oauth-client-*` packages).\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport {\n  PasswordSession,\n  type SessionData,\n} from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'your.bsky.social', // your handle\n  password: 'xxxx-xxxx-xxxx-xxxx', // an app password, generated in the Bluesky app\n  onUpdated: (data: SessionData) => saveToStorage(data),\n  onDeleted: (data: SessionData) => clearStorage(data.did),\n})\nconst client = new Client(session)\nconsole.log(`Authenticated as: ${client.assertDid}`)\n\n// Next time, resume with the persisted session data to avoid storing\n// user credentials:\nconst resumed = await PasswordSession.resume(loadFromStorage(), {\n  onUpdated: (data: SessionData) => saveToStorage(data),\n  onDeleted: (data: SessionData) => clearStorage(data.did),\n})\n```\n\n#### OAuth based session management\n\nDepending on the environment used by your application, different OAuth clients\nare available:\n\n- [@atproto/oauth-client-browser](https://www.npmjs.com/package/@atproto/oauth-client-browser):\n  for the browser.\n- [@atproto/oauth-client-node](https://www.npmjs.com/package/@atproto/oauth-client-node): for\n  Node.js.\n- [@atproto/oauth-client](https://www.npmjs.com/package/@atproto/oauth-client):\n  Lower level; compatible with most JS engines.\n\nEvery `@atproto/oauth-client-*` implementation has a different way to obtain an\nOAuth session instance that can be used to instantiate a `Client`. Here is an\nexample restoring a previously saved session:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { OAuthClient } from '@atproto/oauth-client'\n\nconst oauthClient = new OAuthClient({\n  // ...\n})\n\nconst oauthSession = await oauthClient.restore('did:plc:123')\n\n// Instantiate the lex Client using an OAuth session\nconst client = new Client(oauthSession)\n```\n\n### Authenticated and public clients\n\nUse the `api.*` constants for addressing Bluesky services:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { api } from '@bsky/sdk'\n\n// Bluesky API (authenticated queries, proxied through the account host;\n// record writes and mutations automatically target the account host directly)\nconst bskyClient = new Client(session, { service: api.app.service })\n\n// Bluesky API (unauthenticated public queries)\nconst publicBskyClient = new Client(api.app.urlPublic)\n```\n\n### API calls\n\nThe SDK exports actions for many common operations. Invoke an action against a\nclient with `client.call(action, input)`:\n\n```typescript\nimport {\n  blockActor,\n  blockActorList,\n  deleteFollow,\n  deleteLike,\n  deletePost,\n  deleteRepost,\n  follow,\n  getPreferences,\n  like,\n  muteActor,\n  muteActorList,\n  post,\n  repost,\n  unblockActor,\n  unblockActorList,\n  unmuteActor,\n  unmuteActorList,\n  updateSeenNotifications,\n  upsertProfile,\n} from '@bsky/sdk'\n\n// The DID of the user currently authenticated (or undefined)\nbskyClient.did\nbskyClient.assertDid // Throws if the user is not authenticated\n\n// Feeds and content\nawait bskyClient.call(post, { text: 'Hello, world!' })\nawait bskyClient.call(deletePost, postUri)\nawait bskyClient.call(like, { uri, cid })\nawait bskyClient.call(deleteLike, likeUri)\nawait bskyClient.call(repost, { uri, cid })\nawait bskyClient.call(deleteRepost, repostUri)\n\n// Social graph\nawait bskyClient.call(follow, { did })\nawait bskyClient.call(deleteFollow, followUri)\nawait bskyClient.call(muteActor, { actor })\nawait bskyClient.call(unmuteActor, { actor })\nawait bskyClient.call(blockActor, { did })\nawait bskyClient.call(unblockActor, blockUri)\nawait bskyClient.call(muteActorList, { list })\nawait bskyClient.call(unmuteActorList, { list })\nawait bskyClient.call(blockActorList, { list })\nawait bskyClient.call(unblockActorList, { list })\n\n// Actors\nawait bskyClient.call(upsertProfile, (existing) => ({\n  ...existing,\n  displayName: 'Alice',\n}))\n\n// Notifications\nawait bskyClient.call(updateSeenNotifications, seenAt)\n\n// Preferences\nconst prefs = await bskyClient.call(getPreferences)\n```\n\nQueries without a dedicated action are one `xrpc` call away using the generated\nlexicons — see [Advanced API calls](#advanced-api-calls).\n\n### Validation and types\n\nThe lexicons include a complete types system with validation and type-guards.\nFor example, to validate a post record:\n\n```typescript\nimport { app } from '@bsky/sdk/lexicons'\n\nconst post = { ... }\nif (app.bsky.feed.post.$isTypeOf(post)) {\n  // typescript now recognizes `post` may be an app.bsky.feed.post.Main\n  // however -- we still need to validate it\n  const res = app.bsky.feed.post.main.safeValidate(post)\n  if (res.success) {\n    // a valid record\n  } else {\n    // something is wrong\n    console.log(res.error)\n  }\n}\n```\n\n### Rich text\n\nSome 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.\n\nℹ️ 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.\n\n```typescript\nimport { currentDatetimeString } from '@atproto/lex'\nimport { RichText } from '@bsky/sdk/richtext'\n\n// creating richtext — detects mentions and links, resolving mentions to DIDs\nconst rt = await RichText.resolve(\n  'Hello @alice.com, check out this link: https://example.com',\n  { resolver: client },\n)\nconst postRecord = {\n  $type: 'app.bsky.feed.post',\n  text: rt.text,\n  facets: rt.facets,\n  createdAt: currentDatetimeString(),\n}\n\n// rendering as markdown\nlet markdown = ''\nfor (const segment of rt.segments()) {\n  if (segment.isLink()) {\n    markdown += `[${segment.text}](${segment.link?.uri})`\n  } else if (segment.isMention()) {\n    markdown += `[${segment.text}](https://my-bsky-app.com/user/${segment.mention?.did})`\n  } else {\n    markdown += segment.text\n  }\n}\n\n// calculating string lengths\nconst rt2 = new RichText({ text: 'Hello' })\nconsole.log(rt2.length) // => 5\nconsole.log(rt2.graphemeLength) // => 5\nconst rt3 = new RichText({ text: '👨‍👩‍👧‍👧' })\nconsole.log(rt3.length) // => 25\nconsole.log(rt3.graphemeLength) // => 1\n```\n\n### Moderation\n\nApplying 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:\n\n- Moderator labeling\n- User muting (including mutelists)\n- User blocking\n- Mutewords\n- Hidden posts\n\nFor more information, see the [Moderation Documentation](./docs/moderation.md).\n\n```typescript\nimport { getPreferences } from '@bsky/sdk'\nimport { moderatePost } from '@bsky/sdk/moderation'\n\n// First get the user's moderation prefs and their label definitions\n// =\n\nconst prefs = await bskyClient.call(getPreferences)\nconst labelDefs = {\n  /* see the Moderation Documentation for gathering labelDefs */\n}\n\n// We call the appropriate moderation function for the content\n// =\n\nconst postMod = moderatePost(postView, {\n  userDid: bskyClient.assertDid,\n  prefs: prefs.moderationPrefs,\n  labelDefs,\n})\n\n// We then use the output to decide how to affect rendering\n// =\n\n// in feeds\nif (postMod.ui('contentList').filter) {\n  // don't include in feeds\n}\nif (postMod.ui('contentList').blur) {\n  // render the whole object behind a cover (use postMod.ui('contentList').blurs to explain)\n  if (postMod.ui('contentList').noOverride) {\n    // do not allow the cover the be removed\n  }\n}\nif (postMod.ui('contentList').alert || postMod.ui('contentList').inform) {\n  // render warnings on the post\n  // find the warnings in postMod.ui('contentList').alerts and postMod.ui('contentList').informs\n}\n\n// viewed directly\nif (postMod.ui('contentView').filter) {\n  // don't render the view\n}\nif (postMod.ui('contentView').blur) {\n  // render the whole object behind a cover (use postMod.ui('contentView').blurs to explain)\n  if (postMod.ui('contentView').noOverride) {\n    // do not allow the cover the be removed\n  }\n}\nif (postMod.ui('contentView').alert || postMod.ui('contentView').inform) {\n  // render warnings on the post\n  // find the warnings in postMod.ui('contentView').alerts and postMod.ui('contentView').informs\n}\n\n// post embeds in all contexts\nif (postMod.ui('contentMedia').blur) {\n  // render the whole object behind a cover (use postMod.ui('contentMedia').blurs to explain)\n  if (postMod.ui('contentMedia').noOverride) {\n    // do not allow the cover the be removed\n  }\n}\n```\n\n## Advanced\n\n### Advanced API calls\n\nThe actions above are convenience wrappers. They cover most but not all\navailable methods.\n\nThe AT Protocol identifies methods and records with reverse-DNS names. You can\ncall any of them using the generated lexicons and the client's `call` and\nrecord helpers:\n\n```typescript\nimport { currentDatetimeString } from '@atproto/lex'\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// Record helpers always target the user's account host\nconst res1 = await bskyClient.createRecord({\n  $type: 'app.bsky.feed.post',\n  text: 'Hello, world!',\n  createdAt: currentDatetimeString(),\n})\nconst res2 = await bskyClient.call(\n  com.atproto.repo.listRecords,\n  { repo: alice.did, collection: 'app.bsky.feed.post' },\n  { service: null }, // target the account host rather than the Bluesky API\n)\n// or, using the typed record convenience helper:\nconst res2b = await bskyClient.list(app.bsky.feed.post, {\n  repo: alice.did, // optional. Defaults to bskyClient.did (throws if unauthenticated)\n})\n\nconst res3 = await bskyClient.call(app.bsky.feed.getTimeline, {\n  limit: 20,\n})\n// res3: app.bsky.feed.getTimeline.OutputSchema\n```\n\nNamespace roots exported from `/lexicons`: `app`, `com`, `chat`.\n\n### Bring your own fetch\n\nIf you want to provide your own `fetch` implementation — for logging, mocking,\nretries, or environments without a built-in `fetch` — pass it when constructing\nthe `Client`:\n\n```typescript\nimport { Client } from '@atproto/lex'\n\nconst myFetch: typeof globalThis.fetch = async (input, init) => {\n  console.log('requesting', input)\n  const response = await globalThis.fetch(input, init)\n  console.log('got response', response)\n  return response\n}\n\nconst client = new Client({\n  service: 'https://example.com',\n  fetch: myFetch,\n})\n```\n\n## Subpaths\n\n| Import                 | Contents                                                                             |\n| ---------------------- | ------------------------------------------------------------------------------------ |\n| `@bsky/sdk`            | `api` constants, actions, `DEFAULT_LABEL_SETTINGS`                                   |\n| `@bsky/sdk/lexicons`   | Generated lexicon definitions (`app`, `com`, `chat`)                                 |\n| `@bsky/sdk/moderation` | `moderatePost`, `moderateProfile`, and friends; `ModerationDecision`, `ModerationUI` |\n| `@bsky/sdk/richtext`   | `RichText` builder                                                                   |\n| `@bsky/sdk/utils`      | Age assurance helpers, `sanitizeMutedWordValue`, nux validation                      |\n\n## Upgrading from @atproto/api\n\nSee [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.\n\n## License\n\nThis project is dual-licensed under MIT and Apache 2.0 terms:\n\n- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)\n- 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)\n\nDownstream 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.\n","isInternal":false,"tokens":3282,"sizeBytes":13479}],"systemPromptSnippet":"<yakaai_skills repo=\"bluesky-social/bsky\">\n<!-- File: packages/sdk/docs/moderation.md (Tokens: ~2836 | Category: anthropic-skill) -->\n# Moderation API\n\nApplying 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:\n\n- Moderator labeling\n- User muting (including mutelists)\n- User blocking\n- Mutewords\n- Hidden posts\n\n## Configuration\n\nEvery moderation function takes a set of options which look like this:\n\n```typescript\n{\n  // the logged-in user's DID\n  userDid: 'did:plc:1234...',\n\n  prefs: {\n    // is adult content allowed?\n    adultContentEnabled: true,\n\n    // the global label settings (used on self-labels)\n    labels: {\n      porn: 'hide',\n      sexual: 'warn',\n      nudity: 'ignore',\n      // ...\n    },\n\n    // the subscribed labelers and their label settings\n    labelers: [\n      {\n        did: 'did:plc:1234...',\n        labels: {\n          porn: 'hide',\n          sexual: 'warn',\n          nudity: 'ignore',\n          // ...\n        }\n      }\n    ],\n\n    mutedWords: [/* ... */],\n    hiddenPosts: [/* ... */]\n  },\n\n  // custom label definitions\n  labelDefs: {\n    // labelerDid => defs[]\n    'did:plc:1234...': [\n      /* ... */\n    ]\n  }\n}\n```\n\nThis should match the following interfaces:\n\n```typescript\nexport interface ModerationPrefsLabeler {\n  did: DidString\n  labels: Record<string, LabelPreference>\n}\n\nexport interface ModerationPrefs {\n  adultContentEnabled: boolean\n  labels: Record<string, LabelPreference>\n  labelers: ModerationPrefsLabeler[]\n  mutedWords: app.bsky.actor.defs.MutedWord[]\n  hiddenPosts: AtUriString[]\n}\n\nexport interface ModerationOpts {\n  userDid: DidString | undefined\n  prefs: ModerationPrefs\n  /**\n   * Map of labeler did -> custom definitions\n   */\n  labelDefs?: Record<string, InterpretedLabelValueDefinition[]>\n}\n```\n\nYou can quickly grab the `ModerationPrefs` using the `getPreferences` action:\n\n```typescript\nimport { getPreferences } from '@bsky/sdk'\nimport { moderatePost } from '@bsky/sdk/moderation'\n\nconst prefs = await client.call(getPreferences)\nmoderatePost(post, {\n  userDid: /*...*/,\n  prefs: prefs.moderationPrefs,\n  labelDefs: /*...*/\n})\n```\n\nTo gather the label definitions (`labelDefs`) see the _Labelers_ section below.\n\n## Labelers\n\nLabelers 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:\n\n```typescript\nimport { Client } from '@atproto/lex'\n\nconst client = new Client(session, {\n  appLabelers: ['did:web:my-labeler.com'],\n})\n```\n\n(`Client.configure({ appLabelers })` sets the same thing globally for all client instances; prefer the per-client option.)\n\nUsers 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.\n\nLabelers publish a `app.bsky.labeler.service` record that looks like this:\n\n```js\n{\n  $type: 'app.bsky.labeler.service',\n  policies: {\n    // the list of label values the labeler will publish\n    labelValues: [\n      'rude',\n    ],\n    // any custom definitions the labeler will be using\n    labelValueDefinitions: [\n      {\n        identifier: 'rude',\n        blurs: 'content',\n        severity: 'alert',\n        defaultSetting: 'warn',\n        adultOnly: false,\n        locales: [\n          {\n            lang: 'en',\n            name: 'Rude',\n            description: 'Not keeping things civil.',\n          },\n        ],\n      },\n    ],\n  },\n  createdAt: '2024-03-12T17:17:17.215Z'\n}\n```\n\nThe 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).\n\nHere is how to do this:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { getPreferences } from '@bsky/sdk'\nimport { app } from '@bsky/sdk/lexicons'\nimport {\n  type InterpretedLabelValueDefinition,\n  interpretLabelValueDefinitions,\n  moderatePost,\n} from '@bsky/sdk/moderation'\n\n// assume `client` is backed by a signed-in session\nconst prefs = await client.call(getPreferences)\n\nconst services = await client.call(app.bsky.labeler.getServices, {\n  dids: prefs.moderationPrefs.labelers.map((labeler) => labeler.did),\n  detailed: true,\n})\n\nconst labelDefs: Record<string, InterpretedLabelValueDefinition[]> = {}\nfor (const view of services.views) {\n  if (app.bsky.labeler.defs.labelerViewDetailed.isTypeOf(view)) {\n    labelDefs[view.creator.did] = interpretLabelValueDefinitions(view)\n  }\n}\n\nmoderatePost(post, {\n  userDid: client.assertDid,\n  prefs: prefs.moderationPrefs,\n  labelDefs,\n})\n```\n\n## The `moderate*()` APIs\n\nThe SDK exports methods to moderate the different kinds of content on the network.\n\n```typescript\nimport {\n  moderateProfile,\n  moderatePost,\n  moderateNotification,\n  moderateFeedGenerator,\n  moderateUserList,\n  moderateStatus,\n  moderateViewExternal,\n  moderateProfileLink,\n} from '@bsky/sdk/moderation'\n```\n\nEach of these follows the same API signature:\n\n```typescript\nconst res = moderatePost(post, moderationOptions)\n```\n\nThe response object provides an API for figuring out what your UI should do in different contexts.\n\n```typescript\nres.ui(context) /* =>\n\nModerationUI {\n  filter: boolean // should the content be removed from the interface?\n  blur: boolean // should the content be put behind a cover?\n  alert: boolean // should an alert be put on the content? (negative)\n  inform: boolean // should an informational notice be put on the content? (neutral)\n  noOverride: boolean // if blur=true, should the UI disable opening the cover?\n\n  // the reasons for each of the flags:\n  filters: ModerationCause[]\n  blurs: ModerationCause[]\n  alerts: ModerationCause[]\n  informs: ModerationCause[]\n}\n*/\n```\n\nThere are multiple UI contexts available:\n\n- `profileList` A profile being listed, eg in search or a follower list\n- `profileView` A profile being viewed directly\n- `avatar` The user's avatar in any context\n- `banner` The user's banner in any context\n- `displayName` The user's display name in any context\n- `contentList` Content being listed, eg posts in a feed, posts as replies, a user list list, a feed generator list, etc\n- `contentView` Content being viewed direct, eg an opened post, the user list page, the feedgen page, etc\n- `contentMedia ` Media inside the content, eg a picture embedded in a post\n\nHere's how a post in a feed would use these tools to make a decision:\n\n```typescript\nconst mod = moderatePost(post, moderationOptions)\n\nif (mod.ui('contentList').filter) {\n  // dont show the post\n}\nif (mod.ui('contentList').blur) {\n  // cover the post with the explanation from mod.ui('contentList').blurs[0]\n  if (mod.ui('contentList').noOverride) {\n    // dont allow the cover to be removed\n  }\n}\nif (mod.ui('contentMedia').blur) {\n  // cover the post's embedded images with the explanation from mod.ui('contentMedia').blurs[0]\n  if (mod.ui('contentMedia').noOverride) {\n    // dont allow the cover to be removed\n  }\n}\nif (mod.ui('avatar').blur) {\n  // cover the avatar with the explanation from mod.ui('avatar').blurs[0]\n  if (mod.ui('avatar').noOverride) {\n    // dont allow the cover to be removed\n  }\n}\nfor (const alert of mod.ui('contentList').alerts) {\n  // render this alert\n}\nfor (const inform of mod.ui('contentList').informs) {\n  // render this inform\n}\n```\n\n### External embeds\n\nLabels 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.\n\nAn external card acts as both the \"content\" and the \"media\" of its embed, so combine the `contentView` and `contentMedia` contexts with `mergeModUIResults`:\n\n```typescript\nimport { mergeModUIResults, moderateViewExternal } from '@bsky/sdk/moderation'\n\nconst embedMod = moderateViewExternal(embed.external, moderationOptions)\nconst ui = mergeModUIResults(\n  embedMod.ui('contentView'),\n  embedMod.ui('contentMedia'),\n)\n\nif (ui.blur) {\n  // cover the card with the explanation from ui.blurs[0]\n  if (ui.noOverride) {\n    // don't allow the cover to be removed\n  }\n}\n```\n\n`mergeModUIResults` accepts any number of `ModerationUI` values (undefined entries are skipped), combining their causes and preserving the strictest `noOverride`.\n\n### Profile links\n\nA 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:\n\n```typescript\nimport { moderateProfile, moderateProfileLink } from '@bsky/sdk/moderation'\n\nconst profileMod = moderateProfile(profile, moderationOptions)\n\nfor (const link of profile.links ?? []) {\n  const linkMod = moderateProfileLink(link, moderationOptions)\n\n  if (linkMod.ui('contentView').blur) {\n    // cover the link with the explanation from linkMod.ui('contentView').blurs[0]\n  }\n  if (linkMod.ui('contentMedia').blur) {\n    // cover the link's icon with the explanation from linkMod.ui('contentMedia').blurs[0]\n  }\n}\n```\n\nNotes:\n\n- 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.\n- 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.\n- `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.\n\n## Sending moderation reports\n\nAny 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:\n\n```typescript\nimport { com } from '@bsky/sdk/lexicons'\n\nawait client.call(\n  com.atproto.moderation.createReport,\n  {\n    reasonType: com.atproto.moderation.defs.reasonViolation.$token,\n    reason: 'They were being such a jerk to me!',\n    subject: com.atproto.admin.defs.repoRef.$build({\n      did: 'did:web:bob.com',\n    }),\n  },\n  { service: 'did:web:my-labeler.com#atproto_labeler' },\n)\n```\n\nThe Bluesky moderation service's address is available via the `api` export from the SDK:\n\n```typescript\nimport { api } from '@bsky/sdk'\n\nawait client.call(com.atproto.moderation.createReport, report, {\n  service: api.moderation.service, // did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler\n})\n```\n\n<!-- File: skills/upgrade-from-atproto-api/SKILL.md (Tokens: ~7319 | Category: anthropic-skill) -->\n---\nname: upgrade-from-atproto-api\ndescription: Migrate code from @atproto/api to @bsky/sdk + @atproto/lex — import mappings, agent-to-client patterns, string format types\n---\n\n# Migrating from `@atproto/api` to `@bsky/sdk` + `@atproto/lex`\n\nThe `@atproto/api` package has been superseded by two packages:\n\n- **`@bsky/sdk`** — Bluesky-specific actions, moderation helpers, rich text, and the `api` constants object.\n- **`@atproto/lex`** — the generic AT Protocol client, lexicon utilities, and schema types. A peer dependency of the sdk; install both. (`@atproto/lex-password-session` is separate, only needed for password-based sessions.)\n\n```\nnpm install @bsky/sdk @atproto/lex\n```\n\nImport `Client` and other lex utilities from `@atproto/lex` — not from its underlying sub-packages (`@atproto/lex-client`, `@atproto/lex-schema`), which shouldn't be depended upon directly.\n\n## 1. Client Setup\n\nThere are two common client patterns.\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport { api } from '@bsky/sdk'\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// --- Pattern 1: Authenticated Bluesky client ---\n// Queries are proxied through the account host to api.bsky.app and signed\n// with the user's identity. Record writes and mutations (createRecord,\n// applyWrites, etc.) automatically target the account host directly, so one\n// client covers both.\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx', // App password\n  onUpdated: (data) => saveToStorage(data),\n  onDeleted: (data) => clearStorage(data.did),\n})\nconst bskyClient = new Client(session, {\n  service: api.app.service, // 'did:web:api.bsky.app#bsky_appview'\n})\n\n// --- Pattern 2: Public Bluesky API client (unauthenticated read-only) ---\n// Use for reading public data without authentication.\nconst publicBskyClient = new Client(api.app.urlPublic) // 'https://public.api.bsky.app'\n```\n\nTo target a different service for a single request (e.g. the account host\nitself, or a labeler), pass `service` in the per-request options rather than\nconstructing another client — `{ service: null }` reaches the account host\ndirectly:\n\n```typescript\nconst records = await bskyClient.call(\n  com.atproto.repo.listRecords,\n  { repo: bskyClient.assertDid, collection: 'app.bsky.feed.post' },\n  { service: null },\n)\n```\n\nDeployments that already keep separate clients for the account host vs the\nBluesky API can continue to do so — construct each client from the same\nsession:\n\n```typescript\nconst accountClient = new Client(session)\nconst bskyClient = new Client(session, { service: api.app.service })\n```\n\n**`api` constants reference:**\n\n| Constant                 | Value                                                |\n| ------------------------ | ---------------------------------------------------- |\n| `api.app.did`            | `'did:web:api.bsky.app'`                             |\n| `api.app.service`        | `'did:web:api.bsky.app#bsky_appview'`                |\n| `api.app.url`            | `'https://api.bsky.app'`                             |\n| `api.app.urlPublic`      | `'https://public.api.bsky.app'`                      |\n| `api.chat.did`           | `'did:web:api.bsky.chat'`                            |\n| `api.chat.service`       | `'did:web:api.bsky.chat#bsky_chat'`                  |\n| `api.chat.url`           | `'https://api.bsky.chat'`                            |\n| `api.moderation.did`     | `'did:plc:ar7c4by46qjdydhdevvrndac'`                 |\n| `api.moderation.service` | `'did:plc:ar7c4by46qjdydhdevvrndac#atproto_labeler'` |\n\n**Labeler caveat:** When no labeler headers are sent, the Bluesky API applies its own moderation service by default — so if you don't introduce other labelers, no labeler configuration is needed at all. The `atproto-accept-labelers` header works by replacement, not addition: sending _any_ labelers header replaces that server-side default. So when you introduce other labelers, keep the Bluesky labeler active by setting it as an _app labeler_ — app labelers are always included in the header with the `;redact` parameter (allowing takedowns), which a plain `labelers` entry does not set:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { api } from '@bsky/sdk'\n\nconst client = new Client(session, {\n  service: api.app.service,\n  appLabelers: [api.moderation.did], // keep Bluesky moderation, with `;redact`\n  labelers: ['did:plc:mycustomlabeler...'], // the user's labeler subscriptions\n})\n```\n\n(`Client.configure({ appLabelers })` sets the same thing globally for all client instances; prefer the per-client option.) Defaults cascade app → client → request: a per-request `labelers` array replaces the client's, `labelers: null` disables the header for that request, and `appLabelers: null` opts a client or request out of the app labelers.\n\n---\n\n## 2. Import Mappings\n\n### Client / Session classes\n\n| Old (`@atproto/api`) | New               | Package                         |\n| -------------------- | ----------------- | ------------------------------- |\n| `AtpAgent`           | `Client`          | `@atproto/lex`                  |\n| `Agent`              | `Client`          | `@atproto/lex`                  |\n| `BskyAgent`          | `Client`          | `@atproto/lex`                  |\n| `AtpBaseClient`      | `Client`          | `@atproto/lex`                  |\n| `CredentialSession`  | `PasswordSession` | `@atproto/lex-password-session` |\n\n### Sessions\n\n| Old (`@atproto/api`)       | New           | Package                         | Notes                                                              |\n| -------------------------- | ------------- | ------------------------------- | ------------------------------------------------------------------ |\n| `AtpSessionData`           | `SessionData` | `@atproto/lex-password-session` | Shape for persisted session data; renamed for clarity              |\n| `AtpSessionEvent`          | removed       | —                               | Session event types now inferred from `PasswordSession` callbacks  |\n| `AtpPersistSessionHandler` | removed       | —                               | Use `onUpdated`/`onDeleted` callbacks on `PasswordSession` instead |\n| `AtpAgentLoginOpts`        | removed       | —                               | Merged into `PasswordSession.login()` parameters                   |\n| `AtpAgentGlobalOpts`       | removed       | —                               | Replaced by `Client` constructor options                           |\n\n### Preferences Types\n\n| Old (`@atproto/api`)       | New                        | Package     | Notes                                                      |\n| -------------------------- | -------------------------- | ----------- | ---------------------------------------------------------- |\n| `BskyPreferences`          | `BskyPreferences`          | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n| `BskyFeedViewPreference`   | `BskyFeedViewPreference`   | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n| `BskyThreadViewPreference` | `BskyThreadViewPreference` | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n| `BskyInterestsPreference`  | `BskyInterestsPreference`  | `@bsky/sdk` | Still available, now exported from SDK root (via types.ts) |\n\n### Errors\n\n| Old (`@atproto/api`) | New                                 | Package        |\n| -------------------- | ----------------------------------- | -------------- |\n| `XRPCError`          | `XrpcResponseError` / `XrpcFailure` | `@atproto/lex` |\n\n### Utility / codec functions\n\n| Old (`@atproto/api`) | New                                                    | Package              |\n| -------------------- | ------------------------------------------------------ | -------------------- |\n| `jsonStringToLex`    | `lexParse`                                             | `@atproto/lex`       |\n| `jsonToLex`          | `jsonToLex`                                            | `@atproto/lex`       |\n| `lexToJson`          | `lexToJson`                                            | `@atproto/lex`       |\n| `stringifyLex`       | `lexStringify`                                         | `@atproto/lex`       |\n| `BlobRef`            | `BlobRef`                                              | `@atproto/lex`       |\n| `asPredicate`        | `schema.$matches` (e.g. `app.bsky.feed.post.$matches`) | `@bsky/sdk/lexicons` |\n| `parseLanguage`      | removed — use `isLanguageString`                       | `@atproto/lex`       |\n\n### Type utilities\n\n| Old (`@atproto/api`) | New            | Package                                    |\n| -------------------- | -------------- | ------------------------------------------ |\n| `$Typed<T>`          | `$Typed<V, T>` | `@atproto/lex-schema` (via `@atproto/lex`) |\n| `Un$Typed<T>`        | `Un$Typed<V>`  | `@atproto/lex-schema` (via `@atproto/lex`) |\n\n### Constants\n\n| Old (`@atproto/api`)     | New                      | Package     |\n| ------------------------ | ------------------------ | ----------- |\n| `BSKY_LABELER_DID`       | `api.moderation.did`     | `@bsky/sdk` |\n| `DEFAULT_LABEL_SETTINGS` | `DEFAULT_LABEL_SETTINGS` | `@bsky/sdk` |\n\n### Moderation\n\n| Old (`@atproto/api`)             | New                              | Package                |\n| -------------------------------- | -------------------------------- | ---------------------- |\n| `moderatePost`                   | `moderatePost`                   | `@bsky/sdk/moderation` |\n| `moderateProfile`                | `moderateProfile`                | `@bsky/sdk/moderation` |\n| `moderateUserList`               | `moderateUserList`               | `@bsky/sdk/moderation` |\n| `moderateFeedGenerator`          | `moderateFeedGenerator`          | `@bsky/sdk/moderation` |\n| `moderateNotification`           | `moderateNotification`           | `@bsky/sdk/moderation` |\n| `moderateStatus`                 | `moderateStatus`                 | `@bsky/sdk/moderation` |\n| `ModerationUI`                   | `ModerationUI`                   | `@bsky/sdk/moderation` |\n| `ModerationDecision`             | `ModerationDecision`             | `@bsky/sdk/moderation` |\n| `interpretLabelValueDefinition`  | `interpretLabelValueDefinition`  | `@bsky/sdk/moderation` |\n| `interpretLabelValueDefinitions` | `interpretLabelValueDefinitions` | `@bsky/sdk/moderation` |\n| `hasMutedWord`                   | `hasMutedWord`                   | `@bsky/sdk/moderation` |\n| `matchMuteWords`                 | `matchMuteWords`                 | `@bsky/sdk/moderation` |\n| `LABELS`                         | `LABELS`                         | `@bsky/sdk/moderation` |\n\n### Rich text\n\n| Old (`@atproto/api`)               | New                                    | Package              |\n| ---------------------------------- | -------------------------------------- | -------------------- |\n| `RichText`                         | `RichText`                             | `@bsky/sdk/richtext` |\n| `RichText.detectFacets(agent)`     | `rt.detectFacets(resolver)` — see note | `@bsky/sdk/richtext` |\n| `sanitizeRichText`                 | `sanitizeRichText`                     | `@bsky/sdk/richtext` |\n| `UnicodeString`                    | `UnicodeString`                        | `@bsky/sdk/richtext` |\n| `MENTION_REGEX`, `URL_REGEX`, etc. | same names                             | `@bsky/sdk/richtext` |\n\n**`detectFacets` resolver change:** The method now takes either a `Client` or a `HandleResolver` from `@atproto-labs/handle-resolver` instead of an agent. Passing a `Client` resolves handles via `com.atproto.identity.resolveHandle`:\n\n```typescript\n// Usage with a Client (resolves via com.atproto.identity.resolveHandle):\nconst rt = new RichText({ text: 'Hello @alice.bsky.social!' })\nawait rt.detectFacets(bskyClient)\n\n// Or bring your own HandleResolver:\nawait rt.detectFacets(myHandleResolver)\n\n// Or use the one-step static helper (construct + detect + resolve):\nconst rt2 = await RichText.resolve('Hello @alice.bsky.social!', {\n  resolver: bskyClient,\n})\n```\n\nThe `Client`-backed resolver is also exported directly for standalone use:\n\n```typescript\nimport { ClientHandleResolver } from '@bsky/sdk/utils'\n\nconst resolver = new ClientHandleResolver(bskyClient)\nconst did = await resolver.resolve('alice.bsky.social') // AtprotoDid | null\n```\n\n### Utils\n\n| Old (`@atproto/api`)     | New                                          | Package           |\n| ------------------------ | -------------------------------------------- | ----------------- |\n| `sanitizeMutedWordValue` | `sanitizeMutedWordValue`                     | `@bsky/sdk/utils` |\n| `validateNux`            | `validateNux`                                | `@bsky/sdk/utils` |\n| `nuxSchema`              | `nuxSchema`                                  | `@bsky/sdk/utils` |\n| `savedFeedsToUriArrays`  | removed — compute from `savedFeeds` directly | —                 |\n\n### Removed without direct replacement\n\n| Old (`@atproto/api`)                    | Notes                                                                           |\n| --------------------------------------- | ------------------------------------------------------------------------------- |\n| `mock` / `BskyAgent.mockResolveHandles` | removed; test with a fake `HandleResolver` function                             |\n| `isDid` / `asDid` / `assertDid`         | use `isStringFormat(v, 'did')` / `asStringFormat(v, 'did')` from `@atproto/lex` |\n| `AtUri`                                 | `AtUri` from `@atproto/syntax` (still a direct dependency)                      |\n| `lexicons` export (Lexicons instance)   | use generated schema objects from `@bsky/sdk/lexicons`                          |\n| `getSavedFeedType`                      | internal only; moved to preference actions                                      |\n| `validateSavedFeed`                     | internal only; moved to preference actions                                      |\n\n---\n\n## 3. Agent Methods → Actions / Client Calls\n\nThe `Agent`/`AtpAgent`/`BskyAgent` class has been replaced by:\n\n- **`Client`** from `@atproto/lex` for raw XRPC calls.\n- **Action functions** from `@bsky/sdk` for higher-level operations (preferences, records, graph, notifications).\n\n### Record / graph sugar methods\n\nThese agent methods now correspond to named action functions called via `client.call(action, input)`:\n\n| Old agent method                        | New action                                     | Import      |\n| --------------------------------------- | ---------------------------------------------- | ----------- |\n| `agent.post(input)`                     | `client.call(post, input)`                     | `@bsky/sdk` |\n| `agent.deletePost(uri)`                 | `client.call(deletePost, uri)`                 | `@bsky/sdk` |\n| `agent.like(uri, cid)`                  | `client.call(like, { uri, cid })`              | `@bsky/sdk` |\n| `agent.deleteLike(uri)`                 | `client.call(deleteLike, uri)`                 | `@bsky/sdk` |\n| `agent.repost(uri, cid)`                | `client.call(repost, { uri, cid })`            | `@bsky/sdk` |\n| `agent.deleteRepost(uri)`               | `client.call(deleteRepost, uri)`               | `@bsky/sdk` |\n| `agent.follow(did)`                     | `client.call(follow, { did })`                 | `@bsky/sdk` |\n| `agent.deleteFollow(uri)`               | `client.call(deleteFollow, uri)`               | `@bsky/sdk` |\n| `agent.upsertProfile(fn)`               | `client.call(upsertProfile, fn)`               | `@bsky/sdk` |\n| `agent.mute(actor, opts?)`              | `client.call(muteActor, { actor, ...opts })`   | `@bsky/sdk` |\n| `agent.unmute(actor)`                   | `client.call(unmuteActor, { actor })`          | `@bsky/sdk` |\n| `agent.muteModList(uri)`                | `client.call(muteActorList, { list: uri })`    | `@bsky/sdk` |\n| `agent.unmuteModList(uri)`              | `client.call(unmuteActorList, { list: uri })`  | `@bsky/sdk` |\n| `agent.blockModList(uri)`               | `client.call(blockActorList, { list: uri })`   | `@bsky/sdk` |\n| `agent.unblockModList(uri)`             | `client.call(unblockActorList, { list: uri })` | `@bsky/sdk` |\n| `agent.updateSeenNotifications(seenAt)` | `client.call(updateSeenNotifications, seenAt)` | `@bsky/sdk` |\n\n**Branded input types:** Unlike the old agent methods, action inputs use the lex string format types — URIs are `AtUriString`, DIDs are `DidString`, etc. Values read from API responses already carry these types; for plain strings from your own storage, validate at the boundary with `asStringFormat(v, 'at-uri')` / `asStringFormat(v, 'did')` (see § 5).\n\n### Preferences methods\n\nPreference reads and writes target the user's account host by default (like the client's own record helpers), regardless of the client's `service` setting — so they work unchanged on a client configured to proxy to the Bluesky API. `getPreferences` and `updatePreferences` accept an optional `service` in their input to override:\n\n```typescript\nawait client.call(getPreferences) // account host\nawait client.call(getPreferences, { service: 'did:web:example.com#svc' })\nawait client.call(updatePreferences, (prefs) => prefs) // account host\nawait client.call(updatePreferences, {\n  update: (prefs) => prefs,\n  service: 'did:web:example.com#svc',\n})\n```\n\n| Old agent method                                   | New action                                                          |\n| -------------------------------------------------- | ------------------------------------------------------------------- |\n| `agent.getPreferences()`                           | `client.call(getPreferences)`                                       |\n| `agent.setAdultContentEnabled(v)`                  | `client.call(setAdultContentEnabled, v)`                            |\n| `agent.setContentLabelPref(key, val, labelerDid?)` | `client.call(setContentLabelPref, { key, value: val, labelerDid })` |\n| `agent.addSavedFeeds(feeds)`                       | `client.call(addSavedFeeds, feeds)`                                 |\n| `agent.removeSavedFeeds(ids)`                      | `client.call(removeSavedFeeds, ids)`                                |\n| `agent.updateSavedFeeds(feeds)`                    | `client.call(updateSavedFeeds, feeds)`                              |\n| `agent.overwriteSavedFeeds(feeds)`                 | `client.call(overwriteSavedFeeds, feeds)`                           |\n| `agent.setFeedViewPrefs(feed, prefs)`              | `client.call(setFeedViewPrefs, { feed, ...prefs })`                 |\n| `agent.setThreadViewPrefs(prefs)`                  | `client.call(setThreadViewPrefs, prefs)`                            |\n| `agent.setPersonalDetails({ birthDate })`          | `client.call(setPersonalDetails, { birthDate })`                    |\n| `agent.setInterestsPref({ tags })`                 | `client.call(setInterestsPref, { tags })`                           |\n| `agent.addMutedWord(word)`                         | `client.call(addMutedWord, word)`                                   |\n| `agent.addMutedWords(words)`                       | `client.call(addMutedWords, words)`                                 |\n| `agent.upsertMutedWords(words)`                    | `client.call(upsertMutedWords, words)` (deprecated)                 |\n| `agent.updateMutedWord(word)`                      | `client.call(updateMutedWord, word)`                                |\n| `agent.removeMutedWord(word)`                      | `client.call(removeMutedWord, word)`                                |\n| `agent.removeMutedWords(words)`                    | `client.call(removeMutedWords, words)`                              |\n| `agent.hidePost(uri)`                              | `client.call(hidePost, uri)`                                        |\n| `agent.unhidePost(uri)`                            | `client.call(unhidePost, uri)`                                      |\n| `agent.addLabeler(did)`                            | `client.call(addLabeler, did)`                                      |\n| `agent.removeLabeler(did)`                         | `client.call(removeLabeler, did)`                                   |\n| `agent.setVerificationPrefs(prefs)`                | `client.call(setVerificationPrefs, prefs)`                          |\n| `agent.setPostInteractionSettings(s)`              | `client.call(setPostInteractionSettings, s)`                        |\n| `agent.updateLiveEventPreferences(action)`         | `client.call(updateLiveEventPreferences, action)`                   |\n| `agent.setIsBetaUser(v)`                           | `client.call(setIsBetaUser, v)`                                     |\n\n### Renamed app-state preferences methods (dropped `bskyApp` prefix)\n\n| Old agent method                             | New action                                   |\n| -------------------------------------------- | -------------------------------------------- |\n| `agent.bskyAppQueueNudges(nudges)`           | `client.call(queueNudges, nudges)`           |\n| `agent.bskyAppDismissNudges(nudges)`         | `client.call(dismissNudges, nudges)`         |\n| `agent.bskyAppSetActiveProgressGuide(guide)` | `client.call(setActiveProgressGuide, guide)` |\n| `agent.bskyAppUpsertNux(nux)`                | `client.call(upsertNux, nux)`                |\n| `agent.bskyAppRemoveNuxs(ids)`               | `client.call(removeNuxs, ids)`               |\n\n### Renamed notification method\n\n| Old agent method                         | New                                                         |\n| ---------------------------------------- | ----------------------------------------------------------- |\n| `agent.countUnreadNotifications(params)` | `client.call(app.bsky.notification.getUnreadCount, params)` |\n\n### Bluesky API passthrough methods (17 total)\n\nThese agent shortcuts now map directly to `client.call(schema, params)` with the schema imported from `@bsky/sdk/lexicons`:\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// feeds\nawait client.call(app.bsky.feed.getTimeline, { limit: 50 })\nawait client.call(app.bsky.feed.getAuthorFeed, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.feed.getActorLikes, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.feed.getPostThread, { uri: 'at://...' })\nawait client.call(app.bsky.feed.getPosts, { uris: ['at://...'] })\nawait client.call(app.bsky.feed.getLikes, { uri: 'at://...' })\nawait client.call(app.bsky.feed.getRepostedBy, { uri: 'at://...' })\n\n// actors\nawait client.call(app.bsky.actor.getProfile, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.actor.getProfiles, { actors: ['alice.bsky.social'] })\nawait client.call(app.bsky.actor.getSuggestions, {})\nawait client.call(app.bsky.actor.searchActors, { q: 'alice' })\nawait client.call(app.bsky.actor.searchActorsTypeahead, { q: 'ali' })\n\n// graph\nawait client.call(app.bsky.graph.getFollows, { actor: 'alice.bsky.social' })\nawait client.call(app.bsky.graph.getFollowers, { actor: 'alice.bsky.social' })\n\n// notifications\nawait client.call(app.bsky.notification.listNotifications, {})\nawait client.call(app.bsky.notification.getUnreadCount, {})\n\n// labelers\nawait client.call(app.bsky.labeler.getServices, { dids: ['did:plc:...'] })\n```\n\n### `getPost` recipe\n\nThe old `agent.getPost({ repo, rkey })` sugar is replaced by `client.get()` using the generated schema:\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\nimport { AtUri } from '@atproto/syntax'\n\n// Old: agent.getPost({ repo: 'alice.bsky.social', rkey: '3k2...' })\n// New:\nconst post = await client.get(app.bsky.feed.post, {\n  repo: 'alice.bsky.social',\n  rkey: '3k2...',\n})\n// Or parse from a full at:// URI:\nconst uri = new AtUri('at://alice.bsky.social/app.bsky.feed.post/3k2...')\nconst post2 = await client.get(app.bsky.feed.post, {\n  repo: uri.hostname,\n  rkey: uri.rkey,\n})\n```\n\n---\n\n## 4. Sessions\n\n### Password-based sessions (replacing `CredentialSession`)\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport {\n  PasswordSession,\n  type SessionData,\n} from '@atproto/lex-password-session'\n\n// --- Login ---\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx',\n  onUpdated: (data: SessionData) => {\n    // Persist tokens after login and each automatic refresh\n    localStorage.setItem('session', JSON.stringify(data))\n  },\n  onDeleted: (data: SessionData) => {\n    // Called on logout or server-side session invalidation\n    localStorage.removeItem('session')\n    redirectToLogin()\n  },\n})\n\nconst client = new Client(session)\n\n// --- Resume a saved session ---\nconst saved: SessionData = JSON.parse(localStorage.getItem('session')!)\nconst resumed = await PasswordSession.resume(saved, {\n  onUpdated: (data) => localStorage.setItem('session', JSON.stringify(data)),\n  onDeleted: () => localStorage.removeItem('session'),\n})\nconst resumedClient = new Client(resumed)\n\n// --- Logout ---\nawait session.logout() // calls onDeleted, marks session as destroyed\n```\n\nKey differences from `CredentialSession`:\n\n- No constructor; use static `PasswordSession.login()` or `PasswordSession.resume()`.\n- `onUpdated` and `onDeleted` hooks replace manual `session.on('update', ...)` listeners.\n- Token refresh is fully automatic; no need to call `refreshSession()`.\n\n### OAuth\n\nFor user-facing apps, use OAuth via `@atproto/oauth-client`. The resulting session object can be passed directly to `new Client(oauthSession)`. See `@atproto/oauth-client` docs for setup.\n\n---\n\n## 5. String Format Types\n\nAT Protocol uses branded string types (type aliases with a narrow shape) for values that must meet format requirements — DIDs, AT-URIs, handles, datetimes, and more. These are now available from `@atproto/lex` (which re-exports them from `@atproto/syntax` and `@atproto/lex-schema`).\n\n### Available types and helpers\n\n```typescript\nimport {\n  // Types (branded strings)\n  type DidString,\n  type HandleString,\n  type AtUriString,\n  type DatetimeString,\n  type NsidString,\n  type RecordKeyString,\n  type AtIdentifierString, // DidString | HandleString\n  // Per-format as/is/assert/if helpers (via @atproto/syntax re-export)\n  asDatetimeString,\n  assertDatetimeString,\n  isDatetimeString,\n  ifDatetimeString,\n  asAtUriString,\n  assertAtUriString,\n  isAtUriString,\n  ifAtUriString,\n  asAtIdentifierString,\n  assertAtIdentifierString,\n  isAtIdentifierString,\n  ifAtIdentifierString,\n  // Generic string-format helpers\n  isStringFormat,\n  asStringFormat,\n  assertStringFormat,\n  ifStringFormat,\n} from '@atproto/lex'\n```\n\n### Reading vs constructing\n\n**Reading (ingress validation at trust boundaries):** When you receive data from the network or user input, validate and narrow the type explicitly:\n\n```typescript\nimport { asStringFormat, type DidString } from '@atproto/lex'\n\nfunction processIncoming(rawDid: string): DidString {\n  // Throws if not a valid DID; narrows to DidString\n  return asStringFormat(rawDid, 'did')\n}\n```\n\n**Constructing (from known-valid sources):** When you construct values from your own code or generated data, you can cast directly — generated lexicon builders (`schema.$build(...)`) already produce correctly-typed values, so no runtime validation is needed.\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// $build ensures the type is correct at compile time\nconst post = app.bsky.feed.post.$build({\n  text: 'Hello!',\n  createdAt: new Date().toISOString(),\n})\n// post.$type is typed as 'app.bsky.feed.post'\n```\n\n**Per-boundary idiom:** Validate at the first point you receive external data. After that, pass the typed values through your app without re-validating:\n\n```typescript\nimport { asStringFormat, asAtUriString } from '@atproto/lex'\n\n// API route handler — validate at the boundary\nfunction handleRepostRequest(rawActor: string, rawUri: string) {\n  const actorDid = asStringFormat(rawActor, 'did')\n  const postUri = asAtUriString(rawUri)\n  return doRepost(actorDid, postUri) // typed throughout\n}\n```\n\n**Note:** A planned helper on the lex `Client` for object-level \"upcast\" (e.g. asserting an entire record object matches a schema) is not yet in `@atproto/lex`. Use schema-level `$assert()` / `$matches()` / `$parse()` on generated schema objects until then:\n\n```typescript\nimport { app, com } from '@bsky/sdk/lexicons'\n\nconst unknown: unknown = fetchedRecord\napp.bsky.feed.post.$assert(unknown) // throws if not a valid post\n// unknown is now typed as app.bsky.feed.post.Main\n```\n\n### Replacing old `isDid` / `asDid` etc.\n\nThe `types.ts` helpers that lived in `@atproto/api` (`isDid`, `asDid`, `assertDid`, `isAtprotoProxy`, etc.) are removed. Use the generic string-format helpers or the per-format exports from `@atproto/lex` / `@atproto/syntax`:\n\n```typescript\n// Old: isDid(value) / asDid(value)\n// New:\nimport { isStringFormat, asStringFormat } from '@atproto/lex'\nisStringFormat(value, 'did')\nasStringFormat(value, 'did')\n```\n\n---\n\n## 6. Worked End-to-End Example\n\nLogin, create three clients, post with rich text, read the timeline, and moderate a post for display:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport {\n  PasswordSession,\n  type SessionData,\n} from '@atproto/lex-password-session'\nimport { api, post } from '@bsky/sdk'\nimport { moderatePost, type ModerationOpts } from '@bsky/sdk/moderation'\nimport { RichText } from '@bsky/sdk/richtext'\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// --- 1. Login ---\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'alice.bsky.social',\n  password: 'xxxx-xxxx-xxxx-xxxx',\n  onUpdated: (data: SessionData) => saveSession(data),\n  onDeleted: () => clearSession(),\n})\n\n// --- 2. Clients ---\n// No labeler configuration needed: with no labeler headers sent, the\n// Bluesky API applies its own moderation service by default.\nconst bskyClient = new Client(session, {\n  service: api.app.service,\n})\n\nconst publicBskyClient = new Client(api.app.urlPublic)\n\n// --- 3. Post with rich text ---\nconst rt = new RichText({ text: 'Hello @bob.bsky.social — check this out!' })\nawait rt.detectFacets(bskyClient) // resolves mentions via the client\n\n// Record writes target the account host automatically\nconst { uri, cid } = await bskyClient.call(post, {\n  text: rt.text,\n  facets: rt.facets,\n})\nconsole.log('Posted:', uri, cid)\n\n// --- 4. Read the timeline ---\nconst timeline = await bskyClient.call(app.bsky.feed.getTimeline, {\n  limit: 20,\n})\n\n// --- 5. Moderate a post for display ---\nconst prefs = await bskyClient.call(app.bsky.actor.getPreferences, {})\nconst moderationOpts: ModerationOpts = {\n  userDid: session.did,\n  prefs: {\n    adultContentEnabled: false,\n    labels: {},\n    labelers: [{ did: api.moderation.did, labels: {} }],\n    mutedWords: [],\n    hiddenPosts: [],\n  },\n}\n\nfor (const feedItem of timeline.feed) {\n  const modDecision = moderatePost(feedItem.post, moderationOpts)\n  const ui = modDecision.ui('contentList')\n  if (ui.filter) {\n    // skip filtered posts\n    continue\n  }\n  // record is typed as LexMap; cast to the generated type if needed\n  const record = feedItem.post.record as app.bsky.feed.post.Main\n  console.log(record.text)\n}\n```\n\n<!-- File: packages/jetstream/README.md (Tokens: ~1528 | Category: plugin-manifest) -->\n# @bsky/jetstream\n\nClient library for [Jetstream](https://github.com/bluesky-social/jetstream), a friendly way to consume data published to AT Protocol in realtime.\n\n## Quick start\n\nInstall the Jetstream client:\n\n```\nnpm install @bsky/jetstream @atproto/lex\n```\n\n`@atproto/lex` is a peer dependency providing the lexicon type system that events are typed with.\n\nThis 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:\n\n```ts\nimport { Jetstream, isCreate } from '@bsky/jetstream'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\n\nfor await (const evt of js.live({ collections: ['app.bsky.feed.post'] })) {\n  if (isCreate(evt)) {\n    console.log(evt.commit.collection, evt.commit.record)\n  }\n}\n```\n\n`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.\n\nUsing a lexicon as your collection filter will validate and type the events for you:\n\n```ts\nimport { Jetstream, isCreate } from '@bsky/jetstream'\nimport { app } from '@bsky/sdk/lexicons'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\n\nfor await (const evt of js.live({ collections: [app.bsky.feed.post] })) {\n  if (isCreate(evt)) {\n    console.log(evt.commit.collection, evt.commit.record.text)\n  }\n}\n```\n\n`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.\n\nTyped 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).\n\nA 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.\n\nFor 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:\n\n```ts\nimport { Jetstream, LexIndexer, MemoryCursorStore } from '@bsky/jetstream'\nimport { app } from '@bsky/sdk/lexicons'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\n\nconst indexer = new LexIndexer()\n  .commit(app.bsky.feed.like, {\n    put: async (e) => {\n      console.log('index', e.uri, e.record)\n    },\n    del: async (e) => {\n      console.log('remove', e.uri)\n    },\n  })\n  .identity(async (e) => {\n    console.log('identity change', e.did, e.handle)\n  })\n\n// Implement CursorStore to persist the resume cursor durably.\nconst cursor = new MemoryCursorStore()\n\nawait js.runner(indexer).live({ cursor })\n```\n\n### Cursors\n\n`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.\n\nCursors 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.\n\n### Legacy v1 instances\n\nThe 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.\n\n```ts\nimport { JetstreamV1 } from '@bsky/jetstream'\n\nconst js = new JetstreamV1('https://jetstream1.us-east.bsky.network')\nfor await (const evt of js.live({ collections: ['app.bsky.feed.post'] })) {\n  console.log(evt.seq, evt.kind)\n}\n```\n\n## Connection behavior\n\nThis package runs on Node.js and in the browser.\n\nA 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).\n\nTo observe or tune connection behavior, configure a transport with `websocketTransport()` and pass it via the `liveTransport` option:\n\n```ts\nimport { Jetstream, websocketTransport } from '@bsky/jetstream'\n\nconst js = new Jetstream('https://jetstream.us-east.bsky.network')\nfor await (const ev of js.live({\n  collections: ['app.bsky.feed.post'],\n  liveTransport: websocketTransport({\n    onReconnect: (err, { attempt }) => console.warn('reconnecting', attempt),\n  }),\n})) {\n  // ...\n}\n```\n\nCommonly 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).\n\n<!-- File: packages/sdk/README.md (Tokens: ~3282 | Category: plugin-manifest) -->\n# @bsky/sdk\n\nBluesky 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.\n\n## Install\n\n```sh\nnpm install @bsky/sdk @atproto/lex\n```\n\n`@atproto/lex` provides the `Client` class that powers all API calls. `@bsky/sdk` provides the Bluesky-specific lexicons, actions, and utilities.\n\n## Getting started\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { PasswordSession } from '@atproto/lex-password-session'\nimport { post } from '@bsky/sdk'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'your.bsky.social', // your handle\n  password: 'xxxx-xxxx-xxxx-xxxx', // an app password, generated in the Bluesky app\n})\nconst client = new Client(session)\n\nawait client.call(post, { text: 'Hello, world!' })\n```\n\n## Usage\n\n### Session management\n\nYou'll need an authenticated session for most API calls. There are two ways to\nmanage sessions:\n\n1. [App password based session management](#app-password-based-session-management)\n2. [OAuth based session management](#oauth-based-session-management)\n\n#### App password based session management\n\nUsername / password based authentication can be performed using the\n`PasswordSession` class from `@atproto/lex-password-session`.\n\n> [!CAUTION]\n>\n> This method is deprecated in favor of OAuth based session management. It is\n> recommended to use OAuth based session management (through the\n> `@atproto/oauth-client-*` packages).\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport {\n  PasswordSession,\n  type SessionData,\n} from '@atproto/lex-password-session'\n\nconst session = await PasswordSession.login({\n  service: 'https://bsky.social',\n  identifier: 'your.bsky.social', // your handle\n  password: 'xxxx-xxxx-xxxx-xxxx', // an app password, generated in the Bluesky app\n  onUpdated: (data: SessionData) => saveToStorage(data),\n  onDeleted: (data: SessionData) => clearStorage(data.did),\n})\nconst client = new Client(session)\nconsole.log(`Authenticated as: ${client.assertDid}`)\n\n// Next time, resume with the persisted session data to avoid storing\n// user credentials:\nconst resumed = await PasswordSession.resume(loadFromStorage(), {\n  onUpdated: (data: SessionData) => saveToStorage(data),\n  onDeleted: (data: SessionData) => clearStorage(data.did),\n})\n```\n\n#### OAuth based session management\n\nDepending on the environment used by your application, different OAuth clients\nare available:\n\n- [@atproto/oauth-client-browser](https://www.npmjs.com/package/@atproto/oauth-client-browser):\n  for the browser.\n- [@atproto/oauth-client-node](https://www.npmjs.com/package/@atproto/oauth-client-node): for\n  Node.js.\n- [@atproto/oauth-client](https://www.npmjs.com/package/@atproto/oauth-client):\n  Lower level; compatible with most JS engines.\n\nEvery `@atproto/oauth-client-*` implementation has a different way to obtain an\nOAuth session instance that can be used to instantiate a `Client`. Here is an\nexample restoring a previously saved session:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { OAuthClient } from '@atproto/oauth-client'\n\nconst oauthClient = new OAuthClient({\n  // ...\n})\n\nconst oauthSession = await oauthClient.restore('did:plc:123')\n\n// Instantiate the lex Client using an OAuth session\nconst client = new Client(oauthSession)\n```\n\n### Authenticated and public clients\n\nUse the `api.*` constants for addressing Bluesky services:\n\n```typescript\nimport { Client } from '@atproto/lex'\nimport { api } from '@bsky/sdk'\n\n// Bluesky API (authenticated queries, proxied through the account host;\n// record writes and mutations automatically target the account host directly)\nconst bskyClient = new Client(session, { service: api.app.service })\n\n// Bluesky API (unauthenticated public queries)\nconst publicBskyClient = new Client(api.app.urlPublic)\n```\n\n### API calls\n\nThe SDK exports actions for many common operations. Invoke an action against a\nclient with `client.call(action, input)`:\n\n```typescript\nimport {\n  blockActor,\n  blockActorList,\n  deleteFollow,\n  deleteLike,\n  deletePost,\n  deleteRepost,\n  follow,\n  getPreferences,\n  like,\n  muteActor,\n  muteActorList,\n  post,\n  repost,\n  unblockActor,\n  unblockActorList,\n  unmuteActor,\n  unmuteActorList,\n  updateSeenNotifications,\n  upsertProfile,\n} from '@bsky/sdk'\n\n// The DID of the user currently authenticated (or undefined)\nbskyClient.did\nbskyClient.assertDid // Throws if the user is not authenticated\n\n// Feeds and content\nawait bskyClient.call(post, { text: 'Hello, world!' })\nawait bskyClient.call(deletePost, postUri)\nawait bskyClient.call(like, { uri, cid })\nawait bskyClient.call(deleteLike, likeUri)\nawait bskyClient.call(repost, { uri, cid })\nawait bskyClient.call(deleteRepost, repostUri)\n\n// Social graph\nawait bskyClient.call(follow, { did })\nawait bskyClient.call(deleteFollow, followUri)\nawait bskyClient.call(muteActor, { actor })\nawait bskyClient.call(unmuteActor, { actor })\nawait bskyClient.call(blockActor, { did })\nawait bskyClient.call(unblockActor, blockUri)\nawait bskyClient.call(muteActorList, { list })\nawait bskyClient.call(unmuteActorList, { list })\nawait bskyClient.call(blockActorList, { list })\nawait bskyClient.call(unblockActorList, { list })\n\n// Actors\nawait bskyClient.call(upsertProfile, (existing) => ({\n  ...existing,\n  displayName: 'Alice',\n}))\n\n// Notifications\nawait bskyClient.call(updateSeenNotifications, seenAt)\n\n// Preferences\nconst prefs = await bskyClient.call(getPreferences)\n```\n\nQueries without a dedicated action are one `xrpc` call away using the generated\nlexicons — see [Advanced API calls](#advanced-api-calls).\n\n### Validation and types\n\nThe lexicons include a complete types system with validation and type-guards.\nFor example, to validate a post record:\n\n```typescript\nimport { app } from '@bsky/sdk/lexicons'\n\nconst post = { ... }\nif (app.bsky.feed.post.$isTypeOf(post)) {\n  // typescript now recognizes `post` may be an app.bsky.feed.post.Main\n  // however -- we still need to validate it\n  const res = app.bsky.feed.post.main.safeValidate(post)\n  if (res.success) {\n    // a valid record\n  } else {\n    // something is wrong\n    console.log(res.error)\n  }\n}\n```\n\n### Rich text\n\nSome 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.\n\nℹ️ 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.\n\n```typescript\nimport { currentDatetimeString } from '@atproto/lex'\nimport { RichText } from '@bsky/sdk/richtext'\n\n// creating richtext — detects mentions and links, resolving mentions to DIDs\nconst rt = await RichText.resolve(\n  'Hello @alice.com, check out this link: https://example.com',\n  { resolver: client },\n)\nconst postRecord = {\n  $type: 'app.bsky.feed.post',\n  text: rt.text,\n  facets: rt.facets,\n  createdAt: currentDatetimeString(),\n}\n\n// rendering as markdown\nlet markdown = ''\nfor (const segment of rt.segments()) {\n  if (segment.isLink()) {\n    markdown += `[${segment.text}](${segment.link?.uri})`\n  } else if (segment.isMention()) {\n    markdown += `[${segment.text}](https://my-bsky-app.com/user/${segment.mention?.did})`\n  } else {\n    markdown += segment.text\n  }\n}\n\n// calculating string lengths\nconst rt2 = new RichText({ text: 'Hello' })\nconsole.log(rt2.length) // => 5\nconsole.log(rt2.graphemeLength) // => 5\nconst rt3 = new RichText({ text: '👨‍👩‍👧‍👧' })\nconsole.log(rt3.length) // => 25\nconsole.log(rt3.graphemeLength) // => 1\n```\n\n### Moderation\n\nApplying 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:\n\n- Moderator labeling\n- User muting (including mutelists)\n- User blocking\n- Mutewords\n- Hidden posts\n\nFor more information, see the [Moderation Documentation](./docs/moderation.md).\n\n```typescript\nimport { getPreferences } from '@bsky/sdk'\nimport { moderatePost } from '@bsky/sdk/moderation'\n\n// First get the user's moderation prefs and their label definitions\n// =\n\nconst prefs = await bskyClient.call(getPreferences)\nconst labelDefs = {\n  /* see the Moderation Documentation for gathering labelDefs */\n}\n\n// We call the appropriate moderation function for the content\n// =\n\nconst postMod = moderatePost(postView, {\n  userDid: bskyClient.assertDid,\n  prefs: prefs.moderationPrefs,\n  labelDefs,\n})\n\n// We then use the output to decide how to affect rendering\n// =\n\n// in feeds\nif (postMod.ui('contentList').filter) {\n  // don't include in feeds\n}\nif (postMod.ui('contentList').blur) {\n  // render the whole object behind a cover (use postMod.ui('contentList').blurs to explain)\n  if (postMod.ui('contentList').noOverride) {\n    // do not allow the cover the be removed\n  }\n}\nif (postMod.ui('contentList').alert || postMod.ui('contentList').inform) {\n  // render warnings on the post\n  // find the warnings in postMod.ui('contentList').alerts and postMod.ui('contentList').informs\n}\n\n// viewed directly\nif (postMod.ui('contentView').filter) {\n  // don't render the view\n}\nif (postMod.ui('contentView').blur) {\n  // render the whole object behind a cover (use postMod.ui('contentView').blurs to explain)\n  if (postMod.ui('contentView').noOverride) {\n    // do not allow the cover the be removed\n  }\n}\nif (postMod.ui('contentView').alert || postMod.ui('contentView').inform) {\n  // render warnings on the post\n  // find the warnings in postMod.ui('contentView').alerts and postMod.ui('contentView').informs\n}\n\n// post embeds in all contexts\nif (postMod.ui('contentMedia').blur) {\n  // render the whole object behind a cover (use postMod.ui('contentMedia').blurs to explain)\n  if (postMod.ui('contentMedia').noOverride) {\n    // do not allow the cover the be removed\n  }\n}\n```\n\n## Advanced\n\n### Advanced API calls\n\nThe actions above are convenience wrappers. They cover most but not all\navailable methods.\n\nThe AT Protocol identifies methods and records with reverse-DNS names. You can\ncall any of them using the generated lexicons and the client's `call` and\nrecord helpers:\n\n```typescript\nimport { currentDatetimeString } from '@atproto/lex'\nimport { app, com } from '@bsky/sdk/lexicons'\n\n// Record helpers always target the user's account host\nconst res1 = await bskyClient.createRecord({\n  $type: 'app.bsky.feed.post',\n  text: 'Hello, world!',\n  createdAt: currentDatetimeString(),\n})\nconst res2 = await bskyClient.call(\n  com.atproto.repo.listRecords,\n  { repo: alice.did, collection: 'app.bsky.feed.post' },\n  { service: null }, // target the account host rather than the Bluesky API\n)\n// or, using the typed record convenience helper:\nconst res2b = await bskyClient.list(app.bsky.feed.post, {\n  repo: alice.did, // optional. Defaults to bskyClient.did (throws if unauthenticated)\n})\n\nconst res3 = await bskyClient.call(app.bsky.feed.getTimeline, {\n  limit: 20,\n})\n// res3: app.bsky.feed.getTimeline.OutputSchema\n```\n\nNamespace roots exported from `/lexicons`: `app`, `com`, `chat`.\n\n### Bring your own fetch\n\nIf you want to provide your own `fetch` implementation — for logging, mocking,\nretries, or environments without a built-in `fetch` — pass it when constructing\nthe `Client`:\n\n```typescript\nimport { Client } from '@atproto/lex'\n\nconst myFetch: typeof globalThis.fetch = async (input, init) => {\n  console.log('requesting', input)\n  const response = await globalThis.fetch(input, init)\n  console.log('got response', response)\n  return response\n}\n\nconst client = new Client({\n  service: 'https://example.com',\n  fetch: myFetch,\n})\n```\n\n## Subpaths\n\n| Import                 | Contents                                                                             |\n| ---------------------- | ------------------------------------------------------------------------------------ |\n| `@bsky/sdk`            | `api` constants, actions, `DEFAULT_LABEL_SETTINGS`                                   |\n| `@bsky/sdk/lexicons`   | Generated lexicon definitions (`app`, `com`, `chat`)                                 |\n| `@bsky/sdk/moderation` | `moderatePost`, `moderateProfile`, and friends; `ModerationDecision`, `ModerationUI` |\n| `@bsky/sdk/richtext`   | `RichText` builder                                                                   |\n| `@bsky/sdk/utils`      | Age assurance helpers, `sanitizeMutedWordValue`, nux validation                      |\n\n## Upgrading from @atproto/api\n\nSee [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.\n\n## License\n\nThis project is dual-licensed under MIT and Apache 2.0 terms:\n\n- MIT license ([LICENSE-MIT.txt](https://github.com/bluesky-social/atproto/blob/main/LICENSE-MIT.txt) or http://opensource.org/licenses/MIT)\n- 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)\n\nDownstream 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.\n</yakaai_skills>"}